mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A plugin can provide Runner execution through an environment driver that declares `supportsTasks: true`. Agent computers use this path. > - Core let an agent without a managed AI connection run on that path. The run then used the account that was installed on the plugin's execution host. > - That is not a supported credential source. When the host Runner did not match, the run failed three times, 30 seconds apart, and then blocked the task with a generic error. The error did not name the fix. > - The plugin cannot refuse these runs itself. `describe` has no connection data, and Core replaces plugin error text before the user sees it. > - This pull request makes a managed AI connection a requirement for every plugin Runner-task environment. Core refuses a run without one before it acquires a lease or calls the plugin. > - The benefit is one clear notice that tells the operator to choose an AI connection, and no wasted retries or plugin work. ## Linked Issues or Issue Description Refs #19326. That pull request makes a Runner revision mismatch retry. It noted that runs without a managed AI connection reach the Runner check on a different path, which still blocks after 3 attempts. This pull request removes that path for plugin Runner-task environments. **Problem or motivation** A `paperclip_runner` agent with no managed AI connection can run in a plugin environment that provides Runner tasks. The run uses the account installed on the execution host. If the host Runner does not match, the native session retries 3 times and then blocks the task with "Plugin-provided Runner execution operation unavailable". The operator cannot tell what to fix. **Proposed solution** Refuse these runs in Core before any lease, plugin call, secret resolution, or provider work. Use one stable reason, `ai_connection_required`, for run admission, the setup probe, and agent verification. Show a task notice that names the fix. **Alternatives considered** A refusal in the plugin. It cannot see the connection at `describe`, which runs first. Its error text is also replaced twice before the user sees it. A new structured plugin error code would still not cover `describe`. ## What Changed - Add `server/src/services/plugin-runner-ai-connection.ts`. It holds the `ai_connection_required` code and message, the probe result, and the manifest lookup for a Runner-task driver. The lookup moves here from `agent-runner-selection.ts`, so run admission and runner selection use the same rule. - `heartbeat/execution-configuration.ts`: refuse a new `paperclip_runner` run in a Runner-task environment when it has no managed AI connection. This also applies when `homeMode: "preconfigured"` drops the connection. The run fails as `configuration_incomplete` with reason `ai_connection_required` and an action URL to the agent's runtime settings. The check runs before observability, secrets, the managed runtime, the workspace, and the lease. A resumed native run keeps its admitted contract. - `recovery/stranded-notice.ts`: add an "AI connection required" task notice. - `run-failure-report.ts`: treat this pre-dispatch refusal as an owner action, not an unexpected failure. - `adapters/registry.ts`: the unmanaged probe for a Runner-task target now returns a failed `ai_connection_required` check. It calls no task operation. Managed probes still go through `testManagedEnvironment`. - `agent-harness-verification.ts` and `agent-lifecycle/application/failure.ts`: classify the check as `ai_connection_required` with a fixed message. Verification waits for the configuration to change instead of retrying. - Docs: `PLUGIN_RUNNER_EXECUTION.md` states the requirement. `PLUGIN_ENVIRONMENT_TASKS.md` no longer mentions installed accounts. `agent-config-ui.md` notes the requirement. - Tests: the "plugin-owned account" heartbeat test described the behavior that this change removes. It now checks the assembled heartbeat refusal: no plugin call, no native boundary, no adapter, no workspace files. ## Verification - `npx vitest run` in `server/` for these files. All tests passed: - `src/__tests__/heartbeat-execution-configuration.test.ts`. New cases: refused without a connection, refused for a preconfigured home, managed passes, resumed native input passes, non-task plugin driver passes. The refused cases also check that observability, secrets, and the managed runtime did not start. - `src/__tests__/heartbeat-plugin-environment.test.ts`. One test, "inherits the instance default environment across companies…", also fails on unmodified `master` in my environment. This change does not touch it, because its driver does not declare `supportsTasks`. - `src/adapters/registry.test.ts`, `src/__tests__/agent-harness-verification.test.ts`, `src/modules/agent-lifecycle/__tests__/diagnostics.test.ts`, `src/services/recovery/stranded-notice.test.ts`, and `src/services/__tests__/run-failure-report.test.ts`. - 17 more suites that use plugin Runner-task environments, with 705 tests: `plugin-runner-task.integration`, `plugin-warm-tasks.integration`, `task-creation-recovery.integration`, `native-startup-cleanup-recovery`, `environment-task-authority.integration`, `plugin-task-credential-writeback`, agent-lifecycle `lifecycle`, `setup`, and `environment-plugins`, `workspace-preparation`, `agent-runner-selection`, `agent-harness-verification-provider-api`, `agent-harness-verification-remote`, `plugin-environment-driver-seam`, `environment-task-runtime`, `agent-test-environment-routes`, and `adapter-registry`. - `pnpm typecheck` in `server/` reported no errors in the changed files. Its only errors came from sandbox-provider packages that my local install did not link. - Not run: `plugin-runner-probe.test.ts`, because it needs a Cargo build. This change does not modify `probePluginRunnerEnvironment`. Also not run: the full `pnpm test:run` and `pnpm build`. CI runs them. ## Risks - Behavior change: an agent in a plugin Runner-task environment with no managed AI connection now stops before dispatch. Before, it ran with the host's installed account. The agent's owner must choose an AI connection. This includes agents with `homeMode: "preconfigured"` in these environments. - Runs that were already admitted and resume from saved native input are not refused. They keep the earlier behavior until they finish. - The check reads the plugin manifest on each new run admission for plugin environments. This is the same lookup that runner selection already does. - This pull request and #19326 both edit `stranded-notice.ts` and `failure.ts`. The changes are additive, but whichever merges second needs a small rebase. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (file edits, shell, test runs). Default context window, extended thinking on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
753 lines
26 KiB
TypeScript
753 lines
26 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, rm, stat } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
authUsers,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
environments,
|
|
executionWorkspaces,
|
|
issues,
|
|
plugins,
|
|
projects,
|
|
projectWorkspaces,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
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 () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: { sessionId: "session-1" },
|
|
sessionDisplayId: "session-1",
|
|
provider: "test",
|
|
model: "test-model",
|
|
})));
|
|
|
|
vi.mock("../adapters/index.js", () => ({
|
|
getServerAdapter: () => ({
|
|
type: "codex_local",
|
|
execute: adapterExecute,
|
|
supportsLocalAgentJwt: false,
|
|
}),
|
|
findActiveServerAdapter: () => ({
|
|
type: "codex_local",
|
|
execute: adapterExecute,
|
|
supportsLocalAgentJwt: false,
|
|
}),
|
|
runningProcesses: new Map(),
|
|
}));
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres heartbeat plugin environment tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
|
let stopDb: (() => Promise<void>) | null = null;
|
|
let db!: ReturnType<typeof createDb>;
|
|
const tempRoots: string[] = [];
|
|
const heartbeats: ReturnType<typeof heartbeatService>[] = [];
|
|
|
|
async function grantResponsibleUser(companyId: string, userId: string) {
|
|
await db.insert(authUsers).values({
|
|
id: userId, name: "Responsible user", email: `${userId}@example.test`,
|
|
createdAt: new Date(), updatedAt: new Date(),
|
|
}).onConflictDoNothing();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member",
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const started = await startEmbeddedPostgresTestDatabase("heartbeat-plugin-environment");
|
|
stopDb = started.cleanup;
|
|
db = createDb(started.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
for (const heartbeat of heartbeats.splice(0)) await heartbeat.drainActiveRunExecutions();
|
|
adapterExecute.mockClear();
|
|
while (tempRoots.length > 0) {
|
|
const root = tempRoots.pop();
|
|
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.$client.end();
|
|
await stopDb?.();
|
|
});
|
|
|
|
it("refuses a plugin Runner-task run without a managed AI connection before any plugin call", async () => {
|
|
const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), sourceId = randomUUID();
|
|
const environmentId = randomUUID(), pluginId = randomUUID(), pluginKey = `test.tasks.${pluginId}`;
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-task-heartbeat-")); tempRoots.push(root);
|
|
await db.insert(companies).values({ id: companyId, name: "Task fixture", issuePrefix: "TASK", defaultResponsibleUserId: "task-user" });
|
|
await grantResponsibleUser(companyId, "task-user");
|
|
await db.insert(projects).values({ id: projectId, companyId, name: "Task fixture", status: "active",
|
|
executionWorkspacePolicy: { enabled: true, storageProviderPluginId: pluginId } });
|
|
const pluginOnlyCwd = path.join(root, "plugin-only", "project");
|
|
await db.insert(projectWorkspaces).values([
|
|
{ id: sourceId, companyId, projectId, name: "Primary", cwd: pluginOnlyCwd, isPrimary: true },
|
|
{ companyId, projectId, name: "Extra", cwd: null, repoUrl: "/unavailable-controller-repository.git", isPrimary: false },
|
|
]);
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "@test/tasks", version: "1.0.0", apiVersion: 1,
|
|
categories: ["automation"], status: "ready", manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0",
|
|
displayName: "Tasks", description: "Task fixture", author: "Paperclip", categories: ["automation"],
|
|
capabilities: ["environment.drivers.register", "workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"],
|
|
workspaceProvider: { modes: ["shared_workspace", "isolated_workspace", "operator_branch"], strategies: ["project_primary", "git_worktree"] },
|
|
entrypoints: { worker: "worker.js" },
|
|
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, warmTaskVersion: 1, configSchema: { type: "object" } }] } });
|
|
await db.insert(environments).values({ id: environmentId, name: "Tasks", driver: "plugin", status: "active",
|
|
config: { pluginKey, driverKey: "tasks", driverConfig: {} } });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Task agent", role: "engineer", status: "idle",
|
|
adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-6.1-sol", lifecycleMode: "warm" }, runtimeConfig: {},
|
|
defaultEnvironmentId: environmentId, permissions: {} });
|
|
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId: sourceId, title: "Task-only dispatch", status: "in_progress",
|
|
assigneeAgentId: agentId, issueNumber: 1, identifier: "TASK-1" });
|
|
const call = vi.fn(async (_id: string, method: string) => { throw new Error(`Unexpected task provider call: ${method}`); });
|
|
const workerManager = { isRunning: () => true, getWorker: () => ({ supportedMethods: ["workspaceOperation", "environmentTask", "environmentAcquireLease", "environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] }), call } as unknown as PluginWorkerManager;
|
|
const entered = vi.fn(() => { throw new Error("synthetic_native_boundary_reached"); });
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager, nativeSessionBackendFactory: entered });
|
|
heartbeats.push(heartbeat);
|
|
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId, projectId } });
|
|
expect(run).not.toBeNull();
|
|
await vi.waitFor(async () => { expect((await heartbeat.getRun(run!.id))?.status).toBe("failed"); }, { timeout: 10_000 });
|
|
await heartbeat.drainActiveRunExecutions();
|
|
const failed = await heartbeat.getRun(run!.id);
|
|
expect(failed).toMatchObject({ errorCode: "configuration_incomplete", error: expect.stringContaining("requires a managed AI connection"),
|
|
resultJson: { configurationIncomplete: { reason: "ai_connection_required", actionUrl: `/agents/${agentId}/runtime` } } });
|
|
// No workspace, lease, task, or native Runner work starts.
|
|
expect(call).not.toHaveBeenCalled();
|
|
expect(entered).not.toHaveBeenCalled();
|
|
expect(adapterExecute).not.toHaveBeenCalled();
|
|
await expect(stat(pluginOnlyCwd)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}, 20_000);
|
|
|
|
it("acquires plugin environment leases through the heartbeat execution path", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
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);
|
|
const workerManager = {
|
|
isRunning: vi.fn((id: string) => id === pluginId),
|
|
call: vi.fn(async (_pluginId: string, method: string) => {
|
|
if (method === "environmentAcquireLease") {
|
|
return {
|
|
providerLeaseId: "plugin-heartbeat-lease",
|
|
metadata: {
|
|
remoteCwd: "/workspace/project",
|
|
},
|
|
};
|
|
}
|
|
if (method === "environmentReleaseLease") {
|
|
return undefined;
|
|
}
|
|
throw new Error(`Unexpected plugin environment method: ${method}`);
|
|
}),
|
|
} as unknown as PluginWorkerManager;
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Acme",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
status: "active",
|
|
defaultResponsibleUserId: "responsible-user",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await grantResponsibleUser(companyId, "responsible-user");
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Plugin Environment Heartbeat",
|
|
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: environmentId,
|
|
name: "Plugin Sandbox",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "base",
|
|
},
|
|
},
|
|
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: environmentId,
|
|
permissions: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
|
heartbeats.push(heartbeat);
|
|
const run = await heartbeat.wakeup(agentId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextSnapshot: { projectId },
|
|
});
|
|
|
|
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).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
|
|
creationOperationId: expect.any(String),
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId,
|
|
executionWorkspaceId: expect.any(String),
|
|
executionWorkspaceSettings: null,
|
|
issueId: null,
|
|
config: { template: "base" },
|
|
agentId,
|
|
runId: run!.id,
|
|
workspaceMode: "shared_workspace",
|
|
// Pins the HEARTBEAT-path lease call forwarding the AGENT's adapter type
|
|
// (per-run adapter / mixed-harness envs). environment-runtime.ts has two
|
|
// drivers calling environmentAcquireLease; regressions here previously
|
|
// shipped by editing only the non-heartbeat one.
|
|
adapterType: "codex_local",
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", {
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId,
|
|
issueId: null,
|
|
config: { template: "base" },
|
|
providerLeaseId: "plugin-heartbeat-lease",
|
|
leaseMetadata: expect.objectContaining({
|
|
driver: "plugin",
|
|
pluginId,
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
}),
|
|
});
|
|
}, { timeout: 5_000 });
|
|
expect(adapterExecute).toHaveBeenCalledTimes(1);
|
|
}, 15_000);
|
|
|
|
it("inherits the instance default environment across companies while preserving explicit agent overrides", async () => {
|
|
const sharedEnvironmentId = randomUUID();
|
|
const overrideEnvironmentId = randomUUID();
|
|
const pluginId = randomUUID();
|
|
const pluginKey = `acme.environments.${pluginId}`;
|
|
const companyAId = randomUUID();
|
|
const companyBId = randomUUID();
|
|
const projectAId = randomUUID();
|
|
const projectBId = randomUUID();
|
|
const workspaceAId = randomUUID();
|
|
const workspaceBId = randomUUID();
|
|
const agentAId = randomUUID();
|
|
const agentBId = randomUUID();
|
|
const workspaceRootA = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-company-a-"));
|
|
const workspaceRootB = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-company-b-"));
|
|
tempRoots.push(workspaceRootA, workspaceRootB);
|
|
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 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: sharedEnvironmentId,
|
|
name: "Shared Plugin Sandbox",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "shared",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: overrideEnvironmentId,
|
|
name: "Override Plugin Sandbox",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "override",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await instanceSettingsService(db).update({ defaultEnvironmentId: sharedEnvironmentId });
|
|
|
|
await db.insert(companies).values([
|
|
{
|
|
id: companyAId,
|
|
name: "Acme A",
|
|
issuePrefix: `T${companyAId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
status: "active",
|
|
defaultResponsibleUserId: "responsible-user-a",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: companyBId,
|
|
name: "Acme B",
|
|
issuePrefix: `T${companyBId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
status: "active",
|
|
defaultResponsibleUserId: "responsible-user-b",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await grantResponsibleUser(companyAId, "responsible-user-a");
|
|
await grantResponsibleUser(companyBId, "responsible-user-b");
|
|
await db.insert(projects).values([
|
|
{
|
|
id: projectAId,
|
|
companyId: companyAId,
|
|
name: "Company A Project",
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: projectBId,
|
|
companyId: companyBId,
|
|
name: "Company B Project",
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: workspaceAId,
|
|
companyId: companyAId,
|
|
projectId: projectAId,
|
|
name: "Primary",
|
|
cwd: workspaceRootA,
|
|
isPrimary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: workspaceBId,
|
|
companyId: companyBId,
|
|
projectId: projectBId,
|
|
name: "Primary",
|
|
cwd: workspaceRootB,
|
|
isPrimary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
await db.insert(agents).values([
|
|
{
|
|
id: agentAId,
|
|
companyId: companyAId,
|
|
name: "SharedEnvAgent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: null,
|
|
permissions: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: agentBId,
|
|
companyId: companyBId,
|
|
name: "OverrideEnvAgent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: overrideEnvironmentId,
|
|
permissions: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
|
heartbeats.push(heartbeat);
|
|
const sharedRun = await heartbeat.wakeup(agentAId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextSnapshot: { projectId: projectAId },
|
|
});
|
|
const overrideRun = await heartbeat.wakeup(agentBId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextSnapshot: { projectId: projectBId },
|
|
});
|
|
|
|
expect(sharedRun).not.toBeNull();
|
|
expect(overrideRun).not.toBeNull();
|
|
await vi.waitFor(async () => {
|
|
const [latestShared, latestOverride] = await Promise.all([
|
|
heartbeat.getRun(sharedRun!.id),
|
|
heartbeat.getRun(overrideRun!.id),
|
|
]);
|
|
expect(latestShared?.status).toBe("succeeded");
|
|
expect(latestOverride?.status).toBe("succeeded");
|
|
}, { timeout: 5_000 });
|
|
|
|
const acquireCalls = vi.mocked(workerManager.call).mock.calls
|
|
.filter(([, method]) => method === "environmentAcquireLease");
|
|
const acquirePayloads = acquireCalls.map(([, , payload]) => payload);
|
|
|
|
expect(acquireCalls).toHaveLength(2);
|
|
expect(acquirePayloads).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: companyAId,
|
|
environmentId: sharedEnvironmentId,
|
|
config: { template: "shared" },
|
|
agentId: agentAId,
|
|
runId: sharedRun!.id,
|
|
adapterType: "codex_local",
|
|
}),
|
|
expect.objectContaining({
|
|
companyId: companyBId,
|
|
environmentId: overrideEnvironmentId,
|
|
config: { template: "override" },
|
|
agentId: agentBId,
|
|
runId: overrideRun!.id,
|
|
adapterType: "codex_local",
|
|
}),
|
|
]));
|
|
}, 15_000);
|
|
|
|
it("ignores stale non-reused workspace environment config in favor of the assignee 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",
|
|
defaultResponsibleUserId: "responsible-user",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await grantResponsibleUser(companyId, "responsible-user");
|
|
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,
|
|
name: "QA SSH",
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: {
|
|
pluginKey,
|
|
driverKey: "sandbox",
|
|
driverConfig: {
|
|
template: "old",
|
|
},
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: newEnvironmentId,
|
|
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: newEnvironmentId,
|
|
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",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
executionWorkspaceId: staleExecutionWorkspaceId,
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
|
heartbeats.push(heartbeat);
|
|
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).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
|
|
creationOperationId: expect.any(String),
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId: newEnvironmentId,
|
|
executionWorkspaceId: expect.any(String),
|
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
|
issueId,
|
|
config: { template: "new" },
|
|
agentId,
|
|
runId: run!.id,
|
|
workspaceMode: "shared_workspace",
|
|
adapterType: "codex_local",
|
|
});
|
|
}, 15_000);
|
|
});
|