forked from farhoodlabs/paperclip
Merge upstream/master into dev (76 commits)
Resolved 5 conflicts: - .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev) - server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events - server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard, layered before upstream's soft-delete + provider cleanup in remove() - ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -215,6 +215,103 @@ describe("acpx_local execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("closes successful persistent runs by default while retaining session state", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-close-success-"));
|
||||
try {
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
const result = await execute(buildContext(root));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.sessionParams).toMatchObject({
|
||||
mode: "persistent",
|
||||
acpSessionId: "acp-1",
|
||||
});
|
||||
expect(runtime.closeInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "paperclip completed turn cleanup",
|
||||
discardPersistentState: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies requested Codex model, reasoning effort, and fast mode before starting the turn", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-config-"));
|
||||
try {
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
agent: "codex",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
model: "gpt-5.4",
|
||||
modelReasoningEffort: "xhigh",
|
||||
fastMode: true,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.model).toBe("gpt-5.4");
|
||||
expect(runtime.setConfigInputs).toEqual([
|
||||
expect.objectContaining({ key: "model", value: "gpt-5.4" }),
|
||||
expect.objectContaining({ key: "reasoning_effort", value: "xhigh" }),
|
||||
expect.objectContaining({ key: "service_tier", value: "fast" }),
|
||||
expect.objectContaining({ key: "features.fast_mode", value: "true" }),
|
||||
]);
|
||||
expect(runtime.startInputs).toHaveLength(1);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("logs a clear error when configured session options need unsupported runtime controls", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-missing-config-controls-"));
|
||||
try {
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
Object.defineProperty(runtime, "setConfigOption", { value: undefined });
|
||||
const logs: LogEntry[] = [];
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
agent: "codex",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
onLog: async (stream, chunk) => logs.push({ stream, chunk }),
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorMessage).toContain("does not expose session config controls");
|
||||
expect(logs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
stream: "stderr",
|
||||
chunk: expect.stringContaining("upgrade ACPX or remove configured model"),
|
||||
}),
|
||||
]));
|
||||
expect(runtime.closeInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "paperclip config cleanup",
|
||||
discardPersistentState: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a compatible warm session and starts fresh when cwd changes", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-"));
|
||||
const other = path.join(root, "other");
|
||||
@@ -228,8 +325,15 @@ describe("acpx_local execute", () => {
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
const warmConfig = {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
|
||||
const first = await execute(buildContext(root));
|
||||
const first = await execute(buildContext(root, { config: warmConfig }));
|
||||
const second = await execute(buildContext(root, {
|
||||
runtime: {
|
||||
sessionId: first.sessionId ?? null,
|
||||
@@ -237,6 +341,7 @@ describe("acpx_local execute", () => {
|
||||
sessionDisplayId: first.sessionDisplayId ?? null,
|
||||
taskKey: "PAP-1",
|
||||
},
|
||||
config: warmConfig,
|
||||
}));
|
||||
const third = await execute(buildContext(root, {
|
||||
runtime: {
|
||||
@@ -250,6 +355,7 @@ describe("acpx_local execute", () => {
|
||||
cwd: other,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -279,8 +385,26 @@ describe("acpx_local execute", () => {
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
execute(buildContext(root, { runId: "run-1" })),
|
||||
execute(buildContext(root, { runId: "run-2" })),
|
||||
execute(buildContext(root, {
|
||||
runId: "run-1",
|
||||
config: {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
})),
|
||||
execute(buildContext(root, {
|
||||
runId: "run-2",
|
||||
config: {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
})),
|
||||
]);
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
@@ -295,6 +419,47 @@ describe("acpx_local execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans configured warm handles after their idle window", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-warm-idle-"));
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let clock = 0;
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxLocalExecutor({
|
||||
warmHandles,
|
||||
now: () => clock,
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 1_000,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(warmHandles.size).toBe(1);
|
||||
clock = 1_000;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(runtime.closeInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "paperclip idle cleanup",
|
||||
discardPersistentState: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries with a fresh session when ACPX cannot resume the saved backend session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-"));
|
||||
try {
|
||||
|
||||
@@ -128,7 +128,7 @@ describe.sequential("activity routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves issue identifiers before loading runs", async () => {
|
||||
it("resolves alphanumeric issue identifiers before loading runs", async () => {
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-uuid-1",
|
||||
companyId: "company-1",
|
||||
@@ -141,10 +141,10 @@ describe.sequential("activity routes", () => {
|
||||
]);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/PAP-475/runs"));
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/pc1a2-475/runs"));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-475");
|
||||
expect(mockIssueService.getById).not.toHaveBeenCalled();
|
||||
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
|
||||
expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { models as openCodeFallbackModels } from "@paperclipai/adapter-opencode-local";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
|
||||
vi.mock("acpx/runtime", () => ({
|
||||
createAcpRuntime: vi.fn(),
|
||||
createAgentRegistry: vi.fn(),
|
||||
createRuntimeStore: vi.fn(),
|
||||
isAcpRuntimeError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
@@ -19,6 +27,10 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
|
||||
}));
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
const mockListOpenCodeModels = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
materializeManagedBundle: vi.fn(),
|
||||
@@ -55,6 +67,14 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
|
||||
return {
|
||||
...actual,
|
||||
listOpenCodeModels: mockListOpenCodeModels,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => ({}),
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
@@ -74,6 +94,10 @@ function registerModuleMocks() {
|
||||
vi.doMock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/environments.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
}));
|
||||
}
|
||||
|
||||
const refreshableAdapterType = "refreshable_adapter_route_test";
|
||||
@@ -147,6 +171,10 @@ describe("adapter model refresh route", () => {
|
||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||
mockListOpenCodeModels.mockReset();
|
||||
mockListOpenCodeModels.mockResolvedValue([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]);
|
||||
await unregisterTestAdapter(refreshableAdapterType);
|
||||
});
|
||||
|
||||
@@ -182,4 +210,42 @@ describe("adapter model refresh route", () => {
|
||||
expect(refreshModels).toHaveBeenCalledTimes(1);
|
||||
expect(listModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips OpenCode model discovery for non-local environments", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "env-1",
|
||||
companyId: "company-1",
|
||||
name: "Remote SSH",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toEqual(openCodeFallbackModels);
|
||||
expect(mockListOpenCodeModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps OpenCode model discovery enabled for local environments", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "env-1",
|
||||
companyId: "company-1",
|
||||
name: "Local",
|
||||
driver: "local",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toEqual([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]);
|
||||
expect(mockListOpenCodeModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,17 @@ import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
|
||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { listAdapterModels, refreshAdapterModels } from "../adapters/index.js";
|
||||
import { listAdapterModels, listServerAdapters, refreshAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||
|
||||
vi.mock("acpx/runtime", () => ({
|
||||
createAcpRuntime: vi.fn(),
|
||||
createAgentRegistry: vi.fn(),
|
||||
createRuntimeStore: vi.fn(),
|
||||
isAcpRuntimeError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("adapter model listing", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
@@ -23,6 +30,13 @@ describe("adapter model listing", () => {
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses provider-prefixed ACPX fallback model labels", () => {
|
||||
const adapter = listServerAdapters().find((candidate) => candidate.type === "acpx_local");
|
||||
|
||||
expect(adapter?.models?.some((model) => model.label.startsWith("Claude: "))).toBe(true);
|
||||
expect(adapter?.models?.some((model) => model.label.startsWith("Codex: "))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns codex fallback models when no OpenAI key is available", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const models = await listAdapterModels("codex_local");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
|
||||
const hermesExecuteMock = vi.hoisted(() =>
|
||||
@@ -232,6 +233,34 @@ describe("server adapter registry", () => {
|
||||
await expect(listAdapterModelProfiles("pi_local")).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
|
||||
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
|
||||
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
|
||||
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
|
||||
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
|
||||
|
||||
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
||||
command: "claude",
|
||||
detectCommand: "claude",
|
||||
installCommand: expectedClaudeInstall,
|
||||
});
|
||||
expect(findActiveServerAdapter("codex_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
||||
command: "codex",
|
||||
detectCommand: "codex",
|
||||
installCommand: expectedCodexInstall,
|
||||
});
|
||||
expect(findActiveServerAdapter("gemini_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
||||
command: "gemini",
|
||||
detectCommand: "gemini",
|
||||
installCommand: expectedGeminiInstall,
|
||||
});
|
||||
expect(findActiveServerAdapter("opencode_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
||||
command: "opencode",
|
||||
detectCommand: "opencode",
|
||||
installCommand: expectedOpenCodeInstall,
|
||||
});
|
||||
});
|
||||
|
||||
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
|
||||
const builtIn = findServerAdapter("claude_local");
|
||||
expect(builtIn).not.toBeNull();
|
||||
|
||||
@@ -248,11 +248,33 @@ describe("adapter routes", () => {
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "permissionMode",
|
||||
default: "approve-all",
|
||||
key: "fastMode",
|
||||
default: false,
|
||||
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "warmHandleIdleMs",
|
||||
default: 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const keys = res.body.fields.map((field: { key: string }) => field.key);
|
||||
expect(keys).not.toContain("mode");
|
||||
expect(keys).not.toContain("permissionMode");
|
||||
expect(keys).not.toContain("instructionsFilePath");
|
||||
expect(keys).not.toContain("promptTemplate");
|
||||
expect(keys).not.toContain("bootstrapPromptTemplate");
|
||||
});
|
||||
|
||||
it("GET /api/adapters includes ACPX model availability", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const res = await request(app).get("/api/adapters");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
const acpxLocal = res.body.find((a: any) => a.type === "acpx_local");
|
||||
expect(acpxLocal).toBeDefined();
|
||||
expect(acpxLocal.modelsCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects signed-in users without org access", async () => {
|
||||
|
||||
@@ -53,6 +53,14 @@ vi.mock("../services/index.js", () => ({
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/environments.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: mockFindServerAdapter,
|
||||
listAdapterModels: vi.fn(),
|
||||
@@ -75,6 +83,14 @@ function registerModuleMocks() {
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/environments.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
}));
|
||||
|
||||
vi.doMock("../adapters/index.js", () => ({
|
||||
findServerAdapter: mockFindServerAdapter,
|
||||
listAdapterModels: vi.fn(),
|
||||
|
||||
@@ -12,6 +12,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
getActiveRunIssueSummaryForAgent: vi.fn(),
|
||||
getRunLogAccess: vi.fn(),
|
||||
readLog: vi.fn(),
|
||||
wakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -26,6 +27,8 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
listCompanyIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const routeAgentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
@@ -210,16 +213,24 @@ describe("agent live run routes", () => {
|
||||
content: "chunk",
|
||||
nextOffset: 5,
|
||||
});
|
||||
mockHeartbeatService.wakeup.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
status: "queued",
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a compact active run payload for issue polling", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/pc1a2-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1295");
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
||||
expect(res.body).toMatchObject({
|
||||
id: "run-1",
|
||||
@@ -268,7 +279,7 @@ describe("agent live run routes", () => {
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
@@ -524,4 +535,66 @@ describe("agent live run routes", () => {
|
||||
expect(res.body).toHaveLength(4);
|
||||
expect(db.select).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("passes scoped wake fields through the legacy heartbeat invoke route", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
||||
.send({
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
taskKey: "issue-1",
|
||||
},
|
||||
forceFreshSession: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
|
||||
// caller actually supplied so empty-body callers (e.g. e2e suites) match
|
||||
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
|
||||
// caller supplies reason / payload / forceFreshSession those are
|
||||
// forwarded; idempotencyKey is omitted unless explicitly set.
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
taskKey: "issue-1",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "local-board",
|
||||
contextSnapshot: {
|
||||
triggeredBy: "board",
|
||||
actorId: "local-board",
|
||||
forceFreshSession: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("calls heartbeat.wakeup with the legacy minimal shape when the body is empty", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
||||
.send({}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "local-board",
|
||||
contextSnapshot: {
|
||||
triggeredBy: "board",
|
||||
actorId: "local-board",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
|
||||
vi.mock("acpx/runtime", () => ({
|
||||
createAcpRuntime: vi.fn(),
|
||||
createAgentRegistry: vi.fn(),
|
||||
createRuntimeStore: vi.fn(),
|
||||
isAcpRuntimeError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -908,6 +916,78 @@ describe.sequential("agent permission routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds opencode agent creation with the static default model without live discovery", async () => {
|
||||
mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
|
||||
new Error("`opencode models` should not be called during creation"),
|
||||
);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "OpenCode Builder",
|
||||
role: "engineer",
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: {},
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: expect.objectContaining({
|
||||
model: DEFAULT_OPENCODE_LOCAL_MODEL,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts manual opencode provider/model values without host-side discovery", async () => {
|
||||
mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
|
||||
new Error("`opencode models` should not be called during creation"),
|
||||
);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "OpenCode Builder",
|
||||
role: "engineer",
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: expect.objectContaining({
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getChainOfCommand: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(async () => null),
|
||||
listPrincipalGrants: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
|
||||
}));
|
||||
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
releaseLease: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockReleaseRunLease = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockEnvironmentRuntime = vi.hoisted(() => ({
|
||||
acquireRunLease: vi.fn(),
|
||||
realizeWorkspace: vi.fn(),
|
||||
getDriver: vi.fn(() => ({
|
||||
releaseRunLease: mockReleaseRunLease,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => ({}),
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
companySkillService: () => ({
|
||||
listRuntimeSkillEntries: vi.fn(async () => []),
|
||||
resolveRequestedSkillKeys: vi.fn(async () => []),
|
||||
}),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(),
|
||||
cancelActiveForAgent: vi.fn(),
|
||||
}),
|
||||
ISSUE_LIST_DEFAULT_LIMIT: 50,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => ({}),
|
||||
logActivity: vi.fn(),
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/environments.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/environment-runtime.js", () => ({
|
||||
environmentRuntimeService: () => mockEnvironmentRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../services/environment-execution-target.js", () => ({
|
||||
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
|
||||
}));
|
||||
|
||||
vi.mock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
const testEnvironmentSpy = vi.fn();
|
||||
|
||||
const externalAdapter: ServerAdapterModule = {
|
||||
type: "external_test",
|
||||
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||
testEnvironment: testEnvironmentSpy,
|
||||
};
|
||||
|
||||
async function createApp() {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function unregisterTestAdapter(type: string) {
|
||||
const { unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
unregisterServerAdapter(type);
|
||||
}
|
||||
|
||||
describe("agent test-environment route", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
name: "Sandbox QA",
|
||||
driver: "sandbox",
|
||||
config: { provider: "fake-plugin" },
|
||||
});
|
||||
mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({
|
||||
lease: {
|
||||
id: "lease-1",
|
||||
metadata: { remoteCwd: "/home/user/paperclip-workspace" },
|
||||
},
|
||||
leaseContext: {
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceMode: null,
|
||||
},
|
||||
});
|
||||
mockEnvironmentRuntime.realizeWorkspace.mockResolvedValue({
|
||||
cwd: "/home/user/paperclip-workspace",
|
||||
});
|
||||
mockResolveEnvironmentExecutionTarget.mockResolvedValue(null);
|
||||
testEnvironmentSpy.mockResolvedValue({
|
||||
adapterType: "external_test",
|
||||
status: "pass",
|
||||
checks: [
|
||||
{
|
||||
code: "host_probe_ran",
|
||||
level: "info",
|
||||
message: "host probe should not run",
|
||||
},
|
||||
],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
});
|
||||
await unregisterTestAdapter("external_test");
|
||||
const { registerServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter(externalAdapter);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await unregisterTestAdapter("external_test");
|
||||
});
|
||||
|
||||
it("does not fall back to a host probe when a requested environment cannot produce an execution target", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({
|
||||
adapterConfig: {},
|
||||
environmentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
||||
expect(res.body).toMatchObject({
|
||||
adapterType: "external_test",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "environment_target_unsupported",
|
||||
level: "warn",
|
||||
message: 'Adapter "external_test" is not allowed in "Sandbox QA" environments.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockReleaseRunLease).toHaveBeenCalledWith({
|
||||
environment: expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Sandbox QA",
|
||||
driver: "sandbox",
|
||||
}),
|
||||
lease: expect.objectContaining({
|
||||
id: "lease-1",
|
||||
}),
|
||||
status: "failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a diagnostic result instead of probing the host when the requested environment is missing", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValueOnce(null);
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({
|
||||
adapterConfig: {},
|
||||
environmentId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
||||
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
||||
expect(res.body).toMatchObject({
|
||||
adapterType: "external_test",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "environment_not_found",
|
||||
level: "warn",
|
||||
message: "Selected environment was not found. The test did not run.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("runs the adapter probe against the resolved sandbox target on the happy path and releases the lease on success", async () => {
|
||||
mockResolveEnvironmentExecutionTarget.mockResolvedValueOnce({
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
remoteCwd: "/home/user/paperclip-workspace",
|
||||
providerKey: "fake-plugin",
|
||||
runner: { execute: vi.fn() },
|
||||
});
|
||||
testEnvironmentSpy.mockResolvedValueOnce({
|
||||
adapterType: "external_test",
|
||||
status: "pass",
|
||||
checks: [
|
||||
{
|
||||
code: "external_test_hello_probe_passed",
|
||||
level: "info",
|
||||
message: "OK",
|
||||
},
|
||||
],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({
|
||||
adapterConfig: {},
|
||||
environmentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
executionTarget: expect.objectContaining({
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
}),
|
||||
environmentName: "Sandbox QA",
|
||||
});
|
||||
expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" });
|
||||
expect(mockReleaseRunLease).toHaveBeenCalledWith({
|
||||
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
||||
lease: expect.objectContaining({ id: "lease-1" }),
|
||||
status: "released",
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the lease as failed and returns a diagnostic when realizeWorkspace throws", async () => {
|
||||
mockEnvironmentRuntime.realizeWorkspace.mockRejectedValueOnce(
|
||||
new Error("workspace realization failed"),
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/external_test/test-environment")
|
||||
.send({
|
||||
adapterConfig: {},
|
||||
environmentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
||||
expect(res.body).toMatchObject({
|
||||
adapterType: "external_test",
|
||||
status: "fail",
|
||||
checks: [
|
||||
expect.objectContaining({
|
||||
code: "environment_workspace_realize_failed",
|
||||
level: "error",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(mockReleaseRunLease).toHaveBeenCalledWith({
|
||||
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
||||
lease: expect.objectContaining({ id: "lease-1" }),
|
||||
status: "failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { actorMiddleware } from "../middleware/auth.js";
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
@@ -25,6 +25,13 @@ function createDb() {
|
||||
}
|
||||
|
||||
describe("actorMiddleware authenticated session profile", () => {
|
||||
const originalCloudTenantToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCloudTenantToken === undefined) delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
||||
else process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = originalCloudTenantToken;
|
||||
});
|
||||
|
||||
it("preserves the signed-in user name and email on the board actor", async () => {
|
||||
const app = express();
|
||||
app.use(
|
||||
@@ -58,4 +65,72 @@ describe("actorMiddleware authenticated session profile", () => {
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts Cloud tenant identity headers and seeds board access", async () => {
|
||||
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-token";
|
||||
const inserts: Array<{ values: Record<string, unknown> }> = [];
|
||||
const db = {
|
||||
insert: vi.fn(() => {
|
||||
const chain = {
|
||||
values(values: Record<string, unknown>) {
|
||||
inserts.push({ values });
|
||||
return chain;
|
||||
},
|
||||
onConflictDoUpdate() {
|
||||
return chain;
|
||||
},
|
||||
onConflictDoNothing() {
|
||||
return chain;
|
||||
},
|
||||
returning() {
|
||||
return Promise.resolve([{
|
||||
companyId: inserts.at(-1)?.values.companyId,
|
||||
membershipRole: inserts.at(-1)?.values.membershipRole,
|
||||
status: inserts.at(-1)?.values.status,
|
||||
}]);
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
}),
|
||||
select: vi.fn(),
|
||||
} as any;
|
||||
const app = express();
|
||||
app.use(
|
||||
actorMiddleware(db, {
|
||||
deploymentMode: "authenticated",
|
||||
resolveSession: async () => null,
|
||||
}),
|
||||
);
|
||||
app.get("/actor", (req, res) => {
|
||||
res.json(req.actor);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/actor")
|
||||
.set("x-paperclip-cloud-tenant-token", "tenant-token")
|
||||
.set("x-paperclip-cloud-user-id", "global-user-1")
|
||||
.set("x-paperclip-cloud-user-email", "owner@example.com")
|
||||
.set("x-paperclip-cloud-user-name", "Stack Owner")
|
||||
.set("x-paperclip-cloud-stack-id", "stack-alpha")
|
||||
.set("x-paperclip-cloud-paperclip-company-id", "paperclip-stack-alpha")
|
||||
.set("x-paperclip-cloud-stack-role", "owner");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
type: "board",
|
||||
userId: "global-user-1",
|
||||
userName: "Stack Owner",
|
||||
userEmail: "owner@example.com",
|
||||
source: "cloud_tenant",
|
||||
isInstanceAdmin: true,
|
||||
memberships: [expect.objectContaining({ membershipRole: "owner", status: "active" })],
|
||||
});
|
||||
expect(res.body.companyIds[0]).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(inserts).toHaveLength(4);
|
||||
expect(inserts[0]?.values).toMatchObject({
|
||||
id: "global-user-1",
|
||||
email: "owner@example.com",
|
||||
emailVerified: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
||||
import { SecretProviderClientError } from "../secrets/types.js";
|
||||
|
||||
describe("awsSecretsManagerProvider", () => {
|
||||
const previousEnv = {
|
||||
PAPERCLIP_SECRETS_AWS_REGION: process.env.PAPERCLIP_SECRETS_AWS_REGION,
|
||||
AWS_REGION: process.env.AWS_REGION,
|
||||
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
|
||||
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID: process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID,
|
||||
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID: process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID,
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("creates Paperclip-managed AWS secrets without persisting plaintext in provider material", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret(input) {
|
||||
calls.push({ op: "createSecret", input });
|
||||
return {
|
||||
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
VersionId: "aws-version-1",
|
||||
};
|
||||
},
|
||||
async putSecretValue(input) {
|
||||
calls.push({ op: "putSecretValue", input });
|
||||
return { ARN: String(input.SecretId), VersionId: "unused" };
|
||||
},
|
||||
async getSecretValue(input) {
|
||||
calls.push({ op: "getSecretValue", input });
|
||||
return { SecretString: "resolved-value", VersionId: "unused" };
|
||||
},
|
||||
async deleteSecret(input) {
|
||||
calls.push({ op: "deleteSecret", input });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const prepared = await provider.createSecret({
|
||||
value: "super-secret-value",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
op: "createSecret",
|
||||
input: expect.objectContaining({
|
||||
Name: "paperclip/prod-use1/company-1/openai-api-key",
|
||||
KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(prepared)).not.toContain("super-secret-value");
|
||||
expect(prepared.externalRef).toContain("paperclip/prod-use1/company-1/openai-api-key");
|
||||
expect(prepared.providerVersionRef).toBe("aws-version-1");
|
||||
});
|
||||
|
||||
it("creates AWS secrets from selected provider vault config without deployment env fallback", async () => {
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
||||
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
gateway: {
|
||||
async createSecret(input) {
|
||||
calls.push({ op: "createSecret", input });
|
||||
return {
|
||||
ARN: "arn:aws:secretsmanager:us-west-2:123456789012:secret:clip/prod-us-west/company-1/openai-api-key",
|
||||
VersionId: "aws-version-1",
|
||||
};
|
||||
},
|
||||
async putSecretValue(input) {
|
||||
calls.push({ op: "putSecretValue", input });
|
||||
return { ARN: String(input.SecretId), VersionId: "unused" };
|
||||
},
|
||||
async getSecretValue(input) {
|
||||
calls.push({ op: "getSecretValue", input });
|
||||
return { SecretString: "resolved-value", VersionId: "unused" };
|
||||
},
|
||||
async deleteSecret(input) {
|
||||
calls.push({ op: "deleteSecret", input });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const providerConfig = {
|
||||
id: "vault-1",
|
||||
provider: "aws_secrets_manager" as const,
|
||||
status: "ready",
|
||||
config: {
|
||||
region: "us-west-2",
|
||||
namespace: "prod-us-west",
|
||||
secretNamePrefix: "clip",
|
||||
ownerTag: "platform",
|
||||
environmentTag: "production",
|
||||
},
|
||||
};
|
||||
|
||||
const health = await provider.healthCheck({ providerConfig });
|
||||
const prepared = await provider.createSecret({
|
||||
value: "super-secret-value",
|
||||
providerConfig,
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(health.status).toBe("ok");
|
||||
expect(health.details).toMatchObject({
|
||||
region: "us-west-2",
|
||||
prefix: "clip",
|
||||
deploymentId: "prod-us-west",
|
||||
kmsKeyConfigured: false,
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
op: "createSecret",
|
||||
input: expect.objectContaining({
|
||||
Name: "clip/prod-us-west/company-1/openai-api-key",
|
||||
SecretString: "super-secret-value",
|
||||
Tags: expect.arrayContaining([
|
||||
{ Key: "paperclip:provider-owner", Value: "platform" },
|
||||
{ Key: "paperclip:environment", Value: "production" },
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(calls[0]?.input).not.toHaveProperty("KmsKeyId");
|
||||
expect(JSON.stringify(prepared)).not.toContain("super-secret-value");
|
||||
expect(prepared.externalRef).toContain("clip/prod-us-west/company-1/openai-api-key");
|
||||
});
|
||||
|
||||
it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => {
|
||||
process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
process.env.AWS_SESSION_TOKEN = "test-session-token";
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-1/openai-api-key",
|
||||
VersionId: "aws-version-1",
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
await provider.createSecret({
|
||||
value: "super-secret-value",
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(String(url)).toBe("https://secretsmanager.us-east-1.amazonaws.com/");
|
||||
expect(headers["x-amz-target"]).toBe("secretsmanager.CreateSecret");
|
||||
expect(headers["x-amz-security-token"]).toBe("test-session-token");
|
||||
expect(headers.authorization).toContain("Credential=AKIA_TEST_ACCESS/");
|
||||
expect(headers.authorization).toContain("/us-east-1/secretsmanager/aws4_request");
|
||||
expect(headers.authorization).toContain("SignedHeaders=");
|
||||
expect(headers.authorization).toContain("Signature=");
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("creates new AWS secret versions against a namespace-valid existing secret reference", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue(input) {
|
||||
calls.push({ op: "putSecretValue", input });
|
||||
return {
|
||||
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
VersionId: "aws-version-2",
|
||||
};
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async deleteSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const prepared = await provider.createVersion({
|
||||
value: "rotated-secret-value",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
op: "putSecretValue",
|
||||
input: {
|
||||
SecretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
SecretString: "rotated-secret-value",
|
||||
VersionStages: ["PAPERCLIP_PENDING"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(prepared)).not.toContain("rotated-secret-value");
|
||||
expect(prepared.providerVersionRef).toBe("aws-version-2");
|
||||
});
|
||||
|
||||
it("rejects out-of-namespace refs for managed AWS secret version writes", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue(input) {
|
||||
calls.push({ op: "putSecretValue", input });
|
||||
return { Name: String(input.SecretId), VersionId: "aws-version-2" };
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async deleteSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.createVersion({
|
||||
value: "rotated-secret-value",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 2,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("stores linked external references as metadata-only provider material", async () => {
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const prepared = await provider.linkExternalSecret({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
||||
providerVersionRef: "linked-version-7",
|
||||
});
|
||||
|
||||
expect(prepared.externalRef).toBe(
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
||||
);
|
||||
expect(prepared.providerVersionRef).toBe("linked-version-7");
|
||||
expect(prepared.valueSha256).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects linked external references under the Paperclip-managed namespace", async () => {
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.linkExternalSecret({
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
||||
providerVersionRef: "linked-version-7",
|
||||
}),
|
||||
).rejects.toThrow(/Paperclip-managed namespace/i);
|
||||
});
|
||||
|
||||
it("lists remote AWS secrets with metadata only and never resolves plaintext", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("GetSecretValue must not be used for remote import preview");
|
||||
},
|
||||
async deleteSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async listSecrets(input) {
|
||||
calls.push({ op: "listSecrets", input });
|
||||
return {
|
||||
NextToken: "token-2",
|
||||
SecretList: [
|
||||
{
|
||||
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
Name: "prod/openai",
|
||||
Description: "OpenAI API key",
|
||||
CreatedDate: new Date("2026-05-06T00:00:00.000Z"),
|
||||
Tags: [{ Key: "team", Value: "platform" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listed = await provider.listRemoteSecrets?.({
|
||||
query: "openai",
|
||||
nextToken: "token-1",
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
op: "listSecrets",
|
||||
input: {
|
||||
MaxResults: 25,
|
||||
NextToken: "token-1",
|
||||
IncludePlannedDeletion: false,
|
||||
Filters: [{ Key: "all", Values: ["openai"] }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(listed).toEqual({
|
||||
nextToken: "token-2",
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "prod/openai",
|
||||
providerVersionRef: null,
|
||||
metadata: expect.objectContaining({
|
||||
createdDate: "2026-05-06T00:00:00.000Z",
|
||||
hasDescription: true,
|
||||
tagCount: 1,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(listed)).not.toContain("SecretString");
|
||||
expect(JSON.stringify(listed)).not.toContain("OpenAI API key");
|
||||
expect(JSON.stringify(listed)).not.toContain("team");
|
||||
});
|
||||
|
||||
it("redacts AWS provider exception text when remote listing fails", async () => {
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async deleteSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async listSecrets() {
|
||||
throw new Error(rawProviderMessage);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await provider.listRemoteSecrets?.({});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(SecretProviderClientError);
|
||||
expect(thrown).toMatchObject({
|
||||
code: "access_denied",
|
||||
status: 403,
|
||||
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
rawMessage: rawProviderMessage,
|
||||
});
|
||||
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
||||
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("123456789012");
|
||||
});
|
||||
|
||||
it("resolves AWS secret values by provider version reference", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getSecretValue(input) {
|
||||
calls.push({ op: "getSecretValue", input });
|
||||
return { SecretString: "resolved-secret-value", VersionId: "aws-version-2" };
|
||||
},
|
||||
async deleteSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await provider.resolveVersion({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
providerVersionRef: "aws-version-2",
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretId: "secret-1",
|
||||
secretKey: "openai-api-key",
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toBe("resolved-secret-value");
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
op: "getSecretValue",
|
||||
input: {
|
||||
SecretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
VersionId: "aws-version-2",
|
||||
VersionStage: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects managed resolve attempts when stored refs drift outside the derived scope", async () => {
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
async deleteSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.resolveVersion({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
||||
providerVersionRef: "aws-version-2",
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretId: "secret-1",
|
||||
secretKey: "openai-api-key",
|
||||
version: 2,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
|
||||
});
|
||||
|
||||
it("warns when AWS provider configuration is incomplete and blocks managed writes", async () => {
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
||||
|
||||
const provider = createAwsSecretsManagerProvider();
|
||||
const health = await provider.healthCheck();
|
||||
|
||||
expect(health.status).toBe("warn");
|
||||
expect(health.message).toContain("missing PAPERCLIP_SECRETS_AWS_REGION");
|
||||
expect(health.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Missing required non-secret AWS provider config"),
|
||||
expect.stringContaining("AWS bootstrap credentials must be available"),
|
||||
expect.stringContaining("Do not store AWS root credentials"),
|
||||
]),
|
||||
);
|
||||
expect(health.details).toMatchObject({
|
||||
missingConfig: [
|
||||
"PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION",
|
||||
"PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID",
|
||||
"PAPERCLIP_SECRETS_AWS_KMS_KEY_ID",
|
||||
],
|
||||
credentialSource: "AWS SDK default credential provider chain",
|
||||
});
|
||||
await expect(
|
||||
provider.createSecret({
|
||||
value: "super-secret-value",
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 1,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/PAPERCLIP_SECRETS_AWS_REGION|AWS_REGION/i);
|
||||
});
|
||||
|
||||
it("deletes only Paperclip-managed AWS secrets", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async deleteSecret(input) {
|
||||
calls.push({ op: "deleteSecret", input });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await provider.deleteOrArchive({
|
||||
mode: "delete",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
versionId: null,
|
||||
source: "managed",
|
||||
},
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
provider.deleteOrArchive({
|
||||
mode: "delete",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker",
|
||||
versionId: null,
|
||||
source: "managed",
|
||||
},
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 2,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
|
||||
await provider.deleteOrArchive({
|
||||
mode: "delete",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
||||
versionId: "linked-version-7",
|
||||
source: "external_reference",
|
||||
},
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
op: "deleteSecret",
|
||||
input: {
|
||||
SecretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
RecoveryWindowInDays: 30,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("archives pending Paperclip-managed AWS versions without deleting the secret", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
||||
deploymentId: "prod-use1",
|
||||
prefix: "paperclip",
|
||||
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
environmentTag: "production",
|
||||
providerOwnerTag: "paperclip",
|
||||
deleteRecoveryWindowDays: 30,
|
||||
},
|
||||
gateway: {
|
||||
async createSecret() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async putSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getSecretValue() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async deleteSecret(input) {
|
||||
calls.push({ op: "deleteSecret", input });
|
||||
return {};
|
||||
},
|
||||
async updateSecretVersionStage(input) {
|
||||
calls.push({ op: "updateSecretVersionStage", input });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await provider.deleteOrArchive({
|
||||
mode: "archive",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
context: {
|
||||
companyId: "company-1",
|
||||
secretKey: "openai-api-key",
|
||||
secretName: "OpenAI API Key",
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
op: "updateSecretVersionStage",
|
||||
input: {
|
||||
SecretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
||||
VersionStage: "PAPERCLIP_PENDING",
|
||||
RemoveFromVersionId: "aws-version-2",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -218,4 +218,64 @@ describe("claude_local environment diagnostics", () => {
|
||||
).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses --allowedTools instead of --dangerously-skip-permissions for sandbox hello probes", async () => {
|
||||
const executeCalls: Array<{ command: string; args?: string[] }> = [];
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
command: "claude",
|
||||
},
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "cloudflare",
|
||||
remoteCwd: "/workspace/paperclip",
|
||||
runner: {
|
||||
execute: async (input) => {
|
||||
executeCalls.push({ command: input.command, args: input.args });
|
||||
if (input.command === "claude") {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello" }] } }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
result: "hello",
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
||||
}),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
environmentName: "QA Cloudflare",
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_passed")).toBe(true);
|
||||
const probeCall = executeCalls.find((call) => call.command === "claude");
|
||||
expect(probeCall?.args).not.toContain("--dangerously-skip-permissions");
|
||||
expect(probeCall?.args).not.toContain("--permission-mode");
|
||||
// Sandbox probes pass `--allowedTools` so any tool invocation triggered
|
||||
// by the probe prompt cannot stall waiting for an interactive permission
|
||||
// approval that no human is present to answer.
|
||||
expect(probeCall?.args).toContain("--allowedTools");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,15 @@ describe("claude_local max-turn detection", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("checks every structured stop field for max-turn exhaustion", () => {
|
||||
expect(
|
||||
isClaudeMaxTurnsResult({
|
||||
stop_reason: "end_turn",
|
||||
stopReason: "max_turns_exhausted",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-max-turn results", () => {
|
||||
expect(
|
||||
isClaudeMaxTurnsResult({
|
||||
@@ -29,6 +38,15 @@ describe("claude_local max-turn detection", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not detect max-turn exhaustion from unstructured result text", () => {
|
||||
expect(
|
||||
isClaudeMaxTurnsResult({
|
||||
subtype: "error",
|
||||
result: "Tool output said: Maximum turns reached.",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claude_local ui stdout parser", () => {
|
||||
|
||||
@@ -19,6 +19,24 @@ process.exit(${exit});
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
async function writeTextFailingClaudeCommand(
|
||||
commandPath: string,
|
||||
options: { stdout?: string; stderr?: string; exitCode?: number },
|
||||
): Promise<void> {
|
||||
const exit = options.exitCode ?? 1;
|
||||
const script = `#!/usr/bin/env node
|
||||
if (${JSON.stringify(options.stdout ?? "")}) {
|
||||
process.stdout.write(${JSON.stringify(options.stdout ?? "")});
|
||||
}
|
||||
if (${JSON.stringify(options.stderr ?? "")}) {
|
||||
process.stderr.write(${JSON.stringify(options.stderr ?? "")});
|
||||
}
|
||||
process.exit(${exit});
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
@@ -372,6 +390,119 @@ describe("claude execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes max-turn exhaustion into scheduler stop metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turns-"));
|
||||
const resultEvent = {
|
||||
type: "result",
|
||||
subtype: "error_max_turns",
|
||||
session_id: "claude-session-1",
|
||||
is_error: true,
|
||||
result: "Maximum turns reached.",
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
||||
};
|
||||
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
|
||||
commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-max-turns",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
promptTemplate: "Do work.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("max_turns_exhausted");
|
||||
expect(result.errorFamily).toBeNull();
|
||||
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
|
||||
expect(result.clearSession).toBe(true);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not normalize unstructured max-turn text into scheduler stop metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-text-"));
|
||||
const resultEvent = {
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
session_id: "claude-session-1",
|
||||
is_error: true,
|
||||
result: "Tool output said: Maximum turns reached.",
|
||||
};
|
||||
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
|
||||
commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-max-turns-text",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
promptTemplate: "Do work.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).not.toBe("max_turns_exhausted");
|
||||
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
|
||||
expect(result.clearSession).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not normalize fallback stdout/stderr max-turn text into scheduler stop metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-fallback-"));
|
||||
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
|
||||
commandWriter: (commandPath) =>
|
||||
writeTextFailingClaudeCommand(commandPath, {
|
||||
stdout: "attacker-controlled tool output: max turns exhausted\n",
|
||||
stderr: "Maximum turns reached.\n",
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-max-turns-fallback-text",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
promptTemplate: "Do work.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).not.toBe("max_turns_exhausted");
|
||||
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
|
||||
expect(result.clearSession).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
@@ -505,6 +636,11 @@ describe("claude execute", () => {
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--allowedTools");
|
||||
expect(capture.argv).toContain(
|
||||
"Task AskUserQuestion Bash(*) CronCreate CronDelete CronList Edit EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write",
|
||||
);
|
||||
expect(capture.argv).not.toContain("--dangerously-skip-permissions");
|
||||
expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config"));
|
||||
expect(capture.claudeConfigEntries).toContain("settings.json");
|
||||
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
@@ -517,7 +653,7 @@ describe("claude execute", () => {
|
||||
else process.env.PATH = previousPath;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
|
||||
|
||||
@@ -37,9 +37,11 @@ const projectSvc = {
|
||||
|
||||
const issueSvc = {
|
||||
list: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
create: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
};
|
||||
|
||||
const routineSvc = {
|
||||
@@ -153,6 +155,14 @@ describe("company portability", () => {
|
||||
config,
|
||||
secretKeys: new Set<string>(),
|
||||
}));
|
||||
issueSvc.listComments.mockResolvedValue([]);
|
||||
issueSvc.addComment.mockResolvedValue({
|
||||
id: "comment-imported",
|
||||
body: "Imported comment",
|
||||
authorType: "system",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
});
|
||||
companySvc.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
@@ -508,6 +518,70 @@ describe("company portability", () => {
|
||||
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("requireBoardApprovalForNewAgents: true");
|
||||
});
|
||||
|
||||
it("exports legacy inline sensitive env values as declarations without values", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "agent-inline-secret",
|
||||
name: "InlineSecretAgent",
|
||||
status: "idle",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
OPENAI_API_KEY: "sk-inline-secret-value",
|
||||
NODE_ENV: {
|
||||
type: "plain",
|
||||
value: "development",
|
||||
},
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
permissions: {
|
||||
canCreateAgents: false,
|
||||
},
|
||||
metadata: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(exported);
|
||||
expect(serialized).not.toContain("sk-inline-secret-value");
|
||||
expect(exported.manifest.envInputs).toContainEqual({
|
||||
key: "OPENAI_API_KEY",
|
||||
description: "Optional default for OPENAI_API_KEY on agent inlinesecretagent",
|
||||
agentSlug: "inlinesecretagent",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
});
|
||||
expect(exported.manifest.envInputs).toContainEqual({
|
||||
key: "NODE_ENV",
|
||||
description: "Optional default for NODE_ENV on agent inlinesecretagent",
|
||||
agentSlug: "inlinesecretagent",
|
||||
projectSlug: null,
|
||||
kind: "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: "development",
|
||||
portability: "portable",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
@@ -2363,6 +2437,98 @@ describe("company portability", () => {
|
||||
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
|
||||
});
|
||||
|
||||
it("does not implicitly add local adapter permission bypass defaults on import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
companySvc.create.mockResolvedValue({
|
||||
id: "company-imported",
|
||||
name: "Imported Paperclip",
|
||||
});
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "agent-created",
|
||||
name: String(input.name),
|
||||
adapterType: input.adapterType,
|
||||
adapterConfig: input.adapterConfig,
|
||||
}));
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: exported.rootPath,
|
||||
files: exported.files,
|
||||
},
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "new_company",
|
||||
newCompanyName: "Imported Paperclip",
|
||||
},
|
||||
agents: ["claudecoder"],
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
// Imports must preserve safe-by-default local adapter settings unless the package says otherwise.
|
||||
const firstCreateInput = agentSvc.create.mock.calls[0]?.[1] as Record<string, any>;
|
||||
expect(firstCreateInput?.adapterConfig).toBeTruthy();
|
||||
expect(firstCreateInput.adapterConfig?.dangerouslySkipPermissions).toBeUndefined();
|
||||
|
||||
await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: exported.rootPath,
|
||||
files: exported.files,
|
||||
},
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "new_company",
|
||||
newCompanyName: "Imported Paperclip",
|
||||
},
|
||||
agents: ["claudecoder"],
|
||||
collisionStrategy: "rename",
|
||||
adapterOverrides: {
|
||||
claudecoder: {
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
extraArgs: [],
|
||||
args: ["--legacy-arg"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "user-1");
|
||||
|
||||
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: expect.objectContaining({
|
||||
extraArgs: ["--skip-git-repo-check"],
|
||||
args: ["--legacy-arg"],
|
||||
}),
|
||||
}));
|
||||
const lastCreateInput = agentSvc.create.mock.calls.at(-1)?.[1] as Record<string, any>;
|
||||
expect(lastCreateInput?.adapterConfig).toBeTruthy();
|
||||
expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves issue labelIds through export and import round-trip", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
@@ -2429,6 +2595,204 @@ describe("company portability", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves issue comment presentation fields through export and import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const presentation = { kind: "system_notice", tone: "warning", detailsDefaultOpen: false };
|
||||
const metadata = {
|
||||
version: 1,
|
||||
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
|
||||
};
|
||||
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
projectSvc.listWorkspaces.mockResolvedValue([]);
|
||||
issueSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Needs disposition",
|
||||
description: "System notice source",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
]);
|
||||
issueSvc.listComments.mockResolvedValue([
|
||||
{
|
||||
id: "comment-1",
|
||||
issueId: "issue-1",
|
||||
companyId: "company-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body: "Paperclip needs a disposition before this issue can continue.",
|
||||
presentation,
|
||||
metadata,
|
||||
createdAt: new Date("2026-05-04T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("comments:");
|
||||
expect(extension).toContain("system_notice");
|
||||
expect(extension).toContain("successful_run_missing_state");
|
||||
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Needs disposition" });
|
||||
|
||||
await portability.importBundle({
|
||||
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
target: { mode: "new_company", newCompanyName: "Imported" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(issueSvc.addComment).toHaveBeenCalledWith(
|
||||
"issue-imported",
|
||||
"Paperclip needs a disposition before this issue can continue.",
|
||||
{ agentId: undefined, userId: undefined },
|
||||
{
|
||||
authorType: "system",
|
||||
presentation,
|
||||
metadata,
|
||||
createdAt: "2026-05-04T12:00:00.000Z",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not export raw comment author user ids", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
projectSvc.listWorkspaces.mockResolvedValue([]);
|
||||
issueSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Private board note",
|
||||
description: null,
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
]);
|
||||
issueSvc.listComments.mockResolvedValue([
|
||||
{
|
||||
id: "comment-1",
|
||||
issueId: "issue-1",
|
||||
companyId: "company-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "Need private follow-up.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-05-04T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain('authorType: "user"');
|
||||
expect(extension).not.toContain("authorUserId: local-board");
|
||||
});
|
||||
|
||||
it("downgrades user-authored imported comments to system when no importing user exists", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
projectSvc.listWorkspaces.mockResolvedValue([]);
|
||||
issueSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Private board note",
|
||||
description: null,
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
]);
|
||||
issueSvc.listComments.mockResolvedValue([
|
||||
{
|
||||
id: "comment-1",
|
||||
issueId: "issue-1",
|
||||
companyId: "company-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "Need private follow-up.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-05-04T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
});
|
||||
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Private board note" });
|
||||
|
||||
const result = await portability.importBundle({
|
||||
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
target: { mode: "new_company", newCompanyName: "Imported" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, null);
|
||||
|
||||
expect(issueSvc.addComment).toHaveBeenCalledWith(
|
||||
"issue-imported",
|
||||
"Need private follow-up.",
|
||||
{ agentId: undefined, userId: undefined },
|
||||
{
|
||||
authorType: "system",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: "2026-05-04T12:00:00.000Z",
|
||||
},
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
"Comment on task pap-1 was imported as a system comment because no importing user was available.",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
@@ -2599,7 +2963,7 @@ describe("company portability", () => {
|
||||
|
||||
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
|
||||
"company-imported",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
{ strictMode: false },
|
||||
);
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
@@ -2665,7 +3029,10 @@ describe("company portability", () => {
|
||||
|
||||
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
model: "gpt-5.4",
|
||||
extraArgs: ["--skip-git-repo-check"],
|
||||
}),
|
||||
{ strictMode: false },
|
||||
);
|
||||
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { createCompanySearchRateLimiter } from "../services/company-search-rate-limit.js";
|
||||
import type { CompanySearchQuery, CompanySearchResponse } from "@paperclipai/shared";
|
||||
|
||||
function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse {
|
||||
return {
|
||||
query: query.q,
|
||||
normalizedQuery: query.q.trim().toLowerCase(),
|
||||
scope: query.scope,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
results: [],
|
||||
countsByType: { issue: 0, agent: 0, project: 0 },
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("company search route rate limiting", () => {
|
||||
it("rejects repeated same-actor search calls before invoking search", async () => {
|
||||
const search = vi.fn(async (_companyId: string, query: CompanySearchQuery) => createSearchResponse(query));
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as never, {} as never, {
|
||||
searchService: { search },
|
||||
searchRateLimiter: createCompanySearchRateLimiter({
|
||||
maxRequests: 1,
|
||||
windowMs: 60_000,
|
||||
now: () => 1_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
await request(app).get("/api/companies/company-1/search?q=wizard").expect(200);
|
||||
const limited = await request(app).get("/api/companies/company-1/search?q=wizard").expect(429);
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
expect(limited.body).toMatchObject({
|
||||
error: "Search rate limit exceeded",
|
||||
retryAfterSeconds: 60,
|
||||
});
|
||||
expect(limited.headers["retry-after"]).toBe("60");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
documents,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
COMPANY_SEARCH_BRANCH_FETCH_LIMIT,
|
||||
companySearchBranchFetchLimit,
|
||||
companySearchService,
|
||||
} from "../services/company-search.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres company search tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("company search query validation", () => {
|
||||
it("clamps query length, limit, and offset without rejecting the request", () => {
|
||||
const parsed = companySearchQuerySchema.parse({
|
||||
q: "x".repeat(COMPANY_SEARCH_MAX_QUERY_LENGTH + 50),
|
||||
limit: "500",
|
||||
offset: "9000",
|
||||
scope: "not-a-scope",
|
||||
});
|
||||
|
||||
expect(parsed.q).toHaveLength(COMPANY_SEARCH_MAX_QUERY_LENGTH);
|
||||
expect(parsed.limit).toBe(50);
|
||||
expect(parsed.offset).toBe(200);
|
||||
expect(parsed.scope).toBe("all");
|
||||
});
|
||||
|
||||
it("includes offset in the internal per-branch fetch window", () => {
|
||||
const lowOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "0" });
|
||||
const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "9000" });
|
||||
|
||||
expect(companySearchBranchFetchLimit(lowOffset.limit, lowOffset.offset)).toBe(51);
|
||||
expect(companySearchBranchFetchLimit(highOffset.limit, highOffset.offset)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("companySearchService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof companySearchService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = companySearchService(db);
|
||||
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function createCompany(name = "Paperclip") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function createIssue(companyId: string, values: Partial<typeof issues.$inferInsert> = {}) {
|
||||
const id = values.id ?? randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id,
|
||||
companyId,
|
||||
title: values.title ?? "Search target",
|
||||
description: values.description ?? null,
|
||||
status: values.status ?? "todo",
|
||||
priority: values.priority ?? "medium",
|
||||
identifier: values.identifier ?? null,
|
||||
hiddenAt: values.hiddenAt ?? null,
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createAgent(companyId: string, values: Partial<typeof agents.$inferInsert> = {}) {
|
||||
const id = values.id ?? randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id,
|
||||
companyId,
|
||||
name: values.name ?? "Search agent",
|
||||
role: values.role ?? "engineer",
|
||||
title: values.title ?? null,
|
||||
capabilities: values.capabilities ?? null,
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createProject(companyId: string, values: Partial<typeof projects.$inferInsert> = {}) {
|
||||
const id = values.id ?? randomUUID();
|
||||
await db.insert(projects).values({
|
||||
id,
|
||||
companyId,
|
||||
name: values.name ?? "Search project",
|
||||
description: values.description ?? null,
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it("ranks exact issue identifiers before weaker title matches", async () => {
|
||||
const companyId = await createCompany();
|
||||
const exactId = await createIssue(companyId, {
|
||||
identifier: "TST-42",
|
||||
title: "Backend endpoint",
|
||||
});
|
||||
await createIssue(companyId, {
|
||||
identifier: "TST-43",
|
||||
title: "TST-42 mentioned in title only",
|
||||
});
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "TST-42" }));
|
||||
|
||||
expect(result.results[0]?.id).toBe(exactId);
|
||||
expect(result.results[0]?.matchedFields).toContain("identifier");
|
||||
});
|
||||
|
||||
it("matches multiple tokens across the same issue thread and returns comment snippets", async () => {
|
||||
const companyId = await createCompany();
|
||||
const issueId = await createIssue(companyId, {
|
||||
identifier: "TST-7",
|
||||
title: "Checkout semantics",
|
||||
description: "Atomic ownership is enforced here.",
|
||||
});
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
body: "The ranking snippet should explain why this thread matched.",
|
||||
});
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "checkout snippet" }));
|
||||
const match = result.results.find((item) => item.id === issueId);
|
||||
|
||||
expect(match).toBeTruthy();
|
||||
expect(match?.matchedFields).toEqual(expect.arrayContaining(["title", "comment"]));
|
||||
expect(match?.snippets.some((snippet) => /snippet/i.test(snippet.text))).toBe(true);
|
||||
});
|
||||
|
||||
it("searches issue documents and returns document metadata for snippets", async () => {
|
||||
const companyId = await createCompany();
|
||||
const issueId = await createIssue(companyId, {
|
||||
identifier: "TST-8",
|
||||
title: "Adapter manager",
|
||||
});
|
||||
const documentId = randomUUID();
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Hermes Parser Plan",
|
||||
latestBody: "The external adapter parser should be discovered from the plugin package.",
|
||||
format: "markdown",
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Hermes parser", scope: "documents" }));
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0]?.id).toBe(issueId);
|
||||
expect(result.results[0]?.matchedFields).toContain("document");
|
||||
expect(result.results[0]?.href).toContain("#document-plan");
|
||||
expect(result.results[0]?.snippet).toMatch(/parser/i);
|
||||
});
|
||||
|
||||
it("excludes hidden issues and other companies' data", async () => {
|
||||
const companyId = await createCompany("Visible Co");
|
||||
const otherCompanyId = await createCompany("Other Co");
|
||||
const visibleId = await createIssue(companyId, {
|
||||
identifier: "VIS-1",
|
||||
title: "Visible needle",
|
||||
});
|
||||
await createIssue(companyId, {
|
||||
identifier: "HID-1",
|
||||
title: "Hidden needle",
|
||||
hiddenAt: new Date(),
|
||||
});
|
||||
await createIssue(otherCompanyId, {
|
||||
identifier: "OTH-1",
|
||||
title: "Other company needle",
|
||||
});
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle" }));
|
||||
|
||||
expect(result.results.map((item) => item.id)).toEqual([visibleId]);
|
||||
});
|
||||
|
||||
it("treats bare SQL wildcard characters as literals instead of match-all queries", async () => {
|
||||
const companyId = await createCompany();
|
||||
const issueId = await createIssue(companyId, {
|
||||
identifier: "TST-20",
|
||||
title: "Plain issue target",
|
||||
description: "Plain issue description",
|
||||
});
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
body: "Plain comment body",
|
||||
});
|
||||
const documentId = randomUUID();
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Plain document",
|
||||
latestBody: "Plain document body",
|
||||
format: "markdown",
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plain",
|
||||
});
|
||||
await createAgent(companyId, {
|
||||
name: "Plain Agent",
|
||||
role: "engineer",
|
||||
capabilities: "Plain agent capabilities",
|
||||
});
|
||||
await createProject(companyId, {
|
||||
name: "Plain Project",
|
||||
description: "Plain project description",
|
||||
});
|
||||
|
||||
for (const q of ["%", "_", "\\"]) {
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q }));
|
||||
expect(result.results, `q=${q}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches percent characters literally across issue, comment, document, agent, and project results", async () => {
|
||||
const companyId = await createCompany();
|
||||
const issueMatchId = await createIssue(companyId, {
|
||||
identifier: "TST-21",
|
||||
title: "Release 100% checklist",
|
||||
});
|
||||
const issueDecoyId = await createIssue(companyId, {
|
||||
identifier: "TST-22",
|
||||
title: "Release 1000 checklist",
|
||||
});
|
||||
const commentMatchId = await createIssue(companyId, {
|
||||
identifier: "TST-23",
|
||||
title: "Comment literal holder",
|
||||
});
|
||||
const commentDecoyId = await createIssue(companyId, {
|
||||
identifier: "TST-24",
|
||||
title: "Comment decoy holder",
|
||||
});
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
companyId,
|
||||
issueId: commentMatchId,
|
||||
body: "QA is 100% confident in this result.",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
issueId: commentDecoyId,
|
||||
body: "QA is 1000 confident in this result.",
|
||||
},
|
||||
]);
|
||||
const documentMatchIssueId = await createIssue(companyId, {
|
||||
identifier: "TST-25",
|
||||
title: "Document literal holder",
|
||||
});
|
||||
const documentDecoyIssueId = await createIssue(companyId, {
|
||||
identifier: "TST-26",
|
||||
title: "Document decoy holder",
|
||||
});
|
||||
const documentMatchId = randomUUID();
|
||||
const documentDecoyId = randomUUID();
|
||||
await db.insert(documents).values([
|
||||
{
|
||||
id: documentMatchId,
|
||||
companyId,
|
||||
title: "Literal rollout",
|
||||
latestBody: "Ship 100% complete adapter support.",
|
||||
format: "markdown",
|
||||
},
|
||||
{
|
||||
id: documentDecoyId,
|
||||
companyId,
|
||||
title: "Decoy rollout",
|
||||
latestBody: "Ship 1000 complete adapter support.",
|
||||
format: "markdown",
|
||||
},
|
||||
]);
|
||||
await db.insert(issueDocuments).values([
|
||||
{
|
||||
companyId,
|
||||
issueId: documentMatchIssueId,
|
||||
documentId: documentMatchId,
|
||||
key: "literal",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
issueId: documentDecoyIssueId,
|
||||
documentId: documentDecoyId,
|
||||
key: "decoy",
|
||||
},
|
||||
]);
|
||||
const agentMatchId = await createAgent(companyId, {
|
||||
name: "100% Specialist",
|
||||
role: "engineer",
|
||||
});
|
||||
const agentDecoyId = await createAgent(companyId, {
|
||||
name: "1000 Specialist",
|
||||
role: "engineer",
|
||||
});
|
||||
const projectMatchId = await createProject(companyId, {
|
||||
name: "100% Launch Plan",
|
||||
});
|
||||
const projectDecoyId = await createProject(companyId, {
|
||||
name: "1000 Launch Plan",
|
||||
});
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "100%" }));
|
||||
const ids = result.results.map((row) => row.id);
|
||||
|
||||
expect(ids).toEqual(expect.arrayContaining([
|
||||
issueMatchId,
|
||||
commentMatchId,
|
||||
documentMatchIssueId,
|
||||
agentMatchId,
|
||||
projectMatchId,
|
||||
]));
|
||||
expect(ids).not.toEqual(expect.arrayContaining([
|
||||
issueDecoyId,
|
||||
commentDecoyId,
|
||||
documentDecoyIssueId,
|
||||
agentDecoyId,
|
||||
projectDecoyId,
|
||||
]));
|
||||
});
|
||||
|
||||
it("applies offset after merging cross-type result ranking", async () => {
|
||||
const companyId = await createCompany();
|
||||
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||
const agentIds = await Promise.all([
|
||||
createAgent(companyId, { name: "Needle agent 1", updatedAt: new Date(base + 6_000) }),
|
||||
createAgent(companyId, { name: "Needle agent 2", updatedAt: new Date(base + 5_000) }),
|
||||
createAgent(companyId, { name: "Needle agent 3", updatedAt: new Date(base + 4_000) }),
|
||||
]);
|
||||
const projectIds = await Promise.all([
|
||||
createProject(companyId, { name: "Needle project 1", updatedAt: new Date(base + 3_000) }),
|
||||
createProject(companyId, { name: "Needle project 2", updatedAt: new Date(base + 2_000) }),
|
||||
createProject(companyId, { name: "Needle project 3", updatedAt: new Date(base + 1_000) }),
|
||||
]);
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" }));
|
||||
|
||||
expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]);
|
||||
expect(result.countsByType).toEqual({ issue: 0, agent: 3, project: 3 });
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("escapes underscore and backslash characters in issue phrase and token patterns", async () => {
|
||||
const companyId = await createCompany();
|
||||
const literalId = await createIssue(companyId, {
|
||||
identifier: "TST-27",
|
||||
title: "Literal foo_bar path c:\\tmp",
|
||||
});
|
||||
const decoyId = await createIssue(companyId, {
|
||||
identifier: "TST-28",
|
||||
title: "Decoy fooXbar path c:tmp",
|
||||
});
|
||||
|
||||
for (const q of ["foo_bar", "c:\\tmp"]) {
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q, scope: "issues" }));
|
||||
const ids = result.results.map((row) => row.id);
|
||||
expect(ids, `q=${q}`).toContain(literalId);
|
||||
expect(ids, `q=${q}`).not.toContain(decoyId);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses pg_trgm for conservative fuzzy title matches", async () => {
|
||||
const companyId = await createCompany();
|
||||
const issueId = await createIssue(companyId, {
|
||||
identifier: "TST-9",
|
||||
title: "Onboarding wizard polish",
|
||||
});
|
||||
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "onbordng wizard" }));
|
||||
|
||||
expect(result.results[0]?.id).toBe(issueId);
|
||||
expect(result.results[0]?.matchedFields).toContain("title");
|
||||
});
|
||||
|
||||
it("matches transposition typos against multi-word titles", async () => {
|
||||
const companyId = await createCompany();
|
||||
const searchIssueId = await createIssue(companyId, {
|
||||
identifier: "TST-10",
|
||||
title: "Improve search performance",
|
||||
});
|
||||
const mobileIssueId = await createIssue(companyId, {
|
||||
identifier: "TST-11",
|
||||
title: "Polish mobile navigation",
|
||||
});
|
||||
const otherIssueId = await createIssue(companyId, {
|
||||
identifier: "TST-12",
|
||||
title: "Refactor billing reports",
|
||||
});
|
||||
|
||||
const transpositionCases: Array<{ query: string; expectedId: string; rejected: string }> = [
|
||||
{ query: "serach", expectedId: searchIssueId, rejected: otherIssueId },
|
||||
{ query: "mibile", expectedId: mobileIssueId, rejected: otherIssueId },
|
||||
{ query: "mobail", expectedId: mobileIssueId, rejected: otherIssueId },
|
||||
];
|
||||
|
||||
for (const { query, expectedId, rejected } of transpositionCases) {
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: query }));
|
||||
const ids = result.results.map((row) => row.id);
|
||||
expect(ids, `query=${query}`).toContain(expectedId);
|
||||
expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,17 @@ import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createDb, companies, agents, costEvents, financeEvents, issues, projects } from "@paperclipai/db";
|
||||
import {
|
||||
createDb,
|
||||
companies,
|
||||
agents,
|
||||
activityLog,
|
||||
costEvents,
|
||||
financeEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { costService } from "../services/costs.ts";
|
||||
import { financeService } from "../services/finance.ts";
|
||||
import {
|
||||
@@ -69,6 +79,8 @@ const mockCostService = vi.hoisted(() => ({
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
runCount: 0,
|
||||
runtimeMs: 0,
|
||||
}),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
@@ -178,12 +190,12 @@ beforeEach(() => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
identifier: "PC1A2-1",
|
||||
});
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
identifier: "PC1A2-1",
|
||||
});
|
||||
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -227,11 +239,13 @@ describe("cost routes", () => {
|
||||
|
||||
it("returns issue subtree cost summaries for issue refs", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app).get("/api/issues/PAP-1/cost-summary");
|
||||
const res = await request(app).get("/api/issues/pc1a2-1/cost-summary");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1");
|
||||
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1");
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1");
|
||||
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1", {
|
||||
excludeRoot: false,
|
||||
});
|
||||
expect(res.body).toEqual({
|
||||
issueId: "issue-1",
|
||||
issueCount: 1,
|
||||
@@ -240,6 +254,8 @@ describe("cost routes", () => {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
runCount: 0,
|
||||
runtimeMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,6 +409,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
||||
afterEach(async () => {
|
||||
await db.delete(financeEvents);
|
||||
await db.delete(costEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
@@ -612,9 +630,173 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
||||
inputTokens: 60,
|
||||
cachedInputTokens: 6,
|
||||
outputTokens: 12,
|
||||
runCount: 0,
|
||||
runtimeMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates run wall-clock duration across the recursive issue tree", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const grandchildIssueId = randomUUID();
|
||||
const siblingIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Run Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Root",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "TST-1",
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Child",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
issueNumber: 2,
|
||||
identifier: "TST-2",
|
||||
},
|
||||
{
|
||||
id: grandchildIssueId,
|
||||
companyId,
|
||||
parentId: childIssueId,
|
||||
title: "Grandchild",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
issueNumber: 3,
|
||||
identifier: "TST-3",
|
||||
},
|
||||
{
|
||||
id: siblingIssueId,
|
||||
companyId,
|
||||
title: "Sibling",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
issueNumber: 4,
|
||||
identifier: "TST-4",
|
||||
},
|
||||
]);
|
||||
|
||||
const linkedViaContextRunId = randomUUID();
|
||||
const linkedViaActivityRunId = randomUUID();
|
||||
const grandchildRunId = randomUUID();
|
||||
const siblingRunId = randomUUID();
|
||||
const livePartialRunId = randomUUID();
|
||||
|
||||
await db.insert(heartbeatRuns).values([
|
||||
// 60s run linked to root via contextSnapshot.issueId
|
||||
{
|
||||
id: linkedViaContextRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:00:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:01:00.000Z"),
|
||||
contextSnapshot: { issueId: rootIssueId },
|
||||
},
|
||||
// 120s run linked to child via activity_log
|
||||
{
|
||||
id: linkedViaActivityRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:05:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:07:00.000Z"),
|
||||
},
|
||||
// 30s run linked to grandchild
|
||||
{
|
||||
id: grandchildRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:10:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:10:30.000Z"),
|
||||
contextSnapshot: { issueId: grandchildIssueId },
|
||||
},
|
||||
// sibling run NOT under root – should be excluded
|
||||
{
|
||||
id: siblingRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:20:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:21:00.000Z"),
|
||||
contextSnapshot: { issueId: siblingIssueId },
|
||||
},
|
||||
// Still-running run on child (no finishedAt) – should contribute (now - startedAt)
|
||||
{
|
||||
id: livePartialRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 5_000),
|
||||
contextSnapshot: { issueId: childIssueId },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(activityLog).values({
|
||||
companyId,
|
||||
runId: linkedViaActivityRunId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
agentId,
|
||||
action: "issue.checked_out",
|
||||
entityType: "issue",
|
||||
entityId: childIssueId,
|
||||
details: {},
|
||||
});
|
||||
|
||||
const summary = await costs.issueTreeSummary(companyId, rootIssueId);
|
||||
|
||||
expect(summary.issueCount).toBe(3);
|
||||
// 3 finished runs in tree (root, child via activity, grandchild) + 1 live run
|
||||
expect(summary.runCount).toBe(4);
|
||||
// 60s + 120s + 30s = 210s = 210_000ms from finished runs.
|
||||
// Live run adds ~5_000ms; allow some slack so the assertion isn't flaky.
|
||||
expect(summary.runtimeMs).toBeGreaterThanOrEqual(210_000 + 4_000);
|
||||
expect(summary.runtimeMs).toBeLessThan(210_000 + 60_000);
|
||||
|
||||
// excludeRoot drops the root issue's own runs (the 60s contextSnapshot run)
|
||||
// while keeping the child + grandchild runs and any live child run.
|
||||
const descendantsOnly = await costs.issueTreeSummary(companyId, rootIssueId, {
|
||||
excludeRoot: true,
|
||||
});
|
||||
expect(descendantsOnly.issueCount).toBe(2);
|
||||
expect(descendantsOnly.runCount).toBe(3);
|
||||
// 120s + 30s = 150s + ~5s live run
|
||||
expect(descendantsOnly.runtimeMs).toBeGreaterThanOrEqual(150_000 + 4_000);
|
||||
expect(descendantsOnly.runtimeMs).toBeLessThan(150_000 + 60_000);
|
||||
});
|
||||
|
||||
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
|
||||
const companyId = randomUUID();
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ describe("cursor execute", () => {
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("keeps explicit command overrides for remote sandbox execution", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-"));
|
||||
|
||||
@@ -54,14 +54,11 @@ describe("resolveEnvironmentExecutionTarget", () => {
|
||||
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
|
||||
leaseId: "lease-1",
|
||||
environmentId: "env-1",
|
||||
paperclipTransport: "bridge",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an explicit Paperclip API URL from lease metadata for sandbox targets", async () => {
|
||||
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL = "http://paperclip.example.test:3200";
|
||||
it("keeps sandbox targets on bridge mode even when lease metadata includes a Paperclip API URL", async () => {
|
||||
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
|
||||
driver: "sandbox",
|
||||
config: {
|
||||
@@ -93,8 +90,92 @@ describe("resolveEnvironmentExecutionTarget", () => {
|
||||
expect(target).toMatchObject({
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
paperclipApiUrl: "https://paperclip.example.test",
|
||||
paperclipTransport: "direct",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
|
||||
});
|
||||
expect(target).not.toHaveProperty("paperclipApiUrl");
|
||||
expect(target).not.toHaveProperty("paperclipTransport");
|
||||
});
|
||||
|
||||
it("passes through a provider-declared sandbox shell command from lease metadata", async () => {
|
||||
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
|
||||
driver: "sandbox",
|
||||
config: {
|
||||
provider: "fake-plugin",
|
||||
reuseLease: false,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db: {} as never,
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
environment: {
|
||||
id: "env-1",
|
||||
driver: "sandbox",
|
||||
config: {
|
||||
provider: "fake-plugin",
|
||||
},
|
||||
},
|
||||
leaseId: "lease-1",
|
||||
leaseMetadata: {
|
||||
shellCommand: "bash",
|
||||
},
|
||||
lease: null,
|
||||
environmentRuntime: null,
|
||||
});
|
||||
|
||||
expect(target).toMatchObject({
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
shellCommand: "bash",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves SSH execution targets in bridge mode", async () => {
|
||||
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[ssh.example.test]:22 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
});
|
||||
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db: {} as never,
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
environment: {
|
||||
id: "env-ssh-1",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
},
|
||||
leaseId: "lease-ssh-1",
|
||||
leaseMetadata: {},
|
||||
lease: null,
|
||||
environmentRuntime: null,
|
||||
});
|
||||
|
||||
expect(target).toMatchObject({
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/srv/paperclip",
|
||||
leaseId: "lease-ssh-1",
|
||||
environmentId: "env-ssh-1",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
remoteCwd: "/srv/paperclip",
|
||||
},
|
||||
});
|
||||
expect(target).not.toHaveProperty("paperclipApiUrl");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,9 +161,10 @@ describeLiveSsh("live SSH environment smoke", () => {
|
||||
}
|
||||
|
||||
if (!resolvedConfig) {
|
||||
throw new Error(
|
||||
console.warn(
|
||||
"Live SSH smoke test could not resolve SSH config from env vars or env-lab fixture. Set PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE=true to mark this suite skipped intentionally.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = resolvedConfig;
|
||||
@@ -171,7 +172,7 @@ describeLiveSsh("live SSH environment smoke", () => {
|
||||
const quotedRemoteWorkspacePath = JSON.stringify(config.remoteWorkspacePath);
|
||||
const result = await runSshCommand(
|
||||
config,
|
||||
`sh -lc "cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd"`,
|
||||
`cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd`,
|
||||
{ timeoutMs: 30000, maxBuffer: 256 * 1024 },
|
||||
);
|
||||
|
||||
|
||||
@@ -36,10 +36,13 @@ const mockProbeEnvironment = vi.hoisted(() => vi.fn());
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
resolveSecretValue: vi.fn(),
|
||||
syncSecretRefsForTarget: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
const mockValidatePluginEnvironmentDriverConfig = vi.hoisted(() => vi.fn());
|
||||
const mockValidatePluginSandboxProviderConfig = vi.hoisted(() => vi.fn());
|
||||
const mockListReadyPluginEnvironmentDrivers = vi.hoisted(() => vi.fn());
|
||||
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
|
||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
@@ -69,6 +72,7 @@ vi.mock("../services/execution-workspaces.js", () => ({
|
||||
|
||||
vi.mock("../services/plugin-environment-driver.js", () => ({
|
||||
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
|
||||
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
|
||||
validatePluginEnvironmentDriverConfig: mockValidatePluginEnvironmentDriverConfig,
|
||||
validatePluginSandboxProviderConfig: mockValidatePluginSandboxProviderConfig,
|
||||
}));
|
||||
@@ -96,6 +100,7 @@ let currentActor: Record<string, unknown> = {
|
||||
source: "local_implicit",
|
||||
};
|
||||
const routeOptions: Record<string, unknown> = {};
|
||||
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
|
||||
function createApp(actor: Record<string, unknown>, options: Record<string, unknown> = {}) {
|
||||
currentActor = actor;
|
||||
@@ -119,6 +124,11 @@ function createApp(actor: Record<string, unknown>, options: Record<string, unkno
|
||||
|
||||
describe("environment routes", () => {
|
||||
afterAll(async () => {
|
||||
if (originalSecretsProviderEnv === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
|
||||
}
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server?.close((err) => {
|
||||
@@ -145,9 +155,14 @@ describe("environment routes", () => {
|
||||
mockProbeEnvironment.mockReset();
|
||||
mockSecretService.create.mockReset();
|
||||
mockSecretService.resolveSecretValue.mockReset();
|
||||
mockSecretService.syncSecretRefsForTarget.mockReset();
|
||||
mockSecretService.remove.mockReset();
|
||||
mockSecretService.create.mockResolvedValue({
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
|
||||
mockSecretService.remove.mockResolvedValue(null);
|
||||
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
mockValidatePluginEnvironmentDriverConfig.mockReset();
|
||||
mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config);
|
||||
mockValidatePluginSandboxProviderConfig.mockReset();
|
||||
@@ -162,6 +177,29 @@ describe("environment routes", () => {
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
}));
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockReset();
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(async ({ driverKey }) => (
|
||||
driverKey === "secure-plugin"
|
||||
? {
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: null
|
||||
));
|
||||
mockListReadyPluginEnvironmentDrivers.mockReset();
|
||||
mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]);
|
||||
});
|
||||
@@ -555,6 +593,59 @@ describe("environment routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured provider for SSH private key secret materialization", async () => {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-ssh",
|
||||
name: "SSH Fixture",
|
||||
driver: "ssh" as const,
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
mockEnvironmentService.create.mockResolvedValue(environment);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
.send({
|
||||
name: "SSH Fixture",
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: "super-secret-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockSecretService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
provider: "aws_secrets_manager",
|
||||
value: "super-secret-key",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects persisted fake sandbox environments", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
@@ -732,6 +823,78 @@ describe("environment routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured provider for schema-driven sandbox secret fields", async () => {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-sandbox-secure-plugin",
|
||||
name: "Secure Sandbox",
|
||||
driver: "sandbox" as const,
|
||||
config: {
|
||||
provider: "secure-plugin",
|
||||
template: "base",
|
||||
apiKey: "11111111-1111-1111-1111-111111111111",
|
||||
timeoutMs: 450000,
|
||||
reuseLease: true,
|
||||
},
|
||||
};
|
||||
mockEnvironmentService.create.mockResolvedValue(environment);
|
||||
mockValidatePluginSandboxProviderConfig.mockResolvedValue({
|
||||
normalizedConfig: {
|
||||
template: "base",
|
||||
apiKey: "test-provider-key",
|
||||
timeoutMs: 450000,
|
||||
reuseLease: true,
|
||||
},
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
.send({
|
||||
name: "Secure Sandbox",
|
||||
driver: "sandbox",
|
||||
config: {
|
||||
provider: "secure-plugin",
|
||||
template: "base",
|
||||
apiKey: "test-provider-key",
|
||||
timeoutMs: "450000",
|
||||
reuseLease: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockSecretService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
provider: "aws_secrets_manager",
|
||||
value: "test-provider-key",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates plugin environment config through the plugin driver host", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
|
||||
@@ -420,6 +420,85 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("runs project-level provision commands for ssh environments", async () => {
|
||||
mockBuildWorkspaceRealizationRequest.mockReturnValue({
|
||||
version: 1,
|
||||
adapterType: "gemini_local",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
heartbeatRunId: "run-1",
|
||||
requestedMode: null,
|
||||
source: {
|
||||
kind: "project_primary",
|
||||
localPath: "/workspace/project",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
strategy: "project_primary",
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
},
|
||||
runtimeOverlay: {
|
||||
provisionCommand: "npm install -g @google/gemini-cli",
|
||||
},
|
||||
});
|
||||
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/remote/workspace",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteCwd: "/remote/workspace",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
privateKey: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
});
|
||||
|
||||
const runtime = makeMockRuntime({
|
||||
realizeWorkspace: vi.fn().mockResolvedValue({
|
||||
cwd: "/remote/workspace",
|
||||
metadata: {
|
||||
workspaceRealization: {
|
||||
version: 1,
|
||||
transport: "ssh",
|
||||
remote: { path: "/remote/workspace" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
|
||||
|
||||
await orchestrator.realizeForRun(makeRealizeInput({
|
||||
environment: makeEnvironment("ssh"),
|
||||
lease: makeLease({
|
||||
provider: "ssh",
|
||||
metadata: {
|
||||
driver: "ssh",
|
||||
remoteCwd: "/remote/workspace",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "bash",
|
||||
args: ["-lc", "npm install -g @google/gemini-cli"],
|
||||
}));
|
||||
expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("surfaces remote provision command failures before resolving the adapter target", async () => {
|
||||
mockBuildWorkspaceRealizationRequest.mockReturnValue({
|
||||
version: 1,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -55,7 +54,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const fixtureRoots: string[] = [];
|
||||
const servers: Server[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract");
|
||||
@@ -64,9 +62,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
while (fixtureRoots.length > 0) {
|
||||
const root = fixtureRoots.pop();
|
||||
if (!root) continue;
|
||||
@@ -123,6 +118,13 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
provider: "local_encrypted",
|
||||
value: config.privateKey,
|
||||
});
|
||||
await secretService(db).createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "environment",
|
||||
targetId: environmentId,
|
||||
configPath: "privateKeySecretRef",
|
||||
});
|
||||
config = {
|
||||
...config,
|
||||
privateKey: null,
|
||||
@@ -172,27 +174,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function startHealthServer() {
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/api/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected health server to listen on a TCP port.");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function runContract(testCase: RuntimeContractCase) {
|
||||
const cleanup = await testCase.setup?.();
|
||||
try {
|
||||
@@ -288,9 +269,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
fixtureRoots.push(fixtureRoot);
|
||||
const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") });
|
||||
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
|
||||
const runtimeApiUrl = await startHealthServer();
|
||||
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
|
||||
|
||||
await runContract({
|
||||
name: "ssh",
|
||||
@@ -304,16 +282,8 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
||||
username: sshConfig.username,
|
||||
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
|
||||
remoteCwd: sshConfig.remoteWorkspacePath,
|
||||
paperclipApiUrl: runtimeApiUrl,
|
||||
});
|
||||
},
|
||||
setup: async () => async () => {
|
||||
if (previousCandidates === undefined) {
|
||||
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
} else {
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -178,6 +177,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
provider: "local_encrypted",
|
||||
value: config.privateKey,
|
||||
});
|
||||
await secretService(db).createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "environment",
|
||||
targetId: environmentId,
|
||||
configPath: "privateKeySecretRef",
|
||||
});
|
||||
config = {
|
||||
...config,
|
||||
privateKey: null,
|
||||
@@ -329,26 +335,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
const statePath = path.join(fixtureRoot, "state.json");
|
||||
const fixture = await startSshEnvLabFixture({ statePath });
|
||||
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
|
||||
const healthServer = createServer((req, res) => {
|
||||
if (req.url === "/api/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
healthServer.once("error", reject);
|
||||
healthServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = healthServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
|
||||
throw new Error("Expected the test health server to listen on a TCP port.");
|
||||
}
|
||||
const runtimeApiUrl = `http://127.0.0.1:${address.port}`;
|
||||
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
|
||||
const { companyId, environment, runId } = await seedEnvironment({
|
||||
driver: "ssh",
|
||||
name: "Fixture SSH",
|
||||
@@ -372,7 +358,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
username: sshConfig.username,
|
||||
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
|
||||
remoteCwd: sshConfig.remoteWorkspacePath,
|
||||
paperclipApiUrl: runtimeApiUrl,
|
||||
});
|
||||
|
||||
const released = await runtime.releaseRunLeases(runId);
|
||||
@@ -381,12 +366,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(released[0]?.environment.driver).toBe("ssh");
|
||||
expect(released[0]?.lease.status).toBe("released");
|
||||
} finally {
|
||||
if (previousCandidates === undefined) {
|
||||
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
} else {
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
|
||||
}
|
||||
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -552,7 +531,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(released).toHaveLength(1);
|
||||
expect(released[0]?.lease.status).toBe("released");
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything(), 31000);
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything());
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything(), 31234);
|
||||
});
|
||||
|
||||
it("uses resolved secret-ref config for plugin-backed sandbox execute and release", async () => {
|
||||
@@ -576,6 +555,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driver: "sandbox",
|
||||
config: providerConfig,
|
||||
};
|
||||
await secretService(db).createBinding({
|
||||
companyId,
|
||||
secretId: apiSecret.id,
|
||||
targetType: "environment",
|
||||
targetId: environment.id,
|
||||
configPath: "apiKey",
|
||||
});
|
||||
await environmentService(db).update(environment.id, {
|
||||
driver: "sandbox",
|
||||
name: environment.name,
|
||||
@@ -696,7 +682,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
config: expect.objectContaining({
|
||||
apiKey: "resolved-provider-key",
|
||||
}),
|
||||
}));
|
||||
}), 31234);
|
||||
});
|
||||
|
||||
it("waits briefly for a ready sandbox provider plugin worker to come online", async () => {
|
||||
@@ -788,7 +774,104 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
|
||||
expect(acquired.lease.providerLeaseId).toBe("sandbox-1");
|
||||
expect(workerManager.isRunning).toHaveBeenCalledTimes(3);
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.anything());
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.anything(), 31234);
|
||||
});
|
||||
|
||||
it("extends plugin-backed sandbox lease RPC timeouts from provider config", async () => {
|
||||
const pluginId = randomUUID();
|
||||
const { companyId, environment: baseEnvironment, runId } = await seedEnvironment();
|
||||
const providerConfig = {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:test",
|
||||
timeoutMs: 1_234,
|
||||
bridgeRequestTimeoutMs: 40_000,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
...baseEnvironment,
|
||||
name: "Long Lease Plugin Sandbox",
|
||||
driver: "sandbox",
|
||||
config: providerConfig,
|
||||
};
|
||||
await environmentService(db).update(environment.id, {
|
||||
driver: "sandbox",
|
||||
name: environment.name,
|
||||
config: providerConfig,
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "acme.long-lease-sandbox-provider",
|
||||
packageName: "@acme/long-lease-sandbox-provider",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: "acme.long-lease-sandbox-provider",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Long Lease Sandbox Provider",
|
||||
description: "Test plugin worker acquire timeout",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Plugin",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const workerManager = {
|
||||
isRunning: vi.fn((id: string) => id === pluginId),
|
||||
call: vi.fn(async (_pluginId: string, method: string) => {
|
||||
if (method === "environmentAcquireLease") {
|
||||
return {
|
||||
providerLeaseId: "sandbox-1",
|
||||
metadata: {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:test",
|
||||
timeoutMs: 1_234,
|
||||
bridgeRequestTimeoutMs: 40_000,
|
||||
reuseLease: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected plugin method: ${method}`);
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
const acquired = await runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
heartbeatRunId: runId,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
|
||||
expect(acquired.lease.providerLeaseId).toBe("sandbox-1");
|
||||
expect(workerManager.call).toHaveBeenCalledWith(
|
||||
pluginId,
|
||||
"environmentAcquireLease",
|
||||
expect.objectContaining({
|
||||
driverKey: "fake-plugin",
|
||||
config: {
|
||||
image: "fake:test",
|
||||
timeoutMs: 1_234,
|
||||
bridgeRequestTimeoutMs: 40_000,
|
||||
reuseLease: false,
|
||||
},
|
||||
}),
|
||||
70_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to acquire when plugin-backed sandbox lease resume throws", async () => {
|
||||
@@ -898,7 +981,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentResumeLease", expect.objectContaining({
|
||||
driverKey: "fake-plugin",
|
||||
providerLeaseId: "stale-plugin-lease",
|
||||
}));
|
||||
}), 31234);
|
||||
expect(workerManager.call).toHaveBeenNthCalledWith(2, pluginId, "environmentAcquireLease", expect.objectContaining({
|
||||
driverKey: "fake-plugin",
|
||||
config: {
|
||||
@@ -907,7 +990,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
reuseLease: true,
|
||||
},
|
||||
runId,
|
||||
}));
|
||||
}), 31234);
|
||||
});
|
||||
|
||||
it("releases a sandbox run lease from metadata after the environment config changes", async () => {
|
||||
@@ -1022,6 +1105,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driverKey: "fake-plugin",
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: { template: "base" },
|
||||
runId,
|
||||
workspaceMode: undefined,
|
||||
@@ -1057,6 +1141,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driverKey: "fake-plugin",
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: {},
|
||||
providerLeaseId: "plugin-lease-1",
|
||||
leaseMetadata: expect.objectContaining({
|
||||
@@ -1215,6 +1300,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driverKey: "fake-plugin",
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: { template: "base" },
|
||||
providerLeaseId: "plugin-lease-full",
|
||||
leaseMetadata: expect.objectContaining({
|
||||
@@ -1245,6 +1331,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driverKey: "fake-plugin",
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
issueId: null,
|
||||
config: { template: "base" },
|
||||
providerLeaseId: "plugin-lease-full",
|
||||
leaseMetadata: expect.objectContaining({
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const readline = require("node:readline");
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
const message = JSON.parse(line);
|
||||
const method = message && typeof message.method === "string" ? message.method : null;
|
||||
|
||||
if (method === "initialize") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["environmentExecute"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "environmentExecute") {
|
||||
const delayMs = Number(message.params?.delayMs ?? 0);
|
||||
setTimeout(() => {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "ok\n",
|
||||
stderr: "",
|
||||
},
|
||||
});
|
||||
}, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "shutdown") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {},
|
||||
});
|
||||
setImmediate(() => process.exit(0));
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Unhandled method: ${method}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const readline = require("node:readline");
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
const message = JSON.parse(line);
|
||||
const method = message && typeof message.method === "string" ? message.method : null;
|
||||
|
||||
if (method === "initialize") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["environmentExecute"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "environmentExecute") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32002,
|
||||
message: "[unknown] terminated",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "shutdown") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {},
|
||||
});
|
||||
setImmediate(() => process.exit(0));
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Unhandled method: ${method}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -131,4 +131,57 @@ describe("gemini_local environment diagnostics", () => {
|
||||
expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true);
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("trusts remote sandbox workspaces during the hello probe", async () => {
|
||||
let probeEnv: Record<string, string> | undefined;
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: "gemini",
|
||||
},
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "cloudflare",
|
||||
remoteCwd: "/workspace/paperclip",
|
||||
runner: {
|
||||
execute: async (input) => {
|
||||
if (input.command === "gemini") {
|
||||
probeEnv = input.env;
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}),
|
||||
JSON.stringify({ type: "result", subtype: "success", result: "hello" }),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
environmentName: "QA Cloudflare",
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "gemini_hello_probe_passed")).toBe(true);
|
||||
expect(probeEnv?.GEMINI_CLI_TRUST_WORKSPACE).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server";
|
||||
import {
|
||||
isGeminiTurnLimitResult,
|
||||
isGeminiUnknownSessionError,
|
||||
parseGeminiJsonl,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
|
||||
describe("gemini_local parser", () => {
|
||||
it("extracts session, summary, usage, cost, and terminal error message", () => {
|
||||
it("extracts session, summary, usage, cost, and terminal error message from v0.38 stream-json output", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "output_text", text: "hello" }],
|
||||
},
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
status: "success",
|
||||
session_id: "gemini-session-1",
|
||||
usage: {
|
||||
promptTokenCount: 12,
|
||||
cachedContentTokenCount: 3,
|
||||
candidatesTokenCount: 7,
|
||||
stats: {
|
||||
input_tokens: 12,
|
||||
cached_input_tokens: 3,
|
||||
output_tokens: 7,
|
||||
},
|
||||
total_cost_usd: 0.00123,
|
||||
result: "done",
|
||||
}),
|
||||
JSON.stringify({ type: "error", message: "model access denied" }),
|
||||
].join("\n");
|
||||
@@ -79,45 +81,56 @@ describe("gemini_local stale session detection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local turn-limit detection", () => {
|
||||
it("detects structured turn-limit signals and exit code 53", () => {
|
||||
expect(isGeminiTurnLimitResult({ status: "turn_limit" })).toBe(true);
|
||||
expect(isGeminiTurnLimitResult({ stopReason: "max_turns_exhausted" })).toBe(true);
|
||||
expect(isGeminiTurnLimitResult(null, 53)).toBe(true);
|
||||
});
|
||||
|
||||
it("checks every structured stop field for turn-limit exhaustion", () => {
|
||||
expect(
|
||||
isGeminiTurnLimitResult({
|
||||
status: "success",
|
||||
stopReason: "turn_limit_exhausted",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not detect turn-limit exhaustion from unstructured error text", () => {
|
||||
expect(isGeminiTurnLimitResult({ error: "max_turns reached" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local ui stdout parser", () => {
|
||||
it("parses assistant, thinking, and result events", () => {
|
||||
it("parses v0.38 assistant message and result events", () => {
|
||||
const ts = "2026-03-08T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseGeminiStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I checked the repo." },
|
||||
{ type: "thinking", text: "Reviewing adapter registry" },
|
||||
{ type: "tool_call", name: "shell", input: { command: "ls -1" } },
|
||||
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
|
||||
],
|
||||
},
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "I checked the repo.",
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "assistant", ts, text: "I checked the repo." },
|
||||
{ kind: "thinking", ts, text: "Reviewing adapter registry" },
|
||||
{ kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
|
||||
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseGeminiStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done",
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
cachedContentTokenCount: 2,
|
||||
status: "success",
|
||||
text: "Done",
|
||||
stats: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cached_input_tokens: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
is_error: false,
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
@@ -143,7 +156,7 @@ function stripAnsi(value: string): string {
|
||||
}
|
||||
|
||||
describe("gemini_local cli formatter", () => {
|
||||
it("prints init, assistant, result, and error events", () => {
|
||||
it("prints init, v0.38 assistant, result, and error events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let joined = "";
|
||||
|
||||
@@ -154,19 +167,20 @@ describe("gemini_local cli formatter", () => {
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
cachedContentTokenCount: 2,
|
||||
status: "success",
|
||||
stats: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cached_input_tokens: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
}),
|
||||
|
||||
@@ -39,6 +39,35 @@ console.log(JSON.stringify({
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
async function writeFailingGeminiCommand(
|
||||
commandPath: string,
|
||||
options: {
|
||||
stdoutLines?: Array<Record<string, unknown>>;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const stdoutLines = options.stdoutLines ?? [];
|
||||
const stdout = options.stdout ?? "";
|
||||
const stderr = options.stderr ?? "";
|
||||
const exit = options.exitCode ?? 1;
|
||||
const script = `#!/usr/bin/env node
|
||||
for (const line of ${JSON.stringify(stdoutLines.map((line) => JSON.stringify(line)))}) {
|
||||
console.log(line);
|
||||
}
|
||||
if (${JSON.stringify(stdout)}) {
|
||||
process.stdout.write(${JSON.stringify(stdout)});
|
||||
}
|
||||
if (${JSON.stringify(stderr)}) {
|
||||
console.error(${JSON.stringify(stderr)});
|
||||
}
|
||||
process.exit(${exit});
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
paperclipEnvKeys: string[];
|
||||
@@ -169,6 +198,144 @@ describe("gemini execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes turn-limit exhaustion into scheduler stop metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turns-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFailingGeminiCommand(commandPath, {
|
||||
stdoutLines: [
|
||||
{
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
session_id: "gemini-session-1",
|
||||
status: "turn_limit",
|
||||
error: "Turn limit reached.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-turn-limit",
|
||||
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
},
|
||||
context: {},
|
||||
authToken: "t",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("max_turns_exhausted");
|
||||
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
|
||||
expect(result.clearSession).toBe(true);
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes Gemini exit code 53 as max-turn exhaustion", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-exit-53-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFailingGeminiCommand(commandPath, {
|
||||
stderr: "Gemini stopped because the max turns limit was reached.",
|
||||
exitCode: 53,
|
||||
});
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-exit-53",
|
||||
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
},
|
||||
context: {},
|
||||
authToken: "t",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(53);
|
||||
expect(result.errorCode).toBe("max_turns_exhausted");
|
||||
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
|
||||
expect(result.clearSession).toBe(true);
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not normalize unstructured turn-limit text into scheduler stop metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turn-text-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFailingGeminiCommand(commandPath, {
|
||||
stdoutLines: [
|
||||
{
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
session_id: "gemini-session-1",
|
||||
error: "Tool output said: maximum turns reached.",
|
||||
},
|
||||
],
|
||||
stdout: "attacker-controlled transcript mentions turn limit reached\n",
|
||||
stderr: "Gemini stopped because the max turns limit was reached.",
|
||||
});
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-turn-limit-text",
|
||||
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
},
|
||||
context: {},
|
||||
authToken: "t",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).not.toBe("max_turns_exhausted");
|
||||
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
|
||||
expect(result.clearSession).toBe(false);
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
@@ -208,6 +208,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
expect(evaluations[0]).toMatchObject({
|
||||
priority: "medium",
|
||||
assigneeAgentId: managerId,
|
||||
assigneeAdapterOverrides: { modelProfile: "cheap" },
|
||||
originId: runId,
|
||||
originFingerprint: `stale_active_run:${companyId}:${runId}`,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
|
||||
|
||||
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
|
||||
@@ -543,8 +544,24 @@ describe("heartbeat comment wake batching", () => {
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorType: "user",
|
||||
authorUserId: "user-1",
|
||||
body: "Queued follow-up",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
rows: [
|
||||
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
@@ -577,7 +594,15 @@ describe("heartbeat comment wake batching", () => {
|
||||
comments: [
|
||||
expect.objectContaining({
|
||||
id: queuedComment.id,
|
||||
authorType: "user",
|
||||
body: "Queued follow-up",
|
||||
presentation: expect.objectContaining({
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
}),
|
||||
metadata: expect.objectContaining({
|
||||
version: 1,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
commentWindow: {
|
||||
@@ -1130,6 +1155,7 @@ describe("heartbeat comment wake batching", () => {
|
||||
expect(payloads).toHaveLength(2);
|
||||
expect(runs[1]?.contextSnapshot).toMatchObject({
|
||||
retryReason: "missing_issue_comment",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
@@ -1329,8 +1355,9 @@ describe("heartbeat comment wake batching", () => {
|
||||
eq(agentWakeupRequests.agentId, primaryAgentId),
|
||||
eq(agentWakeupRequests.reason, "missing_issue_comment"),
|
||||
),
|
||||
);
|
||||
);
|
||||
expect(missingCommentRetries).toHaveLength(1);
|
||||
expect(missingCommentRetries[0]?.payload).toMatchObject({ modelProfile: "cheap" });
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
@@ -1566,7 +1593,8 @@ describe("heartbeat comment wake batching", () => {
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied";
|
||||
const sourceRun = runs.find((run) => run.id === firstRun?.id);
|
||||
return sourceRun?.status === "succeeded" && sourceRun.issueCommentStatus === "satisfied";
|
||||
});
|
||||
|
||||
const runs = await db
|
||||
@@ -1574,9 +1602,26 @@ describe("heartbeat comment wake batching", () => {
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.issueCommentStatus).toBe("satisfied");
|
||||
expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull();
|
||||
const sourceRun = runs.find((run) => run.id === firstRun?.id);
|
||||
expect(sourceRun?.issueCommentStatus).toBe("satisfied");
|
||||
expect(sourceRun?.issueCommentSatisfiedByCommentId).not.toBeNull();
|
||||
|
||||
await waitFor(async () => {
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId));
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
|
||||
|
||||
const hasHandoffComment = comments.some((comment) =>
|
||||
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
|
||||
);
|
||||
const hasHandoffWake = wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff");
|
||||
return hasHandoffComment && hasHandoffWake;
|
||||
});
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
@@ -1584,16 +1629,19 @@ describe("heartbeat comment wake batching", () => {
|
||||
.where(eq(issueComments.issueId, issueId))
|
||||
.orderBy(asc(issueComments.createdAt));
|
||||
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toBe("Manual completion comment from the run.");
|
||||
expect(comments[0]?.createdByRunId).toBe(firstRun?.id);
|
||||
expect(comments.some((comment) => comment.body === "Manual completion comment from the run.")).toBe(true);
|
||||
expect(comments.some((comment) =>
|
||||
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
|
||||
)).toBe(true);
|
||||
expect(comments.every((comment) => !comment.body.startsWith("## Run summary"))).toBe(true);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
|
||||
|
||||
expect(wakeups).toHaveLength(1);
|
||||
expect(wakeups.some((wakeup) => wakeup.reason === "missing_issue_comment")).toBe(false);
|
||||
expect(wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff")).toBe(true);
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
|
||||
@@ -1,9 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPaperclipTaskMarkdown,
|
||||
mergeCoalescedContextSnapshot,
|
||||
summarizeHeartbeatRunContextSnapshot,
|
||||
summarizeHeartbeatRunListResultJson,
|
||||
} from "../services/heartbeat.js";
|
||||
|
||||
describe("buildPaperclipTaskMarkdown", () => {
|
||||
it("adds planning directives for assignment and comment task context", () => {
|
||||
const assignment = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(assignment).toContain("- Work mode: \"planning\"");
|
||||
expect(assignment).toContain("Make the plan only. Do not write code or perform implementation work.");
|
||||
|
||||
const commentWake = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
wakeComment: {
|
||||
id: "comment-1",
|
||||
body: "Please revise the plan.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work.");
|
||||
|
||||
const acceptedConfirmation = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
interaction: {
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
expect(acceptedConfirmation).toContain("Create child issues from the approved plan only");
|
||||
expect(acceptedConfirmation).not.toContain("Make the plan only.");
|
||||
});
|
||||
|
||||
it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => {
|
||||
const commentWake = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
wakeComment: {
|
||||
id: "comment-1",
|
||||
body: "Please revise the plan.",
|
||||
},
|
||||
interaction: {
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work.");
|
||||
expect(commentWake).not.toContain("Create child issues from the approved plan only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeCoalescedContextSnapshot", () => {
|
||||
it("clears stale accepted-plan interaction state when merging a later ordinary comment wake", () => {
|
||||
const merged = mergeCoalescedContextSnapshot(
|
||||
{
|
||||
issueId: "issue-1",
|
||||
interactionId: "interaction-1",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
{
|
||||
issueId: "issue-1",
|
||||
commentId: "comment-1",
|
||||
wakeCommentId: "comment-1",
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.interactionId).toBeUndefined();
|
||||
expect(merged.interactionKind).toBeUndefined();
|
||||
expect(merged.interactionStatus).toBeUndefined();
|
||||
expect(merged.continuationPolicy).toBeUndefined();
|
||||
expect(merged.commentId).toBe("comment-1");
|
||||
expect(merged.wakeCommentId).toBe("comment-1");
|
||||
});
|
||||
|
||||
it("preserves accepted-plan interaction state for the interaction wake itself", () => {
|
||||
const merged = mergeCoalescedContextSnapshot(
|
||||
{
|
||||
issueId: "issue-1",
|
||||
},
|
||||
{
|
||||
issueId: "issue-1",
|
||||
interactionId: "interaction-1",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.interactionId).toBe("interaction-1");
|
||||
expect(merged.interactionKind).toBe("request_confirmation");
|
||||
expect(merged.interactionStatus).toBe("accepted");
|
||||
expect(merged.continuationPolicy).toBe("wake_assignee_on_accept");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeHeartbeatRunContextSnapshot", () => {
|
||||
it("keeps only the small retry/linking fields needed by the client", () => {
|
||||
const summarized = summarizeHeartbeatRunContextSnapshot({
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
environmentLeases,
|
||||
environments,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
@@ -122,6 +124,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(issueComments);
|
||||
@@ -137,6 +140,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(environments);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -280,6 +285,23 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
unresolvedBlockerIssueIds: [blockerId],
|
||||
});
|
||||
|
||||
let finishReadyRun!: () => void;
|
||||
const readyRunCanFinish = new Promise<void>((resolve) => {
|
||||
finishReadyRun = resolve;
|
||||
});
|
||||
mockAdapterExecute.mockImplementationOnce(async () => {
|
||||
await readyRunCanFinish;
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Ready dependency scheduling run complete.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
|
||||
const readyWake = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
@@ -288,6 +310,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
contextSnapshot: { issueId: readyIssueId, wakeReason: "issue_assigned" },
|
||||
});
|
||||
expect(readyWake).not.toBeNull();
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: readyIssueId,
|
||||
authorAgentId: agentId,
|
||||
authorType: "agent",
|
||||
createdByRunId: readyWake!.id,
|
||||
body: "Ready dependency scheduling run complete.",
|
||||
});
|
||||
finishReadyRun();
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
@@ -354,6 +385,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
|
||||
expect(promotedBlockedRun?.status).toBe("succeeded");
|
||||
expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const noActiveRuns = await waitForCondition(async () => {
|
||||
const rows = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns);
|
||||
return rows.every((run) => run.status !== "queued" && run.status !== "running");
|
||||
}, 10_000);
|
||||
expect(noActiveRuns).toBe(true);
|
||||
});
|
||||
|
||||
it("honors maxConcurrentRuns 1 by leaving a second assignment wake queued", async () => {
|
||||
@@ -429,6 +468,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
contextSnapshot: { issueId: firstIssueId, wakeReason: "issue_assigned" },
|
||||
});
|
||||
expect(firstWake).not.toBeNull();
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: firstIssueId,
|
||||
authorAgentId: agentId,
|
||||
authorType: "agent",
|
||||
createdByRunId: firstWake!.id,
|
||||
body: "First assignment run completed.",
|
||||
});
|
||||
|
||||
const firstRunStarted = await waitForCondition(async () => {
|
||||
const run = await db
|
||||
@@ -439,7 +486,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
return run?.status === "running";
|
||||
});
|
||||
expect(firstRunStarted).toBe(true);
|
||||
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1);
|
||||
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000);
|
||||
expect(firstAdapterStarted).toBe(true);
|
||||
|
||||
const secondWake = await heartbeat.wakeup(agentId, {
|
||||
@@ -450,6 +497,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
contextSnapshot: { issueId: secondIssueId, wakeReason: "issue_assigned" },
|
||||
});
|
||||
expect(secondWake).not.toBeNull();
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: secondIssueId,
|
||||
authorAgentId: agentId,
|
||||
authorType: "agent",
|
||||
createdByRunId: secondWake!.id,
|
||||
body: "Second assignment run completed.",
|
||||
});
|
||||
|
||||
const secondRunWhileFirstRunning = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
@@ -461,6 +516,16 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
|
||||
finishFirstRun();
|
||||
|
||||
const firstRunSucceeded = await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, firstWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "succeeded";
|
||||
});
|
||||
expect(firstRunSucceeded).toBe(true);
|
||||
|
||||
const secondRunSucceeded = await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
@@ -470,11 +535,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
return run?.status === "succeeded";
|
||||
});
|
||||
expect(secondRunSucceeded).toBe(true);
|
||||
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
|
||||
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
finishFirstRun();
|
||||
}
|
||||
});
|
||||
}, 40_000);
|
||||
|
||||
it("cancels stale queued runs when issue blockers are still unresolved", async () => {
|
||||
const companyId = randomUUID();
|
||||
@@ -598,6 +663,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
.update(agentWakeupRequests)
|
||||
.set({ runId: readyRunId })
|
||||
.where(eq(agentWakeupRequests.id, readyWakeupRequestId));
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: readyIssueId,
|
||||
authorAgentId: agentId,
|
||||
authorType: "agent",
|
||||
createdByRunId: readyRunId,
|
||||
body: "Ready queued run completed.",
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
@@ -665,7 +738,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
executionLockedAt: null,
|
||||
});
|
||||
expect(readyRun?.status).toBe("succeeded");
|
||||
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
|
||||
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {
|
||||
|
||||
@@ -117,7 +117,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function seedBlockedChain(opts: { outsideLookback?: boolean } = {}) {
|
||||
async function seedBlockedChain(opts: {
|
||||
outsideLookback?: boolean;
|
||||
blockerStatus?: string;
|
||||
blockerAssigneeAgentId?: "coder" | "manager" | null;
|
||||
} = {}) {
|
||||
const companyId = randomUUID();
|
||||
const managerId = randomUUID();
|
||||
const coderId = randomUUID();
|
||||
@@ -178,8 +182,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
id: blockerIssueId,
|
||||
companyId,
|
||||
title: "Missing unblock owner",
|
||||
status: "todo",
|
||||
status: opts.blockerStatus ?? "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: opts.blockerAssigneeAgentId === "coder"
|
||||
? coderId
|
||||
: opts.blockerAssigneeAgentId === "manager"
|
||||
? managerId
|
||||
: null,
|
||||
issueNumber: 2,
|
||||
identifier: `${issuePrefix}-2`,
|
||||
createdAt: issueTimestamp,
|
||||
@@ -283,6 +292,46 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it("creates one bounded escalation for an assigned backlog blocker leaf", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({
|
||||
blockerStatus: "backlog",
|
||||
blockerAssigneeAgentId: "coder",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const first = await heartbeat.reconcileIssueGraphLiveness();
|
||||
const second = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(first.findings).toBe(1);
|
||||
expect(first.escalationsCreated).toBe(1);
|
||||
expect(second.findings).toBe(0);
|
||||
expect(second.escalationsCreated).toBe(0);
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(1);
|
||||
expect(escalations[0]).toMatchObject({
|
||||
parentId: blockerIssueId,
|
||||
assigneeAgentId: coderId,
|
||||
originId: [
|
||||
"harness_liveness",
|
||||
companyId,
|
||||
blockedIssueId,
|
||||
"blocked_by_assigned_backlog_issue",
|
||||
blockerIssueId,
|
||||
].join(":"),
|
||||
originFingerprint: [
|
||||
"harness_liveness_leaf",
|
||||
companyId,
|
||||
"blocked_by_assigned_backlog_issue",
|
||||
blockerIssueId,
|
||||
].join(":"),
|
||||
});
|
||||
});
|
||||
|
||||
it("creates one manager escalation, preserves blockers, and records owner selection", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
@@ -320,6 +369,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
expect(escalations[0]).toMatchObject({
|
||||
parentId: blockerIssueId,
|
||||
assigneeAgentId: managerId,
|
||||
assigneeAdapterOverrides: { modelProfile: "cheap" },
|
||||
status: expect.stringMatching(/^(todo|in_progress|done)$/),
|
||||
originFingerprint: [
|
||||
"harness_liveness_leaf",
|
||||
@@ -568,6 +618,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
assigneeAgentId: managerId,
|
||||
assigneeAdapterOverrides: { modelProfile: "cheap" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
companies,
|
||||
createDb,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
issues,
|
||||
plugins,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts";
|
||||
|
||||
const adapterExecute = vi.hoisted(() => vi.fn(async () => ({
|
||||
@@ -35,6 +38,7 @@ vi.mock("../adapters/index.js", () => ({
|
||||
execute: adapterExecute,
|
||||
supportsLocalAgentJwt: false,
|
||||
}),
|
||||
listAdapterModelProfiles: async () => [],
|
||||
runningProcesses: new Map(),
|
||||
}));
|
||||
|
||||
@@ -67,6 +71,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$client.end();
|
||||
await stopDb?.();
|
||||
});
|
||||
|
||||
@@ -76,6 +81,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
const workspaceId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
const pluginKey = `acme.environments.${pluginId}`;
|
||||
const agentId = randomUUID();
|
||||
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-heartbeat-"));
|
||||
tempRoots.push(workspaceRoot);
|
||||
@@ -100,6 +106,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -124,13 +131,13 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "acme.environments",
|
||||
pluginKey,
|
||||
packageName: "@acme/paperclip-environments",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: "acme.environments",
|
||||
id: pluginKey,
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Acme Environments",
|
||||
@@ -157,8 +164,8 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
name: "Plugin Sandbox",
|
||||
driver: "plugin",
|
||||
status: "active",
|
||||
config: {
|
||||
pluginKey: "acme.environments",
|
||||
config: {
|
||||
pluginKey,
|
||||
driverKey: "sandbox",
|
||||
driverConfig: {
|
||||
template: "base",
|
||||
@@ -199,6 +206,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
driverKey: "sandbox",
|
||||
companyId,
|
||||
environmentId,
|
||||
issueId: null,
|
||||
config: { template: "base" },
|
||||
runId: run!.id,
|
||||
workspaceMode: "shared_workspace",
|
||||
@@ -208,16 +216,217 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
driverKey: "sandbox",
|
||||
companyId,
|
||||
environmentId,
|
||||
issueId: null,
|
||||
config: { template: "base" },
|
||||
providerLeaseId: "plugin-heartbeat-lease",
|
||||
leaseMetadata: expect.objectContaining({
|
||||
driver: "plugin",
|
||||
pluginId,
|
||||
pluginKey: "acme.environments",
|
||||
pluginKey,
|
||||
driverKey: "sandbox",
|
||||
}),
|
||||
});
|
||||
}, { timeout: 5_000 });
|
||||
expect(adapterExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("ignores stale non-reused workspace environment config in favor of the issue selection", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const oldEnvironmentId = randomUUID();
|
||||
const newEnvironmentId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
const pluginKey = `acme.environments.${pluginId}`;
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const staleExecutionWorkspaceId = randomUUID();
|
||||
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-issue-"));
|
||||
tempRoots.push(workspaceRoot);
|
||||
const workerManager = {
|
||||
isRunning: vi.fn((id: string) => id === pluginId),
|
||||
call: vi.fn(async (_pluginId: string, method: string, payload: Record<string, unknown>) => {
|
||||
if (method === "environmentAcquireLease") {
|
||||
return {
|
||||
providerLeaseId: `plugin-heartbeat-lease-${String(payload.environmentId)}`,
|
||||
metadata: {
|
||||
remoteCwd: `/workspace/${String(payload.environmentId)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "environmentReleaseLease") {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(`Unexpected plugin environment method: ${method}`);
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
|
||||
await instanceSettingsService(db).updateExperimental({
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
});
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Plugin Environment Issue",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceRoot,
|
||||
isPrimary: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey,
|
||||
packageName: "@acme/paperclip-environments",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: pluginKey,
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Acme Environments",
|
||||
description: "Test plugin environment driver",
|
||||
author: "Acme",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "sandbox",
|
||||
displayName: "Sandbox",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
await db.insert(environments).values([
|
||||
{
|
||||
id: oldEnvironmentId,
|
||||
companyId,
|
||||
name: "QA SSH",
|
||||
driver: "plugin",
|
||||
status: "active",
|
||||
config: {
|
||||
pluginKey,
|
||||
driverKey: "sandbox",
|
||||
driverConfig: {
|
||||
template: "old",
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: newEnvironmentId,
|
||||
companyId,
|
||||
name: "QA E2B",
|
||||
driver: "plugin",
|
||||
status: "active",
|
||||
config: {
|
||||
pluginKey,
|
||||
driverKey: "sandbox",
|
||||
driverConfig: {
|
||||
template: "new",
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: oldEnvironmentId,
|
||||
permissions: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: staleExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: workspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Stale workspace",
|
||||
status: "active",
|
||||
cwd: workspaceRoot,
|
||||
providerType: "local_fs",
|
||||
providerRef: workspaceRoot,
|
||||
metadata: {
|
||||
config: {
|
||||
environmentId: oldEnvironmentId,
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: workspaceId,
|
||||
title: "Environment matrix: e2b / codex_local",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
executionWorkspaceId: staleExecutionWorkspaceId,
|
||||
executionWorkspaceSettings: {
|
||||
mode: "shared_workspace",
|
||||
environmentId: newEnvironmentId,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "manual",
|
||||
contextSnapshot: { issueId },
|
||||
});
|
||||
|
||||
expect(run).not.toBeNull();
|
||||
await vi.waitFor(async () => {
|
||||
const latest = await heartbeat.getRun(run!.id);
|
||||
expect(latest?.status).toBe("succeeded");
|
||||
}, { timeout: 5_000 });
|
||||
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", {
|
||||
driverKey: "sandbox",
|
||||
companyId,
|
||||
environmentId: newEnvironmentId,
|
||||
issueId,
|
||||
config: { template: "new" },
|
||||
runId: run!.id,
|
||||
workspaceMode: "shared_workspace",
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
issueRelations,
|
||||
issueTreeHoldMembers,
|
||||
issueTreeHolds,
|
||||
issueWorkProducts,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -69,7 +70,15 @@ vi.mock("../adapters/index.ts", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import {
|
||||
heartbeatService,
|
||||
redactDetectedSuccessfulRunProgressSummaryForBoard,
|
||||
} from "../services/heartbeat.ts";
|
||||
import {
|
||||
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
} from "../services/recovery/index.ts";
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
@@ -313,6 +322,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await db.delete(costEvents);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(environments);
|
||||
await db.delete(issueWorkProducts);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
@@ -709,6 +719,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
originId: input.issueId,
|
||||
originRunId: input.runId,
|
||||
priority: "medium",
|
||||
assigneeAdapterOverrides: { modelProfile: "cheap" },
|
||||
});
|
||||
expect(recovery.title).toContain("Recover stalled issue");
|
||||
expect(recovery.description).toContain(`Previous source status: \`${input.previousStatus}\``);
|
||||
@@ -743,6 +754,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
companyId: input.companyId,
|
||||
reason: "issue_assigned",
|
||||
source: "assignment",
|
||||
payload: expect.objectContaining({ modelProfile: "cheap" }),
|
||||
});
|
||||
|
||||
const recoveryRun = recoveryWakeup?.runId
|
||||
@@ -758,6 +770,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
source: "stranded_issue_recovery",
|
||||
sourceIssueId: input.issueId,
|
||||
strandedRunId: input.runId,
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
|
||||
return recovery;
|
||||
@@ -915,6 +928,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(retryRun?.status).toBe("queued");
|
||||
expect(retryRun?.retryOfRunId).toBe(runId);
|
||||
expect(retryRun?.processLossRetryCount).toBe(1);
|
||||
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
|
||||
|
||||
const issue = await db
|
||||
.select()
|
||||
@@ -1227,7 +1241,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect((failedRun?.resultJson as Record<string, unknown> | null)?.errorFamily).toBe("transient_upstream");
|
||||
expect(retryRun?.status).toBe("scheduled_retry");
|
||||
expect(retryRun?.scheduledRetryReason).toBe("transient_failure");
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode).toBe("same_session");
|
||||
expect(retryRun?.contextSnapshot).toMatchObject({
|
||||
codexTransientFallbackMode: "same_session",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
|
||||
const issue = await db
|
||||
.select()
|
||||
@@ -1241,6 +1258,448 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(comments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
createdByRunId: ctx.runId,
|
||||
body: "Implemented the backend detector, but did not choose a final issue state.",
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Implemented the backend detector, but did not choose a final issue state.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForRunToSettle(heartbeat, runId, 5_000);
|
||||
|
||||
const handoffWakeups = await waitForValue(async () => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId));
|
||||
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
|
||||
return matches.length > 0 ? matches : null;
|
||||
}, 5_000);
|
||||
await waitForHeartbeatIdle(db, 5_000);
|
||||
|
||||
expect(handoffWakeups).toHaveLength(1);
|
||||
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
|
||||
expect(handoffWakeups[0]?.payload).toMatchObject({
|
||||
issueId,
|
||||
sourceRunId: runId,
|
||||
handoffRequired: true,
|
||||
handoffReason: "successful_run_missing_state",
|
||||
handoffAttempt: 1,
|
||||
maxHandoffAttempts: 1,
|
||||
resumeIntent: true,
|
||||
resumeFromRunId: runId,
|
||||
});
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
|
||||
expect(handoffComment).toBeTruthy();
|
||||
expect(handoffComment?.authorType).toBe("system");
|
||||
expect(handoffComment?.presentation).toMatchObject({
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
detailsDefaultOpen: false,
|
||||
});
|
||||
expect(handoffComment?.metadata).toMatchObject({
|
||||
version: 1,
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Required action",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Run evidence",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "run_link", runId }),
|
||||
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId));
|
||||
expect(activity.some((event) => event.action === "issue.successful_run_handoff_required")).toBe(true);
|
||||
});
|
||||
|
||||
it("requeues a missing-disposition handoff when the previous corrective wake was cancelled", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
const idempotencyKey = `finish_successful_run_handoff:${issueId}:${runId}:1`;
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "finish_successful_run_handoff",
|
||||
payload: {
|
||||
issueId,
|
||||
sourceRunId: runId,
|
||||
handoffRequired: true,
|
||||
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
},
|
||||
status: "cancelled",
|
||||
idempotencyKey,
|
||||
requestedAt: new Date("2026-03-19T00:00:01.000Z"),
|
||||
finishedAt: new Date("2026-03-19T00:00:02.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:00:02.000Z"),
|
||||
});
|
||||
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
createdByRunId: ctx.runId,
|
||||
body: "Implemented recovery handling, but did not choose a final issue state.",
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Implemented recovery handling, but did not choose a final issue state.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForRunToSettle(heartbeat, runId, 5_000);
|
||||
|
||||
const handoffWakeups = await waitForValue(async () => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey));
|
||||
const requeued = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
|
||||
return requeued.length > 1 ? requeued : null;
|
||||
}, 5_000);
|
||||
await waitForHeartbeatIdle(db, 5_000);
|
||||
|
||||
expect(handoffWakeups).toHaveLength(2);
|
||||
expect(handoffWakeups.filter((wakeup) => wakeup.status === "cancelled")).toHaveLength(1);
|
||||
expect(handoffWakeups.some((wakeup) => wakeup.status !== "cancelled")).toBe(true);
|
||||
});
|
||||
|
||||
it("queues one missing-disposition handoff for artifact-producing successful runs left in progress", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
|
||||
const documentId = randomUUID();
|
||||
const revisionId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
createdByRunId: ctx.runId,
|
||||
body: "Drafted the Phase 3 test plan but did not choose a final issue disposition.",
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Regression test plan",
|
||||
format: "markdown",
|
||||
latestBody: "# Regression test plan\n\n- Cover artifact-producing successful runs",
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: agentId,
|
||||
updatedByAgentId: agentId,
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: revisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Regression test plan",
|
||||
format: "markdown",
|
||||
body: "# Regression test plan\n\n- Cover artifact-producing successful runs",
|
||||
createdByAgentId: agentId,
|
||||
createdByRunId: ctx.runId,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(issueWorkProducts).values({
|
||||
companyId,
|
||||
issueId,
|
||||
type: "report",
|
||||
provider: "test",
|
||||
externalId: "phase-3-report",
|
||||
title: "Phase 3 regression notes",
|
||||
status: "ready",
|
||||
summary: "Successful run produced a visible artifact.",
|
||||
createdByRunId: ctx.runId,
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Created comments, a plan document, and a work product without choosing a disposition.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const settledRun = await waitForRunToSettle(heartbeat, runId, 5_000);
|
||||
|
||||
const handoffWakeups = await waitForValue(async () => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId));
|
||||
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
|
||||
return matches.length > 0 ? matches : null;
|
||||
}, 5_000);
|
||||
await waitForHeartbeatIdle(db, 5_000);
|
||||
const classifiedRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(classifiedRun?.status ?? settledRun?.status).toBe("succeeded");
|
||||
expect(classifiedRun?.livenessState).toBe("advanced");
|
||||
expect(handoffWakeups).toHaveLength(1);
|
||||
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("in_progress");
|
||||
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments.filter((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toHaveLength(1);
|
||||
expect(comments.some((comment) => comment.body.startsWith("Drafted the Phase 3 test plan"))).toBe(true);
|
||||
|
||||
const workProducts = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId));
|
||||
expect(workProducts).toHaveLength(1);
|
||||
const recoveryIssues = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
|
||||
expect(recoveryIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("redacts secret-bearing successful-run detected progress before handoff disclosure", async () => {
|
||||
const { agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
const bearerSecret = "live-bearer-token-value";
|
||||
const apiKeySecret = "sk-testsuccessfulhandoffsecret";
|
||||
const redactedDetectedSummary = redactDetectedSuccessfulRunProgressSummaryForBoard(
|
||||
`Next action noted: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
|
||||
{ enabled: false },
|
||||
);
|
||||
expect(redactedDetectedSummary).toContain("***REDACTED***");
|
||||
expect(redactedDetectedSummary).not.toContain(bearerSecret);
|
||||
expect(redactedDetectedSummary).not.toContain(apiKeySecret);
|
||||
|
||||
mockAdapterExecute.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Made progress but left the issue open.",
|
||||
resultJson: {
|
||||
message: `Next action: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
|
||||
},
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForRunToSettle(heartbeat, runId, 5_000);
|
||||
|
||||
const handoffWakeups = await waitForValue(async () => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId));
|
||||
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
|
||||
return matches.length > 0 ? matches : null;
|
||||
}, 5_000);
|
||||
await waitForHeartbeatIdle(db, 5_000);
|
||||
|
||||
expect(handoffWakeups).toHaveLength(1);
|
||||
const wakeupPayloadText = JSON.stringify(handoffWakeups[0]?.payload ?? {});
|
||||
expect(wakeupPayloadText).not.toContain(bearerSecret);
|
||||
expect(wakeupPayloadText).not.toContain(apiKeySecret);
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
|
||||
expect(handoffComment).toBeTruthy();
|
||||
expect(handoffComment?.body).not.toContain(bearerSecret);
|
||||
expect(handoffComment?.body).not.toContain(apiKeySecret);
|
||||
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(bearerSecret);
|
||||
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(apiKeySecret);
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId));
|
||||
const handoffActivity = activity.find((event) => event.action === "issue.successful_run_handoff_required");
|
||||
expect(handoffActivity).toBeTruthy();
|
||||
const activityDetailsText = JSON.stringify(handoffActivity?.details ?? {});
|
||||
expect(activityDetailsText).not.toContain(bearerSecret);
|
||||
expect(activityDetailsText).not.toContain(apiKeySecret);
|
||||
});
|
||||
|
||||
it("escalates an exhausted failed successful-run handoff without using generic continuation recovery first", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "failed",
|
||||
runErrorCode: "adapter_failed",
|
||||
runError: "Authorization: Bearer sk-test-successful-handoff-secret",
|
||||
});
|
||||
const sourceRunId = randomUUID();
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "finish_successful_run_handoff",
|
||||
sourceRunId,
|
||||
resumeFromRunId: sourceRunId,
|
||||
handoffRequired: true,
|
||||
handoffReason: "successful_run_missing_state",
|
||||
missingDisposition: "clear_next_step",
|
||||
handoffAttempt: 1,
|
||||
maxHandoffAttempts: 1,
|
||||
},
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.successfulRunHandoffEscalated).toBe(1);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const recovery = await waitForValue(async () =>
|
||||
db.select().from(issues).where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "stranded_issue_recovery"),
|
||||
eq(issues.originId, issueId),
|
||||
),
|
||||
).then((rows) => rows[0] ?? null),
|
||||
);
|
||||
expect(recovery?.assigneeAgentId).toBe(agentId);
|
||||
expect(recovery?.title).toContain("Recover missing next step");
|
||||
expect(recovery?.description).toContain("Normalized cause: `successful_run_missing_state`");
|
||||
expect(recovery?.description).toContain("not a runtime/adapter crash report");
|
||||
expect(recovery?.description).toContain(`Source run: [\`${sourceRunId}\`]`);
|
||||
expect(recovery?.description).toContain("Missing disposition: `clear_next_step`");
|
||||
expect(recovery?.description).toContain("Source assignee: [CodexCoder]");
|
||||
expect(recovery?.description).not.toContain("sk-test-successful-handoff-secret");
|
||||
|
||||
const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(sourceIssue?.status).toBe("blocked");
|
||||
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recovery?.id]);
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments[0]?.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
|
||||
expect(comments[0]?.authorType).toBe("system");
|
||||
expect(comments[0]?.presentation).toMatchObject({
|
||||
kind: "system_notice",
|
||||
tone: "danger",
|
||||
detailsDefaultOpen: false,
|
||||
});
|
||||
expect(comments[0]?.metadata).toMatchObject({
|
||||
version: 1,
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Recovery owner",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "issue_link", identifier: recovery?.identifier }),
|
||||
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CodexCoder" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Run evidence",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
|
||||
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(comments[0]?.body).not.toContain("sk-test-successful-handoff-secret");
|
||||
expect(JSON.stringify(comments[0]?.metadata ?? {})).not.toContain("sk-test-successful-handoff-secret");
|
||||
|
||||
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
|
||||
expect(activity.some((event) => event.action === "issue.successful_run_handoff_escalated")).toBe(true);
|
||||
});
|
||||
|
||||
it("escalates an exhausted successful handoff run that still leaves no disposition", async () => {
|
||||
const { companyId, runId, issueId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
});
|
||||
const sourceRunId = randomUUID();
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "finish_successful_run_handoff",
|
||||
sourceRunId,
|
||||
resumeFromRunId: sourceRunId,
|
||||
handoffRequired: true,
|
||||
handoffReason: "successful_run_missing_state",
|
||||
missingDisposition: "clear_next_step",
|
||||
handoffAttempt: 1,
|
||||
maxHandoffAttempts: 1,
|
||||
},
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.successfulContinuationObserved).toBe(0);
|
||||
expect(result.successfulRunHandoffEscalated).toBe(1);
|
||||
|
||||
const recovery = await waitForValue(async () =>
|
||||
db.select().from(issues).where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "stranded_issue_recovery"),
|
||||
eq(issues.originId, issueId),
|
||||
),
|
||||
).then((rows) => rows[0] ?? null),
|
||||
);
|
||||
expect(recovery?.description).toContain("Latest handoff run status: `succeeded`");
|
||||
expect(recovery?.description).toContain("Suggested");
|
||||
});
|
||||
|
||||
it("clears the detached warning when the run reports activity again", async () => {
|
||||
const { runId } = await seedRunFixture({
|
||||
includeIssue: false,
|
||||
@@ -1315,6 +1774,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
payload: expect.objectContaining({
|
||||
issueId,
|
||||
mutation: "assigned_todo_liveness_dispatch",
|
||||
modelProfile: "cheap",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1326,6 +1786,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
source: "issue.assigned_todo_liveness_dispatch",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
expect((runs[0]?.contextSnapshot as Record<string, unknown>)?.retryReason).toBeUndefined();
|
||||
|
||||
@@ -1433,6 +1894,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
payload: expect.objectContaining({
|
||||
issueId: unblocked.issueId,
|
||||
mutation: "assigned_todo_liveness_dispatch",
|
||||
modelProfile: "cheap",
|
||||
}),
|
||||
});
|
||||
const unblockedRuns = await db
|
||||
@@ -1486,6 +1948,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.id).toBeTruthy();
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
|
||||
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
@@ -1524,6 +1987,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
retryReason: "issue_continuation_needed",
|
||||
retryOfRunId: runId,
|
||||
source: "issue.continuation_recovery",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
|
||||
const recoveries = await db
|
||||
@@ -1575,6 +2039,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
|
||||
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
@@ -1615,6 +2080,83 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`);
|
||||
});
|
||||
|
||||
it("blocks an already stranded recovery issue without creating a recovery child", async () => {
|
||||
const { companyId, issueId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
runStatus: "failed",
|
||||
retryReason: "assignment_recovery",
|
||||
});
|
||||
const sourceIssueId = randomUUID();
|
||||
const sourceRunId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
title: "Original source issue",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
issueNumber: 2,
|
||||
identifier: `${issuePrefix}-2`,
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
title: "Recover stalled issue from previous adapter failure",
|
||||
parentId: sourceIssueId,
|
||||
originKind: "stranded_issue_recovery",
|
||||
originId: sourceIssueId,
|
||||
originRunId: sourceRunId,
|
||||
originFingerprint: [
|
||||
"stranded_issue_recovery",
|
||||
companyId,
|
||||
sourceIssueId,
|
||||
sourceRunId,
|
||||
].join(":"),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(1);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const recoveryIssues = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
|
||||
expect(recoveryIssues).toHaveLength(1);
|
||||
expect(recoveryIssues[0]).toMatchObject({
|
||||
id: issueId,
|
||||
status: "blocked",
|
||||
parentId: sourceIssueId,
|
||||
originId: sourceIssueId,
|
||||
originRunId: sourceRunId,
|
||||
});
|
||||
expect(recoveryIssues[0]?.checkoutRunId).toBeNull();
|
||||
expect(recoveryIssues[0]?.executionRunId).toBeNull();
|
||||
|
||||
const blockerRelations = await db
|
||||
.select()
|
||||
.from(issueRelations)
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, companyId),
|
||||
eq(issueRelations.relatedIssueId, issueId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
),
|
||||
);
|
||||
expect(blockerRelations).toHaveLength(0);
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("stopped automatic stranded-work recovery");
|
||||
expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues");
|
||||
expect(comments[0]?.body).toContain(`Recovery issue: [${recoveryIssues[0]?.identifier}]`);
|
||||
expect(comments[0]?.body).toContain("Next action:");
|
||||
});
|
||||
|
||||
it("assigns open unassigned blockers back to their creator agent", async () => {
|
||||
const companyId = randomUUID();
|
||||
const creatorAgentId = randomUUID();
|
||||
@@ -1738,6 +2280,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.id).toBeTruthy();
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
|
||||
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
@@ -2215,6 +2758,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
retryReason: "issue_continuation_needed",
|
||||
retryOfRunId: runId,
|
||||
source: "issue.productive_terminal_continuation_recovery",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
|
||||
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
|
||||
@@ -2281,6 +2825,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
retryReason: "issue_continuation_needed",
|
||||
retryOfRunId: runId,
|
||||
source: "issue.productive_terminal_continuation_recovery",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2336,6 +2881,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
retryReason: "issue_continuation_needed",
|
||||
retryOfRunId: runId,
|
||||
source: "issue.productive_terminal_continuation_recovery",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
other: "value",
|
||||
},
|
||||
secretKeys: new Set(["AGENT_SECRET"]),
|
||||
manifest: [
|
||||
{
|
||||
configPath: "env.AGENT_SECRET",
|
||||
envKey: "AGENT_SECRET",
|
||||
secretId: "secret-agent",
|
||||
secretKey: "agent-secret",
|
||||
version: 1,
|
||||
provider: "local_encrypted",
|
||||
outcome: "success",
|
||||
},
|
||||
],
|
||||
});
|
||||
const resolveEnvBindings = vi.fn().mockResolvedValue({
|
||||
env: {
|
||||
@@ -24,6 +35,17 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
PROJECT_ONLY: "project-only",
|
||||
},
|
||||
secretKeys: new Set(["PROJECT_SECRET"]),
|
||||
manifest: [
|
||||
{
|
||||
configPath: "env.PROJECT_SECRET",
|
||||
envKey: "PROJECT_SECRET",
|
||||
secretId: "secret-project",
|
||||
secretKey: "project-secret",
|
||||
version: 1,
|
||||
provider: "local_encrypted",
|
||||
outcome: "success",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await resolveExecutionRunAdapterConfig({
|
||||
@@ -45,12 +67,19 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
},
|
||||
});
|
||||
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
|
||||
expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([
|
||||
"secret-agent",
|
||||
"secret-project",
|
||||
]);
|
||||
expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only");
|
||||
expect(JSON.stringify(result.secretManifest)).not.toContain("project-only");
|
||||
});
|
||||
|
||||
it("skips project env resolution when the project has no bindings", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||
config: { env: { AGENT_ONLY: "agent-only" } },
|
||||
secretKeys: new Set<string>(),
|
||||
manifest: [],
|
||||
});
|
||||
const resolveEnvBindings = vi.fn();
|
||||
|
||||
@@ -65,6 +94,7 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
|
||||
expect(result.secretManifest).toEqual([]);
|
||||
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
budgetPolicies,
|
||||
companies,
|
||||
createDb,
|
||||
environmentLeases,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueRelations,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS,
|
||||
MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
heartbeatService,
|
||||
} from "../services/heartbeat.ts";
|
||||
|
||||
@@ -44,10 +48,12 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
||||
afterEach(async () => {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(budgetPolicies);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
@@ -124,6 +130,92 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function seedMaxTurnFixture(input?: {
|
||||
companyId?: string;
|
||||
agentId?: string;
|
||||
issueId?: string;
|
||||
runId?: string;
|
||||
now?: Date;
|
||||
scheduledRetryAttempt?: number;
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
issueStatus?: string;
|
||||
}) {
|
||||
const companyId = input?.companyId ?? randomUUID();
|
||||
const agentId = input?.agentId ?? randomUUID();
|
||||
const issueId = input?.issueId ?? randomUUID();
|
||||
const runId = input?.runId ?? randomUUID();
|
||||
const now = input?.now ?? new Date("2026-04-20T12:00:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "ClaudeCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: input?.runtimeConfig ?? {
|
||||
heartbeat: {
|
||||
wakeOnDemand: true,
|
||||
maxConcurrentRuns: 1,
|
||||
maxTurnContinuation: {
|
||||
enabled: true,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: "failed",
|
||||
error: "Maximum turns reached",
|
||||
errorCode: "adapter_failed",
|
||||
finishedAt: now,
|
||||
scheduledRetryAttempt: input?.scheduledRetryAttempt ?? 0,
|
||||
scheduledRetryReason: input?.scheduledRetryAttempt ? MAX_TURN_CONTINUATION_RETRY_REASON : null,
|
||||
resultJson: {
|
||||
stopReason: "max_turns_exhausted",
|
||||
},
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Continue after max turns",
|
||||
status: input?.issueStatus ?? "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
executionRunId: runId,
|
||||
executionAgentNameKey: "claudecoder",
|
||||
executionLockedAt: now,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
return { companyId, agentId, issueId, runId, now };
|
||||
}
|
||||
|
||||
it("schedules a retry with durable metadata and only promotes it when due", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
@@ -194,6 +286,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
||||
retryOfRunId: sourceRunId,
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
contextSnapshot: expect.objectContaining({ modelProfile: "cheap" }),
|
||||
});
|
||||
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString());
|
||||
|
||||
@@ -218,6 +311,416 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
||||
expect(promotedRun?.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("schedules max-turn continuations with distinct retry metadata", async () => {
|
||||
const { runId, now } = await seedMaxTurnFixture();
|
||||
|
||||
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
||||
now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
});
|
||||
|
||||
expect(scheduled.outcome).toBe("scheduled");
|
||||
if (scheduled.outcome !== "scheduled") return;
|
||||
expect(scheduled.attempt).toBe(1);
|
||||
expect(scheduled.dueAt.toISOString()).toBe(new Date(now.getTime() + 1_000).toISOString());
|
||||
|
||||
const retryRun = await db
|
||||
.select({
|
||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||
status: heartbeatRuns.status,
|
||||
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
|
||||
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(retryRun).toMatchObject({
|
||||
retryOfRunId: runId,
|
||||
status: "scheduled_retry",
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
});
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.wakeReason).toBe(
|
||||
MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
);
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode ?? null).toBeNull();
|
||||
|
||||
const wakeupRequest = await db
|
||||
.select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeupRequest?.reason).toBe(MAX_TURN_CONTINUATION_WAKE_REASON);
|
||||
expect(wakeupRequest?.payload).toMatchObject({
|
||||
retryOfRunId: runId,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
scheduledRetryAttempt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces duplicate max-turn continuation schedules for the same source run and attempt", async () => {
|
||||
const { issueId, runId, now } = await seedMaxTurnFixture();
|
||||
const retryOptions = {
|
||||
now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
heartbeat.scheduleBoundedRetry(runId, retryOptions),
|
||||
heartbeat.scheduleBoundedRetry(runId, retryOptions),
|
||||
]);
|
||||
|
||||
expect(first.outcome).toBe("scheduled");
|
||||
expect(second.outcome).toBe("scheduled");
|
||||
if (first.outcome !== "scheduled" || second.outcome !== "scheduled") return;
|
||||
|
||||
expect(new Set([first.run.id, second.run.id]).size).toBe(1);
|
||||
|
||||
const retryRuns = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.retryOfRunId, runId),
|
||||
eq(heartbeatRuns.scheduledRetryReason, MAX_TURN_CONTINUATION_RETRY_REASON),
|
||||
eq(heartbeatRuns.scheduledRetryAttempt, 1),
|
||||
),
|
||||
);
|
||||
expect(retryRuns).toHaveLength(1);
|
||||
|
||||
const wakeups = await db
|
||||
.select({
|
||||
id: agentWakeupRequests.id,
|
||||
coalescedCount: agentWakeupRequests.coalescedCount,
|
||||
idempotencyKey: agentWakeupRequests.idempotencyKey,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.reason, MAX_TURN_CONTINUATION_WAKE_REASON));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
expect(wakeups[0]).toMatchObject({
|
||||
id: retryRuns[0]?.wakeupRequestId,
|
||||
coalescedCount: 1,
|
||||
});
|
||||
expect(wakeups[0]?.idempotencyKey).toContain(`:${issueId}:${runId}:1`);
|
||||
|
||||
const issue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBe(retryRuns[0]?.id);
|
||||
});
|
||||
|
||||
it("does not promote a duplicate max-turn continuation that does not own the issue lock", async () => {
|
||||
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture();
|
||||
|
||||
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
||||
now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
});
|
||||
expect(scheduled.outcome).toBe("scheduled");
|
||||
if (scheduled.outcome !== "scheduled") return;
|
||||
|
||||
const duplicateWakeupId = randomUUID();
|
||||
const duplicateRunId = randomUUID();
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
id: duplicateWakeupId,
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
payload: {
|
||||
issueId,
|
||||
retryOfRunId: runId,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
scheduledRetryAttempt: 1,
|
||||
},
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: duplicateRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "scheduled_retry",
|
||||
wakeupRequestId: duplicateWakeupId,
|
||||
retryOfRunId: runId,
|
||||
scheduledRetryAt: scheduled.dueAt,
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(agentWakeupRequests)
|
||||
.set({ runId: duplicateRunId })
|
||||
.where(eq(agentWakeupRequests.id, duplicateWakeupId));
|
||||
|
||||
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
|
||||
expect(promotion).toEqual({ promoted: 1, runIds: [scheduled.run.id] });
|
||||
|
||||
const duplicate = await db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, duplicateRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(duplicate).toEqual({
|
||||
status: "cancelled",
|
||||
errorCode: "issue_execution_lock_changed",
|
||||
});
|
||||
|
||||
const duplicateWakeup = await db
|
||||
.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, duplicateWakeupId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(duplicateWakeup?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it.each(["blocked", "todo", "backlog"] as const)(
|
||||
"does not schedule a max-turn continuation when the issue is already %s",
|
||||
async (issueStatus) => {
|
||||
const { issueId, runId, now } = await seedMaxTurnFixture({ issueStatus });
|
||||
|
||||
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
||||
now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
});
|
||||
|
||||
expect(scheduled).toMatchObject({
|
||||
outcome: "not_scheduled",
|
||||
errorCode: "issue_not_in_progress",
|
||||
issueId,
|
||||
});
|
||||
|
||||
const retryRuns = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.retryOfRunId, runId))
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
expect(retryRuns).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["blocked", "todo", "backlog"] as const)(
|
||||
"cancels a due max-turn continuation when the issue moves to %s before retry promotion",
|
||||
async (issueStatus) => {
|
||||
const { issueId, runId, now } = await seedMaxTurnFixture();
|
||||
|
||||
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
||||
now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
});
|
||||
expect(scheduled.outcome).toBe("scheduled");
|
||||
if (scheduled.outcome !== "scheduled") return;
|
||||
|
||||
await db.update(issues).set({
|
||||
status: issueStatus,
|
||||
updatedAt: new Date(now.getTime() + 500),
|
||||
}).where(eq(issues.id, issueId));
|
||||
|
||||
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
|
||||
expect(promotion).toEqual({ promoted: 0, runIds: [] });
|
||||
|
||||
const retryRun = await db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(retryRun).toMatchObject({
|
||||
status: "cancelled",
|
||||
errorCode: "issue_not_in_progress",
|
||||
});
|
||||
|
||||
const wakeupRequest = await db
|
||||
.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeupRequest?.status).toBe("cancelled");
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
executionRunId: issues.executionRunId,
|
||||
executionAgentNameKey: issues.executionAgentNameKey,
|
||||
executionLockedAt: issues.executionLockedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue).toEqual({
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
});
|
||||
|
||||
const event = await db
|
||||
.select({
|
||||
message: heartbeatRunEvents.message,
|
||||
payload: heartbeatRunEvents.payload,
|
||||
})
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, scheduled.run.id))
|
||||
.orderBy(sql`${heartbeatRunEvents.seq} desc`)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(event?.message).toContain("no longer in_progress");
|
||||
expect(event?.payload).toMatchObject({
|
||||
currentStatus: issueStatus,
|
||||
requiredStatus: "in_progress",
|
||||
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not queue max-turn continuations after the configured cap", async () => {
|
||||
const { runId, now } = await seedMaxTurnFixture({ scheduledRetryAttempt: 2 });
|
||||
|
||||
const exhausted = await heartbeat.scheduleBoundedRetry(runId, {
|
||||
now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
});
|
||||
|
||||
expect(exhausted).toEqual({
|
||||
outcome: "retry_exhausted",
|
||||
attempt: 3,
|
||||
maxAttempts: 2,
|
||||
});
|
||||
|
||||
const runCount = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
expect(runCount).toBe(1);
|
||||
|
||||
const exhaustionEvent = await db
|
||||
.select({ message: heartbeatRunEvents.message, payload: heartbeatRunEvents.payload })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, runId))
|
||||
.orderBy(sql`${heartbeatRunEvents.id} desc`)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(exhaustionEvent?.message).toContain("Bounded retry exhausted");
|
||||
expect(exhaustionEvent?.payload).toMatchObject({
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
maxAttempts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses max-turn continuation scheduling when budget or dependencies block the issue", async () => {
|
||||
const budgetBlocked = await seedMaxTurnFixture({ now: new Date("2026-04-20T16:00:00.000Z") });
|
||||
await db.insert(budgetPolicies).values({
|
||||
companyId: budgetBlocked.companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: budgetBlocked.agentId,
|
||||
windowKind: "monthly",
|
||||
metric: "billed_cents",
|
||||
amount: 0,
|
||||
hardStopEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "paused", pauseReason: "budget" })
|
||||
.where(eq(agents.id, budgetBlocked.agentId));
|
||||
|
||||
const budgetResult = await heartbeat.scheduleBoundedRetry(budgetBlocked.runId, {
|
||||
now: budgetBlocked.now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
});
|
||||
expect(budgetResult).toMatchObject({
|
||||
outcome: "not_scheduled",
|
||||
errorCode: "budget_blocked",
|
||||
issueId: budgetBlocked.issueId,
|
||||
});
|
||||
|
||||
await db.delete(budgetPolicies);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
|
||||
const dependencyBlocked = await seedMaxTurnFixture({ now: new Date("2026-04-20T17:00:00.000Z") });
|
||||
const blockerId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: blockerId,
|
||||
companyId: dependencyBlocked.companyId,
|
||||
title: "Blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
issueNumber: 2,
|
||||
identifier: `T${dependencyBlocked.companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-2`,
|
||||
});
|
||||
await db.insert(issueRelations).values({
|
||||
companyId: dependencyBlocked.companyId,
|
||||
issueId: blockerId,
|
||||
relatedIssueId: dependencyBlocked.issueId,
|
||||
type: "blocks",
|
||||
});
|
||||
|
||||
const dependencyResult = await heartbeat.scheduleBoundedRetry(dependencyBlocked.runId, {
|
||||
now: dependencyBlocked.now,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
maxAttempts: 2,
|
||||
delayMs: 1_000,
|
||||
});
|
||||
expect(dependencyResult).toMatchObject({
|
||||
outcome: "not_scheduled",
|
||||
errorCode: "issue_dependencies_blocked",
|
||||
issueId: dependencyBlocked.issueId,
|
||||
});
|
||||
|
||||
const retryRuns = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.retryOfRunId, dependencyBlocked.runId))
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
expect(retryRuns).toBe(0);
|
||||
});
|
||||
|
||||
it("does not defer a new assignee behind the previous assignee's scheduled retry", async () => {
|
||||
const companyId = randomUUID();
|
||||
const oldAgentId = randomUUID();
|
||||
|
||||
@@ -19,11 +19,16 @@ import {
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import {
|
||||
MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
heartbeatService,
|
||||
} from "../services/heartbeat.ts";
|
||||
import { runningProcesses } from "../adapters/index.ts";
|
||||
|
||||
const mockAdapterExecute = vi.hoisted(() =>
|
||||
@@ -83,6 +88,40 @@ async function waitForCondition(fn: () => Promise<boolean>, timeoutMs = 3_000) {
|
||||
return fn();
|
||||
}
|
||||
|
||||
async function cleanupHeartbeatInvalidationFixture(db: ReturnType<typeof createDb>) {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueTreeHolds);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
return;
|
||||
} catch (error) {
|
||||
const isLateCommentRace =
|
||||
error instanceof Error &&
|
||||
error.message.includes("issue_comments_issue_id_issues_id_fk");
|
||||
if (!isLateCommentRace || attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Heartbeat completion can write issue-thread comments shortly after the
|
||||
// run leaves queued/running. Retry the dependent deletes once those land.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SeedOptions = {
|
||||
agentName?: string;
|
||||
agentRole?: string;
|
||||
@@ -99,6 +138,9 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
let heartbeat!: ReturnType<typeof heartbeatService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
const countExecuteCallsForRun = (runId: string) =>
|
||||
mockAdapterExecute.mock.calls.filter(([context]) => context?.runId === runId).length;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-stale-queue-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
@@ -133,21 +175,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await db.delete(companySkills);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueTreeHolds);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
await cleanupHeartbeatInvalidationFixture(db);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -189,6 +217,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
wakeReason: string;
|
||||
contextExtras?: Record<string, unknown>;
|
||||
invocationSource?: "assignment" | "automation";
|
||||
scheduledRetryReason?: string | null;
|
||||
}) {
|
||||
const wakeupRequestId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
@@ -210,6 +239,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
wakeupRequestId,
|
||||
scheduledRetryReason: input.scheduledRetryReason ?? null,
|
||||
contextSnapshot: {
|
||||
issueId: input.issueId,
|
||||
wakeReason: input.wakeReason,
|
||||
@@ -223,6 +253,43 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
return { runId, wakeupRequestId };
|
||||
}
|
||||
|
||||
async function seedContinuationSummary(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
body: string;
|
||||
}) {
|
||||
const documentId = randomUUID();
|
||||
const revisionId = randomUUID();
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId: input.companyId,
|
||||
title: "Continuation Summary",
|
||||
format: "markdown",
|
||||
latestBody: input.body,
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: input.agentId,
|
||||
updatedByAgentId: input.agentId,
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: revisionId,
|
||||
companyId: input.companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Continuation Summary",
|
||||
format: "markdown",
|
||||
body: input.body,
|
||||
createdByAgentId: input.agentId,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
documentId,
|
||||
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
it("cancels queued runs when the issue assignee changes before the run starts", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent({ agentName: "OriginalCoder" });
|
||||
const replacementAgentId = randomUUID();
|
||||
@@ -293,7 +360,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
expect(run?.resultJson).toMatchObject({ stopReason: "issue_assignee_changed" });
|
||||
expect(wakeup?.status).toBe("skipped");
|
||||
expect(wakeup?.error).toContain("assignee changed");
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels queued runs when the issue reaches a terminal status before the run starts", async () => {
|
||||
@@ -342,7 +409,155 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
expect(run?.status).toBe("cancelled");
|
||||
expect(run?.errorCode).toBe("issue_terminal_status");
|
||||
expect(wakeup?.status).toBe("skipped");
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels queued max-turn continuations when the issue is no longer in_progress before the run starts", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Parked max-turn continuation",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
const { runId, wakeupRequestId } = await seedQueuedRun({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
invocationSource: "automation",
|
||||
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
contextExtras: {
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
},
|
||||
});
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "cancelled";
|
||||
});
|
||||
|
||||
const [run, wakeup] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
expect(run?.status).toBe("cancelled");
|
||||
expect(run?.errorCode).toBe("issue_not_in_progress");
|
||||
expect(run?.resultJson).toMatchObject({ stopReason: "issue_not_in_progress" });
|
||||
expect(wakeup?.status).toBe("skipped");
|
||||
expect(wakeup?.error).toContain("no longer in_progress");
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels queued max-turn continuations when another continuation owns the issue lock", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
const lockOwnerRunId = randomUUID();
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: lockOwnerRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "scheduled_retry",
|
||||
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryAt: new Date("2026-04-20T12:00:00.000Z"),
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Duplicate max-turn continuation",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
executionRunId: lockOwnerRunId,
|
||||
executionAgentNameKey: "claudecoder",
|
||||
executionLockedAt: new Date("2026-04-20T11:59:00.000Z"),
|
||||
});
|
||||
|
||||
const { runId, wakeupRequestId } = await seedQueuedRun({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
||||
invocationSource: "automation",
|
||||
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
contextExtras: {
|
||||
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
},
|
||||
});
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "cancelled";
|
||||
});
|
||||
|
||||
const [run, wakeup, issue] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
expect(run?.status).toBe("cancelled");
|
||||
expect(run?.errorCode).toBe("issue_execution_lock_changed");
|
||||
expect(run?.resultJson).toMatchObject({ stopReason: "issue_execution_lock_changed" });
|
||||
expect(wakeup?.status).toBe("skipped");
|
||||
expect(wakeup?.error).toContain("execution lock");
|
||||
expect(issue?.executionRunId).toBe(lockOwnerRunId);
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels queued in_review runs when the current participant changes before the run starts", async () => {
|
||||
@@ -422,7 +637,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
expect(run?.resultJson).toMatchObject({ stopReason: "issue_review_participant_changed" });
|
||||
expect(wakeup?.status).toBe("skipped");
|
||||
expect(wakeup?.error).toContain("in-review participant changed");
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
|
||||
it("still runs comment-driven wakes on in_review issues even when the agent is no longer the current participant", async () => {
|
||||
@@ -540,6 +755,77 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(run?.status).toBe("succeeded");
|
||||
expect(run?.errorCode).toBeNull();
|
||||
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
|
||||
expect(countExecuteCallsForRun(runId)).toBe(1);
|
||||
});
|
||||
|
||||
it("cancels queued continuation recovery when the continuation summary parks executor work for review", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Implementation parked for review",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await seedContinuationSummary({
|
||||
companyId,
|
||||
issueId,
|
||||
agentId,
|
||||
body: [
|
||||
"# Continuation Summary",
|
||||
"",
|
||||
"## Next Action",
|
||||
"",
|
||||
"- Wait for reviewer feedback or approval before continuing executor work.",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const { runId, wakeupRequestId } = await seedQueuedRun({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
wakeReason: "issue_continuation_needed",
|
||||
invocationSource: "automation",
|
||||
contextExtras: {
|
||||
retryReason: "issue_continuation_needed",
|
||||
},
|
||||
});
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "cancelled";
|
||||
});
|
||||
|
||||
const [run, wakeup] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
expect(run?.status).toBe("cancelled");
|
||||
expect(run?.errorCode).toBe("issue_continuation_waiting_on_review");
|
||||
expect(run?.resultJson).toMatchObject({ stopReason: "issue_continuation_waiting_on_review" });
|
||||
expect(wakeup?.status).toBe("skipped");
|
||||
expect(wakeup?.error).toContain("continuation summary says the executor should wait");
|
||||
expect(countExecuteCallsForRun(runId)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
@@ -109,7 +110,7 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
async function createApp(db: unknown = {}) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
@@ -126,7 +127,7 @@ async function createApp() {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use("/api", issueRoutes(db as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
@@ -266,6 +267,158 @@ describe("issue activity event routes", () => {
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("logs readable workspace change activity details for issue updates", async () => {
|
||||
const previousProjectWorkspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const nextExecutionWorkspaceId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const issue = {
|
||||
...makeIssue(),
|
||||
projectId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
projectWorkspaceId: previousProjectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: "shared_workspace",
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const dbMock = {
|
||||
select: vi.fn(() => ({
|
||||
from: (table: unknown) => ({
|
||||
where: async () => {
|
||||
const tableName = getTableName(table as Parameters<typeof getTableName>[0]);
|
||||
if (tableName === "project_workspaces") {
|
||||
return [{ id: previousProjectWorkspaceId, name: "Main workspace" }];
|
||||
}
|
||||
if (tableName === "execution_workspaces") {
|
||||
return [{ id: nextExecutionWorkspaceId, name: "Feature workspace" }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
const res = await request(await createApp(dbMock))
|
||||
.patch(`/api/issues/${issue.id}`)
|
||||
.send({ executionWorkspaceId: nextExecutionWorkspaceId });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.objectContaining({
|
||||
executionWorkspaceId: nextExecutionWorkspaceId,
|
||||
workspaceChange: {
|
||||
from: {
|
||||
label: "Main workspace",
|
||||
projectWorkspaceId: previousProjectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
to: {
|
||||
label: "Feature workspace",
|
||||
projectWorkspaceId: previousProjectWorkspaceId,
|
||||
executionWorkspaceId: nextExecutionWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
},
|
||||
_previous: expect.objectContaining({
|
||||
executionWorkspaceId: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => {
|
||||
const issue = { ...makeIssue(), status: "in_progress" };
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const handoffActivityRow = {
|
||||
entityId: issue.id,
|
||||
action: "issue.successful_run_handoff_required",
|
||||
agentId: issue.assigneeAgentId,
|
||||
runId: "run-1",
|
||||
details: {
|
||||
sourceRunId: "run-1",
|
||||
correctiveRunId: "run-2",
|
||||
},
|
||||
createdAt: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
const dbMock = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: async () => [handoffActivityRow],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await request(await createApp(dbMock))
|
||||
.patch(`/api/issues/${issue.id}`)
|
||||
.send({ status: "done" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.successful_run_handoff_resolved",
|
||||
entityId: issue.id,
|
||||
details: expect.objectContaining({
|
||||
identifier: "PAP-580",
|
||||
sourceRunId: "run-1",
|
||||
correctiveRunId: "run-2",
|
||||
resolvedByStatus: "done",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not log successful_run_handoff_resolved when status stays in_progress", async () => {
|
||||
const issue = { ...makeIssue(), status: "in_progress" };
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const dbMock = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: async () => [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await request(await createApp(dbMock))
|
||||
.patch(`/api/issues/${issue.id}`)
|
||||
.send({ title: "Updated title" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ action: "issue.successful_run_handoff_resolved" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
createChild: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(async () => null),
|
||||
getComment: vi.fn(),
|
||||
getCommentCursor: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
findMentionedAgents: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
documentService: () => ({
|
||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||
}),
|
||||
executionWorkspaceService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
}),
|
||||
goalService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
getDefaultCompanyGoal: vi.fn(async () => null),
|
||||
}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: mockWakeup,
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
getIssueContinuationSummaryDocument: vi.fn(async () => null),
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
addedReferencedIssues: [],
|
||||
removedReferencedIssues: [],
|
||||
currentReferencedIssues: [],
|
||||
}),
|
||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
||||
syncComment: async () => undefined,
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
listByIds: vi.fn(async () => []),
|
||||
}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
}),
|
||||
}));
|
||||
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue(input: {
|
||||
id: string;
|
||||
title: string;
|
||||
status?: string;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
companyId: "company-1",
|
||||
identifier: input.id === "child-1" ? "PAP-3701" : "PAP-3700",
|
||||
title: input.title,
|
||||
description: null,
|
||||
status: input.status ?? "todo",
|
||||
priority: "medium",
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function expectClearAssignedStatusValidation(res: request.Response) {
|
||||
expect([400, 422]).toContain(res.status);
|
||||
expect(String(res.body?.error ?? res.text)).toMatch(/assign|assignee|status|backlog|todo/i);
|
||||
}
|
||||
|
||||
describe("assigned backlog creation contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({
|
||||
id: "parent-1",
|
||||
title: "Parent issue",
|
||||
status: "blocked",
|
||||
assigneeAgentId,
|
||||
}));
|
||||
mockIssueService.create.mockImplementation(async (_companyId: string, data: Record<string, unknown>) =>
|
||||
makeIssue({
|
||||
id: "issue-1",
|
||||
title: String(data.title),
|
||||
status: String(data.status),
|
||||
assigneeAgentId: data.assigneeAgentId as string | null | undefined,
|
||||
}));
|
||||
mockIssueService.createChild.mockImplementation(async (_parentId: string, data: Record<string, unknown>) => ({
|
||||
issue: makeIssue({
|
||||
id: "child-1",
|
||||
title: String(data.title),
|
||||
status: String(data.status),
|
||||
parentId: "parent-1",
|
||||
assigneeAgentId: data.assigneeAgentId as string | null | undefined,
|
||||
}),
|
||||
parentBlockerAdded: Boolean(data.blockParentUntilDone),
|
||||
}));
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("does not silently create a top-level assigned issue as backlog when status is omitted", async () => {
|
||||
const res = await request(await createApp())
|
||||
.post("/api/companies/company-1/issues")
|
||||
.send({
|
||||
title: "Assigned executable work",
|
||||
assigneeAgentId,
|
||||
});
|
||||
|
||||
if (res.status !== 201) {
|
||||
expectClearAssignedStatusValidation(res);
|
||||
expect(mockIssueService.create).not.toHaveBeenCalled();
|
||||
expect(mockWakeup).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(mockIssueService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Assigned executable work",
|
||||
assigneeAgentId,
|
||||
status: "todo",
|
||||
}),
|
||||
);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
assigneeAgentId,
|
||||
status: "todo",
|
||||
}));
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
assigneeAgentId,
|
||||
expect.objectContaining({
|
||||
source: "assignment",
|
||||
reason: "issue_assigned",
|
||||
payload: expect.objectContaining({ mutation: "create" }),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.created",
|
||||
details: expect.objectContaining({
|
||||
status: "todo",
|
||||
statusDefaulted: true,
|
||||
statusDefaultReason: "assigned_omitted_status",
|
||||
assignmentWakeSkipped: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a parent-blocking assigned child become an unwoken backlog leaf by default", async () => {
|
||||
const res = await request(await createApp())
|
||||
.post("/api/issues/parent-1/children")
|
||||
.send({
|
||||
title: "Assigned child blocker",
|
||||
assigneeAgentId,
|
||||
blockParentUntilDone: true,
|
||||
});
|
||||
|
||||
if (res.status !== 201) {
|
||||
expectClearAssignedStatusValidation(res);
|
||||
expect(mockIssueService.createChild).not.toHaveBeenCalled();
|
||||
expect(mockWakeup).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(mockIssueService.createChild).toHaveBeenCalledWith(
|
||||
"parent-1",
|
||||
expect.objectContaining({
|
||||
title: "Assigned child blocker",
|
||||
assigneeAgentId,
|
||||
blockParentUntilDone: true,
|
||||
status: "todo",
|
||||
}),
|
||||
);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
assigneeAgentId,
|
||||
parentId: "parent-1",
|
||||
status: "todo",
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.child_created",
|
||||
details: expect.objectContaining({
|
||||
status: "todo",
|
||||
statusDefaulted: true,
|
||||
statusDefaultReason: "assigned_omitted_status",
|
||||
assignmentWakeSkipped: false,
|
||||
parentBlockerAdded: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
assigneeAgentId,
|
||||
expect.objectContaining({
|
||||
source: "assignment",
|
||||
reason: "issue_assigned",
|
||||
payload: expect.objectContaining({ mutation: "create" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves deliberate assigned backlog as parked work without assignment wakeup", async () => {
|
||||
const res = await request(await createApp())
|
||||
.post("/api/companies/company-1/issues")
|
||||
.send({
|
||||
title: "Parked assigned work",
|
||||
assigneeAgentId,
|
||||
status: "backlog",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Parked assigned work",
|
||||
assigneeAgentId,
|
||||
status: "backlog",
|
||||
}),
|
||||
);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
assigneeAgentId,
|
||||
status: "backlog",
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.created",
|
||||
entityId: "issue-1",
|
||||
details: expect.objectContaining({
|
||||
status: "backlog",
|
||||
statusDefaulted: false,
|
||||
statusDefaultReason: "explicit",
|
||||
assignmentWakeSkipped: true,
|
||||
assignmentWakeSkipReason: "assigned_backlog",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
||||
status: string;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
originFingerprint?: string | null;
|
||||
@@ -90,6 +91,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
||||
priority: "medium",
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: input.assigneeUserId ?? null,
|
||||
originKind: input.originKind ?? "manual",
|
||||
originId: input.originId ?? null,
|
||||
originFingerprint: input.originFingerprint ?? "default",
|
||||
@@ -147,6 +149,55 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an assigned backlog blocker leaf without a waiting path as attention-needed", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBB");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBB-1", title: "Parent", status: "blocked" });
|
||||
const blockerId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PBB-2",
|
||||
title: "Parked assigned blocker",
|
||||
status: "backlog",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
|
||||
|
||||
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PBB-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a human-owned backlog blocker as a covered waiting path", async () => {
|
||||
const { companyId } = await createCompany("PBU");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBU-1", title: "Parent", status: "blocked" });
|
||||
const blockerId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PBU-2",
|
||||
title: "Human-owned parked blocker",
|
||||
status: "backlog",
|
||||
assigneeUserId: "board-user-1",
|
||||
});
|
||||
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
|
||||
|
||||
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "covered",
|
||||
reason: "active_dependency",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PBU-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mixed blockers attention-required when any path lacks active work", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBM");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBM-1", title: "Parent", status: "blocked" });
|
||||
|
||||
@@ -38,7 +38,12 @@ const mockTxInsert = vi.hoisted(() => vi.fn(() => ({ values: mockTxInsertValues
|
||||
const mockTx = vi.hoisted(() => ({
|
||||
insert: mockTxInsert,
|
||||
}));
|
||||
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
|
||||
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ orderBy: mockDbSelectOrderBy })));
|
||||
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
|
||||
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: mockDbSelect,
|
||||
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
||||
}));
|
||||
const mockFeedbackService = vi.hoisted(() => ({
|
||||
@@ -236,9 +241,17 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
|
||||
mockTxInsertValues.mockReset();
|
||||
mockTxInsert.mockReset();
|
||||
mockDbSelect.mockReset();
|
||||
mockDbSelectFrom.mockReset();
|
||||
mockDbSelectWhere.mockReset();
|
||||
mockDbSelectOrderBy.mockReset();
|
||||
mockDb.transaction.mockReset();
|
||||
mockTxInsertValues.mockResolvedValue(undefined);
|
||||
mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues }));
|
||||
mockDbSelectOrderBy.mockResolvedValue([]);
|
||||
mockDbSelectWhere.mockImplementation(() => ({ orderBy: mockDbSelectOrderBy }));
|
||||
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
|
||||
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
|
||||
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx));
|
||||
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
|
||||
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
|
||||
@@ -545,6 +558,87 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
));
|
||||
});
|
||||
|
||||
it("passes validated comment presentation fields to trusted board comment writes", async () => {
|
||||
const app = await installActor(createApp());
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "Paperclip needs a disposition before this issue can continue.",
|
||||
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
|
||||
const metadata = {
|
||||
version: 1,
|
||||
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
|
||||
};
|
||||
const presentation = { kind: "system_notice", tone: "warning" };
|
||||
const res = await request(app)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({
|
||||
body: "Paperclip needs a disposition before this issue can continue.",
|
||||
presentation,
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"Paperclip needs a disposition before this issue can continue.",
|
||||
{ agentId: undefined, userId: "local-board", runId: null },
|
||||
{
|
||||
authorType: "user",
|
||||
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
|
||||
metadata,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects structured comment presentation fields from agent-authenticated writes", async () => {
|
||||
const app = await installActor(createApp(), agentActor());
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({
|
||||
body: "Hidden details",
|
||||
presentation: { kind: "system_notice", tone: "warning" },
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "covert_channel_attempt" }] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid comment metadata before writing a comment", async () => {
|
||||
const app = await installActor(createApp());
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({
|
||||
body: "Invalid metadata",
|
||||
metadata: { version: 1, arbitrary: true },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not move dependency-blocked issues to todo via POST comments", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
|
||||
mockIssueService.getDependencyReadiness.mockResolvedValue({
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS,
|
||||
buildContinuationSummaryMarkdown,
|
||||
continuationSummaryParksExecutor,
|
||||
extractContinuationSummaryNextAction,
|
||||
} from "../services/issue-continuation-summary.js";
|
||||
|
||||
describe("issue continuation summaries", () => {
|
||||
@@ -83,4 +85,31 @@ describe("issue continuation summaries", () => {
|
||||
expect(body).toContain("Latest run error (adapter_failed): adapter failed");
|
||||
expect(body).toContain("Inspect the failed run, fix the cause");
|
||||
});
|
||||
|
||||
it("detects continuation summaries that explicitly park executor work for review", () => {
|
||||
const body = [
|
||||
"# Continuation Summary",
|
||||
"",
|
||||
"## Next Action",
|
||||
"",
|
||||
"- Wait for reviewer feedback or approval before continuing executor work.",
|
||||
].join("\n");
|
||||
|
||||
expect(extractContinuationSummaryNextAction(body)).toBe(
|
||||
"Wait for reviewer feedback or approval before continuing executor work.",
|
||||
);
|
||||
expect(continuationSummaryParksExecutor(body)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not park executor work when the next action is still runnable", () => {
|
||||
const body = [
|
||||
"# Continuation Summary",
|
||||
"",
|
||||
"## Next Action",
|
||||
"",
|
||||
"- Re-check run `25145432006`, then move the issue to `in_review` if the final step is green.",
|
||||
].join("\n");
|
||||
|
||||
expect(continuationSummaryParksExecutor(body)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
update: vi.fn(),
|
||||
createChild: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
@@ -16,21 +17,33 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
triggerIssueMonitor: vi.fn(async () => ({ outcome: "triggered" as const })),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
}));
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
listApprovalsForIssue: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
@@ -42,6 +55,9 @@ function registerModuleMocks() {
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
environmentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
@@ -52,7 +68,7 @@ function registerModuleMocks() {
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueReferenceService: () => ({
|
||||
deleteDocumentSource: async () => undefined,
|
||||
diffIssueReferenceSummary: () => ({
|
||||
@@ -67,7 +83,8 @@ function registerModuleMocks() {
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
issueThreadInteractionService: () => mockIssueThreadInteractionService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
@@ -76,7 +93,22 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
type TestActor =
|
||||
| {
|
||||
type: "board";
|
||||
userId: string;
|
||||
companyIds: string[];
|
||||
source: "local_implicit";
|
||||
isInstanceAdmin: boolean;
|
||||
}
|
||||
| {
|
||||
type: "agent";
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
runId: string | null;
|
||||
};
|
||||
|
||||
async function createApp(actor?: TestActor) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
import("../middleware/index.js"),
|
||||
import("../routes/issues.js"),
|
||||
@@ -84,7 +116,7 @@ async function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
(req as any).actor = actor ?? {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
@@ -111,6 +143,229 @@ describe("issue execution policy routes", () => {
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
|
||||
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]);
|
||||
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]);
|
||||
mockIssueService.createChild.mockResolvedValue({
|
||||
issue: {
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1002",
|
||||
title: "Child issue",
|
||||
},
|
||||
parentBlockerAdded: false,
|
||||
});
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("rejects an agent-authored in_review transition without a review path", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1003",
|
||||
title: "Missing review path",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "in_review" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain("invalid_issue_disposition");
|
||||
expect(res.body.error).toContain("request_confirmation");
|
||||
expect(res.body.details).toMatchObject({
|
||||
code: "invalid_issue_disposition",
|
||||
missing: "review_path",
|
||||
});
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows an agent-authored in_review transition with a pending confirmation interaction", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1004",
|
||||
title: "Pending confirmation",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([
|
||||
{ id: "interaction-1", kind: "request_confirmation", status: "pending" },
|
||||
]);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "in_review" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({ status: "in_review" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an agent-authored in_review transition with a typed execution participant", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1005",
|
||||
title: "Execution participant",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "in_review", executionPolicy: policy });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
status: "in_review",
|
||||
executionState: expect.objectContaining({
|
||||
status: "pending",
|
||||
currentParticipant: expect.objectContaining({
|
||||
type: "agent",
|
||||
agentId: "44444444-4444-4444-8444-444444444444",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an agent-authored in_review transition with a scheduled monitor", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1006",
|
||||
title: "External review monitor",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
monitorAttemptCount: 0,
|
||||
monitorNextCheckAt: null,
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorNotes: null,
|
||||
monitorScheduledBy: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({
|
||||
status: "in_review",
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-12-01T12:00:00.000Z",
|
||||
scheduledBy: "assignee",
|
||||
notes: "Wait for external QA report.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
status: "in_review",
|
||||
monitorNextCheckAt: new Date("2026-12-01T12:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows board-authored in_review repair updates without a review path", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1007",
|
||||
title: "Board repair",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "in_review" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled();
|
||||
expect(mockIssueApprovalService.listApprovalsForIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
|
||||
@@ -162,4 +417,175 @@ describe("issue execution policy routes", () => {
|
||||
expect(updatePatch.executionState).toBeUndefined();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers a scheduled monitor immediately from the dedicated route", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1001",
|
||||
title: "Manual monitor trigger",
|
||||
executionPolicy: normalizeIssueExecutionPolicy({
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
}),
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/monitor/check-now")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(mockHeartbeatService.triggerIssueMonitor).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
agentId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a board user create a child issue with a scheduled monitor", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1001",
|
||||
title: "Parent issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
|
||||
.send({
|
||||
title: "Child monitor",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
scheduledBy: "assignee",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
|
||||
executionPolicy: { monitor: { scheduledBy: string } };
|
||||
};
|
||||
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("board");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.monitor_scheduled",
|
||||
details: expect.objectContaining({
|
||||
scheduledBy: "board",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects child monitor scheduling by a non-assignee agent even with task assignment permission", async () => {
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1001",
|
||||
title: "Parent issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
|
||||
.send({
|
||||
title: "Child monitor",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Only the assignee agent or a board user can manage issue monitors");
|
||||
expect(mockIssueService.createChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes spoofed child monitor scheduledBy to the assignee actor", async () => {
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1001",
|
||||
title: "Parent issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
|
||||
.send({
|
||||
title: "Child monitor",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
scheduledBy: "board",
|
||||
externalRef: "https://example.test/deploy?token=secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
|
||||
executionPolicy: { monitor: { scheduledBy: string; externalRef: string | null } };
|
||||
};
|
||||
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("assignee");
|
||||
expect(createPayload.executionPolicy.monitor.externalRef).toBe("[redacted]");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.monitor_scheduled",
|
||||
entityId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
details: expect.not.objectContaining({ externalRef: expect.anything() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,26 @@ describe("normalizeIssueExecutionPolicy", () => {
|
||||
it("throws for invalid input", () => {
|
||||
expect(() => normalizeIssueExecutionPolicy({ stages: [{ type: "invalid_type" }] })).toThrow();
|
||||
});
|
||||
|
||||
it("keeps monitor-only policies", () => {
|
||||
const result = normalizeIssueExecutionPolicy({
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
externalRef: "https://example.test/deploy?token=secret",
|
||||
},
|
||||
stages: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "assignee",
|
||||
externalRef: "[redacted]",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIssueExecutionState", () => {
|
||||
@@ -1261,4 +1281,169 @@ describe("issue execution policy transitions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitor policy", () => {
|
||||
it("schedules a one-shot monitor on an active agent-owned issue", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
})!;
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
monitorAttemptCount: 0,
|
||||
monitorNextCheckAt: null,
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorNotes: null,
|
||||
monitorScheduledBy: null,
|
||||
},
|
||||
policy,
|
||||
previousPolicy: null,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
monitorExplicitlyUpdated: true,
|
||||
});
|
||||
|
||||
expect(result.patch.monitorNextCheckAt).toEqual(new Date("2026-04-11T12:30:00.000Z"));
|
||||
expect(result.patch.monitorScheduledBy).toBe("board");
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "idle",
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-clears a scheduled monitor when the issue moves to done", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "assignee",
|
||||
},
|
||||
})!;
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 0,
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "assignee",
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
},
|
||||
monitorAttemptCount: 0,
|
||||
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorNotes: "Check deployment",
|
||||
monitorScheduledBy: "assignee",
|
||||
},
|
||||
policy,
|
||||
previousPolicy: policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch.executionPolicy).toBeNull();
|
||||
expect(result.patch.monitorNextCheckAt).toBeNull();
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
monitor: {
|
||||
status: "cleared",
|
||||
clearReason: "done",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects explicitly scheduling a monitor on an invalid issue state", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
},
|
||||
})!;
|
||||
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "blocked",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
previousPolicy: null,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
monitorExplicitlyUpdated: true,
|
||||
}),
|
||||
).toThrow("Monitor can only be scheduled");
|
||||
});
|
||||
|
||||
it("rejects explicitly re-arming a monitor after max attempts are exhausted", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2099-04-11T12:30:00.000Z",
|
||||
maxAttempts: 1,
|
||||
scheduledBy: "assignee",
|
||||
},
|
||||
})!;
|
||||
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
monitorAttemptCount: 1,
|
||||
monitorNextCheckAt: null,
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorNotes: null,
|
||||
monitorScheduledBy: "assignee",
|
||||
},
|
||||
policy,
|
||||
previousPolicy: null,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
monitorExplicitlyUpdated: true,
|
||||
}),
|
||||
).toThrow("Monitor bounds are already exhausted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { companies, createDb, issues } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres issue identifier route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issue identifier routes", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-identifier-routes-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function createApp(companyId: string) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "cloud-user-1",
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
|
||||
source: "cloud_tenant",
|
||||
isInstanceAdmin: true,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("resolves alphanumeric Cloud tenant issue identifiers for detail reads and updates", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Cloud tenant",
|
||||
issuePrefix: "PC1A2",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
issueNumber: 7,
|
||||
identifier: "PC1A2-7",
|
||||
title: "Tenant identifier route",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: "cloud-user-1",
|
||||
});
|
||||
|
||||
const app = createApp(companyId);
|
||||
const read = await request(app).get("/api/issues/pc1a2-7");
|
||||
|
||||
expect(read.status, JSON.stringify(read.body)).toBe(200);
|
||||
expect(read.body).toMatchObject({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PC1A2-7",
|
||||
});
|
||||
|
||||
const updated = await request(app)
|
||||
.patch("/api/issues/PC1A2-7")
|
||||
.send({ priority: "high" });
|
||||
|
||||
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
|
||||
expect(updated.body).toMatchObject({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PC1A2-7",
|
||||
priority: "high",
|
||||
});
|
||||
|
||||
const stored = await db
|
||||
.select({ priority: issues.priority })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(stored?.priority).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,73 @@ describe("issue graph liveness classifier", () => {
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects an assigned backlog blocker leaf with no action path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue(),
|
||||
issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Parked assigned unblock work",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "blocker-agent",
|
||||
}),
|
||||
],
|
||||
relations: blocks,
|
||||
agents: [
|
||||
agent(),
|
||||
manager,
|
||||
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({
|
||||
issueId: blockedId,
|
||||
identifier: "PAP-1703",
|
||||
state: "blocked_by_assigned_backlog_issue",
|
||||
recoveryIssueId: blockerId,
|
||||
recommendedOwnerAgentId: "blocker-agent",
|
||||
dependencyPath: [
|
||||
expect.objectContaining({ issueId: blockedId }),
|
||||
expect.objectContaining({ issueId: blockerId, status: "backlog" }),
|
||||
],
|
||||
incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_assigned_backlog_issue:${blockerId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flag an assigned backlog blocker that has an explicit waiting path", () => {
|
||||
const backlogBlocker = issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Explicitly parked unblock work",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "blocker-agent",
|
||||
});
|
||||
const baseInput = {
|
||||
issues: [issue(), backlogBlocker],
|
||||
relations: blocks,
|
||||
agents: [
|
||||
agent(),
|
||||
manager,
|
||||
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
|
||||
],
|
||||
};
|
||||
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
issues: [issue(), { ...backlogBlocker, assigneeAgentId: null, assigneeUserId: "board-user-1" }],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
activeRuns: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "running" }],
|
||||
})).toEqual([]);
|
||||
expect(classifyIssueGraphLiveness({
|
||||
...baseInput,
|
||||
openRecoveryIssues: [{ companyId, issueId: blockerId, status: "todo" }],
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag an unassigned blocker that already has an active execution path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
environmentLeases,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres issue monitor scheduler tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const seededAgentIds = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-monitor-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
async function waitForHeartbeatIdle(timeoutMs = 3_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const active = await db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`);
|
||||
if (active.length === 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("Timed out waiting for issue monitor heartbeat runs to settle");
|
||||
}
|
||||
|
||||
async function heartbeatSideEffectFingerprint() {
|
||||
const [active, events, activity, leases, runtimeServices] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(heartbeatRuns)
|
||||
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`),
|
||||
db.select({ count: sql<number>`count(*)` }).from(heartbeatRunEvents),
|
||||
db.select({ count: sql<number>`count(*)` }).from(activityLog),
|
||||
db.select({ count: sql<number>`count(*)` }).from(environmentLeases),
|
||||
db.select({ count: sql<number>`count(*)` }).from(workspaceRuntimeServices),
|
||||
]);
|
||||
|
||||
return [
|
||||
active[0]?.count ?? 0,
|
||||
events[0]?.count ?? 0,
|
||||
activity[0]?.count ?? 0,
|
||||
leases[0]?.count ?? 0,
|
||||
runtimeServices[0]?.count ?? 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
async function waitForHeartbeatSideEffectsSettled(timeoutMs = 5_000, quietMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let previous = "";
|
||||
let stableSince = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const current = await heartbeatSideEffectFingerprint();
|
||||
const activeCount = Number(current.split(":")[0] ?? 0);
|
||||
if (current !== previous || activeCount > 0) {
|
||||
previous = current;
|
||||
stableSince = Date.now();
|
||||
} else if (Date.now() - stableSince >= quietMs) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("Timed out waiting for issue monitor heartbeat side effects to settle");
|
||||
}
|
||||
|
||||
async function cleanupRows() {
|
||||
await waitForHeartbeatSideEffectsSettled();
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
seededAgentIds.clear();
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await cleanupRows();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture(input?: {
|
||||
agentStatus?: "active" | "paused";
|
||||
issueStatus?: "in_progress" | "in_review";
|
||||
monitorAttemptCount?: number;
|
||||
monitor?: Record<string, unknown>;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const nextCheckAt = new Date("2026-04-11T12:30:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
const monitorAttemptCount = input?.monitorAttemptCount ?? 0;
|
||||
const monitor = {
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
notes: "Check deploy",
|
||||
scheduledBy: "assignee",
|
||||
...(input?.monitor ?? {}),
|
||||
};
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Monitor Bot",
|
||||
role: "engineer",
|
||||
status: input?.agentStatus ?? "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
command: process.execPath,
|
||||
args: ["-e", ""],
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
wakeOnDemand: true,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
seededAgentIds.add(agentId);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Watch external deploy",
|
||||
status: input?.issueStatus ?? "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
monitor,
|
||||
},
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: monitorAttemptCount,
|
||||
notes: "Check deploy",
|
||||
scheduledBy: "assignee",
|
||||
serviceName: typeof monitor.serviceName === "string" ? monitor.serviceName : null,
|
||||
externalRef: typeof monitor.externalRef === "string" ? monitor.externalRef : null,
|
||||
timeoutAt: typeof monitor.timeoutAt === "string" ? monitor.timeoutAt : null,
|
||||
maxAttempts: typeof monitor.maxAttempts === "number" ? monitor.maxAttempts : null,
|
||||
recoveryPolicy: typeof monitor.recoveryPolicy === "string" ? monitor.recoveryPolicy : null,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
},
|
||||
monitorNextCheckAt: nextCheckAt,
|
||||
monitorAttemptCount,
|
||||
monitorNotes: "Check deploy",
|
||||
monitorScheduledBy: "assignee",
|
||||
});
|
||||
|
||||
return { companyId, agentId, issueId, nextCheckAt };
|
||||
}
|
||||
|
||||
it("triggers due issue monitors once and clears the one-shot schedule", async () => {
|
||||
const { issueId, agentId } = await seedFixture();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.enqueued).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(issue.monitorAttemptCount).toBe(1);
|
||||
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(tickAt.toISOString());
|
||||
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "triggered",
|
||||
lastTriggeredAt: tickAt.toISOString(),
|
||||
attemptCount: 1,
|
||||
});
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.reason).toBe("issue_monitor_due");
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.then((rows) => rows.map((row) => row.action));
|
||||
expect(activity).toContain("issue.monitor_triggered");
|
||||
});
|
||||
|
||||
it("lets the board trigger a scheduled issue monitor immediately", async () => {
|
||||
const { issueId, agentId, nextCheckAt } = await seedFixture();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const triggeredAt = new Date("2026-04-11T12:00:00.000Z");
|
||||
|
||||
const result = await heartbeat.triggerIssueMonitor(issueId, {
|
||||
now: triggeredAt,
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("triggered");
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(triggeredAt.toISOString());
|
||||
expect(issue.monitorAttemptCount).toBe(1);
|
||||
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.reason).toBe("issue_monitor_due");
|
||||
expect(wakeup?.payload).toMatchObject({
|
||||
issueId,
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.orderBy(activityLog.createdAt);
|
||||
expect(activity.map((row) => row.action)).toContain("issue.monitor_triggered");
|
||||
const triggerEvent = activity.find((row) => row.action === "issue.monitor_triggered");
|
||||
expect(triggerEvent?.actorType).toBe("user");
|
||||
expect(triggerEvent?.actorId).toBe("local-board");
|
||||
expect(triggerEvent?.details).toMatchObject({
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears due monitors that cannot be dispatched and records a skip", async () => {
|
||||
const { issueId } = await seedFixture({ agentStatus: "paused" });
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "cleared",
|
||||
clearReason: "dispatch_skipped",
|
||||
});
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.then((rows) => rows.map((row) => row.action));
|
||||
expect(activity).toContain("issue.monitor_skipped");
|
||||
});
|
||||
|
||||
it("clears exhausted monitors and queues bounded owner recovery instead of another due check", async () => {
|
||||
const { issueId, agentId } = await seedFixture({
|
||||
monitorAttemptCount: 1,
|
||||
monitor: {
|
||||
maxAttempts: 1,
|
||||
recoveryPolicy: "wake_owner",
|
||||
},
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "cleared",
|
||||
clearReason: "max_attempts_exhausted",
|
||||
});
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.reason).toBe("issue_monitor_recovery");
|
||||
expect(wakeup?.payload).toMatchObject({
|
||||
issueId,
|
||||
clearReason: "max_attempts_exhausted",
|
||||
maxAttempts: 1,
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.then((rows) => rows.map((row) => row.action));
|
||||
expect(activity).toContain("issue.monitor_exhausted");
|
||||
expect(activity).toContain("issue.monitor_recovery_wake_queued");
|
||||
expect(activity).not.toContain("issue.monitor_triggered");
|
||||
});
|
||||
|
||||
it("clears timed-out monitors and creates a visible recovery issue when requested", async () => {
|
||||
const { issueId, companyId } = await seedFixture({
|
||||
monitor: {
|
||||
timeoutAt: "2026-04-11T12:00:00.000Z",
|
||||
recoveryPolicy: "create_recovery_issue",
|
||||
},
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "cleared",
|
||||
clearReason: "timeout_exceeded",
|
||||
});
|
||||
|
||||
const recoveryIssue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.originId, issueId))
|
||||
.then((rows) => rows.find((row) => row.companyId === companyId && row.originKind === "stranded_issue_recovery") ?? null);
|
||||
expect(recoveryIssue).toMatchObject({
|
||||
parentId: issueId,
|
||||
priority: "high",
|
||||
assigneeAdapterOverrides: { modelProfile: "cheap" },
|
||||
});
|
||||
expect(["todo", "in_progress"]).toContain(recoveryIssue?.status);
|
||||
});
|
||||
|
||||
it("omits external monitor refs from wake payloads and activity details", async () => {
|
||||
const { issueId, agentId } = await seedFixture({
|
||||
monitor: {
|
||||
serviceName: "Deploy provider",
|
||||
externalRef: "https://provider.example/deploy/123?token=secret",
|
||||
},
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
await heartbeat.tickTimers(tickAt);
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(JSON.stringify(wakeup?.payload)).not.toContain("provider.example");
|
||||
expect(wakeup?.payload).not.toHaveProperty("externalRef");
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId));
|
||||
expect(JSON.stringify(activity.map((row) => row.details))).not.toContain("provider.example");
|
||||
expect(activity.find((row) => row.action === "issue.monitor_triggered")?.details).not.toHaveProperty("externalRef");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueRelations,
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres scheduled retry route tests on this host: ${
|
||||
embeddedPostgresSupport.reason ?? "unsupported environment"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issue scheduled retry routes", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-scheduled-retry-routes-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueTreeHolds);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function createApp(actor: Express.Request["actor"]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function boardActor(companyId: string): Express.Request["actor"] {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "admin", status: "active" }],
|
||||
isInstanceAdmin: false,
|
||||
source: "session",
|
||||
};
|
||||
}
|
||||
|
||||
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: randomUUID(),
|
||||
source: "agent_jwt",
|
||||
};
|
||||
}
|
||||
|
||||
async function seedIssueWithRetry(input: {
|
||||
agentStatus?: "active" | "paused";
|
||||
retryStatus?: "scheduled_retry" | "queued" | "running";
|
||||
issueStatus?: "in_progress" | "todo" | "done" | "cancelled";
|
||||
} = {}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const sourceRunId = randomUUID();
|
||||
const retryRunId = randomUUID();
|
||||
const wakeupRequestId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const now = new Date("2026-05-06T18:00:00.000Z");
|
||||
const scheduledRetryAt = new Date("2026-05-06T19:00:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: input.agentStatus ?? "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
wakeOnDemand: true,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: "failed",
|
||||
error: "transient upstream error",
|
||||
errorCode: "adapter_failed",
|
||||
finishedAt: now,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
id: wakeupRequestId,
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "bounded_transient_heartbeat_retry",
|
||||
payload: {
|
||||
issueId,
|
||||
retryOfRunId: sourceRunId,
|
||||
scheduledRetryAt: scheduledRetryAt.toISOString(),
|
||||
},
|
||||
status: "queued",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: retryRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: input.retryStatus ?? "scheduled_retry",
|
||||
wakeupRequestId,
|
||||
retryOfRunId: sourceRunId,
|
||||
scheduledRetryAt,
|
||||
scheduledRetryAttempt: 2,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "bounded_transient_heartbeat_retry",
|
||||
retryOfRunId: sourceRunId,
|
||||
scheduledRetryAt: scheduledRetryAt.toISOString(),
|
||||
scheduledRetryAttempt: 2,
|
||||
retryReason: "transient_failure",
|
||||
},
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
await db
|
||||
.update(agentWakeupRequests)
|
||||
.set({ runId: retryRunId })
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId));
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Retryable issue",
|
||||
status: input.issueStatus ?? "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
executionRunId: retryRunId,
|
||||
executionAgentNameKey: "codexcoder",
|
||||
executionLockedAt: now,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
return { companyId, agentId, issueId, sourceRunId, retryRunId, scheduledRetryAt };
|
||||
}
|
||||
|
||||
it("surfaces the current scheduled retry in the issue read model", async () => {
|
||||
const { companyId, issueId, agentId, sourceRunId, retryRunId, scheduledRetryAt } = await seedIssueWithRetry();
|
||||
|
||||
const res = await request(createApp(boardActor(companyId))).get(`/api/issues/${issueId}`);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.scheduledRetry).toMatchObject({
|
||||
runId: retryRunId,
|
||||
status: "scheduled_retry",
|
||||
agentId,
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: sourceRunId,
|
||||
scheduledRetryAttempt: 2,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
});
|
||||
expect(res.body.scheduledRetry.scheduledRetryAt).toBe(scheduledRetryAt.toISOString());
|
||||
});
|
||||
|
||||
it("promotes the existing scheduled retry and treats duplicate clicks as idempotent", async () => {
|
||||
const { companyId, issueId, retryRunId } = await seedIssueWithRetry();
|
||||
const app = createApp(boardActor(companyId));
|
||||
|
||||
const first = await request(app).post(`/api/issues/${issueId}/scheduled-retry/retry-now`).send({});
|
||||
|
||||
expect(first.status, JSON.stringify(first.body)).toBe(200);
|
||||
expect(first.body).toMatchObject({
|
||||
outcome: "promoted",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
|
||||
const second = await request(app).post(`/api/issues/${issueId}/scheduled-retry/retry-now`).send({});
|
||||
|
||||
expect(second.status, JSON.stringify(second.body)).toBe(200);
|
||||
expect(second.body).toMatchObject({
|
||||
outcome: "already_promoted",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
|
||||
const retryRuns = await db
|
||||
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.retryOfRunId, first.body.scheduledRetry.retryOfRunId), eq(heartbeatRuns.companyId, companyId)));
|
||||
expect(retryRuns).toHaveLength(1);
|
||||
expect(retryRuns[0]).toMatchObject({ id: retryRunId, status: "queued" });
|
||||
});
|
||||
|
||||
it("returns a clear no-op response when there is no scheduled retry", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "NONE",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "No retry",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "NONE-1",
|
||||
});
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "no_scheduled_retry",
|
||||
scheduledRetry: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports already-promoted retries without creating another run", async () => {
|
||||
const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ retryStatus: "queued" });
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "already_promoted",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses normal promotion gates and records gate-suppressed retries", async () => {
|
||||
const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ agentStatus: "paused" });
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "gate_suppressed",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "cancelled",
|
||||
errorCode: "agent_not_invokable",
|
||||
},
|
||||
});
|
||||
|
||||
const [run] = await db
|
||||
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, retryRunId));
|
||||
expect(run).toEqual({ status: "cancelled", errorCode: "agent_not_invokable" });
|
||||
|
||||
const [activity] = await db
|
||||
.select({ action: activityLog.action, entityId: activityLog.entityId, runId: activityLog.runId })
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId));
|
||||
expect(activity).toEqual({
|
||||
action: "issue.scheduled_retry_retry_now",
|
||||
entityId: issueId,
|
||||
runId: retryRunId,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires board access for retry-now", async () => {
|
||||
const { companyId, agentId, issueId } = await seedIssueWithRetry();
|
||||
|
||||
const res = await request(createApp(agentActor(companyId, agentId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("enforces company scoping for retry-now", async () => {
|
||||
const { issueId } = await seedIssueWithRetry();
|
||||
|
||||
const res = await request(createApp(boardActor(randomUUID())))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("suppresses retry-now when the issue is under a budget hard-stop", async () => {
|
||||
const { companyId, agentId, issueId, retryRunId } = await seedIssueWithRetry();
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "paused", pauseReason: "budget" })
|
||||
.where(eq(agents.id, agentId));
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "gate_suppressed",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "cancelled",
|
||||
errorCode: "budget_blocked",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses retry-now when the issue is waiting on another review participant", async () => {
|
||||
const { companyId, agentId, issueId, retryRunId } = await seedIssueWithRetry({ issueStatus: "in_progress" });
|
||||
const reviewerAgentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: reviewerAgentId,
|
||||
companyId,
|
||||
name: "ReviewerAgent",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
wakeOnDemand: true,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "in_review",
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: randomUUID(),
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId, userId: null },
|
||||
returnAssignee: { type: "agent", agentId, userId: null },
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "gate_suppressed",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "cancelled",
|
||||
errorCode: "issue_review_participant_changed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses retry-now when the issue is under an active subtree pause hold", async () => {
|
||||
const { companyId, issueId, retryRunId } = await seedIssueWithRetry();
|
||||
await db.insert(issueTreeHolds).values({
|
||||
companyId,
|
||||
rootIssueId: issueId,
|
||||
mode: "pause",
|
||||
status: "active",
|
||||
reason: "manual pause for review",
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "gate_suppressed",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "cancelled",
|
||||
errorCode: "issue_paused",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses retry-now when unresolved blockers remain", async () => {
|
||||
const { companyId, issueId, retryRunId } = await seedIssueWithRetry();
|
||||
const blockerId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: blockerId,
|
||||
companyId,
|
||||
title: "Blocking task",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
issueNumber: 2,
|
||||
identifier: "BLOCK-2",
|
||||
});
|
||||
await db.insert(issueRelations).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: blockerId,
|
||||
relatedIssueId: issueId,
|
||||
type: "blocks",
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "blocked" })
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "gate_suppressed",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "cancelled",
|
||||
errorCode: "issue_dependencies_blocked",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses retry-now when the issue already reached a terminal status", async () => {
|
||||
const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ issueStatus: "done" });
|
||||
|
||||
const res = await request(createApp(boardActor(companyId)))
|
||||
.post(`/api/issues/${issueId}/scheduled-retry/retry-now`)
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
outcome: "gate_suppressed",
|
||||
scheduledRetry: {
|
||||
runId: retryRunId,
|
||||
status: "cancelled",
|
||||
errorCode: "issue_terminal_status",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
@@ -110,6 +111,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
{
|
||||
clientKey: "root",
|
||||
title: "Create the root follow-up",
|
||||
workMode: "planning",
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
@@ -153,6 +155,19 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
status: "todo",
|
||||
}),
|
||||
]);
|
||||
const createdIssueRows = await db
|
||||
.select({
|
||||
title: issues.title,
|
||||
workMode: issues.workMode,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId));
|
||||
expect(createdIssueRows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "Create the root follow-up", workMode: "planning" }),
|
||||
expect.objectContaining({ title: "Create the nested follow-up", workMode: "standard" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const children = await issuesSvc.list(companyId, { parentId: issueId });
|
||||
expect(children).toHaveLength(1);
|
||||
|
||||
@@ -13,6 +13,7 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getComment: vi.fn(),
|
||||
listBlockerAttention: vi.fn(),
|
||||
listProductivityReviews: vi.fn(),
|
||||
getCurrentScheduledRetry: vi.fn(),
|
||||
listAttachments: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -91,6 +92,11 @@ const mockWorkProductService = vi.hoisted(() => ({
|
||||
|
||||
const mockEnvironmentService = vi.hoisted(() => ({}));
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
@@ -130,7 +136,7 @@ function createApp() {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use("/api", issueRoutes(mockDb as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
@@ -142,6 +148,7 @@ const legacyProjectLinkedIssue = {
|
||||
title: "Legacy onboarding task",
|
||||
description: "Seed the first CEO task",
|
||||
status: "todo",
|
||||
workMode: "planning",
|
||||
priority: "medium",
|
||||
projectId: "22222222-2222-4222-8222-222222222222",
|
||||
goalId: null,
|
||||
@@ -182,10 +189,19 @@ describe.sequential("issue goal context routes", () => {
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockIssueService.listBlockerAttention.mockResolvedValue(new Map());
|
||||
mockIssueService.listProductivityReviews.mockResolvedValue(new Map());
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
|
||||
mockIssueService.listAttachments.mockResolvedValue([]);
|
||||
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
|
||||
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
orderBy: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
mockDb.execute.mockResolvedValue([]);
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: legacyProjectLinkedIssue.projectId,
|
||||
companyId: "company-1",
|
||||
@@ -251,6 +267,7 @@ describe.sequential("issue goal context routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.issue.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.issue.workMode).toBe("planning");
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
@@ -459,14 +460,14 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
expect(result.map((issue) => issue.id)).toEqual([grandchildId]);
|
||||
});
|
||||
|
||||
it("accepts issue identifiers through getById", async () => {
|
||||
it("accepts issue identifiers with alphanumeric prefixes through getById", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
issuePrefix: "PC1A2",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
@@ -474,19 +475,19 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
id: issueId,
|
||||
companyId,
|
||||
issueNumber: 1064,
|
||||
identifier: "PAP-1064",
|
||||
identifier: "PC1A2-1064",
|
||||
title: "Feedback votes error",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: "user-1",
|
||||
});
|
||||
|
||||
const issue = await svc.getById("PAP-1064");
|
||||
const issue = await svc.getById("pc1a2-1064");
|
||||
|
||||
expect(issue).toEqual(
|
||||
expect.objectContaining({
|
||||
id: issueId,
|
||||
identifier: "PAP-1064",
|
||||
identifier: "PC1A2-1064",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -656,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
|
||||
});
|
||||
|
||||
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const normalIssueId = randomUUID();
|
||||
const pluginVisibleIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Plugin Runner",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Plugin operations",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: normalIssueId,
|
||||
companyId,
|
||||
title: "Normal issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: pluginVisibleIssueId,
|
||||
companyId,
|
||||
title: "Plugin-visible issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclip.missions:feature",
|
||||
},
|
||||
{
|
||||
id: operationIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Plugin operation issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
originId: "mission-alpha:operation-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
||||
expect(defaultIssueIds).toContain(normalIssueId);
|
||||
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
|
||||
expect(defaultIssueIds).not.toContain(operationIssueId);
|
||||
|
||||
const inboxIssueIds = (await svc.list(companyId, {
|
||||
assigneeAgentId: agentId,
|
||||
status: "todo,in_progress,blocked",
|
||||
includeRoutineExecutions: true,
|
||||
})).map((issue) => issue.id);
|
||||
expect(inboxIssueIds).toContain(normalIssueId);
|
||||
expect(inboxIssueIds).not.toContain(operationIssueId);
|
||||
|
||||
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
|
||||
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
||||
expect(projectIssueIds).toContain(operationIssueId);
|
||||
|
||||
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
||||
expect(advancedIssueIds).toContain(operationIssueId);
|
||||
});
|
||||
|
||||
it("excludes plugin operation issues from unread inbox counts", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
const otherUserId = "other-user";
|
||||
const normalIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: normalIssueId,
|
||||
companyId,
|
||||
title: "Normal touched issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
},
|
||||
{
|
||||
id: operationIssueId,
|
||||
companyId,
|
||||
title: "Plugin operation touched issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
},
|
||||
]);
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
companyId,
|
||||
issueId: normalIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread normal update.",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
issueId: operationIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread operation update.",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "user-1";
|
||||
@@ -1184,6 +1322,351 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("captures the assignee default environment when neither issue nor project specifies one", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const assigneeEnvironmentId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{
|
||||
id: assigneeEnvironmentId,
|
||||
companyId,
|
||||
name: "QA E2B",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "e2b" },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA E2B Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: assigneeEnvironmentId,
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId,
|
||||
title: "Environment matrix: e2b / codex_local",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
expect(issue.executionWorkspaceSettings).toEqual({
|
||||
mode: "shared_workspace",
|
||||
environmentId: assigneeEnvironmentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote the assignee default environment when the project policy already specifies one", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const projectEnvironmentId = randomUUID();
|
||||
const assigneeEnvironmentId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{
|
||||
id: projectEnvironmentId,
|
||||
companyId,
|
||||
name: "QA SSH",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: assigneeEnvironmentId,
|
||||
companyId,
|
||||
name: "QA E2B",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "e2b" },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA E2B Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: assigneeEnvironmentId,
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
environmentId: projectEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId,
|
||||
title: "Environment matrix: e2b / codex_local",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
// Project policy's environmentId must win over the assignee's default;
|
||||
// executionWorkspaceSettings should not bake in an environmentId in this case
|
||||
// so resolveExecutionWorkspaceEnvironmentId can fall through to the project
|
||||
// policy's value at run time.
|
||||
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
|
||||
});
|
||||
|
||||
it("captures the new assignee's default environment on reassignment", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const firstEnvironmentId = randomUUID();
|
||||
const secondEnvironmentId = randomUUID();
|
||||
const firstAgentId = randomUUID();
|
||||
const secondAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{
|
||||
id: firstEnvironmentId,
|
||||
companyId,
|
||||
name: "QA SSH",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: secondEnvironmentId,
|
||||
companyId,
|
||||
name: "QA E2B",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "e2b" },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: firstAgentId,
|
||||
companyId,
|
||||
name: "QA SSH Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: firstEnvironmentId,
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondAgentId,
|
||||
companyId,
|
||||
name: "QA E2B Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: secondEnvironmentId,
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const created = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId: firstAgentId,
|
||||
title: "Environment matrix: ssh / codex_local",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
expect(created.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: firstEnvironmentId,
|
||||
});
|
||||
|
||||
const reassigned = await svc.update(created.id, {
|
||||
assigneeAgentId: secondAgentId,
|
||||
});
|
||||
|
||||
expect(reassigned).not.toBeNull();
|
||||
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: secondEnvironmentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an operator-set environmentId across reassignment", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const firstEnvironmentId = randomUUID();
|
||||
const secondEnvironmentId = randomUUID();
|
||||
const operatorEnvironmentId = randomUUID();
|
||||
const firstAgentId = randomUUID();
|
||||
const secondAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{ id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} },
|
||||
{ id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } },
|
||||
{ id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} },
|
||||
]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active",
|
||||
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
||||
defaultEnvironmentId: firstEnvironmentId, permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active",
|
||||
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
||||
defaultEnvironmentId: secondEnvironmentId, permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId, companyId, name: "Workspace project", status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true,
|
||||
});
|
||||
|
||||
const created = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId: firstAgentId,
|
||||
title: "Operator overrides env then reassigns",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
// Operator explicitly overrides the environmentId in a separate update.
|
||||
const overridden = await svc.update(created.id, {
|
||||
executionWorkspaceSettings: {
|
||||
mode: "shared_workspace",
|
||||
environmentId: operatorEnvironmentId,
|
||||
},
|
||||
});
|
||||
expect(overridden!.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: operatorEnvironmentId,
|
||||
});
|
||||
|
||||
// A subsequent reassignment-only update must NOT overwrite the operator's
|
||||
// explicit choice with the new assignee's default.
|
||||
const reassigned = await svc.update(created.id, {
|
||||
assigneeAgentId: secondAgentId,
|
||||
});
|
||||
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: operatorEnvironmentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
validatePluginRuntimeExecute,
|
||||
validatePluginRuntimeQuery,
|
||||
} from "../services/plugin-database.js";
|
||||
import { pluginLoader } from "../services/plugin-loader.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const multiMigrationPluginKey = "paperclip.dbfixture";
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
@@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) {
|
||||
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) {
|
||||
const namespace = derivePluginDatabaseNamespace(pluginKey);
|
||||
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
|
||||
}
|
||||
@@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
async function createInstallablePluginPackage(
|
||||
pluginManifest: PaperclipPluginManifestV1,
|
||||
migrationSql: string,
|
||||
) {
|
||||
const packageRoot = await createPluginPackage(pluginManifest, migrationSql);
|
||||
await writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
type: "module",
|
||||
paperclipPlugin: { manifest: "./manifest.js" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(packageRoot, "manifest.js"),
|
||||
`export default ${JSON.stringify(pluginManifest, null, 2)};\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8");
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
@@ -158,6 +185,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("applies multi-file plugin migrations through the production validator", async () => {
|
||||
const pluginManifest = manifest(multiMigrationPluginKey);
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
const packageRoot = await createPluginPackage(
|
||||
pluginManifest,
|
||||
`CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"),
|
||||
`CREATE TABLE ${namespace}.derived_rows (
|
||||
id uuid PRIMARY KEY,
|
||||
source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id)
|
||||
);`,
|
||||
"utf8",
|
||||
);
|
||||
const pluginId = await installPluginRecord(pluginManifest);
|
||||
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
|
||||
|
||||
const migrations = await db
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
|
||||
expect(migrations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
|
||||
const pluginManifest = manifest();
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
@@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||
expect(migration?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("rolls back plugin install when migration validation fails", async () => {
|
||||
const pluginManifest = manifest("paperclip.escape");
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
const packageRoot = await createInstallablePluginPackage(
|
||||
pluginManifest,
|
||||
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
|
||||
);
|
||||
const loader = pluginLoader(db, {
|
||||
enableLocalFilesystem: false,
|
||||
enableNpmDiscovery: false,
|
||||
});
|
||||
|
||||
await expect(loader.installPlugin({ localPath: packageRoot }))
|
||||
.rejects.toThrow(/public\.plugin_escape|public/i);
|
||||
|
||||
const installedPlugins = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.pluginKey, pluginManifest.id));
|
||||
const namespaces = await db
|
||||
.select()
|
||||
.from(pluginDatabaseNamespaces)
|
||||
.where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id));
|
||||
const migrations = await db
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginKey, pluginManifest.id));
|
||||
const schemaRows = Array.from(
|
||||
await db.execute(
|
||||
sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`,
|
||||
) as Iterable<{ schema_name: string }>,
|
||||
);
|
||||
|
||||
expect(installedPlugins).toHaveLength(0);
|
||||
expect(namespaces).toHaveLength(0);
|
||||
expect(migrations).toHaveLength(0);
|
||||
expect(schemaRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refreshes persisted manifests from disk before activation", async () => {
|
||||
const staleManifest = manifest("paperclip.refresh");
|
||||
const refreshedManifest: PaperclipPluginManifestV1 = {
|
||||
...staleManifest,
|
||||
database: {
|
||||
...staleManifest.database!,
|
||||
coreReadTables: ["companies"],
|
||||
},
|
||||
};
|
||||
const namespace = derivePluginDatabaseNamespace(refreshedManifest.id);
|
||||
const packageRoot = await createInstallablePluginPackage(
|
||||
refreshedManifest,
|
||||
`
|
||||
CREATE TABLE ${namespace}.company_refs (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id)
|
||||
);
|
||||
`,
|
||||
);
|
||||
const pluginId = await installPluginRecord(staleManifest);
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({
|
||||
packagePath: packageRoot,
|
||||
status: "ready",
|
||||
})
|
||||
.where(eq(plugins.id, pluginId));
|
||||
|
||||
const workerManager = {
|
||||
startWorker: vi.fn().mockResolvedValue(undefined),
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const loader = pluginLoader(db, {
|
||||
enableLocalFilesystem: false,
|
||||
enableNpmDiscovery: false,
|
||||
}, {
|
||||
workerManager,
|
||||
eventBus: {
|
||||
forPlugin: vi.fn(() => ({})),
|
||||
subscriptionCount: vi.fn(() => 0),
|
||||
},
|
||||
jobScheduler: {
|
||||
registerPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
jobStore: {
|
||||
syncJobDeclarations: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
toolDispatcher: {
|
||||
registerPluginTools: vi.fn(),
|
||||
},
|
||||
lifecycleManager: {
|
||||
markError: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
buildHostHandlers: vi.fn(() => ({})),
|
||||
instanceInfo: {
|
||||
instanceId: "test-instance",
|
||||
hostVersion: "1.0.0",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await loader.loadSingle(pluginId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(workerManager.startWorker).toHaveBeenCalledWith(
|
||||
pluginId,
|
||||
expect.objectContaining({
|
||||
databaseNamespace: namespace,
|
||||
env: {
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
||||
},
|
||||
manifest: expect.objectContaining({
|
||||
database: expect.objectContaining({ coreReadTables: ["companies"] }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [plugin] = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, pluginId));
|
||||
expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]);
|
||||
});
|
||||
|
||||
it("rejects checksum changes for already applied migrations", async () => {
|
||||
const pluginManifest = manifest();
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import {
|
||||
assertConfiguredLocalFolder,
|
||||
assertWritableConfiguredLocalFolder,
|
||||
inspectPluginLocalFolder,
|
||||
listPluginLocalFolderEntries,
|
||||
preparePluginLocalFolder,
|
||||
readPluginLocalFolderText,
|
||||
resolvePluginLocalFolderPath,
|
||||
deletePluginLocalFolderFile,
|
||||
writePluginLocalFolderTextAtomic,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
|
||||
describe("plugin local folders", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function makeRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-folder-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reports a healthy generic folder when required paths exist", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "sources"));
|
||||
await fs.writeFile(path.join(root, "schema.md"), "schema", "utf8");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(true);
|
||||
expect(status.problems).toEqual([]);
|
||||
expect(status.requiredDirectories).toEqual(["sources"]);
|
||||
expect(status.requiredFiles).toEqual(["schema.md"]);
|
||||
});
|
||||
|
||||
it("reports missing required folders and files without using product-specific branches", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.missingDirectories).toEqual(["sources"]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
expect(status.problems.map((item) => item.code)).toEqual(
|
||||
expect.arrayContaining(["missing_directory", "missing_file"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports all required paths as missing when the configured root does not exist", async () => {
|
||||
const root = await makeRoot();
|
||||
const missingRoot = path.join(root, "missing-root");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: missingRoot,
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.readable).toBe(false);
|
||||
expect(status.missingDirectories).toEqual(["sources"]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
expect(status.problems.map((item) => item.code)).toContain("missing");
|
||||
});
|
||||
|
||||
it("uses manifest declaration access and required paths over stored or caller overrides", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "manifest-dir"));
|
||||
await fs.writeFile(path.join(root, "manifest.md"), "schema", "utf8");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
declaration: {
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "read",
|
||||
requiredDirectories: ["manifest-dir"],
|
||||
requiredFiles: ["manifest.md"],
|
||||
},
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["stored-dir"],
|
||||
requiredFiles: ["stored.md"],
|
||||
},
|
||||
overrideConfig: {
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["override-dir"],
|
||||
requiredFiles: ["override.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.access).toBe("read");
|
||||
expect(status.writable).toBe(false);
|
||||
expect(status.requiredDirectories).toEqual(["manifest-dir"]);
|
||||
expect(status.requiredFiles).toEqual(["manifest.md"]);
|
||||
expect(status.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("prepares required directories for a read-write folder without creating required files", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
await preparePluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "wiki/concepts"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.stat(path.join(root, "sources"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "schema.md"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "wiki/concepts"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
expect(status.missingDirectories).toEqual([]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
});
|
||||
|
||||
it("allows write access to repair folders that are only missing required paths", async () => {
|
||||
const root = await makeRoot();
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(() => assertConfiguredLocalFolder(status)).toThrow("Local folder is not healthy");
|
||||
expect(() => assertWritableConfiguredLocalFolder(status)).not.toThrow();
|
||||
|
||||
await writePluginLocalFolderTextAtomic(root, "schema.md", "schema");
|
||||
const repaired = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
expect(repaired.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects traversal outside the configured folder", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
await expect(resolvePluginLocalFolderPath(root, "../outside.txt")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects required symlinks that escape the configured folder", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "nope", "utf8");
|
||||
await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "linked.txt"));
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
requiredFiles: ["linked.txt"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.problems.some((item) => item.code === "symlink_escape")).toBe(true);
|
||||
});
|
||||
|
||||
it("writes files atomically under the root and can read them back", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "nested"));
|
||||
|
||||
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "hello");
|
||||
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "updated");
|
||||
|
||||
await expect(readPluginLocalFolderText(root, "nested/page.md")).resolves.toBe("updated");
|
||||
const leftovers = await fs.readdir(path.join(root, "nested"));
|
||||
expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the real folder key after deleting a file", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8");
|
||||
|
||||
const status = await deletePluginLocalFolderFile(root, "stale.md", "content-root");
|
||||
|
||||
expect(status.folderKey).toBe("content-root");
|
||||
await expect(fs.stat(path.join(root, "stale.md"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("lists nested local folder entries without following symlink escapes", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "wiki/concepts"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "wiki/concepts/live.md"), "# Live\n", "utf8");
|
||||
await fs.writeFile(path.join(outside, "secret.md"), "# Secret\n", "utf8");
|
||||
await fs.symlink(outside, path.join(root, "wiki/outside"));
|
||||
|
||||
const listing = await listPluginLocalFolderEntries(root, {
|
||||
relativePath: "wiki",
|
||||
recursive: true,
|
||||
maxEntries: 20,
|
||||
});
|
||||
|
||||
expect(listing.entries.map((entry) => entry.path)).toContain("wiki/concepts/live.md");
|
||||
expect(listing.entries.map((entry) => entry.path)).not.toContain("wiki/outside/secret.md");
|
||||
expect(listing.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("revalidates temp-file containment before writing atomic contents", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
const nested = path.join(root, "nested");
|
||||
await fs.mkdir(nested);
|
||||
const originalOpen = fs.open.bind(fs);
|
||||
const openSpy = vi.spyOn(fs, "open");
|
||||
openSpy.mockImplementationOnce(async (file, flags, mode) => {
|
||||
await fs.rm(nested, { recursive: true, force: true });
|
||||
await fs.symlink(outside, nested);
|
||||
return originalOpen(file, flags, mode);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(writePluginLocalFolderTextAtomic(root, "nested/page.md", "secret")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
await expect(fs.readFile(path.join(outside, "page.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await fs.readdir(outside)).toEqual([]);
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
createDb,
|
||||
pluginEntities,
|
||||
pluginCompanySettings,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: async () => {},
|
||||
subscribe: () => {},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function manifest(): PaperclipPluginManifestV1 {
|
||||
return {
|
||||
id: "paperclip.managed-agents-test",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Managed Agents Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["agents.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
agents: [
|
||||
{
|
||||
agentKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
title: "Maintains plugin-owned knowledge",
|
||||
capabilities: "Maintains a plugin-owned wiki.",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
runtimeConfig: { modelProfiles: { cheap: { enabled: true, adapterConfig: { model: "small" } } } },
|
||||
permissions: { canCreateAgents: false },
|
||||
budgetMonthlyCents: 1234,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(pluginEntities);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(pluginCompanySettings);
|
||||
await db.delete(approvals);
|
||||
await db.delete(agents);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompanyAndPlugin(options: { requireApproval?: boolean; manifest?: PaperclipPluginManifestV1 } = {}) {
|
||||
const companyId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
const pluginManifest = options.manifest ?? manifest();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
requireBoardApprovalForNewAgents: options.requireApproval ?? false,
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
packageName: "@paperclipai/plugin-managed-agents-test",
|
||||
version: pluginManifest.version,
|
||||
apiVersion: pluginManifest.apiVersion,
|
||||
categories: pluginManifest.categories,
|
||||
manifestJson: pluginManifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
return { companyId, pluginId, pluginManifest, services };
|
||||
}
|
||||
|
||||
it("creates and resolves managed agents by stable resource key", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
|
||||
const created = await services.agents.managedReconcile({
|
||||
companyId,
|
||||
agentKey: "wiki-maintainer",
|
||||
});
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agentId).toBeTruthy();
|
||||
expect(created.agent).toMatchObject({
|
||||
name: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
});
|
||||
|
||||
const resolved = await services.agents.managedGet({
|
||||
companyId,
|
||||
agentKey: "wiki-maintainer",
|
||||
});
|
||||
expect(resolved.status).toBe("resolved");
|
||||
expect(resolved.agentId).toBe(created.agentId);
|
||||
|
||||
const [binding] = await db.select().from(pluginEntities);
|
||||
expect(binding?.entityType).toBe("managed_agent");
|
||||
expect(binding?.scopeKind).toBe("company");
|
||||
expect(binding?.scopeId).toBe(companyId);
|
||||
expect(binding?.data).toMatchObject({
|
||||
resourceKind: "agent",
|
||||
resourceKey: "wiki-maintainer",
|
||||
agentId: created.agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves user edits during reconcile and resets only on explicit reset", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(created.agentId).toBeTruthy();
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
name: "Knowledge Lead",
|
||||
adapterConfig: { command: "custom" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, created.agentId!));
|
||||
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(reconciled.agent).toMatchObject({
|
||||
name: "Knowledge Lead",
|
||||
adapterConfig: { command: "custom" },
|
||||
});
|
||||
|
||||
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reset.status).toBe("reset");
|
||||
expect(reset.agent).toMatchObject({
|
||||
name: "Wiki Maintainer",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates managed agents with the most-used compatible company adapter", async () => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
adapterType: "claude_local",
|
||||
adapterPreference: ["claude_local", "codex_local"],
|
||||
adapterConfig: {},
|
||||
};
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Codex One",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Codex Two",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Claude One",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agent?.adapterType).toBe("codex_local");
|
||||
});
|
||||
|
||||
it("materializes declared managed agent instructions with local folder paths", async () => {
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
|
||||
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
|
||||
process.env.PAPERCLIP_HOME = tempHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
||||
try {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.localFolders = [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: [],
|
||||
requiredFiles: ["AGENTS.md"],
|
||||
},
|
||||
];
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
instructions: {
|
||||
entryFile: "AGENTS.md",
|
||||
content: [
|
||||
"# LLM Wiki Maintainer",
|
||||
"",
|
||||
"You are the LLM Wiki Maintainer.",
|
||||
"Wiki root: `{{localFolders.wiki-root.path}}`",
|
||||
"Wiki schema: `{{localFolders.wiki-root.agentsPath}}`",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
};
|
||||
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
||||
await fs.writeFile(path.join(wikiRoot, "AGENTS.md"), "# Wiki schema\n", "utf8");
|
||||
await db.insert(pluginCompanySettings).values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: {
|
||||
localFolders: {
|
||||
"wiki-root": {
|
||||
path: wikiRoot,
|
||||
access: "readWrite",
|
||||
requiredDirectories: [],
|
||||
requiredFiles: ["AGENTS.md"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
const instructionsFilePath = created.agent?.adapterConfig.instructionsFilePath;
|
||||
expect(typeof instructionsFilePath).toBe("string");
|
||||
const content = await fs.readFile(instructionsFilePath as string, "utf8");
|
||||
expect(content).toContain("You are the LLM Wiki Maintainer.");
|
||||
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
|
||||
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
await fs.rm(wikiRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
|
||||
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Renamed Wiki Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "custom" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: {
|
||||
paperclipManagedResource: {
|
||||
pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
resourceKind: "agent",
|
||||
resourceKey: "wiki-maintainer",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(relinked.status).toBe("relinked");
|
||||
expect(relinked.agentId).toBe(agentId);
|
||||
|
||||
const [binding] = await db.select().from(pluginEntities);
|
||||
expect(binding?.data).toMatchObject({ agentId });
|
||||
});
|
||||
|
||||
it("respects board approval policy for new managed agents", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agent?.status).toBe("pending_approval");
|
||||
expect(created.approvalId).toBeTruthy();
|
||||
|
||||
const [approval] = await db.select().from(approvals).where(eq(approvals.id, created.approvalId!));
|
||||
expect(approval).toMatchObject({
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
});
|
||||
expect(approval?.payload).toMatchObject({
|
||||
agentId: created.agentId,
|
||||
sourcePluginKey: "paperclip.managed-agents-test",
|
||||
managedResourceKey: "wiki-maintainer",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
issues,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projects,
|
||||
routineRuns,
|
||||
routineTriggers,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
import { routineService } from "../services/routines.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: async () => {},
|
||||
subscribe: () => {},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function manifest(): PaperclipPluginManifestV1 {
|
||||
return {
|
||||
id: "paperclip.managed-routines-test",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Managed Routines Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["agents.managed", "projects.managed", "routines.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
agents: [{
|
||||
agentKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
}],
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Plugin Operations",
|
||||
description: "Plugin operation inspection",
|
||||
status: "in_progress",
|
||||
}],
|
||||
routines: [{
|
||||
routineKey: "nightly-lint",
|
||||
title: "Nightly lint",
|
||||
description: "Lint plugin state",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" },
|
||||
projectRef: { resourceKind: "project", resourceKey: "operations" },
|
||||
status: "active",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
triggers: [{
|
||||
kind: "schedule",
|
||||
label: "Nightly",
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: "UTC",
|
||||
}],
|
||||
issueTemplate: {
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "operation:nightly-lint",
|
||||
billingCode: "plugin-test:nightly-lint",
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin-managed routines", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(issues);
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
|
||||
const companyId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
packageName: "@paperclipai/plugin-managed-routines-test",
|
||||
version: pluginManifest.version,
|
||||
apiVersion: pluginManifest.apiVersion,
|
||||
categories: pluginManifest.categories,
|
||||
manifestJson: pluginManifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
return { companyId, pluginId, pluginManifest, services };
|
||||
}
|
||||
|
||||
it("resolves routine agent and project refs by stable managed keys", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.routine).toMatchObject({
|
||||
title: "Nightly lint",
|
||||
assigneeAgentId: agent.agentId,
|
||||
projectId: project.projectId,
|
||||
managedByPlugin: expect.objectContaining({
|
||||
pluginKey: "paperclip.managed-routines-test",
|
||||
resourceKind: "routine",
|
||||
resourceKey: "nightly-lint",
|
||||
}),
|
||||
});
|
||||
|
||||
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!));
|
||||
expect(trigger).toMatchObject({
|
||||
kind: "schedule",
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: "UTC",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
|
||||
const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
expect(missing.status).toBe("missing_refs");
|
||||
expect(missing.missingRefs).toEqual([
|
||||
expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }),
|
||||
expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }),
|
||||
]);
|
||||
|
||||
const [agent] = await db.insert(agents).values({
|
||||
companyId,
|
||||
name: "Operator-selected maintainer",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
}).returning();
|
||||
const [project] = await db.insert(projects).values({
|
||||
companyId,
|
||||
name: "Operator-selected project",
|
||||
status: "in_progress",
|
||||
}).returning();
|
||||
|
||||
const repaired = await services.routines.managedReconcile({
|
||||
companyId,
|
||||
routineKey: "nightly-lint",
|
||||
assigneeAgentId: agent.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
expect(repaired.status).toBe("created");
|
||||
expect(repaired.routine).toMatchObject({
|
||||
assigneeAgentId: agent.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ title: "Operator renamed lint", updatedAt: new Date() })
|
||||
.where(eq(routines.id, repaired.routineId!));
|
||||
|
||||
const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(reconciled.routine?.title).toBe("Operator renamed lint");
|
||||
});
|
||||
|
||||
it("creates routine operation issues with plugin visibility and managed project scoping", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
|
||||
const routinesSvc = routineService(db, { heartbeat: { wakeup } });
|
||||
|
||||
const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!));
|
||||
expect(issue).toMatchObject({
|
||||
originKind: "plugin:paperclip.managed-routines-test:operation",
|
||||
originId: "operation:nightly-lint",
|
||||
billingCode: "plugin-test:nightly-lint",
|
||||
projectId: project.projectId,
|
||||
assigneeAgentId: agent.agentId,
|
||||
});
|
||||
expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({
|
||||
reason: "issue_assigned",
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: async () => {},
|
||||
subscribe: () => {},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function manifest(): PaperclipPluginManifestV1 {
|
||||
return {
|
||||
id: "paperclip.managed-skills-test",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Managed Skills Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["skills.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
skills: [{
|
||||
skillKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer Skill",
|
||||
description: "Use LLM Wiki tools to maintain company knowledge.",
|
||||
files: [{
|
||||
path: "references/wiki-style.md",
|
||||
content: "# Wiki style\n\nKeep pages cited and terse.\n",
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed skill tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin-managed skills", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-skills-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
|
||||
const companyId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
packageName: "@paperclipai/plugin-managed-skills-test",
|
||||
version: pluginManifest.version,
|
||||
apiVersion: pluginManifest.apiVersion,
|
||||
categories: pluginManifest.categories,
|
||||
manifestJson: pluginManifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
return { companyId, pluginId, pluginManifest, services };
|
||||
}
|
||||
|
||||
it("installs and resolves managed company skills by stable resource key", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
|
||||
const created = await services.skills.managedReconcile({
|
||||
companyId,
|
||||
skillKey: "wiki-maintainer",
|
||||
});
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.skill).toMatchObject({
|
||||
name: "Wiki Maintainer Skill",
|
||||
key: "plugin/paperclip-managed-skills-test/wiki-maintainer",
|
||||
sourceType: "catalog",
|
||||
fileInventory: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "SKILL.md", kind: "skill" }),
|
||||
expect.objectContaining({ path: "references/wiki-style.md", kind: "reference" }),
|
||||
]),
|
||||
});
|
||||
|
||||
const resolved = await services.skills.managedGet({
|
||||
companyId,
|
||||
skillKey: "wiki-maintainer",
|
||||
});
|
||||
expect(resolved.status).toBe("resolved");
|
||||
expect(resolved.skillId).toBe(created.skillId);
|
||||
|
||||
const [binding] = await db.select().from(pluginManagedResources);
|
||||
expect(binding).toMatchObject({
|
||||
companyId,
|
||||
resourceKind: "skill",
|
||||
resourceKey: "wiki-maintainer",
|
||||
resourceId: created.skillId,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves operator edits during reconcile and restores manifest defaults on reset", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(created.skillId).toBeTruthy();
|
||||
|
||||
await db
|
||||
.update(companySkills)
|
||||
.set({
|
||||
name: "Custom Wiki Skill",
|
||||
markdown: "# Custom instructions\n",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySkills.id, created.skillId!));
|
||||
|
||||
const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(reconciled.skill).toMatchObject({
|
||||
name: "Custom Wiki Skill",
|
||||
markdown: "# Custom instructions\n",
|
||||
});
|
||||
|
||||
const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(reset.status).toBe("reset");
|
||||
expect(reset.skill).toMatchObject({
|
||||
name: "Wiki Maintainer Skill",
|
||||
description: "Use LLM Wiki tools to maintain company knowledge.",
|
||||
});
|
||||
expect(reset.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/wiki-maintainer\"");
|
||||
});
|
||||
|
||||
it("does not rewrite managed skill bindings when defaults are unchanged", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(created.skillId).toBeTruthy();
|
||||
|
||||
const [binding] = await db.select().from(pluginManagedResources);
|
||||
const oldUpdatedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
await db
|
||||
.update(pluginManagedResources)
|
||||
.set({ updatedAt: oldUpdatedAt })
|
||||
.where(eq(pluginManagedResources.id, binding.id));
|
||||
|
||||
const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
const [bindingAfter] = await db.select().from(pluginManagedResources);
|
||||
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(bindingAfter.updatedAt.toISOString()).toBe(oldUpdatedAt.toISOString());
|
||||
});
|
||||
|
||||
it("relinks an existing canonical skill without overwriting operator edits", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(created.skillId).toBeTruthy();
|
||||
|
||||
await db.delete(pluginManagedResources).where(eq(pluginManagedResources.resourceId, created.skillId!));
|
||||
await db
|
||||
.update(companySkills)
|
||||
.set({
|
||||
name: "Existing Customized Wiki Skill",
|
||||
markdown: "# Existing customized instructions\n",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySkills.id, created.skillId!));
|
||||
|
||||
const relinked = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
|
||||
expect(relinked.status).toBe("relinked");
|
||||
expect(relinked.skillId).toBe(created.skillId);
|
||||
expect(relinked.skill).toMatchObject({
|
||||
name: "Existing Customized Wiki Skill",
|
||||
markdown: "# Existing customized instructions\n",
|
||||
});
|
||||
expect(relinked.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] });
|
||||
|
||||
const [binding] = await db.select().from(pluginManagedResources);
|
||||
expect(binding).toMatchObject({
|
||||
companyId,
|
||||
resourceKind: "skill",
|
||||
resourceKey: "wiki-maintainer",
|
||||
resourceId: created.skillId,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports drift when installed skill files differ from plugin defaults", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(created.defaultDrift).toBeNull();
|
||||
|
||||
await db
|
||||
.update(companySkills)
|
||||
.set({
|
||||
markdown: "# Custom instructions\n",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySkills.id, created.skillId!));
|
||||
|
||||
const drifted = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(drifted.status).toBe("resolved");
|
||||
expect(drifted.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] });
|
||||
|
||||
const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
|
||||
expect(reset.defaultDrift).toBeNull();
|
||||
});
|
||||
|
||||
it("adds the canonical managed key to manifest-provided markdown skills", async () => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.skills = [
|
||||
...(pluginManifest.skills ?? []),
|
||||
{
|
||||
skillKey: "markdown-skill",
|
||||
displayName: "Markdown Skill",
|
||||
markdown: [
|
||||
"---",
|
||||
"name: markdown-skill",
|
||||
"description: Markdown source without a key.",
|
||||
"---",
|
||||
"",
|
||||
"# Markdown Skill",
|
||||
"",
|
||||
"Follow the managed markdown.",
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
const { companyId, services } = await seedCompanyAndPlugin(pluginManifest);
|
||||
|
||||
const created = await services.skills.managedReconcile({ companyId, skillKey: "markdown-skill" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.skill).toMatchObject({
|
||||
key: "plugin/paperclip-managed-skills-test/markdown-skill",
|
||||
name: "markdown-skill",
|
||||
});
|
||||
expect(created.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/markdown-skill\"");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -11,6 +14,9 @@ import {
|
||||
heartbeatRuns,
|
||||
issueRelations,
|
||||
issues,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
|
||||
describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
|
||||
@@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
await db.delete(activityLog);
|
||||
await db.delete(costEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(projects);
|
||||
await db.delete(plugins);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
@@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
async function makeLocalRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const blockerIssueId = randomUUID();
|
||||
@@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
||||
});
|
||||
|
||||
it("creates plugin operation issues with the generic operation origin", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||
|
||||
const issue = await services.issues.create({
|
||||
companyId,
|
||||
title: "Background operation",
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "mission-alpha:operation-1",
|
||||
});
|
||||
|
||||
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
|
||||
expect(issue.originId).toBe("mission-alpha:operation-1");
|
||||
});
|
||||
|
||||
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclipai.plugin-llm-wiki",
|
||||
packageName: "@paperclipai/plugin-llm-wiki",
|
||||
version: "0.1.0",
|
||||
manifestJson: {
|
||||
id: "paperclipai.plugin-llm-wiki",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "LLM Wiki",
|
||||
description: "Local-file LLM Wiki plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
});
|
||||
const root = await makeLocalRoot();
|
||||
const services = buildHostServices(
|
||||
db,
|
||||
pluginId,
|
||||
"paperclipai.plugin-llm-wiki",
|
||||
createEventBusStub(),
|
||||
undefined,
|
||||
{
|
||||
manifest: {
|
||||
id: "paperclipai.plugin-llm-wiki",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "LLM Wiki",
|
||||
description: "Local-file LLM Wiki plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const configured = await services.localFolders.configure({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
});
|
||||
expect(configured.healthy).toBe(false);
|
||||
expect(configured.missingDirectories).toEqual([]);
|
||||
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
|
||||
|
||||
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
|
||||
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
|
||||
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
|
||||
.rejects.toThrow("Local folder is not healthy");
|
||||
await services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
relativePath: "WIKI.md",
|
||||
contents: "# Wiki\n",
|
||||
});
|
||||
await services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
relativePath: "AGENTS.md",
|
||||
contents: "# Agents\n",
|
||||
});
|
||||
|
||||
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
|
||||
expect(finalStatus.healthy).toBe(true);
|
||||
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
|
||||
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
|
||||
});
|
||||
|
||||
it("rejects worker local-folder access for undeclared manifest keys", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.local-folders",
|
||||
packageName: "@paperclip/plugin-local-folders",
|
||||
version: "0.1.0",
|
||||
manifestJson: {
|
||||
id: "paperclip.local-folders",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Local Folders",
|
||||
description: "Local folder fixture",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
});
|
||||
const services = buildHostServices(
|
||||
db,
|
||||
pluginId,
|
||||
"paperclip.local-folders",
|
||||
createEventBusStub(),
|
||||
undefined,
|
||||
{
|
||||
manifest: {
|
||||
id: "paperclip.local-folders",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Local Folders",
|
||||
description: "Local folder fixture",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(services.localFolders.configure({
|
||||
companyId,
|
||||
folderKey: "ssh",
|
||||
path: "/tmp",
|
||||
access: "read",
|
||||
})).rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
|
||||
.rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
|
||||
.rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "ssh",
|
||||
relativePath: "id_rsa",
|
||||
contents: "secret",
|
||||
})).rejects.toThrow("Local folder key is not declared");
|
||||
});
|
||||
|
||||
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.missions",
|
||||
packageName: "@paperclip/plugin-missions",
|
||||
version: "0.1.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
id: "paperclip.missions",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Missions",
|
||||
description: "Mission orchestration",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["projects.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Mission Operations",
|
||||
description: "Plugin operation inspection area",
|
||||
status: "in_progress",
|
||||
color: "#14b8a6",
|
||||
settings: { surface: "operations" },
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
|
||||
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
|
||||
expect(missing.status).toBe("missing");
|
||||
expect(missing.projectId).toBeNull();
|
||||
await expect(
|
||||
db
|
||||
.select()
|
||||
.from(pluginManagedResources)
|
||||
.where(and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, pluginId),
|
||||
eq(pluginManagedResources.resourceKind, "project"),
|
||||
eq(pluginManagedResources.resourceKey, "operations"),
|
||||
)),
|
||||
).resolves.toHaveLength(0);
|
||||
|
||||
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.projectId).toEqual(expect.any(String));
|
||||
expect(created.project?.managedByPlugin).toMatchObject({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.missions",
|
||||
pluginDisplayName: "Missions",
|
||||
resourceKind: "project",
|
||||
resourceKey: "operations",
|
||||
});
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
|
||||
.where(eq(projects.id, created.projectId!));
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({
|
||||
manifestJson: {
|
||||
id: "paperclip.missions",
|
||||
apiVersion: 1,
|
||||
version: "0.2.0",
|
||||
displayName: "Missions",
|
||||
description: "Mission orchestration",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["projects.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Upgraded Default Name",
|
||||
description: "Upgraded default description",
|
||||
status: "planned",
|
||||
color: "#f97316",
|
||||
settings: { surface: "operations", upgraded: true },
|
||||
}],
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, pluginId));
|
||||
|
||||
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
expect(resolved.status).toBe("resolved");
|
||||
expect(resolved.projectId).toBe(created.projectId);
|
||||
expect(resolved.project?.name).toBe("Renamed by operator");
|
||||
expect(resolved.project?.description).toBe("User-owned text");
|
||||
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
|
||||
displayName: "Upgraded Default Name",
|
||||
settings: { upgraded: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("asserts checkout ownership for run-scoped plugin actions", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
|
||||
@@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByKey: vi.fn(),
|
||||
upsertConfig: vi.fn(),
|
||||
getCompanySettings: vi.fn(),
|
||||
upsertCompanySettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLifecycle = vi.hoisted(() => ({
|
||||
@@ -222,6 +224,30 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||
expect(mockLifecycle.disable).not.toHaveBeenCalled();
|
||||
}, 20_000);
|
||||
|
||||
it("rejects plugin config saves that contain secret refs even for instance admins", async () => {
|
||||
readyPlugin();
|
||||
|
||||
const { app } = await createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyA],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/config`)
|
||||
.send({
|
||||
configJson: {
|
||||
apiKeyRef: "77777777-7777-4777-8777-777777777777",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toMatch(/secret references are disabled/i);
|
||||
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
|
||||
}, 20_000);
|
||||
|
||||
it("allows instance admins to upgrade plugins", async () => {
|
||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
mockRegistry.getById.mockResolvedValue({
|
||||
@@ -317,6 +343,61 @@ describe.sequential("scoped plugin API routes", () => {
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe.sequential("plugin local folder routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRegistry.getCompanySettings.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function readyLocalFolderPlugin() {
|
||||
mockRegistry.getById.mockResolvedValue({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
version: "1.0.0",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
id: "paperclip.example",
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["docs"],
|
||||
requiredFiles: ["README.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects validation for undeclared local folder keys", async () => {
|
||||
readyLocalFolderPlugin();
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`)
|
||||
.send({ path: "/tmp" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Local folder key is not declared");
|
||||
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects saving undeclared local folder keys", async () => {
|
||||
readyLocalFolderPlugin();
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`)
|
||||
.send({ path: "/tmp" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Local folder key is not declared");
|
||||
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("plugin tool and bridge authz", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -98,6 +98,7 @@ describe.sequential("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";
|
||||
const peerAgentId = "33333333-3333-4333-8333-333333333334";
|
||||
const runId = "44444444-4444-4444-8444-444444444444";
|
||||
const issueId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
@@ -252,6 +253,55 @@ describe.sequential("plugin scoped API routes", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
|
||||
const apiRoutes = manifest([
|
||||
{
|
||||
routeKey: "issue.advance",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/advance",
|
||||
auth: "agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
]);
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: issueId,
|
||||
companyId,
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const { app, workerManager } = await createApp({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: peerAgentId,
|
||||
companyId,
|
||||
runId,
|
||||
source: "agent_key",
|
||||
},
|
||||
plugin: {
|
||||
id: pluginId,
|
||||
pluginKey: apiRoutes.id,
|
||||
status: "ready",
|
||||
manifestJson: apiRoutes,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
|
||||
.send({ step: "next" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
|
||||
routeKey: "issue.advance",
|
||||
params: { issueId },
|
||||
body: { step: "next" },
|
||||
actor: expect.objectContaining({ actorType: "agent", agentId: peerAgentId, runId }),
|
||||
companyId,
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
|
||||
const apiRoutes = manifest([
|
||||
{
|
||||
|
||||
@@ -150,6 +150,23 @@ describe("plugin SDK orchestration contract", () => {
|
||||
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
|
||||
});
|
||||
|
||||
it("supports generic plugin operation issue visibility in the test harness", async () => {
|
||||
const companyId = randomUUID();
|
||||
const harness = createTestHarness({
|
||||
manifest: manifest(["issues.create"]),
|
||||
});
|
||||
|
||||
const created = await harness.ctx.issues.create({
|
||||
companyId,
|
||||
title: "Background operation",
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "operation-1",
|
||||
});
|
||||
|
||||
expect(created.originKind).toBe("plugin:paperclip.test-orchestration:operation");
|
||||
expect(created.originId).toBe("operation-1");
|
||||
});
|
||||
|
||||
it("enforces checkout and wakeup capabilities in the test harness", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
|
||||
describe("plugin SDK test harness", () => {
|
||||
it("requires skills.managed capability before resetting a missing declaration", async () => {
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclip.test-missing-managed-skill-capability",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Missing Managed Skill Capability",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
skills: [{
|
||||
skillKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer",
|
||||
}],
|
||||
};
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
await expect(harness.ctx.skills.managed.reset("unknown-skill", "company-1")).rejects.toThrow(
|
||||
"missing required capability 'skills.managed'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginSecretsHandler,
|
||||
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
|
||||
} from "../services/plugin-secrets-handler.js";
|
||||
|
||||
describe("createPluginSecretsHandler", () => {
|
||||
it("fails closed for plugin secret resolution until company scoping lands", async () => {
|
||||
const handler = createPluginSecretsHandler({
|
||||
db: {} as never,
|
||||
pluginId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
await expect(
|
||||
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }),
|
||||
).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
|
||||
});
|
||||
|
||||
it("still rejects malformed secret refs before the feature-disable guard", async () => {
|
||||
const handler = createPluginSecretsHandler({
|
||||
db: {} as never,
|
||||
pluginId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
await expect(
|
||||
handler.resolve({ secretRef: "not-a-uuid" }),
|
||||
).rejects.toThrow(/invalid secret reference/i);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
JsonRpcCallError,
|
||||
type HostToWorkerMethods,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
appendStderrExcerpt,
|
||||
createPluginWorkerHandle,
|
||||
formatWorkerFailureMessage,
|
||||
} from "../services/plugin-worker-manager.js";
|
||||
|
||||
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
|
||||
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
|
||||
|
||||
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
||||
id: "test.plugin",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Test plugin",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
};
|
||||
|
||||
describe("plugin-worker-manager stderr failure context", () => {
|
||||
it("appends worker stderr context to failure messages", () => {
|
||||
@@ -40,4 +67,115 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
expect(excerpt).not.toContain("second line");
|
||||
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
|
||||
it("times out environmentExecute calls using the handle default when no override is provided", async () => {
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
rpcTimeoutMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("environmentExecute", {
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "environment-1",
|
||||
config: {},
|
||||
lease: { providerLeaseId: "lease-1" },
|
||||
command: "echo",
|
||||
delayMs: 50,
|
||||
} as HostToWorkerMethods["environmentExecute"][0])).rejects.toMatchObject({
|
||||
message: expect.stringContaining("timed out after 10ms"),
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("honors per-call timeout overrides for environmentExecute", async () => {
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
rpcTimeoutMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("environmentExecute", {
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "environment-1",
|
||||
config: {},
|
||||
lease: { providerLeaseId: "lease-1" },
|
||||
command: "echo",
|
||||
delayMs: 50,
|
||||
} as HostToWorkerMethods["environmentExecute"][0], 100)).resolves.toMatchObject({
|
||||
exitCode: 0,
|
||||
stdout: "ok\n",
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit an unhandled rejection when a plugin responds with terminated before callers attach handlers", async () => {
|
||||
const unhandledRejection = vi.fn();
|
||||
process.on("unhandledRejection", unhandledRejection);
|
||||
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: TERMINATED_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
const pendingCall = handle.call(
|
||||
"environmentExecute" as keyof HostToWorkerMethods,
|
||||
{
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "environment-1",
|
||||
config: {},
|
||||
lease: { providerLeaseId: "lease-1" },
|
||||
command: "echo",
|
||||
} as HostToWorkerMethods[keyof HostToWorkerMethods][0],
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
await expect(pendingCall).rejects.toBeInstanceOf(JsonRpcCallError);
|
||||
await expect(pendingCall).rejects.toMatchObject({
|
||||
message: expect.stringContaining("terminated"),
|
||||
});
|
||||
expect(unhandledRejection).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.off("unhandledRejection", unhandledRejection);
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,6 +201,7 @@ describeEmbeddedPostgres("productivity review service", () => {
|
||||
expect(reviews).toHaveLength(1);
|
||||
expect(reviews[0]?.parentId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
|
||||
expect(reviews[0]?.assigneeAdapterOverrides).toEqual({ modelProfile: "cheap" });
|
||||
expect(reviews[0]?.originId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
|
||||
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
|
||||
|
||||
@@ -74,6 +74,100 @@ describe("recovery classifier boundary", () => {
|
||||
expect(classifyIssueGraphLiveness(input)).toEqual(classifyIssueGraphLivenessCompat(input));
|
||||
});
|
||||
|
||||
it("treats a scheduled monitor as an explicit review action path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
now: "2026-04-30T18:00:00.000Z",
|
||||
issues: [
|
||||
{
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PAP-2945",
|
||||
title: "Wait for external review",
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionState: null,
|
||||
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
},
|
||||
],
|
||||
relations: [],
|
||||
agents: [
|
||||
{
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat overdue or exhausted monitors as explicit waiting paths", () => {
|
||||
const baseIssue = {
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PAP-2945",
|
||||
title: "Wait for external review",
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
};
|
||||
const agents = [
|
||||
{
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
},
|
||||
];
|
||||
|
||||
const overdue = classifyIssueGraphLiveness({
|
||||
now: "2026-04-30T20:00:00.000Z",
|
||||
issues: [
|
||||
{
|
||||
...baseIssue,
|
||||
executionState: null,
|
||||
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
},
|
||||
],
|
||||
relations: [],
|
||||
agents,
|
||||
});
|
||||
|
||||
const exhausted = classifyIssueGraphLiveness({
|
||||
now: "2026-04-30T18:00:00.000Z",
|
||||
issues: [
|
||||
{
|
||||
...baseIssue,
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
maxAttempts: 1,
|
||||
},
|
||||
},
|
||||
executionState: null,
|
||||
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
monitorAttemptCount: 1,
|
||||
},
|
||||
],
|
||||
relations: [],
|
||||
agents,
|
||||
});
|
||||
|
||||
expect(overdue[0]?.state).toBe("in_review_without_action_path");
|
||||
expect(exhausted[0]?.state).toBe("in_review_without_action_path");
|
||||
});
|
||||
|
||||
it("keeps run liveness continuation decision parity with the compatibility export", () => {
|
||||
const input = {
|
||||
run: {
|
||||
|
||||
@@ -7,6 +7,7 @@ const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const routineId = "33333333-3333-4333-8333-333333333333";
|
||||
const projectId = "44444444-4444-4444-8444-444444444444";
|
||||
const otherAgentId = "55555555-5555-4555-8555-555555555555";
|
||||
const revisionId = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
const routine = {
|
||||
id: routineId,
|
||||
@@ -21,6 +22,9 @@ const routine = {
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
@@ -30,6 +34,40 @@ const routine = {
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const revision = {
|
||||
id: revisionId,
|
||||
companyId,
|
||||
routineId,
|
||||
revisionNumber: 1,
|
||||
title: "Daily routine",
|
||||
description: null,
|
||||
snapshot: {
|
||||
version: 1,
|
||||
routine: {
|
||||
id: routineId,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Daily routine",
|
||||
description: null,
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
},
|
||||
triggers: [],
|
||||
},
|
||||
changeSummary: "Created routine",
|
||||
restoredFromRevisionId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
};
|
||||
const pausedRoutine = {
|
||||
...routine,
|
||||
status: "paused",
|
||||
@@ -65,6 +103,8 @@ const mockRoutineService = vi.hoisted(() => ({
|
||||
getDetail: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
listRevisions: vi.fn(),
|
||||
restoreRevision: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
createTrigger: vi.fn(),
|
||||
getTrigger: vi.fn(),
|
||||
@@ -150,6 +190,14 @@ describe("routine routes", () => {
|
||||
mockRoutineService.get.mockResolvedValue(routine);
|
||||
mockRoutineService.getTrigger.mockResolvedValue(trigger);
|
||||
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
|
||||
mockRoutineService.listRevisions.mockResolvedValue([revision]);
|
||||
mockRoutineService.restoreRevision.mockResolvedValue({
|
||||
routine,
|
||||
revision: { ...revision, revisionNumber: 2, restoredFromRevisionId: revision.id },
|
||||
restoredFromRevisionId: revision.id,
|
||||
restoredFromRevisionNumber: revision.revisionNumber,
|
||||
secretMaterials: [],
|
||||
});
|
||||
mockRoutineService.runRoutine.mockResolvedValue({
|
||||
id: "run-1",
|
||||
source: "manual",
|
||||
@@ -176,6 +224,73 @@ describe("routine routes", () => {
|
||||
expect(mockRoutineService.list).toHaveBeenCalledWith(companyId, { projectId });
|
||||
});
|
||||
|
||||
it("lists routine revisions for a board member in newest-first service order", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/routines/${routineId}/revisions`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockRoutineService.listRevisions).toHaveBeenCalledWith(routineId);
|
||||
expect(res.body[0]).toMatchObject({ id: revisionId, revisionNumber: 1 });
|
||||
});
|
||||
|
||||
it("blocks routine revision reads across company scope", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["99999999-9999-4999-8999-999999999999"],
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/routines/${routineId}/revisions`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockRoutineService.listRevisions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an assigned agent for routine revision history access", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: otherAgentId,
|
||||
companyId,
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/routines/${routineId}/revisions`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockRoutineService.listRevisions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores routine revisions with existing routine-management permissions", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: "88888888-8888-4888-8888-888888888888",
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/api/routines/${routineId}/revisions/${revisionId}/restore`).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockRoutineService.restoreRevision).toHaveBeenCalledWith(routineId, revisionId, {
|
||||
agentId,
|
||||
userId: null,
|
||||
runId: "88888888-8888-4888-8888-888888888888",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.revision_restored",
|
||||
entityId: routineId,
|
||||
runId: "88888888-8888-4888-8888-888888888888",
|
||||
}));
|
||||
});
|
||||
|
||||
it("requires tasks:assign permission for non-admin board routine creation", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
@@ -348,6 +463,7 @@ describe("routine routes", () => {
|
||||
}), {
|
||||
agentId: null,
|
||||
userId: "board-user",
|
||||
runId: null,
|
||||
});
|
||||
expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import * as providerRegistry from "../secrets/provider-registry.ts";
|
||||
import { routineService } from "../services/routines.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
@@ -47,6 +49,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalSecretsProviderEnv === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = originalSecretsProviderEnv;
|
||||
}
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(issueReadStates);
|
||||
@@ -283,6 +290,201 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
expect(routine.status).toBe("paused");
|
||||
});
|
||||
|
||||
it("creates revision 1 on routine create and appends revisions for real updates only", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
|
||||
const initialRevisions = await svc.listRevisions(routine.id);
|
||||
expect(initialRevisions).toHaveLength(1);
|
||||
expect(initialRevisions[0]).toMatchObject({
|
||||
id: routine.latestRevisionId,
|
||||
revisionNumber: 1,
|
||||
title: "ascii frog",
|
||||
changeSummary: "Created routine",
|
||||
});
|
||||
expect(initialRevisions[0]?.snapshot.routine.description).toBe("Run the frog routine");
|
||||
|
||||
const updated = await svc.update(
|
||||
routine.id,
|
||||
{
|
||||
description: "Run the frog routine with logs",
|
||||
baseRevisionId: routine.latestRevisionId,
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(updated?.latestRevisionNumber).toBe(2);
|
||||
expect(updated?.latestRevisionId).not.toBe(routine.latestRevisionId);
|
||||
|
||||
const noOp = await svc.update(
|
||||
routine.id,
|
||||
{
|
||||
description: "Run the frog routine with logs",
|
||||
baseRevisionId: updated?.latestRevisionId,
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(noOp?.latestRevisionId).toBe(updated?.latestRevisionId);
|
||||
expect(noOp?.latestRevisionNumber).toBe(2);
|
||||
|
||||
const revisions = await svc.listRevisions(routine.id);
|
||||
expect(revisions.map((revision) => revision.revisionNumber)).toEqual([2, 1]);
|
||||
expect(revisions[0]?.snapshot.routine.description).toBe("Run the frog routine with logs");
|
||||
expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine");
|
||||
});
|
||||
|
||||
it("rejects stale routine baseRevisionId updates", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const updated = await svc.update(routine.id, { description: "new description" }, {});
|
||||
await expect(
|
||||
svc.update(routine.id, {
|
||||
title: "stale update",
|
||||
baseRevisionId: routine.latestRevisionId,
|
||||
}, {}),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
currentRevisionId: updated?.latestRevisionId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("restores an older routine revision append-only and preserves run history", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const revision1Id = routine.latestRevisionId!;
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
const revision2Routine = await svc.update(routine.id, { description: "revision 2" }, {});
|
||||
|
||||
const restored = await svc.restoreRevision(routine.id, revision1Id, {});
|
||||
|
||||
expect(restored.restoredFromRevisionId).toBe(revision1Id);
|
||||
expect(restored.restoredFromRevisionNumber).toBe(1);
|
||||
expect(restored.routine.latestRevisionNumber).toBe(3);
|
||||
expect(restored.routine.latestRevisionId).not.toBe(revision2Routine?.latestRevisionId);
|
||||
expect(restored.routine.description).toBe("Run the frog routine");
|
||||
expect(restored.revision.restoredFromRevisionId).toBe(revision1Id);
|
||||
expect(restored.revision.snapshot.routine.description).toBe("Run the frog routine");
|
||||
|
||||
const revisions = await svc.listRevisions(routine.id);
|
||||
expect(revisions.map((revision) => revision.revisionNumber)).toEqual([3, 2, 1]);
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, run.id))).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects restoring the current latest routine revision", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
|
||||
await expect(
|
||||
svc.restoreRevision(routine.id, routine.latestRevisionId!, {}),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
currentRevisionId: routine.latestRevisionId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("recreates deleted webhook trigger secrets when restoring a historical revision", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const created = await svc.createTrigger(routine.id, {
|
||||
kind: "webhook",
|
||||
signingMode: "bearer",
|
||||
replayWindowSec: 300,
|
||||
}, {});
|
||||
await svc.deleteTrigger(created.trigger.id, {});
|
||||
|
||||
const restored = await svc.restoreRevision(routine.id, created.revision.id, {});
|
||||
|
||||
expect(restored.secretMaterials).toHaveLength(1);
|
||||
expect(restored.secretMaterials[0]).toMatchObject({
|
||||
triggerId: created.trigger.id,
|
||||
});
|
||||
expect(restored.secretMaterials[0]?.webhookSecret).toBeTruthy();
|
||||
expect(restored.secretMaterials[0]?.webhookUrl).toContain("/api/routine-triggers/public/");
|
||||
|
||||
const restoredTrigger = await svc.getTrigger(created.trigger.id);
|
||||
expect(restoredTrigger?.secretId).toBeTruthy();
|
||||
expect(restoredTrigger?.publicId).toBeTruthy();
|
||||
expect(restoredTrigger?.publicId).not.toBe(created.trigger.publicId);
|
||||
});
|
||||
|
||||
it("blocks agents from restoring routine revisions assigned to another agent", async () => {
|
||||
const { companyId, routine, svc } = await seedFixture();
|
||||
const otherAgentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: otherAgentId,
|
||||
companyId,
|
||||
name: "OtherCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
const revision1Id = routine.latestRevisionId!;
|
||||
|
||||
await svc.update(routine.id, { assigneeAgentId: otherAgentId }, {});
|
||||
|
||||
await expect(
|
||||
svc.restoreRevision(routine.id, revision1Id, { agentId: otherAgentId }),
|
||||
).rejects.toMatchObject({
|
||||
status: 403,
|
||||
message: "Agents can only restore routine revisions assigned to themselves",
|
||||
});
|
||||
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
||||
assigneeAgentId: otherAgentId,
|
||||
latestRevisionNumber: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks restoring routine revisions assigned to agents that are no longer assignable", async () => {
|
||||
const { agentId, routine, svc } = await seedFixture();
|
||||
const revision1Id = routine.latestRevisionId!;
|
||||
await svc.update(routine.id, { description: "revision 2" }, {});
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "terminated" })
|
||||
.where(eq(agents.id, agentId));
|
||||
|
||||
await expect(
|
||||
svc.restoreRevision(routine.id, revision1Id, { userId: "board-user" }),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: "Cannot assign routines to terminated agents",
|
||||
});
|
||||
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
||||
description: "revision 2",
|
||||
latestRevisionNumber: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("appends safe trigger metadata revisions without leaking webhook secrets", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const created = await svc.createTrigger(routine.id, {
|
||||
kind: "webhook",
|
||||
signingMode: "bearer",
|
||||
replayWindowSec: 300,
|
||||
}, {});
|
||||
expect(created.revision.revisionNumber).toBe(2);
|
||||
expect(created.secretMaterial?.webhookSecret).toBeTruthy();
|
||||
|
||||
const updated = await svc.updateTrigger(created.trigger.id, { label: "deploy hook" }, {});
|
||||
expect(updated?.revision.revisionNumber).toBe(3);
|
||||
|
||||
const rotated = await svc.rotateTriggerSecret(created.trigger.id, {});
|
||||
expect(rotated.revision.revisionNumber).toBe(4);
|
||||
expect(rotated.secretMaterial.webhookSecret).toBeTruthy();
|
||||
|
||||
const deleted = await svc.deleteTrigger(created.trigger.id, {});
|
||||
expect(deleted.revision?.revisionNumber).toBe(5);
|
||||
|
||||
const revisions = await svc.listRevisions(routine.id);
|
||||
const serialized = JSON.stringify(revisions.map((revision) => revision.snapshot));
|
||||
expect(serialized).toContain(created.trigger.publicId!);
|
||||
expect(serialized).not.toContain(created.secretMaterial!.webhookSecret);
|
||||
expect(serialized).not.toContain(rotated.secretMaterial.webhookSecret);
|
||||
expect(serialized).not.toContain(created.trigger.secretId!);
|
||||
expect(revisions[0]?.snapshot.triggers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
|
||||
const { agentId, routine, svc, wakeups } = await seedFixture();
|
||||
|
||||
@@ -1077,6 +1279,82 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
expect(run.linkedIssueId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the configured provider for generated webhook trigger secrets", async () => {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
|
||||
const originalGetSecretProvider = providerRegistry.getSecretProvider;
|
||||
const getSecretProviderSpy = vi.spyOn(providerRegistry, "getSecretProvider").mockImplementation((provider) => {
|
||||
if (provider !== "aws_secrets_manager") {
|
||||
return originalGetSecretProvider(provider);
|
||||
}
|
||||
return {
|
||||
id: "aws_secrets_manager",
|
||||
descriptor: () => ({
|
||||
id: "aws_secrets_manager",
|
||||
label: "AWS Secrets Manager",
|
||||
supportsManaged: true,
|
||||
supportsExternalReference: true,
|
||||
}),
|
||||
validateConfig: async () => ({ ok: true, warnings: [] }),
|
||||
createSecret: async ({ value }) => ({
|
||||
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v1" },
|
||||
valueSha256: `sha:${value}`,
|
||||
fingerprintSha256: `sha:${value}`,
|
||||
externalRef: "arn:aws:secretsmanager:stub",
|
||||
providerVersionRef: "v1",
|
||||
}),
|
||||
createVersion: async ({ value }) => ({
|
||||
material: { source: "managed", secretId: "arn:aws:secretsmanager:stub", versionId: "v2" },
|
||||
valueSha256: `sha:${value}`,
|
||||
fingerprintSha256: `sha:${value}`,
|
||||
externalRef: "arn:aws:secretsmanager:stub",
|
||||
providerVersionRef: "v2",
|
||||
}),
|
||||
linkExternalSecret: async ({ externalRef, providerVersionRef }) => ({
|
||||
material: { source: "external", secretId: externalRef, versionId: providerVersionRef ?? null },
|
||||
valueSha256: "external",
|
||||
fingerprintSha256: "external",
|
||||
externalRef,
|
||||
providerVersionRef: providerVersionRef ?? null,
|
||||
}),
|
||||
resolveVersion: async () => "resolved-secret",
|
||||
deleteOrArchive: async () => undefined,
|
||||
healthCheck: async () => ({
|
||||
provider: "aws_secrets_manager",
|
||||
status: "ok",
|
||||
message: "stubbed",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const { trigger } = await svc.createTrigger(
|
||||
routine.id,
|
||||
{
|
||||
kind: "webhook",
|
||||
signingMode: "hmac_sha256",
|
||||
replayWindowSec: 300,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const [secret] = await db
|
||||
.select({
|
||||
id: companySecrets.id,
|
||||
provider: companySecrets.provider,
|
||||
})
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.id, trigger.secretId!));
|
||||
|
||||
expect(secret).toMatchObject({
|
||||
id: trigger.secretId,
|
||||
provider: "aws_secrets_manager",
|
||||
});
|
||||
} finally {
|
||||
getSecretProviderSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts GitHub-style X-Hub-Signature-256 with github_hmac signing mode", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const { trigger, secretMaterial } = await svc.createTrigger(
|
||||
|
||||
@@ -76,10 +76,12 @@ describe("run liveness continuations", () => {
|
||||
continuationAttempt: 1,
|
||||
maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
|
||||
instruction: "Take the first concrete action now.",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
expect(decision.contextSnapshot).toMatchObject({
|
||||
issueId,
|
||||
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||
modelProfile: "cheap",
|
||||
livenessContinuationAttempt: 1,
|
||||
livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
|
||||
livenessContinuationSourceRunId: runId,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { checkSecretProviders, listSecretProviders } from "../secrets/provider-registry.js";
|
||||
|
||||
describe("secret provider registry", () => {
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
if (previousMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
|
||||
}
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("describes managed and external-reference provider capabilities", () => {
|
||||
const descriptors = listSecretProviders();
|
||||
|
||||
expect(descriptors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "local_encrypted",
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: false,
|
||||
configured: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "aws_secrets_manager",
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: true,
|
||||
configured: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when the local encrypted key file is readable by group or others", async () => {
|
||||
const dir = path.join(os.tmpdir(), `paperclip-secret-provider-${randomBytes(6).toString("hex")}`);
|
||||
tmpDirs.push(dir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const keyFile = path.join(dir, "master.key");
|
||||
writeFileSync(keyFile, randomBytes(32).toString("base64"), { encoding: "utf8", mode: 0o644 });
|
||||
chmodSync(keyFile, 0o644);
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFile;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
|
||||
const checks = await checkSecretProviders();
|
||||
const local = checks.find((check) => check.provider === "local_encrypted");
|
||||
|
||||
expect(local).toMatchObject({
|
||||
status: "warn",
|
||||
details: { keyFilePath: keyFile },
|
||||
});
|
||||
expect(local?.warnings?.join("\n")).toContain("chmod 600");
|
||||
expect(local?.backupGuidance?.join("\n")).toContain("database");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { secretRoutes } from "../routes/secrets.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { HttpError, unprocessable } from "../errors.js";
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
listProviders: vi.fn(),
|
||||
checkProviders: vi.fn(),
|
||||
listProviderConfigs: vi.fn(),
|
||||
getProviderConfigById: vi.fn(),
|
||||
createProviderConfig: vi.fn(),
|
||||
updateProviderConfig: vi.fn(),
|
||||
disableProviderConfig: vi.fn(),
|
||||
setDefaultProviderConfig: vi.fn(),
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
previewRemoteImport: vi.fn(),
|
||||
importRemoteSecrets: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
||||
}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", secretRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("secret routes", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mockSecretService)) {
|
||||
mock.mockReset();
|
||||
}
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
it("returns provider health checks for board callers with company access", async () => {
|
||||
mockSecretService.checkProviders.mockResolvedValue([
|
||||
{
|
||||
provider: "local_encrypted",
|
||||
status: "ok",
|
||||
message: "Local encrypted provider configured",
|
||||
backupGuidance: ["Back up the key file together with database backups."],
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(createApp()).get("/api/companies/company-1/secret-providers/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
providers: [
|
||||
{
|
||||
provider: "local_encrypted",
|
||||
status: "ok",
|
||||
message: "Local encrypted provider configured",
|
||||
backupGuidance: ["Back up the key file together with database backups."],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects managed secret creation when externalRef is supplied", async () => {
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secrets").send({
|
||||
name: "OpenAI API Key",
|
||||
managedMode: "paperclip_managed",
|
||||
value: "secret-value",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/Managed secrets cannot set externalRef/);
|
||||
expect(mockSecretService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects provider vault routes for non-board actors", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
})).get("/api/companies/company-1/secret-provider-configs");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects provider vault cross-company access before calling the service", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-2"],
|
||||
memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }],
|
||||
})).get("/api/companies/company-1/secret-provider-configs");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects sensitive provider vault config fields", async () => {
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA...",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
|
||||
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects ready status for coming-soon provider vaults", async () => {
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "vault",
|
||||
displayName: "Vault draft",
|
||||
status: "ready",
|
||||
config: {
|
||||
address: "https://vault.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/locked while coming soon/i);
|
||||
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects credential-bearing Vault provider vault addresses before persistence", async () => {
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "vault",
|
||||
displayName: "Vault draft",
|
||||
config: {
|
||||
address: "https://user:pass@vault.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
|
||||
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://vault.example.com?token=hvs.x",
|
||||
"https://vault.example.com#token=hvs.x",
|
||||
])("rejects token-bearing Vault provider vault address %s before persistence", async (address) => {
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "vault",
|
||||
displayName: "Vault draft",
|
||||
config: { address },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
|
||||
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsafe Vault provider vault address patches before persistence", async () => {
|
||||
const res = await request(createApp()).patch("/api/secret-provider-configs/vault-1").send({
|
||||
config: {
|
||||
address: "https://vault.example.com#token=hvs.x",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
|
||||
expect(mockSecretService.getProviderConfigById).not.toHaveBeenCalled();
|
||||
expect(mockSecretService.updateProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates provider vaults and logs safe activity details", async () => {
|
||||
const createdAt = new Date("2026-05-06T00:00:00.000Z");
|
||||
mockSecretService.createProviderConfig.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: "ready",
|
||||
isDefault: true,
|
||||
config: { region: "us-east-1" },
|
||||
healthStatus: null,
|
||||
healthCheckedAt: null,
|
||||
healthMessage: null,
|
||||
healthDetails: null,
|
||||
disabledAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
isDefault: true,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockSecretService.createProviderConfig).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: undefined,
|
||||
isDefault: true,
|
||||
config: { region: "us-east-1" },
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "secret_provider_config.created",
|
||||
details: {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS prod",
|
||||
status: "ready",
|
||||
isDefault: true,
|
||||
},
|
||||
}));
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
|
||||
});
|
||||
|
||||
it("rejects remote import preview for non-board actors", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
})).post("/api/companies/company-1/secrets/remote-import/preview").send({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSecretService.previewRemoteImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews remote imports and logs only aggregate metadata", async () => {
|
||||
mockSecretService.previewRemoteImport.mockResolvedValue({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
provider: "aws_secrets_manager",
|
||||
nextToken: null,
|
||||
candidates: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
remoteName: "prod/openai",
|
||||
name: "openai",
|
||||
key: "openai",
|
||||
providerVersionRef: null,
|
||||
providerMetadata: { description: "OpenAI API key" },
|
||||
status: "ready",
|
||||
importable: true,
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/secrets/remote-import/preview")
|
||||
.send({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
query: "openai",
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSecretService.previewRemoteImport).toHaveBeenCalledWith("company-1", {
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
query: "openai",
|
||||
nextToken: undefined,
|
||||
pageSize: 25,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "secret.remote_import.previewed",
|
||||
details: {
|
||||
provider: "aws_secrets_manager",
|
||||
candidateCount: 1,
|
||||
readyCount: 1,
|
||||
duplicateCount: 0,
|
||||
conflictCount: 0,
|
||||
},
|
||||
}));
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
|
||||
});
|
||||
|
||||
it("returns sanitized remote import preview provider errors", async () => {
|
||||
mockSecretService.previewRemoteImport.mockRejectedValue(
|
||||
new HttpError(
|
||||
403,
|
||||
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
{ code: "access_denied" },
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/secrets/remote-import/preview")
|
||||
.send({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({
|
||||
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||
details: { code: "access_denied" },
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
|
||||
expect(JSON.stringify(res.body)).not.toContain("123456789012");
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports remote references and logs aggregate row counts", async () => {
|
||||
mockSecretService.importRemoteSecrets.mockResolvedValue({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
provider: "aws_secrets_manager",
|
||||
importedCount: 1,
|
||||
skippedCount: 0,
|
||||
errorCount: 0,
|
||||
results: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
status: "imported",
|
||||
reason: null,
|
||||
secretId: "22222222-2222-4222-8222-222222222222",
|
||||
conflicts: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/secrets/remote-import")
|
||||
.send({
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
description: "Operator-entered Paperclip description",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSecretService.importRemoteSecrets).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{
|
||||
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
description: "Operator-entered Paperclip description",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "secret.remote_import.completed",
|
||||
details: {
|
||||
provider: "aws_secrets_manager",
|
||||
importedCount: 1,
|
||||
skippedCount: 0,
|
||||
errorCount: 0,
|
||||
},
|
||||
}));
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
|
||||
});
|
||||
|
||||
it("surfaces update-route externalRef retarget rejection without logging raw refs", async () => {
|
||||
mockSecretService.getById.mockResolvedValue({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
provider: "aws_secrets_manager",
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
||||
});
|
||||
mockSecretService.update.mockRejectedValue(
|
||||
unprocessable("External reference secrets cannot be retargeted through generic update"),
|
||||
);
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/secrets/22222222-2222-4222-8222-222222222222")
|
||||
.send({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockSecretService.update).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("shared/repointed");
|
||||
});
|
||||
|
||||
it("allows DELETE to retry cleanup for already soft-deleted secrets", async () => {
|
||||
const secret = {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
name: "OpenAI API Key__deleted__33333333-3333-4333-8333-333333333333",
|
||||
key: "openai-api-key__deleted__33333333-3333-4333-8333-333333333333",
|
||||
provider: "aws_secrets_manager",
|
||||
managedMode: "paperclip_managed",
|
||||
status: "deleted",
|
||||
};
|
||||
mockSecretService.getById.mockResolvedValue(secret);
|
||||
mockSecretService.remove.mockResolvedValue(secret);
|
||||
|
||||
const res = await request(createApp()).delete(
|
||||
"/api/secrets/33333333-3333-4333-8333-333333333333",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(mockSecretService.remove).toHaveBeenCalledWith(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "secret.deleted",
|
||||
companyId: "company-1",
|
||||
entityId: secret.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -146,6 +146,7 @@ vi.mock("../services/index.js", () => ({
|
||||
reconcileStrandedAssignedIssues: vi.fn(async () => ({
|
||||
dispatchRequeued: 0,
|
||||
continuationRequeued: 0,
|
||||
successfulRunHandoffEscalated: 0,
|
||||
escalated: 0,
|
||||
skipped: 0,
|
||||
issueIds: [],
|
||||
|
||||
@@ -3134,6 +3134,130 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
expect(persisted?.healthStatus).toBe("unknown");
|
||||
expect(persisted?.stoppedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("restarts a stopped auto-port service on the same port when it is available", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-port-reuse-"));
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Runtime port reuse test",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Execution workspace port reuse test",
|
||||
status: "active",
|
||||
cwd: workspaceRoot,
|
||||
providerType: "local_fs",
|
||||
providerRef: workspaceRoot,
|
||||
});
|
||||
|
||||
const actor = {
|
||||
id: agentId,
|
||||
name: "Codex Coder",
|
||||
companyId,
|
||||
};
|
||||
const workspace = {
|
||||
...buildWorkspace(workspaceRoot),
|
||||
projectId,
|
||||
workspaceId: null,
|
||||
};
|
||||
const config = {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
expose: {
|
||||
type: "url",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const first = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor,
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]?.port).toBeGreaterThan(0);
|
||||
await expect(fetch(first[0]!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
await expect(fetch(first[0]!.url!)).rejects.toThrow();
|
||||
|
||||
const second = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor,
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]?.id).toBe(first[0]?.id);
|
||||
expect(second[0]?.port).toBe(first[0]?.port);
|
||||
expect(second[0]?.url).toBe(first[0]?.url);
|
||||
await expect(fetch(second[0]!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([
|
||||
"acpx_local",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor_cloud",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"openclaw_gateway",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { AdapterModelProfileDefinition, ServerAdapterModule } from "./types.js";
|
||||
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||
import type {
|
||||
AdapterModel,
|
||||
AdapterModelProfileDefinition,
|
||||
AdapterRuntimeCommandSpec,
|
||||
ServerAdapterModule,
|
||||
} from "./types.js";
|
||||
import {
|
||||
buildSandboxNpmInstallCommand,
|
||||
getAdapterSessionManagement,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
execute as acpxExecute,
|
||||
testEnvironment as acpxTestEnvironment,
|
||||
@@ -8,7 +16,10 @@ import {
|
||||
listAcpxSkills,
|
||||
syncAcpxSkills,
|
||||
} from "@paperclipai/adapter-acpx-local/server";
|
||||
import { agentConfigurationDoc as acpxAgentConfigurationDoc } from "@paperclipai/adapter-acpx-local";
|
||||
import {
|
||||
agentConfigurationDoc as acpxAgentConfigurationDoc,
|
||||
models as acpxModels,
|
||||
} from "@paperclipai/adapter-acpx-local";
|
||||
import {
|
||||
execute as claudeExecute,
|
||||
listClaudeSkills,
|
||||
@@ -48,6 +59,13 @@ import {
|
||||
models as cursorModels,
|
||||
modelProfiles as cursorModelProfiles,
|
||||
} from "@paperclipai/adapter-cursor-local";
|
||||
import {
|
||||
execute as cursorCloudExecute,
|
||||
getConfigSchema as getCursorCloudConfigSchema,
|
||||
sessionCodec as cursorCloudSessionCodec,
|
||||
testEnvironment as cursorCloudTestEnvironment,
|
||||
} from "@paperclipai/adapter-cursor-cloud/server";
|
||||
import { agentConfigurationDoc as cursorCloudAgentConfigurationDoc } from "@paperclipai/adapter-cursor-cloud";
|
||||
import {
|
||||
execute as geminiExecute,
|
||||
listGeminiSkills,
|
||||
@@ -113,6 +131,45 @@ import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
|
||||
const value = typeof config.command === "string" ? config.command.trim() : "";
|
||||
return value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function hasPathSeparator(command: string): boolean {
|
||||
return command.includes("/") || command.includes("\\");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildNpmRuntimeCommandSpec(
|
||||
config: Record<string, unknown>,
|
||||
fallbackCommand: string,
|
||||
packageName: string,
|
||||
): AdapterRuntimeCommandSpec {
|
||||
const command = readConfiguredCommand(config, fallbackCommand);
|
||||
const canSelfInstall = !hasPathSeparator(command) && command === fallbackCommand;
|
||||
const installLine = buildSandboxNpmInstallCommand(packageName);
|
||||
return {
|
||||
command,
|
||||
detectCommand: command,
|
||||
installCommand: canSelfInstall
|
||||
? `if ! command -v ${shellQuote(command)} >/dev/null 2>&1; then ${installLine}; fi`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCursorRuntimeCommandSpec(config: Record<string, unknown>): AdapterRuntimeCommandSpec {
|
||||
const command = readConfiguredCommand(config, "agent");
|
||||
return {
|
||||
command,
|
||||
detectCommand: command,
|
||||
installCommand: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(ctx: T): T {
|
||||
const config =
|
||||
ctx && typeof ctx === "object" && "config" in ctx && ctx.config && typeof ctx.config === "object"
|
||||
@@ -144,6 +201,38 @@ function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const result: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
result.push({ ...model, id });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function prefixAdapterModelLabels(models: AdapterModel[], provider: "Claude" | "Codex"): AdapterModel[] {
|
||||
const prefix = `${provider}: `;
|
||||
return models.map((model) => ({
|
||||
...model,
|
||||
label: model.label.startsWith(prefix) ? model.label : `${prefix}${model.label}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listAcpxModels(): Promise<AdapterModel[]> {
|
||||
const [claude, codex] = await Promise.all([
|
||||
listClaudeModels().catch(() => claudeModels),
|
||||
listCodexModels().catch(() => codexModels),
|
||||
]);
|
||||
return dedupeAdapterModels([
|
||||
...acpxModels,
|
||||
...prefixAdapterModelLabels(claude, "Claude"),
|
||||
...prefixAdapterModelLabels(codex, "Codex"),
|
||||
]);
|
||||
}
|
||||
|
||||
const claudeLocalAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
@@ -159,6 +248,8 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) =>
|
||||
buildNpmRuntimeCommandSpec(config, "claude", "@anthropic-ai/claude-code"),
|
||||
agentConfigurationDoc: claudeAgentConfigurationDoc,
|
||||
getQuotaWindows: claudeGetQuotaWindows,
|
||||
};
|
||||
@@ -171,6 +262,11 @@ const acpxLocalAdapter: ServerAdapterModule = {
|
||||
syncSkills: syncAcpxSkills,
|
||||
sessionCodec: acpxSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined,
|
||||
models: dedupeAdapterModels([
|
||||
...prefixAdapterModelLabels(claudeModels, "Claude"),
|
||||
...prefixAdapterModelLabels(codexModels, "Codex"),
|
||||
]),
|
||||
listModels: listAcpxModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
@@ -195,6 +291,7 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||
getQuotaWindows: codexGetQuotaWindows,
|
||||
};
|
||||
@@ -214,9 +311,25 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
getRuntimeCommandSpec: buildCursorRuntimeCommandSpec,
|
||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const cursorCloudAdapter: ServerAdapterModule = {
|
||||
type: "cursor_cloud",
|
||||
execute: cursorCloudExecute,
|
||||
testEnvironment: cursorCloudTestEnvironment,
|
||||
sessionCodec: cursorCloudSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("cursor_cloud") ?? undefined,
|
||||
models: [],
|
||||
supportsLocalAgentJwt: false,
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
agentConfigurationDoc: cursorCloudAgentConfigurationDoc,
|
||||
getConfigSchema: getCursorCloudConfigSchema,
|
||||
};
|
||||
|
||||
const geminiLocalAdapter: ServerAdapterModule = {
|
||||
type: "gemini_local",
|
||||
execute: geminiExecute,
|
||||
@@ -231,6 +344,8 @@ const geminiLocalAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
getRuntimeCommandSpec: (config) =>
|
||||
buildNpmRuntimeCommandSpec(config, "gemini", "@google/gemini-cli"),
|
||||
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
@@ -260,6 +375,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai"),
|
||||
agentConfigurationDoc: openCodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
@@ -278,6 +394,8 @@ const piLocalAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
getRuntimeCommandSpec: (config) =>
|
||||
buildNpmRuntimeCommandSpec(config, "pi", "@mariozechner/pi-coding-agent"),
|
||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
@@ -365,6 +483,7 @@ function registerBuiltInAdapters() {
|
||||
codexLocalAdapter,
|
||||
openCodeLocalAdapter,
|
||||
piLocalAdapter,
|
||||
cursorCloudAdapter,
|
||||
cursorLocalAdapter,
|
||||
geminiLocalAdapter,
|
||||
openclawGatewayAdapter,
|
||||
|
||||
@@ -30,5 +30,6 @@ export type {
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
AdapterRuntimeCommandSpec,
|
||||
ServerAdapterModule,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
@@ -253,6 +253,8 @@ export async function createApp(
|
||||
instanceInfo: {
|
||||
instanceId: opts.instanceId ?? "default",
|
||||
hostVersion: opts.hostVersion ?? "0.0.0",
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
},
|
||||
buildHostHandlers: (pluginId, manifest) => {
|
||||
const notifyWorker = (method: string, params: unknown) => {
|
||||
@@ -261,6 +263,7 @@ export async function createApp(
|
||||
};
|
||||
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
|
||||
pluginWorkerManager: workerManager,
|
||||
manifest,
|
||||
});
|
||||
hostServicesDisposers.set(pluginId, () => services.dispose());
|
||||
return createHostClientHandlers({
|
||||
|
||||
@@ -120,11 +120,6 @@ export function loadConfig(): Config {
|
||||
const fileDatabaseBackup = fileConfig?.database.backup;
|
||||
const fileSecrets = fileConfig?.secrets;
|
||||
const fileStorage = fileConfig?.storage;
|
||||
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
||||
const secretsStrictMode =
|
||||
strictModeFromEnv !== undefined
|
||||
? strictModeFromEnv === "true"
|
||||
: (fileSecrets?.strictMode ?? false);
|
||||
|
||||
const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
const providerFromEnv =
|
||||
@@ -168,6 +163,11 @@ export function loadConfig(): Config {
|
||||
? (deploymentModeFromEnvRaw as DeploymentMode)
|
||||
: null;
|
||||
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
|
||||
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
||||
const secretsStrictMode =
|
||||
strictModeFromEnv !== undefined
|
||||
? strictModeFromEnv === "true"
|
||||
: (fileSecrets?.strictMode ?? deploymentMode === "authenticated");
|
||||
const deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||
const deploymentExposureFromEnv =
|
||||
deploymentExposureFromEnvRaw &&
|
||||
|
||||
+26
-37
@@ -1,57 +1,50 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const FRIENDLY_PATH_SEGMENT_RE = /[^a-zA-Z0-9._-]+/g;
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir as resolveSharedDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir as resolveSharedDefaultLogsDir,
|
||||
resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir as resolveSharedDefaultStorageDir,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot,
|
||||
} from "@paperclipai/shared/home-paths";
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(): string {
|
||||
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRoot(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId());
|
||||
}
|
||||
export {
|
||||
expandHomePrefix,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot,
|
||||
};
|
||||
|
||||
export function resolveDefaultConfigPath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "config.json");
|
||||
return resolvePaperclipConfigPathForInstance();
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "db");
|
||||
return resolveSharedDefaultEmbeddedPostgresDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "logs");
|
||||
return resolveSharedDefaultLogsDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "master.key");
|
||||
return resolveSharedDefaultSecretsKeyFilePath();
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "data", "storage");
|
||||
return resolveSharedDefaultStorageDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "data", "backups");
|
||||
return resolveSharedDefaultBackupDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
|
||||
@@ -89,7 +82,3 @@ export function resolveManagedProjectWorkspaceDir(input: {
|
||||
sanitizeFriendlyPathSegment(input.repoName, "_default"),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
|
||||
@@ -686,6 +686,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
reconciled.assignmentDispatched > 0 ||
|
||||
reconciled.dispatchRequeued > 0 ||
|
||||
reconciled.continuationRequeued > 0 ||
|
||||
reconciled.successfulRunHandoffEscalated > 0 ||
|
||||
reconciled.escalated > 0
|
||||
) {
|
||||
logger.warn(
|
||||
@@ -751,6 +752,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
reconciled.assignmentDispatched > 0 ||
|
||||
reconciled.dispatchRequeued > 0 ||
|
||||
reconciled.continuationRequeued > 0 ||
|
||||
reconciled.successfulRunHandoffEscalated > 0 ||
|
||||
reconciled.escalated > 0
|
||||
) {
|
||||
logger.warn(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { Request, RequestHandler } from "express";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentApiKeys, agents, companyMemberships, instanceUserRoles } from "@paperclipai/db";
|
||||
import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
|
||||
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
@@ -38,6 +38,16 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
const authHeader = req.header("authorization");
|
||||
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
||||
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
|
||||
const cloudTenantActor = await resolveCloudTenantActor(db, req);
|
||||
if (cloudTenantActor) {
|
||||
req.actor = {
|
||||
...cloudTenantActor,
|
||||
runId: runIdHeader ?? undefined,
|
||||
};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
let session: BetterAuthSessionResult | null = null;
|
||||
try {
|
||||
session = await opts.resolveSession(req);
|
||||
@@ -189,6 +199,149 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCloudTenantActor(db: Db, req: Request): Promise<Express.Request["actor"] | null> {
|
||||
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
|
||||
if (!expectedToken) return null;
|
||||
|
||||
const token = req.header("x-paperclip-cloud-tenant-token")?.trim();
|
||||
if (!token || !constantTimeStringEqual(token, expectedToken)) return null;
|
||||
|
||||
const userId = requiredCloudHeader(req, "x-paperclip-cloud-user-id");
|
||||
const userEmail = requiredCloudHeader(req, "x-paperclip-cloud-user-email").toLowerCase();
|
||||
const stackId = requiredCloudHeader(req, "x-paperclip-cloud-stack-id");
|
||||
const stackRole = stackMembershipRole(req.header("x-paperclip-cloud-stack-role"));
|
||||
const userName = req.header("x-paperclip-cloud-user-name")?.trim() || userEmail;
|
||||
const paperclipCompanyId = req.header("x-paperclip-cloud-paperclip-company-id")?.trim();
|
||||
const companyId = cloudTenantCompanyId(stackId);
|
||||
const companyName = paperclipCompanyId || `${stackId} Paperclip`;
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
id: userId,
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: authUsers.id,
|
||||
set: {
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
emailVerified: true,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(instanceUserRoles)
|
||||
.values({
|
||||
userId,
|
||||
role: "instance_admin",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [instanceUserRoles.userId, instanceUserRoles.role],
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(companies)
|
||||
.values({
|
||||
id: companyId,
|
||||
name: companyName,
|
||||
description: `Provisioned by Paperclip Cloud for stack ${stackId}.`,
|
||||
status: "active",
|
||||
issuePrefix: issuePrefixForCloudStack(stackId),
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: companies.id,
|
||||
});
|
||||
|
||||
const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
|
||||
const membership = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
companyMemberships.companyId,
|
||||
companyMemberships.principalType,
|
||||
companyMemberships.principalId,
|
||||
],
|
||||
set: {
|
||||
status: "active",
|
||||
membershipRole,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? {
|
||||
companyId,
|
||||
membershipRole,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
return {
|
||||
type: "board",
|
||||
userId,
|
||||
userName,
|
||||
userEmail,
|
||||
companyIds: [companyId],
|
||||
memberships: [{
|
||||
companyId,
|
||||
membershipRole: membership.membershipRole,
|
||||
status: membership.status,
|
||||
}],
|
||||
isInstanceAdmin: true,
|
||||
source: "cloud_tenant",
|
||||
};
|
||||
}
|
||||
|
||||
function requiredCloudHeader(req: Request, name: string): string {
|
||||
const value = req.header(name)?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing trusted Cloud tenant header ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stackMembershipRole(value: string | undefined): "owner" | "admin" | "member" | "support" {
|
||||
if (value === "owner" || value === "admin" || value === "member" || value === "support") {
|
||||
return value;
|
||||
}
|
||||
throw new Error("Invalid trusted Cloud tenant stack role");
|
||||
}
|
||||
|
||||
function constantTimeStringEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function cloudTenantCompanyId(stackId: string): string {
|
||||
const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest();
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = bytes.subarray(0, 16).toString("hex");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
function issuePrefixForCloudStack(stackId: string): string {
|
||||
const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase();
|
||||
return `PC${hash}`;
|
||||
}
|
||||
|
||||
export function requireBoard(req: Express.Request) {
|
||||
return req.actor.type === "board";
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ You are an agent at Paperclip company.
|
||||
|
||||
- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning.
|
||||
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
|
||||
- Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit.
|
||||
- Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit.
|
||||
- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.
|
||||
- Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.
|
||||
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
|
||||
- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`.
|
||||
- Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { normalizeIssueIdentifier } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { activityService, normalizeActivityLimit } from "../services/activity.js";
|
||||
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
@@ -24,8 +25,9 @@ export function activityRoutes(db: Db) {
|
||||
const issueSvc = issueService(db);
|
||||
|
||||
async function resolveIssueByRef(rawId: string) {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
return issueSvc.getByIdentifier(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
if (identifier) {
|
||||
return issueSvc.getByIdentifier(identifier);
|
||||
}
|
||||
return issueSvc.getById(rawId);
|
||||
}
|
||||
|
||||
+331
-73
@@ -13,6 +13,7 @@ import {
|
||||
createAgentSchema,
|
||||
deriveAgentUrlKey,
|
||||
isUuidLike,
|
||||
normalizeIssueIdentifier,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type AgentSkillSnapshot,
|
||||
@@ -55,8 +56,12 @@ import {
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
@@ -84,7 +89,8 @@ import {
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
|
||||
import {
|
||||
loadDefaultAgentInstructionsBundle,
|
||||
resolveDefaultAgentInstructionsBundleRole,
|
||||
@@ -158,6 +164,9 @@ export function agentRoutes(
|
||||
const approvalsSvc = approvalService(db);
|
||||
const budgets = budgetService(db);
|
||||
const environmentsSvc = environmentService(db);
|
||||
const environmentRuntime = environmentRuntimeService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
const heartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
@@ -189,9 +198,13 @@ export function agentRoutes(
|
||||
* - SSH environment → builds an SSH execution target from the environment
|
||||
* config so the adapter probes the remote box. No lease is required:
|
||||
* the SSH spec is fully derived from the saved environment config.
|
||||
* - Sandbox / plugin environments → currently fall back to local probing
|
||||
* with a warning check, since lifting a temporary sandbox lease for an
|
||||
* ad-hoc test invocation is out of scope for this iteration.
|
||||
* - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the
|
||||
* workspace, and resolves a sandbox execution target wired to the runtime
|
||||
* so the adapter probe runs inside the sandbox the same way a heartbeat
|
||||
* would. The returned `release` callback rolls the lease back when the
|
||||
* route is done.
|
||||
*
|
||||
* The caller MUST always invoke `release()` (typically in a `finally` block).
|
||||
*/
|
||||
async function resolveAdapterTestExecutionContext(input: {
|
||||
companyId: string;
|
||||
@@ -201,9 +214,17 @@ export function agentRoutes(
|
||||
executionTarget: AdapterExecutionTarget | null;
|
||||
environmentName: string | null;
|
||||
fallbackChecks: AdapterEnvironmentCheck[];
|
||||
release: (status?: "released" | "failed") => Promise<void>;
|
||||
}> {
|
||||
const noopRelease = async () => {};
|
||||
|
||||
if (!input.environmentId) {
|
||||
return { executionTarget: null, environmentName: null, fallbackChecks: [] };
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const environment = await environmentsSvc.getById(input.environmentId);
|
||||
@@ -215,14 +236,20 @@ export function agentRoutes(
|
||||
{
|
||||
code: "environment_not_found",
|
||||
level: "warn",
|
||||
message: "Selected environment was not found. Falling back to a local probe.",
|
||||
message: "Selected environment was not found. The test did not run.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "local") {
|
||||
return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] };
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "ssh") {
|
||||
@@ -239,7 +266,12 @@ export function agentRoutes(
|
||||
leaseMetadata: null,
|
||||
});
|
||||
if (target) {
|
||||
return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] };
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executionTarget: null,
|
||||
@@ -249,9 +281,10 @@ export function agentRoutes(
|
||||
code: "environment_target_unavailable",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not resolve an execution target for environment "${environment.name}". Falling back to a local probe.`,
|
||||
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -262,27 +295,163 @@ export function agentRoutes(
|
||||
code: "environment_target_failed",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not connect to environment "${environment.name}" to run the test. Falling back to a local probe.`,
|
||||
`Could not connect to environment "${environment.name}" to run the test.`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests.
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_driver_not_supported_for_test",
|
||||
level: "warn",
|
||||
message:
|
||||
`Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`,
|
||||
hint: "Run a real heartbeat in the environment to verify end-to-end behavior.",
|
||||
// sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize
|
||||
// the workspace inside the box, and run the same probe SSH uses against
|
||||
// a sandbox execution target wired to the environment runtime.
|
||||
//
|
||||
// We pass `heartbeatRunId: null` because there's no heartbeat run for an
|
||||
// operator-initiated `Test` invocation — the leases table FKs heartbeat
|
||||
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
|
||||
// run row. Cleanup goes through the driver's `releaseRunLease` directly
|
||||
// (by lease record), since the batch helper queries by heartbeatRunId.
|
||||
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
|
||||
try {
|
||||
leaseRecord = await environmentRuntime.acquireRunLease({
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_lease_acquire_failed",
|
||||
level: "error",
|
||||
message: `Could not acquire a lease for environment "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Check the environment's provider credentials and quota.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const driver = environmentRuntime.getDriver(environment.driver);
|
||||
const releaseLease = async (status: "released" | "failed" = "released") => {
|
||||
try {
|
||||
if (driver) {
|
||||
await driver.releaseRunLease({
|
||||
environment,
|
||||
lease: leaseRecord.lease,
|
||||
status,
|
||||
});
|
||||
} else {
|
||||
await environmentsSvc.releaseLease(leaseRecord.lease.id, status);
|
||||
}
|
||||
} catch (err) {
|
||||
// Cleanup failures must not mask the test result.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let realizedCwd: string | null = null;
|
||||
try {
|
||||
const realized = await environmentRuntime.realizeWorkspace({
|
||||
environment,
|
||||
lease: leaseRecord.lease,
|
||||
// No host workspace to copy for a Test invocation; sandbox/plugin
|
||||
// realize implementations use the lease metadata's remoteCwd to
|
||||
// create the working directory inside the box.
|
||||
workspace: {},
|
||||
});
|
||||
realizedCwd =
|
||||
typeof realized.cwd === "string" && realized.cwd.trim().length > 0
|
||||
? realized.cwd.trim()
|
||||
: null;
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_workspace_realize_failed",
|
||||
level: "error",
|
||||
message: `Could not realize a workspace inside "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
let target: AdapterExecutionTarget | null;
|
||||
try {
|
||||
// Prefer the cwd the realize step returned; fall back to lease metadata.
|
||||
const leaseMetadataForTarget: Record<string, unknown> | null =
|
||||
realizedCwd
|
||||
? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd }
|
||||
: (leaseRecord.lease.metadata as Record<string, unknown> | null) ?? null;
|
||||
|
||||
target = await resolveEnvironmentExecutionTarget({
|
||||
db,
|
||||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
driver: environment.driver,
|
||||
config: environment.config ?? null,
|
||||
},
|
||||
],
|
||||
leaseId: leaseRecord.lease.id,
|
||||
leaseMetadata: leaseMetadataForTarget,
|
||||
lease: leaseRecord.lease,
|
||||
environmentRuntime,
|
||||
});
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_failed",
|
||||
level: "error",
|
||||
message: `Could not resolve a sandbox execution target for "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_unsupported",
|
||||
level: "warn",
|
||||
message:
|
||||
`Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: releaseLease,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -767,7 +936,6 @@ export function agentRoutes(
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
input.companyId,
|
||||
input.adapterType,
|
||||
input.constraintAdapterConfig
|
||||
? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig }
|
||||
@@ -864,7 +1032,10 @@ export function agentRoutes(
|
||||
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
@@ -872,20 +1043,12 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
async function assertAdapterConfigConstraints(
|
||||
companyId: string,
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType !== "opencode_local") return;
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: runtimeConfig.model,
|
||||
command: runtimeConfig.command,
|
||||
cwd: runtimeConfig.cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
requireOpenCodeModelId(adapterConfig.model);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
||||
@@ -1194,6 +1357,17 @@ export function agentRoutes(
|
||||
const refresh = typeof req.query.refresh === "string"
|
||||
? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase())
|
||||
: false;
|
||||
const environmentId = asNonEmptyString(req.query.environmentId);
|
||||
const environment = environmentId ? await environmentsSvc.getById(environmentId) : null;
|
||||
if (environmentId && (!environment || environment.companyId !== companyId)) {
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
if (type === "opencode_local" && environment && environment.driver !== "local") {
|
||||
const adapter = requireServerAdapter(type);
|
||||
res.json(adapter.models ?? []);
|
||||
return;
|
||||
}
|
||||
const models = refresh
|
||||
? await refreshAdapterModels(type)
|
||||
: await listAdapterModels(type);
|
||||
@@ -1243,33 +1417,51 @@ export function agentRoutes(
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
|
||||
const { executionTarget, environmentName, fallbackChecks } =
|
||||
const { executionTarget, environmentName, fallbackChecks, release } =
|
||||
await resolveAdapterTestExecutionContext({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
environmentId: requestedEnvironmentId,
|
||||
});
|
||||
|
||||
const result = await adapter.testEnvironment({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
config: runtimeAdapterConfig,
|
||||
executionTarget,
|
||||
environmentName,
|
||||
});
|
||||
let releaseStatus: "released" | "failed" = "released";
|
||||
try {
|
||||
// If the caller explicitly selected an environment, never fall back to
|
||||
// probing the host when we couldn't resolve that environment's
|
||||
// execution target. Surface the diagnostic checks instead.
|
||||
if (requestedEnvironmentId && !executionTarget && fallbackChecks.length > 0) {
|
||||
const status: AdapterEnvironmentTestResult["status"] = fallbackChecks.some((c) => c.level === "error")
|
||||
? "fail"
|
||||
: fallbackChecks.some((c) => c.level === "warn")
|
||||
? "warn"
|
||||
: "pass";
|
||||
if (status === "fail") releaseStatus = "failed";
|
||||
const synthesized: AdapterEnvironmentTestResult = {
|
||||
adapterType: type,
|
||||
status,
|
||||
checks: fallbackChecks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
res.json(synthesized);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackChecks.length > 0) {
|
||||
const checks = [...fallbackChecks, ...result.checks];
|
||||
const status: typeof result.status = checks.some((c) => c.level === "error")
|
||||
? "fail"
|
||||
: checks.some((c) => c.level === "warn")
|
||||
? "warn"
|
||||
: result.status;
|
||||
res.json({ ...result, checks, status });
|
||||
return;
|
||||
const result = await adapter.testEnvironment({
|
||||
companyId,
|
||||
adapterType: type,
|
||||
config: runtimeAdapterConfig,
|
||||
executionTarget,
|
||||
environmentName,
|
||||
});
|
||||
|
||||
if (result.status === "fail") releaseStatus = "failed";
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
releaseStatus = "failed";
|
||||
throw err;
|
||||
} finally {
|
||||
await release(releaseStatus);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1997,6 +2189,14 @@ export function agentRoutes(
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
if (agentEnv) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -2473,6 +2673,14 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (touchesAdapterConfiguration) {
|
||||
const agentEnv = asRecord(agent.adapterConfig)?.env;
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
agent.companyId,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
agentEnv,
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -2691,7 +2899,25 @@ export function agentRoutes(
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
||||
// Shared handler body for the wakeup-style endpoints. The two routes differ
|
||||
// only in:
|
||||
// - `source` — the modern /wakeup endpoint reads it from the request body
|
||||
// (timer|assignment|on_demand|automation) while the legacy
|
||||
// /heartbeat/invoke endpoint hardcodes "on_demand", since it has only
|
||||
// ever produced on-demand invocations.
|
||||
// - skipped-response shape — the modern endpoint surfaces the rich
|
||||
// SkippedWakeupResponse; the legacy endpoint stays on the simpler
|
||||
// { status: "skipped" } shape for backward compat.
|
||||
type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation";
|
||||
type WakeupRouteOpts = {
|
||||
source: HeartbeatSource | undefined;
|
||||
skippedResponse: (agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) => unknown | Promise<unknown>;
|
||||
};
|
||||
const handleWakeupRoute = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
opts: WakeupRouteOpts,
|
||||
): Promise<void> => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
@@ -2710,7 +2936,7 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
const run = await heartbeat.wakeup(id, {
|
||||
source: req.body.source,
|
||||
source: opts.source,
|
||||
triggerDetail: req.body.triggerDetail ?? "manual",
|
||||
reason: req.body.reason ?? null,
|
||||
payload: req.body.payload ?? null,
|
||||
@@ -2725,7 +2951,7 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
|
||||
res.status(202).json(await opts.skippedResponse(agent));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2743,9 +2969,23 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
res.status(202).json(run);
|
||||
};
|
||||
|
||||
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
||||
await handleWakeupRoute(req, res, {
|
||||
source: req.body.source,
|
||||
skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
|
||||
// Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior
|
||||
// before the wakeup/invoke convergence). Reads scope fields directly off
|
||||
// the body without `validate(wakeAgentSchema)` because callers — including
|
||||
// the e2e suite — post an empty body, and the schema rejects undefined
|
||||
// / missing bodies. Only forwards fields the caller actually supplied so
|
||||
// an empty body produces the original fixed-arg `heartbeat.invoke()`
|
||||
// shape exactly.
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
@@ -2763,19 +3003,37 @@ export function agentRoutes(
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
|
||||
const run = await heartbeat.invoke(
|
||||
id,
|
||||
"on_demand",
|
||||
{
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
},
|
||||
"manual",
|
||||
{
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
},
|
||||
);
|
||||
const body = (req.body ?? {}) as Partial<{
|
||||
reason: unknown;
|
||||
payload: unknown;
|
||||
idempotencyKey: unknown;
|
||||
forceFreshSession: unknown;
|
||||
triggerDetail: unknown;
|
||||
}>;
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
};
|
||||
if (body.forceFreshSession === true) {
|
||||
contextSnapshot.forceFreshSession = true;
|
||||
}
|
||||
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
|
||||
source: "on_demand",
|
||||
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
|
||||
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
contextSnapshot,
|
||||
};
|
||||
if (typeof body.reason === "string" && body.reason.length > 0) {
|
||||
wakeOpts.reason = body.reason;
|
||||
}
|
||||
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
|
||||
wakeOpts.payload = body.payload as Record<string, unknown>;
|
||||
}
|
||||
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
|
||||
wakeOpts.idempotencyKey = body.idempotencyKey;
|
||||
}
|
||||
const run = await heartbeat.wakeup(id, wakeOpts);
|
||||
|
||||
if (!run) {
|
||||
res.status(202).json({ status: "skipped" });
|
||||
@@ -3082,8 +3340,8 @@ export function agentRoutes(
|
||||
router.get("/issues/:issueId/live-runs", async (req, res) => {
|
||||
const rawId = req.params.issueId as string;
|
||||
const issueSvc = issueService(db);
|
||||
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
|
||||
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
@@ -3136,8 +3394,8 @@ export function agentRoutes(
|
||||
router.get("/issues/:issueId/active-run", async (req, res) => {
|
||||
const rawId = req.params.issueId as string;
|
||||
const issueSvc = issueService(db);
|
||||
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
|
||||
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createCostEventSchema,
|
||||
createFinanceEventSchema,
|
||||
normalizeIssueIdentifier,
|
||||
resolveBudgetIncidentSchema,
|
||||
updateBudgetSchema,
|
||||
upsertBudgetPolicySchema,
|
||||
@@ -62,8 +63,9 @@ export function costRoutes(
|
||||
const issues = issueService(db);
|
||||
|
||||
async function resolveIssueByRef(rawId: string) {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
return issues.getByIdentifier(rawId);
|
||||
const identifier = normalizeIssueIdentifier(rawId);
|
||||
if (identifier) {
|
||||
return issues.getByIdentifier(identifier);
|
||||
}
|
||||
return issues.getById(rawId);
|
||||
}
|
||||
@@ -143,7 +145,8 @@ export function costRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const summary = await costs.issueTreeSummary(issue.companyId, issue.id);
|
||||
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
|
||||
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
import {
|
||||
collectEnvironmentSecretRefs,
|
||||
normalizeEnvironmentConfigForPersistence,
|
||||
normalizeEnvironmentConfigForProbe,
|
||||
parseEnvironmentDriverConfig,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
import { probeEnvironment } from "../services/environment-probe.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
@@ -202,6 +204,7 @@ export function environmentRoutes(
|
||||
companyId,
|
||||
environmentName: req.body.name,
|
||||
driver: req.body.driver,
|
||||
secretProvider: getConfiguredSecretProvider(),
|
||||
config: req.body.config,
|
||||
actor: {
|
||||
agentId: actor.agentId,
|
||||
@@ -211,6 +214,11 @@ export function environmentRoutes(
|
||||
}),
|
||||
};
|
||||
const environment = await svc.create(companyId, input);
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
companyId,
|
||||
{ targetType: "environment", targetId: environment.id },
|
||||
await collectEnvironmentSecretRefs({ db, environment }),
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -305,6 +313,7 @@ export function environmentRoutes(
|
||||
companyId: existing.companyId,
|
||||
environmentName: nextName,
|
||||
driver: nextDriver,
|
||||
secretProvider: getConfiguredSecretProvider(),
|
||||
config: configSource,
|
||||
actor: {
|
||||
agentId: actor.agentId,
|
||||
@@ -320,6 +329,13 @@ export function environmentRoutes(
|
||||
res.status(404).json({ error: "Environment not found" });
|
||||
return;
|
||||
}
|
||||
if (patch.config !== undefined || patch.driver !== undefined) {
|
||||
await secrets.syncSecretRefsForTarget(
|
||||
environment.companyId,
|
||||
{ targetType: "environment", targetId: environment.id },
|
||||
await collectEnvironmentSecretRefs({ db, environment }),
|
||||
);
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: environment.companyId,
|
||||
actorType: actor.actorType,
|
||||
|
||||
+722
-13
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,17 @@ import {
|
||||
getActorInfo,
|
||||
} from "./authz.js";
|
||||
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
||||
import {
|
||||
findLocalFolderDeclaration,
|
||||
getStoredLocalFolders,
|
||||
inspectPluginLocalFolder,
|
||||
requireLocalFolderDeclaration,
|
||||
setStoredLocalFolder,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
import {
|
||||
extractSecretRefPathsFromConfig,
|
||||
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
|
||||
} from "../services/plugin-secrets-handler.js";
|
||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
@@ -1934,6 +1945,12 @@ export function pluginRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
|
||||
if (secretRefsByPath.size > 0) {
|
||||
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await registry.upsertConfig(plugin.id, {
|
||||
configJson: body.configJson,
|
||||
});
|
||||
@@ -2379,6 +2396,152 @@ export function pluginRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Company-scoped trusted local folders
|
||||
// ===========================================================================
|
||||
|
||||
router.get("/plugins/:pluginId/companies/:companyId/local-folders", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
|
||||
const declarations = plugin.manifestJson.localFolders ?? [];
|
||||
const folderKeys = declarations.map((declaration) => declaration.folderKey);
|
||||
|
||||
const statuses = await Promise.all(folderKeys.map((folderKey) =>
|
||||
inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration: findLocalFolderDeclaration(declarations, folderKey),
|
||||
storedConfig: storedFolders[folderKey] ?? null,
|
||||
})));
|
||||
|
||||
res.json({
|
||||
pluginId: plugin.id,
|
||||
companyId,
|
||||
declarations,
|
||||
folders: statuses,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
|
||||
const declarations = plugin.manifestJson.localFolders ?? [];
|
||||
const declaration = requireLocalFolderDeclaration(declarations, folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
storedConfig: storedFolders[folderKey] ?? null,
|
||||
});
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
router.post("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
path?: unknown;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
} | undefined;
|
||||
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
|
||||
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
overrideConfig: {
|
||||
path: body.path,
|
||||
},
|
||||
});
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
path?: unknown;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
} | undefined;
|
||||
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
|
||||
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
storedConfig: getStoredLocalFolders(existing?.settingsJson)[folderKey] ?? null,
|
||||
overrideConfig: {
|
||||
path: body.path,
|
||||
},
|
||||
});
|
||||
|
||||
const nextSettings = setStoredLocalFolder(existing?.settingsJson, folderKey, {
|
||||
path: body.path,
|
||||
access: status.access,
|
||||
requiredDirectories: status.requiredDirectories,
|
||||
requiredFiles: status.requiredFiles,
|
||||
});
|
||||
await registry.upsertCompanySettings(plugin.id, companyId, {
|
||||
enabled: existing?.enabled ?? true,
|
||||
settingsJson: nextSettings,
|
||||
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
|
||||
});
|
||||
await logPluginMutationActivity(req, "plugin.local_folder.configured", plugin.id, {
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
companyId,
|
||||
folderKey,
|
||||
healthy: status.healthy,
|
||||
});
|
||||
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Plugin health dashboard — aggregated diagnostics for the settings page
|
||||
// ===========================================================================
|
||||
|
||||
@@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
const project = await svc.create(companyId, projectData);
|
||||
if (project.env) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
{ targetType: "project", targetId: project.id },
|
||||
project.env,
|
||||
);
|
||||
}
|
||||
let createdWorkspaceId: string | null = null;
|
||||
if (workspace) {
|
||||
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
|
||||
@@ -207,6 +214,13 @@ export function projectRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
if (body.env !== undefined) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
project.companyId,
|
||||
{ targetType: "project", targetId: project.id },
|
||||
project.env,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
|
||||
@@ -57,6 +57,34 @@ export function routineRoutes(
|
||||
return routine;
|
||||
}
|
||||
|
||||
async function logRoutineRevisionCreated(req: Request, input: {
|
||||
companyId: string;
|
||||
routineId: string;
|
||||
revisionId: string | null;
|
||||
revisionNumber: number;
|
||||
changeSummary?: string | null;
|
||||
triggerCount?: number | null;
|
||||
}) {
|
||||
if (!input.revisionId) return;
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "routine.revision_created",
|
||||
entityType: "routine",
|
||||
entityId: input.routineId,
|
||||
details: {
|
||||
revisionId: input.revisionId,
|
||||
revisionNumber: input.revisionNumber,
|
||||
changeSummary: input.changeSummary ?? null,
|
||||
triggerCount: input.triggerCount ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/routines", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -72,6 +100,7 @@ export function routineRoutes(
|
||||
const created = await svc.create(companyId, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -89,6 +118,14 @@ export function routineRoutes(
|
||||
if (telemetryClient) {
|
||||
trackRoutineCreated(telemetryClient);
|
||||
}
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId,
|
||||
routineId: created.id,
|
||||
revisionId: created.latestRevisionId,
|
||||
revisionNumber: created.latestRevisionNumber,
|
||||
changeSummary: "Created routine",
|
||||
triggerCount: 0,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
@@ -102,6 +139,16 @@ export function routineRoutes(
|
||||
res.json(detail);
|
||||
});
|
||||
|
||||
router.get("/routines/:id/revisions", async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
const revisions = await svc.listRevisions(routine.id);
|
||||
res.json(revisions);
|
||||
});
|
||||
|
||||
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
@@ -131,6 +178,7 @@ export function routineRoutes(
|
||||
const updated = await svc.update(routine.id, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -144,9 +192,52 @@ export function routineRoutes(
|
||||
entityId: routine.id,
|
||||
details: { title: updated?.title ?? routine.title },
|
||||
});
|
||||
if (updated && updated.latestRevisionId !== routine.latestRevisionId) {
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: updated.latestRevisionId,
|
||||
revisionNumber: updated.latestRevisionNumber,
|
||||
changeSummary: "Updated routine",
|
||||
triggerCount: null,
|
||||
});
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/routines/:id/revisions/:revisionId/restore", async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||
const result = await svc.restoreRevision(routine.id, req.params.revisionId as string, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: routine.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "routine.revision_restored",
|
||||
entityType: "routine",
|
||||
entityId: routine.id,
|
||||
details: {
|
||||
revisionId: result.revision.id,
|
||||
revisionNumber: result.revision.revisionNumber,
|
||||
restoredFromRevisionId: result.restoredFromRevisionId,
|
||||
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
|
||||
triggerCount: result.revision.snapshot.triggers.length,
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/routines/:id/runs", async (req, res) => {
|
||||
const routine = await svc.get(req.params.id as string);
|
||||
if (!routine) {
|
||||
@@ -169,6 +260,7 @@ export function routineRoutes(
|
||||
const created = await svc.createTrigger(routine.id, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -182,6 +274,14 @@ export function routineRoutes(
|
||||
entityId: created.trigger.id,
|
||||
details: { routineId: routine.id, kind: created.trigger.kind },
|
||||
});
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: created.revision.id,
|
||||
revisionNumber: created.revision.revisionNumber,
|
||||
changeSummary: created.revision.changeSummary,
|
||||
triggerCount: created.revision.snapshot.triggers.length,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
@@ -200,6 +300,7 @@ export function routineRoutes(
|
||||
const updated = await svc.updateTrigger(trigger.id, req.body, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -211,9 +312,19 @@ export function routineRoutes(
|
||||
action: "routine.trigger_updated",
|
||||
entityType: "routine_trigger",
|
||||
entityId: trigger.id,
|
||||
details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind },
|
||||
details: { routineId: routine.id, kind: updated?.trigger.kind ?? trigger.kind },
|
||||
});
|
||||
res.json(updated);
|
||||
if (updated) {
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: updated.revision.id,
|
||||
revisionNumber: updated.revision.revisionNumber,
|
||||
changeSummary: updated.revision.changeSummary,
|
||||
triggerCount: updated.revision.snapshot.triggers.length,
|
||||
});
|
||||
}
|
||||
res.json(updated?.trigger ?? null);
|
||||
});
|
||||
|
||||
router.delete("/routine-triggers/:id", async (req, res) => {
|
||||
@@ -227,7 +338,11 @@ export function routineRoutes(
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
await svc.deleteTrigger(trigger.id);
|
||||
const deleted = await svc.deleteTrigger(trigger.id, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: routine.companyId,
|
||||
@@ -240,6 +355,16 @@ export function routineRoutes(
|
||||
entityId: trigger.id,
|
||||
details: { routineId: routine.id, kind: trigger.kind },
|
||||
});
|
||||
if (deleted.revision) {
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: deleted.revision.id,
|
||||
revisionNumber: deleted.revision.revisionNumber,
|
||||
changeSummary: deleted.revision.changeSummary,
|
||||
triggerCount: deleted.revision.snapshot.triggers.length,
|
||||
});
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
@@ -260,6 +385,7 @@ export function routineRoutes(
|
||||
const rotated = await svc.rotateTriggerSecret(trigger.id, {
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
runId: req.actor.runId ?? null,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -273,6 +399,14 @@ export function routineRoutes(
|
||||
entityId: trigger.id,
|
||||
details: { routineId: routine.id },
|
||||
});
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
revisionId: rotated.revision.id,
|
||||
revisionNumber: rotated.revision.revisionNumber,
|
||||
changeSummary: rotated.revision.changeSummary,
|
||||
triggerCount: rotated.revision.snapshot.triggers.length,
|
||||
});
|
||||
res.json(rotated);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
SECRET_PROVIDERS,
|
||||
type SecretProvider,
|
||||
createSecretProviderConfigSchema,
|
||||
createSecretSchema,
|
||||
remoteSecretImportPreviewSchema,
|
||||
remoteSecretImportSchema,
|
||||
rotateSecretSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
updateSecretSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { logActivity, secretService } from "../services/index.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
|
||||
export function secretRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = secretService(db);
|
||||
const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
const defaultProvider = (
|
||||
configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider)
|
||||
? configuredDefaultProvider
|
||||
: "local_encrypted"
|
||||
) as SecretProvider;
|
||||
const defaultProvider = getConfiguredSecretProvider();
|
||||
|
||||
router.get("/companies/:companyId/secret-providers", (req, res) => {
|
||||
assertBoard(req);
|
||||
@@ -28,6 +26,205 @@ export function secretRoutes(db: Db) {
|
||||
res.json(svc.listProviders());
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secret-providers/health", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const checks = await svc.checkProviders();
|
||||
res.json({ providers: checks });
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secret-provider-configs", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listProviderConfigs(companyId));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const created = await svc.createProviderConfig(
|
||||
companyId,
|
||||
{
|
||||
provider: req.body.provider,
|
||||
displayName: req.body.displayName,
|
||||
status: req.body.status,
|
||||
isDefault: req.body.isDefault,
|
||||
config: req.body.config,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.created",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
provider: created.provider,
|
||||
displayName: created.displayName,
|
||||
status: created.status,
|
||||
isDefault: created.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.get("/secret-provider-configs/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const existing = await svc.getProviderConfigById(req.params.id as string);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
res.json(existing);
|
||||
});
|
||||
|
||||
router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const updated = await svc.updateProviderConfig(id, {
|
||||
displayName: req.body.displayName,
|
||||
status: req.body.status,
|
||||
isDefault: req.body.isDefault,
|
||||
config: req.body.config,
|
||||
});
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.updated",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
provider: updated.provider,
|
||||
displayName: updated.displayName,
|
||||
status: updated.status,
|
||||
isDefault: updated.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete("/secret-provider-configs/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const disabled = await svc.disableProviderConfig(id);
|
||||
if (!disabled) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: disabled.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.disabled",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: disabled.id,
|
||||
details: {
|
||||
provider: disabled.provider,
|
||||
displayName: disabled.displayName,
|
||||
status: disabled.status,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(disabled);
|
||||
});
|
||||
|
||||
router.post("/secret-provider-configs/:id/default", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const updated = await svc.setDefaultProviderConfig(id);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.default_set",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
provider: updated.provider,
|
||||
displayName: updated.displayName,
|
||||
isDefault: updated.isDefault,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/secret-provider-configs/:id/health", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getProviderConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const health = await svc.checkProviderConfigHealth(id);
|
||||
if (!health) {
|
||||
res.status(404).json({ error: "Provider vault not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret_provider_config.health_checked",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: existing.id,
|
||||
details: {
|
||||
provider: existing.provider,
|
||||
status: health.status,
|
||||
code: health.details.code,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secrets", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -45,10 +242,15 @@ export function secretRoutes(db: Db) {
|
||||
companyId,
|
||||
{
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
provider: req.body.provider ?? defaultProvider,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
managedMode: req.body.managedMode,
|
||||
value: req.body.value,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
@@ -66,6 +268,77 @@ export function secretRoutes(db: Db) {
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/secrets/remote-import/preview",
|
||||
validate(remoteSecretImportPreviewSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const preview = await svc.previewRemoteImport(companyId, {
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
query: req.body.query,
|
||||
nextToken: req.body.nextToken,
|
||||
pageSize: req.body.pageSize,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret.remote_import.previewed",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: preview.providerConfigId,
|
||||
details: {
|
||||
provider: preview.provider,
|
||||
candidateCount: preview.candidates.length,
|
||||
readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length,
|
||||
duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length,
|
||||
conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(preview);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/secrets/remote-import",
|
||||
validate(remoteSecretImportSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const result = await svc.importRemoteSecrets(
|
||||
companyId,
|
||||
{
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
secrets: req.body.secrets,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "secret.remote_import.completed",
|
||||
entityType: "secret_provider_config",
|
||||
entityId: result.providerConfigId,
|
||||
details: {
|
||||
provider: result.provider,
|
||||
importedCount: result.importedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
errorCount: result.errorCount,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
@@ -75,12 +348,18 @@ export function secretRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (existing.status === "deleted") {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rotated = await svc.rotate(
|
||||
id,
|
||||
{
|
||||
value: req.body.value,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
@@ -107,11 +386,19 @@ export function secretRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (existing.status === "deleted") {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await svc.update(id, {
|
||||
name: req.body.name,
|
||||
key: req.body.key,
|
||||
status: req.body.status,
|
||||
providerConfigId: req.body.providerConfigId,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
@@ -145,6 +432,32 @@ export function secretRoutes(db: Db) {
|
||||
res.json({ agents: used.agents, skills: used.skills });
|
||||
});
|
||||
|
||||
router.get("/secrets/:id/usage", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
|
||||
res.json({ secretId: existing.id, bindings });
|
||||
});
|
||||
|
||||
router.get("/secrets/:id/access-events", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Secret not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const events = await svc.listAccessEvents(existing.companyId, existing.id);
|
||||
res.json(events);
|
||||
});
|
||||
|
||||
router.delete("/secrets/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
import { SECRET_PROVIDERS, type SecretProvider } from "@paperclipai/shared";
|
||||
|
||||
export function getConfiguredSecretProvider(): SecretProvider {
|
||||
const configuredProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
return configuredProvider && SECRET_PROVIDERS.includes(configuredProvider as SecretProvider)
|
||||
? configuredProvider as SecretProvider
|
||||
: "local_encrypted";
|
||||
}
|
||||
@@ -1,23 +1,78 @@
|
||||
import { unprocessable } from "../errors.js";
|
||||
import type { SecretProviderModule } from "./types.js";
|
||||
import type { PreparedSecretVersion, SecretProviderModule } from "./types.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function unavailableProvider(
|
||||
id: "aws_secrets_manager" | "gcp_secret_manager" | "vault",
|
||||
label: string,
|
||||
): SecretProviderModule {
|
||||
function externalFingerprint(externalRef: string, providerVersionRef: string | null): string {
|
||||
return createHash("sha256")
|
||||
.update(`${id}:${externalRef}:${providerVersionRef ?? ""}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function prepareExternalReference(input: {
|
||||
externalRef: string;
|
||||
providerVersionRef?: string | null;
|
||||
}): PreparedSecretVersion {
|
||||
const externalRef = input.externalRef.trim();
|
||||
const providerVersionRef = input.providerVersionRef?.trim() || null;
|
||||
const fingerprint = externalFingerprint(externalRef, providerVersionRef);
|
||||
return {
|
||||
material: {
|
||||
scheme: "external_reference_v1",
|
||||
provider: id,
|
||||
externalRef,
|
||||
providerVersionRef,
|
||||
},
|
||||
valueSha256: fingerprint,
|
||||
fingerprintSha256: fingerprint,
|
||||
externalRef,
|
||||
providerVersionRef,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
descriptor: {
|
||||
id,
|
||||
label,
|
||||
requiresExternalRef: true,
|
||||
descriptor() {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
requiresExternalRef: true,
|
||||
supportsManagedValues: false,
|
||||
supportsExternalReferences: true,
|
||||
configured: false,
|
||||
};
|
||||
},
|
||||
async validateConfig() {
|
||||
return { ok: false, warnings: [`${id} provider is not configured in this deployment`] };
|
||||
},
|
||||
async createSecret() {
|
||||
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
|
||||
},
|
||||
async createVersion() {
|
||||
throw unprocessable(`${id} provider is not configured in this deployment`);
|
||||
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
|
||||
},
|
||||
async linkExternalSecret(input) {
|
||||
return prepareExternalReference(input);
|
||||
},
|
||||
async resolveVersion() {
|
||||
throw unprocessable(`${id} provider is not configured in this deployment`);
|
||||
},
|
||||
async deleteOrArchive() {
|
||||
// External references are metadata-only in Paperclip for unconfigured providers.
|
||||
},
|
||||
async healthCheck() {
|
||||
return {
|
||||
provider: id,
|
||||
status: "warn",
|
||||
message: `${id} provider is available for external references but not configured for runtime resolution`,
|
||||
warnings: [
|
||||
"Linked external references can be stored as metadata, but runtime resolution will fail until this provider is configured.",
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SecretProviderModule, StoredSecretVersionMaterial } from "./types.js";
|
||||
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
|
||||
import type {
|
||||
PreparedSecretVersion,
|
||||
SecretProviderHealthCheck,
|
||||
SecretProviderModule,
|
||||
SecretProviderValidationResult,
|
||||
StoredSecretVersionMaterial,
|
||||
} from "./types.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
||||
@@ -14,7 +21,7 @@ interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
||||
function resolveMasterKeyFilePath() {
|
||||
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
|
||||
return path.resolve(process.cwd(), "data/secrets/master.key");
|
||||
return resolveDefaultSecretsKeyFilePath();
|
||||
}
|
||||
|
||||
function decodeMasterKey(raw: string): Buffer | null {
|
||||
@@ -52,6 +59,7 @@ function loadOrCreateMasterKey(): Buffer {
|
||||
|
||||
const keyPath = resolveMasterKeyFilePath();
|
||||
if (existsSync(keyPath)) {
|
||||
enforceKeyFilePermissionsBestEffort(keyPath);
|
||||
const raw = readFileSync(keyPath, "utf8");
|
||||
const decoded = decodeMasterKey(raw);
|
||||
if (!decoded) {
|
||||
@@ -72,10 +80,118 @@ function loadOrCreateMasterKey(): Buffer {
|
||||
return generated;
|
||||
}
|
||||
|
||||
function enforceKeyFilePermissionsBestEffort(keyPath: string) {
|
||||
try {
|
||||
const mode = statSync(keyPath).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0) {
|
||||
chmodSync(keyPath, 0o600);
|
||||
}
|
||||
} catch {
|
||||
// best effort only; health checks surface persistent permission problems.
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function prepareManagedVersion(value: string): PreparedSecretVersion {
|
||||
const masterKey = loadOrCreateMasterKey();
|
||||
const valueSha256 = sha256Hex(value);
|
||||
return {
|
||||
material: encryptValue(masterKey, value),
|
||||
valueSha256,
|
||||
fingerprintSha256: valueSha256,
|
||||
externalRef: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectLocalEncryptedHealth(): Promise<SecretProviderHealthCheck> {
|
||||
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
if (envKeyRaw && envKeyRaw.trim().length > 0) {
|
||||
if (!decodeMasterKey(envKeyRaw)) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message:
|
||||
"PAPERCLIP_SECRETS_MASTER_KEY is invalid; expected 32-byte base64, 64-char hex, or raw 32-char string",
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "ok",
|
||||
message: "Local encrypted provider is using PAPERCLIP_SECRETS_MASTER_KEY",
|
||||
backupGuidance: [
|
||||
"Back up the configured master key separately from the database.",
|
||||
"A restore needs both the database metadata and the same master key.",
|
||||
],
|
||||
details: { keySource: "env" },
|
||||
};
|
||||
}
|
||||
|
||||
const keyPath = resolveMasterKeyFilePath();
|
||||
if (!existsSync(keyPath)) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "warn",
|
||||
message: `Secrets key file does not exist yet: ${keyPath}`,
|
||||
warnings: ["The first managed secret write will create this key file with 0600 permissions."],
|
||||
backupGuidance: [
|
||||
"Back up the key file together with database backups.",
|
||||
"The database alone cannot restore local encrypted secret values.",
|
||||
],
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
let mode: number | null = null;
|
||||
try {
|
||||
mode = statSync(keyPath).mode & 0o777;
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message: `Could not stat secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(keyPath, "utf8");
|
||||
if (!decodeMasterKey(raw)) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message: `Invalid key material in ${keyPath}`,
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
const warnings =
|
||||
mode !== null && (mode & 0o077) !== 0
|
||||
? [`Secrets key file permissions are ${mode.toString(8)}; run chmod 600 ${keyPath}`]
|
||||
: [];
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: warnings.length > 0 ? "warn" : "ok",
|
||||
message: `Local encrypted provider configured with key file ${keyPath}`,
|
||||
warnings,
|
||||
backupGuidance: [
|
||||
"Back up the key file together with database backups.",
|
||||
"The database alone cannot restore local encrypted secret values.",
|
||||
],
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
|
||||
@@ -115,21 +231,45 @@ function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncr
|
||||
|
||||
export const localEncryptedProvider: SecretProviderModule = {
|
||||
id: "local_encrypted",
|
||||
descriptor: {
|
||||
id: "local_encrypted",
|
||||
label: "Local encrypted (default)",
|
||||
requiresExternalRef: false,
|
||||
descriptor() {
|
||||
return {
|
||||
id: "local_encrypted",
|
||||
label: "Local encrypted (default)",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: false,
|
||||
configured: true,
|
||||
};
|
||||
},
|
||||
async validateConfig(input): Promise<SecretProviderValidationResult> {
|
||||
const warnings: string[] = [];
|
||||
if (input?.deploymentMode === "authenticated" && input.strictMode !== true) {
|
||||
warnings.push("Strict secret mode should be enabled for authenticated deployments");
|
||||
}
|
||||
const health = await inspectLocalEncryptedHealth();
|
||||
if (health.status === "error") {
|
||||
throw badRequest(health.message);
|
||||
}
|
||||
warnings.push(...(health.warnings ?? []));
|
||||
return { ok: true, warnings };
|
||||
},
|
||||
async createSecret(input) {
|
||||
return prepareManagedVersion(input.value);
|
||||
},
|
||||
async createVersion(input) {
|
||||
const masterKey = loadOrCreateMasterKey();
|
||||
return {
|
||||
material: encryptValue(masterKey, input.value),
|
||||
valueSha256: sha256Hex(input.value),
|
||||
externalRef: null,
|
||||
};
|
||||
return prepareManagedVersion(input.value);
|
||||
},
|
||||
async linkExternalSecret() {
|
||||
throw badRequest("local_encrypted does not support external reference secrets");
|
||||
},
|
||||
async resolveVersion(input) {
|
||||
const masterKey = loadOrCreateMasterKey();
|
||||
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
|
||||
},
|
||||
async deleteOrArchive() {
|
||||
// Secret metadata deletion is handled in Paperclip DB; the local key is shared and must remain.
|
||||
},
|
||||
async healthCheck() {
|
||||
return inspectLocalEncryptedHealth();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||
import { awsSecretsManagerProvider } from "./aws-secrets-manager-provider.js";
|
||||
import { localEncryptedProvider } from "./local-encrypted-provider.js";
|
||||
import {
|
||||
awsSecretsManagerProvider,
|
||||
gcpSecretManagerProvider,
|
||||
vaultProvider,
|
||||
} from "./external-stub-providers.js";
|
||||
import type { SecretProviderModule } from "./types.js";
|
||||
import type { SecretProviderHealthCheck, SecretProviderModule } from "./types.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
const providers: SecretProviderModule[] = [
|
||||
@@ -26,5 +26,9 @@ export function getSecretProvider(id: SecretProvider): SecretProviderModule {
|
||||
}
|
||||
|
||||
export function listSecretProviders(): SecretProviderDescriptor[] {
|
||||
return providers.map((provider) => provider.descriptor);
|
||||
return providers.map((provider) => provider.descriptor());
|
||||
}
|
||||
|
||||
export async function checkSecretProviders(): Promise<SecretProviderHealthCheck[]> {
|
||||
return Promise.all(providers.map((provider) => provider.healthCheck()));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user