mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is an open-source app that lets teams run AI agents for work tasks; each agent session uses an execution workspace — a git checkout — to track the agent's active code state. > - Every execution workspace has an expected target branch (`PAPERCLIP_WORKSPACE_BRANCH`). The workspace git HEAD should always point to that branch so agents commit in the right place. > - When workspace git HEAD diverges from the expected branch — for example after a harness branch-name fix or an accidental `checkout -b` during a CI-retrigger — the discrepancy must be corrected before agents can continue safely. > - Operators (board users) need a controlled, audited path to reconcile a workspace's live branch back to the expected target, with an override escape-hatch for cases where the normal forward path is blocked. > - This pull request adds a board-only `POST /api/execution-workspaces/:id/reconcile-branch` service operation and route that validates safety preconditions, resolves matching recovery-action fingerprints, posts source-issue audit comments, and records the reconciliation outcome. > - The benefit is that operators can correct branch divergence through the API with a full audit trail, instead of via raw database edits. ## Linked Issues or Issue Description No public GitHub issue exists for this change. Context below follows the feature-request template format. **Subsystem affected** server/ — REST API & orchestration services; packages/shared — request validation. **Problem or motivation** Execution workspaces have an expected branch record that must match the checked-out worktree branch. When the live git branch and stored branch record drift apart, operators currently lack a first-class, audited API to reconcile the record. The fallback is manual database repair or workspace replacement, both of which are risky and hard to audit. **Proposed solution** Add a board-only execution workspace branch reconciliation operation. `forward` mode re-inspects the server-side git state and only updates the branch record when the stored branch is an ancestor of the checked-out branch. `override` mode is a break-glass path that requires board access and an operator reason. Both modes require a clean, idle workspace, write audit details, post a source-issue audit comment, and resolve the matching workspace-validation recovery action. **Alternatives considered** Manual database edit (no durable audit trail and easy to mistype), recreating the workspace (heavier operational disruption), or trusting client-supplied ancestry evidence (unsafe because the server must verify the git state itself). **Roadmap alignment** This is incremental hardening for execution-workspace recovery and operator controls. It does not duplicate a public roadmap item. **Additional context** The endpoint is intended for operator recovery, not normal agent control flow, so the generated OpenAPI metadata and runtime route both classify it as board-only. ## What Changed - Added `reconcileExecutionWorkspaceBranchSchema` discriminated-union validator (`forward` with optional reason, `override` requiring a non-empty reason string) to `packages/shared/src/validators/execution-workspace.ts` - Exported `ReconcileExecutionWorkspaceBranch` type and the new schema from the shared package index - Added board-only reconcile-branch service operation in the execution workspaces service: safety checks, recovery-action fingerprint resolution, source-issue audit comment, and outcome recording - Added clean-worktree and stopped-runtime-service preconditions before branch-record mutation. - Marked the reconcile route as board-only in OpenAPI generated auth metadata. - Added `POST /api/execution-workspaces/:id/reconcile-branch` route wired to the new service operation with board-permission gate - Extended `execution-workspaces-routes.test.ts` and `execution-workspaces-service.test.ts` to cover: safety-check rejection, override-reason validation, audit-comment posting, and recovery-action fingerprint resolution (2 files / 19 tests) ## Verification ```sh pnpm --filter @paperclipai/shared typecheck pnpm --filter @paperclipai/server typecheck pnpm exec vitest run server/src/__tests__/execution-workspaces-routes.test.ts server/src/__tests__/execution-workspaces-service.test.ts pnpm exec vitest run server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/openapi-routes.test.ts ``` ## Risks - **Board-only gate:** the operation is gated behind the board permission; no agent can trigger it without operator authorization. - **Override requires reason:** the `override` mode requires a non-empty reason string so every bypass is audited. - **Idempotent recovery-action resolution:** re-running with the same fingerprint is safe; duplicate resolution is a no-op. - **No execution-state mutation:** the route records a reconciliation intent and updates the branch record; it does not restart the workspace or modify running agent state. - Overall risk: **low**. ## Model Used - Provider: Anthropic - Model ID: `claude-sonnet-4-6` (Claude Sonnet 4.6) - Context window: 200 K tokens - Capabilities: tool use, code execution, multi-turn context Follow-up safety commit: - Provider: OpenAI - Model ID: `codex` / GPT-5 with tool use and code execution ## 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 (`feat/execution-workspace-branch-reconciliation-route`) 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
686 lines
21 KiB
TypeScript
686 lines
21 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, rm } 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,
|
|
companies,
|
|
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,
|
|
}),
|
|
listAdapterModelProfiles: async () => [],
|
|
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[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const started = await startEmbeddedPostgresTestDatabase("heartbeat-plugin-environment");
|
|
stopDb = started.stop;
|
|
db = createDb(started.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
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("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 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,
|
|
companyId,
|
|
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 });
|
|
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", {
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId,
|
|
executionWorkspaceId: expect.any(String),
|
|
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 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 });
|
|
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 = 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 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: 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 });
|
|
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", {
|
|
driverKey: "sandbox",
|
|
companyId,
|
|
environmentId: newEnvironmentId,
|
|
executionWorkspaceId: expect.any(String),
|
|
issueId,
|
|
config: { template: "new" },
|
|
agentId,
|
|
runId: run!.id,
|
|
workspaceMode: "shared_workspace",
|
|
adapterType: "codex_local",
|
|
});
|
|
}, 15_000);
|
|
});
|