Files
PaperClipAI/server/src/__tests__/heartbeat-session-preparation.test.ts
T
Nicky LeachandPaperclip 98ce93dd4e Choose project storage from compatible environments (#20587)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Projects supply repository files to agent runs through core staging
or a storage plugin.
> - A storage plugin's default claim silently attached new projects even
when their agents used a different environment.
> - An environment name or driver type does not establish shared storage
access. The existing binding and task-driver contracts do.
> - This pull request groups creation choices by that contract and
selects storage from the creator's effective environment.
> - The benefit is a visible, validated choice and a clear error before
an incompatible managed workspace operation.

## Linked Issues or Issue Description

Fixes #20459.

Related: #20156 covers a separate provider-failure diagnostic. The PR
search found no other fix for #20459. ROADMAP.md has no duplicate
project-creation change.

## What Changed

- Show **Available in** when configured environments require different
project storage. Each option names the environments that can use the
same storage owner. Hide the selector when all choices are compatible.
- Derive compatibility from repository staging and managed binding
ownership. Local, SSH, and sandbox environments use core staging.
Managed storage requires a registered task driver from the same
installed storage plugin. Separate storage plugins remain incompatible.
Unknown plugin contracts and the fake sandbox are excluded.
- Follow the creating agent's environment, or the person's primary
agent, then the instance default and local fallback. Apply managed-only
and forced Kubernetes policy. The product has no separate user or
company environment-default field today.
- Add a company-authorized discovery endpoint and an optional creation
`environmentId`. Validate the choice again on save. Store the storage
owner without overriding agent run environments. Preserve explicit API
storage choices and existing project ownership.
- Keep the hidden repository-free private-task project independent of
environment availability. Submit the observed storage owner as
`expectedStorageProviderPluginId` so a changed contract fails at save
without setting a workspace policy. Instance task-isolation defaults
stay inherited.
- Reject an incompatible managed workspace before a provider operation.
Name the storage and selected environment in the error. Add regression
tests, API documentation, creation-story fixtures, and minimal
corrections to obsolete map text.

## Verification

- **102 tests passed across 7 files:** project environment integration,
installed storage policy, repository persistence, project environment
routes, project creation telemetry routes, OpenAPI, and
NewProjectDialog. Run with `node node_modules/vitest/vitest.mjs run
server/src/__tests__/project-environments.test.ts
server/src/__tests__/project-workspace-provider.integration.test.ts
server/src/__tests__/project-repositories-persistence.test.ts
server/src/__tests__/project-routes-env.test.ts
server/src/__tests__/project-goal-telemetry-routes.test.ts
server/src/__tests__/openapi-routes.test.ts
ui/src/components/NewProjectDialog.test.tsx --silent`.
- The review fixes cover both archived-default private-task cases and
changed-storage rejection. With both instance isolation flags enabled, a
new core repository project keeps a null policy and resolves to an
isolated Git worktree. The complete affected set passes all 102 tests.
- The CI query-error policy failure was fixed by using the shared query
view. The form and shared query-view suites pass **24 tests**, including
cached choices during a transient refetch failure. `node
scripts/check-query-error-rendering.mjs --enforce --summary` reports
zero findings.
- CI exposed stale SDK source hashes and heartbeat fixtures that paired
plugin storage with Local. Refreshed exactly four shared-source hashes
with the maintained generator (runtime pins unchanged). All **53 SDK
privacy/source-proof tests**, including real built SDK output, pass. All
**7 corrected managed-storage heartbeat scenarios** pass; the fixtures
declare the owning plugin's task environment and substitute transport at
the existing execution seam.
- Shared and UI TypeScript checks pass. The UI production build passes.
`pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node
scripts/check-module-boundaries.mjs`, and diff checks pass.
- Tested UI states: compatible choices, incompatible groups, default
within a group, manual override, create failure, loading, discovery
failure and retry, and an unavailable default. Tests use the production
form and existing design tokens.
- The integration tests use real isolated PostgreSQL on macOS arm64 with
Node 26.12.0. They cover user/agent defaults, storage persistence,
policy restrictions, company and authentication denials, and rejection
before a provider call. Discovery uses saved configuration and makes no
worker or provider requests.
- Local verification limits: `pnpm -r typecheck` and `pnpm build` stop
in unchanged adapter-utils code because the installed ACPX types lack
`inheritProcessEnv`. A direct server check also finds an unchanged AI
SDK type-version mismatch. The existing heartbeat test `inherits the
instance default environment across companies while preserving explicit
agent overrides` times out at line 518 on both this change and
unmodified master with the same dependencies. The full `pnpm test:run`
attempt passed all six chat shards (1,948 tests), then the Git workspace
expiration suite reported 14 failures. I stopped that broad run. Two
sampled failures reproduce on unchanged master with the same
`unknown_worktree_git_files` refusal; the other expiration failures were
not independently compared. A later broad workspace-preparation run also
hit workspace validation failures and was stopped; the seven corrected
fixture scenarios pass independently. These limits do not establish a
full local suite pass. Hosted CI remains required.
- Initial missing-package and loopback-denial failures were resolved by
repairing isolated dependency links, building the SDK, and running
database tests with loopback access. No deployed or live-provider
execution is claimed.

## Risks

- Creation defaults now follow the effective environment. A plugin's
`defaultForProjects` claim alone no longer selects storage. This is the
intended behavior change.
- Compatibility covers repository project storage. It does not guarantee
arbitrary host paths, retained-workspace moves, adapter/image support,
credentials, or worker readiness. Matching provider configs cannot prove
a cross-plugin storage contract.
- A person's primary agent supplies the personal default because there
is no separate personal environment setting. An unavailable configured
default requires a valid explicit choice or a configuration repair.
- No migration runs. Existing projects keep their storage owner. This
change does not detach existing managed data or provide a storage
migration.
- Full local verification has the limits listed above. CI and standard
Greptile review are pending.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact serving snapshot and context-window size were not
exposed in this session.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 17:38:31 +00:00

218 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, executionWorkspaces, environments, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
import { heartbeatService } from "../services/heartbeat.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import * as environmentOrchestrator from "../services/environment-run-orchestrator.js";
import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
if (!support.supported) console.warn(`Skipping session preparation tests: ${support.reason}`);
describePostgres("heartbeat session preparation through adapter dispatch", () => {
const adapterType = "session_preparation_test";
const captured = new Map<string, AdapterExecutionContext>();
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let heartbeat: ReturnType<typeof heartbeatService>;
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-session-preparation-");
db = createDb(temporary.connectionString);
heartbeat = heartbeatService(db);
registerServerAdapter({
type: adapterType,
supportsLocalAgentJwt: false,
execute: async (input) => {
captured.set(input.runId, input);
// Settle the fixture's work so no automatic unfinished-task retry
// competes with the next explicit session-continuation wake.
await db.update(issues).set({ status: "done" }).where(eq(issues.id, String(input.context.issueId)));
return { exitCode: 0, signal: null, timedOut: false, sessionId: "saved-session", sessionParams: { sessionId: "saved-session", adapterState: "preserved" }, resultJson: {} };
},
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
});
}, 30_000);
afterAll(async () => {
await heartbeat?.drainActiveRunExecutions();
unregisterServerAdapter(adapterType);
await db?.$client.end({ timeout: 0 });
await temporary?.cleanup();
});
async function seed(runtimeConfig: Record<string, unknown> = {}) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Session fixture", issuePrefix: `S${companyId.slice(0, 6)}` });
await db.insert(authUsers).values({ id: userId, name: "Session owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
await db.insert(agents).values({ id: agentId, companyId, name: "Session reader", adapterType, status: "idle", adapterConfig: { model: "model-a" }, runtimeConfig });
await db.insert(issues).values({ id: issueId, companyId, title: "Session fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1 });
return { companyId, agentId, issueId };
}
async function execute(f: Awaited<ReturnType<typeof seed>>, context: Record<string, unknown> = {}) {
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, f.issueId));
const run = await heartbeat.wakeup(f.agentId, {
source: "on_demand", triggerDetail: "manual", reason: "manual",
payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId, ...context },
});
expect(run).not.toBeNull();
await heartbeat.drainActiveRunExecutions();
const finished = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)).then(rows => rows[0]);
expect(finished?.status, finished?.error ?? undefined).toBe("succeeded");
expect(captured.has(run!.id)).toBe(true);
return { input: captured.get(run!.id)!, finished };
}
it("reuses unchanged task sessions and strips host freshness metadata before dispatch", async () => {
const f = await seed();
const first = await execute(f);
expect(first.input.runtime.sessionId).toBeNull();
const stored = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, f.agentId)).then(rows => rows[0]);
expect(stored?.sessionParamsJson).toHaveProperty("__paperclipConfigFingerprint");
const resumed = await execute(f, { paperclipSessionHandoffMarkdown: "stale handoff", paperclipSessionRotationReason: "stale reason", paperclipPreviousSessionId: "stale session" });
expect(resumed.input.runtime).toMatchObject({ sessionId: "saved-session", sessionDisplayId: "saved-session", taskKey: f.issueId });
expect(resumed.input.runtime.sessionParams).toMatchObject({ sessionId: "saved-session", adapterState: "preserved" });
expect(resumed.input.runtime.sessionParams).not.toHaveProperty("__paperclipConfigFingerprint");
expect(resumed.input.context.paperclipSessionHandoffMarkdown).toBeUndefined();
expect(resumed.input.context.paperclipSessionRotationReason).toBeUndefined();
expect(resumed.input.context.paperclipPreviousSessionId).toBeUndefined();
expect(resumed.finished.sessionIdBefore).toBe("saved-session");
});
it("keeps a default-attached project policy stable after binding a repository-free workspace", async () => {
const f = await seed();
const storage = await createPluginWorkspaceFixture(db, f.companyId);
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId));
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: {
...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
} } }).where(eq(plugins.id, storage.pluginId));
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Directory project", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).returning();
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId));
try {
const first = await execute(f);
const [bound] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(bound.executionWorkspaceId).toEqual(expect.any(String));
const next = await execute(f);
expect(next.input.runtime.sessionId).toBe("saved-session");
expect(next.input.config.workspaceStrategy).toEqual(first.input.config.workspaceStrategy);
expect(next.finished.resultJson).toMatchObject({ configFreshness: {
session: { reset: false, taskSessionReused: true, changedCategories: [] },
workspace: { action: "reuse", workspaceReused: true, changedCategories: [], activeWorkspaceId: bound.executionWorkspaceId },
} });
} finally { await db.delete(plugins).where(eq(plugins.id, storage.pluginId)); }
});
it.each([
null,
{ mode: "isolated_workspace" },
{ workspaceStrategy: { type: "git_worktree", branchTemplate: "work" } },
])("keeps a default-attached repository policy stable through binding with settings %j and retains the provider on policy changes", async (settings) => {
const f = await seed();
const storage = await createPluginWorkspaceFixture(db, f.companyId);
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId));
const [environment] = await db.insert(environments).values({ name: `Storage tasks ${storage.pluginId}`, driver: "plugin", status: "active",
config: { pluginKey: plugin.pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson,
capabilities: [...plugin.manifestJson.capabilities, "environment.drivers.register"],
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }],
workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } },
} }).where(eq(plugins.id, storage.pluginId));
await db.update(agents).set({ defaultEnvironmentId: environment.id }).where(eq(agents.id, f.agentId));
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId));
await db.update(issues).set({ projectId: storage.input.projectId, projectWorkspaceId: storage.input.projectWorkspaceId,
executionWorkspaceSettings: settings,
}).where(eq(issues.id, f.issueId));
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: true });
const cwd = await mkdtemp(join(tmpdir(), "session-provider-fixture-"));
const createOrchestrator = environmentOrchestrator.environmentRunOrchestrator;
const realization = vi.spyOn(environmentOrchestrator, "environmentRunOrchestrator").mockImplementation((...args) => ({
...createOrchestrator(...args),
// These tests dispatch a synthetic adapter; substitute only the provider
// transport while preserving the configured storage/task-driver identity.
acquireForRun: async input => createOrchestrator(...args).acquireForRun({ ...input, selectedEnvironmentId: input.localEnvironmentId }),
realizeForRun: async input => {
input.executionWorkspace.cwd = cwd;
return { lease: input.lease, persistedExecutionWorkspace: input.persistedExecutionWorkspace,
workspaceRealization: {}, executionTarget: null, remoteExecution: null };
},
}));
const originalHeartbeat = heartbeat;
heartbeat = heartbeatService(db, { pluginWorkerManager: storage.workers as PluginWorkerManager });
try {
const first = await execute(f);
const [bound] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(bound.executionWorkspaceId).toEqual(expect.any(String));
expect(bound.executionWorkspacePreference).toBe("reuse_existing");
expect(bound.executionWorkspaceSettings).toEqual({ ...settings, mode: "isolated_workspace" });
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!));
expect([...captured.values()].filter(input => input.context.issueId === f.issueId)).toHaveLength(1);
expect(workspace).toMatchObject({ providerType: "plugin", strategyType: "git_worktree" });
for (let turn = 0; turn < 2; turn++) {
const next = await execute(f);
expect(next.input.runtime.sessionId).toBe("saved-session");
expect(next.input.config.workspaceStrategy).toEqual(first.input.config.workspaceStrategy);
expect(next.finished.resultJson).toMatchObject({ configFreshness: {
session: { reset: false, taskSessionReused: true, changedCategories: [] },
workspace: { action: "reuse", workspaceReused: true, changedCategories: [], activeWorkspaceId: bound.executionWorkspaceId },
} });
}
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, allowIssueOverride: false, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId));
const changed = await execute(f);
expect(changed.input.runtime.sessionId).toBeNull();
expect(changed.finished.resultJson).toMatchObject({ configFreshness: { session: { reset: true, changedCategories: ["workspaceConfig"] } } });
const [retained] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!));
expect(retained.metadata?.workspaceProviderOwner).toBe(storage.pluginId);
} finally {
await heartbeat.drainActiveRunExecutions();
heartbeat = originalHeartbeat;
realization.mockRestore();
await rm(cwd, { recursive: true, force: true });
await db.delete(plugins).where(eq(plugins.id, storage.pluginId));
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false, enableIsolatedWorkspacesByDefault: false });
}
});
it("starts fresh when effective model configuration changes and persists the reason", async () => {
const f = await seed();
await execute(f);
await db.update(agents).set({ adapterConfig: { model: "model-b" } }).where(eq(agents.id, f.agentId));
const next = await execute(f);
expect(next.input.runtime.sessionId).toBeNull();
expect(next.input.runtime.sessionParams).toBeNull();
expect(next.finished.sessionIdBefore).toBeNull();
expect(next.finished.resultJson).toMatchObject({ configFreshness: { session: { reset: true, taskSessionAvailable: true, taskSessionReused: false } } });
});
it("refreshes tools with a fresh session when the selected adapter cannot refresh on resume", async () => {
const f = await seed();
await execute(f);
const next = await execute(f, { refreshTools: true });
expect(next.input.context.forceFreshSession).toBe(true);
expect(next.input.runtime.sessionId).toBeNull();
expect(next.finished.resultJson).toMatchObject({ configFreshness: { session: { resetReasons: ["forceFreshSession was requested"] } } });
});
it("rotates a session at the configured run threshold and supplies a handoff", async () => {
const f = await seed({ sessionCompaction: { enabled: true, maxSessionRuns: 1 } });
await execute(f);
await execute(f);
const next = await execute(f);
expect(next.input.runtime.sessionId).toBeNull();
expect(next.input.runtime.sessionParams).toBeNull();
expect(next.input.context.paperclipPreviousSessionId).toBe("saved-session");
expect(next.input.context.paperclipSessionRotationReason).toBe("session exceeded 1 runs");
expect(next.input.context.paperclipSessionHandoffMarkdown).toEqual(expect.any(String));
expect(next.finished.sessionIdBefore).toBeNull();
});
});