mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
Merge master into qualified test lifecycle fixes
Preserve master retention I/O settlement and held-I/O coverage alongside the controlled fairness clock and exact run completion barriers. Retain the delegated-completion fixture without changing production code. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
ab0b15d234
465 files changed
+33126
-1543
No files matched your search
@@ -183,6 +183,33 @@ describe("adapter model refresh route", () => {
|
||||
await unregisterTestAdapter(refreshableAdapterType);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ qualification: "pending", qualified: false, status: 422 },
|
||||
{ qualification: "released", qualified: true, status: 200 },
|
||||
])("keeps $qualification Kimi model selection separate from other provider catalogs", async ({ qualified, status }) => {
|
||||
const { PAPERCLIP_RUNNER_ACPX_PROFILES } = await import("@paperclipai/adapter-utils");
|
||||
const kimi = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "kimi")! as { qualified: boolean };
|
||||
const previousQualified = kimi.qualified;
|
||||
const previousAdmission = process.env.PAPERCLIP_ACPX_CANDIDATE_ADMISSION;
|
||||
try {
|
||||
kimi.qualified = qualified;
|
||||
delete process.env.PAPERCLIP_ACPX_CANDIDATE_ADMISSION;
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, baseUrl =>
|
||||
request(baseUrl).get("/api/companies/company-1/adapters/paperclip_runner/models?provider=acpx&acpxAgent=kimi&refresh=1"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(status);
|
||||
if (qualified) expect(res.body).toEqual([]);
|
||||
else expect(res.body).toMatchObject({ code: "paperclip_runner_acpx_agent_unavailable" });
|
||||
expect(mockListOpenCodeModels).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
kimi.qualified = previousQualified;
|
||||
if (previousAdmission === undefined) delete process.env.PAPERCLIP_ACPX_CANDIDATE_ADMISSION;
|
||||
else process.env.PAPERCLIP_ACPX_CANDIDATE_ADMISSION = previousAdmission;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses refreshModels when refresh=1 is requested", async () => {
|
||||
const listModels = vi.fn(async () => [{ id: "stale-model", label: "stale-model" }]);
|
||||
const refreshModels = vi.fn(async () => [{ id: "fresh-model", label: "fresh-model" }]);
|
||||
|
||||
@@ -31,6 +31,8 @@ vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
|
||||
probeAcpxGrokInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxPiInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxCursorInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxKimiInstallation: vi.fn(async () => undefined),
|
||||
probeKimiModels: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
it("advertises tool-refresh recovery for the selected legacy harness", () => {
|
||||
|
||||
@@ -487,9 +487,12 @@ describe("adapter routes", () => {
|
||||
default: "claude",
|
||||
meta: { visibleWhen: { key: "provider", value: "acpx" } },
|
||||
options: [
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "grok", label: "Grok Build" },
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "cursor", label: "Cursor" },
|
||||
{ value: "copilot", label: "GitHub Copilot" },
|
||||
{ value: "pi", label: "Pi" },
|
||||
{ value: "kimi", label: "Kimi Code" },
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain("Codex via ACPX");
|
||||
|
||||
@@ -1002,6 +1002,52 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "" } });
|
||||
});
|
||||
|
||||
it("keeps Kimi draft models unset through the public create route", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
const originalCreate = mockAgentService.create.getMockImplementation()!;
|
||||
mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] }));
|
||||
const app = await createApp();
|
||||
const input = { name: "Kimi draft", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "kimi" } };
|
||||
const draft = await requestApp(app, base => request(base).post("/api/companies/company-1/agents")
|
||||
.send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" }));
|
||||
expect(draft.status, JSON.stringify(draft.body)).toBe(201);
|
||||
const saved = mockAgentService.create.mock.calls.at(-1)?.[1];
|
||||
expect(saved).toMatchObject(input);
|
||||
expect(saved.adapterConfig.model).toBeUndefined();
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true });
|
||||
mockAgentService.create.mockClear();
|
||||
const active = await requestApp(app, base => request(base).post("/api/companies/company-1/agents").send(input));
|
||||
expect(active.status).toBe(422);
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["process", undefined, ""],
|
||||
["kimi_local", "kimi-for-coding", "kimi-for-coding"],
|
||||
["pi_local", "openrouter/anthropic/claude-sonnet-4.6", ""],
|
||||
] as const)("preserves Kimi model selection only from the same harness: %s", async (previousAdapterType, previousModel, expectedModel) => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: previousAdapterType,
|
||||
adapterConfig: previousModel === undefined ? {} : { model: previousModel },
|
||||
status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] });
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, base => request(base).patch("/api/agents/11111111-1111-4111-8111-111111111111")
|
||||
.send({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "kimi" } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "kimi", model: expectedModel } });
|
||||
});
|
||||
|
||||
it("persists an explicit Kimi model through the public create route", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
const app = await createApp();
|
||||
const input = { name: "Kimi", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "kimi", model: "kimi-for-coding" } };
|
||||
const response = await requestApp(app, base => request(base).post("/api/companies/company-1/agents").send(input));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject(input);
|
||||
});
|
||||
|
||||
it.each([{ thinking: "high", status: 200 }, { thinking: "medium", status: 422 }, { command: "custom-pi", status: 422 }])("validates saved Pi settings during direct adapter conversion: %j", async ({ status, ...settings }) => {
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { activityLog, agentApiKeys, agents, authUsers, chatCompletionDeliveries, chatTaskHandoffs, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues, projects } from "@paperclipai/db";
|
||||
import type { AgentApiKeyScope } from "@paperclipai/shared";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { sendAgentChatMessage } from "../services/agent-chat-messages.js";
|
||||
import { withConversationReadState } from "../services/agent-chat-read-state.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { activityService } from "../services/activity.js";
|
||||
import { recordAcceptedChatHandoff } from "../services/chat-completion-delivery.js";
|
||||
import { conversationReplay, deliverConversationComments, undeliveredConversationComments } from "../services/agent-conversations.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
import { CROSS_ISSUE_INFLUENCE_LIMIT } from "../services/cross-issue-influence-limit.js";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
|
||||
|
||||
(support.supported ? describe : describe.skip)("proactive agent chat messages", () => {
|
||||
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
||||
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
||||
server = await startRunnerApiTestServer({ humanSessions: true });
|
||||
await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: true });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await server?.close();
|
||||
if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
|
||||
});
|
||||
const fixture = () => server.fixture({ connectionScenario: "fresh", apiToolsEnabled: false, disableWakeOnDemand: true });
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
const input = { body: "## Morning review\n\nThree PRs need your review.", idempotencyKey: "morning-pr-digest" };
|
||||
const send = (f: Fixture, value: unknown = input) => sendAgentChatMessage(server.db, f, value);
|
||||
const httpSend = (f: Fixture, value: unknown = input, keyScope: AgentApiKeyScope = { kind: "standard" }, authorizingUserId?: string) => fetch(`${server.apiUrl}/api/agents/me/chat/messages`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId, keyScope, authorizingUserId)}` },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
const apiKey = async (f: Fixture, scopeConfig: AgentApiKeyScope = { kind: "standard" }) => {
|
||||
const token = `pcp_${randomUUID()}`;
|
||||
const [key] = await server.db.insert(agentApiKeys).values({ companyId: f.companyId, agentId: f.agentId, name: "Chat delivery test",
|
||||
responsibleUserId: f.responsibleUserId, scopeConfig, keyHash: createHash("sha256").update(token).digest("hex") }).returning();
|
||||
return { ...key, token };
|
||||
};
|
||||
const keySend = (f: Fixture, token: string, value: unknown = input) => fetch(`${server.apiUrl}/api/agents/me/chat/messages`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "x-paperclip-run-id": f.runId },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
const spendInfluence = (f: Fixture, count: number) => server.db.insert(activityLog).values(Array.from({ length: count }, (_, index) => ({
|
||||
companyId: f.companyId, actorType: "agent" as const, actorId: f.agentId, agentId: f.agentId, runId: f.runId,
|
||||
action: "issue.cross_issue_influence_observed", entityType: "issue", entityId: f.issueId,
|
||||
details: { kind: ["comment", "update", "interaction_resolution"][index % 3] },
|
||||
})));
|
||||
|
||||
it.each(["native", "http"])("enforces the shared run cap for %s delivery without creating an empty chat", async transport => {
|
||||
const f = await fixture();
|
||||
await spendInfluence(f, CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
if (transport === "native") {
|
||||
await expect(f.authority.execute({ tool: "send_chat_message", arguments: input, callId: randomUUID() }))
|
||||
.rejects.toMatchObject({ status: 429, details: { code: "cross_issue_influence_cap_exceeded", cap: CROSS_ISSUE_INFLUENCE_LIMIT } });
|
||||
} else {
|
||||
const response = await httpSend(f);
|
||||
expect(response.status, await response.clone().text()).toBe(429);
|
||||
expect(await response.json()).toMatchObject({ details: { code: "cross_issue_influence_cap_exceeded", cap: CROSS_ISSUE_INFLUENCE_LIMIT } });
|
||||
}
|
||||
const snapshot = await f.snapshot();
|
||||
expect(snapshot.comments).toEqual([]);
|
||||
expect(snapshot.issues.filter(issue => issue.conversationAgentId)).toEqual([]);
|
||||
expect(snapshot.activity).toHaveLength(CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
});
|
||||
|
||||
it("charges a committed message once across retries, recovery and conflicting content", async () => {
|
||||
const f = await fixture(), receipt = (await send(f))!;
|
||||
expect((await httpSend(f)).status).toBe(200);
|
||||
expect(await f.authority.recover({ tool: "send_chat_message", arguments: input, callId: randomUUID() }))
|
||||
.toMatchObject({ result: { commentId: receipt.commentId, replayed: true } });
|
||||
expect((await httpSend(f, { ...input, body: "Different content" })).status).toBe(409);
|
||||
expect(await f.authority.recover({ tool: "send_chat_message", arguments: { ...input, idempotencyKey: "not-committed" }, callId: randomUUID() })).toBeNull();
|
||||
expect((await f.snapshot()).activity.filter(row => row.action === "issue.cross_issue_influence_observed")).toHaveLength(1);
|
||||
expect((await f.snapshot()).comments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cannot race native and HTTP delivery past the shared final slot", async () => {
|
||||
const f = await fixture();
|
||||
await spendInfluence(f, CROSS_ISSUE_INFLUENCE_LIMIT - 1);
|
||||
const outcomes = await Promise.all([
|
||||
f.authority.execute({ tool: "send_chat_message", arguments: { ...input, idempotencyKey: "native-last-slot" }, callId: randomUUID() })
|
||||
.then(() => 201, error => error.status),
|
||||
httpSend(f, { ...input, idempotencyKey: "http-last-slot" }).then(response => response.status),
|
||||
]);
|
||||
expect(outcomes.sort()).toEqual([201, 429]);
|
||||
const snapshot = await f.snapshot();
|
||||
expect(snapshot.activity.filter(row => row.action === "issue.cross_issue_influence_observed")).toHaveLength(CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
expect(snapshot.comments).toHaveLength(1);
|
||||
expect(snapshot.issues.filter(issue => issue.conversationAgentId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("supports standard task API keys without changing agent and run attribution", async () => {
|
||||
const f = await fixture(), key = await apiKey(f);
|
||||
const response = await keySend(f, key.token);
|
||||
expect(response.status, await response.clone().text()).toBe(201);
|
||||
expect((await f.snapshot()).activity).toContainEqual(expect.objectContaining({ action: "issue.comment_added",
|
||||
actorId: f.agentId, agentId: f.agentId, runId: f.runId, responsibleUserId: f.responsibleUserId }));
|
||||
});
|
||||
|
||||
it("preserves an external authorizer's current access intersection", async () => {
|
||||
const f = await fixture();
|
||||
expect((await httpSend(f, input, { kind: "standard" }, randomUUID())).status).toBe(403);
|
||||
await expect(sendAgentChatMessage(server.db, { ...f, authorizingUserId: randomUUID() }, input)).rejects.toMatchObject({ status: 403 });
|
||||
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
|
||||
expect((await f.snapshot()).comments).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps skill-test JWT and API-key scopes confined to their own task", async () => {
|
||||
const f = await fixture(), scope = { kind: "skill_test", issueId: f.issueId } as const;
|
||||
const key = await apiKey(f, scope);
|
||||
expect((await httpSend(f, input, scope)).status).toBe(403);
|
||||
expect((await keySend(f, key.token)).status).toBe(403);
|
||||
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
|
||||
expect((await f.snapshot()).comments).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["admitted", "legacy", "saved-task"])("retains a persisted %s skill-test run boundary after task mode changes", async kind => {
|
||||
const f = await fixture();
|
||||
const contextSnapshot = kind === "admitted" ? { paperclipAgentJwtScope: { kind: "skill_test", issueId: f.issueId } }
|
||||
: kind === "legacy" ? { paperclipSkillTest: { id: randomUUID() } }
|
||||
: { paperclipIssue: { id: f.issueId, workMode: "skill_test" } };
|
||||
await server.db.update(heartbeatRuns).set({ contextSnapshot }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, f.issueId));
|
||||
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, f.issueId));
|
||||
expect((await httpSend(f)).status).toBe(403);
|
||||
await expect(sendAgentChatMessage(server.db, f, input, { receiptOnly: true })).rejects.toThrow(/skill.test/i);
|
||||
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
|
||||
expect((await f.snapshot()).comments).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not let task-bridge credentials publish outside their admitted task", async () => {
|
||||
const f = await fixture(), key = await apiKey(f, { kind: "task_bridge", parentIssueId: f.issueId });
|
||||
expect((await keySend(f, key.token)).status).toBe(403);
|
||||
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["revoked", "narrowed", "responsible-human"])("rechecks a %s API key after waiting for the destination", async kind => {
|
||||
const f = await fixture(), receipt = (await send(f))!, key = await apiKey(f);
|
||||
let unlock!: () => void, ready!: (pid: number) => void;
|
||||
const locked = new Promise<number>(resolve => { ready = resolve; });
|
||||
const release = new Promise<void>(resolve => { unlock = resolve; });
|
||||
const gate = server.db.transaction(async tx => {
|
||||
const [{ pid }] = await tx.execute(sql<{ pid: number }>`select pg_backend_pid() as pid`);
|
||||
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, receipt.conversationId)).for("update");
|
||||
ready(pid); await release;
|
||||
});
|
||||
const pid = await locked;
|
||||
const delivery = keySend(f, key.token, { ...input, idempotencyKey: "key-after-wait" });
|
||||
try {
|
||||
await vi.waitFor(async () => {
|
||||
const [row] = await server.db.execute(sql<{ blocked: boolean }>`select exists(select 1 from pg_stat_activity
|
||||
where datname=current_database() and ${pid}=any(pg_blocking_pids(pid))) as blocked`);
|
||||
expect(row.blocked).toBe(true);
|
||||
}, { timeout: 5_000, interval: 10 });
|
||||
await server.db.update(agentApiKeys).set(kind === "revoked" ? { revokedAt: new Date() }
|
||||
: kind === "narrowed" ? { scopeConfig: { kind: "skill_test", issueId: f.issueId } }
|
||||
: { responsibleUserId: randomUUID() }).where(eq(agentApiKeys.id, key.id));
|
||||
unlock(); await gate;
|
||||
expect((await delivery).status).toBe(403);
|
||||
expect((await f.snapshot()).comments).toHaveLength(1);
|
||||
} finally { unlock(); await gate; await delivery; }
|
||||
}, 30_000);
|
||||
|
||||
it("publishes through the native tool without generic API tools, without starting a turn or changing the source", async () => {
|
||||
const f = await fixture();
|
||||
expect(f.authority.definitions().map(d => d.name)).toContain("send_chat_message");
|
||||
const before = await issueService(server.db).getById(f.issueId);
|
||||
const receipt = await f.authority.execute({ tool: "send_chat_message", arguments: input, callId: randomUUID() }) as { commentId: string; conversationId: string; chatUrl: string };
|
||||
const snapshot = await f.snapshot();
|
||||
const comment = snapshot.comments.find(c => c.id === receipt.commentId)!;
|
||||
expect(comment).toMatchObject({ body: input.body, authorAgentId: f.agentId, createdByRunId: f.runId, onBehalfOfUserId: f.responsibleUserId,
|
||||
metadata: { chatMessage: { sourceTaskId: f.issueId, sourceRunId: f.runId } } });
|
||||
expect(snapshot.issues.find(i => i.id === receipt.conversationId)).toMatchObject({ conversationAgentId: f.agentId,
|
||||
conversationUserId: f.responsibleUserId, conversationState: "waiting", executionRunId: null, status: "in_review" });
|
||||
expect(await issueService(server.db).getById(f.issueId)).toEqual(before);
|
||||
expect(await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
|
||||
expect(snapshot.activity.filter(a => a.action === "issue.comment_added")).toHaveLength(1);
|
||||
expect(await activityService(server.db).runsForIssue(f.companyId, receipt.conversationId)).toEqual([]);
|
||||
expect(receipt.chatUrl).toContain(`/chats/${f.agentId}`);
|
||||
expect(await withConversationReadState(server.db, f.companyId, f.responsibleUserId!, [{ id: receipt.conversationId }])).toMatchObject([{ isUnreadForMe: true }]);
|
||||
expect(await f.authority.recover({ tool: "send_chat_message", arguments: input, callId: randomUUID() }))
|
||||
.toMatchObject({ result: { commentId: receipt.commentId, replayed: true } });
|
||||
});
|
||||
|
||||
it("creates once under concurrent first sends and recovers a committed response across run retries", async () => {
|
||||
const f = await fixture();
|
||||
expect(await f.authority.recover({ tool: "send_chat_message", arguments: input, callId: randomUUID() })).toBeNull();
|
||||
expect((await f.snapshot()).issues.filter(i => i.conversationAgentId)).toHaveLength(0);
|
||||
const [a, b] = await Promise.all([send(f), send(f)]);
|
||||
expect(a!.commentId).toBe(b!.commentId);
|
||||
expect((await f.snapshot()).comments).toHaveLength(1);
|
||||
const other = await send(f, { ...input, idempotencyKey: "second" });
|
||||
expect(other!.commentId).not.toBe(a!.commentId);
|
||||
await expect(send(f, { ...input, body: "Different digest" })).rejects.toThrow(/different content/);
|
||||
const nextRunId = randomUUID();
|
||||
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await server.db.insert(heartbeatRuns).values({ id: nextRunId, companyId: f.companyId, agentId: f.agentId,
|
||||
status: "running", nativeIssueId: f.issueId, responsibleUserId: f.responsibleUserId });
|
||||
await server.db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, f.issueId));
|
||||
expect(await send({ ...f, runId: nextRunId })).toMatchObject({ commentId: a!.commentId, replayed: true });
|
||||
expect((await f.snapshot()).comments).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("supports HTTP routine delivery during an active chat reply and after a context reset", async () => {
|
||||
const f = await fixture();
|
||||
await server.db.update(heartbeatRuns).set({ invocationSource: "automation", triggerDetail: "routine" }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const first = await httpSend(f);
|
||||
expect(first.status, await first.clone().text()).toBe(201);
|
||||
const receipt = await first.json();
|
||||
const replyRun = randomUUID();
|
||||
await server.db.insert(heartbeatRuns).values({ id: replyRun, companyId: f.companyId, agentId: f.agentId, status: "running",
|
||||
nativeIssueId: receipt.conversationId, responsibleUserId: f.responsibleUserId, contextSnapshot: { issueId: receipt.conversationId, conversationSessionGeneration: 0 } });
|
||||
await server.db.update(issues).set({ executionRunId: replyRun, conversationState: "active" }).where(eq(issues.id, receipt.conversationId));
|
||||
const before = await issueService(server.db).getById(receipt.conversationId);
|
||||
expect((await httpSend(f, { ...input, idempotencyKey: "during-reply" })).status).toBe(201);
|
||||
const after = await issueService(server.db).getById(receipt.conversationId);
|
||||
expect(after).toMatchObject({ executionRunId: replyRun, conversationState: before!.conversationState, status: before!.status });
|
||||
await server.db.update(issues).set({ conversationSessionGeneration: 1, conversationState: "waiting", executionRunId: null }).where(eq(issues.id, receipt.conversationId));
|
||||
expect((await httpSend(f, { ...input, idempotencyKey: "after-new" })).status).toBe(201);
|
||||
await expect(issueService(server.db).addComment(receipt.conversationId, "Stale reply", { agentId: f.agentId, runId: replyRun })).rejects.toThrow(/earlier session/);
|
||||
expect(await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(2);
|
||||
expect((await f.snapshot()).comments).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("never enqueues a digest during recovery or the next human reply", async () => {
|
||||
const f = await fixture(), svc = issueService(server.db);
|
||||
const receipt = (await send(f))!;
|
||||
const conversation = (await svc.getById(receipt.conversationId))!;
|
||||
const enqueue = vi.fn(async (_agentId: string, _options: unknown) => null);
|
||||
expect(await undeliveredConversationComments(server.db, f.companyId, conversation.id)).toEqual([]);
|
||||
await deliverConversationComments(server.db, conversation, enqueue);
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
const reply = await svc.addComment(conversation.id, "Which PR should I review first?", { userId: f.responsibleUserId! }, { clientRequestId: randomUUID() });
|
||||
expect((await undeliveredConversationComments(server.db, f.companyId, conversation.id)).map(comment => comment.id)).toEqual([reply.id]);
|
||||
await deliverConversationComments(server.db, conversation, enqueue);
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
expect(enqueue.mock.calls[0]?.[1]).toMatchObject({ requestedByActorType: "user", requestedByActorId: f.responsibleUserId,
|
||||
contextSnapshot: { wakeCommentId: reply.id, wakeCommentIds: [reply.id] } });
|
||||
expect(await conversationReplay(server.db, f.companyId, conversation.id, reply.id))
|
||||
.toContain(JSON.stringify({ author: "agent", body: input.body }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ transport: "native", replay: false }, { transport: "native", replay: true },
|
||||
{ transport: "http", replay: false }, { transport: "http", replay: true },
|
||||
])("serializes $transport delivery with a native chat mutation, replay=$replay", async ({ transport, replay }) => {
|
||||
const f = await fixture(), receipt = (await send(f))!, chatRunId = randomUUID();
|
||||
await server.db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false, maxConcurrentRuns: 2 } } }).where(eq(agents.id, f.agentId));
|
||||
await server.db.insert(heartbeatRuns).values({ id: chatRunId, companyId: f.companyId, agentId: f.agentId, status: "running",
|
||||
runtimeMode: "native", nativeIssueId: receipt.conversationId, responsibleUserId: f.responsibleUserId,
|
||||
contextSnapshot: { issueId: receipt.conversationId, conversationSessionGeneration: 0 } });
|
||||
await server.db.update(issues).set({ executionRunId: chatRunId, conversationState: "active" }).where(eq(issues.id, receipt.conversationId));
|
||||
const chat = new PaperclipRunnerToolAuthority(server.db, { companyId: f.companyId, agentId: f.agentId,
|
||||
issueId: receipt.conversationId, runId: chatRunId, apiUrl: server.apiUrl, apiToolsEnabled: false });
|
||||
let unlock!: () => void, ready!: (pid: number) => void;
|
||||
const locked = new Promise<number>(resolve => { ready = resolve; });
|
||||
const release = new Promise<void>(resolve => { unlock = resolve; });
|
||||
const gate = server.db.transaction(async tx => {
|
||||
const [{ pid }] = await tx.execute(sql<{ pid: number }>`select pg_backend_pid() as pid`);
|
||||
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, f.agentId)).for("update");
|
||||
ready(pid);
|
||||
await release;
|
||||
});
|
||||
const pid = await locked;
|
||||
const waitForBlocked = async (count: number) => vi.waitFor(async () => {
|
||||
const [row] = await server.db.execute(sql<{ count: number }>`with recursive blocked(pid) as (
|
||||
select pid from pg_stat_activity where datname = current_database() and ${pid} = any(pg_blocking_pids(pid))
|
||||
union select activity.pid from pg_stat_activity activity join blocked on blocked.pid = any(pg_blocking_pids(activity.pid))
|
||||
where activity.datname = current_database()
|
||||
) select count(*)::int as count from blocked`);
|
||||
expect(row.count).toBeGreaterThanOrEqual(count);
|
||||
}, { timeout: 5000, interval: 10 });
|
||||
const message = replay ? input : { ...input, idempotencyKey: "concurrent-message" };
|
||||
let settled: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
||||
let observedDelivery: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
||||
try {
|
||||
const delivery = transport === "native"
|
||||
? f.authority.execute({ tool: "send_chat_message", arguments: message, callId: randomUUID() })
|
||||
: httpSend(f, message).then(async response => {
|
||||
expect(response.status, await response.clone().text()).toBe(replay ? 200 : 201);
|
||||
return response.json();
|
||||
});
|
||||
observedDelivery = Promise.allSettled([delivery]);
|
||||
await waitForBlocked(1);
|
||||
const mutation = chat.execute({ tool: "write_document", callId: randomUUID(), arguments: {
|
||||
key: "concurrent-note", title: "Concurrent note", body: "The chat reply stays independent.", baseRevisionId: null, idempotencyKey: "concurrent-note",
|
||||
} });
|
||||
settled = Promise.allSettled([delivery, mutation]);
|
||||
await waitForBlocked(2);
|
||||
unlock();
|
||||
await gate;
|
||||
const results = await settled;
|
||||
expect(results.map(result => result.status), JSON.stringify(results)).toEqual(["fulfilled", "fulfilled"]);
|
||||
expect(results[1]).toMatchObject({ status: "fulfilled", value: { disposition: "applied", document: { key: "concurrent-note" } } });
|
||||
expect(await issueService(server.db).getById(receipt.conversationId)).toMatchObject({ conversationState: "active" });
|
||||
} finally {
|
||||
unlock();
|
||||
await gate;
|
||||
await settled;
|
||||
await observedDelivery;
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("isolates two humans sharing an agent and two agents serving one human", async () => {
|
||||
const f = await fixture();
|
||||
const a = await send(f);
|
||||
const userId = randomUUID();
|
||||
await server.db.insert(authUsers).values({ id: userId, name: "Second human", email: `${userId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
||||
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
|
||||
await server.db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const b = await send(f);
|
||||
expect(b!.conversationId).not.toBe(a!.conversationId);
|
||||
const [agent] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Second agent", status: "active", adapterType: "process" }).returning();
|
||||
const runId = randomUUID();
|
||||
await server.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: agent.id, status: "running", issueId: f.issueId, responsibleUserId: userId });
|
||||
await server.db.update(issues).set({ assigneeAgentId: agent.id, executionRunId: runId }).where(eq(issues.id, f.issueId));
|
||||
const c = await send({ ...f, agentId: agent.id, runId });
|
||||
expect(new Set([a!.conversationId, b!.conversationId, c!.conversationId]).size).toBe(3);
|
||||
await expect(issueService(server.db).addComment(a!.conversationId, "Wrong agent", { agentId: agent.id, runId })).rejects.toThrow(/Only this conversation/);
|
||||
await expect(issueService(server.db).addComment(b!.conversationId, "Bypass", { agentId: f.agentId, runId: f.runId })).rejects.toThrow(/earlier session/);
|
||||
});
|
||||
|
||||
it("delivers to its own chat while preserving pending questions and the original handoff completion", async () => {
|
||||
const f = await fixture(), svc = issueService(server.db);
|
||||
const own = await svc.create(f.companyId, { title: "Own chat", assigneeAgentId: f.agentId,
|
||||
conversationAgentId: f.agentId, conversationUserId: f.responsibleUserId!, conversationState: "waiting", status: "in_review" });
|
||||
const [coordinator] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Coordinator", status: "active", adapterType: "process" }).returning();
|
||||
const origin = await svc.create(f.companyId, { title: "Origin chat", assigneeAgentId: coordinator.id,
|
||||
conversationAgentId: coordinator.id, conversationUserId: f.responsibleUserId!, conversationState: "waiting", status: "in_review" });
|
||||
await recordAcceptedChatHandoff(server.db, (await svc.getById(f.issueId))!, origin);
|
||||
const [question] = await server.db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: own.id,
|
||||
kind: "request_confirmation", status: "pending", payload: { version: 1, prompt: "Proceed?" } }).returning();
|
||||
expect(await send(f)).toMatchObject({ conversationId: own.id });
|
||||
expect(await server.db.select().from(issueComments).where(eq(issueComments.issueId, origin.id))).toEqual([]);
|
||||
expect(await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, question.id))).toEqual([question]);
|
||||
expect(await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
|
||||
await svc.update(f.issueId, { status: "done" });
|
||||
expect(await server.db.select().from(chatCompletionDeliveries).where(eq(chatCompletionDeliveries.taskId, f.issueId)))
|
||||
.toMatchObject([{ status: "pending", responseCommentId: null }]);
|
||||
expect(await server.db.select().from(chatTaskHandoffs).where(eq(chatTaskHandoffs.taskId, f.issueId)))
|
||||
.toMatchObject([{ conversationId: origin.id, agentId: coordinator.id }]);
|
||||
});
|
||||
|
||||
it.each(["ask", "planning", "review", "watchdog", "stopped", "stale", "missing-human", "revoked", "private-task", "private-project", "private-ancestor", "paused-agent"])("rejects %s before creating a chat", async kind => {
|
||||
const f = await fixture();
|
||||
if (kind === "ask" || kind === "planning") await server.db.update(issues).set({ workMode: kind }).where(eq(issues.id, f.issueId));
|
||||
if (kind === "review") await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
if (kind === "watchdog") await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, taskWatchdog: true, watchedIssueId: f.issueId } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
if (kind === "stopped") await server.db.update(heartbeatRuns).set({ resultJson: { executionCancellation: { state: "requested" } } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
if (kind === "stale") await server.db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
|
||||
if (kind === "missing-human") await server.db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, f.runId));
|
||||
if (kind === "revoked") await server.db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.responsibleUserId!));
|
||||
if (kind === "private-task") await server.db.update(issues).set({ visibility: "private" }).where(eq(issues.id, f.issueId));
|
||||
if (kind === "private-project") await server.db.update(projects).set({ visibility: "private", privacyOwnerUserId: f.responsibleUserId }).where(eq(projects.id, f.projectId));
|
||||
if (kind === "private-ancestor") {
|
||||
await server.db.update(issues).set({ visibility: "private" }).where(eq(issues.id, f.blockerId));
|
||||
await server.db.update(issues).set({ parentId: f.blockerId, privacyParentIssueId: f.blockerId, privacyRootIssueId: f.blockerId }).where(eq(issues.id, f.issueId));
|
||||
}
|
||||
if (kind === "paused-agent") await server.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
||||
await expect(send(f)).rejects.toThrow();
|
||||
expect((await f.snapshot()).comments).toHaveLength(0);
|
||||
expect((await f.snapshot()).issues.filter(i => i.conversationAgentId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects forged destinations and server-owned provenance, and respects the feature gate", async () => {
|
||||
const f = await fixture();
|
||||
expect((await httpSend(f, { ...input, agentId: f.agentId })).status).toBe(400);
|
||||
await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: false });
|
||||
try { expect((await httpSend(f)).status).toBe(404); }
|
||||
finally { await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: true }); }
|
||||
const receipt = await send(f);
|
||||
const [comment] = await server.db.select().from(issueComments).where(eq(issueComments.id, receipt!.commentId));
|
||||
await expect(issueService(server.db).addComment(f.issueId, "Forged source", { agentId: f.agentId, runId: f.runId }, { metadata: comment.metadata! })).rejects.toThrow(/server-owned/);
|
||||
});
|
||||
|
||||
it("keeps unseen concurrent arrivals unread with exact comment cutoffs and monotonic receipts", async () => {
|
||||
const f = await fixture();
|
||||
const a = (await send(f))!, b = (await send(f, { ...input, idempotencyKey: "later" }))!;
|
||||
await server.db.update(issueComments).set({ createdAt: sql`'2026-10-30 12:00:00.123456+00'::timestamptz` }).where(eq(issueComments.id, a.commentId));
|
||||
await server.db.update(issueComments).set({ createdAt: sql`'2026-10-30 12:00:00.123789+00'::timestamptz` }).where(eq(issueComments.id, b.commentId));
|
||||
const cookie = await server.humanSession(f.responsibleUserId!);
|
||||
const mark = (id: string) => fetch(`${server.apiUrl}/api/issues/${a.conversationId}/read`, { method: "POST", headers: { Cookie: cookie, Origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ throughCommentId: id }) });
|
||||
const unread = async () => (await withConversationReadState(server.db, f.companyId, f.responsibleUserId!, [{ id: a.conversationId }]))[0].isUnreadForMe;
|
||||
const response = await mark(a.commentId);
|
||||
expect(response.status, await response.text()).toBe(200);
|
||||
expect(await unread()).toBe(true);
|
||||
expect((await mark(b.commentId)).status).toBe(200);
|
||||
expect(await unread()).toBe(false);
|
||||
await mark(a.commentId);
|
||||
expect(await unread()).toBe(false);
|
||||
const foreign = await server.fixture({ connectionScenario: "fresh" });
|
||||
const foreignMessage = (await send(foreign))!;
|
||||
expect((await mark(foreignMessage.commentId)).status).toBe(404);
|
||||
const chats = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/chats`, { headers: { Cookie: cookie } });
|
||||
expect(chats.status).toBe(200);
|
||||
expect(await chats.json()).toMatchObject([{ id: a.conversationId, isUnreadForMe: false }]);
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,9 @@ import {
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
recoveryPostgresSupport as support,
|
||||
startRecoveryPostgres,
|
||||
} from "./helpers/recovery-postgres.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { getTaskPlanContext } from "../services/task-plan-context.js";
|
||||
@@ -58,16 +58,15 @@ import {
|
||||
heartbeatService,
|
||||
} from "../services/heartbeat.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)(
|
||||
"persistent agent conversations",
|
||||
() => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let database: Awaited<ReturnType<typeof startRecoveryPostgres>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let companyId: string;
|
||||
let agentId: string;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase(
|
||||
database = await startRecoveryPostgres(
|
||||
"paperclip-agent-conversations-",
|
||||
);
|
||||
db = createDb(database.connectionString);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { remoteTerminationReceipt } from "../services/remote-execution-terminati
|
||||
import { AgentDirectoryReuseInvalidatedError, agentDirectoryWorkingCopyService } from "../services/agent-directory-working-copies.js";
|
||||
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { heartbeatRunEvents } from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_AGENT_DIRECTORY_TEST_DATABASE_URL");
|
||||
|
||||
@@ -393,6 +394,106 @@ describe("persistent agent directories", () => {
|
||||
expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBeUndefined();
|
||||
await copies.collectStopped({ companyId, runId: first.runId });
|
||||
});
|
||||
it("adopts unchanged ownership after collection retry metadata changes before claim", async () => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", runtimeMode: "native" }).where(eq(heartbeatRuns.id, first.runId));
|
||||
const secondId = await nextRun();
|
||||
const transaction = db.transaction.bind(db);
|
||||
let transactions = 0;
|
||||
let injected = false;
|
||||
const spy = vi.spyOn(db, "transaction").mockImplementation((async (callback, options) => {
|
||||
transactions++;
|
||||
if (transactions === 2 && !injected) {
|
||||
injected = true;
|
||||
const before = (await copies.get(companyId, first.runId))!;
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(before.updatedAt.getTime() + 1000));
|
||||
try { await copies.recoverStopped(); } finally { vi.useRealTimers(); }
|
||||
const after = (await copies.get(companyId, first.runId))!;
|
||||
expect(after.updatedAt.getTime()).toBeGreaterThan(before.updatedAt.getTime());
|
||||
expect(after.nextAttemptAt).toBeInstanceOf(Date);
|
||||
for (const field of ["state", "baseHash", "localRoot", "executionRoot", "processStoppedAt", "receipt", "attempts"] as const)
|
||||
expect(after[field]).toEqual(before[field]);
|
||||
}
|
||||
return transaction(callback, options);
|
||||
}) as typeof db.transaction);
|
||||
try {
|
||||
const adopted = await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home });
|
||||
expect(injected).toBe(true);
|
||||
expect(adopted).toMatchObject({ runId: secondId, localRoot: first.localRoot, executionRoot: first.executionRoot });
|
||||
expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBe(secondId);
|
||||
} finally { spy.mockRestore(); vi.useRealTimers(); expect(injected).toBe(true); }
|
||||
});
|
||||
|
||||
it.each(["materialization", "checkpoint", "stopped", "state"])("rejects changed %s authority with the same baseline before adoption", async change => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const secondId = await nextRun();
|
||||
const warning = vi.spyOn(logger, "warn");
|
||||
const privateValue = "private-materialization-/tmp/untrusted-token-do-not-log";
|
||||
const transaction = db.transaction.bind(db);
|
||||
let calls = 0;
|
||||
let injected = false;
|
||||
const spy = vi.spyOn(db, "transaction").mockImplementation((async (callback, options) => {
|
||||
if (++calls === 2) {
|
||||
injected = true;
|
||||
const prior = (await copies.get(companyId, first.runId))!;
|
||||
const values = change === "materialization" ? { receipt: { ...prior.receipt, materializationIdentity: privateValue } }
|
||||
: change === "checkpoint" ? { receipt: { ...prior.receipt, checkpointState: "saved" } }
|
||||
: change === "stopped" ? { processStoppedAt: new Date() } : { state: "pending_collection" };
|
||||
await db.update(agentInstructionWorkingCopies).set({ ...values, updatedAt: new Date(Date.now() + 1000) })
|
||||
.where(eq(agentInstructionWorkingCopies.runId, first.runId));
|
||||
expect((await copies.get(companyId, first.runId))!.baseHash).toBe(prior.baseHash);
|
||||
}
|
||||
return transaction(callback, options);
|
||||
}) as typeof db.transaction);
|
||||
try {
|
||||
await expect(copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home }))
|
||||
.rejects.toMatchObject({ status: 409, details: { stage: "locked_adoption_authority", predecessorPresent: true,
|
||||
observedAfterInitialRead: true, changedFields: expect.arrayContaining([change === "materialization" || change === "checkpoint" ? "receipt" : change === "stopped" ? "processStoppedAt" : "state"]) } });
|
||||
expect(injected).toBe(true);
|
||||
expect(warning).toHaveBeenCalledWith({ companyId, agentId, runId: secondId, previousRunId: first.runId,
|
||||
adoptionConflict: { stage: "locked_adoption_authority", predecessorPresent: true, observedAfterInitialRead: true,
|
||||
changedFields: [change === "materialization" || change === "checkpoint" ? "receipt" : change === "stopped" ? "processStoppedAt" : "state"] } },
|
||||
"Agent directory adoption conflict");
|
||||
expect(JSON.stringify(warning.mock.calls)).not.toContain(privateValue);
|
||||
expect(await copies.get(companyId, secondId)).toBeNull();
|
||||
expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBeUndefined();
|
||||
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
||||
} finally { spy.mockRestore(); warning.mockRestore(); }
|
||||
});
|
||||
|
||||
it("reports closed claim predicates when retirement wins before the final owner read", async () => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const secondId = await nextRun();
|
||||
const warning = vi.spyOn(logger, "warn");
|
||||
const transaction = db.transaction.bind(db);
|
||||
let injected = false;
|
||||
const spy = vi.spyOn(db, "transaction").mockImplementation((async (callback, options) => {
|
||||
const result = await transaction(callback, options);
|
||||
if (!injected) {
|
||||
injected = true;
|
||||
await db.update(agentInstructionWorkingCopies).set({ state: "pending_collection" })
|
||||
.where(eq(agentInstructionWorkingCopies.runId, first.runId));
|
||||
}
|
||||
return result;
|
||||
}) as typeof db.transaction);
|
||||
try {
|
||||
await expect(copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home }))
|
||||
.rejects.toMatchObject({ status: 409, details: { stage: "adoption_claim", observedAfterClaim: true,
|
||||
predecessorPresent: true, conditions: { unchangedTurn: false, timestampMatches: true, baselineMatches: true,
|
||||
localRootMatches: true, unstopped: true, successorAbsent: true, receiptMatches: true } } });
|
||||
expect(warning).toHaveBeenCalledWith({ companyId, agentId, runId: secondId, previousRunId: first.runId,
|
||||
adoptionConflict: { stage: "adoption_claim", observedAfterClaim: true, predecessorPresent: true,
|
||||
conditions: { unchangedTurn: false, timestampMatches: true, baselineMatches: true, localRootMatches: true,
|
||||
unstopped: true, successorAbsent: true, receiptMatches: true } } }, "Agent directory adoption conflict");
|
||||
expect(await copies.get(companyId, secondId)).toBeNull();
|
||||
expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBeUndefined();
|
||||
} finally { spy.mockRestore(); warning.mockRestore(); }
|
||||
});
|
||||
|
||||
it("allows only one stale concurrent adoption with identical updatedAt timestamps", async () => {
|
||||
const fixed = new Date("2030-01-01T00:00:00.000Z");
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
|
||||
@@ -61038,7 +61038,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const beforeComments = await db
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, conversation.issueId));
|
||||
.where(eq(issueComments.issueId, conversation.issueId))
|
||||
.orderBy(issueComments.id);
|
||||
const beforeWakes = wakeup.mock.calls.length;
|
||||
const providerRuntime = runtime.endpoints.get(endpoint.id)!;
|
||||
const beforeProvider = {
|
||||
@@ -61195,7 +61196,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
db
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, conversation.issueId)),
|
||||
.where(eq(issueComments.issueId, conversation.issueId))
|
||||
.orderBy(issueComments.id),
|
||||
).resolves.toEqual(beforeComments);
|
||||
expect(wakeup.mock.calls.length).toBe(beforeWakes);
|
||||
expect({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, asc, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agents, agentWakeupRequests, chatCompletionDeliveries as deliveries, chatTaskHandoffs as handoffs,
|
||||
companies, createDb, documents, environmentLeases, heartbeatRuns, issueComments, issueRecoveryActions, issueThreadInteractions, issues } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { recoveryPostgresSupport as support, startRecoveryPostgres } from "./helpers/recovery-postgres.js";
|
||||
import { settleInterruptedNativeBootstrap, terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
|
||||
import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
import { settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js";
|
||||
@@ -16,12 +16,11 @@ import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { chatCompletionDeliveryService, isCompletedOnboardingHandoffWake, prepareChatCompletionTurn, recordChatCompletion, recordChatHandoff } from "../services/chat-completion-delivery.js";
|
||||
import { heartbeatService, shouldQueueFollowupForRunningIssueWake } from "../services/heartbeat.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("chat completion delivery", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let temporary: Awaited<ReturnType<typeof startRecoveryPostgres>>;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("chat-completion-"); db = createDb(temporary.connectionString);
|
||||
temporary = await startRecoveryPostgres("chat-completion-"); db = createDb(temporary.connectionString);
|
||||
await instanceSettingsService(db).updateExperimental({ enableAgentChat: true });
|
||||
}, 30_000);
|
||||
afterAll(async () => { await temporary?.cleanup(); });
|
||||
|
||||
@@ -11,9 +11,8 @@ import { issueService } from "../services/issues.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { activityService } from "../services/activity.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("chat project tool handoff", () => {
|
||||
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
||||
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
|
||||
@@ -785,6 +785,10 @@ describe("connection management with production default agents", () => {
|
||||
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Selected Google services", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", status: "draft",
|
||||
config: { url: "https://1.1.1.1/mcp", sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace" } });
|
||||
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
||||
const handoff = await f.request("POST", `/tools/oauth/${created.body.id}/start`, { asCurrentUser: true });
|
||||
expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { expiresAt: null, requiredUserAction: { userId: f.userId } } });
|
||||
const [unselected] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
|
||||
expect(unselected.config.workspaceSetup).toBeUndefined();
|
||||
const services = { gmail: "read" as const };
|
||||
const started = await f.request("POST", `/tools/oauth/${created.body.id}/start`, { asCurrentUser: true, workspaceServices: services });
|
||||
expect(started, JSON.stringify(started)).toMatchObject({ status: 200, body: { expiresAt: null, requiredUserAction: { userId: f.userId } } });
|
||||
@@ -797,6 +801,10 @@ describe("connection management with production default agents", () => {
|
||||
[f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services) },
|
||||
} } } } });
|
||||
expect(await server.db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, saved.id))).toHaveLength(0);
|
||||
expect(await f.request("POST", `/tools/oauth/${saved.id}/start`, { asCurrentUser: true })).toMatchObject({ status: 200, body: { authorizationUrl: started.body.authorizationUrl } });
|
||||
const [handedOff] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, saved.id));
|
||||
expect(handedOff.config.workspaceSetup).toBeUndefined();
|
||||
expect(handedOff.config.oauth).toEqual(saved.config.oauth);
|
||||
});
|
||||
|
||||
it("discovers and invokes connection APIs through default production native bindings", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, assets, chatTaskHandoffs, companies, companyMemberships, createDb, documentRevisions, documents, heartbeatRuns,
|
||||
import { agents, assets, chatTaskHandoffs, companies, companyMemberships, createDb, applyPendingMigrations, decisionRetention, decisionTriage, documentRevisions, documents, heartbeatRuns,
|
||||
inboxDismissals, issueAttachments, issueComments, issueDocuments, issues, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { requestConfirmationTargetSchema, suggestedTaskDraftSchema } from "@paperclipai/shared";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
@@ -10,6 +10,7 @@ import { issueService } from "../services/issues.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { conversationDecisionReceiptsService } from "../services/conversation-decision-receipts.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
|
||||
describe("Coordinator task and artifact contracts", () => {
|
||||
it("keeps a proposed plan until explicit acceptance and requires immutable attachment identity", () => {
|
||||
@@ -20,13 +21,15 @@ describe("Coordinator task and artifact contracts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const externalDatabase = resolveExternalTestDatabaseUrl("PAPERCLIP_COORDINATOR_HANDOFF_TEST_DATABASE_URL");
|
||||
const support = externalDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("Coordinator canonical handoffs", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("coordinator-handoff-");
|
||||
db = createDb(temporary.connectionString);
|
||||
if (externalDatabase) await applyPendingMigrations(externalDatabase);
|
||||
else temporary = await startEmbeddedPostgresTestDatabase("coordinator-handoff-");
|
||||
db = createDb(externalDatabase ?? temporary!.connectionString);
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false, enableAgentChat: true });
|
||||
}, 30_000);
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||
@@ -194,7 +197,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
all: true, allowUnscopedAll: true,
|
||||
actor: { type: "board", userId: f.userId, source: "session", ignoreInstanceAdmin: true }, userId: f.userId,
|
||||
});
|
||||
expect(await f.pendingQuestions()).toEqual(canonical.items.filter(item => item.subject.id === question.id));
|
||||
const withoutRank = ({ rank: _rank, ...item }: (typeof canonical.items)[number]) => item;
|
||||
expect((await f.pendingQuestions()).map(withoutRank)).toEqual(canonical.items.filter(item => item.subject.id === question.id).map(withoutRank));
|
||||
expect(await f.pendingQuestions()).toMatchObject([{ sourceKind: "issue_thread_interaction",
|
||||
subject: { id: question.id, status: "pending" }, relatedIssue: { id: task.id }, detail: { kind: "questions" } }]);
|
||||
await f.service.answerQuestions(task, question.id, { answers: [{ questionId: "channel", optionIds: ["blog"] }] }, { userId: f.userId });
|
||||
@@ -202,6 +206,35 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect((await f.receipts()).items).toEqual(expect.arrayContaining([expect.objectContaining({ sourceId: question.id, outcome: "answered" })]));
|
||||
});
|
||||
|
||||
it("does not materialize unrelated company attention when opening task questions", async () => {
|
||||
const f = await seed();
|
||||
const task = await f.direct();
|
||||
const question = await f.ask(task);
|
||||
const [unrelated] = await db.insert(issues).values({ companyId: f.companyId, title: "Unrelated", status: "blocked" }).returning();
|
||||
const unrelatedQuestion = await f.ask(unrelated);
|
||||
expect((await f.pendingQuestions()).map(item => item.subject.id)).toEqual([question.id]);
|
||||
const materialized = await db.select().from(decisionRetention).where(eq(decisionRetention.companyId, f.companyId));
|
||||
expect(materialized.map(row => row.sourceId)).toEqual([question.id]);
|
||||
expect(materialized.map(row => row.sourceId)).not.toContain(unrelatedQuestion.id);
|
||||
const options = { actor: { type: "board" as const, source: "local_implicit" as const, userId: f.userId }, userId: f.userId };
|
||||
expect(await attentionService(db).taskQuestions(f.companyId, [], options)).toEqual([]);
|
||||
expect(await attentionService(db).taskQuestions(randomUUID(), [task.id], options)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps scoped questions consistent with snooze and archive policy", async () => {
|
||||
const f = await seed();
|
||||
const task = await f.direct();
|
||||
const question = await f.ask(task);
|
||||
expect(await f.pendingQuestions()).toHaveLength(1);
|
||||
await db.insert(decisionTriage).values({ companyId: f.companyId, sourceKind: "issue_thread_interaction",
|
||||
sourceId: question.id, snoozedUntil: new Date(Date.now() + 60_000), setByType: "user", setByUserId: f.userId });
|
||||
expect(await f.pendingQuestions()).toEqual([]);
|
||||
await db.delete(decisionTriage).where(eq(decisionTriage.companyId, f.companyId));
|
||||
expect(await f.pendingQuestions()).toHaveLength(1);
|
||||
await db.update(decisionRetention).set({ archivedAt: new Date() }).where(eq(decisionRetention.companyId, f.companyId));
|
||||
expect(await f.pendingQuestions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows only questions from tasks handed off by this agent in the current conversation generation", async () => {
|
||||
const f = await seed();
|
||||
const first = await f.handoff();
|
||||
|
||||
@@ -383,6 +383,36 @@ describeEmbeddedPostgres("decision queue routes", () => {
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("batches repeated seed checks and records only new memberships across concurrent reads", async () => {
|
||||
const { companyId } = await seed();
|
||||
const service = decisionQueueService(db);
|
||||
const items = Array.from({ length: 501 }, () => ({ sourceKind: "issue_thread_interaction" as const,
|
||||
sourceId: randomUUID(), issueId: null, interactionKind: "ask_user_questions" }));
|
||||
await service.materializeSeedSources(companyId, items.slice(0, 2));
|
||||
await Promise.all([
|
||||
service.materializeSeedSources(companyId, [...items, items[0]!]),
|
||||
service.materializeSeedSources(companyId, items),
|
||||
]);
|
||||
const memberships = await db.select().from(decisionQueueItems).where(eq(decisionQueueItems.companyId, companyId));
|
||||
expect(new Set(memberships.map(item => item.sourceId))).toEqual(new Set(items.map(item => item.sourceId)));
|
||||
expect(memberships).toHaveLength(items.length);
|
||||
const events = await db.select().from(decisionTriageEvents).where(and(
|
||||
eq(decisionTriageEvents.companyId, companyId), eq(decisionTriageEvents.action, "queue_item.seeded")));
|
||||
const activity = await db.select().from(activityLog).where(and(
|
||||
eq(activityLog.companyId, companyId), eq(activityLog.action, "decision_queue_item.seeded")));
|
||||
expect(events).toHaveLength(items.length);
|
||||
expect(activity).toHaveLength(items.length);
|
||||
const queries: string[] = [];
|
||||
const previousDebug = db.$client.options.debug;
|
||||
try {
|
||||
db.$client.options.debug = (_connection, text) => queries.push(text);
|
||||
await service.materializeSeedSources(companyId, items);
|
||||
} finally { db.$client.options.debug = previousDebug; }
|
||||
expect(queries.filter(query => query.startsWith('insert into "decision_queue_items"')).length).toBeLessThan(10);
|
||||
expect(await db.select().from(decisionTriageEvents).where(and(
|
||||
eq(decisionTriageEvents.companyId, companyId), eq(decisionTriageEvents.action, "queue_item.seeded")))).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it("records agent decide-by, preserves override history, and exposes the board override attribution", async () => {
|
||||
const { companyId, agentId, issueId } = await seed();
|
||||
const agent = agentActor(companyId, agentId);
|
||||
|
||||
@@ -2,18 +2,19 @@ import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
|
||||
import { access, chmod, mkdir, readdir, readFile, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { access, chmod, mkdir, readdir, readFile, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, companySkills, companySkillTestRuns, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects, plugins, environments } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, companySkills, companySkillTestRuns, createDb, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issues, projects, plugins, environments } from "@paperclipai/db";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { bindHeartbeatRunnerHomeMode, prepareHeartbeatExecutionConfiguration, type HeartbeatExecutionConfigurationInput } from "../services/heartbeat/execution-configuration.js";
|
||||
import { buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat/workspaces.js";
|
||||
import { aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
@@ -24,6 +25,12 @@ import * as assignedMcp from "../services/native-runtime/assigned-mcp-tools.js";
|
||||
import * as sentry from "../sentry.js";
|
||||
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
|
||||
import { recoveryPostgresSupport, startRecoveryPostgres } from "./helpers/recovery-postgres.js";
|
||||
import { selectHeartbeatEnvironment, type HeartbeatEnvironmentSelectionInput } from "../services/heartbeat/environment-selection.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { buildExecutionWorkspaceAdapterConfig } from "../services/execution-workspace-policy.js";
|
||||
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -35,6 +42,44 @@ const skillFailures = vi.hoisted(() => new Map<string, string>());
|
||||
const skillWaits = vi.hoisted(() => new Map<string, () => Promise<void>>());
|
||||
|
||||
describe("Runner home mode during recovery preparation", () => {
|
||||
const fingerprint = (effectiveAdapterConfig: Record<string, unknown>) => buildEffectiveRunSessionConfigMetadata({
|
||||
adapterType: "paperclip_runner", effectiveAdapterConfig, agentRuntimeConfig: {}, issueOverrides: null,
|
||||
workspaceConfig: {}, environment: null, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [],
|
||||
});
|
||||
it.each([
|
||||
{ schema: "paperclip.native-execution-input.v5" },
|
||||
{ schema: "paperclip.native-execution-input.v7", homeMode: "isolated" },
|
||||
])("preserves the implicit isolated fingerprint through recovery and the next run: %j", async saved => {
|
||||
const config = { provider: "codex", model: "fixture-model" };
|
||||
// This is the effective configuration shape built by preparation, including
|
||||
// its optional property when no explicit home mode was configured.
|
||||
const prepare = (input: unknown) => ({ ...config, homeMode: bindHeartbeatRunnerHomeMode(config, input).homeMode });
|
||||
const fresh = await fingerprint(prepare(null));
|
||||
const recovered = await fingerprint(prepare(saved));
|
||||
const next = await fingerprint(prepare(null));
|
||||
expect(recovered.fingerprint).toBe(fresh.fingerprint);
|
||||
expect(next.fingerprint).toBe(recovered.fingerprint);
|
||||
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: config.model,
|
||||
taskSessionParams: { __paperclipConfiguredModel: config.model, __paperclipConfigFingerprint: recovered.fingerprint,
|
||||
__paperclipConfigFingerprintVersion: recovered.version, __paperclipConfigCategoryFingerprints: recovered.categoryFingerprints },
|
||||
configMetadata: next })).toMatchObject({ reset: false, changedCategories: [] });
|
||||
expect(config).not.toHaveProperty("homeMode");
|
||||
});
|
||||
|
||||
it("pins the admitted isolated mode but invalidates a genuinely preconfigured next run", async () => {
|
||||
const config = { provider: "codex", model: "fixture-model", homeMode: "preconfigured" };
|
||||
const recoveredConfig = bindHeartbeatRunnerHomeMode(config, { schema: "paperclip.native-execution-input.v5" });
|
||||
expect(recoveredConfig.homeMode).toBe("isolated");
|
||||
expect(bindHeartbeatRunnerHomeMode(config, null).homeMode).toBe("preconfigured");
|
||||
const recovered = await fingerprint(recoveredConfig);
|
||||
const next = await fingerprint(config);
|
||||
expect(next.fingerprint).not.toBe(recovered.fingerprint);
|
||||
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: config.model,
|
||||
taskSessionParams: { __paperclipConfiguredModel: config.model, __paperclipConfigFingerprint: recovered.fingerprint,
|
||||
__paperclipConfigFingerprintVersion: recovered.version, __paperclipConfigCategoryFingerprints: recovered.categoryFingerprints },
|
||||
configMetadata: next })).toMatchObject({ reset: true, changedCategories: ["adapterConfig"] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["preconfigured", { schema: "paperclip.native-execution-input.v5" }, "isolated"],
|
||||
["preconfigured", { schema: "paperclip.native-execution-input.v7", homeMode: "isolated" }, "isolated"],
|
||||
@@ -395,6 +440,118 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
expect(initial.configSnapshot).toEqual(reused.configSnapshot);
|
||||
});
|
||||
|
||||
it("keeps shared provider workspace configuration across native recovery and the next turn", async () => {
|
||||
const f = await seed();
|
||||
const selectedSkills = [];
|
||||
for (const name of ["Alphabetically first long skill", "B longer selected skill", "C", "D small"]) {
|
||||
selectedSkills.push(await companySkillService(db).createLocalSkill(f.companyId, { name, markdown: "# Keep the saved result\n" }));
|
||||
}
|
||||
const instructionsRootPath = path.join(home, f.companyId);
|
||||
await mkdir(instructionsRootPath);
|
||||
await writeFile(path.join(instructionsRootPath, "AGENTS.md"), "Preserve the workspace across controller replacement.\n");
|
||||
const config = { provider: "codex", model: "fixture-model", lifecycleMode: "warm", idleTimeoutMs: 300_000,
|
||||
codexPermissionMode: "full", paperclipSkillSync: { desiredSkills: selectedSkills.map(skill => skill.key) },
|
||||
instructionsRootPath, instructionsFilePath: path.join(instructionsRootPath, "AGENTS.md"),
|
||||
instructionsEntryFile: "AGENTS.md", instructionsBundleMode: "managed", env: { OPENAI_API_KEY: "fixture-direct-key" } };
|
||||
const [environment] = await db.insert(environments).values({ name: `Directory ${f.companyId}`, driver: "plugin",
|
||||
config: { pluginKey: "test.directory", driverKey: "tasks" } }).returning();
|
||||
const [agent] = await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: config,
|
||||
defaultEnvironmentId: environment.id }).where(eq(agents.id, f.agentId)).returning();
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
|
||||
issueId: f.issueId, scopeKind: "issue", status: "running", runtimeMode: "native", responsibleUserId: f.userId, contextSnapshot: {} }).returning();
|
||||
const workspaceId = run.id;
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ id: workspaceId, companyId: f.companyId,
|
||||
projectId: issue.projectId, sourceIssueId: issue.id, name: "Shared task workspace", mode: "shared_workspace",
|
||||
strategyType: "project_primary", providerType: "adapter_managed", cwd: `/workspaces/${workspaceId}` }).returning();
|
||||
const issueSettings = { mode: "shared_workspace" as const };
|
||||
const sourceConfig = (current: Record<string, unknown>) => buildExecutionWorkspaceAdapterConfig({ agentConfig: current,
|
||||
projectPolicy: null, issueSettings, mode: "shared_workspace", legacyUseProjectWorkspace: null });
|
||||
const selectionInput: HeartbeatEnvironmentSelectionInput = {
|
||||
run, agent, task: { issueRef: { ...issue, executionWorkspaceId: null }, issueContext: issue, issueId: issue.id,
|
||||
executionProjectId: issue.projectId, workspaceProjectContext: { hasWorkspace: false }, boundSourceWorkspace: null,
|
||||
hasSelectedWorkspaceSource: false, taskWorkspaceIntent: null, responsibleUserId: f.userId, context: {},
|
||||
freshWorkspace: null },
|
||||
policy: { trustPreset: { kind: "standard", preset: "standard", boundary: null, sourcePresets: {} },
|
||||
requestedExecutionWorkspaceMode: "shared_workspace", isolatedWorkspacesEnabled: true,
|
||||
projectExecutionWorkspacePolicy: null, issueExecutionWorkspaceSettings: issueSettings },
|
||||
config: { config, sourceWorkspaceConfig: sourceConfig(config), issueAssigneeOverrides: null },
|
||||
services: { environmentsSvc: environmentService(db), executionWorkspacesSvc: executionWorkspaceService(db),
|
||||
instanceSettings: instanceSettingsService(db), findSharedWorkspaceHolder: vi.fn(async () => null),
|
||||
findExecutionWorkspaceHolder: vi.fn(async () => null) },
|
||||
};
|
||||
const prepare = async () => {
|
||||
const selected = await selectHeartbeatEnvironment(db, selectionInput);
|
||||
const result = await prepareHeartbeatExecutionConfiguration(db, { run, agent,
|
||||
task: { issueId: issue.id, context: {}, responsibleUserId: f.userId, acceptedPlanContinuationWake: false,
|
||||
acceptedPlanWakeRoutingDecision: null, taskProjectContext: null,
|
||||
routineEnvContext: { routineId: null, env: null, responsibleUserId: null } },
|
||||
config: { sourceWorkspaceConfig: selectionInput.config.sourceWorkspaceConfig, aiBinding: undefined },
|
||||
policy: { trustPreset: selectionInput.policy.trustPreset, useIsolatedTaskDirectory: selected.useIsolatedTaskDirectory,
|
||||
explicitTaskDirectory: selected.explicitTaskDirectory, requestedExecutionWorkspaceMode: selectionInput.policy.requestedExecutionWorkspaceMode },
|
||||
environment: selected, services: { secretsSvc: secretService(db), companySkills: companySkillService(db),
|
||||
appendRunEvent: vi.fn(), finalizeAiConnectionBusyDeferral: vi.fn() },
|
||||
onFailureSecretsResolved: () => {}, onManagedAiRuntimeAcquired: () => {}, onIdentityRedactorPrepared: () => {} });
|
||||
if (!result.prepared) throw new Error("Configuration was not prepared");
|
||||
const metadata = await buildEffectiveRunSessionConfigMetadata({ adapterType: "paperclip_runner",
|
||||
effectiveAdapterConfig: result.runtimeConfig, agentRuntimeConfig: {}, issueOverrides: null, workspaceConfig: {},
|
||||
environment: null, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: result.runtimeSkillEntries });
|
||||
return { selected, result, metadata };
|
||||
};
|
||||
const first = await prepare();
|
||||
expect(first.selected.useIsolatedTaskDirectory).toBe(false);
|
||||
expect(first.result.runtimeConfig.workspaceStrategy).toBeUndefined();
|
||||
// Preserve the pre-fix fresh name/key query order, including exact versions.
|
||||
expect(first.result.runtimeConfig.paperclipSkillSync).toEqual({ desiredSkills: selectedSkills.map(skill => ({
|
||||
key: skill.key, versionId: skill.currentVersionId,
|
||||
})) });
|
||||
// Read the actual JSONB snapshot rather than reusing the pre-persistence
|
||||
// JavaScript map: PostgreSQL key order differs for these four real keys.
|
||||
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
|
||||
run.contextSnapshot = saved.contextSnapshot;
|
||||
selectionInput.task.freshWorkspace = null;
|
||||
selectionInput.task.issueRef!.executionWorkspaceId = workspaceId;
|
||||
selectionInput.task.issueRef!.executionWorkspacePreference = "reuse_existing";
|
||||
await db.update(issues).set({ executionWorkspaceId: workspaceId, executionWorkspacePreference: "reuse_existing" }).where(eq(issues.id, issue.id));
|
||||
selectionInput.run.runnerProfileJson = { nativeExecutionInput: buildNativeExecutionInput({
|
||||
companyId: f.companyId, agentId: agent.id, runId: run.id, normalizedSessionId: randomUUID(), provider: "codex",
|
||||
issue: { id: issue.id, identifier: "CFG-1", title: issue.title, description: null, workMode: "standard" },
|
||||
taskPrompt: "Continue the admitted work", workspace: { id: workspaceId, cwd: workspace.cwd!, repoUrl: null, repoRef: null, branchName: null },
|
||||
completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
||||
contract: { revision: "1", objective: "Retain the directory", criteria: [{ id: "objective", requirement: "Preserve the workspace" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
}) };
|
||||
const recovered = await prepare();
|
||||
expect(recovered.selected.nativeRecoveryExecutionWorkspaceId).toBe(workspaceId);
|
||||
expect(recovered.selected.useIsolatedTaskDirectory).toBe(false);
|
||||
expect(recovered.result.runtimeSkillEntries).toEqual(first.result.runtimeSkillEntries);
|
||||
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: config.model,
|
||||
taskSessionParams: { __paperclipConfiguredModel: config.model, __paperclipConfigFingerprint: first.metadata.fingerprint,
|
||||
__paperclipConfigFingerprintVersion: first.metadata.version, __paperclipConfigCategoryFingerprints: first.metadata.categoryFingerprints },
|
||||
configMetadata: recovered.metadata })).toMatchObject({ reset: false, changedCategories: [] });
|
||||
expect(recovered.result.runtimeConfig).toEqual(first.result.runtimeConfig);
|
||||
expect(recovered.metadata.fingerprint).toBe(first.metadata.fingerprint);
|
||||
selectionInput.run.runnerProfileJson = {};
|
||||
run.contextSnapshot = {};
|
||||
const next = await prepare();
|
||||
expect(next.result.runtimeConfig).toEqual(first.result.runtimeConfig);
|
||||
expect(next.metadata.fingerprint).toBe(first.metadata.fingerprint);
|
||||
|
||||
selectionInput.config.sourceWorkspaceConfig = sourceConfig({ ...config,
|
||||
paperclipSkillSync: { desiredSkills: selectedSkills.slice(1).map(skill => skill.key) } });
|
||||
const fewerSkills = await prepare();
|
||||
expect(fewerSkills.result.runtimeSkillEntries).toHaveLength(3);
|
||||
expect(fewerSkills.metadata.fingerprint).not.toBe(first.metadata.fingerprint);
|
||||
|
||||
// A real model change must still invalidate the next admission.
|
||||
selectionInput.config.config = { ...config, model: "changed-model" };
|
||||
selectionInput.config.sourceWorkspaceConfig = sourceConfig(selectionInput.config.config);
|
||||
const changed = await prepare();
|
||||
expect(changed.selected.useIsolatedTaskDirectory).toBe(false);
|
||||
expect(changed.result.runtimeConfig.model).toBe("changed-model");
|
||||
expect(changed.metadata.fingerprint).not.toBe(first.metadata.fingerprint);
|
||||
}, 30_000);
|
||||
|
||||
it.each([
|
||||
{ mode: "shared_workspace" as const, config: { workspaceStrategy: { type: "git_worktree", baseRef: "main" } } },
|
||||
{ mode: "agent_default" as const, config: {} },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { persistHeartbeatRunProcessMetadata } from "../services/heartbeat/run-lifecycle.js";
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { createSystemRecovery } from "../services/system-recovery-state.js";
|
||||
@@ -2418,6 +2419,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
updatedAt: new Date(0),
|
||||
}).returning())[0].id }
|
||||
: await seedEnvironmentLeaseFixture(fixture);
|
||||
await persistHeartbeatRunProcessMetadata(db, runId, { pid: 2_000_000_000, processGroupId: null,
|
||||
startedAt: new Date(0).toISOString(), targetKind: "local" });
|
||||
return { ...fixture, ...lease, complete };
|
||||
}
|
||||
|
||||
@@ -2940,6 +2943,39 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(wakeup?.status).toBe("claimed");
|
||||
});
|
||||
|
||||
it.each(["running", "failed"] as const)("holds a remote %s native PID despite a colliding live host child", async status => {
|
||||
const child = spawnAliveProcess();
|
||||
childProcesses.add(child);
|
||||
expect(child.pid).toBeTypeOf("number");
|
||||
const { companyId, agentId, runId, issueId } = await seedRunFixture({ adapterType: "process",
|
||||
runtimeMode: "native", runStatus: status,
|
||||
runErrorCode: status === "failed" ? "native_session_interrupted" : null,
|
||||
runError: status === "failed" ? "native session timed out after 120000ms" : null });
|
||||
if (status === "failed") {
|
||||
await db.update(heartbeatRuns).set({ nativeIssueId: issueId, nativePhase: "retryable_failure", finishedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId,
|
||||
phase: "retryable_failure", nextAttemptAt: new Date(0) });
|
||||
}
|
||||
await persistHeartbeatRunProcessMetadata(db, runId, { pid: child.pid!, processGroupId: null,
|
||||
startedAt: new Date().toISOString(), targetKind: "remote" });
|
||||
const heartbeat = heartbeatService(db);
|
||||
const before = await heartbeat.getRun(runId);
|
||||
expect(await heartbeat.reapOrphanedRuns()).toEqual({ reaped: 0, runIds: [] });
|
||||
expect(await heartbeat.getRun(runId)).toMatchObject({ status, processPid: child.pid,
|
||||
processStartedAt: before!.processStartedAt, errorCode: before!.errorCode, error: before!.error });
|
||||
expect(isPidAlive(child.pid!)).toBe(true);
|
||||
expect(mockTerminateLocalService).not.toHaveBeenCalled();
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
child.kill("SIGTERM");
|
||||
await waitForPidExit(child.pid!);
|
||||
expect(await heartbeat.reapOrphanedRuns()).toEqual({ reaped: 0, runIds: [] });
|
||||
expect(await heartbeat.getRun(runId)).toMatchObject({ status, processPid: child.pid,
|
||||
processStartedAt: before!.processStartedAt, errorCode: before!.errorCode, error: before!.error });
|
||||
expect(await selectRecoveryRunsByAgent(agentId)).toHaveLength(1);
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a native run active without granting legacy retry or signal authority", async () => {
|
||||
const child = spawnAliveProcess();
|
||||
childProcesses.add(child);
|
||||
@@ -2951,6 +2987,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
processPid: child.pid ?? null,
|
||||
includeIssue: false,
|
||||
});
|
||||
await persistHeartbeatRunProcessMetadata(db, runId, { pid: child.pid!, processGroupId: null,
|
||||
startedAt: new Date().toISOString(), targetKind: "local" });
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
@@ -3229,6 +3267,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
processPid: 999_999_999,
|
||||
includeIssue: false,
|
||||
});
|
||||
await persistHeartbeatRunProcessMetadata(db, runId, { pid: 999_999_999, processGroupId: null,
|
||||
startedAt: new Date().toISOString(), targetKind: "local" });
|
||||
// The persisted run remains native even after the agent's current adapter
|
||||
// changes to one that normally owns a legacy local child.
|
||||
await db
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock("../sentry.js", async () => {
|
||||
});
|
||||
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { persistHeartbeatRunProcessMetadata } from "../services/heartbeat/run-lifecycle.js";
|
||||
|
||||
function agentTaskRunCalls(fromIndex: number) {
|
||||
return mockTelemetryClient.track.mock.calls
|
||||
@@ -113,6 +114,9 @@ describeEmbeddedPostgres(
|
||||
processPid: process.pid,
|
||||
contextSnapshot: { issueId },
|
||||
});
|
||||
await persistHeartbeatRunProcessMetadata(db, runId, {
|
||||
pid: process.pid, processGroupId: null, startedAt: new Date().toISOString(), targetKind: "local",
|
||||
});
|
||||
await db.insert(nativeRunFinalizations).values({
|
||||
runId,
|
||||
companyId,
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createDb } from "@paperclipai/db";
|
||||
import { executePaperclipNativeSession } from "../../services/native-runtime/native-session-executor.js";
|
||||
import { dockerCommandRunner } from "./docker-command-runner.js";
|
||||
|
||||
// Separate TypeScript fixture import time from actual controller admission.
|
||||
process.send?.({ ready: true });
|
||||
|
||||
process.once("message", async (input: {
|
||||
connectionString: string;
|
||||
container: string;
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256, KIMI_NODE_DISTRIBUTIONS, KIMI_NODE_VERSION, KIMI_RUNTIME_VERSION } from "../../../packages/paperclip-runner/src/drivers/acpx/kimi-closure-pins.js";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ command: vi.fn(), local: vi.fn(), digest: vi.fn(), ssh: vi.fn(), prepare: vi.fn(), select: vi.fn(), wire: [] as Array<{ file: string; args: string[]; stdin: boolean }> }));
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: mocks.command }));
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: mocks.ssh }));
|
||||
vi.mock("../services/native-runtime/native-session-executor.js", () => ({
|
||||
prepareRemoteProviderPackOnTarget: mocks.prepare, resolveRemoteProviderPackSource: mocks.select,
|
||||
}));
|
||||
vi.mock("node:child_process", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual,
|
||||
execFile: (file: string, args: string[], options: object, callback: Parameters<typeof actual.execFile>[3]) => {
|
||||
if (file !== "ssh") return actual.execFile(file, args, options, callback);
|
||||
mocks.wire.push({ file, args, stdin: false });
|
||||
return actual.execFile(process.execPath, ["-e", 'process.stdout.write("{}")'], options, callback);
|
||||
},
|
||||
spawn: (file: string, args: string[], options: Parameters<typeof actual.spawn>[2]) => {
|
||||
if (file !== "ssh") return actual.spawn(file, args, options);
|
||||
mocks.wire.push({ file, args, stdin: true });
|
||||
return actual.spawn(process.execPath, ["-e", 'process.stdout.write(require("node:fs").readFileSync(0,"utf8"))'], options);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", async () => {
|
||||
const provider = await import("../../../packages/paperclip-runner/src/drivers/acpx/kimi-metadata-probe.js");
|
||||
return { probeKimiMetadata: mocks.local, kimiMetadataProbeDigest: mocks.digest,
|
||||
validateKimiMetadata: provider.validateKimiMetadata, kimiMetadataProbeNodeDigest: provider.kimiMetadataProbeNodeDigest };
|
||||
});
|
||||
import { probeKimiExecutionTarget as rawProbeKimiExecutionTarget } from "../services/kimi-connection-probe.js";
|
||||
|
||||
const model = "selected-model'; $(never-execute)";
|
||||
const apiKey = "selected-secret'; $(never-execute)";
|
||||
const digest = `sha256:${"a".repeat(64)}`;
|
||||
const remote = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } as AdapterExecutionTarget;
|
||||
const verified = () => ({ schema: "paperclip.kimi.metadata.v1", status: "verified", version: KIMI_RUNTIME_VERSION,
|
||||
nodeVersion: KIMI_NODE_VERSION, profileDigest: QUALIFIED_ACPX_PROFILES.kimi.commandDigest, target: "linux-x64",
|
||||
closureDigest: `sha256:${KIMI_DISTRIBUTION_CLOSURE_SHA256["linux-x64"]}`, model, contextLength: 1048576, promptSent: false });
|
||||
const response = (value: unknown = verified(), exitCode = 0) => ({ stdout: JSON.stringify(value), stderr: "", exitCode, signal: null, timedOut: false });
|
||||
const failed = (code: string) => ({ schema: "paperclip.kimi.metadata.v1", status: "failed", code, message: apiKey, promptSent: false });
|
||||
|
||||
const companyId = "candidate-company";
|
||||
const pending = QUALIFIED_ACPX_PROFILES.kimi.qualificationStatus === "pending";
|
||||
function probeKimiExecutionTarget(apiKey: string, target: AdapterExecutionTarget | null | undefined, model: string) {
|
||||
const platform = target?.kind === "remote" ? "linux" : process.platform;
|
||||
const architecture = target?.kind === "remote" ? "x64" : process.arch;
|
||||
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", JSON.stringify({ schema: "paperclip.acpx-candidate-admission.v1",
|
||||
companyId, agent: "kimi", model, profileDigest: QUALIFIED_ACPX_PROFILES.kimi.commandDigest,
|
||||
platform, architecture, expiresAt: Date.now() + 60_000,
|
||||
nativeClosureDigest: `sha256:${KIMI_DISTRIBUTION_CLOSURE_SHA256[`${platform}-${architecture}` as "darwin-arm64" | "linux-x64"]}` }));
|
||||
return rawProbeKimiExecutionTarget(apiKey, target, model, { companyId });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.digest.mockResolvedValue(digest);
|
||||
mocks.command.mockResolvedValue(response());
|
||||
mocks.ssh.mockResolvedValue({ stdout: JSON.stringify(verified()), stderr: "" });
|
||||
mocks.wire.length = 0;
|
||||
mocks.local.mockResolvedValue(verified());
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
||||
|
||||
describe("Kimi setup on a selected execution target", () => {
|
||||
it.each(["sandbox", "ssh"])("uses admitted fixed bytes and only explicit credentials on %s", async transport => {
|
||||
vi.stubEnv("KIMI_API_KEY", "unselected-ambient-secret");
|
||||
vi.stubEnv("KIMI_CODE_BASE_URL", "https://unselected.example");
|
||||
const target = { ...remote, transport } as AdapterExecutionTarget;
|
||||
await expect(probeKimiExecutionTarget(apiKey, target, model)).resolves.toEqual(verified());
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
const call = transport === "ssh" ? mocks.ssh.mock.calls[0] : mocks.command.mock.calls[0];
|
||||
expect(call).toBeTruthy();
|
||||
const command = call![transport === "ssh" ? 1 : 2], options = call![transport === "ssh" ? 2 : 3];
|
||||
if (transport === "ssh") {
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
expect(mocks.ssh).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(mocks.command).toHaveBeenCalledOnce();
|
||||
expect(call![1]).toBe(target);
|
||||
}
|
||||
expect(command).toContain("pack=/opt/paperclip-runner/provider-pack");
|
||||
expect(command).toContain('node="$pack/provider-assets/kimi/linux-x64/runtime/node/bin/node"');
|
||||
expect(command).toContain('/usr/bin/sha256sum "$entry"');
|
||||
expect(command).toContain(digest.slice(7));
|
||||
expect(command).toContain('/usr/bin/sha256sum "$node"');
|
||||
expect(command).toContain(KIMI_NODE_DISTRIBUTIONS["linux-x64"].executableSha256);
|
||||
expect(command.indexOf('/usr/bin/sha256sum "$entry"')).toBeLessThan(command.indexOf('exec /usr/bin/env -i'));
|
||||
expect(command.indexOf('/usr/bin/sha256sum "$node"')).toBeLessThan(command.indexOf('exec /usr/bin/env -i'));
|
||||
expect(command).toContain("unset NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS");
|
||||
expect(command).toContain('PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT="$pack"');
|
||||
expect(command).not.toContain(model);
|
||||
expect(command).not.toContain(apiKey);
|
||||
if (transport === "ssh") {
|
||||
expect(options.env).toBeUndefined();
|
||||
const input = JSON.parse(options.stdin);
|
||||
expect(input).toMatchObject({ apiKey, model });
|
||||
if (pending) expect(input.candidateAdmission).toMatchObject({ companyId, agent: "kimi", model, platform: "linux", architecture: "x64", runId: expect.stringMatching(/^kimi-metadata-/), normalizedSessionId: expect.stringMatching(/^metadata-/) });
|
||||
else expect(input).not.toHaveProperty("candidateAdmission");
|
||||
expect(options.timeoutMs).toBeGreaterThan(0);
|
||||
expect(options.timeoutMs).toBeLessThanOrEqual(32000);
|
||||
} else {
|
||||
expect(options).toEqual({ cwd: "/workspace", env: { KIMI_API_KEY: apiKey, PAPERCLIP_KIMI_PROBE_MODEL: model, ...(pending ? { PAPERCLIP_ACPX_CANDIDATE_ADMISSION: expect.any(String) } : {}) }, timeoutSec: expect.any(Number), graceSec: 3 });
|
||||
expect(options.timeoutSec).toBeGreaterThan(0);
|
||||
expect(options.timeoutSec).toBeLessThanOrEqual(32);
|
||||
}
|
||||
expect(JSON.stringify(options)).not.toContain("unselected");
|
||||
});
|
||||
|
||||
it("keeps local verification explicit and does not run a remote command", async () => {
|
||||
for (const target of [undefined, null, { kind: "local" } as AdapterExecutionTarget]) {
|
||||
await expect(probeKimiExecutionTarget(apiKey, target, model)).resolves.toEqual(verified());
|
||||
}
|
||||
expect(mocks.local).toHaveBeenCalledTimes(3);
|
||||
if (pending) expect(mocks.local).toHaveBeenCalledWith(model, apiKey, expect.objectContaining({ companyId, agent: "kimi", model, platform: process.platform, architecture: process.arch, runId: expect.stringMatching(/^kimi-metadata-/), normalizedSessionId: expect.stringMatching(/^metadata-/) }));
|
||||
else expect(mocks.local).toHaveBeenCalledWith(model, apiKey, undefined);
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
expect(mocks.digest).not.toHaveBeenCalled();
|
||||
mocks.local.mockResolvedValue(failed("KIMI_AUTH_REJECTED"));
|
||||
const error = await probeKimiExecutionTarget(apiKey, null, model).catch(error => error);
|
||||
expect(error).toMatchObject({ status: 422, details: { code: "KIMI_AUTH_REJECTED" } });
|
||||
expect(error.message).not.toContain(apiKey);
|
||||
});
|
||||
|
||||
it("rejects task-only plugin hosting before credentials leave the server", async () => {
|
||||
const runnerTask = vi.fn();
|
||||
await expect(probeKimiExecutionTarget(apiKey, { ...remote, runnerTask } as AdapterExecutionTarget, model))
|
||||
.rejects.toMatchObject({ details: { code: "KIMI_INSTALLATION_INVALID" } });
|
||||
expect(runnerTask).not.toHaveBeenCalled();
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
expect(mocks.digest).not.toHaveBeenCalled();
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects absent or invalid key/model inputs before transmission", async () => {
|
||||
vi.stubEnv("KIMI_API_KEY", apiKey);
|
||||
for (const key of ["", " ", "key\nheader", "key\0header", "x".repeat(16385)]) {
|
||||
await expect(probeKimiExecutionTarget(key, remote, model)).rejects.toMatchObject({ details: { code: "KIMI_AUTH_REQUIRED" } });
|
||||
}
|
||||
for (const selected of ["", " ", "x\0y", "x".repeat(513), "\ud800"]) {
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, selected)).rejects.toMatchObject({ details: { code: "KIMI_MODEL_UNAVAILABLE" } });
|
||||
}
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote Kimi installation fallback and failure admission", () => {
|
||||
it("prepares the selected current pack before testing an older SDK image", async () => {
|
||||
// Actual credential-free artifacts from the failed source-16 qualification:
|
||||
// the immutable SDK image carries this older helper, while the controller
|
||||
// and complete selected Linux pack require the newer bytes.
|
||||
const imageHelper = "9e87ba56ce4663aa36f310a7eb6224bebb7d838be479c1741c3393577f2953c7";
|
||||
const selectedHelper = "22bf5125adbda95208d26762d4889de2b365a351b403ac92863f8f838f6d36fc";
|
||||
const installedRoot = "/workspace/.paperclip-runtime/paperclip-runner/provider-pack";
|
||||
const runner = { execute: vi.fn() };
|
||||
const selected = { configuredRoot: "/controller/complete-linux-pack",
|
||||
manifest: { payload: { target: { platform: "linux", architecture: "x64" } } } };
|
||||
mocks.select.mockReturnValue(selected);
|
||||
mocks.prepare.mockResolvedValue({ root: installedRoot, manifest: selected.manifest, source: "uploaded" });
|
||||
mocks.digest.mockResolvedValue(`sha256:${selectedHelper}`);
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "/controller/complete-linux-pack");
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "/controller/linux-runnerd");
|
||||
mocks.command.mockImplementation(async (_id, _target, command) => {
|
||||
expect(command).toContain(selectedHelper);
|
||||
if (command.includes("pack=/opt/paperclip-runner/provider-pack")) {
|
||||
expect(imageHelper).not.toBe(selectedHelper);
|
||||
return { ...response(), stdout: "", exitCode: 126 };
|
||||
}
|
||||
if (command.includes('pack="$HOME/.local/share/paperclip-runner/provider-pack"')) {
|
||||
return { ...response(), stdout: "", exitCode: 127 };
|
||||
}
|
||||
expect(command).toContain(`pack='${installedRoot}'`);
|
||||
return response();
|
||||
});
|
||||
const target = { ...remote, runner } as AdapterExecutionTarget;
|
||||
await expect(probeKimiExecutionTarget(apiKey, target, model)).resolves.toEqual(verified());
|
||||
expect(mocks.select).toHaveBeenCalledWith(selected.configuredRoot);
|
||||
expect(mocks.prepare).toHaveBeenCalledWith({ target, runner, configuredProviderPackRoot: selected.configuredRoot, manifest: selected.manifest });
|
||||
expect(mocks.command).toHaveBeenCalledOnce();
|
||||
expect(mocks.prepare.mock.invocationCallOrder[0]).toBeLessThan(mocks.command.mock.invocationCallOrder[0]!);
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["unreadable-source", "wrong-platform", "missing-transport", "failed-install"])("does not transmit credentials or use the baked image after %s", async failure => {
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "/controller/selected-pack");
|
||||
mocks.select.mockReturnValue({ configuredRoot: "/controller/selected-pack",
|
||||
manifest: { payload: { target: { platform: failure === "wrong-platform" ? "darwin" : "linux", architecture: "x64" } } } });
|
||||
if (failure === "unreadable-source") mocks.select.mockImplementation(() => { throw new Error(apiKey); });
|
||||
if (failure === "failed-install") mocks.prepare.mockRejectedValue(new Error(apiKey));
|
||||
const target = { ...remote, ...(failure === "missing-transport" ? {} : { runner: { execute: vi.fn() } }) } as AdapterExecutionTarget;
|
||||
const error = await probeKimiExecutionTarget(apiKey, target, model).catch(error => error);
|
||||
expect(error).toMatchObject({ status: 422, details: { code: "KIMI_INSTALLATION_INVALID" } });
|
||||
expect(error.message).not.toContain(apiKey);
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
if (failure !== "failed-install") expect(mocks.prepare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the current qualification policy before selected-pack preparation", async () => {
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "/controller/selected-pack");
|
||||
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", JSON.stringify({ schema: "paperclip.acpx-candidate-admission.v1",
|
||||
companyId: "other-company", agent: "kimi", model, profileDigest: QUALIFIED_ACPX_PROFILES.kimi.commandDigest,
|
||||
platform: "linux", architecture: "x64", expiresAt: Date.now() + 60_000,
|
||||
nativeClosureDigest: `sha256:${KIMI_DISTRIBUTION_CLOSURE_SHA256["linux-x64"]}` }));
|
||||
if (pending) {
|
||||
await expect(rawProbeKimiExecutionTarget(apiKey, remote, model, { companyId }))
|
||||
.rejects.toMatchObject({ details: { code: "KIMI_QUALIFICATION_REQUIRED" } });
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
expect(mocks.prepare).not.toHaveBeenCalled();
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
} else {
|
||||
const runner = { execute: vi.fn() };
|
||||
const selected = { configuredRoot: "/controller/selected-pack",
|
||||
manifest: { payload: { target: { platform: "linux", architecture: "x64" } } } };
|
||||
mocks.select.mockReturnValue(selected);
|
||||
mocks.prepare.mockResolvedValue({ root: "/workspace/.paperclip-runtime/paperclip-runner/provider-pack", manifest: selected.manifest, source: "uploaded" });
|
||||
await expect(rawProbeKimiExecutionTarget(apiKey, { ...remote, runner } as AdapterExecutionTarget, model, { companyId })).resolves.toEqual(verified());
|
||||
expect(mocks.prepare).toHaveBeenCalledOnce();
|
||||
expect(mocks.command.mock.calls[0]![3].env).not.toHaveProperty("PAPERCLIP_ACPX_CANDIDATE_ADMISSION");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["missing", "different-cli-bytes", "installation", "profile", "closure"])("tries only the fixed home pack after %s", async variant => {
|
||||
const first = variant === "missing" ? { ...response(), stdout: "", exitCode: 127 }
|
||||
: variant === "different-cli-bytes" ? { ...response(), stdout: "", exitCode: 126 }
|
||||
: variant === "installation" ? response(failed("KIMI_INSTALLATION_INVALID"), 1)
|
||||
: response({ ...verified(), [variant === "profile" ? "profileDigest" : "closureDigest"]: `sha256:${"0".repeat(64)}` });
|
||||
mocks.command.mockResolvedValueOnce(first).mockResolvedValueOnce(response());
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, model)).resolves.toEqual(verified());
|
||||
expect(mocks.command).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.command.mock.calls[1]![2]).toContain('pack="$HOME/.local/share/paperclip-runner/provider-pack"');
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["KIMI_AUTH_REQUIRED", "KIMI_AUTH_REJECTED", "KIMI_MODEL_UNAVAILABLE", "KIMI_METADATA_TIMEOUT", "KIMI_METADATA_INVALID", "KIMI_PROVIDER_ERROR"])("keeps %s final and strips remote text", async code => {
|
||||
mocks.command.mockResolvedValueOnce(response(failed(code), 1));
|
||||
const error = await probeKimiExecutionTarget(apiKey, remote, model).catch(error => error);
|
||||
expect(error).toMatchObject({ status: 422, details: { code } });
|
||||
expect(error.message).not.toContain(apiKey);
|
||||
expect(mocks.command).toHaveBeenCalledOnce();
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ stdout: "not json" }, { stdout: "x".repeat(65537) }, { signal: "SIGTERM" }, { timedOut: true },
|
||||
{ exitCode: 1 }, { stdout: JSON.stringify({ ...verified(), promptSent: true }) },
|
||||
{ stdout: JSON.stringify({ ...verified(), arbitrary: true }) },
|
||||
])("never accepts malformed or interrupted success %#", alteration => {
|
||||
mocks.command.mockResolvedValue({ ...response(), ...alteration });
|
||||
return expect(probeKimiExecutionTarget(apiKey, remote, model)).rejects.toMatchObject({ details: { code: "KIMI_REQUEST_FAILED" } });
|
||||
});
|
||||
|
||||
it("fails closed when no fixed remote installation matches without trying the controller", async () => {
|
||||
mocks.command.mockResolvedValue({ ...response(), stdout: "", exitCode: 127 });
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, model)).rejects.toMatchObject({ details: { code: "KIMI_INSTALLATION_INVALID" } });
|
||||
expect(mocks.command).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a receipt for another model or release platform", async () => {
|
||||
mocks.command.mockResolvedValue(response({ ...verified(), model: "unselected-model" }));
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, model)).rejects.toMatchObject({ details: { code: "KIMI_MODEL_UNAVAILABLE" } });
|
||||
expect(mocks.command).toHaveBeenCalledOnce();
|
||||
mocks.command.mockResolvedValue(response({ ...verified(), target: "darwin-arm64", closureDigest: `sha256:${KIMI_DISTRIBUTION_CLOSURE_SHA256["darwin-arm64"]}` }));
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, model)).rejects.toMatchObject({ details: { code: "KIMI_INSTALLATION_INVALID" } });
|
||||
});
|
||||
|
||||
it("requires the current bundled probe before transmitting credentials", async () => {
|
||||
for (const value of ["", "arbitrary-path", `sha256:${"f".repeat(63)}`]) {
|
||||
mocks.digest.mockResolvedValue(value);
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, model)).rejects.toMatchObject({ details: { code: "KIMI_INSTALLATION_INVALID" } });
|
||||
}
|
||||
mocks.digest.mockRejectedValue(new Error(`missing build ${apiKey}`));
|
||||
const error = await probeKimiExecutionTarget(apiKey, remote, model).catch(error => error);
|
||||
expect(error.message).not.toContain(apiKey);
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares the 35 second budget across local digest and both remote locations", async () => {
|
||||
let now = 1000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
mocks.digest.mockImplementation(async () => { now += 2000; return digest; });
|
||||
mocks.command.mockImplementationOnce(async () => { now += 20_000; return { ...response(), stdout: "", exitCode: 127 }; });
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, model)).resolves.toEqual(verified());
|
||||
expect(mocks.command.mock.calls[0]![3].timeoutSec).toBe(30);
|
||||
expect(mocks.command.mock.calls[1]![3].timeoutSec).toBe(10);
|
||||
mocks.command.mockReset().mockImplementation(async () => { now += 35_001; return response(); });
|
||||
await expect(probeKimiExecutionTarget(apiKey, remote, model)).rejects.toMatchObject({ details: { code: "KIMI_REQUEST_FAILED" } });
|
||||
});
|
||||
|
||||
it("sanitizes transport errors without trying another host or local credentials", async () => {
|
||||
mocks.command.mockRejectedValue(new Error(`transport ${apiKey}`));
|
||||
const error = await probeKimiExecutionTarget(apiKey, remote, model).catch(error => error);
|
||||
expect(error).toMatchObject({ details: { code: "KIMI_REQUEST_FAILED" } });
|
||||
expect(error.message).not.toContain(apiKey);
|
||||
expect(mocks.command).toHaveBeenCalledOnce();
|
||||
expect(mocks.local).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("keeps the selected SSH key out of actual ssh argv and delivers it only over the existing stdin pipe", async () => {
|
||||
const wireApiKey = "wire-private-sentinel-abc123", wireModel = "wire-model";
|
||||
mocks.command.mockResolvedValue(response({ ...verified(), model: wireModel }));
|
||||
mocks.ssh.mockResolvedValue({ stdout: JSON.stringify({ ...verified(), model: wireModel }), stderr: "" });
|
||||
const spec = { host: "fixture.invalid", username: "fixture", port: 22, remoteWorkspacePath: "/workspace",
|
||||
privateKey: null, knownHosts: null, strictHostKeyChecking: true };
|
||||
const target = { kind: "remote", transport: "ssh", remoteCwd: "/workspace", spec } as AdapterExecutionTarget;
|
||||
await probeKimiExecutionTarget(wireApiKey, target, wireModel);
|
||||
const selected = mocks.ssh.mock.calls[0] ?? [spec, mocks.command.mock.calls[0]![2], { env: mocks.command.mock.calls[0]![3].env }];
|
||||
const { runSshCommand } = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>("@paperclipai/adapter-utils/ssh");
|
||||
const result = await runSshCommand(spec, selected[1], selected[2]);
|
||||
expect(mocks.wire).toHaveLength(1);
|
||||
const argv = JSON.stringify(mocks.wire[0]!.args);
|
||||
for (const value of [wireApiKey, wireModel, Buffer.from(wireApiKey).toString("base64"), Buffer.from(wireApiKey).toString("hex")]) expect(argv).not.toContain(value);
|
||||
expect(mocks.wire[0]!.stdin).toBe(true);
|
||||
const input = JSON.parse(result.stdout);
|
||||
expect(input).toMatchObject({ apiKey: wireApiKey, model: wireModel });
|
||||
if (pending) expect(input.candidateAdmission).toMatchObject({ companyId, model: wireModel, platform: "linux", architecture: "x64" });
|
||||
else expect(input).not.toHaveProperty("candidateAdmission");
|
||||
});
|
||||
|
||||
|
||||
it("loads the verified entry after decoding private stdin without interpreting key or model text", async () => {
|
||||
await probeKimiExecutionTarget(apiKey, { ...remote, transport: "ssh" } as AdapterExecutionTarget, model);
|
||||
const command = mocks.ssh.mock.calls[0]![1] as string;
|
||||
const bootstrap = command.match(/ -e '([^']+)' "\$entry"/)?.[1];
|
||||
expect(bootstrap).toBeTruthy();
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "kimi-probe-stdin-"));
|
||||
try {
|
||||
const entry = path.join(root, "entry.cjs");
|
||||
await writeFile(entry, 'process.stdout.write(JSON.stringify({ key:process.env.KIMI_API_KEY, model:process.env.PAPERCLIP_KIMI_PROBE_MODEL, ambient:process.env.UNSELECTED_SECRET }));');
|
||||
const execute = (input: string) => new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["-e", bootstrap!, entry], { env: {}, stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stdout = "", stderr = "";
|
||||
child.stdout.on("data", chunk => { stdout += chunk; }); child.stderr.on("data", chunk => { stderr += chunk; });
|
||||
child.on("error", reject); child.on("close", code => resolve({ code, stdout, stderr })); child.stdin.end(input);
|
||||
});
|
||||
const result = await execute(JSON.stringify({ apiKey, model }));
|
||||
expect(result.code).toBe(0);expect(result.stderr).toBe("");expect(JSON.parse(result.stdout)).toEqual({ key:apiKey, model });
|
||||
for (const bad of ["not-json-private-sentinel", JSON.stringify({ apiKey: 1, model })]) {
|
||||
const result = await execute(bad);expect(result.code).toBe(1);expect(result.stdout).toBe("");expect(result.stderr).toBe("");
|
||||
}
|
||||
} finally { await rm(root, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
it("preserves SSH installation fallback and typed rejection while stripping transport text", async () => {
|
||||
const target = { ...remote, transport:"ssh" } as AdapterExecutionTarget;
|
||||
mocks.ssh.mockRejectedValueOnce(Object.assign(new Error(apiKey), { code:127, stdout:"" }));
|
||||
await expect(probeKimiExecutionTarget(apiKey, target, model)).resolves.toEqual(verified());
|
||||
expect(mocks.ssh).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.ssh.mock.calls[1]![1]).toContain('pack="$HOME/.local/share/paperclip-runner/provider-pack"');
|
||||
for (const failure of [Object.assign(new Error(apiKey), { code:1, stdout:JSON.stringify(failed("KIMI_AUTH_REJECTED")) }),
|
||||
Object.assign(new Error(apiKey), { code:"ETIMEDOUT" })]) {
|
||||
mocks.ssh.mockRejectedValueOnce(failure);
|
||||
const error = await probeKimiExecutionTarget(apiKey,target,model).catch(error=>error);
|
||||
expect(error.details.code).toBe(failure.code===1?"KIMI_AUTH_REJECTED":"KIMI_REQUEST_FAILED");expect(error.message).not.toContain(apiKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses trusted authority only for a pending metadata Test and ordinary no-grant execution after release", async () => {
|
||||
if (!pending) {
|
||||
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", "unrelated-invalid-authority");
|
||||
for (const context of [{}, { companyId }, { companyId, candidateAdmission: { allowAll: true } }]) {
|
||||
await expect(rawProbeKimiExecutionTarget(apiKey, remote, model, context)).resolves.toEqual(verified());
|
||||
}
|
||||
expect(mocks.command).toHaveBeenCalledTimes(3);
|
||||
for (const call of mocks.command.mock.calls) {
|
||||
expect(call[2]).not.toContain("PAPERCLIP_ACPX_CANDIDATE_ADMISSION");
|
||||
expect(call[3].env).not.toHaveProperty("PAPERCLIP_ACPX_CANDIDATE_ADMISSION");
|
||||
}
|
||||
} else {
|
||||
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", undefined);
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", JSON.stringify([{ agent: "kimi", model }]));
|
||||
for (const context of [{}, { companyId }, { companyId, candidateAdmission: { allowAll: true } }]) {
|
||||
await expect(rawProbeKimiExecutionTarget(apiKey, remote, model, context)).rejects.toMatchObject({ details: { code: "KIMI_QUALIFICATION_REQUIRED" } });
|
||||
}
|
||||
expect(mocks.local).not.toHaveBeenCalled(); expect(mocks.digest).not.toHaveBeenCalled();
|
||||
expect(mocks.command).not.toHaveBeenCalled(); expect(mocks.ssh).not.toHaveBeenCalled();
|
||||
await probeKimiExecutionTarget(apiKey, remote, model);
|
||||
mocks.command.mockClear(); mocks.digest.mockClear();
|
||||
for (const context of [{ companyId: "foreign" }, {}]) {
|
||||
await expect(rawProbeKimiExecutionTarget(apiKey, remote, model, context)).rejects.toMatchObject({ details: { code: "KIMI_QUALIFICATION_REQUIRED" } });
|
||||
}
|
||||
const grant = JSON.parse(process.env.PAPERCLIP_ACPX_CANDIDATE_ADMISSION!);
|
||||
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", JSON.stringify({ ...grant, expiresAt: 0 }));
|
||||
await expect(rawProbeKimiExecutionTarget(apiKey, remote, model, { companyId })).rejects.toMatchObject({ details: { code: "KIMI_QUALIFICATION_REQUIRED" } });
|
||||
expect(mocks.command).not.toHaveBeenCalled(); expect(mocks.digest).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits candidate authority entirely for released metadata Test, including the actual remote shell environment", async () => {
|
||||
const path = "../vendor/paperclip-runner/index.js";
|
||||
vi.doMock(path, async () => {
|
||||
const actual = await vi.importActual<typeof import("../vendor/paperclip-runner/index.js")>(path);
|
||||
return { ...actual, QUALIFIED_ACPX_PROFILES: { ...actual.QUALIFIED_ACPX_PROFILES,
|
||||
kimi: { ...actual.QUALIFIED_ACPX_PROFILES.kimi, qualificationStatus: undefined } } };
|
||||
});
|
||||
vi.resetModules();
|
||||
const root = await mkdtemp(os.tmpdir() + "/kimi-released-probe-");
|
||||
try {
|
||||
vi.stubEnv("PAPERCLIP_ACPX_CANDIDATE_ADMISSION", undefined);
|
||||
const { probeKimiExecutionTarget } = await import("../services/kimi-connection-probe.js");
|
||||
const { resolveKimiCandidateAdmission } = await import("../services/native-runtime/acpx-qualification.js");
|
||||
expect(resolveKimiCandidateAdmission({ companyId, model }, { PAPERCLIP_ACPX_CANDIDATE_ADMISSION: "stale-invalid-candidate" })).toBeUndefined();
|
||||
await probeKimiExecutionTarget(apiKey, remote, model, { companyId });
|
||||
const command = mocks.command.mock.calls[0]![2] as string;
|
||||
const options = mocks.command.mock.calls[0]![3];
|
||||
expect(command).not.toContain("PAPERCLIP_ACPX_CANDIDATE_ADMISSION");
|
||||
expect(options.env).not.toHaveProperty("PAPERCLIP_ACPX_CANDIDATE_ADMISSION");
|
||||
const entry = root + "/environment.cjs";
|
||||
await writeFile(entry, 'process.stdout.write(JSON.stringify({candidatePresent:Object.hasOwn(process.env,"PAPERCLIP_ACPX_CANDIDATE_ADMISSION")}));');
|
||||
const suffix = command.slice(command.lastIndexOf("exec /usr/bin/env -i"));
|
||||
const result = await new Promise<{code:number|null;stdout:string;stderr:string}>((resolve, reject) => {
|
||||
const child = spawn("/bin/sh", ["-c", suffix], { env: { node: process.execPath, entry, pack: root,
|
||||
KIMI_API_KEY: apiKey, PAPERCLIP_KIMI_PROBE_MODEL: model, PAPERCLIP_ACPX_CANDIDATE_ADMISSION: "ambient-forgery" }, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "", stderr = "";
|
||||
child.stdout.on("data", chunk => { stdout += chunk; }); child.stderr.on("data", chunk => { stderr += chunk; });
|
||||
child.on("error", reject); child.on("close", code => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
expect(result).toEqual({ code: 0, stdout: JSON.stringify({ candidatePresent: false }), stderr: "" });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
vi.doUnmock(path); vi.resetModules();
|
||||
}
|
||||
});
|
||||
@@ -412,6 +412,15 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
|
||||
expect(await exists(session.current)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains incomplete native snapshot publications even after the issue closes", async () => {
|
||||
const session = await seedSession({ finalization: { phase: "committed" } });
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeControllerRecovery: {
|
||||
schema: "paperclip.native-controller-recovery.v1", checkpoint: null,
|
||||
} } }).where(eq(heartbeatRuns.id, session.runId));
|
||||
expect(await service().tick()).toMatchObject({ expired: 0, skipped: { native_recovery_incomplete: 1 } });
|
||||
expect(await exists(session.current)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["committed", "resuming_session"],
|
||||
["terminal_failure", "blocked"],
|
||||
|
||||
@@ -9,12 +9,14 @@ import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agentTaskSessions,
|
||||
agents,
|
||||
companies,
|
||||
authUsers,
|
||||
companyMemberships,
|
||||
completionContracts,
|
||||
createDb,
|
||||
closeRegisteredClients,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
@@ -78,7 +80,11 @@ vi.mock("../sentry.js", async () => {
|
||||
return { ...actual, captureRunFailure: mockCaptureRunFailure };
|
||||
});
|
||||
|
||||
import { createIsolatedTestHome } from "./helpers/isolated-test-home.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import * as heartbeatWorkspaces from "../services/heartbeat/workspaces.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { persistHeartbeatRunProcessMetadata } from "../services/heartbeat/run-lifecycle.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
|
||||
describe("P6-25 pre-result native session recovery", () => {
|
||||
@@ -1043,13 +1049,10 @@ describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
|
||||
);
|
||||
await once(staleProviderProcess, "spawn");
|
||||
expect(staleProviderProcess.pid).toEqual(expect.any(Number));
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
processPid: staleProviderProcess.pid!,
|
||||
processStartedAt: new Date("2026-08-09T04:00:00.000Z"),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
await persistHeartbeatRunProcessMetadata(db, runId, {
|
||||
pid: staleProviderProcess.pid!, processGroupId: null,
|
||||
startedAt: new Date("2026-08-09T04:00:00.000Z").toISOString(), targetKind: "local",
|
||||
});
|
||||
const factoryInputs: NativeExecutionInput[] = [];
|
||||
const freshStartTurn = vi.fn<NativeSession["startTurn"]>();
|
||||
const freshBackend = (
|
||||
@@ -1514,4 +1517,165 @@ describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
|
||||
);
|
||||
|
||||
|
||||
|
||||
describe("goal-less native task-session publication", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
if (external) db = createDb(external);
|
||||
else {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-native-pointer-");
|
||||
db = createDb(database.connectionString);
|
||||
}
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => {
|
||||
await closeRegisteredClients(process.env.PAPERCLIP_TEST_DATABASE_URL ?? database!.connectionString);
|
||||
await database?.cleanup();
|
||||
});
|
||||
|
||||
it.each(["retained", "reset"] as const)("publishes the completed task session before provider cleanup and resumes an ordinary comment (%s)", async mode => {
|
||||
const home = createIsolatedTestHome();
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.PAPERCLIP_HOME = home.home;
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const issuePrefix = `NPT${companyId.slice(0, 6).toUpperCase()}`;
|
||||
const projectId = randomUUID(), projectWorkspaceId = randomUUID(), workProductId = randomUUID();
|
||||
let providerSessionId: string | undefined;
|
||||
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const evidenceRef = `work_product:${workProductId}`;
|
||||
const inputs: NativeExecutionInput[] = [];
|
||||
const configs: Record<string, unknown>[] = [];
|
||||
const buildMetadata = heartbeatWorkspaces.buildEffectiveRunSessionConfigMetadata;
|
||||
const configuration = vi.spyOn(heartbeatWorkspaces, "buildEffectiveRunSessionConfigMetadata").mockImplementation(input => {
|
||||
configs.push(structuredClone(input.effectiveAdapterConfig));
|
||||
return buildMetadata(input);
|
||||
});
|
||||
const openSession = vi.fn(), recoverSession = vi.fn(), startTurn = vi.fn();
|
||||
let releaseClose!: () => void, notifyClose!: () => void;
|
||||
const closeHeld = new Promise<void>(resolve => { releaseClose = resolve; });
|
||||
const closeEntered = new Promise<void>(resolve => { notifyClose = resolve; });
|
||||
let firstRunId: string | undefined;
|
||||
const backendFactory = (execution: NativeExecutionInput): NativeSessionBackend => {
|
||||
inputs.push(structuredClone(execution));
|
||||
firstRunId ??= execution.binding.runId;
|
||||
const currentRunId = execution.binding.runId;
|
||||
const currentProviderSessionId = `provider-${execution.session.normalizedSessionId}`;
|
||||
providerSessionId ??= currentProviderSessionId;
|
||||
const turnId = `turn-${currentRunId}`;
|
||||
const identity = { companyId, agentId, issueId, runId: currentRunId, sessionId: execution.session.normalizedSessionId };
|
||||
const result = structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT);
|
||||
result.completionClaim.contractRevision = execution.completionContract.contract.revision;
|
||||
result.completionClaim.criteria = execution.completionContract.contract.criteria.map(criterion => ({
|
||||
...result.completionClaim.criteria[0]!, criterionId: criterion.id, evidenceRefs: [evidenceRef],
|
||||
}));
|
||||
result.evidence = [{ kind: "work_product", ref: evidenceRef }];
|
||||
result.verification[0]!.artifactRef = evidenceRef;
|
||||
let started!: () => void;
|
||||
const turnStarted = new Promise<void>(resolve => { started = resolve; });
|
||||
const session: NativeSession = {
|
||||
identity: () => identity,
|
||||
async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
|
||||
async *events() {
|
||||
await turnStarted;
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, currentRunId));
|
||||
yield { schema: "paperclip.prp.event.v1", sourceEventId: `${currentRunId}:terminal`, sourceSeq: 1,
|
||||
sourceInstanceId: run.runnerInstanceId!, sourceKind: "runner", runId: currentRunId,
|
||||
normalizedSessionId: identity.sessionId, turnId, eventType: "turn.completed", schemaVersion: 1,
|
||||
priority: 0, emittedAt: new Date().toISOString(), payload: {} } satisfies PrpEvent;
|
||||
},
|
||||
async startTurn(input) { startTurn(input); started(); return { turnId }; },
|
||||
async result() { await turnStarted; return { result, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, turnId }; },
|
||||
async snapshot() { return { backendKind: "mock", sessionId: "driver-retained", identity, providerSessionId: currentProviderSessionId,
|
||||
activeTurnId: null, pendingRuntimeRequests: [], lineage: [], cursor: "1" }; },
|
||||
async close() { if (currentRunId === firstRunId) { notifyClose(); await closeHeld; } },
|
||||
};
|
||||
return {
|
||||
async descriptor() { return { kind: "mock", name: "task-pointer-backend", version: "1",
|
||||
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
|
||||
runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } }; },
|
||||
async openSession(input) { openSession(input); return session; },
|
||||
async recoverSession(snapshot) { recoverSession(snapshot); expect(snapshot.providerSessionId).toBe(currentProviderSessionId);
|
||||
return { recovered: true, session }; },
|
||||
};
|
||||
};
|
||||
const heartbeat = heartbeatService(db, { nativeSessionBackendFactory: backendFactory });
|
||||
try {
|
||||
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true, enableIsolatedWorkspaces: true });
|
||||
await db.insert(authUsers).values({ id: "pointer-user", name: "Pointer user", email: "pointer@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
||||
await db.insert(companies).values({ id: companyId, name: "Native pointer", issuePrefix, status: "active", defaultResponsibleUserId: "pointer-user" });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "pointer-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Pointer project", status: "active" });
|
||||
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId, projectId, name: "Pointer workspace", cwd: repoRoot, isPrimary: true });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Pointer agent", adapterType: "paperclip_runner", status: "active",
|
||||
adapterConfig: { provider: "codex", workspaceStrategy: { type: "project_primary" } },
|
||||
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId, issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`, title: "Deliver the saved artifact", status: "in_progress", assigneeAgentId: agentId, workMode: "standard",
|
||||
// Declare the shared mode up front, as the normal profile does. The
|
||||
// first workspace otherwise materializes this setting and legitimately
|
||||
// changes the effective adapter strategy before the next admission.
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" } });
|
||||
await db.insert(issueWorkProducts).values({ id: workProductId, companyId, issueId, type: "artifact", provider: "paperclip",
|
||||
title: "Saved artifact", status: "ready_for_review", reviewState: "approved" });
|
||||
const first = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented",
|
||||
requestedByActorType: "user", requestedByActorId: "pointer-user", payload: { issueId }, contextSnapshot: { issueId, taskId: issueId, skipIssueComment: true } });
|
||||
expect(first).not.toBeNull();
|
||||
await closeEntered;
|
||||
const [persisted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, first!.id));
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, first!.id))).toHaveLength(1);
|
||||
expect(persisted.runnerProfileJson?.sessionCheckpoint).toMatchObject({ identity: { sessionId: persisted.nativeSessionId },
|
||||
providerSessionId, semanticResult: expect.any(Object), terminal: { runTerminalState: "succeeded" } });
|
||||
const pointer = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, agentId));
|
||||
expect(pointer).toEqual([expect.objectContaining({ lastRunId: first!.id,
|
||||
sessionParamsJson: expect.objectContaining({ sessionId: persisted.nativeSessionId }), sessionDisplayId: providerSessionId })]);
|
||||
if (mode === "reset") {
|
||||
expect(await heartbeat.resetRuntimeSession(agentId, { taskKey: issueId })).toMatchObject({ clearedTaskSessions: 1 });
|
||||
expect(await heartbeat.listTaskSessions(agentId)).toEqual([]);
|
||||
}
|
||||
releaseClose();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
if (mode === "reset") expect(await heartbeat.listTaskSessions(agentId)).toEqual([]);
|
||||
// The normal comment route reopens a completed task before its user wake.
|
||||
// Use those public services; a raw comment on a still-done task is stale.
|
||||
const issuesSvc = issueService(db);
|
||||
await issuesSvc.update(issueId, { status: "todo", actorUserId: "pointer-user" });
|
||||
const comment = await issuesSvc.addComment(issueId, "Continue using the saved artifact.", { userId: "pointer-user" });
|
||||
const second = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment",
|
||||
requestedByActorType: "user", requestedByActorId: "pointer-user", payload: { issueId, commentId: comment.id, mutation: "comment", reopenedFrom: "done" },
|
||||
contextSnapshot: { issueId, taskId: issueId, wakeCommentId: comment.id, reopenedFrom: "done", skipIssueComment: true } });
|
||||
expect(second).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const after = await heartbeat.getRun(second!.id);
|
||||
expect(after?.status).toBe("succeeded");
|
||||
expect(inputs).toHaveLength(2);
|
||||
if (mode === "retained") {
|
||||
const changedKeys = [...new Set(configs.flatMap(config => Object.keys(config)))].filter(key =>
|
||||
JSON.stringify(configs[0]?.[key]) !== JSON.stringify(configs.at(-1)?.[key]));
|
||||
expect(changedKeys).toEqual([]);
|
||||
expect(after).toMatchObject({ nativeSessionId: persisted.nativeSessionId, sessionIdAfter: providerSessionId });
|
||||
expect(openSession).toHaveBeenCalledOnce();
|
||||
expect(recoverSession).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(after?.nativeSessionId).not.toBe(persisted.nativeSessionId);
|
||||
expect(after?.sessionIdAfter).not.toBe(providerSessionId);
|
||||
expect(openSession).toHaveBeenCalledTimes(2);
|
||||
expect(recoverSession).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(startTurn).toHaveBeenCalledTimes(2);
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, second!.id))).toHaveLength(1);
|
||||
} finally {
|
||||
releaseClose();
|
||||
try {
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
} finally {
|
||||
configuration.mockRestore();
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
home.cleanup();
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -23,6 +23,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"agents.ts": "/api",
|
||||
"agent-project-access.ts": "/api",
|
||||
"agent-commentary.ts": "/api",
|
||||
"agent-chat-messages.ts": "/api",
|
||||
"agent-avatars.ts": "/api",
|
||||
"agent-profile-avatar.ts": "/api",
|
||||
"announcements.ts": "/api",
|
||||
|
||||
@@ -37,3 +37,166 @@ it("preserves a subsecond replacement deadline when the owner does not require p
|
||||
expect(check).toHaveBeenCalledOnce();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
type Entry = { id: string; companyId: string; issueId: string; dueAt: number };
|
||||
|
||||
function concurrentFixture(entries: Entry[], limit = 4, concurrent = true) {
|
||||
vi.useFakeTimers();
|
||||
const onError = vi.fn();
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
|
||||
let canRun = true;
|
||||
let releaseAll = false;
|
||||
const releases = new Map<string, () => void>();
|
||||
const signals: AbortSignal[] = [];
|
||||
const started: string[] = [];
|
||||
const active = new Set<string>();
|
||||
const completed = new Set<string>();
|
||||
let maximumActive = 0;
|
||||
const check = vi.fn(async (entry: Entry, signal?: AbortSignal) => {
|
||||
started.push(entry.id);
|
||||
signals.push(signal!);
|
||||
active.add(entry.id);
|
||||
maximumActive = Math.max(maximumActive, active.size);
|
||||
if (!releaseAll) await new Promise<void>(resolve => releases.set(entry.id, resolve));
|
||||
active.delete(entry.id);
|
||||
completed.add(entry.id);
|
||||
return null;
|
||||
});
|
||||
const load = vi.fn(async () => entries.filter(entry => !completed.has(entry.id)));
|
||||
coordinator = createDeliveryWorkCoordinator({ owner, canRun: () => canRun, onError });
|
||||
const options = {
|
||||
queue: DELIVERY_QUEUES.nativeResponsePresentation,
|
||||
concurrentChecks: concurrent ? { limit, key: (entry: Entry) => `${entry.companyId}:${entry.issueId}` } : undefined,
|
||||
decodeScope: () => undefined,
|
||||
matches: () => true,
|
||||
load,
|
||||
check,
|
||||
};
|
||||
const worker = registerScopedDeadlineWork(coordinator, options, () => canRun);
|
||||
return {
|
||||
worker, check, load, started, active, signals, onError, entries,
|
||||
maximumActive: () => maximumActive,
|
||||
pause: () => { canRun = false; },
|
||||
resume: () => { canRun = true; worker.wake(); },
|
||||
release: (id: string) => releases.get(id)?.(),
|
||||
releaseAll: () => { releaseAll = true; for (const resolve of releases.values()) resolve(); },
|
||||
};
|
||||
}
|
||||
|
||||
const entry = (id: string, issueId = id, companyId = "company"): Entry => ({ id, issueId, companyId, dueAt: 0 });
|
||||
|
||||
it("checks independent tasks concurrently with a bounded number of active checks", async () => {
|
||||
const f = concurrentFixture(Array.from({ length: 6 }, (_, index) => entry(String(index))));
|
||||
try {
|
||||
await vi.waitFor(() => expect(f.started).toHaveLength(4), { timeout: 100 });
|
||||
expect(f.maximumActive()).toBe(4);
|
||||
f.release("1");
|
||||
await vi.waitFor(() => expect(f.started).toHaveLength(5), { timeout: 100 });
|
||||
expect(f.active.has("0")).toBe(true);
|
||||
expect(f.maximumActive()).toBe(4);
|
||||
} finally { f.releaseAll(); await f.worker.ready; }
|
||||
expect(f.started).toHaveLength(6);
|
||||
expect(new Set(f.started).size).toBe(6);
|
||||
expect(coordinator!.nextWakeAt()).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(f.load).toHaveBeenCalledOnce();
|
||||
expect(f.check).toHaveBeenCalledTimes(6);
|
||||
f.entries.push(entry("fresh"));
|
||||
f.worker.invalidate();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(f.load).toHaveBeenCalledTimes(2);
|
||||
expect(f.check).toHaveBeenCalledTimes(7);
|
||||
expect(f.started.at(-1)).toBe("fresh");
|
||||
expect(coordinator!.nextWakeAt()).toBeNull();
|
||||
expect(f.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes each task in candidate order while other tasks and companies progress", async () => {
|
||||
const f = concurrentFixture([
|
||||
entry("1", "shared"), entry("2", "shared"), entry("3", "other"), entry("4", "shared", "other-company"),
|
||||
]);
|
||||
try {
|
||||
await vi.waitFor(() => expect(new Set(f.started)).toEqual(new Set(["1", "3", "4"])), { timeout: 100 });
|
||||
f.release("3");
|
||||
await vi.waitFor(() => expect(f.active.has("3")).toBe(false), { timeout: 100 });
|
||||
expect(f.started).not.toContain("2");
|
||||
f.release("1");
|
||||
await vi.waitFor(() => expect(f.started).toHaveLength(4), { timeout: 100 });
|
||||
expect(f.started.filter(id => id === "1" || id === "2")).toEqual(["1", "2"]);
|
||||
expect(f.active.has("1")).toBe(false);
|
||||
} finally { f.releaseAll(); await f.worker.ready; }
|
||||
expect(f.maximumActive()).toBe(3);
|
||||
expect(f.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves global candidate order and serial dispatch unless concurrency is enabled", async () => {
|
||||
const f = concurrentFixture([entry("1", "shared"), entry("2", "other"), entry("3", "shared")], 4, false);
|
||||
try {
|
||||
await vi.waitFor(() => expect(f.started).toEqual(["1"]), { timeout: 100 });
|
||||
f.release("1");
|
||||
await vi.waitFor(() => expect(f.started).toEqual(["1", "2"]), { timeout: 100 });
|
||||
} finally { f.releaseAll(); await f.worker.ready; }
|
||||
expect(f.started).toEqual(["1", "2", "3"]);
|
||||
expect(f.maximumActive()).toBe(1);
|
||||
});
|
||||
|
||||
it("retains unstarted checks through drain and resumes without idle discovery reads", async () => {
|
||||
const f = concurrentFixture(Array.from({ length: 6 }, (_, index) => entry(String(index))));
|
||||
try {
|
||||
await vi.waitFor(() => expect(f.started).toHaveLength(4), { timeout: 100 });
|
||||
f.pause();
|
||||
} finally { f.releaseAll(); await f.worker.ready; }
|
||||
expect(f.started).toHaveLength(4);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(f.load).toHaveBeenCalledOnce();
|
||||
expect(f.started).toHaveLength(4);
|
||||
f.resume();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(f.started).toHaveLength(6);
|
||||
expect(coordinator!.nextWakeAt()).toBeNull();
|
||||
expect(f.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops starting checks and awaits every admitted check during shutdown", async () => {
|
||||
const f = concurrentFixture(Array.from({ length: 6 }, (_, index) => entry(String(index))));
|
||||
let stopped = false;
|
||||
let stop: Promise<void> | undefined;
|
||||
try {
|
||||
await vi.waitFor(() => expect(f.started).toHaveLength(4), { timeout: 100 });
|
||||
stop = coordinator!.stop().then(() => { stopped = true; });
|
||||
expect(f.signals.every(signal => signal.aborted)).toBe(true);
|
||||
expect(stopped).toBe(false);
|
||||
f.release("0");
|
||||
await vi.waitFor(() => expect(f.active.has("0")).toBe(false), { timeout: 100 });
|
||||
expect(stopped).toBe(false);
|
||||
expect(f.started).toHaveLength(4);
|
||||
} finally { f.releaseAll(); await f.worker.ready; await stop; }
|
||||
expect(stopped).toBe(true);
|
||||
expect(f.started).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps failures isolated and retries their deadline without repeating successful checks", async () => {
|
||||
vi.useFakeTimers();
|
||||
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
|
||||
const onError = vi.fn();
|
||||
coordinator = createDeliveryWorkCoordinator({ owner, canRun: () => true, onError });
|
||||
const entries = [entry("1", "shared"), entry("2", "shared"), entry("3")];
|
||||
const load = vi.fn(async () => entries);
|
||||
const check = vi.fn(async (_entry: Entry) => null).mockRejectedValueOnce(new Error("temporary failure"));
|
||||
const options = {
|
||||
queue: DELIVERY_QUEUES.nativeResponsePresentation,
|
||||
concurrentChecks: { limit: 4, key: (entry: Entry) => `${entry.companyId}:${entry.issueId}` },
|
||||
decodeScope: () => undefined, matches: () => true, load, check,
|
||||
};
|
||||
const worker = registerScopedDeadlineWork(coordinator, options, () => true);
|
||||
await worker.ready;
|
||||
expect(check.mock.calls.map(([entry]) => entry.id).sort()).toEqual(["1", "2", "3"]);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls[0][0]).toBeInstanceOf(AggregateError);
|
||||
await vi.advanceTimersByTimeAsync(29_999);
|
||||
expect(check).toHaveBeenCalledTimes(3);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(check.mock.calls.map(([entry]) => entry.id)).toEqual(["1", "3", "2", "1"]);
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { registerSkillVersionRetentionWork } from "../services/skill-version-ret
|
||||
import { notifySkillVersionRetention } from "../services/skill-version-retention-notifications.js";
|
||||
import { notifyInstructionCollection } from "../services/instruction-collection-notifications.js";
|
||||
import { notifyNativeEnvironmentSettlement } from "../services/native-environment-settlement-notifications.js";
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
import { idleWorkSnapshot, trackIdleWork } from "../services/task-admission.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { applyPendingMigrations } from "@paperclipai/db";
|
||||
import { touchSkillVersionCache, lastUsedAt, skillVersionCachePaths } from "../services/skill-version-cache.js";
|
||||
@@ -158,9 +158,15 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function startRetention(graceMs = 20_000, service = skillVersionRetentionService(db, { graceMs }), clock: { shouldAdvanceTime?: boolean; now?: number } = {}) {
|
||||
async function startRetention(
|
||||
graceMs = 20_000,
|
||||
service = skillVersionRetentionService(db, { graceMs }),
|
||||
clock: boolean | { shouldAdvanceTime?: boolean; now?: number } = true,
|
||||
) {
|
||||
const clockOptions = typeof clock === "boolean" ? { shouldAdvanceTime: clock } : clock;
|
||||
const advanceAutomatically = clockOptions.shouldAdvanceTime ?? true;
|
||||
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select 1`)));
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true, ...clock });
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: advanceAutomatically, ...clockOptions });
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => true,
|
||||
onError: error => { throw error; } });
|
||||
const register = coordinator.register.bind(coordinator);
|
||||
@@ -170,7 +176,8 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
||||
return pass;
|
||||
} });
|
||||
worker = registerSkillVersionRetentionWork(coordinator, service, () => admitted);
|
||||
await worker.ready;
|
||||
if (advanceAutomatically) await worker.ready;
|
||||
else await waitForIo(worker.ready);
|
||||
return service;
|
||||
}
|
||||
async function settled(deadline: number | null = null) {
|
||||
@@ -180,6 +187,32 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
||||
expect(coordinator!.nextWakeAt()).toBe(deadline);
|
||||
}, { timeout: 10_000 });
|
||||
}
|
||||
async function waitForIo<T>(operation: Promise<T>) {
|
||||
let complete = false;
|
||||
void operation.then(() => { complete = true; }, () => { complete = true; });
|
||||
const deadline = process.hrtime.bigint() + 10_000_000_000n;
|
||||
while (!complete) {
|
||||
if (process.hrtime.bigint() >= deadline) throw new Error("Retention I/O did not settle within 10000ms");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await realDelay(0);
|
||||
}
|
||||
return operation;
|
||||
}
|
||||
async function settledAtDeadlines() {
|
||||
const deadline = process.hrtime.bigint() + 10_000_000_000n;
|
||||
for (;;) {
|
||||
if (process.hrtime.bigint() >= deadline) throw new Error("Retention work did not settle within 10000ms");
|
||||
if (idleWorkSnapshot().active > 0) {
|
||||
if (passes.size) await waitForIo(Promise.all([...passes]));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await realDelay(0);
|
||||
continue;
|
||||
}
|
||||
const next = coordinator!.nextWakeAt();
|
||||
if (next === null) return;
|
||||
await vi.advanceTimersByTimeAsync(Math.max(0, next - Date.now()));
|
||||
}
|
||||
}
|
||||
async function quiet(ms = 3 * HOUR_MS) {
|
||||
const reads = vi.spyOn(db, "select"), transactions = vi.spyOn(db, "transaction"), directories = vi.spyOn(fs, "opendir");
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
@@ -581,6 +614,30 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
||||
expect(await exists(directory)).toBe(false); await quiet();
|
||||
});
|
||||
|
||||
it("does not advance retention deadlines while owned directory I/O is pending", async () => {
|
||||
await startRetention(20_000, undefined, false);
|
||||
const entered = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
||||
const reading = trackIdleWork((async () => {
|
||||
const directory = await fs.opendir(paperclipHome!);
|
||||
try { entered.resolve(); await release.promise; }
|
||||
finally { await directory.close(); }
|
||||
})());
|
||||
const advance = vi.spyOn(vi, "advanceTimersByTimeAsync");
|
||||
let settling: Promise<void> | undefined;
|
||||
try {
|
||||
await Promise.race([entered.promise, reading]);
|
||||
const heldAt = Date.now();
|
||||
settling = settledAtDeadlines();
|
||||
await realDelay(0);
|
||||
expect(advance.mock.calls.every(([ms]) => ms === 0)).toBe(true);
|
||||
expect(Date.now()).toBe(heldAt);
|
||||
} finally {
|
||||
release.resolve();
|
||||
try { await reading; await settling; }
|
||||
finally { advance.mockRestore(); }
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps directory pages moving while another company's grace deadline is due", async () => {
|
||||
const dueCompany = await createCompany(), pagedCompany = await createCompany();
|
||||
const dueSkill = await createSkill(dueCompany, 2), pagedSkill = await createSkill(pagedCompany, 1);
|
||||
@@ -595,26 +652,12 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
||||
return prune(id, options);
|
||||
};
|
||||
const expiring = await writeSnapshot(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!, 0);
|
||||
const completeIo = async (work: Promise<unknown>) => {
|
||||
let done = false;
|
||||
void work.then(() => { done = true; }, () => { done = true; });
|
||||
while (!done) {
|
||||
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0);
|
||||
await realDelay(0);
|
||||
}
|
||||
await work;
|
||||
};
|
||||
const accessedAt = (await lastUsedAt(skillVersionCachePaths(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!).access))!;
|
||||
await completeIo(startRetention(5000, service, { shouldAdvanceTime: false, now: accessedAt }));
|
||||
await startRetention(5000, service, { shouldAdvanceTime: false, now: accessedAt });
|
||||
expect(await exists(expiring)).toBe(true);
|
||||
// Keep virtual time fixed during real I/O; measure scheduler fairness,
|
||||
// independent of filesystem latency and CI CPU scheduling.
|
||||
while (coordinator!.nextWakeAt() !== null) {
|
||||
await vi.advanceTimersByTimeAsync(Math.max(0, coordinator!.nextWakeAt()! - Date.now()));
|
||||
await completeIo(Promise.all([...passes]));
|
||||
await realDelay(0);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}
|
||||
await settledAtDeadlines();
|
||||
expect(pageTimes.length).toBeGreaterThan(5);
|
||||
expect(Math.max(...pageTimes.slice(1).map((at, index) => at - pageTimes[index]!))).toBeLessThan(3000);
|
||||
expect(await exists(expiring)).toBe(false); await quiet();
|
||||
|
||||
@@ -83,6 +83,10 @@ import {
|
||||
} from "../services/tool-access.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { createOAuthAuthorizationStart } from "../services/tool-access/oauth/authorization-start.js";
|
||||
import { createOAuthClientRegistration } from "../services/tool-access/oauth/client-registration.js";
|
||||
import { createPersonalConnectionIdentity } from "../services/tool-access/connections/personal-identity.js";
|
||||
import { createConnectionSetupPolicy } from "../services/tool-access/connections/setup-policy.js";
|
||||
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import {
|
||||
@@ -2620,6 +2624,224 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(reconnected.connection.agentInstructions).toEqual(settings);
|
||||
});
|
||||
|
||||
it.each(["managed", "customer"] as const)("preserves a %s Workspace draft's selection after declined and expired authorization", async (mode) => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `workspace-draft-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
||||
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", grantKind: "user",
|
||||
connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth",
|
||||
...(mode === "customer" ? { oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" } } : {}),
|
||||
}, actor);
|
||||
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
||||
const [unstarted] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
||||
const configWithProposals = { ...unstarted.config, oauth: { ...(unstarted.config.oauth as Record<string, unknown>), pendingWorkspaceServiceRequests: {
|
||||
[userId]: { requestedScopes: googleWorkspaceSelectedScopes({ drive: "write" }), requestedAt: "2026-10-26T10:00:00Z" },
|
||||
other: { requestedScopes: googleWorkspaceSelectedScopes({ calendar: "write" }), requestedAt: "2026-10-26T10:00:00Z" },
|
||||
} } };
|
||||
await db.update(toolConnections).set({ config: configWithProposals, transportConfig: configWithProposals }).where(eq(toolConnections.id, connected.connectionId));
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri, actor, workspaceServices: { gmail: "read" },
|
||||
});
|
||||
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
||||
if (mode === "managed") {
|
||||
await expect(service.completePaperclipCloudConnectorCallback({ state, error: "access_denied", actor })).rejects.toMatchObject({ status: 400 });
|
||||
} else {
|
||||
await expect(service.completeOAuthCallback({ state, error: "access_denied", redirectUri, actor })).rejects.toMatchObject({ status: 400 });
|
||||
}
|
||||
const draft = await service.getConnection(connected.connectionId);
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.config.workspaceSetup).toEqual({
|
||||
version: 1, companyId: company.id, connectionId: draft.id, actorType: "user", actorId: userId,
|
||||
grantKind: "user", subjectId: userId,
|
||||
requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"],
|
||||
});
|
||||
expect((draft.config.oauth as typeof configWithProposals.oauth).pendingWorkspaceServiceRequests).toEqual({ other: configWithProposals.oauth.pendingWorkspaceServiceRequests.other });
|
||||
const retained = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", resumeConnectionId: draft.id,
|
||||
connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth",
|
||||
...(mode === "customer" ? { oauthClient: { clientId: "fixture-client" } } : {}),
|
||||
}, actor);
|
||||
expect(retained.connectionId).toBe(draft.id);
|
||||
const resumed = await service.startOAuth(company.id, retained.connectionId, { redirectUri, actor });
|
||||
const [resumedState] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, new URL(resumed.authorizationUrl).searchParams.get("state")!));
|
||||
expect(resumedState.requestedScopes).toEqual(["https://www.googleapis.com/auth/gmail.readonly"]);
|
||||
await db.update(toolOauthStates).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolOauthStates.state, resumedState.state));
|
||||
if (mode === "managed") {
|
||||
await expect(service.completePaperclipCloudConnectorCallback({ state: resumedState.state, claimId: "expired", actor })).rejects.toMatchObject({ status: 400 });
|
||||
} else {
|
||||
await expect(service.completeOAuthCallback({ state: resumedState.state, code: "expired", redirectUri, actor })).rejects.toMatchObject({ status: 400 });
|
||||
}
|
||||
const coldService = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const coldResume = await coldService.startOAuth(company.id, draft.id, { redirectUri, actor });
|
||||
const [coldState] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, new URL(coldResume.authorizationUrl).searchParams.get("state")!));
|
||||
expect(coldState.requestedScopes).toEqual(["https://www.googleapis.com/auth/gmail.readonly"]);
|
||||
});
|
||||
|
||||
it.each(["bound", "first-use"] as const)("preserves the latest Workspace draft choice across overlapping %s customer starts", async (bindingMode) => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `workspace-overlap-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user",
|
||||
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
||||
}, actor);
|
||||
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
||||
const endpoints = {
|
||||
provider: "google", authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token", registrationUrl: null,
|
||||
scopes: googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION),
|
||||
offlineAccessSupported: true, issuer: "https://accounts.google.com", resource: null,
|
||||
};
|
||||
const getConnectionRow = async () => {
|
||||
const [row] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
||||
return row;
|
||||
};
|
||||
const initial = await getConnectionRow();
|
||||
if (bindingMode === "bound") {
|
||||
const config = { ...initial.config, oauth: { ...(initial.config.oauth as Record<string, unknown>),
|
||||
clientRedirectUri: redirectUri, clientIssuer: endpoints.issuer, clientResource: null, clientCompanyId: company.id,
|
||||
} };
|
||||
await db.update(toolConnections).set({ config, transportConfig: config }).where(eq(toolConnections.id, initial.id));
|
||||
} else {
|
||||
expect((initial.config.oauth as Record<string, unknown>).clientRedirectUri).toBeUndefined();
|
||||
}
|
||||
const observedClientRows: typeof initial[] = [];
|
||||
const ensureOAuthClient = createOAuthClientRegistration(db, {
|
||||
now: () => new Date(), options: {}, getConnectionRow,
|
||||
requestOAuthClientRegistration: vi.fn(),
|
||||
store: {
|
||||
createOrRotateOAuthSecret: vi.fn(), syncCredentialBindings: vi.fn(),
|
||||
oauthClientForConnection: async (row) => {
|
||||
observedClientRows.push(row);
|
||||
return { clientId: "fixture-client", clientSecret: "fixture-secret", clientIdEnv: null, clientSecretEnv: null };
|
||||
},
|
||||
},
|
||||
});
|
||||
let releaseA!: () => void;
|
||||
let reachedA!: () => void;
|
||||
const pausedA = new Promise<void>(resolve => { reachedA = resolve; });
|
||||
const resumeA = new Promise<void>(resolve => { releaseA = resolve; });
|
||||
let discoveries = 0;
|
||||
const makeStart = () => createOAuthAuthorizationStart(db, {
|
||||
now: () => new Date(), getConnectionRow, ensureOAuthClient,
|
||||
setupPolicy: createConnectionSetupPolicy(db), fixedPersonalIdentityForReconnect: createPersonalConnectionIdentity(db),
|
||||
currentCloudConnector: () => null, vercelConnect: null,
|
||||
oauthEndpointsForConnection: async () => {
|
||||
if (++discoveries === 1) { reachedA(); await resumeA; }
|
||||
return endpoints;
|
||||
},
|
||||
assertOAuthEndpointUrl: (_kind, url) => String(url),
|
||||
});
|
||||
const start = makeStart();
|
||||
const first = start(company.id, initial.id, { redirectUri, actor, subjectUserId: userId, workspaceServices: { gmail: "write" } });
|
||||
let startedA;
|
||||
let startedB;
|
||||
let afterB;
|
||||
try {
|
||||
await pausedA;
|
||||
startedB = await start(company.id, initial.id, { redirectUri, actor, subjectUserId: userId, workspaceServices: { gmail: "read" } });
|
||||
afterB = await getConnectionRow();
|
||||
expect((afterB.config.oauth as Record<string, unknown>).clientRedirectUri).toBe(redirectUri);
|
||||
const pending = { anotherUser: { requestedScopes: googleWorkspaceSelectedScopes({ calendar: "read" }), requestedAt: "2026-10-30T16:00:00Z" } };
|
||||
await db.update(toolConnections).set({
|
||||
config: { ...afterB.config, concurrentSetting: "config-b", oauth: { ...(afterB.config.oauth as Record<string, unknown>), pendingWorkspaceServiceRequests: pending } },
|
||||
transportConfig: { ...afterB.transportConfig, transportOnlySetting: "transport-b", oauth: { ...(afterB.transportConfig.oauth as Record<string, unknown>), pendingWorkspaceServiceRequests: pending } },
|
||||
}).where(eq(toolConnections.id, initial.id));
|
||||
} finally {
|
||||
releaseA();
|
||||
startedA = await first;
|
||||
}
|
||||
const writeScopes = googleWorkspaceSelectedScopes({ gmail: "write" });
|
||||
const readScopes = googleWorkspaceSelectedScopes({ gmail: "read" });
|
||||
const expectedBinding = { version: 1, companyId: company.id, connectionId: initial.id, actorType: "user", actorId: userId,
|
||||
grantKind: "user", subjectId: userId, requestedScopes: readScopes };
|
||||
if (bindingMode === "first-use") {
|
||||
expect(observedClientRows).toHaveLength(2);
|
||||
expect((observedClientRows[1].config.oauth as Record<string, unknown>).clientRedirectUri).toBe(redirectUri);
|
||||
expect(observedClientRows[1].config.workspaceSetup).toEqual(expectedBinding);
|
||||
}
|
||||
const latest = await getConnectionRow();
|
||||
expect(afterB!.config.workspaceSetup).toEqual(expectedBinding);
|
||||
expect(latest.config.workspaceSetup).toEqual(expectedBinding);
|
||||
expect(latest.transportConfig.workspaceSetup).toEqual(expectedBinding);
|
||||
expect(latest.config.concurrentSetting).toBe("config-b");
|
||||
expect(latest.transportConfig.transportOnlySetting).toBe("transport-b");
|
||||
for (const config of [latest.config, latest.transportConfig]) {
|
||||
expect((config.oauth as Record<string, unknown>).pendingWorkspaceServiceRequests).toEqual({ anotherUser: {
|
||||
requestedScopes: googleWorkspaceSelectedScopes({ calendar: "read" }), requestedAt: "2026-10-30T16:00:00Z",
|
||||
} });
|
||||
}
|
||||
for (const [started, expected] of [[startedA!, writeScopes], [startedB!, readScopes]] as const) {
|
||||
const url = new URL(started.authorizationUrl);
|
||||
expect(url.searchParams.get("scope")?.split(" ")).toEqual(expected);
|
||||
const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!));
|
||||
expect(state.requestedScopes).toEqual(expected);
|
||||
}
|
||||
const cold = await makeStart()(company.id, initial.id, { redirectUri, actor, subjectUserId: userId });
|
||||
const coldUrl = new URL(cold.authorizationUrl);
|
||||
expect(coldUrl.searchParams.get("scope")?.split(" ")).toEqual(readScopes);
|
||||
const [coldState] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, coldUrl.searchParams.get("state")!));
|
||||
expect(coldState.requestedScopes).toEqual(readScopes);
|
||||
});
|
||||
|
||||
it.each((["clientId", "clientSecretRef", "clientIssuer", "clientResource", "clientRedirectUri", "issuer", "resource"] as const)
|
||||
.flatMap(field => (["discovery", "client-resolution"] as const).map(stage => [field, stage] as const)))(
|
||||
"rejects a concurrent %s change during Workspace %s", async (field, stage) => {
|
||||
const company = await createCompany(db);
|
||||
const actor = { actorType: "user" as const, actorId: "board" };
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "organization",
|
||||
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
||||
}, actor);
|
||||
const getConnectionRow = async () => {
|
||||
const [row] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
||||
return row;
|
||||
};
|
||||
const endpoints = { provider: "google", authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token", scopes: googleWorkspaceSelectedScopes({ gmail: "read" }),
|
||||
issuer: "https://accounts.google.com", resource: null };
|
||||
const ensureClient = createOAuthClientRegistration(db, {
|
||||
now: () => new Date(), options: {}, getConnectionRow, requestOAuthClientRegistration: vi.fn(),
|
||||
store: { createOrRotateOAuthSecret: vi.fn(), syncCredentialBindings: vi.fn(),
|
||||
oauthClientForConnection: async () => ({ clientId: "fixture-client", clientSecret: "fixture-secret", clientIdEnv: null, clientSecretEnv: null }) },
|
||||
});
|
||||
let changed: Awaited<ReturnType<typeof getConnectionRow>>;
|
||||
const mutateClient = async () => {
|
||||
const row = await getConnectionRow();
|
||||
const updates = field === "clientSecretRef"
|
||||
? { credentialSecretRefs: row.credentialSecretRefs.map(ref => ref.configPath === "oauth.client_secret" ? { ...ref, versionSelector: 1 } : ref) }
|
||||
: {
|
||||
config: { ...row.config, oauth: { ...(row.config.oauth as Record<string, unknown>), [field]: `changed-${field}` } },
|
||||
transportConfig: { ...row.transportConfig, oauth: { ...(row.transportConfig.oauth as Record<string, unknown>), [field]: `changed-${field}` } },
|
||||
};
|
||||
if (field === "clientSecretRef") expect(updates.credentialSecretRefs).not.toEqual(row.credentialSecretRefs);
|
||||
[changed] = await db.update(toolConnections).set(updates).where(eq(toolConnections.id, row.id)).returning();
|
||||
};
|
||||
const start = createOAuthAuthorizationStart(db, {
|
||||
now: () => new Date(), getConnectionRow,
|
||||
setupPolicy: createConnectionSetupPolicy(db), fixedPersonalIdentityForReconnect: createPersonalConnectionIdentity(db),
|
||||
currentCloudConnector: () => null, vercelConnect: null,
|
||||
oauthEndpointsForConnection: async () => { if (stage === "discovery") await mutateClient(); return endpoints; },
|
||||
ensureOAuthClient: async input => { const resolved = await ensureClient(input); if (stage === "client-resolution") await mutateClient(); return resolved; },
|
||||
assertOAuthEndpointUrl: (_kind, url) => String(url),
|
||||
});
|
||||
await expect(start(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { gmail: "read" },
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
const retained = await getConnectionRow();
|
||||
expect(retained.config).toEqual(changed!.config);
|
||||
expect(retained.transportConfig).toEqual(changed!.transportConfig);
|
||||
expect(retained.credentialSecretRefs).toEqual(changed!.credentialSecretRefs);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["user", "organization"] as const)("preserves the %s Workspace request when account-page reconnect omits a selection", async (kind) => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
@@ -2653,6 +2875,69 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(new URL(selected.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(requestedScopes);
|
||||
});
|
||||
|
||||
it("requires explicit selection for historical Workspace drafts and a different setup owner", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `workspace-owner-${randomUUID()}`;
|
||||
const otherUserId = `workspace-owner-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
await grantBoardUser(db, company.id, otherUserId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
||||
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", grantKind: "organization",
|
||||
}, actor);
|
||||
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
||||
await expect(service.startOAuth(company.id, connected.connectionId, { redirectUri, actor })).rejects.toMatchObject({ status: 400 });
|
||||
await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, workspaceServices: { gmail: "read" } });
|
||||
await expect(service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri, actor: { actorType: "user", actorId: otherUserId },
|
||||
})).rejects.toMatchObject({ status: 400 });
|
||||
expect((await service.getConnection(connected.connectionId)).config.workspaceSetup).toMatchObject({ actorId: userId });
|
||||
});
|
||||
|
||||
it.each(["credentialPolicy", "credentialSource", "authKind", "transport", "createdByUserId", "sourceTemplateKey", "connectionMethodKey"] as const)("rejects a Workspace draft whose %s changes during authorization", async (field) => {
|
||||
const company = await createCompany(db);
|
||||
const actor = { actorType: "user" as const, actorId: "board" };
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "organization",
|
||||
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
||||
}, actor);
|
||||
const [original] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
||||
const changed: Partial<typeof toolConnections.$inferInsert> = {};
|
||||
if (field === "credentialPolicy") changed.credentialPolicy = "per_user";
|
||||
else if (field === "credentialSource") {
|
||||
changed.credentialSource = "vercel_connect";
|
||||
changed.credentialRefs = [];
|
||||
changed.credentialSecretRefs = [];
|
||||
changed.externalCredential = { provider: "vercel_connect", connectorId: "fixture", connectorUid: "fixture", service: "google", principalMode: "user" };
|
||||
}
|
||||
else if (field === "authKind") changed.authKind = "none";
|
||||
else if (field === "transport") changed.transport = "rest_api";
|
||||
else if (field === "createdByUserId") changed.createdByUserId = "another-owner";
|
||||
else changed.config = { ...original.config, [field]: "different" };
|
||||
const start = createOAuthAuthorizationStart(db, {
|
||||
now: () => new Date(),
|
||||
getConnectionRow: async () => {
|
||||
await db.update(toolConnections).set(changed).where(eq(toolConnections.id, original.id));
|
||||
return original;
|
||||
},
|
||||
setupPolicy: createConnectionSetupPolicy(db),
|
||||
fixedPersonalIdentityForReconnect: createPersonalConnectionIdentity(db),
|
||||
currentCloudConnector: () => null,
|
||||
vercelConnect: null,
|
||||
ensureOAuthClient: vi.fn(),
|
||||
oauthEndpointsForConnection: vi.fn(),
|
||||
assertOAuthEndpointUrl: vi.fn(),
|
||||
});
|
||||
await expect(start(company.id, original.id, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { gmail: "read" },
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
expect((await service.getConnection(original.id)).config.workspaceSetup).toBeUndefined();
|
||||
expect(await db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, original.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("binds selected Workspace service scopes to OAuth state and rejects other providers", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
@@ -2686,7 +2971,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
oauthClient: { clientId: "test-workspace-client", clientSecret: "test-workspace-secret" },
|
||||
}, actor);
|
||||
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor });
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION });
|
||||
const authorization = new URL(started.authorizationUrl);
|
||||
expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION));
|
||||
expect(authorization.searchParams.get("enable_granular_consent")).toBe("true");
|
||||
@@ -2742,7 +3027,9 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
const redirectUri = `https://paperclip.example/api/tools/oauth/${mode === "managed" ? "cloud-connector/" : ""}callback`;
|
||||
for (const reconnect of [false, true]) {
|
||||
if (reconnect) await service.connectGalleryApp(company.id, { ...input, reconnectConnectionId: connected.connectionId }, actor);
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor });
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor,
|
||||
...(!reconnect ? { workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION } : {}),
|
||||
});
|
||||
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
||||
const completed = mode === "managed"
|
||||
? await service.completePaperclipCloudConnectorCallback({ state, claimId: "workspace-no-agents", actor })
|
||||
@@ -7629,6 +7916,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(workspaceApp.methods.map((method: { key: string }) => method.key)).toContain(methodKey);
|
||||
const connected = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({
|
||||
galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Personal ${slug}`,
|
||||
...(profile === "workspace.all" ? { workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION } : {}),
|
||||
});
|
||||
expect(connected.status).toBe(201);
|
||||
expect(connected.body.connection).toMatchObject({ credentialPolicy: "per_user", ownership: "platform_shared" });
|
||||
@@ -7691,6 +7979,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
.map((method: { key: string }) => method.key)).toContain(methodKey);
|
||||
const result = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({
|
||||
galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Cloud ${slug}`,
|
||||
...(profile === "workspace.all" ? { workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION } : {}),
|
||||
});
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(201);
|
||||
expect(result.body.auth.startUrl).toBe("https://my.paperclip.app/connections/confirm?id=test-workspace-session");
|
||||
@@ -8297,6 +8586,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
}, actor);
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor,
|
||||
workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION,
|
||||
});
|
||||
const completed = await service.completePaperclipCloudConnectorCallback({
|
||||
state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "workspace-claim", actor,
|
||||
@@ -8364,7 +8654,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-client-secret" } } : {}),
|
||||
}, actor);
|
||||
const redirectUri = mode === "managed" ? "https://paperclip.example/api/tools/oauth/cloud-connector/callback" : "https://paperclip.example/api/tools/oauth/callback";
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor });
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION });
|
||||
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
||||
const completed = mode === "managed"
|
||||
? await service.completePaperclipCloudConnectorCallback({ state, claimId: "fixture", actor })
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
toolApplications,
|
||||
toolCatalogEntries,
|
||||
toolConnections,
|
||||
toolProfileEntries,
|
||||
toolProfiles,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { createToolProfileService } from "../services/tool-access/profiles/service.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeDatabase = support.supported ? describe : describe.skip;
|
||||
|
||||
describeDatabase("tool profile entry order", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-profile-entry-order-");
|
||||
db = createDb(database.connectionString, { maxConnections: 1 });
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await database?.cleanup();
|
||||
});
|
||||
|
||||
it("keeps list and detail entries stable across tied timestamps and physical row order", async () => {
|
||||
const [company, foreign] = await db.insert(companies).values([
|
||||
{ name: "Entry order", issuePrefix: `EO${randomUUID().slice(0, 6)}` },
|
||||
{ name: "Other entry order", issuePrefix: `EF${randomUUID().slice(0, 6)}` },
|
||||
]).returning();
|
||||
const [profile, foreignProfile] = await db.insert(toolProfiles).values([
|
||||
{ companyId: company.id, profileKey: "ordered", name: "Ordered" },
|
||||
{ companyId: foreign.id, profileKey: "foreign", name: "Foreign" },
|
||||
]).returning();
|
||||
const [application] = await db.insert(toolApplications).values({
|
||||
companyId: company.id, name: "Order fixture", type: "mcp_http", status: "active",
|
||||
}).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({
|
||||
companyId: company.id, applicationId: application.id, name: "Order fixture",
|
||||
uid: `fixture/${randomUUID()}`, transport: "mcp_remote", status: "active",
|
||||
enabled: true, config: {},
|
||||
}).returning();
|
||||
await db.insert(toolCatalogEntries).values(["allowed", "blocked"].map(toolName => ({
|
||||
companyId: company.id, applicationId: application.id, connectionId: connection.id,
|
||||
name: toolName, toolName, riskLevel: "read" as const, status: "active" as const,
|
||||
versionHash: randomUUID(), schemaHash: randomUUID(),
|
||||
})));
|
||||
// Batch inserts use now() for every row. No entry ordinal exists, and an
|
||||
// exclusion wins regardless of where it appears among matching includes.
|
||||
const tiedAt = new Date("2026-01-02T00:00:00Z");
|
||||
const tied = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||
companyId: company.id, profileId: profile.id, selectorType: "tool_name" as const,
|
||||
effect: index === 29 ? "exclude" as const : "include" as const,
|
||||
toolName: index === 0 ? "allowed" : index === 1 || index === 29 ? "blocked" : `other_${index}`,
|
||||
createdAt: tiedAt,
|
||||
}));
|
||||
const earlier = {
|
||||
...tied[0], id: "ffffffff-ffff-4fff-8fff-ffffffffffff", toolName: "earlier",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
await db.insert(toolProfileEntries).values([
|
||||
...tied.toReversed(), earlier,
|
||||
{ companyId: foreign.id, profileId: foreignProfile.id,
|
||||
selectorType: "tool_name", toolName: "foreign" },
|
||||
]);
|
||||
|
||||
// Change only heap layout in this disposable database. Force sequential
|
||||
// scans so both reads exercise the different layouts, not one index order.
|
||||
await db.execute(sql`SET enable_indexscan = off`);
|
||||
await db.execute(sql`SET enable_bitmapscan = off`);
|
||||
await db.execute(sql`CREATE INDEX entry_order_fixture_desc ON tool_profile_entries (id DESC)`);
|
||||
const service = createToolProfileService(db, () => new Date());
|
||||
const snapshots = [];
|
||||
for (const index of ["tool_profile_entries_pkey", "entry_order_fixture_desc"]) {
|
||||
await db.execute(sql`CLUSTER tool_profile_entries USING ${sql.identifier(index)}`);
|
||||
const physical = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM tool_profile_entries WHERE profile_id = ${profile.id} ORDER BY ctid
|
||||
`);
|
||||
const [listed] = await service.listProfiles(company.id);
|
||||
const detail = await service.getProfile(profile.id, company.id);
|
||||
snapshots.push({ physical: physical.map(row => row.id), listed, detail });
|
||||
}
|
||||
expect(snapshots[0].physical).not.toEqual(snapshots[1].physical);
|
||||
const expectedIds = [earlier.id, ...tied.map(entry => entry.id)];
|
||||
for (const { listed, detail } of snapshots) {
|
||||
expect(listed.entries.map(entry => entry.id)).toEqual(expectedIds);
|
||||
expect(detail).toEqual(listed);
|
||||
expect(listed.summary).toMatchObject({ allowedToolCount: 1, excludedToolCount: 1, totalToolCount: 2 });
|
||||
}
|
||||
expect(snapshots[1].listed).toEqual(snapshots[0].listed);
|
||||
expect(await service.listProfiles(foreign.id)).toMatchObject([{ id: foreignProfile.id }]);
|
||||
await expect(service.getProfile(profile.id, foreign.id)).rejects.toThrow("Tool profile not found");
|
||||
|
||||
// A real entry change must remain visible to configuration comparison.
|
||||
await db.update(toolProfileEntries).set({ effect: "include" }).where(eq(toolProfileEntries.id, tied[29].id));
|
||||
const changed = await service.getProfile(profile.id, company.id);
|
||||
expect(changed.entries).not.toEqual(snapshots[1].detail.entries);
|
||||
expect(changed.summary).toMatchObject({ allowedToolCount: 2, excludedToolCount: 0 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user