Dev #11
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+213
-28
@@ -56,8 +56,12 @@ import {
|
|||||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||||
import { environmentService } from "../services/environments.js";
|
import { environmentService } from "../services/environments.js";
|
||||||
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.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 { 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 { secretService } from "../services/secrets.js";
|
||||||
import {
|
import {
|
||||||
detectAdapterModel,
|
detectAdapterModel,
|
||||||
@@ -160,6 +164,9 @@ export function agentRoutes(
|
|||||||
const approvalsSvc = approvalService(db);
|
const approvalsSvc = approvalService(db);
|
||||||
const budgets = budgetService(db);
|
const budgets = budgetService(db);
|
||||||
const environmentsSvc = environmentService(db);
|
const environmentsSvc = environmentService(db);
|
||||||
|
const environmentRuntime = environmentRuntimeService(db, {
|
||||||
|
pluginWorkerManager: options.pluginWorkerManager,
|
||||||
|
});
|
||||||
const heartbeat = heartbeatService(db, {
|
const heartbeat = heartbeatService(db, {
|
||||||
pluginWorkerManager: options.pluginWorkerManager,
|
pluginWorkerManager: options.pluginWorkerManager,
|
||||||
});
|
});
|
||||||
@@ -191,9 +198,13 @@ export function agentRoutes(
|
|||||||
* - SSH environment → builds an SSH execution target from the environment
|
* - SSH environment → builds an SSH execution target from the environment
|
||||||
* config so the adapter probes the remote box. No lease is required:
|
* config so the adapter probes the remote box. No lease is required:
|
||||||
* the SSH spec is fully derived from the saved environment config.
|
* the SSH spec is fully derived from the saved environment config.
|
||||||
* - Sandbox / plugin environments → currently fall back to local probing
|
* - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the
|
||||||
* with a warning check, since lifting a temporary sandbox lease for an
|
* workspace, and resolves a sandbox execution target wired to the runtime
|
||||||
* ad-hoc test invocation is out of scope for this iteration.
|
* 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: {
|
async function resolveAdapterTestExecutionContext(input: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -203,9 +214,17 @@ export function agentRoutes(
|
|||||||
executionTarget: AdapterExecutionTarget | null;
|
executionTarget: AdapterExecutionTarget | null;
|
||||||
environmentName: string | null;
|
environmentName: string | null;
|
||||||
fallbackChecks: AdapterEnvironmentCheck[];
|
fallbackChecks: AdapterEnvironmentCheck[];
|
||||||
|
release: (status?: "released" | "failed") => Promise<void>;
|
||||||
}> {
|
}> {
|
||||||
|
const noopRelease = async () => {};
|
||||||
|
|
||||||
if (!input.environmentId) {
|
if (!input.environmentId) {
|
||||||
return { executionTarget: null, environmentName: null, fallbackChecks: [] };
|
return {
|
||||||
|
executionTarget: null,
|
||||||
|
environmentName: null,
|
||||||
|
fallbackChecks: [],
|
||||||
|
release: noopRelease,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = await environmentsSvc.getById(input.environmentId);
|
const environment = await environmentsSvc.getById(input.environmentId);
|
||||||
@@ -217,14 +236,20 @@ export function agentRoutes(
|
|||||||
{
|
{
|
||||||
code: "environment_not_found",
|
code: "environment_not_found",
|
||||||
level: "warn",
|
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") {
|
if (environment.driver === "local") {
|
||||||
return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] };
|
return {
|
||||||
|
executionTarget: null,
|
||||||
|
environmentName: environment.name,
|
||||||
|
fallbackChecks: [],
|
||||||
|
release: noopRelease,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (environment.driver === "ssh") {
|
if (environment.driver === "ssh") {
|
||||||
@@ -241,7 +266,12 @@ export function agentRoutes(
|
|||||||
leaseMetadata: null,
|
leaseMetadata: null,
|
||||||
});
|
});
|
||||||
if (target) {
|
if (target) {
|
||||||
return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] };
|
return {
|
||||||
|
executionTarget: target,
|
||||||
|
environmentName: environment.name,
|
||||||
|
fallbackChecks: [],
|
||||||
|
release: noopRelease,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
executionTarget: null,
|
executionTarget: null,
|
||||||
@@ -251,9 +281,10 @@ export function agentRoutes(
|
|||||||
code: "environment_target_unavailable",
|
code: "environment_target_unavailable",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
message:
|
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) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
@@ -264,27 +295,163 @@ export function agentRoutes(
|
|||||||
code: "environment_target_failed",
|
code: "environment_target_failed",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
message:
|
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),
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
release: noopRelease,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests.
|
// 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 {
|
return {
|
||||||
executionTarget: null,
|
executionTarget: null,
|
||||||
environmentName: environment.name,
|
environmentName: environment.name,
|
||||||
fallbackChecks: [
|
fallbackChecks: [
|
||||||
{
|
{
|
||||||
code: "environment_driver_not_supported_for_test",
|
code: "environment_lease_acquire_failed",
|
||||||
level: "warn",
|
level: "error",
|
||||||
message:
|
message: `Could not acquire a lease for environment "${environment.name}".`,
|
||||||
`Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`,
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
hint: "Run a real heartbeat in the environment to verify end-to-end behavior.",
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,13 +1417,35 @@ export function agentRoutes(
|
|||||||
normalizedAdapterConfig,
|
normalizedAdapterConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { executionTarget, environmentName, fallbackChecks } =
|
const { executionTarget, environmentName, fallbackChecks, release } =
|
||||||
await resolveAdapterTestExecutionContext({
|
await resolveAdapterTestExecutionContext({
|
||||||
companyId,
|
companyId,
|
||||||
adapterType: type,
|
adapterType: type,
|
||||||
environmentId: requestedEnvironmentId,
|
environmentId: requestedEnvironmentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await adapter.testEnvironment({
|
const result = await adapter.testEnvironment({
|
||||||
companyId,
|
companyId,
|
||||||
adapterType: type,
|
adapterType: type,
|
||||||
@@ -1265,18 +1454,14 @@ export function agentRoutes(
|
|||||||
environmentName,
|
environmentName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fallbackChecks.length > 0) {
|
if (result.status === "fail") releaseStatus = "failed";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
releaseStatus = "failed";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await release(releaseStatus);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { environmentLeases } from "@paperclipai/db";
|
import { environmentLeases } from "@paperclipai/db";
|
||||||
@@ -102,7 +103,13 @@ export interface EnvironmentDriverAcquireInput {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
environment: Environment;
|
environment: Environment;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
heartbeatRunId: string;
|
/**
|
||||||
|
* UUID of the owning heartbeat run, or null for ad-hoc invocations
|
||||||
|
* (e.g. operator-initiated `Test` probes) that are not tied to a run.
|
||||||
|
* Null leases must be released by id via `getDriver(...).releaseRunLease`
|
||||||
|
* since `releaseRunLeases(heartbeatRunId)` cannot find them.
|
||||||
|
*/
|
||||||
|
heartbeatRunId: string | null;
|
||||||
executionWorkspaceId: string | null;
|
executionWorkspaceId: string | null;
|
||||||
executionWorkspaceMode: ExecutionWorkspace["mode"] | null;
|
executionWorkspaceMode: ExecutionWorkspace["mode"] | null;
|
||||||
}
|
}
|
||||||
@@ -407,14 +414,21 @@ function createSandboxEnvironmentDriver(
|
|||||||
|
|
||||||
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
|
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
|
||||||
const storedConfig = storedParsed.config;
|
const storedConfig = storedParsed.config;
|
||||||
const existingLeases = parsed.config.reuseLease
|
// Ad-hoc tests (heartbeatRunId === null) must never resume an existing
|
||||||
? await environmentsSvc.listLeases(input.environment.id)
|
// provider lease. If they did, releasing the test lease at the end of
|
||||||
|
// the probe would tear down the live heartbeat run that owns it.
|
||||||
|
// We also filter out leases whose policy is not reuse_by_environment
|
||||||
|
// so any non-reusable lease (including ad-hoc test leases that
|
||||||
|
// landed in the table from older code paths) cannot be matched.
|
||||||
|
const reusableExistingLeases = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||||
|
? (await environmentsSvc.listLeases(input.environment.id))
|
||||||
|
.filter((lease) => lease.leasePolicy === "reuse_by_environment")
|
||||||
: [];
|
: [];
|
||||||
const reusableProviderLeaseId = parsed.config.reuseLease
|
const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||||
? findReusableSandboxLeaseId({ config: storedConfig, leases: existingLeases })
|
? findReusableSandboxLeaseId({ config: storedConfig, leases: reusableExistingLeases })
|
||||||
: null;
|
: null;
|
||||||
const reusableLease = reusableProviderLeaseId
|
const reusableLease = reusableProviderLeaseId
|
||||||
? existingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
|
? reusableExistingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const providerLease = reusableLease?.providerLeaseId
|
const providerLease = reusableLease?.providerLeaseId
|
||||||
@@ -443,12 +457,18 @@ function createSandboxEnvironmentDriver(
|
|||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
environmentId: input.environment.id,
|
environmentId: input.environment.id,
|
||||||
config: workerConfig,
|
config: workerConfig,
|
||||||
runId: input.heartbeatRunId,
|
// Plugin SDK requires a string; ad-hoc test leases use a fresh
|
||||||
|
// UUID so providers that validate or persist the runId still see
|
||||||
|
// a well-formed identifier.
|
||||||
|
runId: input.heartbeatRunId ?? randomUUID(),
|
||||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolvedLeasePolicy = parsed.config.reuseLease
|
// Ad-hoc test leases are never publishable for reuse: storing them
|
||||||
|
// as `reuse_by_environment` would let a concurrent heartbeat resume
|
||||||
|
// the test's provider lease and lose its sandbox when the test ends.
|
||||||
|
const resolvedLeasePolicy = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||||
? "reuse_by_environment"
|
? "reuse_by_environment"
|
||||||
: "ephemeral";
|
: "ephemeral";
|
||||||
|
|
||||||
@@ -477,22 +497,33 @@ function createSandboxEnvironmentDriver(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built-in sandbox provider path.
|
// Built-in sandbox provider path. Same guard as the plugin-backed path:
|
||||||
const reusableProviderLeaseId = parsed.config.reuseLease
|
// ad-hoc tests (heartbeatRunId === null) must never resume an existing
|
||||||
|
// provider lease, or releasing the test lease will terminate the live
|
||||||
|
// heartbeat run that shares it. Filter to leases whose policy is
|
||||||
|
// reuse_by_environment so non-reusable rows can never be matched.
|
||||||
|
const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||||
? (await environmentsSvc
|
? (await environmentsSvc
|
||||||
.listLeases(input.environment.id)
|
.listLeases(input.environment.id)
|
||||||
.then((leases) => findReusableSandboxLeaseId({ config: parsed.config, leases })))
|
.then((leases) =>
|
||||||
|
findReusableSandboxLeaseId({
|
||||||
|
config: parsed.config,
|
||||||
|
leases: leases.filter((lease) => lease.leasePolicy === "reuse_by_environment"),
|
||||||
|
}),
|
||||||
|
))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const providerLease = await acquireSandboxProviderLease({
|
const providerLease = await acquireSandboxProviderLease({
|
||||||
config: parsed.config,
|
config: parsed.config,
|
||||||
environmentId: input.environment.id,
|
environmentId: input.environment.id,
|
||||||
heartbeatRunId: input.heartbeatRunId,
|
heartbeatRunId: input.heartbeatRunId ?? randomUUID(),
|
||||||
issueId: input.issueId,
|
issueId: input.issueId,
|
||||||
reusableProviderLeaseId,
|
reusableProviderLeaseId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolvedLeasePolicy = parsed.config.reuseLease
|
// Same ephemeral-policy-for-tests guard as the plugin-backed path:
|
||||||
|
// ad-hoc test leases must not be publishable for reuse.
|
||||||
|
const resolvedLeasePolicy = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||||
? "reuse_by_environment"
|
? "reuse_by_environment"
|
||||||
: "ephemeral";
|
: "ephemeral";
|
||||||
|
|
||||||
@@ -831,7 +862,7 @@ function createPluginEnvironmentDriver(
|
|||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
environmentId: input.environment.id,
|
environmentId: input.environment.id,
|
||||||
config: parsed.config.driverConfig,
|
config: parsed.config.driverConfig,
|
||||||
runId: input.heartbeatRunId,
|
runId: input.heartbeatRunId ?? randomUUID(),
|
||||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1040,7 +1071,8 @@ export function environmentRuntimeService(
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
environment: Environment;
|
environment: Environment;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
heartbeatRunId: string;
|
/** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */
|
||||||
|
heartbeatRunId: string | null;
|
||||||
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
||||||
}): Promise<EnvironmentRuntimeLeaseRecord> {
|
}): Promise<EnvironmentRuntimeLeaseRecord> {
|
||||||
if (input.environment.status !== "active") {
|
if (input.environment.status !== "active") {
|
||||||
|
|||||||
Reference in New Issue
Block a user