mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## 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>
218 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|