mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Reconcile Claude platform packages with merged Codex defaults
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/claude-runner-default: (85 commits) feat: default new Codex agents to Paperclip Runner on Linux and macOS (#15683) docs(skills): add slow-query diagnosis and fix skill (#16303) feat(sentry): add opt-in run failure reporting with saved run references (#16300) refactor(server): isolate provider message admission and runtime policies (#16289) refactor(server): isolate provider webhook lifecycle decisions (#16283) fix(workspaces): keep cleanup Git scans fresh and dedupe page reads (#16302) fix(sentry): keep provider-rejected Grok wakes out of run failure reports (#16297) fix(runner): report Runner disconnect age when a Dot bridge exits (#16296) refactor(server): isolate chat conversation and control policies (#16276) perf(recovery): use indexed execution identity lookups (#16291) perf(runs): bound history and reduce repeated context reads (#16286) fix(native-runtime): report which close barrier failed (#16298) feat(skills): select and validate tracked GitHub branches (#16288) fix(observability): sample native runs independently of triggers (#16285) fix(github): retry and recover review publication (#16284) perf(workspaces): wake terminal cleanup from committed changes (#16282) fix(mcp): deduplicate discovered tool descriptors (#16281) perf: remove history scans from task and chat queries (#16278) docs(skills): add maintenance bot setup workflow (#16279) fix(observability): time execution configuration steps (#16236) ... # Conflicts: # scripts/verify-grok-npm-install.mjs
This commit is contained in:
commit
b030683ca2
1176 files changed
+129461
-261897
No files matched your search
@@ -125,6 +125,8 @@
|
||||
"acpx"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@opentelemetry/context-async-hooks": "2.10.0",
|
||||
"@opentelemetry/sdk-trace": "2.10.0",
|
||||
"@paperclipai/paperclip-runner": "workspace:*",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.0",
|
||||
|
||||
@@ -174,8 +174,6 @@ describe("adapter auth-signal route", () => {
|
||||
effectiveHome: "/tmp/codex-home",
|
||||
sharedSourceHome: "/tmp/codex-shared-home",
|
||||
});
|
||||
// Keep cold route-module transforms in fixture setup, while the unchanged
|
||||
// test deadline measures the HTTP request and its authorization assertions.
|
||||
const [routes, middleware] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
|
||||
@@ -745,8 +745,6 @@ describe("agent routes adapter validation", () => {
|
||||
});
|
||||
|
||||
it("defaults new Codex on a qualified Linux target while the experimental flag is off", async () => {
|
||||
// Exercise route ordering and the real translator with an explicitly qualified target.
|
||||
// Environment/platform discovery has its own company-boundary and platform tests.
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
||||
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
|
||||
|
||||
@@ -77,6 +77,7 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
// idempotent-repeat-login and secret-creation-failure paths.
|
||||
getByName: vi.fn(async (_companyId: string, _name: string) => null as Record<string, unknown> | null),
|
||||
create: vi.fn(async (_companyId: string, _input: Record<string, unknown>) => ({ id: "secret-1" })),
|
||||
hasInstanceAccountHomeClaim: vi.fn(async (_companyId: string, _path: string) => false),
|
||||
// The company-wide secret listing the cleanup-safety scan reads. Empty by
|
||||
// default, so a cleanup that finds no other claimant removes the
|
||||
// directory; a test overrides it with a differently named secret to prove
|
||||
@@ -1404,6 +1405,27 @@ describe("adapter device-login routes", () => {
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps a newly promoted account home claimed by an instance secret when company secret creation fails", async () => {
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted", accountId: "acct-instance-owner", accountHomeDir, accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
mockSecretService.hasInstanceAccountHomeClaim.mockResolvedValueOnce(true);
|
||||
const app = await createApp();
|
||||
const start = await request(app).post(loginPath(COMPANY_1)).send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${start.body.sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
expect(mockSecretService.hasInstanceAccountHomeClaim).toHaveBeenCalledWith(COMPANY_1, accountHomeDir);
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("does not remove an account home a concurrent login already claimed", async () => {
|
||||
// This call created the account home directory (`accountHomeCreated:
|
||||
// true`), but a concurrent login for the same account can still create
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import * as codexAdapter from "@paperclipai/adapter-codex-local/server";
|
||||
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -567,3 +568,6 @@ describe("hired agents sharing a subscription", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -0,0 +1,240 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, createDb, type Db } from "@paperclipai/db";
|
||||
import type { AgentLifecycleRequest } from "@paperclipai/shared";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentInstanceSecretService } from "../services/agent-instance-secrets.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import { createLifecycleDriver } from "../services/agent-lifecycle-driver.js";
|
||||
import { createLifecycleStore } from "../modules/agent-lifecycle/adapters/postgres.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { resolveCodexAuthCacheDir } from "@paperclipai/adapter-codex-local/server";
|
||||
import path from "node:path";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("agent instance secrets use the tenant secret system", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let companyId: string;
|
||||
let pluginId: string;
|
||||
const runtime = JSON.stringify({ url: "https://runtime.example.test", token: "e".repeat(64) });
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-instance-secrets-");
|
||||
db = createDb(database.connectionString);
|
||||
companyId = randomUUID(); pluginId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Secret fixture", issuePrefix: "SCR" });
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "fixture-lifecycle", version: "1.0.0", status: "ready",
|
||||
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
|
||||
}, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); });
|
||||
async function fixture(ownerPluginId = pluginId) {
|
||||
const operationId = randomUUID();
|
||||
const owner = randomUUID();
|
||||
const now = new Date();
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Secret owner", lifecycleState: "preparing", lifecycleVersion: 1,
|
||||
lifecycleRequiredPluginIds: [ownerPluginId], lifecycleOperation: { id: operationId, hostComplete: true, completedPluginIds: [], attempts: 0,
|
||||
leaseOwner: owner, leaseUntil: new Date(now.getTime() + 120_000).toISOString() } }).returning();
|
||||
const request: AgentLifecycleRequest = { companyId, agentId: agent.id, operationId, version: 1, phase: "preparing" };
|
||||
const scope = { companyId, agentId: agent.id, pluginId: ownerPluginId };
|
||||
const native = agentInstanceSecretService(db);
|
||||
const service = { read: native.read, apply: (input: AgentLifecycleRequest, id: string, changes: unknown, attemptOwner = owner) => native.apply(input, id, changes, attemptOwner) };
|
||||
return { agent, request, scope, service, owner };
|
||||
}
|
||||
|
||||
it("persists and rotates native secret versions, survives restart, and isolates agent, company and plugin", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.apply(f.request, pluginId, { runtime });
|
||||
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id));
|
||||
expect(stored).toMatchObject({ scope: "instance", ownerPluginId: pluginId, provider: "local_encrypted", latestVersion: 1 });
|
||||
const versions = await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, stored.id));
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(JSON.stringify(versions)).not.toContain("e".repeat(64));
|
||||
expect(JSON.stringify(versions)).not.toContain("runtime.example.test");
|
||||
expect(await agentInstanceSecretService(createDb(database.connectionString)).read(f.scope)).toEqual({ runtime });
|
||||
for (const scope of [{ ...f.scope, companyId: randomUUID() }, { ...f.scope, agentId: randomUUID() }, { ...f.scope, pluginId: randomUUID() }]) {
|
||||
expect(await f.service.read(scope)).toEqual({});
|
||||
}
|
||||
const replacement = runtime.replace("e".repeat(64), "f".repeat(64));
|
||||
await f.service.apply(f.request, pluginId, { runtime: replacement });
|
||||
expect(await f.service.read(f.scope)).toEqual({ runtime: replacement });
|
||||
expect((await secretService(db).getById(stored.id))!.latestVersion).toBe(2);
|
||||
expect(JSON.stringify(await db.select().from(activityLog).where(eq(activityLog.entityId, f.agent.id)))).not.toContain(replacement);
|
||||
});
|
||||
|
||||
it("excludes instance secrets from company catalogs, ordinary resolution, bindings and agent access", async () => {
|
||||
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
||||
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id));
|
||||
const secrets = secretService(db);
|
||||
expect((await secrets.list(companyId)).some(secret => secret.id === stored.id)).toBe(false);
|
||||
expect(await secrets.getByName(companyId, "runtime")).toBeNull();
|
||||
expect(await secrets.getByKey(companyId, "runtime")).toBeNull();
|
||||
await expect(secrets.resolveSecretValue(companyId, stored.id, "latest")).rejects.toThrow("secret declarations");
|
||||
await expect(secrets.createBinding({ companyId, secretId: stored.id, targetType: "agent", targetId: f.agent.id, configPath: "env.RUNTIME" })).rejects.toThrow("company-scoped");
|
||||
});
|
||||
|
||||
it("fences stale writes and removals, and cascades credentials when the owning agent is deleted", async () => {
|
||||
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
||||
for (const patch of [{ version: 2 }, { operationId: randomUUID() }, { phase: "cleaning_up" }, { companyId: randomUUID() }, { agentId: randomUUID() }]) {
|
||||
await expect(f.service.apply({ ...f.request, ...patch } as AgentLifecycleRequest, pluginId, { runtime: null })).rejects.toThrow("stale or outside scope");
|
||||
}
|
||||
await expect(f.service.apply(f.request, randomUUID(), { runtime })).rejects.toThrow("stale or outside scope");
|
||||
await db.update(agents).set({ lifecycleState: "cleaning_up", lifecycleVersion: 2 }).where(eq(agents.id, f.agent.id));
|
||||
await expect(f.service.apply(f.request, pluginId, { runtime })).rejects.toThrow("stale or outside scope");
|
||||
const cleanup = { ...f.request, version: 2, phase: "cleaning_up" as const };
|
||||
await expect(f.service.apply(cleanup, pluginId, { runtime })).rejects.toThrow("preparation or resume");
|
||||
await f.service.apply(cleanup, pluginId, { runtime: null });
|
||||
expect(await f.service.read(f.scope)).toEqual({});
|
||||
await db.delete(agents).where(eq(agents.id, f.agent.id));
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails without acknowledging storage errors and validates changes before writes", async () => {
|
||||
const f = await fixture();
|
||||
const spy = vi.spyOn(localEncryptedProvider, "createSecret").mockRejectedValueOnce(new Error(runtime));
|
||||
const warnings = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
||||
await expect(f.service.apply(f.request, pluginId, { runtime })).rejects.toThrow("storage unavailable");
|
||||
expect(JSON.stringify(warnings.mock.calls)).not.toContain("e".repeat(64));
|
||||
warnings.mockRestore();
|
||||
spy.mockRestore();
|
||||
expect(await f.service.read(f.scope)).toEqual({});
|
||||
for (const changes of [null, [], { invalid: 1 }, { runtime: "" }, { "bad/key": runtime }, { runtime: "x".repeat(65_537) }]) {
|
||||
await expect(f.service.apply(f.request, pluginId, changes)).rejects.toThrow("Invalid agent instance secret changes");
|
||||
}
|
||||
});
|
||||
|
||||
it("saves the existing local lifecycle reply before completion and supplies secrets on the next invocation", async () => {
|
||||
const f = await fixture();
|
||||
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime } }));
|
||||
const driver = createLifecycleDriver(db, { call } as never);
|
||||
const store = createLifecycleStore(db, {} as never);
|
||||
const snapshot = (await store.get(f.agent.id))!;
|
||||
expect(await driver.runPlugin(snapshot, pluginId)).toBe("complete");
|
||||
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
||||
expect(call.mock.calls[0][2].instanceSecrets).toEqual({});
|
||||
await driver.runPlugin(snapshot, pluginId);
|
||||
expect(call.mock.calls[1][2].instanceSecrets).toEqual({ runtime });
|
||||
call.mockImplementationOnce(async (_id, _method, input) => {
|
||||
await db.update(agents).set({ lifecycleVersion: 2, lifecycleState: "terminating" }).where(eq(agents.id, f.agent.id));
|
||||
return { operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime: "stale credentials" } };
|
||||
});
|
||||
await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("stale or outside scope");
|
||||
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
||||
});
|
||||
|
||||
it("preserves the native guard against missing account-home directories", async () => {
|
||||
const f = await fixture();
|
||||
const missing = path.join(resolveCodexAuthCacheDir(undefined, companyId), "missing-instance-secret-fixture");
|
||||
await expect(f.service.apply(f.request, pluginId, { runtime: missing })).rejects.toThrow("storage unavailable");
|
||||
expect(await f.service.read(f.scope)).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects a previous attempt after lease takeover and rejects an expired owner's writes", async () => {
|
||||
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
||||
const snapshot = (await createLifecycleStore(db, {} as never).get(f.agent.id))!;
|
||||
const successor = randomUUID();
|
||||
await db.update(agents).set({ lifecycleOperation: { ...f.agent.lifecycleOperation!, leaseOwner: successor } }).where(eq(agents.id, f.agent.id));
|
||||
await f.service.apply(f.request, pluginId, { runtime: "successor credentials" }, successor);
|
||||
await expect(f.service.apply(f.request, pluginId, { runtime })).rejects.toThrow("stale or outside scope");
|
||||
await expect(f.service.apply(f.request, pluginId, { runtime: null })).rejects.toThrow("stale or outside scope");
|
||||
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime } }));
|
||||
await expect(createLifecycleDriver(db, { call } as never).runPlugin(snapshot, pluginId)).rejects.toThrow("stale or outside scope");
|
||||
expect(await f.service.read(f.scope)).toEqual({ runtime: "successor credentials" });
|
||||
await db.update(agents).set({ lifecycleOperation: { ...f.agent.lifecycleOperation!, leaseOwner: successor, leaseUntil: new Date(0).toISOString() } }).where(eq(agents.id, f.agent.id));
|
||||
await expect(f.service.apply(f.request, pluginId, { runtime }, successor)).rejects.toThrow("stale or outside scope");
|
||||
expect(await f.service.read(f.scope)).toEqual({ runtime: "successor credentials" });
|
||||
});
|
||||
|
||||
it("deletes live secret metadata and versions with the owning agent or plugin", async () => {
|
||||
for (const deleting of ["agent", "plugin"] as const) {
|
||||
const ownerPluginId = randomUUID();
|
||||
await db.insert(plugins).values({ id: ownerPluginId, pluginKey: ownerPluginId, packageName: "fixture-cascade", version: "1.0.0", status: "ready",
|
||||
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
|
||||
const f = await fixture(ownerPluginId);
|
||||
await f.service.apply(f.request, ownerPluginId, { runtime });
|
||||
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id));
|
||||
expect(await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, stored.id))).toHaveLength(1);
|
||||
if (deleting === "agent") await db.delete(agents).where(eq(agents.id, f.agent.id));
|
||||
else await db.delete(plugins).where(eq(plugins.id, ownerPluginId));
|
||||
expect(await secretService(db).getById(stored.id)).toBeNull();
|
||||
expect(await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, stored.id))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("can clean up after a lost master key without decrypting credentials", async () => {
|
||||
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
||||
const masterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY!;
|
||||
try {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable");
|
||||
await db.update(agents).set({ lifecycleState: "cleaning_up", lifecycleVersion: 2 }).where(eq(agents.id, f.agent.id));
|
||||
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime: null } }));
|
||||
const driver = createLifecycleDriver(db, { call } as never);
|
||||
const snapshot = (await createLifecycleStore(db, {} as never).get(f.agent.id))!;
|
||||
expect(await driver.runPlugin(snapshot, pluginId)).toBe("complete");
|
||||
expect(call.mock.calls[0][2].instanceSecrets).toEqual({});
|
||||
expect(await f.service.read(f.scope)).toEqual({});
|
||||
} finally { vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", masterKey); }
|
||||
});
|
||||
|
||||
it("allows company vault imports with the same name and key as an instance secret", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.apply(f.request, pluginId, { runtime });
|
||||
const secrets = secretService(db);
|
||||
const vault = await secrets.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager", displayName: "Import fixture", config: { region: "us-east-1" },
|
||||
});
|
||||
const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:runtime";
|
||||
const list = vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
||||
secrets: [{ externalRef, name: "runtime", providerVersionRef: null, metadata: {} }],
|
||||
});
|
||||
try {
|
||||
const preview = await secrets.previewRemoteImport(companyId, { providerConfigId: vault.id });
|
||||
expect(preview.candidates[0]).toMatchObject({ status: "ready", importable: true, conflicts: [] });
|
||||
const result = await secrets.importRemoteSecrets(companyId, {
|
||||
providerConfigId: vault.id, secrets: [{ externalRef, name: "runtime", key: "runtime" }],
|
||||
});
|
||||
expect(result).toMatchObject({ importedCount: 1, skippedCount: 0, errorCount: 0 });
|
||||
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
||||
const imported = await secrets.getByName(companyId, "runtime");
|
||||
expect(imported).toMatchObject({ scope: "company", externalRef });
|
||||
await secrets.remove(imported!.id);
|
||||
} finally { list.mockRestore(); }
|
||||
});
|
||||
|
||||
it("protects instance-owned account homes without exposing their values in the company catalog", async () => {
|
||||
const f = await fixture();
|
||||
const accountHome = path.join(resolveCodexAuthCacheDir(undefined, companyId), randomUUID());
|
||||
await mkdir(accountHome, { recursive: true });
|
||||
const secrets = secretService(db);
|
||||
try {
|
||||
await f.service.apply(f.request, pluginId, { account_home: accountHome });
|
||||
expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome)).toBe(true);
|
||||
expect(await secrets.hasInstanceAccountHomeClaim(randomUUID(), accountHome)).toBe(false);
|
||||
expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome + "-other")).toBe(false);
|
||||
expect((await secrets.list(companyId)).some(secret => secret.ownerAgentId === f.agent.id)).toBe(false);
|
||||
const resolve = vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce(new Error("unavailable"));
|
||||
try { expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome)).toBe(true); }
|
||||
finally { resolve.mockRestore(); }
|
||||
await f.service.apply(f.request, pluginId, { account_home: null });
|
||||
expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome)).toBe(false);
|
||||
} finally { await rm(accountHome, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("keeps same-named company and other-agent secrets independent", async () => {
|
||||
const f = await fixture(); const other = await fixture();
|
||||
const companySecret = await secretService(db).create(companyId, { name: "runtime", provider: "local_encrypted", value: "ordinary company secret" });
|
||||
await f.service.apply(f.request, pluginId, { runtime });
|
||||
await other.service.apply(other.request, pluginId, { runtime: "other agent connection" });
|
||||
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
||||
expect(await other.service.read(other.scope)).toEqual({ runtime: "other agent connection" });
|
||||
expect(await secretService(db).resolveSecretValue(companyId, companySecret.id, "latest")).toBe("ordinary company secret");
|
||||
await f.service.apply(f.request, pluginId, { runtime: null });
|
||||
expect(await other.service.read(other.scope)).toEqual({ runtime: "other agent connection" });
|
||||
expect(await secretService(db).getByName(companyId, "runtime")).toMatchObject({ id: companySecret.id });
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => []),
|
||||
buildRunOutputSilence: vi.fn(),
|
||||
decorateActiveRunStatus: vi.fn(),
|
||||
getRunIssueSummary: vi.fn(),
|
||||
@@ -277,6 +278,17 @@ async function requestApp(
|
||||
}
|
||||
|
||||
describe("agent live run routes", () => {
|
||||
it.each([
|
||||
["", 200], ["?limit=10&agentId=agent-1&summary=true", 10],
|
||||
["?limit=99999", 1000], ["?limit=invalid", 200], ["?limit=-10", 1],
|
||||
])("bounds company history reads for %s", async (query, limit) => {
|
||||
const app = await createApp({});
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).get(`/api/companies/company-1/heartbeat-runs${query}`));
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockHeartbeatService.list).toHaveBeenCalledWith("company-1", query.includes("agentId") ? "agent-1" : undefined,
|
||||
limit, { summary: query.includes("summary=true") });
|
||||
expect(mockRunSecretRedactionRegistry.redactForRuns).toHaveBeenCalled();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/agents.js");
|
||||
|
||||
@@ -133,6 +133,7 @@ it("rejects URLs, SVG, corrupt and oversized images, unsupported fields and inac
|
||||
|
||||
it("lets a paired Dot upload without an assignment, with feature and current grant authorization", async () => {
|
||||
const f = await fixture(); const userId = randomUUID(); const clientId = randomUUID();
|
||||
await db.update(agents).set({ adapterConfig: { provider: "openai_dot" } }).where(eq(agents.id, f.agent.id));
|
||||
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await db.insert(mcpOauthClients).values({ id: clientId, name: "Dot", redirectUris: [] });
|
||||
|
||||
@@ -8,7 +8,6 @@ vi.mock("../services/agent-runner-selection.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
|
||||
return {
|
||||
...actual,
|
||||
// Exercise the real selection/translation policy on an explicit target.
|
||||
resolveNewAgentRunnerForCompany: vi.fn(async (...[_db, _companyId, input]: Parameters<typeof actual.resolveNewAgentRunnerForCompany>) =>
|
||||
actual.resolveNewAgentRunner({ ...input, target: runnerTarget })),
|
||||
};
|
||||
@@ -1232,7 +1231,6 @@ describe("agent skill routes", () => {
|
||||
if (native) expect(input.adapterConfig).toMatchObject(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } : { provider: "codex" });
|
||||
const canonicalKey = skill === "paperclip" ? "paperclipai/paperclip/paperclip" : skill;
|
||||
const expected = ["paperclip", "paperclip-board", "paperclip-converting-plans-to-tasks", "paperclip-create-agent", "para-memory-files", "first-task"]
|
||||
// Native PRP supplies the operational contract; role skills and their pins remain assigned.
|
||||
.filter((name) => !native || name !== "paperclip")
|
||||
.map((name) => ({ key: `paperclipai/paperclip/${name}`, versionId: `paperclipai/paperclip/${name}` === canonicalKey ? versionId : null }));
|
||||
const desiredSkills = input.adapterConfig.paperclipSkillSync.desiredSkills.map((entry: string | { key: string; versionId: string | null }) =>
|
||||
|
||||
@@ -81,7 +81,6 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
expect(reviewedLegacy.adapterType).toBe("codex_local");
|
||||
for (const agent of [native, legacy, reviewedLegacy]) {
|
||||
expect(agent).not.toHaveProperty("runner");
|
||||
// Ordinary title changes on an activated agent must not revisit creation defaults.
|
||||
await lifecycle.approveHire(agent.id);
|
||||
await service.update(agent.id, { title: "Updated title" });
|
||||
const saved = await service.getById(agent.id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
@@ -451,3 +452,6 @@ describe("durable, authorized connection routing", () => {
|
||||
} finally { await first.cleanup(); }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -107,6 +107,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
// scheduled method returns; app shutdown must also join those workers.
|
||||
expect(source).toContain("await chatChannels.shutdown()");
|
||||
expect(source).not.toContain("chatChannels.processPendingSlackSessionSyncs()");
|
||||
expect(source).not.toContain("chatChannels.processPendingChatMaintenance()");
|
||||
const service = readFileSync(new URL("../services/chat-channels.ts", import.meta.url), "utf8");
|
||||
const actionMaintenance = service.slice(service.indexOf("async function processPendingChatMaintenance("),
|
||||
service.indexOf("async function processPendingDeliveries("));
|
||||
|
||||
@@ -1810,6 +1810,41 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
expect(await auth.decide({ actor, action: "agent:wake", resource: { ...resource, companyId: otherCompany.id } })).toMatchObject({ allowed: false });
|
||||
});
|
||||
|
||||
it.each(["session", "cloud_tenant"] as const)("restricts %s workspace-owner stop confirmation to administrators", async source => {
|
||||
const company = await createCompany(db, "OwnerStop");
|
||||
const otherCompany = await createCompany(db, "OtherOwnerStop");
|
||||
const userId = await createUser(db);
|
||||
await db.insert(companyMemberships).values({ companyId: company.id,
|
||||
principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
||||
const actor = { type: "board" as const, source, userId, companyIds: [company.id] };
|
||||
const auth = authorizationService(db);
|
||||
const request = { actor, action: "runtime:confirm_owner_stop" as const,
|
||||
resource: { type: "company" as const, companyId: company.id } };
|
||||
for (const membershipRole of ["owner", "admin", "operator", "member", "viewer", null]) {
|
||||
await db.update(companyMemberships).set({ membershipRole }).where(eq(companyMemberships.principalId, userId));
|
||||
expect((await auth.decide(request)).allowed, `role ${membershipRole}`).toBe(membershipRole === "owner" || membershipRole === "admin");
|
||||
}
|
||||
await db.update(companyMemberships).set({ membershipRole: "admin", status: "suspended" }).where(eq(companyMemberships.principalId, userId));
|
||||
expect((await auth.decide(request)).allowed).toBe(false);
|
||||
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.principalId, userId));
|
||||
expect((await auth.decide({ ...request, resource: { type: "company", companyId: otherCompany.id } })).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not allow agent delegation or stale cloud admin rows to confirm an owner stop", async () => {
|
||||
const company = await createCompany(db, "OwnerStopDelegation");
|
||||
const userId = await createUser(db);
|
||||
const agent = await createAgent(db, company.id, { role: "ceo" });
|
||||
await db.insert(companyMemberships).values({ companyId: company.id,
|
||||
principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
|
||||
await db.insert(instanceUserRoles).values({ userId, role: "instance_admin" });
|
||||
const auth = authorizationService(db);
|
||||
const request = { action: "runtime:confirm_owner_stop" as const, resource: { type: "company" as const, companyId: company.id } };
|
||||
expect((await auth.decide({ ...request, actor: { type: "board", userId, source: "cloud_tenant", isInstanceAdmin: false } })).allowed).toBe(false);
|
||||
expect((await auth.decide({ ...request, actor: { type: "agent", agentId: agent.id, companyId: company.id, onBehalfOfUserId: userId } })).allowed).toBe(false);
|
||||
expect((await auth.decide({ ...request, actor: { type: "board", userId, source: "cloud_tenant", isInstanceAdmin: true } })).allowed).toBe(true);
|
||||
expect((await auth.decide({ ...request, actor: { type: "board", source: "local_implicit" } })).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("limits viewer members to read-only visibility actions", async () => {
|
||||
const company = await createCompany(db, "BoardViewerVisibility");
|
||||
const userId = `user-${randomUUID()}`;
|
||||
|
||||
@@ -160,7 +160,6 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Exercise real runner policy on the qualified Linux release target.
|
||||
runnerTarget.platform = "linux";
|
||||
runnerTarget.architecture = "x64";
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
@@ -329,7 +328,6 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
expect(switchedBack.agent).toMatchObject({ adapterType: "codex_local", adapterConfig: savedSettings });
|
||||
const switchedAgain = await svc.ensure(companyId, "briefs", { runner: "paperclip" });
|
||||
expect(switchedAgain.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", ...savedSettings } });
|
||||
// Existing native execution survives editing on an unqualified target.
|
||||
runnerTarget.platform = "win32";
|
||||
runnerTarget.architecture = "x64";
|
||||
const edited = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } });
|
||||
@@ -340,7 +338,6 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
const companyId = await seedCompany({ requireApproval: true });
|
||||
const result = await builtInAgentService(db).provision(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, { requestedByUserId: "board-user" });
|
||||
expect(result.approval?.payload).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } });
|
||||
// Restart and approval activate the reviewed config even if eligibility changed.
|
||||
runnerTarget.platform = "win32";
|
||||
runnerTarget.architecture = "x64";
|
||||
await reconcileBuiltInAgentsOnStartup(db);
|
||||
@@ -350,6 +347,18 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
expect(approved.agent?.adapterConfig).toEqual(result.approval!.payload.adapterConfig);
|
||||
});
|
||||
|
||||
it("records the human who enabled a built-in agent and leaves automatic creation unattributed", async () => {
|
||||
const enabledCompanyId = await seedCompany({ requireApproval: false });
|
||||
const automaticCompanyId = await seedCompany({ requireApproval: false });
|
||||
const builtIns = builtInAgentService(db);
|
||||
|
||||
const enabled = await builtIns.provision(enabledCompanyId, "briefs", {}, { requestedByUserId: "board-user" });
|
||||
const automatic = await builtIns.provision(automaticCompanyId, "briefs");
|
||||
|
||||
expect(enabled.state.agent?.createdByUserId).toBe("board-user");
|
||||
expect(automatic.state.agent?.createdByUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("routes policy-gated built-in provisioning through a pending hire approval", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const builtIns = builtInAgentService(db);
|
||||
@@ -391,7 +400,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
const rowsBeforeApproval = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(rowsBeforeApproval).toHaveLength(1);
|
||||
expect(rowsBeforeApproval[0]).toMatchObject({ status: "pending_approval" });
|
||||
expect(rowsBeforeApproval[0]).toMatchObject({ status: "pending_approval", createdByUserId: "board-user" });
|
||||
|
||||
await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).rejects.toMatchObject({
|
||||
status: 412,
|
||||
|
||||
@@ -20,6 +20,10 @@ function setup() {
|
||||
coordinators.push(coordinator);
|
||||
const service = {
|
||||
processFailedChatRunRetries: vi.fn(async () => {}),
|
||||
processPendingTelegramActions: vi.fn(async () => {}),
|
||||
processTelegramSubscriptionDiscovery: vi.fn(async () => {}),
|
||||
scheduleTelegramSubscriptionDiscovery: vi.fn(),
|
||||
hasPendingTelegramSubscriptionDiscovery: vi.fn(() => false),
|
||||
processPendingProviderEffects: vi.fn(async () => {}),
|
||||
processPendingGitHubWebhookIngress: vi.fn(async () => {}),
|
||||
processPendingSlackSessionSyncs: vi.fn(async () => {}),
|
||||
@@ -31,17 +35,40 @@ function setup() {
|
||||
nextChatActionAt: vi.fn(async (): Promise<number | null> => null),
|
||||
nextVerificationMessageAt: vi.fn(async (): Promise<number | null> => null),
|
||||
};
|
||||
const runs = [service.processFailedChatRunRetries, service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts,
|
||||
const runs = [service.processFailedChatRunRetries, service.processPendingTelegramActions, service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts,
|
||||
service.processPendingReceiptReactions, service.processPendingSlackSessionStops, service.processPendingVerificationMessages];
|
||||
return { owner, service, runs, coordinator, enabled, onError, start: () => registerChatActionWork(coordinator, service, enabled) };
|
||||
}
|
||||
describe("chat action scheduling", () => {
|
||||
it("retains configuration commits during discovery and retries discovery failures", async () => {
|
||||
const s = setup();
|
||||
let release!: () => void;
|
||||
s.service.processTelegramSubscriptionDiscovery.mockImplementationOnce(() => new Promise<void>(resolve => { release = resolve; }));
|
||||
const work = s.start();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(1);
|
||||
await s.owner.transaction(tx => notifyChatEndpointWork(tx, "connection-fixture"));
|
||||
expect(s.service.scheduleTelegramSubscriptionDiscovery).toHaveBeenCalledWith("connection-fixture");
|
||||
s.service.processTelegramSubscriptionDiscovery.mockRejectedValueOnce(new Error("discovery unavailable"));
|
||||
release();
|
||||
await work.ready;
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(2);
|
||||
expect(s.onError).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(3);
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(3);
|
||||
expect(s.coordinator.nextWakeAt()).toBeNull();
|
||||
});
|
||||
it("does one startup scan per lane and no more SQL or timers during an idle hour", async () => {
|
||||
const s = setup();
|
||||
await s.start().ready;
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(8);
|
||||
expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(9);
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(1);
|
||||
expect(s.service.scheduleTelegramSubscriptionDiscovery).not.toHaveBeenCalled();
|
||||
expect(s.service.nextVerificationMessageAt).toHaveBeenCalledTimes(1);
|
||||
expect(s.coordinator.nextWakeAt()).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
@@ -58,9 +85,12 @@ describe("chat action scheduling", () => {
|
||||
await s.owner.transaction(tx => notifyChatVerificationWork(tx));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(2);
|
||||
await s.owner.transaction(tx => notifyChatEndpointWork(tx));
|
||||
// Queue claims/settlements do not rediscover every configured endpoint.
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(1);
|
||||
await s.owner.transaction(tx => notifyChatEndpointWork(tx, "connection-fixture"));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(3);
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it("restores durable deadlines and reschedules when a foreground send moves a retry earlier", async () => {
|
||||
@@ -101,6 +131,7 @@ describe("chat action scheduling", () => {
|
||||
await s.start().ready;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(s.service.nextChatActionAt).not.toHaveBeenCalled();
|
||||
expect(s.service.processTelegramSubscriptionDiscovery).not.toHaveBeenCalled();
|
||||
for (const run of s.runs) expect(run).not.toHaveBeenCalled();
|
||||
s.enabled.mockReturnValue(true);
|
||||
s.service.processPendingSlackTaskStarts.mockRejectedValueOnce(new Error("DB unavailable"));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import * as activityLogModule from "../services/activity-log.js";
|
||||
import * as connectionCredentialBindings from "../services/connection-credential-bindings.js";
|
||||
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
|
||||
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js";
|
||||
import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
@@ -13,7 +15,7 @@ import { fastResponseService } from "../services/fast-responses.js";
|
||||
import { fastResponseReceipt } from "../services/fast-response-provider.js";
|
||||
import { fastResponseRequests } from "@paperclipai/db";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { toolActionRequests, toolInvocations } from "@paperclipai/db";
|
||||
import { toolActionRequests, toolInvocations, toolPolicies } from "@paperclipai/db";
|
||||
import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { githubChatWizardService } from "../services/chat-github-wizard.js";
|
||||
@@ -2420,7 +2422,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const saved = await f.management.configuration(f.endpoint.id, "owner-user");
|
||||
await f.management.saveConfiguration(f.endpoint.id, { expectedRevision: saved.revision, configuration: {
|
||||
...saved.configuration, defaults: { ...saved.configuration.defaults,
|
||||
instructions: `Use ${ref(common)} and ${ref(foreign)}.`,
|
||||
instructions: `Use ${ref(common)}.`,
|
||||
prompts: { ...saved.configuration.defaults.prompts, mention: `Use ${ref(mention)}.`, opened: ref(injected) },
|
||||
},
|
||||
} }, "owner-user");
|
||||
@@ -2450,6 +2452,15 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect(await resolveRunScopedMentionedSkillKeys(args)).toEqual([common.key, mention.key]);
|
||||
expect(await resolveRunScopedMentionedSkillKeys({ ...args, commentIds: all.map((c) => c.id) })).toEqual(expect.arrayContaining([common.key, mention.key, next.key]));
|
||||
expect(await resolveRunScopedMentionedSkillKeys({ ...args, commentIds: [] })).toEqual([next.key]);
|
||||
const invalid = await f.management.configuration(f.endpoint.id, "owner-user");
|
||||
await f.management.saveConfiguration(f.endpoint.id, { expectedRevision: invalid.revision, configuration: {
|
||||
...invalid.configuration, defaults: { ...invalid.configuration.defaults, instructions: ref(foreign) },
|
||||
} }, "owner-user");
|
||||
// Instructions are captured on admission; changing configuration must
|
||||
// not change the already admitted wake above.
|
||||
await deliverMessage({ callbacks: f.callbacks, endpointId: f.endpoint.id, provider: "github", thread,
|
||||
trigger: "subscribed_message", message: makeMessage({ id: "41903", text: 'Check again', userId: "42", userName: "octocat" }) });
|
||||
await expect(resolveRunScopedMentionedSkillKeys({ ...args, commentIds: [] })).rejects.toThrow("referenced skill is unavailable");
|
||||
});
|
||||
it.each([
|
||||
"github:paperclipai/paperclip:issue:419",
|
||||
@@ -2525,8 +2536,14 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect(f.wakeup).toHaveBeenCalledTimes(wakeCount);
|
||||
expect(await readIssueCommentsByIssueId(f.conversation.issueId!)).toHaveLength(1);
|
||||
});
|
||||
async function toolReplyFixture(threadId = "github:paperclipai/paperclip:issue:418") {
|
||||
async function toolReplyFixture(threadId = "github:paperclipai/paperclip:issue:418", reportOnly = false) {
|
||||
const f = await reviewBotFixture();
|
||||
if (reportOnly) {
|
||||
const config = await f.management.configuration(f.endpoint.id, "owner-user");
|
||||
await f.management.saveConfiguration(f.endpoint.id, { expectedRevision: config.revision,
|
||||
configuration: { ...config.configuration, defaults: { ...config.configuration.defaults, ratingThreshold: null } },
|
||||
}, "owner-user");
|
||||
}
|
||||
const thread = makeThread({ channelId: "paperclipai/paperclip", id: threadId }).thread;
|
||||
await deliverMessage({ callbacks: f.callbacks, endpointId: f.endpoint.id, provider: "github", thread,
|
||||
trigger: "mention", message: makeMessage({ id: "41801", text: "Are you there?", userId: "42", userName: "octocat", mentioned: true }) });
|
||||
@@ -2538,6 +2555,302 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const session = { companyId: f.companyId, agentId: f.assignedAgentId, issueId: conversation.issueId, runId: run.id };
|
||||
return { ...f, conversation, run, session };
|
||||
}
|
||||
async function publicationRetryFixture() {
|
||||
const f = await toolReplyFixture("github:paperclipai/paperclip:418", true);
|
||||
const head = "b".repeat(40);
|
||||
const inline: Array<{ id: number; body: string; html_url: string; user: { login: string } }> = [];
|
||||
const checks: Array<{ id: number; external_id: string; app: { id: number }; html_url: string }> = [];
|
||||
const writes: Array<{ path: string; method: string }> = [];
|
||||
const provider = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/pulls/418")) return Response.json({ number: 418, state: "open", draft: false,
|
||||
head: { sha: head }, base: { sha: "a".repeat(40), ref: "master" }, user: { id: 42, login: "octocat", type: "User" }, labels: [] });
|
||||
if (url.includes("/files?")) return Response.json([{ filename: "src/math.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }]);
|
||||
if (url.includes("/pulls/418/comments?")) return Response.json(inline);
|
||||
if (url.includes("/check-runs?")) return Response.json({ check_runs: checks });
|
||||
if (init?.method === "POST" && url.endsWith("/pulls/418/comments")) {
|
||||
writes.push({ path: url, method: init.method });
|
||||
const item = { id: 420 + inline.length, body: JSON.parse(String(init.body)).body,
|
||||
html_url: "https://github.com/acme/repo/pull/418#discussion_r420", user: { login: f.endpoint.botUsername! } };
|
||||
inline.push(item);
|
||||
return Response.json(item);
|
||||
}
|
||||
if (url.includes("/check-runs") && init?.method) {
|
||||
writes.push({ path: url, method: init.method });
|
||||
const body = JSON.parse(String(init.body));
|
||||
const check = checks[0] ?? { id: 419, external_id: body.external_id, app: { id: Number(f.endpoint.botExternalId) }, html_url: "https://github.com/checks/419" };
|
||||
if (!checks.length) checks.push(check);
|
||||
return Response.json(check);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
f.setSupplementalProviderFetch(provider);
|
||||
const service = githubChatReviewService(db, f.providerFetch);
|
||||
await service.execute(f.session, "begin_review", { reviewedCommit: head });
|
||||
const assessment = { reviewedCommit: head, score: 3, complete: true, summary: "Advisory review", rationale: "Reviewed the file",
|
||||
coverage: { reviewedPaths: ["src/math.ts"], omittedPaths: [], limitations: [] }, findings: [] };
|
||||
const action = async () => (await db.select().from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "github_review_publication"),
|
||||
)))[0];
|
||||
return { ...f, service, head, assessment, provider, action, inline, checks, writes };
|
||||
}
|
||||
it.each([false, true])("recovers an approved queued GitHub review without another approval (revoked: %s)", async revoked => {
|
||||
const f = await publicationRetryFixture();
|
||||
let unavailable = true;
|
||||
let attempts = 0;
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
if (String(input).includes("/issues/comments/") && init?.method === "PATCH") {
|
||||
attempts++;
|
||||
if (unavailable) return new Response("Unavailable", { status: 503, headers: { "retry-after": "120" } });
|
||||
}
|
||||
return f.provider(input, init);
|
||||
});
|
||||
vi.stubGlobal("fetch", f.providerFetch);
|
||||
onTestFinished(() => { vi.unstubAllGlobals(); });
|
||||
await initializeRunIdentity(db, { companyId: f.companyId, runId: f.run.id, issueId: f.conversation.issueId!,
|
||||
responsibleUserId: null, cause: "company_default" });
|
||||
const { createToolGatewayService } = await import("../services/tool-gateway.js");
|
||||
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "github-approval-retry-test-secret" });
|
||||
const session = await gateway.createSession({ companyId: f.companyId, agentId: f.assignedAgentId, runId: f.run.id });
|
||||
const tool = (await githubBotToolsForSession(db, f.session)).find(tool => tool.upstreamToolName === "submit_review")!;
|
||||
await db.insert(toolPolicies).values({ companyId: f.companyId, name: "Review publication approval", policyType: "require_approval",
|
||||
priority: 0, selectors: { catalogEntryId: tool.catalogEntryId! } });
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment }))
|
||||
.rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [approval] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.companyId));
|
||||
expect(await gateway.approveActionRequest({ companyId: f.companyId, actionRequestId: approval.id, actor: { userId: "owner-user" } }))
|
||||
.toMatchObject({ status: "executed" });
|
||||
const action = await f.action();
|
||||
expect(action).toMatchObject({ status: "failed", result: { retryable: true } });
|
||||
unavailable = false;
|
||||
if (revoked) await db.insert(toolPolicies).values({ companyId: f.companyId, name: "Revoke approved publication", policyType: "block",
|
||||
priority: -1, selectors: { catalogEntryId: tool.catalogEntryId! } });
|
||||
await db.update(chatActions).set({ result: { ...action.result, retryAt: new Date(0).toISOString() } }).where(eq(chatActions.id, action.id));
|
||||
await f.service.processPending();
|
||||
expect((await f.action()).status).toBe(revoked ? "cancelled" : "processed");
|
||||
if (revoked) await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment })).rejects.toThrow();
|
||||
else expect(await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment }))
|
||||
.toMatchObject({ status: "replayed", result: { data: { status: "processed", score: 3 } } });
|
||||
expect(attempts).toBe(revoked ? 1 : 2);
|
||||
expect(await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.companyId))).toHaveLength(1);
|
||||
}, 25_000);
|
||||
it("recovers lost inline and check responses without duplicate GitHub publications", async () => {
|
||||
const f = await publicationRetryFixture();
|
||||
const lost = new Set<string>();
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
const response = await f.provider(input, init);
|
||||
if (init?.method === "POST" && /\/(?:comments|check-runs)$/.test(String(input)) && !lost.has(String(input))) {
|
||||
lost.add(String(input));
|
||||
throw new Error("Provider accepted the write but the response was lost");
|
||||
}
|
||||
return response;
|
||||
});
|
||||
const result = await f.service.execute(f.session, "submit_review", { ...f.assessment,
|
||||
findings: [{ key: "note", path: "src/math.ts", line: 1, side: "RIGHT", severity: "error", category: "correctness", body: "Example finding" }],
|
||||
}, "lost-receipts");
|
||||
expect(result).toMatchObject({ status: "processed", score: 3, conclusion: "neutral" });
|
||||
expect(f.inline).toHaveLength(1);
|
||||
expect(f.checks).toHaveLength(1);
|
||||
expect(f.writes.filter(write => write.method === "POST")).toHaveLength(2);
|
||||
expect(f.responseComments.size).toBe(1);
|
||||
expect((await f.action()).status).toBe("processed");
|
||||
}, 25_000);
|
||||
it("resumes a delayed GitHub publication after coordinator restart without another inbound event", async () => {
|
||||
const f = await publicationRetryFixture();
|
||||
let unavailable = true;
|
||||
let attempts = 0;
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
if (String(input).includes("/issues/comments/") && init?.method === "PATCH") {
|
||||
attempts++;
|
||||
if (unavailable) return new Response("Unavailable", { status: 503, headers: { "retry-after": "120" } });
|
||||
}
|
||||
return f.provider(input, init);
|
||||
});
|
||||
const result = await f.service.execute(f.session, "submit_review", f.assessment, "scheduled-review");
|
||||
expect(result).toMatchObject({ status: "failed", receipt: { retryable: true, attempts: 1 } });
|
||||
const action = await f.action();
|
||||
expect(await f.service.nextPendingAt()).toBe(Date.parse(String(action.result?.retryAt)));
|
||||
expect(await f.service.processPending()).toBe(0);
|
||||
const due = new Date(Date.now() + 1500);
|
||||
await db.update(chatActions).set({ result: { ...action.result, retryAt: due.toISOString() } }).where(eq(chatActions.id, action.id));
|
||||
unavailable = false;
|
||||
const { createDeliveryWorkCoordinator } = await import("../services/delivery-work-coordinator.js");
|
||||
const { registerChatQueueWork } = await import("../services/chat-delivery-work.js");
|
||||
const { DELIVERY_QUEUES } = await import("../services/delivery-work-notifications.js");
|
||||
const restarted = githubChatReviewService(db, f.providerFetch);
|
||||
const errors = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: errors });
|
||||
try {
|
||||
await registerChatQueueWork(coordinator, { queue: DELIVERY_QUEUES.chatGitHubPublications,
|
||||
run: restarted.processPending, next: restarted.nextPendingAt }, () => true).ready;
|
||||
expect(coordinator.nextWakeAt()).toBe(due.getTime());
|
||||
expect(attempts).toBe(1);
|
||||
await expect.poll(async () => (await f.action()).status, { timeout: 10_000 }).toBe("processed");
|
||||
expect(attempts).toBe(2);
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
expect(await restarted.nextPendingAt()).toBeNull();
|
||||
expect(f.responseComments.size).toBe(1);
|
||||
} finally {
|
||||
await coordinator.stop();
|
||||
}
|
||||
}, 25_000);
|
||||
it.each([403, 422])("does not resubmit a permanent GitHub publication rejection (HTTP %s)", async status => {
|
||||
const f = await publicationRetryFixture();
|
||||
let attempts = 0;
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
if (String(input).includes("/issues/comments/") && init?.method === "PATCH") {
|
||||
attempts++;
|
||||
return new Response("Rejected", { status });
|
||||
}
|
||||
return f.provider(input, init);
|
||||
});
|
||||
const result = await f.service.execute(f.session, "submit_review", f.assessment, "permanent-failure");
|
||||
expect(result).toMatchObject({ status: "failed", receipt: { retryable: false, attempts: 1, providerStatus: status } });
|
||||
await f.service.execute(f.session, "submit_review", f.assessment, "permanent-failure");
|
||||
expect(await f.service.processPending()).toBe(0);
|
||||
expect(await f.service.nextPendingAt()).toBeNull();
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
it.each(["revoked", "exhausted"] as const)("stops a queued GitHub publication after %s", async mode => {
|
||||
const f = await publicationRetryFixture();
|
||||
let attempts = 0;
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
if (String(input).includes("/issues/comments/") && init?.method === "PATCH") {
|
||||
attempts++;
|
||||
return new Response("Unavailable", { status: 503, headers: { "retry-after": "120" } });
|
||||
}
|
||||
return f.provider(input, init);
|
||||
});
|
||||
await f.service.execute(f.session, "submit_review", f.assessment, "bounded-retry");
|
||||
const action = await f.action();
|
||||
await db.update(chatActions).set({ result: { ...action.result, retryAt: new Date(0).toISOString() } }).where(eq(chatActions.id, action.id));
|
||||
if (mode === "revoked") await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
for (let attempt = 1; attempt < (mode === "exhausted" ? 8 : 2); attempt++) {
|
||||
const current = await f.action();
|
||||
await db.update(chatActions).set({ result: { ...current.result, retryAt: new Date(0).toISOString() } }).where(eq(chatActions.id, current.id));
|
||||
await f.service.processPending();
|
||||
}
|
||||
await f.service.processPending();
|
||||
expect((await f.action()).result?.retryable).toBe(false);
|
||||
expect((await f.action()).status).toBe(mode === "revoked" ? "cancelled" : "failed");
|
||||
expect(await f.service.nextPendingAt()).toBeNull();
|
||||
expect(attempts).toBe(mode === "exhausted" ? 8 : 1);
|
||||
});
|
||||
it.each([503, 429])("automatically retries a transient GitHub review publication (HTTP %s)", async providerStatus => {
|
||||
const f = await toolReplyFixture("github:paperclipai/paperclip:418", true);
|
||||
const head = "b".repeat(40);
|
||||
let summaryAttempts = 0;
|
||||
const checks: Record<string, unknown>[] = [];
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/pulls/418")) return Response.json({ number: 418, state: "open", draft: false,
|
||||
head: { sha: head }, base: { sha: "a".repeat(40), ref: "master" }, user: { id: 42, login: "octocat", type: "User" }, labels: [] });
|
||||
if (url.includes("/files?")) return Response.json([{ filename: "src/math.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }]);
|
||||
if (url.includes("/check-runs?")) return Response.json({ check_runs: [] });
|
||||
if (url.includes("/check-runs") && init?.method) {
|
||||
checks.push(JSON.parse(String(init.body)));
|
||||
return Response.json({ id: 419, html_url: "https://github.com/checks/419" });
|
||||
}
|
||||
if (url.includes("/issues/comments/") && init?.method === "PATCH" && ++summaryAttempts === 1)
|
||||
return new Response("Temporary provider failure", { status: providerStatus, headers: { "retry-after": "1" } });
|
||||
return undefined;
|
||||
});
|
||||
vi.stubGlobal("fetch", f.providerFetch);
|
||||
onTestFinished(() => { vi.unstubAllGlobals(); });
|
||||
const { createToolGatewayService } = await import("../services/tool-gateway.js");
|
||||
const gateway = createToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: f.companyId, agentId: f.assignedAgentId, runId: f.run.id });
|
||||
const tool = (await githubBotToolsForSession(db, f.session)).find(tool => tool.upstreamToolName === "submit_review")!;
|
||||
await githubChatReviewService(db, f.providerFetch).execute(f.session, "begin_review", { reviewedCommit: head });
|
||||
const assessment = { reviewedCommit: head, score: 3, complete: true, summary: "Advisory review", rationale: "Reviewed the file",
|
||||
coverage: { reviewedPaths: ["src/math.ts"], omittedPaths: [], limitations: [] }, findings: [] };
|
||||
const idempotencyKey = randomUUID();
|
||||
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: assessment, idempotencyKey });
|
||||
expect(result).toMatchObject({ status: "completed", result: { data: { status: "processed", score: 3, conclusion: "neutral" } } });
|
||||
expect(summaryAttempts).toBe(2);
|
||||
expect(f.responseComments.size).toBe(1);
|
||||
expect(checks).toEqual([expect.objectContaining({ conclusion: "neutral", status: "completed" })]);
|
||||
const replay = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: assessment, idempotencyKey });
|
||||
expect(replay).toMatchObject({ result: { data: { status: "processed", score: 3, conclusion: "neutral" } } });
|
||||
expect(summaryAttempts).toBe(2);
|
||||
const [action] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "github_review_publication")));
|
||||
expect(action).toMatchObject({ status: "processed" });
|
||||
expect((await readChatGitHubReviewsByEndpointId(f.endpoint.id))).toHaveLength(1);
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { ...assessment, score: 5 }, idempotencyKey }))
|
||||
.rejects.toMatchObject({ status: 409, reasonCode: "github_idempotency_conflict" });
|
||||
await db.insert(toolPolicies).values({ companyId: f.companyId, name: "Revoke review publication", policyType: "block", priority: 0,
|
||||
selectors: { catalogEntryId: tool.catalogEntryId! },
|
||||
});
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: assessment, idempotencyKey })).rejects.toThrow();
|
||||
expect(summaryAttempts).toBe(2);
|
||||
}, 25_000);
|
||||
it("lists each GitHub bot tool once when an inline thread shares the task", async () => {
|
||||
const f = await toolReplyFixture("github:paperclipai/paperclip:418");
|
||||
const { id: _id, ...conversation } = f.conversation;
|
||||
await db.insert(chatConversations).values({
|
||||
...conversation,
|
||||
externalThreadId: "github:paperclipai/paperclip:418:rc:41901",
|
||||
providerUrl: "https://github.com/paperclipai/paperclip/pull/418#discussion_r41901",
|
||||
});
|
||||
const tools = await githubBotToolsForSession(db, f.session);
|
||||
expect(tools).toHaveLength(7);
|
||||
expect(new Set(tools.map(tool => tool.name)).size).toBe(7);
|
||||
expect(await githubBotToolsForSession(db, { ...f.session, issueId: randomUUID() })).toEqual([]);
|
||||
expect(await readChatConversationsByEndpointId(f.endpoint.id)).toHaveLength(2);
|
||||
});
|
||||
it.each([
|
||||
{ label: "returns the confirmed GitHub assessment when publication takes longer than ten seconds", delayMs: 11_000, timeoutMs: undefined },
|
||||
{ label: "preserves an explicit shorter GitHub tool deadline and reconciles without duplicate publication", delayMs: 200, timeoutMs: 50 },
|
||||
])("recovers a durable GitHub publication receipt ($label)", async ({ delayMs, timeoutMs }) => {
|
||||
const f = await toolReplyFixture("github:paperclipai/paperclip:418", true);
|
||||
const head = "b".repeat(40);
|
||||
const checks: Record<string, unknown>[] = [];
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/pulls/418")) return Response.json({ number: 418, state: "open", draft: false,
|
||||
head: { sha: head }, base: { sha: "a".repeat(40), ref: "master" }, user: { id: 42, login: "octocat", type: "User" }, labels: [] });
|
||||
if (url.includes("/files?")) return Response.json([{ filename: "src/math.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }]);
|
||||
if (url.includes("/check-runs?")) return Response.json({ check_runs: [] });
|
||||
if (url.includes("/check-runs") && init?.method) {
|
||||
checks.push(JSON.parse(String(init.body)));
|
||||
return Response.json({ id: 419, html_url: "https://github.com/checks/419" });
|
||||
}
|
||||
if (url.includes("/issues/comments/") && init?.method === "PATCH") {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
vi.stubGlobal("fetch", f.providerFetch);
|
||||
onTestFinished(() => { vi.unstubAllGlobals(); });
|
||||
const { createToolGatewayService } = await import("../services/tool-gateway.js");
|
||||
const gateway = createToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: f.companyId, agentId: f.assignedAgentId, runId: f.run.id });
|
||||
const tools = await githubBotToolsForSession(db, f.session);
|
||||
const tool = tools.find(tool => tool.upstreamToolName === "submit_review")!;
|
||||
await githubChatReviewService(db, f.providerFetch).execute(f.session, "begin_review", { reviewedCommit: head });
|
||||
const assessment = { reviewedCommit: head, score: 5, complete: true, summary: "Looks good", rationale: "Reviewed the changed file",
|
||||
coverage: { reviewedPaths: ["src/math.ts"], omittedPaths: [], limitations: [] }, findings: [] };
|
||||
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: assessment, timeoutMs })
|
||||
.then(value => ({ value }), error => ({ error }));
|
||||
await expect.poll(async () => {
|
||||
const [action] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "github_review_publication")));
|
||||
return action?.status;
|
||||
}, { timeout: 15_000 }).toBe("processed");
|
||||
expect(f.responseComments.size).toBe(1);
|
||||
expect(f.responseWrites.map(write => write.method)).toEqual(["POST", "PATCH"]);
|
||||
expect(checks).toContainEqual(expect.objectContaining({ conclusion: "neutral", status: "completed" }));
|
||||
if (timeoutMs === undefined) {
|
||||
expect(result).toMatchObject({ value: { status: "completed", result: { data: { status: "processed", score: 5, conclusion: "neutral" } } } });
|
||||
} else {
|
||||
expect(result).toMatchObject({ error: { status: 504, reasonCode: "tool_timeout" } });
|
||||
}
|
||||
const writes = f.responseWrites.length;
|
||||
const replay = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: assessment });
|
||||
expect(replay).toMatchObject({ result: { data: { status: "processed", score: 5, conclusion: "neutral" } } });
|
||||
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, replay.invocationId));
|
||||
expect(invocation).toMatchObject({ status: "succeeded", errorCode: null, errorMessage: null });
|
||||
expect(f.responseWrites).toHaveLength(writes);
|
||||
}, 40_000);
|
||||
it("does not post a GitHub working comment when the scheduler declines the request", async () => {
|
||||
const f = await reviewBotFixture();
|
||||
f.wakeup.mockImplementation(async (agentId, opts) => {
|
||||
@@ -2720,6 +3033,36 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect(f.responseComments.size).toBe(1);
|
||||
expect(f.runtime.endpoints.get(f.endpoint.id)?.posts).toEqual([]);
|
||||
});
|
||||
it.each([false, true])("finishes the same GitHub working comment after an authorized same-App reconnect (legacy: %s)", async legacy => {
|
||||
const f = await toolReplyFixture();
|
||||
const originalId = f.responseWrites[0].id;
|
||||
if (legacy) await db.update(chatActions).set({ payload: { runtimeGeneration: f.endpoint.setup.runtimeGeneration ?? 0, agentId: f.assignedAgentId } })
|
||||
.where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "github_response_comment")));
|
||||
await f.service.configure(f.endpoint.id, { action: "reconnect" }, "owner-user");
|
||||
const result = await githubChatReviewService(db, f.providerFetch).execute(f.session, "comment", {
|
||||
body: "Review complete after reconnect", idempotencyKey: "reconnected-final",
|
||||
});
|
||||
expect(result.status).toBe("processed");
|
||||
expect(f.responseWrites.map(write => write.method)).toEqual(["POST", "PATCH"]);
|
||||
expect(f.responseWrites.every(write => write.id === originalId)).toBe(true);
|
||||
expect([...f.responseComments.values()][0].body).toContain("Review complete after reconnect");
|
||||
});
|
||||
it("still fences a GitHub publication when reconnect races the provider read", async () => {
|
||||
const f = await toolReplyFixture();
|
||||
let reconnected = false;
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
if (!reconnected && String(input).includes("/issues/comments/") && (!init?.method || init.method === "GET")) {
|
||||
reconnected = true;
|
||||
await f.service.configure(f.endpoint.id, { action: "reconnect" }, "owner-user");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const result = await githubChatReviewService(db, f.providerFetch).execute(f.session, "comment", {
|
||||
body: "Stale attempt", idempotencyKey: "reconnect-race",
|
||||
});
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(f.responseWrites).toHaveLength(1);
|
||||
});
|
||||
it("adds the progress tool to an existing enabled bot without restoring disabled tools", async () => {
|
||||
const f = await toolReplyFixture();
|
||||
await db.delete(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, f.endpoint.connectionId), eq(toolCatalogEntries.name, "github_bot:update_comment")));
|
||||
@@ -4067,6 +4410,53 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect((await context.service.listResources(context.endpoint.id)).every(resource => !resource.enabled)).toBe(true);
|
||||
expect(await management.configuration(context.endpoint.id, "owner-user")).toMatchObject({ revision: 1, configuration: { toolsEnabled: false } });
|
||||
});
|
||||
it.each([
|
||||
["active", "current", "enabled"], ["active", "foreign", "enabled"], ["active", "invalid_signature", "enabled"], ["active", "missing_installation", "enabled"],
|
||||
["attention", "current", "enabled"], ["attention", "foreign", "enabled"], ["attention", "invalid_signature", "enabled"], ["attention", "missing_installation", "enabled"],
|
||||
["attention", "current", "disabled"], ["attention", "current", "unavailable"],
|
||||
] as const)("verifies a repaired GitHub webhook in %s only from a %s installation delivery with %s repository", async (status, kind, resourceState) => {
|
||||
const f = await reviewBotFixture();
|
||||
const [endpoint] = await readChatEndpointsById(f.endpoint.id);
|
||||
if (resourceState !== "enabled") {
|
||||
await db.update(chatEndpointResources).set(resourceState === "disabled" ? { enabled: false } : { availability: "unavailable" }).where(eq(chatEndpointResources.endpointId, endpoint.id));
|
||||
}
|
||||
if (status === "attention") await db.update(toolConnections).set({ status: "disabled", enabled: false }).where(eq(toolConnections.id, endpoint.connectionId));
|
||||
const resourcesBefore = await f.service.listResources(endpoint.id);
|
||||
const conversationsBefore = await readChatConversationsByEndpointId(endpoint.id);
|
||||
const actionsBefore = await db.select().from(chatActions).where(eq(chatActions.endpointId, endpoint.id));
|
||||
f.wakeup.mockClear();
|
||||
await db.update(chatEndpoints).set({ status, setup: { ...endpoint.setup, webhookVerifiedAt: null } }).where(eq(chatEndpoints.id, endpoint.id));
|
||||
const response = await f.service.handleWebhook(endpoint.publicId, "github", signedGitHubWebhookRequest({
|
||||
event: "issue_comment", delivery: randomUUID(), webhookSecret: kind === "invalid_signature" ? "wrong-secret" : f.webhookSecret,
|
||||
payload: { action: "created", ...(kind === "missing_installation" ? {} : { installation: { id: kind === "foreign" ? 9999 : 2468 } }),
|
||||
repository: { id: 97531, full_name: "paperclipai/paperclip", owner: { id: 1357, login: "paperclipai" } },
|
||||
issue: { number: 418 }, comment: { id: 41801, body: status === "attention" ? `@${endpoint.botUsername} please review` : "Ordinary comment", user: { id: 42, login: "octocat" } }, sender: { id: 42, login: "octocat" } },
|
||||
}));
|
||||
if (kind === "invalid_signature") expect(response.status).toBe(401);
|
||||
else expect(response.status).toBeLessThan(300);
|
||||
const current = await f.service.get(endpoint.id);
|
||||
if (kind === "current") expect(current.setup.webhookVerifiedAt).toEqual(expect.any(String));
|
||||
else expect(current.setup.webhookVerifiedAt).toBeNull();
|
||||
if (status === "attention") {
|
||||
expect(current.status).toBe("attention");
|
||||
expect(await f.service.listResources(endpoint.id)).toEqual(resourcesBefore);
|
||||
expect(await readChatConversationsByEndpointId(endpoint.id)).toEqual(conversationsBefore);
|
||||
expect(await db.select().from(chatActions).where(eq(chatActions.endpointId, endpoint.id))).toEqual(actionsBefore);
|
||||
expect(f.wakeup).not.toHaveBeenCalled();
|
||||
if (kind === "current") {
|
||||
expect(current.setup.step).toBe("provider_setup");
|
||||
await f.service.handleWebhook(endpoint.publicId, "github", signedGitHubWebhookRequest({
|
||||
event: "installation_repositories", delivery: randomUUID(), webhookSecret: f.webhookSecret,
|
||||
payload: { action: "added", installation: { id: 2468 }, repositories_added: [], repositories_removed: [] },
|
||||
}));
|
||||
expect((await f.service.get(endpoint.id)).status).toBe("attention");
|
||||
expect((await readToolConnectionsById(endpoint.connectionId))[0]).toMatchObject({ status: "disabled", enabled: false });
|
||||
expect(f.wakeup).not.toHaveBeenCalled();
|
||||
const configured = await f.service.configure(endpoint.id, { action: "reconnect" }, "owner-user");
|
||||
expect(configured.status).toBe("verifying");
|
||||
}
|
||||
}
|
||||
});
|
||||
it("stack rejects forged raw gateway deliveries without changing verification or waking an agent", async () => {
|
||||
const f = await reviewBotFixture();
|
||||
await db
|
||||
@@ -4911,6 +5301,37 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
})]);
|
||||
expect(await readChatGitHubReviewsByEndpointId(f.endpoint.id)).toHaveLength(0);
|
||||
});
|
||||
it.each(["succeeded", "failed", "cancelled"] as const)("keeps a report-only GitHub check neutral after a %s run without an assessment", async status => {
|
||||
const f = await toolReplyFixture("github:paperclipai/paperclip:418");
|
||||
const config = await f.management.configuration(f.endpoint.id, "owner-user");
|
||||
await f.management.saveConfiguration(f.endpoint.id, { expectedRevision: config.revision,
|
||||
configuration: { ...config.configuration, defaults: { ...config.configuration.defaults, ratingThreshold: null } },
|
||||
}, "owner-user");
|
||||
const event = githubAutomaticReviewEvent({ action: "opened", repository: { id: 97531, full_name: "paperclipai/paperclip" }, sender: { id: 42, login: "octocat" },
|
||||
pull_request: { number: 418, title: "Review", body: "", draft: false, base: { sha: "a".repeat(40), ref: "master" }, head: { sha: "b".repeat(40) }, user: { id: 42, login: "octocat" }, labels: [] } }, randomUUID())!;
|
||||
const [review] = await db.insert(chatGitHubReviews).values({ companyId: f.companyId, endpointId: f.endpoint.id,
|
||||
issueId: f.conversation.issueId!, runId: f.run.id, repositoryId: "97531", repository: event.repository, pullNumber: 418, headSha: event.headSha,
|
||||
deliveryId: event.deliveryId, configurationRevision: config.revision + 1, policySnapshot: { ...config.configuration.defaults, ratingThreshold: null }, event }).returning();
|
||||
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
f.setSupplementalProviderFetch(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/pulls/418")) return Response.json({ head: { sha: event.headSha } });
|
||||
if (url.includes("/check-runs?")) return Response.json({ check_runs: [] });
|
||||
if (url.endsWith("/check-runs") && init?.method === "POST") {
|
||||
writes.push(JSON.parse(String(init.body)));
|
||||
return Response.json({ id: 418, html_url: "https://github.com/checks/418" });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const checks = githubReviewCheckService(db, f.providerFetch);
|
||||
const [endpoint] = await readChatEndpointsById(f.endpoint.id);
|
||||
await checks.enqueue(endpoint, event, true, "authorized");
|
||||
await checks.processPending();
|
||||
expect(writes).toEqual([expect.objectContaining({ status: "completed", conclusion: "neutral" })]);
|
||||
expect(writes[0].output).toMatchObject({ title: status === "succeeded" ? "Agent finished without a complete assessment" : "Review execution did not complete" });
|
||||
expect((await db.select().from(chatGitHubReviews).where(eq(chatGitHubReviews.id, review.id)))[0].conclusion).toBe("neutral");
|
||||
});
|
||||
it("uses task-bound bot tools and deterministic checks, then denies revoked people", async () => {
|
||||
const publicOrigin = vi.spyOn(cloudRuntimeIdentity, "runtimePublicOrigin").mockReturnValue("https://current-vanity.example");
|
||||
onTestFinished(() => publicOrigin.mockRestore());
|
||||
@@ -4982,6 +5403,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
for (const comment of f.responseComments.values()) comments.set(comment.id, comment);
|
||||
let head = "b".repeat(40);
|
||||
let check = { id: 99, status: "completed", external_id: `${f.endpoint.id}:91:${head}`, app: { id: Number(f.endpoint.botExternalId) } };
|
||||
// Other bots can publish the same named check on this PR. Neither another
|
||||
// App nor a different endpoint's review may be selected for our updates.
|
||||
const peerCheck = { ...check, id: 9901, app: { id: Number(f.endpoint.botExternalId) + 1 } };
|
||||
const otherEndpointCheck = { ...check, id: 9902, external_id: `other-bot:91:${head}` };
|
||||
const pull = () => ({
|
||||
number: 91,
|
||||
title: "Test PR",
|
||||
@@ -5052,7 +5477,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
},
|
||||
]);
|
||||
if (url.includes("/check-runs?"))
|
||||
return Response.json({ check_runs: [check] });
|
||||
return Response.json({ check_runs: [peerCheck, otherEndpointCheck, check] });
|
||||
return Response.json([]);
|
||||
});
|
||||
const service = githubChatReviewService(db, f.providerFetch);
|
||||
@@ -5140,6 +5565,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
conclusion: "failure",
|
||||
details_url: `https://current-vanity.example/${company.issuePrefix}/issues/${conversation.issueId}`,
|
||||
});
|
||||
const postedSummary = mutations.find((m) => typeof m.body.body === "string" && m.body.body.includes("Addition subtracts"));
|
||||
expect(postedSummary?.body.body).toContain(`## ${f.endpoint.botLabel} — 2/5`);
|
||||
const publishedCheck = mutations.find((m) => m.body.conclusion === "failure");
|
||||
expect(publishedCheck?.url).toBe(`https://api.github.com/repos/paperclipai/paperclip/check-runs/${check.id}`);
|
||||
expect(mutations.some((m) => /\/check-runs\/990[12]$/.test(m.url))).toBe(false);
|
||||
const publishedCount = mutations.length;
|
||||
const retried = await service.execute(
|
||||
session,
|
||||
@@ -8020,7 +8450,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await service.shutdown();
|
||||
});
|
||||
|
||||
it.each([false, true])("requires at-mentions by default for new Slack connections (DM=%s)", async (isDM) => {
|
||||
it.each([false, true])("requires at-mentions by default only in Slack channels (DM=%s)", async (isDM) => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { requireAtMention: true });
|
||||
// The returned endpoint is the original creation result, before fixture updates.
|
||||
@@ -8030,12 +8460,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const send = (id: string, mentioned: boolean) => deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
|
||||
message: makeMessage({ id, text: mentioned ? "@maya start" : "Start", mentioned }), trigger: isDM ? "direct_message" : mentioned ? "mention" : "subscribed_message" });
|
||||
await send("7500.1", false);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
expect(wakeup).toHaveBeenCalledTimes(isDM ? 1 : 0);
|
||||
await send("7500.2", true);
|
||||
expect(wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(wakeup).toHaveBeenCalledTimes(isDM ? 2 : 1);
|
||||
await service.update(endpoint.id, { requireAtMention: false });
|
||||
await send("7500.3", false);
|
||||
expect(wakeup).toHaveBeenCalledTimes(2);
|
||||
expect(wakeup).toHaveBeenCalledTimes(isDM ? 3 : 2);
|
||||
const other = await service.create(fixture.companyId, { provider: "github", assignedAgentId: fixture.assignedAgentId });
|
||||
expect(other.requireAtMention).toBe(false);
|
||||
expect((await service.get(endpoint.id)).requireAtMention).toBe(false);
|
||||
@@ -8063,11 +8493,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await expect(resolveSlackTaskAuthority(db, binding)).resolves.toMatchObject({ slackUserId: "UBOARD" });
|
||||
});
|
||||
|
||||
it.each([false, true])("requires Slack at-mentions for every message when enabled (DM=%s)", async (isDM) => {
|
||||
it.each([false, true])("applies Slack mention settings to channel replies while exempting DMs (DM=%s)", async (isDM) => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture);
|
||||
expect((await service.get(endpoint.id)).requireAtMention).toBe(false);
|
||||
const thread = makeThread({ channelId: isDM ? "D-MENTION" : "C-MENTION", id: `slack:${isDM ? "D-MENTION" : "C-MENTION"}:7100.1`, isDM });
|
||||
const thread = makeThread({ channelId: isDM ? "D-MENTION" : "C-MENTION", id: isDM ? "slack:D-MENTION:" : "slack:C-MENTION:7100.1", isDM });
|
||||
const send = (id: string, mentioned: boolean, trigger: ChatSdkMessageTrigger = isDM ? "direct_message" : "subscribed_message") =>
|
||||
deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
|
||||
message: makeMessage({ id, text: mentioned ? "@maya continue" : "Continue", mentioned }), trigger });
|
||||
@@ -8076,47 +8506,53 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await send("7100.2", false);
|
||||
expect(wakeup).toHaveBeenCalledTimes(2);
|
||||
expect((await service.update(endpoint.id, { requireAtMention: true })).requireAtMention).toBe(true);
|
||||
await send("7100.3", false);
|
||||
expect(wakeup).toHaveBeenCalledTimes(2);
|
||||
await send("7100.3", false, "subscribed_message");
|
||||
expect(wakeup).toHaveBeenCalledTimes(isDM ? 3 : 2);
|
||||
const [filtered] = await db.select().from(chatDeliveries).where(and(
|
||||
eq(chatDeliveries.endpointId, endpoint.id), eq(chatDeliveries.state, "filtered")));
|
||||
expect(filtered.redactedError).toBe("Message did not @mention the bot");
|
||||
expect(filtered.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } });
|
||||
expect(filtered.normalizedEvent).not.toHaveProperty("message.text");
|
||||
if (isDM) {
|
||||
expect(filtered).toBeUndefined();
|
||||
} else {
|
||||
expect(filtered.redactedError).toBe("Message did not @mention the bot");
|
||||
expect(filtered.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } });
|
||||
expect(filtered.normalizedEvent).not.toHaveProperty("message.text");
|
||||
}
|
||||
// Both Slack callback kinds can carry an explicit mention. Retries cannot
|
||||
// produce a second wakeup for the same provider message.
|
||||
await send("7100.4", true);
|
||||
await send("7100.4", true, "mention");
|
||||
expect(wakeup).toHaveBeenCalledTimes(3);
|
||||
expect(wakeup).toHaveBeenCalledTimes(isDM ? 4 : 3);
|
||||
const conversations = await readChatConversationsByEndpointId(endpoint.id);
|
||||
expect(conversations).toHaveLength(1);
|
||||
const comments = await readIssueCommentsByIssueId(conversations[0].issueId);
|
||||
expect(comments).toHaveLength(3);
|
||||
expect(comments).toHaveLength(isDM ? 4 : 3);
|
||||
expect(conversations[0].externalThreadId).toBe(thread.thread.id);
|
||||
await service.update(endpoint.id, { requireAtMention: false });
|
||||
await send("7100.5", false);
|
||||
expect(wakeup).toHaveBeenCalledTimes(4);
|
||||
expect(wakeup).toHaveBeenCalledTimes(isDM ? 5 : 4);
|
||||
if (isDM) {
|
||||
await service.update(endpoint.id, { requireAtMention: true, allowDirectMessages: false });
|
||||
await send("7100.6", true);
|
||||
expect(wakeup).toHaveBeenCalledTimes(4);
|
||||
await send("7100.6", false);
|
||||
expect(wakeup).toHaveBeenCalledTimes(5);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters a queued Slack message when Require at-mention is enabled before drain", async () => {
|
||||
it.each([false, true])("rechecks queued Slack channel mentions but exempts DMs before drain (DM=%s)", async (isDM) => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, {
|
||||
deferWebhookProcessing: true, scheduleDeferredWork: () => undefined,
|
||||
});
|
||||
const thread = makeThread({ channelId: "D-QUEUED-MENTION", id: "slack:D-QUEUED-MENTION:7300.1", isDM: true });
|
||||
const channelId = isDM ? "D-QUEUED-MENTION" : "C-QUEUED-MENTION";
|
||||
const thread = makeThread({ channelId, id: `slack:${channelId}:${isDM ? "" : "7300.1"}`, isDM });
|
||||
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
|
||||
message: makeMessage({ id: "7300.1", text: "Start work" }), trigger: "direct_message" });
|
||||
message: makeMessage({ id: "7300.1", text: "Start work" }), trigger: isDM ? "direct_message" : "subscribed_message" });
|
||||
await service.update(endpoint.id, { requireAtMention: true });
|
||||
const [delivery] = await readChatDeliveriesByEndpointId(endpoint.id);
|
||||
await db.update(chatDeliveries).set({ nextAttemptAt: new Date(0) }).where(eq(chatDeliveries.id, delivery.id));
|
||||
await service.processPendingDeliveries(25, delivery.id);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(0);
|
||||
expect((await readChatDeliveriesById(delivery.id))[0].state).toBe("filtered");
|
||||
expect(wakeup).toHaveBeenCalledTimes(isDM ? 1 : 0);
|
||||
expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(isDM ? 1 : 0);
|
||||
expect((await readChatDeliveriesById(delivery.id))[0].state).toBe(isDM ? "processed" : "filtered");
|
||||
});
|
||||
|
||||
it("snapshots channel workspace defaults on new tasks and preserves them on replies", async () => {
|
||||
@@ -12891,6 +13327,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await configured.service.shutdown();
|
||||
});
|
||||
|
||||
it("leaves Cloud-managed GitHub webhook recovery to the relay", async () => {
|
||||
const context = await githubPreIngressRecoveryFixture();
|
||||
try {
|
||||
const [endpoint] = await readChatEndpointsById(context.endpoint.id);
|
||||
await db.update(chatEndpoints).set({ setup: { ...endpoint.setup,
|
||||
github: { ...endpoint.setup.github, cloudRegistrationId: "cloud-registration" },
|
||||
} }).where(eq(chatEndpoints.id, endpoint.id));
|
||||
const checkpoint = await context.checkpoint();
|
||||
await db.update(chatSdkState).set({ value: { ...(checkpoint.value as Record<string, unknown>), outcome: "callback_mismatch" } }).where(eq(chatSdkState.id, checkpoint.id));
|
||||
await context.makeScanDue();
|
||||
await context.service.processFailedGitHubWebhookDeliveries(5, endpoint.id);
|
||||
expect((await context.service.listActivity(endpoint.id)).some(row => row.summary === "GitHub webhook recovery needs attention")).toBe(false);
|
||||
expect(context.requests).toEqual([]);
|
||||
expect(context.posts).toEqual([]);
|
||||
} finally { await context.service.shutdown(); }
|
||||
});
|
||||
|
||||
it("automatically recovers a pre-ingress GitHub failure only through a genuine callback and normal durable workers", async () => {
|
||||
const context = await githubPreIngressRecoveryFixture();
|
||||
const { service, endpoint } = context;
|
||||
@@ -33055,7 +33508,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
try {
|
||||
for (const kind of Object.keys(CHAT_ACTION_QUEUES) as (keyof typeof CHAT_ACTION_QUEUES)[]) {
|
||||
const [action] = await db.insert(chatActions).values({ companyId: f.companyId, endpointId: lane.endpoint.id,
|
||||
kind, providerActionId: randomUUID(), status: "received", updatedAt: at }).returning();
|
||||
kind, providerActionId: randomUUID(), status: "received", updatedAt: at,
|
||||
...(kind === "telegram_maintenance" ? { payload: { operation: "register_commands" } } : {}) }).returning();
|
||||
const set = (status: string, result: Record<string, unknown> = {}) => db.update(chatActions)
|
||||
.set({ status, result, updatedAt: at }).where(eq(chatActions.id, action!.id));
|
||||
const due = async () => db.select({ id: chatActions.id }).from(chatActions)
|
||||
@@ -69067,6 +69521,72 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["not-a-uuid", "", "uppercase"])("skips a pre-run FIFO candidate with a noncanonical coalesced owner id (%s)", async (ownerId) => {
|
||||
const context = await deferredChatQueueFixture();
|
||||
try {
|
||||
const [receipt] = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, context.action.id));
|
||||
await db
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
payload: {
|
||||
...receipt.payload,
|
||||
coalescedIntoWakeupRequestId: ownerId === "uppercase" ? context.action.id.toUpperCase() : ownerId,
|
||||
},
|
||||
})
|
||||
.where(eq(agentWakeupRequests.id, context.action.id));
|
||||
await expect(
|
||||
context.service.enqueueInboundWakeupPublications(),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(eq(chatPublications.endpointId, context.endpoint.id)),
|
||||
).resolves.toEqual([]);
|
||||
} finally {
|
||||
await context.service.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the pre-run FIFO sweep running when a valid owner's action has comment ids that are not uuids", async () => {
|
||||
const context = await deferredChatQueueFixture();
|
||||
try {
|
||||
// Publish the queued notice first. The removed-comment and queued-notice
|
||||
// checks then have real rows to compare the malformed ids against.
|
||||
await context.service.processPendingPublications();
|
||||
await expect(
|
||||
db
|
||||
.select({ state: chatPublications.state })
|
||||
.from(chatPublications)
|
||||
.where(eq(chatPublications.endpointId, context.endpoint.id)),
|
||||
).resolves.toEqual([{ state: "published" }]);
|
||||
await db
|
||||
.update(chatActions)
|
||||
.set({
|
||||
payload: {
|
||||
...context.action.payload,
|
||||
issueId: "not-a-uuid",
|
||||
commentId: "not-a-uuid",
|
||||
},
|
||||
})
|
||||
.where(eq(chatActions.id, context.action.id));
|
||||
await expect(
|
||||
context.service.enqueueInboundWakeupPublications(),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
db
|
||||
.select({ state: chatPublications.state })
|
||||
.from(chatPublications)
|
||||
.where(eq(chatPublications.endpointId, context.endpoint.id)),
|
||||
).resolves.toEqual([{ state: "published" }]);
|
||||
} finally {
|
||||
await context.service.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("never promises a queued follow-up before durable admission succeeds", async () => {
|
||||
const context = await deferredChatQueueFixture(true);
|
||||
try {
|
||||
@@ -70629,6 +71149,195 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it.each(["startup", "connection_commit", "rollback", "retry", "stale_claim"] as const)(
|
||||
"recovers Telegram maintenance from committed work and becomes SQL quiet: %s",
|
||||
async mode => {
|
||||
const lane = await draftFixture();
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
const maintenance = () => db.select().from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, lane.endpoint.id), eq(chatActions.kind, "telegram_maintenance"),
|
||||
sql`${chatActions.payload}->>'operation' = 'stop_subscription'`,
|
||||
));
|
||||
try {
|
||||
await db.delete(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id),
|
||||
eq(chatActions.kind, "telegram_stop_subscription")));
|
||||
lane.setSubscriptionInfo({});
|
||||
if (mode === "connection_commit" || mode === "rollback") {
|
||||
await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: false });
|
||||
}
|
||||
if (mode === "stale_claim") {
|
||||
// Leave committed legacy work behind before starting the workers.
|
||||
await lane.context.service.processTelegramSubscriptionDiscovery();
|
||||
const [action] = await maintenance();
|
||||
expect(action).toBeDefined();
|
||||
await db.update(chatActions).set({ status: "processing", updatedAt: new Date(Date.now() - 120_000) })
|
||||
.where(eq(chatActions.id, action!.id));
|
||||
}
|
||||
let failedAt = 0;
|
||||
let sends = 0;
|
||||
if (mode === "retry") lane.setMaintenanceHook(async method => {
|
||||
if (method === "setWebhook" && sends++ === 0) {
|
||||
failedAt = Date.now();
|
||||
throw new Error("temporary subscription transport outage");
|
||||
}
|
||||
if (method === "setWebhook") expect(Date.now() - failedAt).toBeGreaterThanOrEqual(1000);
|
||||
return undefined;
|
||||
});
|
||||
await registerChatActionWork(coordinator, lane.context.service, () => true).ready;
|
||||
if (mode === "connection_commit" || mode === "rollback") {
|
||||
expect(lane.maintenanceRequests).toEqual([]);
|
||||
if (mode === "rollback") {
|
||||
await expect(db.transaction(async tx => {
|
||||
await toolAccessService(tx as unknown as Db).updateConnection(lane.endpoint.connectionId, { enabled: true });
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
expect(lane.maintenanceRequests).toEqual([]);
|
||||
throw new Error("rollback connection wake");
|
||||
})).rejects.toThrow("rollback connection wake");
|
||||
await vi.waitFor(() => {
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 10_000 });
|
||||
expect(lane.maintenanceRequests).toEqual([]);
|
||||
expect(await maintenance()).toEqual([]);
|
||||
}
|
||||
await db.transaction(async tx => {
|
||||
await toolAccessService(tx as unknown as Db).updateConnection(lane.endpoint.connectionId, { enabled: true });
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
expect(lane.maintenanceRequests).toEqual([]);
|
||||
});
|
||||
}
|
||||
await vi.waitFor(async () => {
|
||||
const actions = await maintenance();
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]?.status).toBe("processed");
|
||||
expect(actions[0]?.result.attempts).toBe(mode === "retry" ? 2 : 1);
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 10_000 });
|
||||
expect(lane.maintenanceRequests.filter(x => x.method === "setWebhook")).toHaveLength(mode === "retry" ? 2 : 1);
|
||||
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id),
|
||||
eq(chatActions.kind, "telegram_stop_subscription"), eq(chatActions.status, "processed")))).toHaveLength(1);
|
||||
const scans = vi.spyOn(db, "select");
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(scans).not.toHaveBeenCalled();
|
||||
} finally { scans.mockRestore(); }
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally { await coordinator.stop(); await lane.close(); }
|
||||
},
|
||||
);
|
||||
|
||||
it("commits chat connection credentials atomically before waking maintenance", async () => {
|
||||
const lane = await draftFixture();
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
const bindings = () => db.select({ secretId: companySecretBindings.secretId, configPath: companySecretBindings.configPath })
|
||||
.from(companySecretBindings).where(and(eq(companySecretBindings.companyId, lane.fixture.companyId),
|
||||
eq(companySecretBindings.targetType, "tool_connection"), eq(companySecretBindings.targetId, lane.endpoint.connectionId)))
|
||||
.orderBy(asc(companySecretBindings.configPath));
|
||||
let release!: () => void;
|
||||
let entered!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const rebuilding = new Promise<void>(resolve => { entered = resolve; });
|
||||
let update: Promise<unknown> | undefined;
|
||||
const original = connectionCredentialBindings.syncConnectionCredentialBindings;
|
||||
const spy = vi.spyOn(connectionCredentialBindings, "syncConnectionCredentialBindings");
|
||||
try {
|
||||
await db.delete(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id),
|
||||
eq(chatActions.kind, "telegram_stop_subscription")));
|
||||
lane.setSubscriptionInfo({});
|
||||
await lane.context.service.processTelegramSubscriptionDiscovery();
|
||||
await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: false });
|
||||
await registerChatActionWork(coordinator, lane.context.service, () => true).ready;
|
||||
const before = await bindings();
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
spy.mockImplementation(async (database, connection, refs, client = database) => {
|
||||
if (connection.id === lane.endpoint.connectionId) {
|
||||
// Hold the real binding rebuild after its delete boundary. Another
|
||||
// connection must still see the old bindings until outer commit.
|
||||
await client.delete(companySecretBindings).where(and(eq(companySecretBindings.companyId, connection.companyId),
|
||||
eq(companySecretBindings.targetType, "tool_connection"), eq(companySecretBindings.targetId, connection.id)));
|
||||
entered();
|
||||
await held;
|
||||
}
|
||||
return original(database, connection, refs, client);
|
||||
});
|
||||
update = toolAccessService(db).updateConnection(lane.endpoint.connectionId, { name: "Renamed fixture", enabled: true });
|
||||
await rebuilding;
|
||||
expect(await bindings()).toEqual(before);
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
expect(lane.maintenanceRequests).toEqual([]);
|
||||
release();
|
||||
await update;
|
||||
await vi.waitFor(async () => {
|
||||
expect(await db.select({ status: chatActions.status }).from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, lane.endpoint.id), eq(chatActions.kind, "telegram_maintenance"),
|
||||
sql`${chatActions.payload}->>'operation' = 'stop_subscription'`,
|
||||
))).toEqual([{ status: "processed" }]);
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
}, { timeout: 10_000 });
|
||||
expect(await bindings()).toEqual(before);
|
||||
expect(lane.maintenanceRequests.filter(x => x.method === "setWebhook")).toHaveLength(1);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally { release(); await update; spy.mockRestore(); await coordinator.stop(); await lane.close(); }
|
||||
});
|
||||
|
||||
it.each(["endpoint", "connection"] as const)("parks pending Telegram subscription repair while its %s is inactive", async inactive => {
|
||||
const lane = await draftFixture();
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
const actions = () => db.select().from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, lane.endpoint.id), eq(chatActions.kind, "telegram_maintenance"),
|
||||
sql`${chatActions.payload}->>'operation' = 'stop_subscription'`,
|
||||
));
|
||||
try {
|
||||
await db.delete(chatActions).where(and(eq(chatActions.endpointId, lane.endpoint.id),
|
||||
eq(chatActions.kind, "telegram_stop_subscription")));
|
||||
lane.setSubscriptionInfo({});
|
||||
await lane.context.service.processTelegramSubscriptionDiscovery();
|
||||
expect(await actions()).toEqual([expect.objectContaining({ status: "received" })]);
|
||||
if (inactive === "endpoint") {
|
||||
await lane.context.service.configure(lane.endpoint.id, { action: "pause" }, "owner-user");
|
||||
} else {
|
||||
await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: false });
|
||||
}
|
||||
await registerChatActionWork(coordinator, lane.context.service, () => true).ready;
|
||||
await vi.waitFor(() => {
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 10_000 });
|
||||
expect(await lane.context.service.nextChatActionAt("telegram_maintenance")).toBeNull();
|
||||
expect(lane.maintenanceRequests).toEqual([]);
|
||||
expect(await actions()).toEqual([expect.objectContaining({ status: "received" })]);
|
||||
const scans = vi.spyOn(db, "select");
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(scans).not.toHaveBeenCalled();
|
||||
} finally { scans.mockRestore(); }
|
||||
if (inactive === "endpoint") {
|
||||
await lane.context.service.configure(lane.endpoint.id, { action: "resume" }, "owner-user");
|
||||
} else {
|
||||
await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { enabled: true });
|
||||
}
|
||||
await vi.waitFor(async () => {
|
||||
const rows = await actions();
|
||||
expect(rows.filter(row => row.status === "processed")).toEqual([
|
||||
expect.objectContaining({ result: expect.objectContaining({ attempts: 1 }) }),
|
||||
]);
|
||||
// Endpoint pause/resume advances its ownership generation. The old
|
||||
// obligation must settle without touching the provider again.
|
||||
expect(rows.filter(row => row.status !== "processed")).toEqual(inactive === "endpoint" ? [
|
||||
expect.objectContaining({ status: "cancelled", result: expect.objectContaining({ code: "telegram_maintenance_credentials_superseded" }) }),
|
||||
] : []);
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 10_000 });
|
||||
expect(lane.maintenanceRequests.filter(x => x.method === "setWebhook")).toHaveLength(1);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally { await coordinator.stop(); await lane.close(); }
|
||||
});
|
||||
|
||||
it("durably stops during the first draft RPC without cancelling a run, and never reuses its ID after restart", async () => {
|
||||
const lane = await draftFixture();
|
||||
try {
|
||||
@@ -70844,11 +71553,14 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["explicit", "empty", "omitted"] as const)(
|
||||
it.each(["explicit", "empty", "omitted", "worker"] as const)(
|
||||
"automatically repairs an existing %s subscription after restart without dropping updates",
|
||||
async (shape) => {
|
||||
async (mode) => {
|
||||
const shape = mode === "worker" ? "explicit" : mode;
|
||||
const lane = await draftFixture();
|
||||
const prefixIds: string[] = [];
|
||||
const onError = vi.fn();
|
||||
const coordinator = mode === "worker" ? createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError }) : null;
|
||||
try {
|
||||
// Scheduler-only fixtures model a full already-confirmed first page.
|
||||
// They have no credentials; no provider qualification is claimed for
|
||||
@@ -70856,7 +71568,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const [templateEndpoint] = await readChatEndpointsById(lane.endpoint.id);
|
||||
const [templateConnection] = await readToolConnectionsById(lane.endpoint.connectionId);
|
||||
const fingerprint = createHash("sha256").update("[]").digest("hex");
|
||||
for (let index = 0; index < 25; index++) {
|
||||
for (let index = 0; index < (mode === "worker" ? 105 : 25); index++) {
|
||||
const id = `00000000-${randomUUID().slice(9)}`;
|
||||
expect(id < lane.endpoint.id).toBe(true);
|
||||
const connectionId = randomUUID();
|
||||
@@ -70953,7 +71665,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const targetIndex = eligible.findIndex(
|
||||
({ id }) => id === lane.endpoint.id,
|
||||
);
|
||||
expect(targetIndex).toBeGreaterThanOrEqual(25);
|
||||
expect(targetIndex).toBeGreaterThanOrEqual(mode === "worker" ? 105 : 25);
|
||||
const discoveryPasses = Math.floor(targetIndex / 25) + 1;
|
||||
const targetActions = () =>
|
||||
db
|
||||
@@ -70966,7 +71678,17 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
sql`${chatActions.payload}->>'operation' = 'stop_subscription'`,
|
||||
),
|
||||
);
|
||||
for (let page = 1; page <= discoveryPasses; page++) {
|
||||
if (coordinator) {
|
||||
await registerChatActionWork(coordinator, lane.context.service, () => true).ready;
|
||||
// Startup recovery uses successive bounded pages. The target is
|
||||
// beyond a complete 100-row confirmed prefix and is not in page one.
|
||||
expect(await targetActions()).toEqual([]);
|
||||
await vi.waitFor(async () => {
|
||||
expect((await targetActions())[0]?.status).toBe("processed");
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
}, { timeout: 30_000 });
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} else for (let page = 1; page <= discoveryPasses; page++) {
|
||||
await lane.context.service.processPendingDeliveries();
|
||||
if (page < discoveryPasses) {
|
||||
expect(await targetActions()).toEqual([]);
|
||||
@@ -71019,6 +71741,21 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
({ method }) => method === "setWebhook",
|
||||
);
|
||||
expect(mutations).toHaveLength(1);
|
||||
if (coordinator) {
|
||||
// A committed edit must not rediscover another connection, even
|
||||
// when its durable proof is absent. Startup covers that inventory.
|
||||
const foreignEndpoint = prefixIds[0]!;
|
||||
await db.delete(chatActions).where(and(eq(chatActions.endpointId, foreignEndpoint),
|
||||
eq(chatActions.kind, "telegram_stop_subscription")));
|
||||
await toolAccessService(db).updateConnection(lane.endpoint.connectionId, { name: "Scoped Telegram edit" });
|
||||
await vi.waitFor(() => {
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
}, { timeout: 10_000 });
|
||||
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, foreignEndpoint),
|
||||
eq(chatActions.kind, "telegram_maintenance")))).toEqual([]);
|
||||
expect(lane.maintenanceRequests.filter(({ method }) => method === "setWebhook")).toHaveLength(1);
|
||||
}
|
||||
expect(mutations[0]!.body).toEqual({
|
||||
url: lane.configuredWebhook.url,
|
||||
secret_token: lane.configuredWebhook.secret_token,
|
||||
@@ -71065,6 +71802,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
try {
|
||||
await coordinator?.stop();
|
||||
if (prefixIds.length)
|
||||
await db
|
||||
.update(chatEndpoints)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signal
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { registerChatDeliveryWork } from "../services/chat-delivery-work.js";
|
||||
import { notifyChatDeliveryWork, notifyChatPublicationWork } from "../services/chat-work-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../services/delivery-work-notifications.js";
|
||||
|
||||
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
|
||||
afterEach(async () => {
|
||||
@@ -27,6 +28,8 @@ function setup() {
|
||||
nextInboundDeliveryAt: vi.fn(async (): Promise<number | null> => null),
|
||||
nextPublicationAt: vi.fn(async (): Promise<number | null> => null),
|
||||
nextSlackReceiptAt: vi.fn(async (): Promise<number | null> => null),
|
||||
processPendingGitHubReviewPublications: vi.fn(async () => {}),
|
||||
nextGitHubReviewPublicationAt: vi.fn(async (): Promise<number | null> => null),
|
||||
onPublicationsSettled: vi.fn(),
|
||||
};
|
||||
const start = () => registerChatDeliveryWork(coordinator, service, enabled);
|
||||
@@ -38,7 +41,7 @@ describe("chat delivery work", () => {
|
||||
const s = setup();
|
||||
await s.start().ready;
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
for (const run of [s.service.processQueuedDeliveries, s.service.scheduleQueuedPublications, s.service.processPendingSlackFileUploadReceipts]) expect(run).toHaveBeenCalledTimes(1);
|
||||
for (const run of [s.service.processQueuedDeliveries, s.service.scheduleQueuedPublications, s.service.processPendingSlackFileUploadReceipts, s.service.processPendingGitHubReviewPublications]) expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(s.coordinator.nextWakeAt()).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
@@ -100,6 +103,23 @@ describe("chat delivery work", () => {
|
||||
expect(s.service.processQueuedDeliveries).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it("restores GitHub retry deadlines on startup and wakes after a publication commit", async () => {
|
||||
const s = setup();
|
||||
const due = Date.now() + 60_000;
|
||||
s.service.nextGitHubReviewPublicationAt.mockResolvedValue(due);
|
||||
await s.start().ready;
|
||||
expect(s.coordinator.nextWakeAt()).toBe(due);
|
||||
await vi.advanceTimersByTimeAsync(59_999);
|
||||
expect(s.service.processPendingGitHubReviewPublications).toHaveBeenCalledTimes(1);
|
||||
s.service.nextGitHubReviewPublicationAt.mockResolvedValue(null);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.processPendingGitHubReviewPublications).toHaveBeenCalledTimes(2);
|
||||
await s.owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubPublications));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.processPendingGitHubReviewPublications).toHaveBeenCalledTimes(3);
|
||||
expect(s.service.scheduleQueuedPublications).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it("retries failed selection without losing its startup recovery", async () => {
|
||||
const s = setup();
|
||||
s.service.nextSlackReceiptAt.mockRejectedValueOnce(new Error("connection lost"));
|
||||
|
||||
@@ -1590,7 +1590,8 @@ process.exit(1);
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(sourceHome, { recursive: true });
|
||||
await fs.mkdir(skillSource, { recursive: true });
|
||||
await fs.writeFile(path.join(sourceHome, "auth.json"), fakeCodexAuthJson);
|
||||
const selectedAuth = JSON.stringify({ tokens: { account_id: "original-selected-account", access_token: "test-subscription-token" } });
|
||||
await fs.writeFile(path.join(sourceHome, "auth.json"), selectedAuth);
|
||||
await fs.writeFile(path.join(skillSource, "SKILL.md"), "# AgentMail\nAssigned inbox one.");
|
||||
await writeFakeCodexCommand(command);
|
||||
const keys = ["PAPERCLIP_HOME", "PAPERCLIP_INSTANCE_ID", "CODEX_HOME"] as const;
|
||||
@@ -1634,8 +1635,127 @@ process.exit(1);
|
||||
await expect(fs.stat(path.join(sourceHome, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const removed = await invoke("agent-1", null);
|
||||
expect(removed.prompt).not.toContain("inline@example.test");
|
||||
expect(removed.codexHome).toBe(sourceHome);
|
||||
expect(removed.codexHome).not.toBe(sourceHome);
|
||||
expect(removed.codexHome).toContain("skill-runtimes/agent-1/");
|
||||
await expect(fs.stat(path.join(removed.codexHome!, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
// Remote credential copy-back leaves a regular file. A later authorized
|
||||
// account switch must not reuse that previous account's generated home.
|
||||
await fs.unlink(path.join(first.codexHome!, "auth.json"));
|
||||
await fs.writeFile(path.join(first.codexHome!, "auth.json"), selectedAuth);
|
||||
const nextAccount = JSON.parse(selectedAuth);
|
||||
nextAccount.tokens.account_id = "new-selected-account";
|
||||
await fs.writeFile(path.join(sourceHome, "auth.json"), JSON.stringify(nextAccount));
|
||||
const switched = await invoke("agent-1", "a".repeat(64));
|
||||
expect(switched.codexHome).not.toBe(first.codexHome);
|
||||
expect(JSON.parse(await fs.readFile(path.join(switched.codexHome!, "auth.json"), "utf8")).tokens.account_id).toBe("new-selected-account");
|
||||
nextAccount.tokens.access_token = "refreshed-same-account-token";
|
||||
await fs.writeFile(path.join(sourceHome, "auth.json"), JSON.stringify(nextAccount));
|
||||
expect((await invoke("agent-1", "a".repeat(64))).codexHome).toBe(switched.codexHome);
|
||||
expect(await fs.readFile(path.join(first.codexHome!, "auth.json"), "utf8")).toBe(selectedAuth);
|
||||
|
||||
} finally {
|
||||
for (const key of keys) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; }
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("isolates private skills and keeps custom routing with an explicit credential home: %s", async (explicitHome) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-private-codex-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const command = path.join(root, "codex");
|
||||
const capture = path.join(root, "capture.json");
|
||||
const sourceHome = path.join(root, "selected-account");
|
||||
const skillSource = path.join(root, "skill-v1");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(sourceHome, { recursive: true });
|
||||
await fs.mkdir(skillSource, { recursive: true });
|
||||
await fs.writeFile(path.join(sourceHome, "auth.json"), fakeCodexAuthJson);
|
||||
await fs.writeFile(path.join(skillSource, "SKILL.md"), "# AgentMail\nAssigned inbox one.");
|
||||
await writeFakeCodexCommand(command);
|
||||
const keys = ["PAPERCLIP_HOME", "PAPERCLIP_INSTANCE_ID", "CODEX_HOME"] as const;
|
||||
const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "connectors";
|
||||
process.env.CODEX_HOME = sourceHome;
|
||||
const invoke = async (agentId: string, digest: string | null, source = skillSource, connectorSkillInstructions = "") => {
|
||||
const config = {
|
||||
engine: "cli", command, cwd: workspace,
|
||||
env: { ...(explicitHome ? { CODEX_HOME: sourceHome } : {}), PAPERCLIP_TEST_CAPTURE_PATH: capture,
|
||||
PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ providers: { test_gateway: {
|
||||
name: "Fixture gateway", base_url: "https://gateway.example/v1", wire_api: "responses",
|
||||
} }, model_provider: "test_gateway" }),
|
||||
},
|
||||
|
||||
paperclipSkillSync: { desiredSkills: digest ? ["paperclipai/paperclip/agentmail"] : [] },
|
||||
paperclipRuntimeSkills: digest ? [{ key: "paperclipai/paperclip/agentmail", runtimeName: "agentmail", source }] : [],
|
||||
};
|
||||
const result = await execute({ runId: `run-${agentId}-${digest?.slice(0, 1) ?? "none"}`,
|
||||
agent: { id: agentId, companyId: "company-1", name: "Email agent", adapterType: "codex_local", adapterConfig: config },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config, context: { paperclipWake: { connectorSkillInstructions } }, authToken: "test-token", onLog: async () => {},
|
||||
});
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
return JSON.parse(await fs.readFile(capture, "utf8")) as CapturePayload;
|
||||
};
|
||||
try {
|
||||
const first = await invoke("agent-1", "a".repeat(64));
|
||||
expect(first.codexHome).toContain("skill-runtimes/agent-1/");
|
||||
expect(first.codexConfigContents).toContain('model_provider = "test_gateway"');
|
||||
expect(first.codexConfigContents).toContain('base_url = "https://gateway.example/v1"');
|
||||
expect(await fs.realpath(path.join(first.codexHome!, "auth.json"))).toBe(await fs.realpath(path.join(sourceHome, "auth.json")));
|
||||
expect(await fs.readFile(path.join(first.codexHome!, "skills/agentmail/SKILL.md"), "utf8")).toContain("inbox one");
|
||||
await expect(fs.stat(path.join(sourceHome, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const unchanged = await invoke("agent-1", "a".repeat(64));
|
||||
expect(unchanged.codexHome).toBe(first.codexHome);
|
||||
const other = await invoke("agent-2", "a".repeat(64));
|
||||
expect(other.codexHome).not.toBe(first.codexHome);
|
||||
const nextSource = path.join(root, "skill-v2");
|
||||
await fs.mkdir(nextSource);
|
||||
await fs.writeFile(path.join(nextSource, "SKILL.md"), "# AgentMail\nAssigned inbox two.");
|
||||
const next = await invoke("agent-1", "b".repeat(64), nextSource);
|
||||
expect(next.codexHome).not.toBe(first.codexHome);
|
||||
expect(await fs.readFile(path.join(next.codexHome!, "skills/agentmail/SKILL.md"), "utf8")).toContain("inbox two");
|
||||
const inline = await invoke("agent-1", null, skillSource, "# AgentMail\nAssigned inbox inline@example.test");
|
||||
expect(inline.prompt).toContain("Assigned inbox inline@example.test");
|
||||
await expect(fs.stat(path.join(sourceHome, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const removed = await invoke("agent-1", null);
|
||||
expect(removed.prompt).not.toContain("inline@example.test");
|
||||
// The old saved files still exist; exclusion is enforced by the launch home.
|
||||
expect(await fs.readFile(path.join(first.codexHome!, "skills/agentmail/SKILL.md"), "utf8")).toContain("inbox one");
|
||||
expect(removed.codexHome).not.toBe(first.codexHome);
|
||||
expect(removed.codexConfigContents).toContain('model_provider = "test_gateway"');
|
||||
expect(await fs.realpath(path.join(removed.codexHome!, "auth.json"))).toBe(await fs.realpath(path.join(sourceHome, "auth.json")));
|
||||
expect(removed.codexHome).not.toBe(sourceHome);
|
||||
expect(removed.codexHome).toContain("skill-runtimes/agent-1/");
|
||||
await expect(fs.stat(path.join(removed.codexHome!, "skills/agentmail"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
if (!explicitHome) {
|
||||
// A promoted company login can differ from the host's default account.
|
||||
// Digest and seeding must use that same effective credential source.
|
||||
const companyHome = path.resolve(first.codexHome!, "../../..");
|
||||
await fs.unlink(path.join(companyHome, "auth.json"));
|
||||
const originalAccount = JSON.stringify({ tokens: { account_id: "company-account-one", access_token: "test-token-one" } });
|
||||
await fs.writeFile(path.join(companyHome, "auth.json"), originalAccount);
|
||||
const companyRun = await invoke("agent-1", "a".repeat(64));
|
||||
expect(JSON.parse(await fs.readFile(path.join(companyRun.codexHome!, "auth.json"), "utf8")).tokens.account_id).toBe("company-account-one");
|
||||
await fs.unlink(path.join(companyRun.codexHome!, "auth.json"));
|
||||
await fs.writeFile(path.join(companyRun.codexHome!, "auth.json"), originalAccount);
|
||||
await fs.writeFile(path.join(companyHome, "auth.json"), JSON.stringify({ tokens: { account_id: "company-account-two", access_token: "test-token-two" } }));
|
||||
const companyNext = await invoke("agent-1", "a".repeat(64));
|
||||
expect(companyNext.codexHome).not.toBe(companyRun.codexHome);
|
||||
expect(JSON.parse(await fs.readFile(path.join(companyNext.codexHome!, "auth.json"), "utf8")).tokens.account_id).toBe("company-account-two");
|
||||
expect(await fs.readFile(path.join(sourceHome, "auth.json"), "utf8")).toBe(fakeCodexAuthJson);
|
||||
} else {
|
||||
await fs.writeFile(path.join(sourceHome, "config.toml"), '[mcp_servers.changed]\ncommand = "new-selected-server"\n');
|
||||
const settingsChanged = await invoke("agent-1", "a".repeat(64));
|
||||
expect(settingsChanged.codexHome).not.toBe(first.codexHome);
|
||||
expect(settingsChanged.codexConfigContents).toContain('command = "new-selected-server"');
|
||||
expect(settingsChanged.codexConfigContents).toContain('model_provider = "test_gateway"');
|
||||
await fs.unlink(path.join(sourceHome, "config.toml"));
|
||||
const settingsRemoved = await invoke("agent-1", "a".repeat(64));
|
||||
expect(settingsRemoved.codexConfigContents).not.toContain('new-selected-server');
|
||||
}
|
||||
|
||||
} finally {
|
||||
for (const key of keys) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; }
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
|
||||
@@ -6,13 +6,12 @@ import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
|
||||
import { runnerApiResponseReservations } from "@paperclipai/db";
|
||||
|
||||
const runnerTarget = vi.hoisted(() => ({ driver: "local", platform: "linux", architecture: "x64" }));
|
||||
|
||||
vi.mock("../services/agent-runner-selection.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
|
||||
// Keep selection/translation real on an explicit target, including when an
|
||||
// existing test resets modules. Environment discovery has its own coverage.
|
||||
return { ...actual, resolveNewAgentRunnerForCompany: vi.fn(async (...[_db, _companyId, input]: Parameters<typeof actual.resolveNewAgentRunnerForCompany>) =>
|
||||
actual.resolveNewAgentRunner({ ...input, target: runnerTarget })) };
|
||||
});
|
||||
@@ -207,6 +206,13 @@ vi.mock("../routes/org-chart-svg.js", () => ({
|
||||
|
||||
const { companyPortabilityService, dedupeImportedCompanyName, parseGitHubSourceUrl, renderYamlBlock, renderFrontmatter } = await import("../services/company-portability.js");
|
||||
|
||||
function responseAssetDb(reservations: Array<{ id: string }> = []) {
|
||||
return { select: () => ({ from: (table: unknown) => ({ where: () => {
|
||||
const rows = table === runnerApiResponseReservations ? reservations : [];
|
||||
return Object.assign(Promise.resolve(rows), { limit: async () => rows });
|
||||
} }) }) };
|
||||
}
|
||||
|
||||
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
||||
expect(typeof entry).toBe("string");
|
||||
return typeof entry === "string" ? entry : "";
|
||||
@@ -1058,6 +1064,20 @@ describe("company portability", () => {
|
||||
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(["namespace", "reservation"])("excludes a private API snapshot used as a company logo (%s)", async kind => {
|
||||
const storage = { getObject: vi.fn() };
|
||||
companySvc.getById.mockResolvedValue({ id: "company-1", name: "Paperclip", issuePrefix: "PAP", logoAssetId: "private-logo" });
|
||||
assetSvc.getById.mockResolvedValue({ id: "private-logo", companyId: "company-1",
|
||||
objectKey: kind === "namespace" ? "company-1/runner-api/run/private.json" : "assets/companies/private-logo", contentType: "image/png" });
|
||||
const db = responseAssetDb(kind === "reservation" ? [{ id: "capture-reservation" }] : []);
|
||||
const exported = await companyPortabilityService(db as any, storage as any).exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: false },
|
||||
});
|
||||
expect(storage.getObject).not.toHaveBeenCalled();
|
||||
expect(exported.files["images/company-logo.png"]).toBeUndefined();
|
||||
expect(exported.warnings).toContain("Skipped private API response snapshot used as a company logo.");
|
||||
});
|
||||
|
||||
it("exports the company logo into images/ and references it from .paperclip.yaml", async () => {
|
||||
const storage = {
|
||||
getObject: vi.fn().mockResolvedValue({
|
||||
@@ -1081,7 +1101,7 @@ describe("company portability", () => {
|
||||
originalFilename: "logo.png",
|
||||
});
|
||||
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
@@ -3420,7 +3440,7 @@ describe("company portability", () => {
|
||||
name: "ClaudeCoder",
|
||||
});
|
||||
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
exported.files["images/company-logo.png"] = {
|
||||
@@ -4639,7 +4659,7 @@ describe("company portability", () => {
|
||||
|
||||
it("carries issue attachments as content-addressed blobs through export and import", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
mockAttachmentExportSources();
|
||||
const sha = sha256Of("png-bytes");
|
||||
|
||||
@@ -4747,7 +4767,7 @@ describe("company portability", () => {
|
||||
|
||||
it("skips all attachment imports with one warning when the target has no storage", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const exporting = companyPortabilityService({} as any, storage as any);
|
||||
const exporting = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
mockAttachmentExportSources();
|
||||
const exported = await exporting.exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
@@ -4773,7 +4793,7 @@ describe("company portability", () => {
|
||||
|
||||
it("fails closed when a bundle blob does not match its declared sha256", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
mockAttachmentExportSources();
|
||||
const sha = sha256Of("png-bytes");
|
||||
|
||||
@@ -4930,9 +4950,23 @@ describe("company portability", () => {
|
||||
));
|
||||
}
|
||||
|
||||
it.each(["namespace", "reservation"])("excludes private API snapshots from embedded images (%s)", async kind => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
mockEmbeddedAssetExportSources();
|
||||
const asset = await assetSvc.getById(EMBEDDED_ASSET_ID);
|
||||
assetSvc.getById.mockResolvedValue({ ...asset, objectKey: kind === "namespace" ? "company-1/runner-api/run/private.json" : asset.objectKey });
|
||||
const db = responseAssetDb(kind === "reservation" ? [{ id: "capture-reservation" }] : []);
|
||||
const exported = await companyPortabilityService(db as any, storage as any).exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
});
|
||||
expect(Object.keys(exported.files).filter(filePath => filePath.startsWith("blobs/"))).toEqual([]);
|
||||
expect(storage.getObject).not.toHaveBeenCalled();
|
||||
expect(exported.warnings).toContainEqual(expect.stringContaining("private API response snapshot"));
|
||||
});
|
||||
|
||||
it("carries embedded asset images through export and import with rewritten references", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
mockEmbeddedAssetExportSources();
|
||||
const sha = sha256Of("embedded-image-bytes");
|
||||
|
||||
@@ -5016,7 +5050,7 @@ describe("company portability", () => {
|
||||
|
||||
it("skips embedded image references that are foreign or dangling with one aggregate warning", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
const foreignAssetId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
const missingAssetId = "12345678-1234-4123-8123-123456789abc";
|
||||
mockEmbeddedAssetExportSources(
|
||||
@@ -5050,13 +5084,13 @@ describe("company portability", () => {
|
||||
expect(asTextFile(exported.files[".paperclip.yaml"])).not.toContain("embeddedAssets:");
|
||||
expect(exported.manifest.embeddedAssets).toEqual([]);
|
||||
expect(exported.warnings).toContain(
|
||||
"2 embedded image references point at assets that do not belong to this company or no longer exist; their images were not exported.",
|
||||
"2 embedded image references point at assets that are unavailable for export (missing, outside this company, or private API response snapshots); their images were not exported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves embedded image references untouched when their blob is missing at import", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
mockEmbeddedAssetExportSources();
|
||||
const sha = sha256Of("embedded-image-bytes");
|
||||
|
||||
@@ -5090,7 +5124,7 @@ describe("company portability", () => {
|
||||
|
||||
it("prunes embedded asset entries and blobs when the referencing files are excluded from the export selection", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const portability = companyPortabilityService(responseAssetDb() as any, storage as any);
|
||||
mockEmbeddedAssetExportSources();
|
||||
const sha = sha256Of("embedded-image-bytes");
|
||||
|
||||
@@ -5989,7 +6023,6 @@ describe("company portability", () => {
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ id: "agent-created", ...input }));
|
||||
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename" }, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining(runner === "paperclip" ? { provider: "codex", model: "gpt-5.4" } : { model: "gpt-5.4" }) }), { createdByUserId: "user-1" });
|
||||
// The import UI sends the underlying harness even for config-only edits.
|
||||
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename", adapterOverrides: { claudecoder: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.5" } } } }, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining({ model: "gpt-5.5" }) }), { createdByUserId: "user-1" });
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
|
||||
|
||||
const mockSkillSourceService = vi.hoisted(() => ({ sourceForSkill: vi.fn(), importFromUrl: vi.fn(), discover: vi.fn() }));
|
||||
const mockCaptureRunIdentity = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockSkillSourceService = vi.hoisted(() => ({ sourceForSkill: vi.fn(), importFromUrl: vi.fn(), discover: vi.fn(), branches: vi.fn() }));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -133,6 +135,10 @@ function denySkillPolicy(action = "skills.import") {
|
||||
}
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/run-identity.js", async () => ({
|
||||
...await vi.importActual<typeof import("../services/run-identity.js")>("../services/run-identity.js"),
|
||||
captureRunIdentity: mockCaptureRunIdentity,
|
||||
}));
|
||||
vi.doMock("../services/skill-sources.js", () => ({ skillSourceService: () => mockSkillSourceService }));
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
@@ -159,6 +165,15 @@ function registerModuleMocks() {
|
||||
agentService: () => mockAgentService,
|
||||
}));
|
||||
|
||||
// This route-contract harness supplies no database. Private-source decisions
|
||||
// are exercised with the real database in project-skill-sources.test.ts.
|
||||
vi.doMock("../services/skill-access.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/skill-access.js")>("../services/skill-access.js");
|
||||
return { ...actual, skillAccessService: (db: Parameters<typeof actual.skillAccessService>[0]) => ({
|
||||
...actual.skillAccessService(db), assertSource: async () => {},
|
||||
}) };
|
||||
});
|
||||
|
||||
vi.doMock("../services/company-skills.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/company-skills.js")>(
|
||||
"../services/company-skills.js",
|
||||
@@ -295,6 +310,7 @@ describe("company skill mutation permissions", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCaptureRunIdentity.mockResolvedValue({ run: { responsibleUserId: "validated-owner" } });
|
||||
mockSkillSourceService.sourceForSkill.mockResolvedValue(null);
|
||||
mockSkillSourceService.importFromUrl.mockImplementation((companyId, source) => mockCompanySkillService.importFromSource(companyId, source));
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
@@ -1232,6 +1248,33 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["responsible-person", "local-board"])("does not persist API-key refresh authority from %s", async (onBehalfOfUserId) => {
|
||||
const agent = { ...assignedAgentActor(), source: "agent_key", runId: undefined, onBehalfOfUserId,
|
||||
onBehalfOfMemberships: [{ companyId: "company-1", membershipRole: "member", status: "active" }] };
|
||||
const response = await request(createApp(agent)).post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/acme/public-skills" });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(mockSkillSourceService.importFromUrl).toHaveBeenCalledWith("company-1", expect.anything(),
|
||||
expect.objectContaining({ setup: { userId: null, agentId: agent.agentId, localTrusted: false } }));
|
||||
});
|
||||
|
||||
it("derives refresh authority from the authenticated run instead of caller responsibility", async () => {
|
||||
const agent = { ...assignedAgentActor(), source: "agent_jwt", onBehalfOfUserId: "unverified-person",
|
||||
onBehalfOfMemberships: [{ companyId: "company-1", membershipRole: "member", status: "active" }] };
|
||||
const response = await request(createApp(agent)).post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/acme/public-skills" });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(mockCaptureRunIdentity).toHaveBeenCalledWith(expect.anything(), { companyId: "company-1", agentId: agent.agentId, runId: "run-1" });
|
||||
expect(mockSkillSourceService.importFromUrl).toHaveBeenCalledWith("company-1", expect.anything(),
|
||||
expect.objectContaining({ setup: { userId: "validated-owner", agentId: agent.agentId, localTrusted: false } }));
|
||||
});
|
||||
|
||||
it("rejects a run-authenticated import without a run identity", async () => {
|
||||
await request(createApp({ ...assignedAgentActor(), source: "agent_jwt", runId: undefined }))
|
||||
.post("/api/companies/company-1/skills/import").send({ source: "https://github.com/acme/public-skills" }).expect(403);
|
||||
expect(mockSkillSourceService.importFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams discovery updates and a final result while retaining JSON compatibility', async () => {
|
||||
const result = { candidates: [], commitSha: 'a'.repeat(40) };
|
||||
mockSkillSourceService.discover.mockImplementation(async (_input, _context, options) => {
|
||||
@@ -1293,6 +1336,25 @@ describe("company skill mutation permissions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an explicit skill disposition and revision when removing a source", async () => {
|
||||
const app = createApp({ type: "board", userId: "member", companyIds: ["company-1"], source: "session" });
|
||||
const path = "/api/companies/company-1/skill-sources/source-id";
|
||||
for (const body of [{}, { revision: 0 }, { skills: "delete" }, { revision: 0, skills: "ignore" }]) {
|
||||
await request(app).delete(path).send(body).expect(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('lists authorized source branches with a validated connection override', async () => {
|
||||
const app = createApp({ type: 'board', userId: 'member', companyIds: ['company-1'], source: 'session' });
|
||||
const connectionId = '00000000-0000-4000-8000-000000000042';
|
||||
const data = { defaultBranch: 'master', branches: [{ name: 'master', isDefault: true }], truncated: false };
|
||||
mockSkillSourceService.branches.mockResolvedValue(data);
|
||||
const path = '/api/companies/company-1/skill-sources/source-id/branches';
|
||||
expect((await request(app).get(path).query({ connectionId }).expect(200)).body).toEqual(data);
|
||||
expect(mockSkillSourceService.branches).toHaveBeenCalledWith('company-1', 'source-id', { connectionId }, expect.objectContaining({ read: expect.any(Function) }), expect.any(AbortSignal));
|
||||
await request(app).get(path).query({ connectionId: 'invalid' }).expect(400);
|
||||
});
|
||||
|
||||
it("rejects cross-company source reads and mutations for board users and agents", async () => {
|
||||
for (const actor of [
|
||||
{ type: "board", userId: "member", companyIds: ["company-1"], isInstanceAdmin: true, source: "session" },
|
||||
@@ -1301,7 +1363,7 @@ describe("company skill mutation permissions", () => {
|
||||
const app = createApp(actor);
|
||||
const base = "/api/companies/company-2/skill-sources";
|
||||
const responses = await Promise.all([
|
||||
request(app).get(base), request(app).get(`${base}/repositories`), request(app).get(`${base}/source-id`),
|
||||
request(app).get(base), request(app).get(`${base}/repositories`), request(app).get(`${base}/source-id`), request(app).get(`${base}/source-id/branches`),
|
||||
request(app).post(`${base}/discover`).send({ repositoryUrl: "https://github.com/acme/skills" }),
|
||||
request(app).post(`${base}/discover`).set('Accept', 'application/x-ndjson').send({ repositoryUrl: "https://github.com/acme/skills" }),
|
||||
request(app).post(`${base}/preview`).send({ repositoryUrl: "https://github.com/acme/skills", commitSha: "a".repeat(40), skillPath: "SKILL.md", filePath: "SKILL.md" }),
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { createGrokRoutineDispatcher } from "../services/grok-bot/dispatcher.js";
|
||||
import { grokRoutineService } from "../services/grok-bot/routine.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { Readable } from "node:stream";
|
||||
import { createPublicMcpTransfers } from "../services/public-mcp/file-transfers.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -5,11 +11,11 @@ import { fileURLToPath } from "node:url";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { grokBotRoutines, grokBotDispatches, agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
|
||||
heartbeatRuns, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, mcpEventSubscriptions, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
@@ -50,6 +56,11 @@ describe("durable Dot Runner integration", () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: false });
|
||||
}, 360000);
|
||||
afterEach(async () => {
|
||||
const { heartbeatService } = await import("../services/heartbeat.js");
|
||||
dotRunnerBroker(db, { heartbeat: heartbeatService(db) });
|
||||
await instanceSettingsService(db).updateExperimental({ enableGrokBot: false, enableNativeRunner: false });
|
||||
});
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await temporary?.cleanup();
|
||||
@@ -97,12 +108,15 @@ describe("durable Dot Runner integration", () => {
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } });
|
||||
});
|
||||
|
||||
async function fixture(options: { cloudOrigin?: string } = {}) {
|
||||
async function fixture(options: { cloudOrigin?: string; provider?: "openai_dot" | "grok_bot" } = {}) {
|
||||
const provider = options.provider ?? "openai_dot";
|
||||
if (provider === "grok_bot") await instanceSettingsService(db).updateExperimental({ enableGrokBot: true, enableNativeRunner: true });
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Dot operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot test", issuePrefix: "DT" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", status: "active", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } }).returning();
|
||||
await accessService(db).setPrincipalPermission(company!.id, "user", userId, "agents:create", true, userId);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: provider === "grok_bot" ? "Grok Bot" : "Dot", adapterType: "paperclip_runner", status: "active", adapterConfig: { provider, allowUnmeteredProvider: true } }).returning();
|
||||
const config = { origin: "https://paperclip.example", resource: "https://paperclip.example/mcp/runner" };
|
||||
const oauth = createPublicMcpOAuth(db, config);
|
||||
const client = await oauth.register({ client_name: "Dedicated Dot", redirect_uris: ["https://chatgpt.com/connector_platform/oauth/callback"] }, randomUUID());
|
||||
@@ -116,9 +130,9 @@ describe("durable Dot Runner integration", () => {
|
||||
const unpaired = await oauth.authenticate(tokens.access_token);
|
||||
expect(unpaired.actor.type).toBe("none");
|
||||
const broker = dotRunnerBroker(db);
|
||||
const pairing = await broker.createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId });
|
||||
const pairing = await broker.createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId, provider });
|
||||
await broker.pair(unpaired, pairing.pairingCode);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!.adapterConfig.dotBindingId).toBe(pairing.bindingId);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!.adapterConfig[provider === "grok_bot" ? "externalBindingId" : "dotBindingId"]).toBe(pairing.bindingId);
|
||||
await expect(broker.pair(unpaired, pairing.pairingCode)).rejects.toThrow();
|
||||
const principal = await oauth.authenticate(tokens.access_token);
|
||||
expect(principal.actor).toMatchObject({ type: "agent", agentId: agent!.id, companyId: company!.id });
|
||||
@@ -137,6 +151,17 @@ describe("durable Dot Runner integration", () => {
|
||||
};
|
||||
const events = createPublicMcpEvents(db, oauth, async () => { throw new Error("personal API dispatch forbidden"); }, { enableDotRunner: true, fetch: fetcher, ...options });
|
||||
const subscription = { name: "paperclip.dot.mailbox_updated", arguments: { companyId: company!.id, bindingId: pairing.bindingId }, delivery: { mode: "webhook", url: "https://example.com/dot-hook", secret } };
|
||||
if (provider === "grok_bot") {
|
||||
const routines = grokRoutineService(db, async () => ({ delivery: "accepted", httpStatus: 200 }));
|
||||
await routines.configure({ companyId: company!.id, agentId: agent!.id, bindingId: pairing.bindingId, operatorId: userId,
|
||||
expectedRevision: 0, botName: "Synthetic Grok", webhookUrl: "https://bot.example.test/routine", webhookKey: "synthetic-key" });
|
||||
const wake = await routines.reserve({ companyId: company!.id, agentId: agent!.id, bindingId: pairing.bindingId, sourceEventId: randomUUID(), kind: "verify" });
|
||||
await routines.send(company!.id, agent!.id, wake.id);
|
||||
await routines.acknowledge(principal, { correlationId: wake.id, bindingId: wake.bindingId, bindingGeneration: wake.bindingGeneration, challenge: String(wake.payload.challenge) });
|
||||
const snapshot = await broker.snapshot(company!.id, agent!.id, pairing.bindingId);
|
||||
const [fresh] = await db.select().from(agents).where(eq(agents.id, agent!.id));
|
||||
return { company: company!, agent: fresh!, userId, broker, principal, oauth, events, subscription, received, snapshot, tokens, client, verifications: () => verifications };
|
||||
}
|
||||
await events.subscribe(principal, subscription);
|
||||
await events.tick();
|
||||
if (!received.length) throw new Error("Dot readiness delivery missing: " + JSON.stringify(await db.select({ outcome: mcpEventDeliveries.outcome, event: mcpEventDeliveries.event }).from(mcpEventDeliveries)));
|
||||
@@ -208,8 +233,8 @@ describe("durable Dot Runner integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("admits one durable idle intake without requiring a preassigned task", async () => {
|
||||
const f = await fixture();
|
||||
it.each(["openai_dot", "grok_bot"] as const)("admits one durable %s idle intake without requiring a preassigned task", async provider => {
|
||||
const f = await fixture({ provider });
|
||||
await db.update(companies).set({ defaultResponsibleUserId: f.userId }).where(eq(companies.id, f.company.id));
|
||||
const holding = await offeredWork(f);
|
||||
await db.update(heartbeatRuns).set({ status: "running", startedAt: new Date() }).where(eq(heartbeatRuns.id, holding.run.id));
|
||||
@@ -220,13 +245,13 @@ describe("durable Dot Runner integration", () => {
|
||||
const intake = await db.select().from(issues).where(eq(issues.id, requests[0]!.issueId));
|
||||
expect(intake).toHaveLength(1);
|
||||
expect(intake[0]).toMatchObject({ assigneeAgentId: f.agent.id, createdByAgentId: f.agent.id, responsibleUserId: f.userId });
|
||||
expect(await f.broker.capabilities(f.principal)).toMatchObject({ agentId: f.agent.id, responsibleUser: { id: f.userId }, idle: { start: "paperclip_dot_request_turn" } });
|
||||
expect(await f.broker.capabilities(f.principal)).toMatchObject({ agentId: f.agent.id, responsibleUser: { id: f.userId }, idle: { start: provider === "grok_bot" ? "paperclip_grok_request_turn" : "paperclip_dot_request_turn" } });
|
||||
await expect(f.broker.requestTurn(f.principal, "Different request", requestId)).rejects.toThrow("reused");
|
||||
await db.update(companies).set({ budgetMonthlyCents: 1, spentMonthlyCents: 1 }).where(eq(companies.id, f.company.id));
|
||||
await expect(f.broker.requestTurn(f.principal, "Spend more", randomUUID())).rejects.toThrow("budget");
|
||||
await db.update(companies).set({ budgetMonthlyCents: 0, spentMonthlyCents: 0 }).where(eq(companies.id, f.company.id));
|
||||
await f.broker.revoke(f.company.id, f.agent.id, f.userId);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]!.adapterConfig).not.toHaveProperty("dotBindingId");
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]!.adapterConfig).not.toHaveProperty(provider === "grok_bot" ? "externalBindingId" : "dotBindingId");
|
||||
await expect(f.broker.requestTurn(f.principal, "After revoke", randomUUID())).rejects.toThrow();
|
||||
} finally { await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, holding.run.id)); }
|
||||
});
|
||||
@@ -426,6 +451,68 @@ describe("durable Dot Runner integration", () => {
|
||||
} finally { await f.events.stop(); }
|
||||
}, 30000);
|
||||
|
||||
it.each(["queued", "running"] as const)("stops a %s Grok run before an external assignment exists", async status => {
|
||||
const f = await fixture({ provider: "grok_bot" });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status, invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const cancelRun = vi.fn().mockResolvedValue(null);
|
||||
const broker = dotRunnerBroker(db, { heartbeat: { wakeup: vi.fn(), cancelRun } });
|
||||
expect(await broker.stopGrokWork(f.company.id, f.agent.id, f.userId, run!.id)).toMatchObject({ stopped: true, remoteTerminationConfirmed: false });
|
||||
expect(cancelRun).toHaveBeenCalledWith(run!.id, "Paperclip work stopped; Grok execution unconfirmed");
|
||||
expect(await grokRoutineService(db).recoveryNeeded(f.company.id, f.agent.id)).toBe(status === "running");
|
||||
expect(await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id))).toHaveLength(0);
|
||||
const other = await fixture({ provider: "grok_bot" });
|
||||
await expect(broker.stopGrokWork(other.company.id, other.agent.id, other.userId, run!.id)).rejects.toThrow("changed");
|
||||
expect(cancelRun).toHaveBeenCalledTimes(1);
|
||||
await f.events.stop(); await other.events.stop();
|
||||
});
|
||||
|
||||
it("cancels queued Grok work without fencing another active assignment", async () => {
|
||||
const f = await fixture({ provider: "grok_bot" }), active = await offeredWork(f);
|
||||
const [queued] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "queued", invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const cancelRun = vi.fn().mockResolvedValue(null);
|
||||
const broker = dotRunnerBroker(db, { heartbeat: { wakeup: vi.fn(), cancelRun } });
|
||||
await broker.stopGrokWork(f.company.id, f.agent.id, f.userId, queued!.id);
|
||||
expect(cancelRun).toHaveBeenCalledTimes(1);
|
||||
expect(cancelRun).toHaveBeenCalledWith(queued!.id, "Paperclip work stopped; Grok execution unconfirmed");
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, active.assignment.id)))[0]!.status).toBe("offered");
|
||||
expect(await grokRoutineService(db).recoveryNeeded(f.company.id, f.agent.id)).toBe(false);
|
||||
await f.events.stop();
|
||||
});
|
||||
|
||||
it("rejects a stale Grok Stop click without fencing the newly displayed work", async () => {
|
||||
const f = await fixture({ provider: "grok_bot" });
|
||||
const old = await offeredWork(f);
|
||||
await db.update(dotRunnerAssignments).set({ status: "settled" }).where(eq(dotRunnerAssignments.id, old.assignment.id));
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, old.run.id));
|
||||
const current = await offeredWork(f);
|
||||
await expect(f.broker.stopGrokWork(f.company.id, f.agent.id, f.userId, old.run.id)).rejects.toThrow("changed");
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, current.assignment.id)))[0]!.status).toBe("offered");
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, current.run.id)))[0]!.status).toBe("queued");
|
||||
await f.events.stop();
|
||||
});
|
||||
|
||||
it("preserves Grok feedback and its deferred wake while routine recovery blocks notification", async () => {
|
||||
const f = await fixture({ provider: "grok_bot" }), { run, assignment } = await offeredWork(f);
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Grok feedback", assigneeAgentId: f.agent.id }).returning();
|
||||
await db.update(heartbeatRuns).set({ nativeIssueId: task!.id, status: "running", runtimeMode: "native", nativeSessionId: assignment.normalizedSessionId }).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.update(dotRunnerAssignments).set({ status: "accepted" }).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
await db.update(grokBotRoutines).set({ recoveryRequiredAt: new Date(), recoveryReason: "dispatch" }).where(eq(grokBotRoutines.bindingId, f.snapshot.bindingId));
|
||||
const [comment] = await db.insert(issueComments).values({ companyId: f.company.id, issueId: task!.id, authorUserId: f.userId, body: "Please include the source dates." }).returning();
|
||||
await db.transaction(async tx => {
|
||||
expect(await publishActiveDotComment(tx as unknown as typeof db, { companyId: f.company.id, agentId: f.agent.id,
|
||||
bindingId: f.snapshot.bindingId, runId: run.id, issueId: task!.id, commentId: comment!.id })).toMatchObject({ consumed: false });
|
||||
await tx.insert(agentWakeupRequests).values({ companyId: f.company.id, agentId: f.agent.id, source: "assignment", triggerDetail: "system",
|
||||
reason: "issue_comment", status: "deferred_issue_execution", payload: { issueId: task!.id, commentId: comment!.id } });
|
||||
});
|
||||
const delivery = vi.fn().mockResolvedValue({ delivery: "accepted", httpStatus: 200 });
|
||||
const dispatcher = createGrokRoutineDispatcher(db, () => true, delivery);
|
||||
await dispatcher.tick();
|
||||
expect(delivery).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(dotMailboxItems).where(and(eq(dotMailboxItems.assignmentId, assignment.id), eq(dotMailboxItems.kind, "follow_up")))).toHaveLength(1);
|
||||
expect(await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.agentId, f.agent.id), eq(agentWakeupRequests.status, "deferred_issue_execution")))).toHaveLength(1);
|
||||
await f.events.stop();
|
||||
});
|
||||
|
||||
it("serializes tool-result writes and cursor reads with the binding mailbox lock", async () => {
|
||||
const f = await fixture();
|
||||
const { run, assignment } = await offeredWork(f);
|
||||
@@ -665,12 +752,13 @@ describe("durable Dot Runner integration", () => {
|
||||
}, 30000);
|
||||
|
||||
it.each([
|
||||
{ name: "local Rust", managed: false },
|
||||
...(process.env.PAPERCLIP_DOT_DAYTONA_LIVE === "1" ? [{ name: "private Daytona Rust", managed: true }] : []),
|
||||
])("signed readiness, native tools and finalization through $name", async ({ managed }) => {
|
||||
{ name: "Dot local Rust", managed: false, provider: "openai_dot" as const },
|
||||
{ name: "Grok local Rust", managed: false, provider: "grok_bot" as const },
|
||||
...(process.env.PAPERCLIP_DOT_DAYTONA_LIVE === "1" ? [{ name: "private Daytona Rust", managed: true, provider: "openai_dot" as const }] : []),
|
||||
])("signed readiness, native tools and finalization through $name", async ({ managed, provider }) => {
|
||||
const remote = managed ? await (await import("./helpers/dot-daytona.js")).startDotDaytona(root) : null;
|
||||
try {
|
||||
const f = await fixture();
|
||||
const f = await fixture({ provider });
|
||||
const [issue] = await db.insert(issues).values({ companyId: f.company.id, title: "Save the arithmetic report", description: "Save 17 + 25 = 42 as a task document.",
|
||||
status: "in_progress", workMode: "standard", assigneeAgentId: f.agent.id }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "running", invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: issue!.id } }).returning();
|
||||
@@ -684,7 +772,7 @@ describe("durable Dot Runner integration", () => {
|
||||
runtimeContext.instructions.bundle.rootPath = root;
|
||||
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
|
||||
const execution = buildNativeExecutionInput({ companyId: f.company.id, agentId: f.agent.id, runId: run!.id, issue: issue!,
|
||||
taskPrompt: issue!.description!, normalizedSessionId: prepared.normalizedSessionId, provider: "openai_dot", dotBinding: f.snapshot,
|
||||
taskPrompt: issue!.description!, normalizedSessionId: prepared.normalizedSessionId, provider, dotBinding: f.snapshot,
|
||||
workspace: { id: randomUUID(), cwd: root, repoUrl: null, repoRef: null, branchName: null }, runtimeContext,
|
||||
completionContract: { id: contract!.id, sha256: contract!.canonicalSha256,
|
||||
schemaVersion: contract!.schemaVersion, contract: contract!.contractJson as unknown as StrictCompletionContractInput } });
|
||||
@@ -696,7 +784,7 @@ describe("durable Dot Runner integration", () => {
|
||||
const appCall = vi.fn().mockResolvedValue({ status: "completed", result: { content: [{ type: "text", text: "synthetic gateway result" }] } });
|
||||
const gatewayTools = [{ name: "fixture:read_status", displayName: "Read fixture status", description: "Read a synthetic app", parametersSchema: { type: "object", properties: {}, additionalProperties: false }, risk: "read" }];
|
||||
registerAssignedMcpGateway(db, { listToolsForNamedGateway: vi.fn().mockResolvedValue(gatewayTools), executeTool: appCall } as unknown as ToolGatewayService);
|
||||
await db.update(agents).set({ adapterConfig: { ...f.agent.adapterConfig, dotWorkspaceAccess: true, dotAttachmentAccess: true } }).where(eq(agents.id, f.agent.id));
|
||||
if (provider === "openai_dot") await db.update(agents).set({ adapterConfig: { ...f.agent.adapterConfig, dotBindingId: f.snapshot.bindingId, dotWorkspaceAccess: true, dotAttachmentAccess: true } }).where(eq(agents.id, f.agent.id));
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-cloud");
|
||||
try {
|
||||
for (const useRunnerd of [false, true]) {
|
||||
@@ -719,18 +807,53 @@ describe("durable Dot Runner integration", () => {
|
||||
try {
|
||||
await vi.waitFor(async () => { if (startupError) throw startupError; expect(await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id))).toHaveLength(1); }, { timeout: 30000, interval: 50 });
|
||||
const [assignment] = await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id));
|
||||
await f.events.tick();
|
||||
if (provider === "openai_dot") await f.events.tick();
|
||||
expect(JSON.stringify(f.received)).not.toContain("17 + 25");
|
||||
const work = await f.broker.read(f.principal, assignment!.id);
|
||||
expect(work.accounting).toEqual({ usage: null, cost: null });
|
||||
expect(work.status).toBe("offered");
|
||||
expect(observed.filter(event => event.eventType === "turn.started")).toHaveLength(0);
|
||||
expect(JSON.stringify(work)).not.toContain(root);
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => tool.operationId === "register_deliverable")).toBe(true);
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => tool.operationId === "register_deliverable")).toBe(provider === "openai_dot");
|
||||
expect(await f.broker.operation(f.principal, assignment!.id, randomUUID(), "tool", { name: "write_document", arguments: {} })).toMatchObject({ status: "rejected" });
|
||||
await f.broker.operation(f.principal, assignment!.id, randomUUID(), "accept", {});
|
||||
expect(JSON.stringify(work)).not.toContain("fixture-gateway-secret");
|
||||
if (provider === "grok_bot") {
|
||||
const files = new Map<string, Buffer>();
|
||||
const transfers = createPublicMcpTransfers(db, f.oauth, async () => { throw new Error("Personal API dispatch forbidden"); }, {
|
||||
provider: "local_disk", putFile: async input => {
|
||||
if (!Buffer.isBuffer(input.body)) throw new Error("Expected bounded bytes");
|
||||
const objectKey = input.objectKey!; files.set(objectKey, input.body);
|
||||
return { provider: "local_disk", objectKey, contentType: input.contentType, byteSize: input.body.length,
|
||||
sha256: createHash("sha256").update(input.body).digest("hex"), originalFilename: input.originalFilename };
|
||||
}, getObject: async (_company, key) => ({ stream: Readable.from(files.get(key)!) }),
|
||||
headObject: async () => ({ exists: true }), deleteObject: async (_company, key) => { files.delete(key); },
|
||||
}, { pathPrefix: "/mcp/runner/files", agentSession: { authorize: (principal, sessionId, taskId, write, database) => dotRunnerBroker(database ?? db).authorizeFileSession(principal, sessionId, taskId, write) } });
|
||||
const api = express(); api.use(transfers.router);
|
||||
const body = Buffer.from("Grok arithmetic file: 42\n"), uploadId = randomUUID();
|
||||
const args = { companyId: f.company.id, taskId: issue!.id, requestId: uploadId, filename: "report.txt", contentType: "text/plain", byteSize: body.length, sha256: createHash("sha256").update(body).digest("hex") };
|
||||
const grant = await transfers.getUploadUrl(f.principal, args, assignment!.id);
|
||||
const url = new URL(String(grant.url));
|
||||
const uploaded = await request(api).put(url.pathname + url.search).set("Content-Type", "text/plain").send(body);
|
||||
expect(uploaded.status).toBe(200);
|
||||
const duplicate = await request(api).put(url.pathname + url.search).set("Content-Type", "text/plain").send(body);
|
||||
expect(duplicate.body.attachment.id).toBe(uploaded.body.attachment.id);
|
||||
expect(await issueService(db).getAttachmentById(uploaded.body.attachment.id)).toMatchObject({ createdByAgentId: f.agent.id, originatingRunId: run!.id, createdByUserId: null });
|
||||
await expect(transfers.getUploadUrl(f.principal, { ...args, taskId: randomUUID() }, assignment!.id)).rejects.toThrow();
|
||||
const download = new URL(String((await transfers.getDownloadUrl(f.principal, uploaded.body.attachment.id, assignment!.id)).url));
|
||||
expect((await request(api).get(download.pathname + download.search)).status).toBe(200);
|
||||
const pendingUpload = new URL(String((await transfers.getUploadUrl(f.principal, { ...args, requestId: randomUUID() }, assignment!.id)).url));
|
||||
await db.update(issues).set({ workMode: "ask" }).where(eq(issues.id, issue!.id));
|
||||
await expect(transfers.getUploadUrl(f.principal, { ...args, requestId: randomUUID() }, assignment!.id)).rejects.toThrow("work mode");
|
||||
expect((await request(api).put(pendingUpload.pathname + pendingUpload.search).set("Content-Type", "text/plain").send(body)).status).toBeGreaterThanOrEqual(400);
|
||||
await db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, issue!.id));
|
||||
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
expect((await request(api).get(download.pathname + download.search)).status).toBeGreaterThanOrEqual(400);
|
||||
await db.update(agents).set({ status: "active" }).where(eq(agents.id, f.agent.id));
|
||||
}
|
||||
|
||||
const app = (work.tools as Array<{ operationId: string }>).find(tool => tool.operationId.startsWith("app_"))!;
|
||||
if (provider === "openai_dot") {
|
||||
expect(app).toBeTruthy();
|
||||
if (remote) {
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => tool.operationId.startsWith("workspace_"))).toBe(false);
|
||||
@@ -748,6 +871,10 @@ describe("durable Dot Runner integration", () => {
|
||||
await f.broker.operation(f.principal, assignment!.id, appRequestId, "tool", { name: app.operationId, arguments: {} });
|
||||
await vi.waitFor(async () => expect(await f.broker.operationStatus(f.principal, assignment!.id, appRequestId)).toMatchObject({ status: "completed", isError: false }), { timeout: 10000 });
|
||||
expect(appCall).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "dot-fixture", sessionToken: "fixture-gateway-secret", tool: "fixture:read_status" }));
|
||||
} else {
|
||||
expect(app).toBeUndefined();
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => ["get_workspace", "prepare_repository", "workspace_read"].includes(tool.operationId))).toBe(false);
|
||||
}
|
||||
await db.update(agents).set({ budgetMonthlyCents: 100, spentMonthlyCents: 100 }).where(eq(agents.id, f.agent.id));
|
||||
await expect(f.broker.read(f.principal, assignment!.id)).rejects.toThrow("execution authority");
|
||||
await db.update(agents).set({ budgetMonthlyCents: 0, spentMonthlyCents: 0, status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
@@ -775,7 +902,14 @@ describe("durable Dot Runner integration", () => {
|
||||
schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", artifacts: [], attentionRequests: [],
|
||||
verification: [{ commandOrCheck: "17 + 25", status: "passed" }],
|
||||
} } });
|
||||
if (provider === "grok_bot") await db.update(grokBotDispatches).set({ status: "accepted", sentAt: new Date() })
|
||||
.where(and(eq(grokBotDispatches.assignmentId, assignment!.id), eq(grokBotDispatches.kind, "mailbox_updated")));
|
||||
await f.broker.operation(f.principal, assignment!.id, randomUUID(), "finish", { result });
|
||||
if (provider === "grok_bot") {
|
||||
await vi.waitFor(async () => expect(await db.select().from(grokBotDispatches).where(and(eq(grokBotDispatches.assignmentId, assignment!.id),
|
||||
eq(grokBotDispatches.kind, "mailbox_updated"), inArray(grokBotDispatches.status, ["prepared", "sending", "accepted", "unknown"])))).toHaveLength(0));
|
||||
expect(await grokRoutineService(db).recoveryNeeded(f.company.id, f.agent.id)).toBe(false);
|
||||
}
|
||||
await resultPromise;
|
||||
// The heartbeat records its controller workspace settlement before the
|
||||
// ordinary status finalizer commits the task disposition.
|
||||
@@ -786,14 +920,14 @@ describe("durable Dot Runner integration", () => {
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, run!.id))).toHaveLength(1);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issue!.id)))[0]?.status).toBe("done");
|
||||
expect(await db.select().from(dotRunnerOperations).where(eq(dotRunnerOperations.requestId, writeId))).toHaveLength(1);
|
||||
expect(startupOutcome).toMatchObject({ exitCode: 0, model: null, nativeFinalization: { providerSessionId: null, driverKind: "openai_dot_mcp" } });
|
||||
expect(startupOutcome).toMatchObject({ exitCode: 0, model: null, nativeFinalization: { providerSessionId: null, driverKind: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp" } });
|
||||
expect(observed.filter(event => event.eventType === "turn.started")).toHaveLength(1);
|
||||
} catch (error) {
|
||||
if (startupError) throw startupError;
|
||||
await Promise.race([resultPromise.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]);
|
||||
if (startupError) throw startupError;
|
||||
throw error;
|
||||
} finally { await f.events.unsubscribe(f.principal, f.subscription); await f.events.stop(); server.closeAllConnections(); await new Promise<void>(done => server.close(() => done())); }
|
||||
} finally { if (provider === "openai_dot") await f.events.unsubscribe(f.principal, f.subscription); await f.events.stop(); server.closeAllConnections(); await new Promise<void>(done => server.close(() => done())); }
|
||||
} finally { await remote?.close(); }
|
||||
}, 360000);
|
||||
|
||||
|
||||
@@ -196,9 +196,12 @@ describe("durable inbound chat scheduler receipts", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it.each(["unmentioned", "mentioned", "coalesced", "recovery", "deferred"])("checks Slack mentions after a real heartbeat queue wait (%s)", async (scenario) => {
|
||||
it.each([
|
||||
...["unmentioned", "mentioned", "coalesced", "recovery", "deferred"].map((scenario) => ({ scenario, isDM: false })),
|
||||
...["unmentioned", "coalesced"].map((scenario) => ({ scenario, isDM: true })),
|
||||
])("checks Slack channel mentions after a real heartbeat queue wait ($scenario, DM=$isDM)", async ({ scenario, isDM }) => {
|
||||
const mentioned = scenario === "mentioned" || scenario === "coalesced";
|
||||
const allowed = scenario === "mentioned" || scenario === "recovery";
|
||||
const allowed = isDM || scenario === "mentioned" || scenario === "recovery";
|
||||
const f = await fixture();
|
||||
await db.update(agents).set({ adapterType: "durable_chat_retry_test" }).where(eq(agents.id, f.agentId));
|
||||
const applicationId = randomUUID(), connectionId = randomUUID(), endpointId = randomUUID();
|
||||
@@ -211,8 +214,8 @@ describe("durable inbound chat scheduler receipts", () => {
|
||||
await db.insert(issueComments).values({ id: request.commentId, companyId: f.companyId, issueId: f.issueId,
|
||||
authorUserId: "board-user", body: mentioned ? "@bot work" : "Work" });
|
||||
const [delivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId,
|
||||
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed",
|
||||
normalizedEvent: { trigger: "direct_message", message: { mentionedBot: mentioned } } }).returning();
|
||||
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed",
|
||||
normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: mentioned } } }).returning();
|
||||
await db.insert(chatActions).values({ id: request.id, companyId: f.companyId, endpointId,
|
||||
deliveryId: delivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed",
|
||||
payload: { issueId: f.issueId, commentId: request.commentId } });
|
||||
@@ -233,8 +236,8 @@ describe("durable inbound chat scheduler receipts", () => {
|
||||
await db.insert(issueComments).values({ id: second.commentId, companyId: f.companyId, issueId: f.issueId,
|
||||
authorUserId: "board-user", body: scenario === "deferred" ? "@bot follow-up" : "Unmentioned follow-up" });
|
||||
const [secondDelivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId,
|
||||
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed",
|
||||
normalizedEvent: { trigger: "direct_message", message: { mentionedBot: scenario === "deferred" } } }).returning();
|
||||
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed",
|
||||
normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: scenario === "deferred" } } }).returning();
|
||||
await db.insert(chatActions).values({ id: second.id, companyId: f.companyId, endpointId,
|
||||
deliveryId: secondDelivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed",
|
||||
payload: { issueId: f.issueId, commentId: second.commentId } });
|
||||
|
||||
@@ -83,6 +83,67 @@ describe("errorHandler", () => {
|
||||
expect(telemetryMocks.trackErrorHandlerCrash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function endedPoolQueryError() {
|
||||
// Drizzle wraps the driver error; postgres.js uses CONNECTION_ENDED for a
|
||||
// query issued after this process called `sql.end()`.
|
||||
const driverError = Object.assign(new Error("write CONNECTION_ENDED db.example:5432"), {
|
||||
code: "CONNECTION_ENDED",
|
||||
});
|
||||
return new Error('Failed query: select "id" from "issues"', { cause: driverError });
|
||||
}
|
||||
|
||||
it("answers 503 without reporting a query that outlived the database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: { paperclipDatabaseClosing: true } } } as unknown as Request;
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
|
||||
errorHandler(endedPoolQueryError(), req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Server is shutting down" });
|
||||
expect(res.err).toBeUndefined();
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
expect(telemetryMocks.trackErrorHandlerCrash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only ends the response when headers were already sent during database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: { paperclipDatabaseClosing: true } } } as unknown as Request;
|
||||
const res = { ...makeRes(), end: vi.fn(), headersSent: true } as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
|
||||
errorHandler(endedPoolQueryError(), req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reports an ended-pool query error outside database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: {} } } as unknown as Request;
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = endedPoolQueryError();
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(captureExceptionMock).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it("still reports other connection failures during database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: { paperclipDatabaseClosing: true } } } as unknown as Request;
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = new Error("Failed query", {
|
||||
cause: Object.assign(new Error("write CONNECTION_CLOSED"), { code: "CONNECTION_CLOSED" }),
|
||||
});
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(captureExceptionMock).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it("exposes raw 500 messages for trusted Cloud tenant imports", () => {
|
||||
const req = {
|
||||
...makeReq(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, subscribeDatabaseWork } from "@paperclipai/db";
|
||||
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises";
|
||||
@@ -345,13 +346,16 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
|
||||
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4100 });
|
||||
|
||||
const svc = executionWorkspaceService(db);
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.terminalWorkspaces, event => events.push(event));
|
||||
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
|
||||
workspaceId,
|
||||
issue: { id: issueId, companyId, projectId },
|
||||
actor: { agentId: null, actorType: "user" },
|
||||
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
||||
});
|
||||
}).finally(unsubscribe);
|
||||
|
||||
expect(events).toEqual(["intent", "settled"]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.reopened).toBe(true);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
databaseWorkPending,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
subscribeDatabaseWork,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -41,6 +43,8 @@ import {
|
||||
readMetadataReopenPendingConsumptionSince,
|
||||
} from "../services/execution-workspaces.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { workProductService } from "../services/work-products.js";
|
||||
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
||||
import {
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
@@ -519,6 +523,201 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
return { companyId, projectId, executionWorkspaceId, sourceIssueId, identifier, repoRoot, worktreePath, headSha };
|
||||
}
|
||||
|
||||
it("skips nonterminal trees, cooldowns, and active runs without a Git scan", async () => {
|
||||
await seedTerminalWorkspace({ childStatus: "todo" });
|
||||
const recent = await seedTerminalWorkspace({ mergedPr: true });
|
||||
const running = await seedTerminalWorkspace({ activeRun: true });
|
||||
await db.update(issues).set({ completedAt: new Date("2020-01-01") }).where(eq(issues.id, running.sourceIssueId));
|
||||
await db.update(issues).set({ completedAt: new Date() }).where(eq(issues.id, recent.sourceIssueId));
|
||||
const service = executionWorkspaceService(db, { workspaceReaperCooldownDays: 7 });
|
||||
const scan = vi.spyOn(workspaceGitOperationScheduler, "run");
|
||||
try {
|
||||
expect(await service.sweepTerminalWorkspaces()).toMatchObject({
|
||||
archived: 0, skippedNonTerminalTree: 1, skippedCooldown: 1, skippedActiveRun: 1,
|
||||
});
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
} finally { scan.mockRestore(); }
|
||||
});
|
||||
|
||||
it("derives cooldown deadlines from the full company-scoped terminal tree", async () => {
|
||||
const seed = await seedTerminalWorkspace({ childStatus: "done" });
|
||||
const [child] = await db.select().from(issues).where(eq(issues.parentId, seed.sourceIssueId));
|
||||
const sourceDone = new Date("2026-01-01T00:00:00Z"), childCancelled = new Date("2026-01-02T00:00:00Z");
|
||||
await db.update(issues).set({ completedAt: sourceDone }).where(eq(issues.id, seed.sourceIssueId));
|
||||
await db.update(issues).set({ status: "cancelled", cancelledAt: childCancelled }).where(eq(issues.id, child!.id));
|
||||
const unrelated = await seedTerminalWorkspace();
|
||||
await db.update(issues).set({ parentId: seed.sourceIssueId, status: "todo" }).where(eq(issues.id, unrelated.sourceIssueId));
|
||||
const service = executionWorkspaceService(db, {
|
||||
workspaceReaperCooldownDays: 7, now: () => new Date("2026-01-03T00:00:00Z"),
|
||||
});
|
||||
await service.sweepTerminalWorkspaces();
|
||||
const select = vi.spyOn(db, "select");
|
||||
try {
|
||||
expect(await service.nextTerminalWorkspaceSweepAt()).toBe(childCancelled.getTime() + 7 * 86_400_000);
|
||||
expect(await service.nextTerminalWorkspaceSweepAt()).toBe(childCancelled.getTime() + 7 * 86_400_000);
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
} finally { select.mockRestore(); }
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, child!.id));
|
||||
await service.sweepTerminalWorkspaces();
|
||||
expect(await service.nextTerminalWorkspaceSweepAt()).toBeNull();
|
||||
await db.update(issues).set({ status: "done", completedAt: null, updatedAt: sourceDone }).where(eq(issues.id, child!.id));
|
||||
await service.sweepTerminalWorkspaces();
|
||||
expect(await service.nextTerminalWorkspaceSweepAt()).toBe(sourceDone.getTime() + 7 * 86_400_000);
|
||||
});
|
||||
|
||||
it("does not use cached display Git state to remove a worktree", async () => {
|
||||
const seed = await seedTerminalWorkspace({ mergedPr: true });
|
||||
await svc.getById(seed.executionWorkspaceId);
|
||||
await fs.writeFile(path.join(seed.worktreePath, "unsaved.txt"), "keep this work\n");
|
||||
expect(await svc.sweepTerminalWorkspaces()).toMatchObject({ archived: 0, skippedUndelivered: 1 });
|
||||
expect(await fs.readFile(path.join(seed.worktreePath, "unsaved.txt"), "utf8")).toBe("keep this work\n");
|
||||
});
|
||||
|
||||
it("shares compatible Git inspections within a batch while keeping branch and company decisions separate", async () => {
|
||||
const first = await seedTerminalWorkspace();
|
||||
const otherCompany = await seedTerminalWorkspace();
|
||||
const [original] = await db.select().from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, first.executionWorkspaceId));
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{ ...original!, id: randomUUID(), name: "Same directory", updatedAt: new Date("2020-01-01") },
|
||||
{ ...original!, id: randomUUID(), name: "Different base", baseRef: "other-base", updatedAt: new Date("2020-01-01") },
|
||||
]);
|
||||
await db.update(executionWorkspaces).set({
|
||||
cwd: first.worktreePath,
|
||||
providerRef: first.worktreePath,
|
||||
branchName: original!.branchName,
|
||||
baseRef: original!.baseRef,
|
||||
}).where(eq(executionWorkspaces.id, otherCompany.executionWorkspaceId));
|
||||
const service = executionWorkspaceService(db, { workspaceReaperCooldownDays: 0 });
|
||||
const scan = vi.spyOn(workspaceGitOperationScheduler, "run");
|
||||
const select = vi.spyOn(db, "select");
|
||||
try {
|
||||
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ checked: 4, archived: 0 });
|
||||
expect(scan.mock.calls.filter(([input]) => input.operation === "execution_workspaces.close_readiness_status"))
|
||||
.toHaveLength(3);
|
||||
expect(select.mock.calls.filter(([fields]) => fields && "completedAt" in fields && "cancelledAt" in fields))
|
||||
.toHaveLength(4);
|
||||
} finally {
|
||||
scan.mockRestore();
|
||||
select.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("backs off unchanged terminal candidates while unrelated commits wake the worker", async () => {
|
||||
await seedTerminalWorkspace();
|
||||
let clock = Date.now();
|
||||
const service = executionWorkspaceService(db, {
|
||||
workspaceReaperCooldownDays: 0, terminalWorkspaceInspectionBackoffMs: 300_000,
|
||||
now: () => new Date(clock),
|
||||
});
|
||||
const scan = vi.spyOn(workspaceGitOperationScheduler, "run");
|
||||
try {
|
||||
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ archived: 0, skippedUndelivered: 1 });
|
||||
const initial = scan.mock.calls.length;
|
||||
expect(initial).toBeGreaterThan(0);
|
||||
service.terminalWorkspaceWorkSettled(new Set(["issue:unrelated"]));
|
||||
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ skippedBackoff: 1 });
|
||||
expect(scan).toHaveBeenCalledTimes(initial);
|
||||
clock += 240_000;
|
||||
expect(await service.nextTerminalWorkspaceSweepAt()).toBe(clock + 60_000);
|
||||
clock += 60_000;
|
||||
await service.sweepTerminalWorkspaces();
|
||||
expect(scan).toHaveBeenCalledTimes(initial * 2);
|
||||
} finally { scan.mockRestore(); }
|
||||
});
|
||||
|
||||
it("invalidates a candidate backoff after its real PR producer commits", async () => {
|
||||
const seed = await seedTerminalWorkspace();
|
||||
const service = executionWorkspaceService(db, {
|
||||
workspaceReaperCooldownDays: 0, terminalWorkspaceInspectionBackoffMs: 300_000,
|
||||
resolvePullRequestDetails: async () => ({ state: "merged", headRef: "PAP-16015-delivery", headSha: seed.headSha }),
|
||||
});
|
||||
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ archived: 0, skippedUndelivered: 1 });
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.terminalWorkspaces, (event, hints) => {
|
||||
if (event === "settled") service.terminalWorkspaceWorkSettled(hints);
|
||||
});
|
||||
try {
|
||||
await workProductService(db).createForIssue(seed.sourceIssueId, seed.companyId, {
|
||||
type: "pull_request", provider: "github", title: "PR", status: "merged",
|
||||
url: "https://github.com/paperclipai/paperclip/pull/10623", executionWorkspaceId: seed.executionWorkspaceId,
|
||||
});
|
||||
expect(await service.sweepTerminalWorkspaces()).toMatchObject({ archived: 1 });
|
||||
await expect(fs.access(seed.worktreePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
it("rescans committed changes that arrive behind a bounded recovery cursor", async () => {
|
||||
const seed = await seedTerminalWorkspace();
|
||||
const service = executionWorkspaceService(db, {
|
||||
workspaceReaperCooldownDays: 0,
|
||||
resolvePullRequestDetails: async () => ({ state: "merged", headRef: "PAP-16015-delivery", headSha: seed.headSha }),
|
||||
});
|
||||
expect(await service.sweepTerminalWorkspaces(1)).toMatchObject({ checked: 1, skippedUndelivered: 1 });
|
||||
await workProductService(db).createForIssue(seed.sourceIssueId, seed.companyId, {
|
||||
type: "pull_request", provider: "github", title: "PR", status: "merged",
|
||||
url: "https://github.com/paperclipai/paperclip/pull/10623",
|
||||
});
|
||||
service.terminalWorkspaceWorkSettled(new Set([`issue:${seed.sourceIssueId}`]));
|
||||
expect(await service.sweepTerminalWorkspaces(1)).toMatchObject({ checked: 0 });
|
||||
expect(service.hasTerminalWorkspaceSweepContinuation()).toBe(true);
|
||||
expect(await service.sweepTerminalWorkspaces(2)).toMatchObject({ checked: 1, archived: 1 });
|
||||
expect(service.hasTerminalWorkspaceSweepContinuation()).toBe(false);
|
||||
expect(await service.nextTerminalWorkspaceSweepAt()).toBeNull();
|
||||
});
|
||||
|
||||
it("retries unread rows from a failed full page before it can park recovery", async () => {
|
||||
const seed = await seedTerminalWorkspace({ mergedPr: true });
|
||||
await seedTerminalWorkspace({ childStatus: "todo" });
|
||||
await db.update(executionWorkspaces).set({ updatedAt: new Date("2019-01-01") })
|
||||
.where(eq(executionWorkspaces.id, seed.executionWorkspaceId));
|
||||
const service = executionWorkspaceService(db, {
|
||||
workspaceReaperCooldownDays: 0,
|
||||
resolvePullRequestDetails: async () => ({ state: "merged", headRef: "PAP-16015-delivery", headSha: seed.headSha }),
|
||||
});
|
||||
const original = db.select.bind(db);
|
||||
let fail = true;
|
||||
const select = vi.spyOn(db, "select").mockImplementation(((fields: any) => {
|
||||
if (fail && fields?.completedAt && fields?.cancelledAt) {
|
||||
fail = false;
|
||||
throw new Error("task tree read failed");
|
||||
}
|
||||
return original(fields);
|
||||
}) as typeof db.select);
|
||||
try {
|
||||
await expect(service.sweepTerminalWorkspaces(1)).rejects.toThrow("task tree read failed");
|
||||
expect(service.hasTerminalWorkspaceSweepContinuation()).toBe(true);
|
||||
expect(await service.sweepTerminalWorkspaces(1)).toMatchObject({ checked: 1, archived: 1 });
|
||||
expect(await service.sweepTerminalWorkspaces(1)).toMatchObject({ checked: 1, skippedNonTerminalTree: 1 });
|
||||
expect(await service.sweepTerminalWorkspaces(1)).toMatchObject({ checked: 0 });
|
||||
expect(service.hasTerminalWorkspaceSweepContinuation()).toBe(false);
|
||||
expect(await service.nextTerminalWorkspaceSweepAt()).toBeNull();
|
||||
await expect(fs.access(seed.worktreePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally { select.mockRestore(); }
|
||||
});
|
||||
|
||||
it("signals real issue producers before writes and waits for the outer transaction", async () => {
|
||||
const seed = await seedTerminalWorkspace();
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.terminalWorkspaces, event => events.push(event));
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await issueService(db).update(seed.sourceIssueId, { status: "cancelled" }, tx);
|
||||
expect(events).toEqual(["intent"]);
|
||||
expect(databaseWorkPending(db, DELIVERY_QUEUES.terminalWorkspaces)).toBe(true);
|
||||
const [outside] = await db.select().from(issues).where(eq(issues.id, seed.sourceIssueId));
|
||||
expect(outside?.status).toBe("done");
|
||||
});
|
||||
expect(events).toEqual(["intent", "settled"]);
|
||||
expect(databaseWorkPending(db, DELIVERY_QUEUES.terminalWorkspaces)).toBe(false);
|
||||
await workProductService(db).createForIssue(seed.sourceIssueId, seed.companyId, {
|
||||
type: "pull_request", provider: "github", title: "PR", status: "merged", url: "https://github.com/paperclipai/paperclip/pull/10623",
|
||||
});
|
||||
expect(events.slice(-2)).toEqual(["intent", "settled"]);
|
||||
await svc.update(seed.executionWorkspaceId, { status: "idle" });
|
||||
expect(events).toHaveLength(6);
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
it("reports a squash cross-branch delivery as merged_via_pr and suppresses the ancestry warning", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
tempDirs.add(repoRoot);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFeedbackRedactionState,
|
||||
finalizeFeedbackRedactionSummary,
|
||||
sanitizeFeedbackText,
|
||||
sanitizeFeedbackValue,
|
||||
} from "../services/feedback-redaction.js";
|
||||
|
||||
describe("feedback redaction", () => {
|
||||
it.each([
|
||||
"https://agents.staging.example.com/team/chats/assistant",
|
||||
"api.openai.com and deployment.credentials.example.md",
|
||||
"paperclip.native-model-envelope.v3 and executor.coreTools.integrations.list",
|
||||
"Use bearer tokens for authentication and bearer authentication for requests.",
|
||||
"Document bearer authorization, bearer credentials, bearer schemes, and bearer flows.",
|
||||
"Use (bearer tokens). Preserve [bearer authentication] and {bearer credentials}.",
|
||||
])("preserves non-secret chat context: %s", (input) => {
|
||||
const state = createFeedbackRedactionState();
|
||||
|
||||
expect(sanitizeFeedbackText(input, state, "body", 10_000)).toBe(input);
|
||||
expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual([]);
|
||||
expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({});
|
||||
});
|
||||
|
||||
it("redacts JWTs without counting neighboring hostnames as credentials", () => {
|
||||
const header = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url");
|
||||
const jwt = `${header}.abcdefghijk.abcdefghijkl`;
|
||||
const state = createFeedbackRedactionState();
|
||||
|
||||
expect(sanitizeFeedbackText(`Visit api.openai.com; token ${jwt}`, state, "body", 10_000))
|
||||
.toBe("Visit api.openai.com; token [REDACTED_JWT]");
|
||||
expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 });
|
||||
expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]);
|
||||
});
|
||||
|
||||
it("still redacts a JWT with a short payload and signature", () => {
|
||||
const header = Buffer.from('{"alg":"HS256"}').toString("base64url");
|
||||
const state = createFeedbackRedactionState();
|
||||
const jwt = `${header}.e30.c2ln`;
|
||||
|
||||
expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]");
|
||||
expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 });
|
||||
});
|
||||
|
||||
it("redacts encrypted JWTs", () => {
|
||||
const header = Buffer.from('{"alg":"RSA-OAEP","enc":"A256GCM"}').toString("base64url");
|
||||
const jwt = `${header}.abcdefghijk.abcdefghijkl.abcdefghijk.abcdefghijkl`;
|
||||
const state = createFeedbackRedactionState();
|
||||
|
||||
expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]");
|
||||
expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 });
|
||||
});
|
||||
|
||||
it("redacts direct-encryption JWTs with an empty encrypted-key segment", () => {
|
||||
const header = Buffer.from('{"alg":"dir","enc":"A256GCM"}').toString("base64url");
|
||||
const jwt = `${header}..abcdefghijk.abcdefghijkl.abcdefghijk`;
|
||||
const state = createFeedbackRedactionState();
|
||||
|
||||
expect(sanitizeFeedbackText(`Token ${jwt}; visit api.openai.com.`, state, "body", 10_000))
|
||||
.toBe("Token [REDACTED_JWT]; visit api.openai.com.");
|
||||
expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 });
|
||||
expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]);
|
||||
});
|
||||
|
||||
it("still redacts explicit credentials and personal information", () => {
|
||||
const state = createFeedbackRedactionState();
|
||||
const input = [
|
||||
"api_key=opaque-value",
|
||||
"Bearer abcdefghijklmnop",
|
||||
"ghp_abcdefghijklmnopqrstuv",
|
||||
"sk-abcdefghijklmnopqrstuv",
|
||||
"postgresql://admin:password@example.com/db",
|
||||
"person@example.com",
|
||||
"+1 (415) 555-1234",
|
||||
].join("\n");
|
||||
const output = sanitizeFeedbackText(input, state, "body", 10_000);
|
||||
|
||||
for (const value of input.split("\n")) expect(output).not.toContain(value);
|
||||
expect(output).toContain("Bearer [REDACTED_TOKEN]");
|
||||
expect(output).toContain("[REDACTED_EMAIL]");
|
||||
expect(output).toContain("[REDACTED_PHONE]");
|
||||
});
|
||||
|
||||
it("preserves nested metadata while masking structured secrets", () => {
|
||||
const state = createFeedbackRedactionState();
|
||||
const input = {
|
||||
body: "Visit api.openai.com and use bearer tokens.",
|
||||
tokenPolicy: "least privilege",
|
||||
nested: [{ apiKey: "opaque-value", path: "deployment.credentials.example.md" }],
|
||||
};
|
||||
|
||||
expect(sanitizeFeedbackValue(input, state, "bundle", 10_000)).toEqual({
|
||||
...input,
|
||||
nested: [{ apiKey: "***REDACTED***", path: "deployment.credentials.example.md" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ rl.on("line", (line) => {
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["environmentExecute"],
|
||||
supportedMethods: ["environmentExecute", "configChanged"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -44,6 +44,16 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "configChanged") {
|
||||
setTimeout(() => send({
|
||||
jsonrpc: "2.0", id: message.id,
|
||||
...(message.params.config.fail
|
||||
? { error: { code: -32000, message: "configuration rejected" } }
|
||||
: { result: {} }),
|
||||
}), Number(message.params.config.delayMs ?? 0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "shutdown") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
|
||||
@@ -6,13 +6,38 @@ import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { scanGitHubSkills, previewGitHubSkillFile, parseSkillRepository } from '../services/github-skill-source.js';
|
||||
import { scanGitHubSkills, scanGitHubSkillsWithPackageFallback, rejectIncompleteSkillPackages, previewGitHubSkillFile, parseSkillRepository } from '../services/github-skill-source.js';
|
||||
import { skillSourceDiscoverySchema, skillSourcePreviewSchema, type SkillSourceScanUpdate } from '@paperclipai/shared';
|
||||
import { skillFileBytes } from '../services/skill-snapshot.js';
|
||||
|
||||
const sha = 'a'.repeat(40);
|
||||
const md = (name: string) => `---\nname: ${name}\ndescription: A useful skill\n---\nFollow these instructions.\n`;
|
||||
describe('GitHub skill repository discovery', () => {
|
||||
it('keeps bounded packages discoverable but rejects dependencies omitted by repository fallback', async () => {
|
||||
const fixture = githubFixture({
|
||||
'skills/main/SKILL.md': md('main') + '\nRead [shared](../../shared/guide.md) and [helper](../helper/SKILL.md).',
|
||||
'skills/helper/SKILL.md': md('helper'),
|
||||
'shared/guide.md': 'Shared instructions',
|
||||
...Object.fromEntries(Array.from({ length: 62 }, (_, i) => [`generated/${i}.bin`, 'x'])),
|
||||
});
|
||||
const open = fixture.openSnapshot;
|
||||
fixture.openSnapshot = async (...args) => {
|
||||
const snapshot = await open(...args);
|
||||
for (const entry of snapshot.entries) if (entry.path.startsWith('generated/')) entry.size = 1024 * 1024;
|
||||
return snapshot;
|
||||
};
|
||||
const scan = await scanGitHubSkillsWithPackageFallback({ repositoryUrl: 'https://github.com/acme/skills', packageMode: 'repository' }, fixture);
|
||||
expect(scan.packageMode).toBe('skills');
|
||||
expect(scan.skills.find(skill => skill.name === 'helper')?.error).toBeNull();
|
||||
expect(scan.skills.find(skill => skill.name === 'main')?.error).toContain('dependencies could not be preserved');
|
||||
expect(scan.candidates.find(skill => skill.name === 'main')?.error).toContain('dependencies could not be preserved');
|
||||
const later = await scanGitHubSkills({ repositoryUrl: 'https://github.com/acme/skills', packageMode: 'skills' }, fixture);
|
||||
expect(later.skills.find(skill => skill.name === 'main')?.error).toBeNull();
|
||||
rejectIncompleteSkillPackages(later);
|
||||
expect(later.candidates.find(skill => skill.name === 'main')?.error).toContain('dependencies could not be preserved');
|
||||
expect(later.skills.find(skill => skill.name === 'helper')?.error).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves moving branches before cache lookup and reuses the same pinned commit until the branch changes', async () => {
|
||||
const fixture = githubFixture({ 'SKILL.md': md('one') });
|
||||
const read = vi.fn(fixture) as unknown as typeof fixture;
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { grokBotRoutes } from "../routes/grok-bot.js";
|
||||
import { publicMcpIngressRoutes } from "../routes/public-mcp.js";
|
||||
import { createPublicMcpExecutor } from "../services/public-mcp/capabilities.js";
|
||||
import { createDotRunnerMcpTools } from "../services/dot-runner-broker.js";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, grokBotDispatches,
|
||||
grokBotRoutines, mcpOauthGrants, activityLog, issues, heartbeatRuns, dotRunnerAssignments, dotMailboxItems, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { createPublicMcpOAuth } from "../services/public-mcp/oauth.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
import { createGrokRoutineDispatcher } from "../services/grok-bot/dispatcher.js";
|
||||
import { grokRoutineService } from "../services/grok-bot/routine.js";
|
||||
import { type GrokRoutineWake } from "../services/grok-bot/webhook.js";
|
||||
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-grok-routine-");
|
||||
db = createDb(temporary.connectionString);
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableNativeRunner: true, enableGrokBot: true, enableOpenAiDot: true });
|
||||
}, 120000);
|
||||
afterAll(async () => { try { await temporary?.cleanup(); } finally { vi.unstubAllEnvs(); } });
|
||||
|
||||
async function fixture() {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Grok operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Grok test", issuePrefix: "GK" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await accessService(db).setPrincipalPermission(company!.id, "user", userId, "agents:create", true, userId);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Grok test", adapterType: "paperclip_runner", status: "active", adapterConfig: { provider: "grok_bot", allowUnmeteredProvider: true, lifecycleMode: "per_turn" } }).returning();
|
||||
const oauth = createPublicMcpOAuth(db, { origin: "https://paperclip.example", resource: "https://paperclip.example/mcp/runner" });
|
||||
const client = await oauth.register({ client_name: "Synthetic Grok client", redirect_uris: ["https://client.example/callback"] }, randomUUID());
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const input = { client_id: client.client_id, redirect_uri: "https://client.example/callback", response_type: "code", resource: oauth.config.resource,
|
||||
scope: "paperclip:agent offline_access", code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256" };
|
||||
const requestId = (await oauth.authorize(input)).split("/").at(-1)!;
|
||||
const pairing = await dotRunnerBroker(db).createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId, provider: "grok_bot" });
|
||||
const consent = await oauth.consentDotPairing(requestId, pairing.pairingCode);
|
||||
const tokens = await oauth.token({ grant_type: "authorization_code", client_id: client.client_id, redirect_uri: input.redirect_uri,
|
||||
resource: oauth.config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: verifier });
|
||||
const principal = await oauth.authenticate(tokens.access_token);
|
||||
const delivery = vi.fn().mockResolvedValue({ delivery: "accepted", httpStatus: 200 });
|
||||
const service = grokRoutineService(db, delivery);
|
||||
const config = { companyId: company!.id, agentId: agent!.id, bindingId: pairing.bindingId, operatorId: userId,
|
||||
expectedRevision: 0, botName: "Synthetic Bot", webhookUrl: "https://bot.example.test/routines/synthetic", webhookKey: "synthetic-sender-key" };
|
||||
await service.configure(config);
|
||||
return { company: company!, agent: agent!, principal, pairing, service, delivery, config, oauth, tokens };
|
||||
}
|
||||
async function verification(f: Awaited<ReturnType<typeof fixture>>, sourceEventId = randomUUID()) {
|
||||
return f.service.reserve({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId, sourceEventId, kind: "verify" });
|
||||
}
|
||||
function acknowledgement(row: Awaited<ReturnType<typeof verification>>) {
|
||||
const wake = row.payload as GrokRoutineWake & { challenge: string };
|
||||
return { correlationId: row.id, bindingId: row.bindingId, bindingGeneration: row.bindingGeneration, challenge: wake.challenge };
|
||||
}
|
||||
|
||||
describe("durable Grok routine qualification", () => {
|
||||
it("receives an authenticated Grok acknowledgement through the actual MCP transport without exposing personal or Dot event tools", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
const app = express(); app.use(express.json());
|
||||
const dispatch = async () => { throw new Error("Personal dispatch forbidden"); };
|
||||
app.use(publicMcpIngressRoutes(f.oauth, createPublicMcpExecutor(db, f.oauth, dispatch), undefined, createDotRunnerMcpTools(db)));
|
||||
const invoke = (method: string, params: unknown) => request(app).post("/mcp/runner")
|
||||
.set("Authorization", `Bearer ${f.tokens.access_token}`).set("Accept", "application/json, text/event-stream")
|
||||
.send({ jsonrpc: "2.0", id: 1, method, params });
|
||||
const list = await invoke("tools/list", {});
|
||||
expect(list.status).toBe(200);
|
||||
const names = list.body.result.tools.map((tool: {name: string}) => tool.name);
|
||||
expect(names).toContain("paperclip_grok_ack_wake");
|
||||
expect(names).not.toContain("paperclip_dot_confirm_event");
|
||||
expect(names).not.toContain("paperclip_call_api");
|
||||
const reply = await invoke("tools/call", { name: "paperclip_grok_ack_wake", arguments: acknowledgement(row) });
|
||||
expect(reply.status).toBe(200);
|
||||
expect(reply.body.result.structuredContent).toMatchObject({ outcome: "completed", result: { status: "acknowledged" } });
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true });
|
||||
});
|
||||
|
||||
it("rejects acknowledgement after the authorizing person loses permission to manage the agent", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await accessService(db).setPrincipalPermission(f.company.id, "user", f.principal.grant.userId, "agents:create", false, f.principal.grant.userId);
|
||||
await expect(f.service.acknowledge(f.principal, acknowledgement(row))).rejects.toThrow("permission");
|
||||
});
|
||||
|
||||
it("filters private current and queued tasks from the connection overview", async () => {
|
||||
const f = await fixture();
|
||||
const reader = randomUUID();
|
||||
await db.insert(authUsers).values({ id: reader, name: "Reader", email: `${reader}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: reader, membershipRole: "member", status: "active" });
|
||||
const [privateTask] = await db.insert(issues).values({ companyId: f.company.id, title: "Private strategy", visibility: "private", createdByUserId: f.principal.grant.userId, assigneeAgentId: f.agent.id, status: "todo" }).returning();
|
||||
await db.insert(issues).values({ companyId: f.company.id, title: "Visible task", assigneeAgentId: f.agent.id, status: "todo" });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "running", invocationSource: "assignment", triggerDetail: "system", nativeIssueId: privateTask!.id }).returning();
|
||||
await db.insert(dotRunnerAssignments).values({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId, bindingGeneration: 1,
|
||||
runId: run!.id, normalizedSessionId: randomUUID(), turnId: randomUUID(), controllerGeneration: 1, catalogDigest: "fixture", projection: {},
|
||||
status: "accepted", acceptBy: new Date(Date.now() + 60000), expiresAt: new Date(Date.now() + 60000) });
|
||||
const app = express(); app.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId: reader, companyIds: [f.company.id], memberships: [{ companyId: f.company.id, membershipRole: "member", status: "active" }] }; next(); });
|
||||
app.use("/api", grokBotRoutes(db, f.oauth.config.resource));
|
||||
const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/grok-binding`;
|
||||
const hidden = await request(app).get(path).expect(200);
|
||||
expect(hidden.body).toMatchObject({ currentTask: null, queuedTaskCount: 1 });
|
||||
expect(JSON.stringify(hidden.body)).not.toContain("Private strategy");
|
||||
await db.update(issues).set({ responsibleUserId: reader }).where(eq(issues.id, privateTask!.id));
|
||||
const visible = await request(app).get(path).expect(200);
|
||||
expect(visible.body).toMatchObject({ currentTask: { id: privateTask!.id, title: "Private strategy" }, queuedTaskCount: 2 });
|
||||
});
|
||||
|
||||
it.each(["dispatch", "work_session"] as const)("closes settled wake obligations without clearing a %s hold incorrectly", async reason => {
|
||||
const f = await fixture(); const verified = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, verified.id); await f.service.acknowledge(f.principal, acknowledgement(verified));
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "succeeded", invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const [work] = await db.insert(dotRunnerAssignments).values({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId, bindingGeneration: 1,
|
||||
runId: run!.id, normalizedSessionId: randomUUID(), turnId: randomUUID(), controllerGeneration: 1, catalogDigest: "fixture", projection: {},
|
||||
status: "settled", acceptBy: new Date(), expiresAt: new Date(Date.now() - 1) }).returning();
|
||||
const wake = await f.service.reserve({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId,
|
||||
assignmentId: work!.id, kind: "mailbox_updated", sourceEventId: randomUUID() });
|
||||
await db.update(grokBotDispatches).set({ status: "unknown", sentAt: new Date() }).where(eq(grokBotDispatches.id, wake.id));
|
||||
await db.update(grokBotRoutines).set({ recoveryRequiredAt: new Date(), recoveryReason: reason }).where(eq(grokBotRoutines.bindingId, f.pairing.bindingId));
|
||||
await db.transaction(tx => grokRoutineService(tx as unknown as typeof db).settleWorkNotifications(f.company.id, f.pairing.bindingId, work!.id));
|
||||
expect((await db.select().from(grokBotDispatches).where(eq(grokBotDispatches.id, wake.id)))[0]!.status).toBe("cancelled");
|
||||
const ack = { correlationId: wake.id, bindingId: wake.bindingId, bindingGeneration: wake.bindingGeneration };
|
||||
expect(await f.service.acknowledge(f.principal, ack)).toMatchObject({ status: "acknowledged", remoteTerminationConfirmed: false });
|
||||
expect(await f.service.recoveryNeeded(f.company.id, f.agent.id)).toBe(reason === "work_session");
|
||||
await expect(dotRunnerBroker(db).operation(f.principal, work!.id, randomUUID(), "progress", { text: "late effect" })).rejects.toThrow("expired");
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, work!.id)))[0]!.status).toBe("settled");
|
||||
await db.update(dotRunnerAssignments).set({ status: "fenced" }).where(eq(dotRunnerAssignments.id, work!.id));
|
||||
await expect(f.service.acknowledge(f.principal, ack)).rejects.toThrow("revoked");
|
||||
});
|
||||
|
||||
it("keeps HTTP delivery and authenticated reply separate, with auditable agent and authorizer", async () => {
|
||||
const f = await fixture();
|
||||
const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: false, lastExchange: { status: "accepted", httpStatus: 200, acknowledgedAt: null } });
|
||||
expect((await db.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, row.bindingId)))[0]!.status).toBe("connected");
|
||||
await f.service.acknowledge(f.principal, acknowledgement(row));
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true, recoveryNeeded: false, lastExchange: { status: "acknowledged" } });
|
||||
const audit = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.company.id), eq(activityLog.action, "grok.wake_verified")));
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0]).toMatchObject({ actorType: "agent", actorId: f.agent.id, details: { authorizingPersonId: f.principal.grant.userId, correlationId: row.id } });
|
||||
expect(JSON.stringify(await f.service.state(f.company.id, f.agent.id, row.bindingId))).not.toContain("synthetic-sender-key");
|
||||
});
|
||||
|
||||
it("reserves once across concurrent attempts and never resends accepted work after a new controller starts", async () => {
|
||||
const f = await fixture(), source = randomUUID();
|
||||
const rows = await Promise.all([1, 2, 3].map(() => verification(f, source)));
|
||||
expect(new Set(rows.map(r => r.id)).size).toBe(1);
|
||||
await Promise.all(rows.map(r => f.service.send(f.company.id, f.agent.id, r.id)));
|
||||
const restarted = grokRoutineService(db, f.delivery);
|
||||
await restarted.send(f.company.id, f.agent.id, rows[0]!.id);
|
||||
expect(f.delivery).toHaveBeenCalledTimes(1);
|
||||
await expect(verification(f)).rejects.toThrow("outstanding delivery");
|
||||
});
|
||||
|
||||
it("reconciles a lost HTTP response with the same authenticated challenge without retrying", async () => {
|
||||
const f = await fixture(); f.delivery.mockResolvedValue({ delivery: "unknown", httpStatus: null });
|
||||
const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: true, verified: false });
|
||||
await f.service.acknowledge(f.principal, acknowledgement(row));
|
||||
await f.service.acknowledge(f.principal, acknowledgement(row));
|
||||
expect(f.delivery).toHaveBeenCalledTimes(1);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: false, verified: true });
|
||||
});
|
||||
|
||||
it("does not downgrade an authenticated acknowledgement when an ambiguous transport response arrives later", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockImplementation(async () => { await f.service.acknowledge(f.principal, acknowledgement(row)); return { delivery: "unknown", httpStatus: 504 }; });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: false, verified: true, lastExchange: { status: "acknowledged" } });
|
||||
});
|
||||
|
||||
it("keeps crashed sending intents in recovery and prevents credential replacement while delivery is uncertain", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
await db.update(grokBotDispatches).set({ status: "sending", sentAt: new Date(Date.now() - 60000), updatedAt: new Date(Date.now() - 60000) }).where(eq(grokBotDispatches.id, row.id));
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(f.delivery).not.toHaveBeenCalled();
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: true });
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1 })).rejects.toThrow("recover");
|
||||
});
|
||||
|
||||
it.each(["wrong nonce", "wrong binding", "wrong generation", "expired", "revoked", "personal grant", "other company"])("rejects %s without readiness or accepted late results", async condition => {
|
||||
const f = await fixture(); const other = await fixture(); const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
const ack = acknowledgement(row);
|
||||
let principal = f.principal;
|
||||
if (condition === "wrong nonce") ack.challenge = "a-different-nonce-long-enough";
|
||||
if (condition === "wrong binding") ack.bindingId = other.pairing.bindingId;
|
||||
if (condition === "wrong generation") ack.bindingGeneration++;
|
||||
if (condition === "expired") await db.update(grokBotDispatches).set({ expiresAt: new Date(0) }).where(eq(grokBotDispatches.id, row.id));
|
||||
if (condition === "revoked") await db.update(mcpOauthGrants).set({ revokedAt: new Date() }).where(eq(mcpOauthGrants.id, f.principal.grant.id));
|
||||
if (condition === "personal grant") principal = { ...f.principal, grant: { ...f.principal.grant, purpose: "personal" } };
|
||||
if (condition === "other company") principal = other.principal;
|
||||
await expect(f.service.acknowledge(principal, ack)).rejects.toThrow();
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: false });
|
||||
});
|
||||
|
||||
it("stores URL and key together encrypted and rejects stale, foreign or unauthorized configuration", async () => {
|
||||
const f = await fixture();
|
||||
const [stored] = await db.select().from(grokBotRoutines).where(eq(grokBotRoutines.bindingId, f.pairing.bindingId));
|
||||
expect(JSON.stringify(stored)).not.toContain(f.config.webhookKey);
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 0 })).rejects.toThrow("changed");
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1, operatorId: randomUUID() })).rejects.toThrow("permission");
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1, companyId: randomUUID() })).rejects.toThrow();
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1, webhookUrl: "https://user:password@bot.example.test/routine" })).rejects.toThrow("Invalid");
|
||||
});
|
||||
it("retries only confirmed rejection, retaining receipts and deduplicating concurrent operator retries", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValueOnce({ delivery: "rejected", httpStatus: 429 });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, randomUUID(), row.id)).rejects.toThrow("permission");
|
||||
await expect(f.service.retryRejected(randomUUID(), f.agent.id, f.principal.grant.userId, row.id)).rejects.toThrow();
|
||||
await Promise.all([1, 2].map(() => f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, row.id)));
|
||||
const receipts = await db.select().from(grokBotDispatches).where(eq(grokBotDispatches.bindingId, f.pairing.bindingId));
|
||||
expect(receipts).toHaveLength(2);
|
||||
expect(receipts.find(receipt => receipt.id === row.id)).toMatchObject({ status: "rejected", httpStatus: 429 });
|
||||
const retry = receipts.find(receipt => receipt.id !== row.id)!;
|
||||
expect(retry).toMatchObject({ status: "accepted", sourceEventId: `retry:${row.id}` });
|
||||
expect(f.delivery).toHaveBeenCalledTimes(2);
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, retry.id)).rejects.toThrow("confirmed rejected");
|
||||
await f.service.acknowledge(f.principal, acknowledgement(retry));
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true });
|
||||
});
|
||||
|
||||
it("keeps rejected unread feedback available for an explicit retry without automatic resend", async () => {
|
||||
const f = await fixture(); const verified = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, verified.id);
|
||||
await f.service.acknowledge(f.principal, acknowledgement(verified));
|
||||
const sessionId = randomUUID();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id,
|
||||
status: "running", runtimeMode: "native", nativeSessionId: sessionId, invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Feedback retry",
|
||||
assigneeAgentId: f.agent.id, executionRunId: run!.id, checkoutRunId: run!.id, status: "in_progress" }).returning();
|
||||
await db.update(heartbeatRuns).set({ nativeIssueId: task!.id }).where(eq(heartbeatRuns.id, run!.id));
|
||||
await db.insert(nativeRunFinalizations).values({ runId: run!.id, companyId: f.company.id, issueId: task!.id,
|
||||
phase: "observed", controllerGeneration: 1, leaseExpiresAt: new Date(Date.now() + 60_000) });
|
||||
const [work] = await db.insert(dotRunnerAssignments).values({ companyId: f.company.id, agentId: f.agent.id,
|
||||
bindingId: f.pairing.bindingId, bindingGeneration: 1, runId: run!.id, normalizedSessionId: sessionId, turnId: randomUUID(),
|
||||
controllerGeneration: 1, catalogDigest: "test", status: "accepted", projection: {},
|
||||
acceptBy: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000) }).returning();
|
||||
const sourceEventId = randomUUID();
|
||||
await db.insert(dotMailboxItems).values({ companyId: f.company.id, bindingId: f.pairing.bindingId, bindingGeneration: 1,
|
||||
assignmentId: work!.id, kind: "follow_up", sourceEventId, references: { consumed: false } });
|
||||
f.delivery.mockClear(); f.delivery.mockResolvedValueOnce({ delivery: "rejected", httpStatus: 429 });
|
||||
const dispatcher = createGrokRoutineDispatcher(db, () => true, f.delivery);
|
||||
await dispatcher.tick(); await dispatcher.tick();
|
||||
expect(f.delivery).toHaveBeenCalledTimes(1);
|
||||
const [rejected] = await db.select().from(grokBotDispatches).where(and(eq(grokBotDispatches.assignmentId, work!.id), eq(grokBotDispatches.sourceEventId, sourceEventId)));
|
||||
expect(rejected).toMatchObject({ status: "rejected" });
|
||||
await f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, rejected!.id);
|
||||
await dispatcher.tick();
|
||||
expect(f.delivery).toHaveBeenCalledTimes(2);
|
||||
const [input] = await db.select().from(dotMailboxItems).where(eq(dotMailboxItems.sourceEventId, sourceEventId));
|
||||
expect(input?.references).toMatchObject({ consumed: false });
|
||||
});
|
||||
|
||||
it("never retries uncertain delivery or a rejection from replaced credentials", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValueOnce({ delivery: "unknown", httpStatus: 504 });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, row.id)).rejects.toThrow("confirmed rejected");
|
||||
await f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
const rejected = await verification(f);
|
||||
f.delivery.mockResolvedValueOnce({ delivery: "rejected", httpStatus: 401 });
|
||||
await f.service.send(f.company.id, f.agent.id, rejected.id);
|
||||
await f.service.configure({ ...f.config, expectedRevision: 1 });
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, rejected.id)).rejects.toThrow("authority changed");
|
||||
expect(f.delivery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for the binding before acquiring routine recovery locks", async () => {
|
||||
const f = await fixture();
|
||||
let recovery: Promise<unknown> | undefined;
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await tx.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, f.pairing.bindingId)).for("update");
|
||||
recovery = f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
void recovery.catch(() => {});
|
||||
await vi.waitFor(async () => {
|
||||
const result = await db.execute(sql`select exists (select 1 from pg_stat_activity where datname = current_database()
|
||||
and pid <> pg_backend_pid() and wait_event_type = 'Lock' and query like '%dot_agent_bindings%') as waiting`);
|
||||
const row = Array.isArray(result) ? result[0] : result.rows[0];
|
||||
expect(row?.waiting).toBe(true);
|
||||
}, { timeout: 5000 });
|
||||
await tx.execute(sql`set local lock_timeout = '2s'`);
|
||||
// A reverse-order recovery holds this row while waiting for our binding.
|
||||
await tx.select().from(grokBotRoutines).where(eq(grokBotRoutines.bindingId, f.pairing.bindingId)).for("update");
|
||||
});
|
||||
} finally { await recovery; }
|
||||
});
|
||||
|
||||
it("holds uncertainty across disconnect and requires inspection plus a fresh verification", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValue({ delivery: "unknown", httpStatus: 504 });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: false, recoveryNeeded: false });
|
||||
await expect(f.service.acknowledge(f.principal, acknowledgement(row))).rejects.toThrow("cancelled");
|
||||
const next = await verification(f);
|
||||
expect(next.id).not.toBe(row.id);
|
||||
f.delivery.mockResolvedValue({ delivery: "accepted", httpStatus: 200 });
|
||||
await f.service.send(f.company.id, f.agent.id, next.id);
|
||||
await f.service.acknowledge(f.principal, acknowledgement(next));
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true });
|
||||
});
|
||||
it("keeps a revoked binding's unknown activity from being bypassed by reconnecting", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValue({ delivery: "unknown", httpStatus: null });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await dotRunnerBroker(db).revoke(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
expect(await f.service.recoveryNeeded(f.company.id, f.agent.id)).toBe(true);
|
||||
await f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
expect(await f.service.recoveryNeeded(f.company.id, f.agent.id)).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
createDb,
|
||||
environmentLeases,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRunWatchdogDecisions,
|
||||
heartbeatRuns,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { truncateTablesWithDeadlockRetry } from "./helpers/truncate-with-deadlock-retry.js";
|
||||
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
|
||||
import { createPostgresWatchdogAdapter } from "../modules/active-run-watchdog/adapters/postgres.js";
|
||||
import {
|
||||
ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS,
|
||||
ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS,
|
||||
@@ -446,6 +449,75 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["remote_lease", "workspace_finalize", "failed_finalize", "native_owner", "restore_failed"])(
|
||||
"leaves a terminal task's run with its lifecycle owner while %s is unsettled",
|
||||
async pending => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({ now, ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
|
||||
sourceStatus: "done", sameRunTerminalEvidence: true });
|
||||
if (pending === "remote_lease") {
|
||||
await db.insert(environmentLeases).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, status: "active", provider: "daytona", providerLeaseId: "sandbox-with-unsaved-files" });
|
||||
} else if (pending === "workspace_finalize" || pending === "failed_finalize") {
|
||||
await db.insert(workspaceOperations).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, phase: "workspace_finalize", status: pending === "failed_finalize" ? "failed" : "running" });
|
||||
} else if (pending === "native_owner") {
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native" }).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
} else {
|
||||
await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreFailure: "restore_failed" } })
|
||||
.where(eq(heartbeatRuns.id, seeded.runId));
|
||||
}
|
||||
const { recovery, enqueueWakeup } = createRecovery();
|
||||
expect(await recovery.scanSilentActiveRuns({ now, companyId: seeded.companyId }))
|
||||
.toMatchObject({ folded: 0, created: 0, skipped: 1 });
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, seeded.coderId));
|
||||
expect(run.status).toBe("running");
|
||||
expect(task).toMatchObject({ status: "done", executionRunId: seeded.runId });
|
||||
expect(agent.status).toBe("running");
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(heartbeatRunWatchdogDecisions).where(eq(heartbeatRunWatchdogDecisions.runId, seeded.runId)))
|
||||
.toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("folds stale bookkeeping after remote release and successful workspace finalization", async () => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({ now, ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
|
||||
sourceStatus: "done", sameRunTerminalEvidence: true });
|
||||
await db.insert(environmentLeases).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, status: "released", releasedAt: now, cleanupStatus: "success",
|
||||
provider: "daytona", providerLeaseId: "finished-sandbox" });
|
||||
await db.insert(workspaceOperations).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, phase: "workspace_finalize", status: "succeeded", finishedAt: now });
|
||||
expect(await createRecovery().recovery.scanSilentActiveRuns({ now, companyId: seeded.companyId }))
|
||||
.toMatchObject({ folded: 1, created: 0 });
|
||||
});
|
||||
|
||||
it("rechecks remote ownership when a lease appears after the initial fold check", async () => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({ now, ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
|
||||
sourceStatus: "done", sameRunTerminalEvidence: true });
|
||||
const adapter = createPostgresWatchdogAdapter(db);
|
||||
const run = await adapter.findRunForCompany(seeded.companyId, seeded.runId);
|
||||
expect(await adapter.isRuntimeSettlementPending(seeded.companyId, seeded.runId)).toBe(false);
|
||||
await db.insert(environmentLeases).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, status: "active", provider: "daytona", providerLeaseId: "late-owned-sandbox" });
|
||||
expect(await adapter.foldSourceResolvedRun(seeded.companyId, {
|
||||
run: run!, sourceIssue: { id: seeded.issueId, identifier: `${seeded.issuePrefix}-1`, status: "done",
|
||||
originKind: "manual", isRecoveryOriginKind: false },
|
||||
evidence: { kind: "activity", id: randomUUID(), action: "issue.updated", createdAt: now },
|
||||
existingEvaluation: null, silenceStartedAt: seeded.startedAt, silenceAgeMs: 16 * 60 * 1000,
|
||||
cleanup: { attempted: false, outcome: "no_process_metadata", adapterType: "grok_local" }, now,
|
||||
})).toEqual({ kind: "stale" });
|
||||
const [persisted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
expect(persisted.status).toBe("running");
|
||||
expect(persisted.resultJson).toBeNull();
|
||||
expect(await db.select().from(heartbeatRunWatchdogDecisions).where(eq(heartbeatRunWatchdogDecisions.runId, seeded.runId)))
|
||||
.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("folds existing legacy evaluation and recovery rows idempotently", async () => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
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, readFile, mkdtemp, rm } 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, companies, companyMemberships, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { agents, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
@@ -37,10 +42,14 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
const captured = new Map<string, AdapterExecutionContext>();
|
||||
const inspect = new Map<string, (input: AdapterExecutionContext) => Promise<void>>();
|
||||
const adapterType = "codex_local";
|
||||
const oldHome = process.env.PAPERCLIP_HOME;
|
||||
let home: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-execution-config-");
|
||||
db = createDb(temporary.connectionString);
|
||||
home = await mkdtemp(path.join(tmpdir(), "paperclip-execution-skills-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
heartbeat = heartbeatService(db);
|
||||
// Exercise real preparation for a managed-capable adapter without calling a provider.
|
||||
registerServerAdapter({
|
||||
@@ -60,6 +69,8 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
unregisterServerAdapter(adapterType);
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
if (oldHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = oldHome;
|
||||
if (home) await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seed(managed = false) {
|
||||
@@ -112,14 +123,36 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
expect(JSON.parse(await readFile(path.join(providerHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: f.credential });
|
||||
await input.onMeta?.({ adapterType, command: "fixture", env, prompt: "fixture" });
|
||||
});
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
|
||||
expect(input?.context.aiConnection).toMatchObject({ accountName: "Fixture account", provider: "openai" });
|
||||
expect(providerHome).toEqual(expect.any(String));
|
||||
await expect(access(providerHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, finished.id));
|
||||
expect(events.some(event => event.eventType === "adapter.invoke")).toBe(true);
|
||||
expect(JSON.stringify(events)).not.toContain(f.credential);
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] });
|
||||
expect(trace.setGlobalTracerProvider(provider)).toBe(true);
|
||||
try {
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
|
||||
expect(input?.context.aiConnection).toMatchObject({ accountName: "Fixture account", provider: "openai" });
|
||||
expect(providerHome).toEqual(expect.any(String));
|
||||
await expect(access(providerHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, finished.id));
|
||||
expect(events.some(event => event.eventType === "adapter.invoke")).toBe(true);
|
||||
expect(JSON.stringify(events)).not.toContain(f.credential);
|
||||
await provider.forceFlush();
|
||||
const runIdHash = createHash("sha256").update(finished.id).digest("hex").slice(0, 12);
|
||||
const steps = exporter.getFinishedSpans().filter(span => span.name.startsWith("heartbeat.prepare.configuration_")
|
||||
&& span.attributes["paperclip.task.run.run_id"] === runIdHash);
|
||||
expect(steps.map(span => span.name)).toEqual([
|
||||
"configuration_mentions", "configuration_github_identity", "configuration_secrets", "configuration_ai_runtime",
|
||||
"configuration_skills", "configuration_connector_assignments", "configuration_connector_skills",
|
||||
"configuration_connector_delivery", "configuration_connection_instructions", "configuration_agent_identity",
|
||||
].map(step => `heartbeat.prepare.${step}`));
|
||||
for (const span of steps) {
|
||||
expect(span.status).toEqual({ code: 0 });
|
||||
expect(span.attributes).toEqual({ "paperclip.task.run.run_id": runIdHash });
|
||||
expect(span.events).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
trace.disable();
|
||||
await provider.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans acquired credentials and redacts diagnostics when later skill preparation fails", async () => {
|
||||
@@ -134,18 +167,36 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
await access(acquiredHome!);
|
||||
return runtime;
|
||||
});
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status).toBe("failed");
|
||||
expect(input).toBeUndefined();
|
||||
expect(acquiredHome).toEqual(expect.any(String));
|
||||
await expect(access(acquiredHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(finished.error).toContain("Invalid fixture skill configuration");
|
||||
// The diagnostic reporter redacts runtime values before the external sink.
|
||||
// The legacy local run.error field does not apply this value-based redaction.
|
||||
await waitForPendingRunFailureReports();
|
||||
expect(capturedFailure).toHaveBeenCalled();
|
||||
expect(JSON.stringify(capturedFailure.mock.calls)).toContain("Invalid fixture skill configuration");
|
||||
expect(JSON.stringify(capturedFailure.mock.calls)).not.toContain(f.credential);
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] });
|
||||
expect(trace.setGlobalTracerProvider(provider)).toBe(true);
|
||||
try {
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status).toBe("failed");
|
||||
expect(input).toBeUndefined();
|
||||
expect(acquiredHome).toEqual(expect.any(String));
|
||||
await expect(access(acquiredHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(finished.error).toContain("Invalid fixture skill configuration");
|
||||
await waitForPendingRunFailureReports();
|
||||
expect(capturedFailure).toHaveBeenCalled();
|
||||
expect(JSON.stringify(capturedFailure.mock.calls)).toContain("Invalid fixture skill configuration");
|
||||
expect(JSON.stringify(capturedFailure.mock.calls)).not.toContain(f.credential);
|
||||
await provider.forceFlush();
|
||||
const runIdHash = createHash("sha256").update(finished.id).digest("hex").slice(0, 12);
|
||||
const steps = exporter.getFinishedSpans().filter(span => span.name.startsWith("heartbeat.prepare.configuration_")
|
||||
&& span.attributes["paperclip.task.run.run_id"] === runIdHash);
|
||||
expect(steps.find(span => span.name === "heartbeat.prepare.configuration_ai_runtime")?.status.code).toBe(0);
|
||||
expect(steps.find(span => span.name === "heartbeat.prepare.configuration_skills")?.status).toEqual({ code: 2 });
|
||||
expect(steps.some(span => span.name === "heartbeat.prepare.configuration_connector_assignments")).toBe(false);
|
||||
for (const span of steps) {
|
||||
expect(span.attributes).toEqual({ "paperclip.task.run.run_id": runIdHash });
|
||||
expect(span.events).toEqual([]);
|
||||
}
|
||||
expect(JSON.stringify(steps.map(span => ({ name: span.name, status: span.status, attributes: span.attributes, events: span.events })))).not.toContain(f.credential);
|
||||
} finally {
|
||||
trace.disable();
|
||||
await provider.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("defers a busy account without dispatch and preserves its scheduled retry", async () => {
|
||||
@@ -157,4 +208,40 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, f.agentId));
|
||||
expect(runs.filter(run => run.id !== finished.id)).toEqual([expect.objectContaining({ status: "scheduled_retry", scheduledRetryReason: "ai_connection_busy" })]);
|
||||
});
|
||||
|
||||
it("snapshots only selected immutable skills and omits inaccessible optional assignments", async () => {
|
||||
const f = await seed();
|
||||
const skills = companySkillService(db);
|
||||
const selected = await skills.createLocalSkill(f.companyId, { name: "Selected workflow", markdown: "# Selected\nUse the original workflow.\n" });
|
||||
const unused = await skills.createLocalSkill(f.companyId, { name: "Unused workflow" });
|
||||
const restricted = await skills.createLocalSkill(f.companyId, { name: "Restricted workflow" });
|
||||
await db.update(companySkills).set({ accessSourceId: randomUUID() }).where(eq(companySkills.id, restricted.id));
|
||||
await db.update(agents).set({ adapterConfig: { paperclipSkillSync: { desiredSkills: [restricted.key] } } }).where(eq(agents.id, f.agentId));
|
||||
await db.update(issues).set({ description: "Use [/selected](skill://" + selected.id + "?s=selected-workflow)." }).where(eq(issues.id, f.issueId));
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
|
||||
const inventory = input?.config.paperclipRuntimeSkills as Array<{ key: string; source: string; versionId: string }>;
|
||||
expect(inventory.some(entry => entry.key === unused.key || entry.key === restricted.key)).toBe(false);
|
||||
const delivered = inventory.find(entry => entry.key === selected.key)!;
|
||||
expect(delivered.versionId).toBe(selected.currentVersionId);
|
||||
expect(delivered.source).toContain(selected.currentVersionId!);
|
||||
expect(await readFile(path.join(delivered.source, "SKILL.md"), "utf8")).toContain("Use the original workflow.");
|
||||
expect(finished.contextSnapshot).toMatchObject({ skillSelectionSnapshot: true, skillVersions: { [selected.key]: selected.currentVersionId } });
|
||||
expect(Object.keys(finished.contextSnapshot!.skillVersions as object)).not.toContain(unused.key);
|
||||
expect(Object.keys(finished.contextSnapshot!.skillVersions as object)).not.toContain(restricted.key);
|
||||
});
|
||||
|
||||
it("rejects an inaccessible explicit skill reference before adapter dispatch", async () => {
|
||||
const f = await seed();
|
||||
const skill = await companySkillService(db).createLocalSkill(f.companyId, { name: "Private reference" });
|
||||
await db.update(companySkills).set({ accessSourceId: randomUUID() }).where(eq(companySkills.id, skill.id));
|
||||
await db.update(issues).set({ description: "Use [/private](skill://" + skill.id + "?s=private-reference)." }).where(eq(issues.id, f.issueId));
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status).toBe("failed");
|
||||
expect(finished.error).toContain("referenced skill is unavailable");
|
||||
expect(input).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import * as conversationContinuation from "../services/conversation-continuation.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
@@ -16789,3 +16790,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { activityRoutes } from "../routes/activity.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { authorizationService } from "../services/authorization.js";
|
||||
import { getHeartbeatRunReadDecisions } from "../services/heartbeat-run-privacy.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -43,6 +45,7 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
|
||||
}, 120_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(activityLog);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
@@ -424,4 +427,103 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
}
|
||||
});
|
||||
it.each(["owner", "outsider"])("checks concurrent duplicate issue runs once for the %s", async (viewer) => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const decide = vi.spyOn(access, "decide");
|
||||
const actor = {
|
||||
type: "agent" as const,
|
||||
companyId: fixture.companyId,
|
||||
agentId: viewer === "owner" ? fixture.ownerAgentId : fixture.otherAgentId,
|
||||
};
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
const decisions = await getHeartbeatRunReadDecisions(db, access, actor, Array.from({ length: 200 }, () => ({ ...binding })));
|
||||
expect(decisions).toEqual(Array(200).fill(viewer === "owner"));
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
decide.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps company scope, issue scope, and company identity separate", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding, { ...binding, companyId: randomUUID() }])).toEqual([true, false]);
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, { ...actor, agentId: fixture.otherAgentId }, [
|
||||
{ ...binding, scopeKind: "company" }, binding, { ...binding, scopeKind: null },
|
||||
{ companyId: fixture.companyId, scopeKind: "issue" },
|
||||
])).toEqual([true, false, false, false]);
|
||||
});
|
||||
|
||||
it("uses the captured binding if a caller changes its input while checks are pending", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const decide = vi.spyOn(access, "decide");
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
const pending = getHeartbeatRunReadDecisions(db, access, actor, [binding, binding]);
|
||||
binding.issueId = randomUUID();
|
||||
expect(await pending).toEqual([true, true]);
|
||||
expect(decide).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
resource: expect.objectContaining({ companyId: fixture.companyId, issueId: fixture.issueId }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not share decisions between actors", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([true]);
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, { ...actor, agentId: fixture.otherAgentId }, [binding])).toEqual([false]);
|
||||
});
|
||||
|
||||
it("rechecks a revoked grant on the next history request", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const duplicateId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: duplicateId, companyId: fixture.companyId, agentId: fixture.ownerAgentId,
|
||||
scopeKind: "issue", issueId: fixture.issueId, invocationSource: "on_demand", status: "succeeded",
|
||||
resultJson: { summary: "PRIVATE_HISTORY_CANARY" },
|
||||
});
|
||||
const app = createApp(fixture.companyId, fixture.grantedAgentId);
|
||||
const path = `/api/companies/${fixture.companyId}/heartbeat-runs`;
|
||||
const permitted = await request(app).get(path);
|
||||
expect(permitted.status).toBe(200);
|
||||
expect(permitted.body.find((run: { id: string }) => run.id === duplicateId).resultJson.summary).toBe("PRIVATE_HISTORY_CANARY");
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.subjectId, fixture.grantedAgentId));
|
||||
for (const suffix of ["", "?summary=true"]) {
|
||||
const denied = await request(app).get(path + suffix);
|
||||
expect(denied.status).toBe(200);
|
||||
for (const id of [fixture.privateRunId, duplicateId]) {
|
||||
expect(denied.body.find((run: { id: string }) => run.id === id)).toMatchObject({ id, redacted: true });
|
||||
}
|
||||
expect(JSON.stringify(denied.body)).not.toContain("PRIVATE_HISTORY_CANARY");
|
||||
expect(JSON.stringify(denied.body)).not.toContain("private@example.test");
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when a shared decision rejects and can recover in a new request", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const error = new Error("authorization unavailable");
|
||||
const decide = vi.spyOn(access, "decide").mockRejectedValue(error);
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
await expect(getHeartbeatRunReadDecisions(db, access, actor, Array(20).fill(binding))).rejects.toBe(error);
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
decide.mockRestore();
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([true]);
|
||||
});
|
||||
|
||||
it("rejects an issue deleted before the next request", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([true]);
|
||||
await db.delete(issues).where(eq(issues.id, fixture.issueId));
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([false]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
agents,
|
||||
companies,
|
||||
companySkills,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
createDb,
|
||||
toolApplications,
|
||||
toolConnectionInstalls,
|
||||
@@ -110,9 +112,10 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
// Terminal status precedes trailing writes; drain them before truncating the fixture.
|
||||
await heartbeatService(db).drainActiveRunExecutions();
|
||||
capturedRuns.length = 0;
|
||||
await instanceSettingsService(db).updateExperimental({ enableBetaSkills: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await db.execute(sql.raw(`
|
||||
TRUNCATE TABLE
|
||||
"activity_log",
|
||||
@@ -144,6 +147,39 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it.each(["local_path", "catalog"] as const)("snapshots changed installed %s files for new executions and keeps resume versions", async (sourceType) => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: 'Local skill snapshots', issuePrefix: 'LSK' });
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'paperclip-local-snapshot-'));
|
||||
cleanupDirs.add(directory);
|
||||
const [project] = await db.insert(projects).values({ companyId, name: 'Approved local workspace' }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId, projectId: project!.id, name: 'Local skill workspace', sourceType: 'local_path', cwd: directory, isPrimary: true });
|
||||
await fs.mkdir(path.join(directory, 'references'));
|
||||
await fs.writeFile(path.join(directory, 'SKILL.md'), '---\nname: local-snapshot\ndescription: Read the installed reference.\n---\nRead references/marker.txt.\n');
|
||||
await fs.writeFile(path.join(directory, 'references/marker.txt'), 'v1\n');
|
||||
const skills = companySkillService(db);
|
||||
const imported = await skills.importFromSource(companyId, directory);
|
||||
const key = imported.imported[0]!.key;
|
||||
if (sourceType === "catalog") await db.update(companySkills).set({ sourceType }).where(eq(companySkills.key, key));
|
||||
const selected = { snapshotLatest: true, onlyKeys: new Set([key]) };
|
||||
const [first] = await skills.listRuntimeSkillEntries(companyId, selected);
|
||||
expect(first!.versionId).toBeTruthy();
|
||||
expect(await fs.readFile(path.join(first!.source, 'references/marker.txt'), 'utf8')).toBe('v1\n');
|
||||
|
||||
// A reference-only change must be discovered even when SKILL.md is unchanged.
|
||||
await fs.writeFile(path.join(directory, 'references/marker.txt'), 'v2\n');
|
||||
if (sourceType === "local_path") await skills.importFromSource(companyId, directory);
|
||||
const [latest] = await skills.listRuntimeSkillEntries(companyId, selected);
|
||||
expect(latest!.versionId).not.toBe(first!.versionId);
|
||||
expect(await fs.readFile(path.join(latest!.source, 'references/marker.txt'), 'utf8')).toBe('v2\n');
|
||||
const [unchanged] = await skills.listRuntimeSkillEntries(companyId, selected);
|
||||
expect(unchanged!.versionId).toBe(latest!.versionId);
|
||||
|
||||
const [resumed] = await skills.listRuntimeSkillEntries(companyId, { ...selected, versionSelections: new Map([[key, first!.versionId!]]) });
|
||||
expect(resumed!.versionId).toBe(first!.versionId);
|
||||
expect(await fs.readFile(path.join(resumed!.source, 'references/marker.txt'), 'utf8')).toBe('v1\n');
|
||||
});
|
||||
|
||||
it("materializes different pinned skill versions for different agents at runtime", async () => {
|
||||
const companyId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
@@ -292,7 +328,7 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
|
||||
?.skills.find((entry) => entry.key === skillKey);
|
||||
expect(defaultSkill).toMatchObject({
|
||||
key: skillKey,
|
||||
versionId: null,
|
||||
versionId: versionTwo.id,
|
||||
currentVersionId: versionTwo.id,
|
||||
sourceStatus: "available",
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -143,3 +144,6 @@ describeEmbeddedPostgres(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -89,6 +89,7 @@ describePostgres("heartbeat task context delivered to an adapter", () => {
|
||||
wakeCommentId: commentId, wakeCommentIds: [commentId],
|
||||
paperclipTaskCommunicationGuidance: "FORGED caller guidance", paperclipSkillTest: { directive: "FORGED skill" },
|
||||
paperclipContinuationSummary: { body: "FORGED summary" },
|
||||
paperclipAgentJwtScope: { kind: "skill_test", issueId: f.issueId },
|
||||
});
|
||||
expect(context.paperclipTaskMarkdown).toContain("Assignment description canary");
|
||||
expect(context.paperclipTaskMarkdown).toContain("Wake comment canary");
|
||||
@@ -103,6 +104,7 @@ describePostgres("heartbeat task context delivered to an adapter", () => {
|
||||
}
|
||||
expect(context.paperclipTaskCommunicationGuidance).toBe(FAST_RESPONSE_AGENT_GUIDANCE);
|
||||
expect(context.paperclipSkillTest).toBeUndefined();
|
||||
expect(context.paperclipAgentJwtScope).toEqual({ kind: "standard" });
|
||||
expect(context.paperclipContinuationSummary).toBeUndefined();
|
||||
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
expect(context.paperclipTurnContext).toMatchObject({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
@@ -1077,3 +1078,6 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => {
|
||||
expect(holderRun?.status).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -26,6 +26,7 @@ import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { findNativeChatWorkspaceScope } from "../services/native-runtime/native-chat-workspace.js";
|
||||
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn(async (_input: any) => ({ exitCode: 0, signal: null, timedOut: false })));
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
@@ -149,6 +150,7 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
["configured", "both", "local_default"], ["builtin", "both", "local_default"],
|
||||
["configured", "both", "local_new_default"], ["builtin", "both", "local_new_default"],
|
||||
["configured", "both", "local_new_primary"], ["builtin", "both", "local_new_primary"],
|
||||
["configured", "agent", "local_effective_strategy"], ["builtin", "agent", "local_effective_strategy"],
|
||||
] as const)("places standard source %s provisioning with %s grants on %s", async (provisionKind, grants, placement) => {
|
||||
const implicitDefault = placement.startsWith("local_");
|
||||
const newerDefault = placement === "local_new_default" || placement === "local_new_primary";
|
||||
@@ -171,16 +173,18 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
} });
|
||||
if (newerDefault) await db.insert(projectWorkspaces).values({ id: randomUUID(), companyId, projectId, name: "Older source", sourceType: "local_path", cwd: path.join(root, companyId, "older-source"), isPrimary: placement === "local_new_default", createdAt: new Date(0) });
|
||||
await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "Source", sourceType: "local_path", cwd, repoUrl: pathToFileURL(cwd).href, isPrimary: placement !== "local_new_default" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Reader", status: "idle", adapterType: "codex_local", adapterConfig: { fullAuto: grants === "both" }, permissions: {} });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Reader", status: "idle", adapterType: "codex_local", adapterConfig: { fullAuto: grants === "both",
|
||||
...(placement === "local_effective_strategy" ? { workspaceStrategy: { type: "project_primary" } } : {}) }, permissions: {} });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, status: "active", membershipRole: "member" });
|
||||
const taskInput = { id: issueId, companyId, projectId: implicitDefault ? projectId : null, title: "Read-only isolated source", status: "todo" as const, assigneeAgentId: agentId,
|
||||
projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree",
|
||||
worktreeParentDir: path.join(root, companyId, "worktrees"), ...(provisionKind === "configured" ? { provisionCommand: `printf host-script-ran > '${marker}'` } : {}) } },
|
||||
workspaceSelection: implicitDefault ? null : { version: 1 as const, source: "explicit" as const, selection: { kind: "configured_source" as const, projectWorkspaceId: sourceId, mode: "managed_isolated" as const } } };
|
||||
if (newerDefault) {
|
||||
if (newerDefault || placement === "local_effective_strategy") {
|
||||
const { projectWorkspaceId: _source, workspaceSelection: _selection, ...createInput } = taskInput;
|
||||
const created = await issueService(db).create(companyId, createInput);
|
||||
expect(created.projectWorkspaceId).toBe(sourceId);
|
||||
expect(created.workspaceSelection).toMatchObject({ source: "project" });
|
||||
} else await db.insert(issues).values(taskInput);
|
||||
if (grants !== "none") await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: agentId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId, assigneeAgentId: agentId } });
|
||||
@@ -189,6 +193,12 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
const actor = { type: "agent" as const, agentId, companyId, source: "agent_jwt" as const, onBehalfOfUserId: "responsible-user" };
|
||||
expect(await accessService(db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId } })).toMatchObject({ allowed: true });
|
||||
expect(await accessService(db).decide({ actor, action: "project:write_workspace", resource: { type: "issue", companyId, projectId, issueId, assigneeAgentId: agentId }, scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: grants === "both" });
|
||||
if (placement === "local_effective_strategy") {
|
||||
const selection = { kind: "configured_source" as const, projectWorkspaceId: sourceId, mode: "managed_isolated" as const };
|
||||
await expect(executionWorkspaceService(db).validateSelection({ companyId, actor, issueId, selection })).rejects.toThrow("project:write_workspace");
|
||||
await expect(executionWorkspaceService(db).validateSelection({ companyId, actor, issueId,
|
||||
selection: { ...selection, mode: "shared" }, preserveImplicitDefaultSettings: true })).rejects.toThrow("Task workspace default changed");
|
||||
}
|
||||
const environmentId = randomUUID();
|
||||
await db.insert(environments).values({ id: environmentId, name: `Standard sandbox ${companyId}`, driver: "sandbox", status: "active", config: { provider: "fake", image: "fake:test" } });
|
||||
await db.update(agents).set({ defaultEnvironmentId: placement === "sandbox" ? environmentId : null }).where(eq(agents.id, agentId));
|
||||
@@ -205,6 +215,7 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!));
|
||||
expect(workspace.cwd).not.toBe(cwd);
|
||||
expect(await readFile(path.join(workspace.cwd!, "source.txt"), "utf8")).toBe("read-only source bytes");
|
||||
expect(await readFile(path.join(cwd, "source.txt"), "utf8")).toBe("read-only source bytes");
|
||||
const calls = execute.mock.calls.filter(([input]) => input.runId === run!.id);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].context.paperclipEnvironment.driver).toBe(placement === "sandbox" ? "sandbox" : "local");
|
||||
@@ -213,7 +224,6 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
await rm(marker, { force: true });
|
||||
if (grants === "none") await rm(workspace.cwd!, { recursive: true, force: true });
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
const { executionWorkspaceService } = await import("../services/execution-workspaces.js");
|
||||
if (placement === "local" && grants === "both") await db.update(issues).set({ projectId, workspaceSelection: null }).where(eq(issues.id, issueId));
|
||||
await executionWorkspaceService(db).update(workspace.id, { metadata: { ...workspace.metadata,
|
||||
_controllerProvisionCopyOnly: false, config: { provisionCommand: `printf host-script-ran > '${marker}'` } } });
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { GitHubRead } from "../../services/github-skill-source.js";
|
||||
export function githubFixture(files: Record<string, string | Buffer>, modes: Record<string, string> = {}, commit = "a".repeat(40)): GitHubRead {
|
||||
const entries = Object.keys(files).map((path, i) => ({ path, type: modes[path] === '160000' ? 'commit' : 'blob', mode: modes[path] ?? '100644', sha: String(i), size: Buffer.byteLength(files[path]!) }));
|
||||
const treeSha = createHash("sha1").update(JSON.stringify(entries.map(entry => [entry.path, entry.mode, files[entry.path]]).sort())).digest("hex");
|
||||
const read = (async (url: string) => {
|
||||
if (url === '/repos/acme/skills') return { id: 42, full_name: 'acme/skills', default_branch: 'main' };
|
||||
if (url.startsWith('/repos/acme/skills/commits/')) return { sha: commit };
|
||||
if (url.startsWith('/repos/acme/skills/commits/')) return { sha: commit, commit: { tree: { sha: treeSha } } };
|
||||
throw new Error(`Unexpected request ${url}`);
|
||||
}) as GitHubRead;
|
||||
read.openSnapshot = async (_input, options) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
await options?.onDownload?.({ stage: 'receiving', percent: 50, receivedBytes: 1024 });
|
||||
return { commitSha: commit, entries, readBlob: async entry => Buffer.from(files[entry.path]!), release: async () => {} };
|
||||
return { commitSha: commit, treeSha, downloadBytes: entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0), entries, readBlob: async entry => Buffer.from(files[entry.path]!), release: async () => {} };
|
||||
};
|
||||
return read;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { afterEach, beforeEach } from "vitest";
|
||||
|
||||
export function enableRunFailureReporting(): void {
|
||||
let previousMode: string | undefined;
|
||||
beforeEach(() => {
|
||||
previousMode = process.env.PAPERCLIP_SENTRY_RUN_FAILURES;
|
||||
process.env.PAPERCLIP_SENTRY_RUN_FAILURES = "unexpected";
|
||||
});
|
||||
afterEach(() => {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_SENTRY_RUN_FAILURES;
|
||||
else process.env.PAPERCLIP_SENTRY_RUN_FAILURES = previousMode;
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { inspectIdleSpool, inspectIdleQuotaRecovery, idleQuotaRecoveryPath, readIdleLocalWork, markIdleIngressTracked, markIdleStartupComplete, registerIdleSpoolDirectory } from "../services/idle-local-work.js";
|
||||
import { inspectIdleSpool, inspectIdleQuotaRecovery, idleQuotaRecoveryPath, readIdleLocalWork, markIdleIngressTracked, markIdleStartupComplete, registerIdleSpoolDirectory, registerIdleLocalWorkProbe } from "../services/idle-local-work.js";
|
||||
|
||||
let root: string;
|
||||
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "idle-spool-")); });
|
||||
@@ -31,6 +31,22 @@ describe("idle cleanup inspection", () => {
|
||||
vi.spyOn(fs, "opendir").mockRejectedValue(Object.assign(new Error("private path"), { code: "EACCES" }));
|
||||
expect(await inspectIdleSpool(root)).toBe("unknown");
|
||||
});
|
||||
it("retains failed local recovery and fails closed on probe errors", async () => {
|
||||
vi.stubEnv("PAPERCLIP_HOME", root);
|
||||
markIdleIngressTracked(); markIdleStartupComplete({ scheduledBackups: false });
|
||||
let pending = true;
|
||||
const unregister = registerIdleLocalWorkProbe(() => pending ? "present" : "none");
|
||||
try {
|
||||
expect(await readIdleLocalWork()).toBe("present");
|
||||
pending = false;
|
||||
expect(await readIdleLocalWork()).toBe("none");
|
||||
} finally { unregister(); }
|
||||
const removeBroken = registerIdleLocalWorkProbe(() => { throw new Error("unknown deadline"); });
|
||||
try { expect(await readIdleLocalWork()).toBe("unknown"); }
|
||||
finally { removeBroken(); }
|
||||
expect(await readIdleLocalWork()).toBe("none");
|
||||
});
|
||||
|
||||
it("checks custom spool directories as well as the default paths", async () => {
|
||||
vi.stubEnv("PAPERCLIP_HOME", root);
|
||||
vi.stubEnv("SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR", path.join(root, "orphans"));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { readIdleRoutineSchedule } from "../services/idle-scheduled-work.js";
|
||||
|
||||
const now = Date.UTC(2020, 0, 1);
|
||||
const trigger = { id: "fixture", routine_id: "routine", company_id: "company", kind: "schedule",
|
||||
setup_pending: false, cron_expression: "*/15 * * * *", timezone: "UTC", scoped: true,
|
||||
next_run_at: new Date(now + 600_000), routine_updated: new Date(now), trigger_updated: new Date(now) };
|
||||
function database(rows: unknown[]) {
|
||||
return { execute: vi.fn().mockResolvedValueOnce([{ blocked: false }]).mockResolvedValueOnce(rows) } as unknown as Pick<Db, "execute">;
|
||||
}
|
||||
describe("bounded idle routine catalog", () => {
|
||||
it("validates shared timezones once in a full-size catalog", async () => {
|
||||
const formatter = vi.spyOn(Intl, "DateTimeFormat");
|
||||
try {
|
||||
const rows = Array.from({ length: 10_000 }, (_, index) => ({ ...trigger, id: String(index) }));
|
||||
const result = await readIdleRoutineSchedule(database(rows), "owner", now);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.scheduledWake?.nextWakeAt).toBe(trigger.next_run_at.toISOString());
|
||||
expect(formatter).toHaveBeenCalledOnce();
|
||||
} finally { formatter.mockRestore(); }
|
||||
});
|
||||
it("refuses an oversized catalog before expensive timezone validation", async () => {
|
||||
const formatter = vi.spyOn(Intl, "DateTimeFormat");
|
||||
try {
|
||||
expect(await readIdleRoutineSchedule(database(Array(10_001).fill(trigger)), "owner", now))
|
||||
.toEqual({ blocked: true });
|
||||
expect(formatter).not.toHaveBeenCalled();
|
||||
} finally { formatter.mockRestore(); }
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
agentInstructionWorkingCopies, agents, agentApiKeys, agentWakeupRequests, companies, companySecretProposals, createDb,
|
||||
adapterAuthSessions, environments, environmentLeases, executionWorkspaces,
|
||||
heartbeatRuns, issues, issueWatchdogs, issueThreadInteractions, issueQuestionResponseDeliveries,
|
||||
projects, projectWorkspaces, routines, toolApplications, toolConnections, workspaceOperations, executionWorkspaceRuntimeLeases, type Db,
|
||||
projects, projectWorkspaces, routines, routineTriggers, toolApplications, toolConnections, workspaceOperations, executionWorkspaceRuntimeLeases, type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { readIdleSleepSafety, type IdleSleepDrainStatus } from "../services/idle-sleep-safety.js";
|
||||
@@ -175,6 +175,7 @@ if (!support.supported) console.warn(`Skipping idle sleep Postgres tests: ${supp
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
const read = () => readIdleSleepSafety(db, owned, () => now, ownerId, emptyLocal);
|
||||
const readScheduled = () => readIdleSleepSafety(db, owned, () => now, ownerId, emptyLocal, undefined, undefined, true);
|
||||
async function seed() {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
@@ -185,6 +186,84 @@ if (!support.supported) console.warn(`Skipping idle sleep Postgres tests: ${supp
|
||||
return { companyId, agentId, runId };
|
||||
}
|
||||
|
||||
async function scheduledRoutine(at = now + 600_000) {
|
||||
const fixture = await seed();
|
||||
const [routine] = await db.insert(routines).values({ companyId: fixture.companyId,
|
||||
title: "Scheduled fixture", status: "active", assigneeAgentId: fixture.agentId }).returning();
|
||||
const [trigger] = await db.insert(routineTriggers).values({ companyId: fixture.companyId,
|
||||
routineId: routine!.id, kind: "schedule", cronExpression: "*/15 * * * *", timezone: "UTC",
|
||||
nextRunAt: new Date(at), enabled: true, archived: false, setupPending: false }).returning();
|
||||
return { ...fixture, routine: routine!, trigger: trigger! };
|
||||
}
|
||||
|
||||
it("scheduled routines require the opt-in report and retain their durable definitions", async () => {
|
||||
const f = await scheduledRoutine();
|
||||
expect(await read()).toEqual(present);
|
||||
const result = await readScheduled();
|
||||
expect(result).toMatchObject({ version: 2, backgroundWork: "none", scheduledWake: {
|
||||
kind: "routines", version: 1, nextWakeAt: new Date(now + 600_000).toISOString(),
|
||||
} });
|
||||
expect(result.scheduledWake?.generation).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(await db.select().from(routineTriggers).where(eq(routineTriggers.id, f.trigger.id)))
|
||||
.toMatchObject([{ nextRunAt: f.trigger.nextRunAt, cronExpression: f.trigger.cronExpression }]);
|
||||
expect((await readScheduled()).scheduledWake).toEqual(result.scheduledWake);
|
||||
});
|
||||
it("scheduled routines cover all companies and invalidate a moved-earlier alarm", async () => {
|
||||
await scheduledRoutine(now + 900_000);
|
||||
const f = await scheduledRoutine(now + 600_000);
|
||||
const first = await readScheduled();
|
||||
expect(first.scheduledWake?.nextWakeAt).toBe(new Date(now + 600_000).toISOString());
|
||||
await db.update(routineTriggers).set({ nextRunAt: new Date(now + 300_000) }).where(eq(routineTriggers.id, f.trigger.id));
|
||||
const edited = await readScheduled();
|
||||
expect(edited.scheduledWake?.nextWakeAt).toBe(new Date(now + 300_000).toISOString());
|
||||
expect(edited.scheduledWake?.generation).not.toBe(first.scheduledWake?.generation);
|
||||
});
|
||||
it.each([
|
||||
{ kind: "webhook" }, { setupPending: true }, { nextRunAt: null },
|
||||
{ nextRunAt: new Date(now) }, { cronExpression: "bad" }, { timezone: "Unknown/Zone" },
|
||||
])("scheduled routines retain unsupported or due work: %j", async change => {
|
||||
const f = await scheduledRoutine();
|
||||
await db.update(routineTriggers).set(change).where(eq(routineTriggers.id, f.trigger.id));
|
||||
expect(await readScheduled()).toEqual(present);
|
||||
});
|
||||
it("scheduled routines cannot exempt external API credentials or an unowned hold", async () => {
|
||||
const f = await scheduledRoutine();
|
||||
expect(await readIdleSleepSafety(db, owned, () => now, "stale", emptyLocal, undefined, undefined, true)).toEqual(unknown);
|
||||
await db.insert(agentApiKeys).values({ companyId: f.companyId, agentId: f.agentId,
|
||||
name: "Fixture external automation", keyHash: "fixture-hash" });
|
||||
expect(await readScheduled()).toEqual(present);
|
||||
});
|
||||
it("scheduled routines ignore disabled triggers and fail closed on unknown routine states", async () => {
|
||||
const f = await scheduledRoutine();
|
||||
await db.update(routineTriggers).set({ enabled: false }).where(eq(routineTriggers.id, f.trigger.id));
|
||||
expect(await readScheduled()).toEqual(none);
|
||||
await db.update(routines).set({ status: "unknown" }).where(eq(routines.id, f.routine.id));
|
||||
expect(await readScheduled()).toEqual(present);
|
||||
});
|
||||
it("scheduled routines reject a trigger with a different company", async () => {
|
||||
const f = await scheduledRoutine();
|
||||
const other = await seed();
|
||||
await db.update(routineTriggers).set({ companyId: other.companyId }).where(eq(routineTriggers.id, f.trigger.id));
|
||||
expect(await readScheduled()).toEqual(present);
|
||||
});
|
||||
it.each(["checkpoint", "local inspection"])("keeps a routine awake when it becomes due during %s", async stage => {
|
||||
await scheduledRoutine(now + 1000);
|
||||
let clock = now;
|
||||
let inspections = 0;
|
||||
const inspect = async () => {
|
||||
if (++inspections === 2 && stage === "local inspection") clock += 1000;
|
||||
return "none" as const;
|
||||
};
|
||||
const checkpoint = stage === "checkpoint" ? async () => {
|
||||
const done = beginIdleTrackedWork();
|
||||
clock += 1000;
|
||||
done();
|
||||
return true;
|
||||
} : undefined;
|
||||
expect(await readIdleSleepSafety(db, owned, () => clock, ownerId, inspect, undefined, checkpoint, true)).toEqual(present);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["succeeded", "failed", "cancelled", "timed_out", "interrupted"])(
|
||||
"permits a finished %s run with a consumed retry date without changing history", async status => {
|
||||
const { runId } = await seed();
|
||||
|
||||
@@ -907,7 +907,7 @@ describe("instance settings routes", () => {
|
||||
await request(createApp(adminActor)).post("/api/instance/task-drain").send({ purpose: "idle", ttlMs: 60_000 }).expect(200);
|
||||
expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ purpose: "idle", ttlMs: 60_000 });
|
||||
await request(createApp(adminActor)).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200);
|
||||
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined);
|
||||
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined, false);
|
||||
});
|
||||
|
||||
it.each([{}, { ttlMs: null }, { ttlMs: 1 }, { ttlMs: 300_001 }])("rejects an unbounded or invalid idle TTL: %j", async body => {
|
||||
@@ -934,10 +934,23 @@ describe("instance settings routes", () => {
|
||||
const res = await request(createApp(adminActor)).get("/api/instance/task-drain?idleSleepSafety=1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ...idleStatus, idleSleepSafety: report });
|
||||
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined);
|
||||
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined, false);
|
||||
expect(mockReadIdleSleepSafety.mock.calls[0][1]()).toEqual(idleStatus);
|
||||
});
|
||||
|
||||
it("reserves routine deadline reports for verified Cloud control callers", async () => {
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
|
||||
for (const source of ["session", "cloud_tenant", "local_implicit"]) {
|
||||
await request(createApp({ ...adminActor, source }))
|
||||
.get("/api/instance/task-drain?idleSleepSafety=1&scheduledWake=1&ownerId=fixture-owner").expect(403);
|
||||
}
|
||||
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
|
||||
await request(createApp({ ...adminActor, source: "cloud_control" }))
|
||||
.get("/api/instance/task-drain?idleSleepSafety=1&scheduledWake=1&ownerId=fixture-owner").expect(200);
|
||||
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
|
||||
"fixture-owner", expect.any(Function), undefined, undefined, true);
|
||||
});
|
||||
|
||||
it("uses the live worker manager for inspection and releases only the owned hold", async () => {
|
||||
const inspect = vi.fn(async () => ({ backgroundWork: "none", pluginIds: [] }));
|
||||
const release = vi.fn();
|
||||
@@ -970,7 +983,7 @@ describe("instance settings routes", () => {
|
||||
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
|
||||
await request(app).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200);
|
||||
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
|
||||
"fixture-owner", expect.any(Function), undefined, backup);
|
||||
"fixture-owner", expect.any(Function), undefined, backup, false);
|
||||
mockReadIdleSleepSafety.mockClear();
|
||||
await request(createApp(nonAdminActor, undefined, backup)).get("/api/instance/task-drain?idleSleepSafety=1").expect(403);
|
||||
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
|
||||
@@ -992,7 +1005,7 @@ describe("instance settings routes", () => {
|
||||
const control = createApp({ ...adminActor, source: "cloud_control" }, undefined, backup);
|
||||
await request(control).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200);
|
||||
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
|
||||
"fixture-owner", expect.any(Function), undefined, backup);
|
||||
"fixture-owner", expect.any(Function), undefined, backup, false);
|
||||
} finally {
|
||||
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("instance settings service", () => {
|
||||
enableAgentChat: false,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableGrokBot: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
|
||||
@@ -162,8 +162,10 @@ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn(async () => null));
|
||||
|
||||
const mockRetryWorkspaceExport = vi.hoisted(() => vi.fn());
|
||||
const mockResumeWorkspaceFinalization = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("../services/native-runtime/native-workspace-owner-recovery.js", () => ({ resumeNativeWorkspaceAfterOwnerStop: mockResumeWorkspaceFinalization }));
|
||||
vi.doMock("../services/native-runtime/native-workspace-export-retry.js", () => ({ retryNativeWorkspaceExport: mockRetryWorkspaceExport }));
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: vi.fn(),
|
||||
@@ -2009,6 +2011,52 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes a verified stopped workspace owner without waking the provider", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: true });
|
||||
mockResumeWorkspaceFinalization.mockResolvedValue({ runId: ownerRunId, status: "queued" });
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
|
||||
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
|
||||
controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." });
|
||||
expect(res.status).toBe(202);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "runtime:confirm_owner_stop" }));
|
||||
expect(mockResumeWorkspaceFinalization).toHaveBeenCalledWith(expect.objectContaining({ companyId, issueId, runId: ownerRunId, ownerToken: ownerRunId, actorId: "board-user" }));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ controllerAndCopybackStopped: false }, { stopEvidence: "" }])("requires explicit stop evidence: %j", async invalid => {
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
|
||||
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
|
||||
controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited.", ...invalid });
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["agent", "viewer"])("rejects %s workspace-owner release before admission", async kind => {
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:read", explanation: "No runtime access" }));
|
||||
const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor()))
|
||||
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
|
||||
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
|
||||
controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." });
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an ordinary runtime manager's claimed foreign-owner stop", async () => {
|
||||
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
|
||||
allowed: action === "issue:read" || action === "runtime:manage",
|
||||
explanation: "A deployment administrator must verify the controller stopped",
|
||||
}));
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
|
||||
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
|
||||
controllerAndCopybackStopped: true, stopEvidence: "I claim that the foreign controller and all its processes have stopped." });
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues board export-only recovery without calling provider wake", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: true });
|
||||
mockRetryWorkspaceExport.mockResolvedValue({ runId: ownerRunId, status: "queued" });
|
||||
@@ -2030,7 +2078,25 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
expect(res.status).toBe(403); expect(mockRetryWorkspaceExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["todo", "done", "in_review"].flatMap(sourceIssueStatus => ["native_workspace_sync_out_unsafe_archive", "native_workspace_sync_out_retry_exhausted"].map(cause => ({ sourceIssueStatus, cause }))))("does not resolve accepted export recovery through an ordinary $sourceIssueStatus transition: $cause", async ({ sourceIssueStatus, cause }) => {
|
||||
it.each(["done", "in_review"])("does not bypass unverified copyback via false_positive to %s", async sourceIssueStatus => {
|
||||
const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId });
|
||||
mockIssueService.getById.mockResolvedValue(sourceIssue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({ ...sourceIssue, ...patch }));
|
||||
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
|
||||
id: recoveryActionId, status: "active", kind: "active_run_watchdog", ownerType: "board",
|
||||
ownerAgentId: null, returnOwnerAgentId: ownerAgentId,
|
||||
cause: "native_workspace_finalization_owner_unverified", evidence: { runId: ownerRunId },
|
||||
});
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
|
||||
.send({ actionId: recoveryActionId, outcome: "false_positive", sourceIssueStatus });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details?.code).toBe("workspace_owner_stop_required");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["todo", "done", "in_review"].flatMap(sourceIssueStatus => ["native_workspace_sync_out_unsafe_archive", "native_workspace_sync_out_retry_exhausted", "native_workspace_finalization_owner_unverified"].map(cause => ({ sourceIssueStatus, cause }))))("does not resolve accepted export recovery through an ordinary $sourceIssueStatus transition: $cause", async ({ sourceIssueStatus, cause }) => {
|
||||
const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId });
|
||||
mockIssueService.getById.mockResolvedValue(sourceIssue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({ ...sourceIssue, ...patch }));
|
||||
@@ -2043,7 +2109,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
|
||||
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus });
|
||||
expect.soft(res.status).toBe(409);
|
||||
expect.soft(res.body.details?.code).toBe(cause === "native_workspace_sync_out_unsafe_archive" ? "workspace_export_automatic_recovery" : "workspace_export_retry_required");
|
||||
expect.soft(res.body.details?.code).toBe(cause === "native_workspace_sync_out_unsafe_archive" ? "workspace_export_automatic_recovery" : cause === "native_workspace_finalization_owner_unverified" ? "workspace_owner_stop_required" : "workspace_export_retry_required");
|
||||
expect.soft(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect.soft(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import request from "supertest";
|
||||
import { expect, it } from "vitest";
|
||||
import { activityLog, issueComments, issueReadStates, issues } from "@paperclipai/db";
|
||||
import { isAgentCreatedIssue } from "@paperclipai/shared";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { describeEmbeddedPostgres, resetCompanyIssueFixtures, routeApp, seedCompanyWithBoardAccess, useEmbeddedPostgres } from "./helpers/route-test-harness.js";
|
||||
|
||||
describeEmbeddedPostgres("issue creation visibility", () => {
|
||||
const ctx = useEmbeddedPostgres("paperclip-creation-visibility-", {
|
||||
resetEach: async (db) => {
|
||||
await db.delete(activityLog);
|
||||
await resetCompanyIssueFixtures(db);
|
||||
},
|
||||
});
|
||||
|
||||
it("filters ordinary and compact searches before pagination without changing API defaults", async () => {
|
||||
const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Creation visibility");
|
||||
const other = await seedCompanyWithBoardAccess(ctx.db, "Other company");
|
||||
const rows = [
|
||||
{ id: randomUUID(), companyId, title: "Visibility human", createdByUserId: userId, originKind: "manual" },
|
||||
{ id: randomUUID(), companyId, title: "Visibility second human", createdByUserId: userId, originKind: "manual" },
|
||||
{ id: randomUUID(), companyId, title: "Visibility channel", createdByUserId: userId, originKind: "chat_channel" },
|
||||
{ id: randomUUID(), companyId, title: "Visibility onboarding", createdByUserId: userId, originKind: "onboarding_first_task" },
|
||||
{ id: randomUUID(), companyId, title: "Visibility inbound email", originKind: "chat_channel", originId: "email:endpoint:thread" },
|
||||
{ id: randomUUID(), companyId, title: "Visibility outbound email", originKind: "chat_channel", originId: "email-send:tool-call" },
|
||||
...["routine_execution", "task_watchdog", "stranded_issue_recovery", "plugin:workflow:task", "harness_liveness_escalation", "manual"].map((originKind) => ({ id: randomUUID(), companyId, title: `Visibility ${originKind}`, originKind, responsibleUserId: userId })),
|
||||
{ id: randomUUID(), companyId, title: "Visibility manually run routine", createdByUserId: userId, originKind: "routine_execution" },
|
||||
];
|
||||
await ctx.db.insert(issues).values([
|
||||
...rows.map((row, index) => ({ ...row, status: "todo", updatedAt: new Date(2026, 9, 13, 0, index) })),
|
||||
{ companyId: other.companyId, title: "Visibility other company", createdByUserId: other.userId },
|
||||
{ companyId, title: "Visibility private task", createdByUserId: other.userId, visibility: "private" },
|
||||
]);
|
||||
const app = routeApp(ctx.db, actor, issueRoutes);
|
||||
const path = `/api/companies/${companyId}/issues`;
|
||||
const defaults = await request(app).get(`${path}?includeRoutineExecutions=true`).expect(200);
|
||||
expect(defaults.body).toHaveLength(rows.length);
|
||||
for (const view of ["", "&view=compact"]) {
|
||||
const first = await request(app).get(`${path}?includeAgentCreatedTasks=false&q=Visibility&sortField=updated&limit=2${view}`).expect(200);
|
||||
const second = await request(app).get(`${path}?includeAgentCreatedTasks=false&q=Visibility&sortField=updated&limit=2&offset=2${view}`).expect(200);
|
||||
expect(first.body).toHaveLength(2);
|
||||
expect(second.body).toHaveLength(1);
|
||||
expect([...first.body, ...second.body].every((issue: Parameters<typeof isAgentCreatedIssue>[0]) => !isAgentCreatedIssue(issue))).toBe(true);
|
||||
}
|
||||
await request(app).get(`${path}?includeAgentCreatedTasks=invalid`).expect(400);
|
||||
await request(app).get(`${path}?includeParticipatedTasks=invalid`).expect(400);
|
||||
await request(app).get(`/api/companies/${other.companyId}/issues?includeAgentCreatedTasks=false`).expect(403);
|
||||
});
|
||||
|
||||
it("preserves participation but excludes routine ownership and read-only activity", async () => {
|
||||
const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Inbox participation");
|
||||
const ids = Object.fromEntries(["scheduled", "commented", "assigned", "manual", "read", "touched", "channelScheduled", "channelCommented", "channelManual"].map((name) => [name, randomUUID()]));
|
||||
await ctx.db.insert(issues).values(Object.entries(ids).map(([name, id]) => ({
|
||||
id, companyId, title: `Participation ${name}`, status: "todo", originKind: name.startsWith("channel") ? "chat_channel" : "routine_execution",
|
||||
responsibleUserId: userId,
|
||||
assigneeUserId: name === "assigned" ? userId : null,
|
||||
createdByUserId: name === "manual" || name === "channelManual" ? userId : null,
|
||||
})));
|
||||
await ctx.db.insert(issueComments).values([ids.commented!, ids.channelCommented!].map((issueId) => ({ companyId, issueId, authorUserId: userId, body: "I joined this task" })));
|
||||
await ctx.db.insert(issueReadStates).values({ companyId, issueId: ids.read!, userId, lastReadAt: new Date() });
|
||||
await ctx.db.insert(activityLog).values([
|
||||
{ companyId, entityType: "issue", entityId: ids.read!, actorType: "user", actorId: userId, action: "issue.read_marked" },
|
||||
{ companyId, entityType: "issue", entityId: ids.touched!, actorType: "user", actorId: userId, action: "issue.inbox_touched" },
|
||||
]);
|
||||
const app = routeApp(ctx.db, actor, issueRoutes);
|
||||
const path = `/api/companies/${companyId}/issues`;
|
||||
const mine = await request(app).get(`${path}?touchedByUserId=me&inboxArchivedByUserId=me&includeRoutineExecutions=true`).expect(200);
|
||||
const expected = [ids.commented, ids.assigned, ids.manual, ids.touched, ids.channelCommented, ids.channelManual].sort();
|
||||
expect(mine.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected);
|
||||
const discovery = await request(app).get(`${path}?q=Participation&includeAgentCreatedTasks=false&includeParticipatedTasks=true`).expect(200);
|
||||
expect(discovery.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected);
|
||||
const strict = await issueService(ctx.db).list(companyId, { includeAgentCreatedTasks: false });
|
||||
expect(strict).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import { WebSocket } from "ws";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setupLiveEventsWebSocketServer } from "../realtime/live-events-ws.js";
|
||||
import { publishLiveEvent } from "../services/live-events.js";
|
||||
import { taskToastEligible } from "../realtime/task-toast-eligibility.js";
|
||||
|
||||
vi.mock("../realtime/task-toast-eligibility.js", () => ({ taskToastEligible: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../services/authorization.js", () => ({
|
||||
authorizationService: () => ({ decide: async ({ resource }: { resource: { issueId: string } }) => ({ allowed: resource.issueId !== "hidden" }) }),
|
||||
@@ -30,6 +33,65 @@ function database(onPredicate: (kind: "where" | "join", condition: SQL) => void
|
||||
} };
|
||||
}
|
||||
describe("authenticated activity notifications", () => {
|
||||
it("uses the persisted local-board user identity for local task eligibility", async () => {
|
||||
vi.mocked(taskToastEligible).mockImplementation(async (_db, _event, userId) => userId === "local-board");
|
||||
const server = createServer();
|
||||
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "local_trusted" });
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
|
||||
try {
|
||||
await once(socket, "open");
|
||||
const delivery = once(socket, "message");
|
||||
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
||||
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "agent", actorId: "agent",
|
||||
} });
|
||||
const [data] = await delivery;
|
||||
expect(JSON.parse(data.toString()).toastEligible).toBe(true);
|
||||
expect(taskToastEligible).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "local-board");
|
||||
} finally {
|
||||
socket.terminate();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("projects eligibility per viewer and keeps authorized updates on lookup failure", async () => {
|
||||
vi.mocked(taskToastEligible).mockImplementation(async (_db, event, userId) => {
|
||||
if (event.payload.entityId === "lookup-failure") throw new Error("Relevance unavailable");
|
||||
return userId === "alice";
|
||||
});
|
||||
const server = createServer();
|
||||
setupLiveEventsWebSocketServer(server, database() as never, {
|
||||
deploymentMode: "authenticated",
|
||||
resolveCloudActor: async (req) => ({ userId: String(req.headers["x-test-viewer"]), companyIds: ["company"] }),
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`;
|
||||
const alice = new WebSocket(url, { headers: { "x-test-viewer": "alice" } });
|
||||
const bob = new WebSocket(url, { headers: { "x-test-viewer": "bob" } });
|
||||
try {
|
||||
await Promise.all([once(alice, "open"), once(bob, "open")]);
|
||||
for (const entityId of ["visible", "lookup-failure"]) {
|
||||
const deliveries = Promise.all([once(alice, "message"), once(bob, "message")]);
|
||||
const original = publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
||||
action: "issue.comment_added", entityType: "issue", entityId, actorType: "agent", actorId: "agent",
|
||||
} });
|
||||
const [a, b] = (await deliveries).map(([data]) => JSON.parse(data.toString()));
|
||||
expect(a.toastEligible).toBe(entityId === "visible");
|
||||
expect(b.toastEligible).toBe(false);
|
||||
expect(a.payload.entityId).toBe(entityId);
|
||||
expect(b.payload.entityId).toBe(entityId);
|
||||
expect(original).not.toHaveProperty("toastEligible");
|
||||
}
|
||||
} finally {
|
||||
alice.terminate(); bob.terminate();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
vi.mocked(taskToastEligible).mockReset().mockResolvedValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps actor identity after authorization and excludes hidden tasks and private details", async () => {
|
||||
const server = createServer();
|
||||
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { livenessRoutes } from "../routes/liveness.js";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
||||
import { cloudRuntimeIdentityMiddleware } from "../middleware/cloud-runtime-identity.js";
|
||||
import { cloudWarmStandbyMiddleware } from "../middleware/cloud-warm-standby.js";
|
||||
import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "../middleware/idle-admission.js";
|
||||
import { idleWorkSnapshot, startTaskDrain, stopTaskDrain } from "../services/task-admission.js";
|
||||
import { resetStartupRecoveryStateForTests, setStartupRecoveryPhase } from "../startup-recovery-state.js";
|
||||
import { getServerInfoSnapshot } from "../server-info.js";
|
||||
|
||||
afterEach(() => { stopTaskDrain(); resetStartupRecoveryStateForTests(); });
|
||||
|
||||
function fixture(standby = false) {
|
||||
const db = { execute: vi.fn().mockRejectedValue(new Error("database offline")) } as unknown as Db;
|
||||
const auth = vi.fn((_req, _res, next) => next());
|
||||
const health = healthRoutes(db, {
|
||||
deploymentMode: "authenticated", deploymentExposure: "public", authReady: true,
|
||||
companyDeletionEnabled: false, runtimeEnv: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "fixture-token" },
|
||||
isWarmStandby: () => standby,
|
||||
});
|
||||
const app = express();
|
||||
app.use(idleAdmissionMiddleware);
|
||||
app.use(privateHostnameGuard({ enabled: true, allowedHostnames: ["allowed.example"], bindHost: "0.0.0.0" }));
|
||||
app.use(cloudRuntimeIdentityMiddleware(db));
|
||||
app.use("/api/liveness", livenessRoutes());
|
||||
app.use(cloudWarmStandbyMiddleware(() => standby, health));
|
||||
app.use(auth);
|
||||
app.use("/api/health", health);
|
||||
app.get("/api/private", (_req, res) => res.sendStatus(401));
|
||||
trackIdleRequestHandlers(app);
|
||||
const http = request.agent(app).set("host", "allowed.example");
|
||||
return { app, http, db, auth };
|
||||
}
|
||||
|
||||
describe("database-free process liveness", () => {
|
||||
it.each([false, true])("does not resolve auth or query SQL with an unavailable database (standby: %s)", async standby => {
|
||||
const f = fixture(standby);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const response = await f.http.get("/api/liveness")
|
||||
.set("authorization", "Bearer invalid-fixture")
|
||||
.set("cookie", "session_token=invalid-fixture")
|
||||
.set("x-paperclip-cloud-tenant-token", "invalid-fixture");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
const serverInfo = getServerInfoSnapshot();
|
||||
expect(response.body).toEqual({ status: "ok", commit: serverInfo.git.available ? serverInfo.git.fullSha : null });
|
||||
}
|
||||
const head = await f.http.head("/api/liveness").expect(200);
|
||||
expect(head.text).toBeUndefined();
|
||||
expect(f.auth).not.toHaveBeenCalled();
|
||||
expect(f.db.execute).not.toHaveBeenCalled();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
if (!standby) {
|
||||
await f.http.get("/api/health").expect(503);
|
||||
expect(f.db.execute).toHaveBeenCalledOnce();
|
||||
await f.http.get("/api/private").expect(401);
|
||||
expect(f.auth).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["starting", "recovering"] as const)("reports %s until startup recovery is complete", async phase => {
|
||||
const f = fixture();
|
||||
setStartupRecoveryPhase(phase);
|
||||
await f.http.get("/api/liveness").expect(503).expect(response => expect(response.body.status).toBe("starting"));
|
||||
setStartupRecoveryPhase("ready");
|
||||
await f.http.get("/api/liveness").expect(200);
|
||||
expect(f.auth).not.toHaveBeenCalled();
|
||||
expect(f.db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains hostname and signed bootstrap endpoint validation", async () => {
|
||||
const f = fixture();
|
||||
await f.http.get("/api/liveness").set("host", "denied.example").expect(403);
|
||||
await f.http.get("/api/liveness").set("x-paperclip-cloud-runtime-identity", "invalid-fixture")
|
||||
.expect(400).expect({ error: "cloud_runtime_identity_wrong_endpoint" });
|
||||
expect(f.auth).not.toHaveBeenCalled();
|
||||
expect(f.db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays reachable during drain without exempting writes or other paths", async () => {
|
||||
const f = fixture();
|
||||
const before = idleWorkSnapshot();
|
||||
startTaskDrain({ purpose: "idle", ttlMs: 60_000 });
|
||||
for (const path of ["/api/liveness", "/api/liveness/"]) {
|
||||
await f.http.get(path).expect(200);
|
||||
await f.http.head(path).expect(200);
|
||||
await f.http.post(path).expect(503);
|
||||
}
|
||||
await f.http.get("/api/liveness/details").expect(503);
|
||||
await f.http.get("/api/private").expect(503);
|
||||
expect(idleWorkSnapshot()).toEqual(before);
|
||||
expect(f.auth).not.toHaveBeenCalled();
|
||||
expect(f.db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mounts the production route after host and bootstrap validation and before standby/auth", () => {
|
||||
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
|
||||
const route = source.indexOf('app.use("/api/liveness", livenessRoutes())');
|
||||
expect(route).toBeGreaterThan(source.indexOf("privateHostnameGuard({"));
|
||||
expect(route).toBeGreaterThan(source.indexOf("app.use(cloudRuntimeIdentityMiddleware(db))"));
|
||||
expect(route).toBeLessThan(source.indexOf("app.use(cloudWarmStandbyMiddleware("));
|
||||
expect(route).toBeLessThan(source.indexOf("actorMiddleware(db,"));
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -1457,3 +1458,6 @@ describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
|
||||
}, 30_000);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -848,8 +848,6 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
completionState === "kill_switch_during_active_native_run"
|
||||
&& (fixture.covers.migrationRows ?? []).includes("MIG-08")
|
||||
) {
|
||||
// Codex has graduated; preserve the existing rollback control on an
|
||||
// experimental provider, whose fresh runs still require the flag.
|
||||
const experimentalAdapterConfig = {
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
|
||||
@@ -41,7 +41,6 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
|
||||
let runnerSelectionSpy: { mockRestore(): void } | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Exercise real runner policy on the qualified Linux release target.
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
||||
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
|
||||
|
||||
@@ -45,6 +45,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"costs.ts": "/api",
|
||||
"dashboard.ts": "/api",
|
||||
"dot-runner.ts": "/api",
|
||||
"grok-bot.ts": "/api",
|
||||
"decision-queues.ts": "/api",
|
||||
"decisions.ts": "/api",
|
||||
"decision-training.ts": "/api",
|
||||
@@ -55,6 +56,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"folders.ts": "/api",
|
||||
"goals.ts": "/api",
|
||||
"health.ts": "/api/health",
|
||||
"liveness.ts": "/api/liveness",
|
||||
"inbox-agent-policy.ts": "/api",
|
||||
"inbox-dismissals.ts": "/api",
|
||||
"instance-database-backups.ts": "/api",
|
||||
@@ -87,9 +89,9 @@ const apiPrefixes: Record<string, string> = {
|
||||
};
|
||||
|
||||
const ROUTE_LITERAL_PATTERN =
|
||||
/router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
|
||||
const ROUTE_ARRAY_PATTERN = /router\.(get|post|put|patch|delete)\(\s*\[([^\]]+)\]/g;
|
||||
const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/;
|
||||
/router\.(get|head|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
|
||||
const ROUTE_ARRAY_PATTERN = /router\.(get|head|post|put|patch|delete)\(\s*\[([^\]]+)\]/g;
|
||||
const ROUTER_METHOD_PATTERN = /router\.(get|head|post|put|patch|delete)\(/;
|
||||
const HTTP_METHODS = new Set([
|
||||
"get",
|
||||
"put",
|
||||
@@ -183,7 +185,7 @@ function resolveMountedPath(file: string, prefix: string, routePath: string) {
|
||||
) {
|
||||
return routePath;
|
||||
}
|
||||
if ((file === "companies.ts" || file === "health.ts") && routePath === "/") {
|
||||
if ((file === "companies.ts" || file === "health.ts" || file === "liveness.ts") && routePath === "/") {
|
||||
return prefix;
|
||||
}
|
||||
if (file === "companies.ts" || file === "health.ts") {
|
||||
@@ -246,11 +248,11 @@ function loadActualRoutes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (file === "dot-runner.ts") {
|
||||
for (const name of ["path", "invitePath"]) {
|
||||
if (file === "dot-runner.ts" || file === "grok-bot.ts") {
|
||||
for (const name of file === "dot-runner.ts" ? ["path", "invitePath"] : ["path"]) {
|
||||
const basePath = new RegExp(`const ${name} = "([^"]+)"`).exec(source)?.[1];
|
||||
if (!basePath) throw new Error(`Dot ${name} route prefix is missing`);
|
||||
const methods = new RegExp(`router\\.(get|post|delete)\\(${name}(?: \\+ "([^"]+)")?`, "g");
|
||||
const methods = new RegExp(`router\\.(get|post|put|delete)\\(${name}(?: \\+ "([^"]+)")?`, "g");
|
||||
for (const match of source.matchAll(methods)) {
|
||||
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
|
||||
}
|
||||
@@ -296,6 +298,17 @@ function loadSpecRoutes() {
|
||||
}
|
||||
|
||||
describe("openapi routes", () => {
|
||||
it("documents anonymous liveness separately from database readiness", () => {
|
||||
const spec = buildOpenApiSpec() as any;
|
||||
for (const method of ["get", "head"]) {
|
||||
const operation = spec.paths["/api/liveness"][method];
|
||||
expect(operation.security).toEqual([]);
|
||||
expect(operation.responses[503]).toBeDefined();
|
||||
}
|
||||
expect(Object.keys(spec.paths["/api/liveness"].get.responses[200].content["application/json"].schema.properties))
|
||||
.toEqual(["status", "commit"]);
|
||||
expect(spec.paths["/api/health"].get.responses[503]).toBeDefined();
|
||||
});
|
||||
it("documents only writable agent update fields and lifecycle status requests", () => {
|
||||
const document = buildOpenApiSpec() as any;
|
||||
const properties = document.paths["/api/agents/{id}"].patch.requestBody.content["application/json"].schema.properties;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { companies, createDb, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { registerPluginLifecycleDelivery } from "../services/plugin-lifecycle-delivery.js";
|
||||
import { pluginLifecycleInbox } from "../services/plugin-lifecycle-inbox.js";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "../services/resource-lifecycle-events.js";
|
||||
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import type { PluginLifecycleManager } from "../services/plugin-lifecycle.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
if (!support.supported) console.warn(`Skipping lifecycle delivery database tests: ${support.reason}`);
|
||||
|
||||
describePostgres("durable plugin lifecycle delivery", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let companyId: string;
|
||||
let pluginId: string;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-plugin-delivery-"); db = createDb(database.connectionString); }, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
beforeEach(async () => {
|
||||
await db.delete(plugins);
|
||||
companyId = randomUUID(); pluginId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Delivery fixture", issuePrefix: `L${companyId.slice(0, 6)}` });
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "delivery-fixture", version: "1.0.0", status: "ready",
|
||||
manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe"] } as never });
|
||||
await db.insert(pluginConfig).values({ pluginId, companyId, configJson: {} });
|
||||
});
|
||||
async function event(resourceId = randomUUID(), action: "create" | "update" | "delete" = "create") {
|
||||
await db.transaction(async tx => {
|
||||
if (action === "create") await recordResourceCreationEvent(tx as unknown as Db, companyId, "project", resourceId);
|
||||
else await recordProjectLifecycleEvent(tx as unknown as Db, companyId, resourceId, action);
|
||||
});
|
||||
return resourceId;
|
||||
}
|
||||
function delivery() {
|
||||
let task!: Parameters<ReturnType<typeof createDeliveryWorkCoordinator>["register"]>[1];
|
||||
const wake = vi.fn();
|
||||
const coordinator = { register: vi.fn((_queue, input) => { task = input; return { wake, ready: Promise.resolve() }; }) };
|
||||
const call = vi.fn(async (_pluginId, _method, input) => ({ eventId: input.id, status: "acknowledged" }));
|
||||
const running = vi.fn(() => true);
|
||||
const lifecycle = new EventEmitter();
|
||||
const configApplied = new Set<() => void>();
|
||||
const onConfigApplied = (listener: () => void) => { configApplied.add(listener); return () => { configApplied.delete(listener); }; };
|
||||
const worker = registerPluginLifecycleDelivery(coordinator as never, db, { call, isRunning: running, onConfigApplied } as unknown as PluginWorkerManager, lifecycle as unknown as PluginLifecycleManager);
|
||||
return { call, running, wake, lifecycle, worker, applied: () => { for (const listener of configApplied) listener(); }, run: () => task.run(new AbortController().signal), pending: () => task.hasPending(), nextRunAt: () => task.nextRunAt?.(), settled: () => task.onSettled?.() };
|
||||
}
|
||||
it("drains creation before transitions, recovers acknowledgments across dispatcher replacement, and ignores agent events", async () => {
|
||||
const id = await event(); await event(id, "update"); await event(id, "delete");
|
||||
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "agent", randomUUID()));
|
||||
const first = delivery(); await first.run(); first.worker.dispose();
|
||||
expect(first.call.mock.calls.map(c => c[2].action)).toEqual(["create"]);
|
||||
const restarted = delivery(); await restarted.run(); await restarted.run(); await restarted.run();
|
||||
expect(restarted.call.mock.calls.map(c => c[2].action)).toEqual(["update", "delete"]);
|
||||
expect(await restarted.pending()).toBe(false);
|
||||
expect((await pluginLifecycleInbox(db, pluginId).list(companyId)).map(e => e.resourceType)).toEqual(["agent"]);
|
||||
restarted.worker.dispose();
|
||||
});
|
||||
it("replays failures and invalid acknowledgments without blocking independent resources", async () => {
|
||||
let now = Date.now(); vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const failed = await event(); await event(failed, "delete"); await event();
|
||||
const d = delivery();
|
||||
d.call.mockImplementationOnce(async () => { throw new Error("worker exited after provider effect"); });
|
||||
await d.run();
|
||||
expect(d.call).toHaveBeenCalledTimes(2); expect(await d.pending()).toBe(true);
|
||||
d.call.mockImplementationOnce(async () => ({ eventId: "wrong", status: "acknowledged" }));
|
||||
now += 30_000; await d.run(); expect(await d.pending()).toBe(true);
|
||||
now += 30_000; await d.run(); await d.run();
|
||||
expect(d.call.mock.calls.map(c => c[2].action)).toEqual(["create", "create", "create", "create", "delete"]);
|
||||
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
||||
d.worker.dispose();
|
||||
});
|
||||
it("healthy acknowledgments and new events do not bypass a failed event's retry deadline", async () => {
|
||||
let now = Date.now(); vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const failed = await event(), healthy = await event();
|
||||
await event(healthy, "update"); await event(healthy, "delete");
|
||||
const d = delivery();
|
||||
d.call.mockImplementation(async (_p, _m, e) => ({ eventId: e.id, status: e.resourceId === failed ? "retry" : "acknowledged" }));
|
||||
await d.run();
|
||||
const deadline = now + 30_000;
|
||||
expect(d.nextRunAt()).toBe(deadline);
|
||||
now += 1000;
|
||||
await d.run(); await d.run(); await event(); d.settled(); await d.run();
|
||||
expect(d.call.mock.calls.filter(c => c[2].resourceId === healthy).map(c => c[2].action)).toEqual(["create", "update", "delete"]);
|
||||
expect(d.call.mock.calls.filter(c => c[2].resourceId === failed)).toHaveLength(1);
|
||||
expect(d.nextRunAt()).toBe(deadline);
|
||||
now = deadline;
|
||||
expect(d.nextRunAt()).toBeNull(); // Paused dispatch waits for the normal retry, not a zero-delay loop.
|
||||
await d.run();
|
||||
expect(d.call.mock.calls.filter(c => c[2].resourceId === failed)).toHaveLength(2);
|
||||
expect(d.nextRunAt()).toBe(now + 30_000);
|
||||
d.worker.dispose();
|
||||
});
|
||||
it("defers without a retry deadline, retries unfinished work, and wakes on plugin startup", async () => {
|
||||
await event(); const d = delivery();
|
||||
d.call.mockImplementationOnce(async (_p, _m, e) => ({ eventId: e.id, status: "deferred" }));
|
||||
await d.run(); expect(await d.pending()).toBe(false); expect(d.wake).not.toHaveBeenCalled();
|
||||
d.lifecycle.emit("plugin.worker_started"); expect(d.wake).toHaveBeenCalledTimes(1);
|
||||
d.call.mockImplementationOnce(async (_p, _m, e) => ({ eventId: e.id, status: "retry" }));
|
||||
await d.run(); expect(await d.pending()).toBe(true);
|
||||
d.running.mockReturnValue(false); await d.run(); expect(await d.pending()).toBe(true);
|
||||
expect(d.call).toHaveBeenCalledTimes(2); d.worker.dispose();
|
||||
d.lifecycle.emit("plugin.worker_started"); expect(d.wake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("requires ready, configured, company-enabled plugins and rechecks disabling within a batch", async () => {
|
||||
await event(); await event(); const d = delivery(); const registry = pluginRegistryService(db);
|
||||
await registry.upsertCompanySettings(pluginId, companyId, { enabled: false, settingsJson: {} });
|
||||
await d.run(); expect(d.call).not.toHaveBeenCalled();
|
||||
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
||||
d.call.mockImplementationOnce(async (_p, _m, e) => {
|
||||
await registry.upsertCompanySettings(pluginId, companyId, { enabled: false, settingsJson: {} });
|
||||
return { eventId: e.id, status: "acknowledged" };
|
||||
});
|
||||
await d.run(); expect(d.call).toHaveBeenCalledTimes(1);
|
||||
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
||||
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
|
||||
await d.run(); expect(d.call).toHaveBeenCalledTimes(1);
|
||||
await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId));
|
||||
await db.delete(pluginConfig).where(eq(pluginConfig.pluginId, pluginId));
|
||||
await d.run(); expect(d.call).toHaveBeenCalledTimes(1); d.worker.dispose();
|
||||
});
|
||||
it("notifies only after the outer transaction settles and wakes deferred work for config changes", async () => {
|
||||
const wake = vi.fn(); const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle, wake);
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await eventInTransaction(tx as unknown as Db);
|
||||
expect(wake).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(wake).toHaveBeenCalledTimes(1);
|
||||
const registry = pluginRegistryService(db);
|
||||
await registry.upsertConfig(pluginId, companyId, { configJson: { enabled: true } });
|
||||
await registry.patchConfig(pluginId, companyId, { configJson: { enabled: false } });
|
||||
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
||||
expect(wake).toHaveBeenCalledTimes(4);
|
||||
} finally { unsubscribe(); }
|
||||
async function eventInTransaction(tx: Db) {
|
||||
await tx.transaction(inner => recordResourceCreationEvent(inner as unknown as Db, companyId, "project", randomUUID()));
|
||||
}
|
||||
});
|
||||
it("retains startup and settled work while idle drain prevents dispatch", async () => {
|
||||
const d = delivery();
|
||||
// The coordinator can call hasPending without run when admission is paused.
|
||||
expect(await d.pending()).toBe(true);
|
||||
await d.run(); expect(await d.pending()).toBe(false);
|
||||
await event(); d.settled();
|
||||
expect(await d.pending()).toBe(true);
|
||||
expect(d.call).not.toHaveBeenCalled();
|
||||
await d.run();
|
||||
expect(d.call).toHaveBeenCalledTimes(1);
|
||||
expect(await d.pending()).toBe(true); // An acknowledgment must drain its successor.
|
||||
await d.run(); expect(await d.pending()).toBe(false);
|
||||
d.lifecycle.emit("plugin.enabled"); expect(await d.pending()).toBe(true);
|
||||
await d.run(); expect(await d.pending()).toBe(false);
|
||||
d.worker.wake(); expect(await d.pending()).toBe(true);
|
||||
d.worker.dispose();
|
||||
});
|
||||
it("wakes deferred events after live configuration finishes applying", async () => {
|
||||
await event(); const d = delivery();
|
||||
let configured = false;
|
||||
d.call.mockImplementation(async (_p, _m, e) => ({ eventId: e.id, status: configured ? "acknowledged" : "deferred" }));
|
||||
await d.run(); expect(await d.pending()).toBe(false);
|
||||
// Commit notification races ahead of the asynchronous worker callback.
|
||||
d.settled(); await d.run(); expect(await d.pending()).toBe(false);
|
||||
configured = true; d.applied();
|
||||
expect(await d.pending()).toBe(true); expect(d.wake).toHaveBeenCalledTimes(1);
|
||||
await d.run(); expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
||||
d.worker.dispose(); d.wake.mockClear(); d.applied(); expect(d.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
it("retains a failed scan across a retry skipped during idle drain", async () => {
|
||||
await event(); const d = delivery();
|
||||
const select = vi.spyOn(db, "select").mockImplementationOnce(() => { throw new Error("database unavailable"); });
|
||||
await expect(d.run()).rejects.toThrow("database unavailable");
|
||||
// The coordinator's drain path checks hasPending without calling run.
|
||||
expect(await d.pending()).toBe(true); expect(d.call).not.toHaveBeenCalled();
|
||||
select.mockRestore(); await d.run(); await d.run();
|
||||
expect(await d.pending()).toBe(false);
|
||||
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
||||
d.worker.dispose();
|
||||
});
|
||||
it("saves config and settings without borrowing another pool connection", async () => {
|
||||
const single = createDb(database.connectionString, { maxConnections: 1 });
|
||||
const registry = pluginRegistryService(single);
|
||||
await registry.upsertConfig(pluginId, companyId, { configJson: { first: true } });
|
||||
await registry.patchConfig(pluginId, companyId, { configJson: { second: true } });
|
||||
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
||||
expect((await registry.getConfig(pluginId, companyId))?.configJson).toEqual({ first: true, second: true });
|
||||
}, 5000);
|
||||
it("records failed delivery scope and stage without provider secrets", async () => {
|
||||
await event(); const d = delivery();
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
d.call.mockImplementationOnce(async () => { throw Object.assign(new Error("provider-secret-fixture"), { code: -32001 }); });
|
||||
await d.run();
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ pluginId, companyId, eventId: expect.any(String), stage: "callback", rpcCode: -32001 }), expect.any(String));
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("provider-secret-fixture");
|
||||
expect(await d.pending()).toBe(true);
|
||||
} finally { warn.mockRestore(); d.worker.dispose(); }
|
||||
});
|
||||
it("rollbacks never expose queue rows even when settlement wakes the dispatcher", async () => {
|
||||
const d = delivery();
|
||||
await expect(db.transaction(async tx => {
|
||||
await recordResourceCreationEvent(tx as unknown as Db, companyId, "project", randomUUID());
|
||||
throw new Error("rollback");
|
||||
})).rejects.toThrow("rollback");
|
||||
await d.run(); expect(d.call).not.toHaveBeenCalled(); expect(await d.pending()).toBe(false);
|
||||
expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId))).toEqual([]);
|
||||
d.worker.dispose();
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,6 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Exercise real runner policy on the qualified Linux release target.
|
||||
runnerTarget.platform = "linux";
|
||||
runnerTarget.architecture = "x64";
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
@@ -337,7 +336,6 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
expect(created.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig);
|
||||
// Reset declaration defaults without requalifying an existing native agent.
|
||||
runnerTarget.platform = "win32";
|
||||
runnerTarget.architecture = "x64";
|
||||
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
appendStderrExcerpt,
|
||||
createDuplexRouteSlotController,
|
||||
createPluginWorkerHandle,
|
||||
createPluginWorkerManager,
|
||||
formatWorkerFailureMessage,
|
||||
resolveRpcCallTimeoutMs,
|
||||
} from "../services/plugin-worker-manager.js";
|
||||
@@ -56,6 +57,28 @@ const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
};
|
||||
|
||||
describe("worker configuration completion", () => {
|
||||
it("notifies only after a successful config callback and releases subscriptions", async () => {
|
||||
const manager = createPluginWorkerManager();
|
||||
const applied = vi.fn(), unsubscribe = manager.onConfigApplied(applied);
|
||||
try {
|
||||
await manager.startWorker("test.plugin", {
|
||||
entrypointPath: DELAYED_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1, hostHandlers: {},
|
||||
});
|
||||
expect(applied).not.toHaveBeenCalled();
|
||||
const updating = manager.call("test.plugin", "configChanged", { companyId: "company-1", config: { delayMs: 20 } });
|
||||
expect(applied).not.toHaveBeenCalled();
|
||||
await updating; expect(applied).toHaveBeenCalledTimes(1);
|
||||
await expect(manager.call("test.plugin", "configChanged", { companyId: "company-1", config: { fail: true } })).rejects.toThrow("configuration rejected");
|
||||
expect(applied).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
await manager.call("test.plugin", "configChanged", { companyId: "company-1", config: {} });
|
||||
expect(applied).toHaveBeenCalledTimes(1);
|
||||
} finally { unsubscribe(); await manager.stopAll(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRpcCallTimeoutMs", () => {
|
||||
const MAX_RPC_TIMEOUT_MS = 15 * 60 * 1_000;
|
||||
const MAX_NODE_TIMER_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
@@ -170,6 +170,16 @@ describe("project env routes", () => {
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
});
|
||||
|
||||
it('requires workspace-write authority to attach a repository despite project read access', async () => {
|
||||
mockProjectService.getById.mockResolvedValue(buildProject());
|
||||
mockAccessService.decide.mockImplementation(async (input) => ({ allowed: input.action !== 'project:write_workspace',
|
||||
action: input.action, reason: 'deny_no_grant', explanation: 'Workspace write permission required.' }));
|
||||
const app = await createApp();
|
||||
await request(app).post('/api/projects/project-1/workspaces').send({ name: 'Repository', repoUrl: 'https://github.com/acme/skills' }).expect(403);
|
||||
await request(app).put('/api/projects/project-1/repositories').send({ repositoryIds: ['42'] }).expect(403);
|
||||
expect(mockProjectService.createWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes env bindings on create and logs only env keys", async () => {
|
||||
const normalizedEnv = {
|
||||
API_KEY: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { Db } from '@paperclipai/db';
|
||||
import { projectSkillSetupIdentity } from '../services/project-skill-source-bindings.js';
|
||||
const { capture } = vi.hoisted(() => ({ capture: vi.fn() }));
|
||||
vi.mock('../services/run-identity.js', () => ({ captureRunIdentity: capture }));
|
||||
const db = {} as Db;
|
||||
const actor = { type: 'agent' as const, source: 'agent_jwt' as const, companyId: 'company', agentId: 'agent', runId: 'run', onBehalfOfUserId: 'forged' };
|
||||
beforeEach(() => capture.mockReset());
|
||||
it('keeps responsibility without searching personal GitHub grants when the run has no selected credential', async () => {
|
||||
capture.mockResolvedValue({ run: { responsibleUserId: 'person' }, context: { github: null } });
|
||||
expect(await projectSkillSetupIdentity(db, 'company', actor)).toMatchObject({ refreshUserId: 'person', connectionId: null, refreshGrantId: null });
|
||||
});
|
||||
it('pins only the connection and grant already authorized for the active run', async () => {
|
||||
capture.mockResolvedValue({ run: { responsibleUserId: 'person' }, context: { github: { status: 'available', connectionId: 'selected', grantId: 'grant' } } });
|
||||
expect(await projectSkillSetupIdentity(db, 'company', actor)).toMatchObject({ refreshUserId: 'person', connectionId: 'selected', refreshGrantId: 'grant' });
|
||||
expect(capture).toHaveBeenCalledWith(db, { companyId: 'company', agentId: 'agent', runId: 'run' });
|
||||
});
|
||||
it('does not reuse an unavailable run credential', async () => {
|
||||
capture.mockResolvedValue({ run: { responsibleUserId: 'person' }, context: { github: { status: 'unavailable', connectionId: 'old', grantId: 'old' } } });
|
||||
expect(await projectSkillSetupIdentity(db, 'company', actor)).toMatchObject({ connectionId: null, refreshGrantId: null });
|
||||
});
|
||||
it('API keys do not obtain responsibility or a run credential', async () => {
|
||||
expect(await projectSkillSetupIdentity(db, 'company', { ...actor, source: 'agent_key' })).toMatchObject({ refreshUserId: null, connectionId: null, refreshGrantId: null });
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,933 @@
|
||||
import { reserveInitialSkillWork } from '../services/skill-source-import-budget.js';
|
||||
import { admitDeferredSkillImports, lockSkillImportQueue, MAX_COMPANY_PENDING_SKILL_IMPORTS, MAX_PROJECT_PENDING_SKILL_IMPORTS, SKILL_IMPORT_CAPACITY_WAIT } from '../services/skill-source-import-queue.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { activityLog, agents, authUsers, companies, companyMemberships, companySkillSources, companySkillImportBudgets, companySkills, createDb, projects,
|
||||
projectWorkspaces, projectSkillSources, projectAccessMembers } from '@paperclipai/db';
|
||||
import { startEmbeddedPostgresTestDatabase, getEmbeddedPostgresTestSupport } from './helpers/embedded-postgres.js';
|
||||
import { githubFixture } from './helpers/github-skills.js';
|
||||
import { enqueueProjectSkillSource, detachProjectSkillSource, projectSkillSetupIdentity } from '../services/project-skill-source-bindings.js';
|
||||
import { adoptProjectSkillRepository, projectSkillRepositories, skillSourceSyncService } from '../services/skill-source-sync.js';
|
||||
import { skillSourceService, type SkillSourceContext } from '../services/skill-sources.js';
|
||||
import { companySkillService } from '../services/company-skills.js';
|
||||
import { projectService } from '../services/projects.js';
|
||||
import { skillAccessService, withSkillActor } from '../services/skill-access.js';
|
||||
import type { AuthorizationActor } from '../services/authorization.js';
|
||||
import { forbidden, unprocessable } from '../errors.js';
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)('automatic project skill sources', () => {
|
||||
let testDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let home: string;
|
||||
const oldHome = process.env.PAPERCLIP_HOME;
|
||||
const companyId = randomUUID();
|
||||
const md = (name: string) => `---\nname: ${name}\ndescription: Test workflow\n---\nFollow the workflow.\n`;
|
||||
let files: Record<string, string> = {};
|
||||
let commit = 'a'.repeat(40);
|
||||
const downloads = vi.fn();
|
||||
const context: SkillSourceContext = { actor: { type: 'system' }, setup: { userId: null, localTrusted: true },
|
||||
read: () => {
|
||||
const fixture = githubFixture(files, {}, commit), open = fixture.openSnapshot;
|
||||
fixture.openSnapshot = async (...args) => { downloads(); return open(...args); };
|
||||
return fixture;
|
||||
}, authorize: async () => {}, audit: async () => {} };
|
||||
beforeAll(async () => {
|
||||
testDb = await startEmbeddedPostgresTestDatabase('paperclip-project-skills-');
|
||||
db = createDb(testDb.connectionString);
|
||||
home = await fs.mkdtemp(path.join(os.tmpdir(), 'paperclip-project-skills-home-'));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
await db.insert(companies).values({ id: companyId, name: 'Project skills', issuePrefix: 'PSK' });
|
||||
}, 30000);
|
||||
it('rechecks the locked source ref when branch repair races with project attachment', async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: 'Branch repair race', issuePrefix: 'RCE' });
|
||||
const content = { 'race/SKILL.md': md('race') };
|
||||
const sourceContext = { ...context, read: () => githubFixture(content) };
|
||||
const service = skillSourceService(db);
|
||||
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'configured-branch',
|
||||
commitSha: 'a'.repeat(40), selectedPaths: ['race/SKILL.md'] }, sourceContext);
|
||||
const [project] = await db.insert(projects).values({ companyId, name: 'Configured branch' }).returning();
|
||||
const [row] = await db.insert(projectWorkspaces).values({ companyId, projectId: project!.id, name: 'Repository',
|
||||
repoUrl: created.source.repositoryUrl, defaultRef: 'configured-branch' }).returning();
|
||||
await enqueueProjectSkillSource(db, row!, { refreshUserId: null, refreshAgentId: null, refreshLocalTrusted: true, connectionId: null, refreshGrantId: null });
|
||||
let importChecks = 0;
|
||||
const worker = skillSourceSyncService(db, { context: async () => ({ ...sourceContext, authorize: async action => {
|
||||
// The second authorization occurs after matching the existing source,
|
||||
// before the worker locks it for attachment.
|
||||
if (action === 'skills.import' && ++importChecks === 2) await service.settings(companyId, created.source.id,
|
||||
{ revision: created.source.revision, trackingRef: 'HEAD' }, sourceContext);
|
||||
} }) });
|
||||
try {
|
||||
await worker.tick();
|
||||
const [blocked] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row!.id));
|
||||
expect(blocked!.sourceId).toBeNull();
|
||||
expect(blocked!.status).toBe('error');
|
||||
expect(blocked!.lastError).toContain('branch changed');
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
const [attached] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row!.id));
|
||||
expect(attached!.sourceId).not.toBe(created.source.id);
|
||||
expect((await service.detail(companyId, attached!.sourceId!)).trackingRef).toBe('configured-branch');
|
||||
} finally {
|
||||
await worker.stop();
|
||||
await db.update(companies).set({ status: 'archived' }).where(eq(companies.id, companyId));
|
||||
}
|
||||
});
|
||||
it('requires explicit repair before an agent-created repository can search a person’s GitHub grants', async () => {
|
||||
const row = await workspace('agent-repair', 'open');
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: 'Agent repository setup', adapterType: 'codex_local', status: 'idle' }).returning();
|
||||
await db.update(projectSkillSources).set({ refreshUserId: 'local-board', refreshAgentId: agent!.id, refreshLocalTrusted: true }).where(eq(projectSkillSources.id, binding!.id));
|
||||
const provider = vi.spyOn(globalThis, 'fetch');
|
||||
try {
|
||||
await skillSourceSyncService(db).tick();
|
||||
const [saved] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, binding!.id));
|
||||
expect(saved!.lastError).toMatch(/Choose a GitHub connection/);
|
||||
expect(saved!.active).toBe(false);
|
||||
expect(provider).not.toHaveBeenCalled();
|
||||
} finally { provider.mockRestore(); }
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (oldHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = oldHome;
|
||||
await testDb?.cleanup();
|
||||
if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
async function workspace(ref: string, visibility = 'open') {
|
||||
const [project] = await db.insert(projects).values({ companyId, name: ref, visibility }).returning();
|
||||
const [row] = await db.insert(projectWorkspaces).values({ companyId, projectId: project!.id, name: ref,
|
||||
repoUrl: 'https://github.com/acme/skills', defaultRef: ref }).returning();
|
||||
return row!;
|
||||
}
|
||||
const actor = (userId: string): AuthorizationActor => ({ type: 'board', userId, source: 'session', companyIds: [companyId], ignoreInstanceAdmin: true });
|
||||
|
||||
it('bounds per-project pending imports and admits deferred work after capacity frees', async () => {
|
||||
await db.update(projectSkillSources).set({ active: false, lastError: null });
|
||||
const root = await workspace('quota-root');
|
||||
const rows = [];
|
||||
for (let index = 0; index <= MAX_PROJECT_PENDING_SKILL_IMPORTS; index++) {
|
||||
const [row] = await db.insert(projectWorkspaces).values({ companyId, projectId: root.projectId, name: `Repository ${index}`,
|
||||
repoUrl: root.repoUrl, defaultRef: `project-quota-${index}` }).returning();
|
||||
rows.push(row!); await enqueueProjectSkillSource(db, row!);
|
||||
}
|
||||
const pending = await db.select().from(projectSkillSources).where(eq(projectSkillSources.projectId, root.projectId));
|
||||
expect(pending.filter(row => row.active)).toHaveLength(MAX_PROJECT_PENDING_SKILL_IMPORTS);
|
||||
expect(pending.filter(row => !row.active)).toEqual([expect.objectContaining({ status: 'pending', lastError: SKILL_IMPORT_CAPACITY_WAIT })]);
|
||||
await detachProjectSkillSource(db, companyId, rows[0]!.id);
|
||||
await admitDeferredSkillImports(db, companyId, new Date());
|
||||
const [admitted] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, rows.at(-1)!.id));
|
||||
expect(admitted).toMatchObject({ active: true, lastError: null });
|
||||
const [extra] = await db.insert(projectWorkspaces).values({ companyId, projectId: root.projectId, name: 'Detached waiting repository', repoUrl: root.repoUrl, defaultRef: 'quota-detached' }).returning();
|
||||
await enqueueProjectSkillSource(db, extra!);
|
||||
await detachProjectSkillSource(db, companyId, extra!.id);
|
||||
await detachProjectSkillSource(db, companyId, rows[1]!.id);
|
||||
await admitDeferredSkillImports(db, companyId, new Date());
|
||||
const [detached] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, extra!.id));
|
||||
expect(detached!.active).toBe(false);
|
||||
await db.update(projectSkillSources).set({ active: false, lastError: null });
|
||||
});
|
||||
|
||||
it('does not reactivate a deferred import detached after admission selection', async () => {
|
||||
const row = await workspace('deferred-removal-race');
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
await db.update(projectSkillSources).set({ active: false, lastError: SKILL_IMPORT_CAPACITY_WAIT }).where(eq(projectSkillSources.id, binding!.id));
|
||||
let detached = false;
|
||||
const database = new Proxy(db, { get(target, key) {
|
||||
if (key !== 'transaction') return Reflect.get(target, key);
|
||||
return (callback: (tx: unknown) => Promise<unknown>) => target.transaction(async tx => callback(new Proxy(tx, { get(target, key) {
|
||||
if (key !== 'update') return Reflect.get(target, key);
|
||||
return (table: unknown) => {
|
||||
const update = target.update(table as typeof projectSkillSources);
|
||||
if (table !== projectSkillSources) return update;
|
||||
return { set: (values: Parameters<typeof update.set>[0]) => {
|
||||
const set = update.set(values);
|
||||
return { where: async (condition: Parameters<typeof set.where>[0]) => {
|
||||
if (!detached) { detached = true; await detachProjectSkillSource(db, companyId, row.id); }
|
||||
return set.where(condition);
|
||||
} };
|
||||
} };
|
||||
};
|
||||
} })));
|
||||
} });
|
||||
await admitDeferredSkillImports(database, companyId, new Date());
|
||||
const [saved] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, binding!.id));
|
||||
expect(detached).toBe(true);
|
||||
expect(saved!.active).toBe(false);
|
||||
expect(saved!.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('bounds the company pending queue across projects without losing deferred provenance', async () => {
|
||||
const projectsToInsert = Array.from({ length: MAX_COMPANY_PENDING_SKILL_IMPORTS + 1 }, (_, index) => ({ id: randomUUID(), companyId, name: `Quota project ${index}` }));
|
||||
await db.insert(projects).values(projectsToInsert);
|
||||
const workspaces = await db.insert(projectWorkspaces).values(projectsToInsert.map((project, index) => ({ companyId, projectId: project.id,
|
||||
name: 'Repository', repoUrl: 'https://github.com/acme/skills', defaultRef: `company-quota-${index}` }))).returning();
|
||||
for (const row of workspaces) await enqueueProjectSkillSource(db, row);
|
||||
const pending = await db.select().from(projectSkillSources).where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.active, true)));
|
||||
expect(pending.filter(row => !row.sourceId)).toHaveLength(MAX_COMPANY_PENDING_SKILL_IMPORTS);
|
||||
const deferred = await db.select().from(projectSkillSources).where(eq(projectSkillSources.lastError, SKILL_IMPORT_CAPACITY_WAIT));
|
||||
expect(deferred).toHaveLength(1);
|
||||
expect(deferred[0]!.workspaceId).toBeTruthy();
|
||||
await db.update(projectSkillSources).set({ active: false, lastError: null });
|
||||
});
|
||||
|
||||
it('admits another project beyond a full project’s oldest waiting batch', async () => {
|
||||
const full = await workspace('full-admission-project');
|
||||
const other = await workspace('other-admission-project');
|
||||
const rows = await db.insert(projectWorkspaces).values(Array.from({ length: 120 }, (_, index) => ({ companyId, projectId: full.projectId,
|
||||
name: `Queued ${index}`, repoUrl: full.repoUrl, defaultRef: `starvation-${index}` }))).returning();
|
||||
await db.insert(projectSkillSources).values(rows.map((row, index) => ({ companyId, projectId: row.projectId, workspaceId: row.id,
|
||||
repositoryUrl: row.repoUrl!, trackingRef: row.defaultRef!, active: index < 20,
|
||||
lastError: index < 20 ? null : SKILL_IMPORT_CAPACITY_WAIT, createdAt: new Date(0) })));
|
||||
const [waiting] = await db.insert(projectSkillSources).values({ companyId, projectId: other.projectId, workspaceId: other.id,
|
||||
repositoryUrl: other.repoUrl!, trackingRef: other.defaultRef!, active: false, lastError: SKILL_IMPORT_CAPACITY_WAIT }).returning();
|
||||
await admitDeferredSkillImports(db, companyId, new Date());
|
||||
const [saved] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, waiting!.id));
|
||||
expect(saved!.active).toBe(true);
|
||||
await db.update(projectSkillSources).set({ active: false, lastError: null });
|
||||
});
|
||||
|
||||
it('adoption waits for the company queue before taking repository binding locks', async () => {
|
||||
const row = await workspace('adoption-lock-order');
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
const [source] = await db.insert(companySkillSources).values({ companyId, repositoryUrl: row.repoUrl!, fullName: 'acme/skills', trackingRef: row.defaultRef! }).returning();
|
||||
await db.update(projectSkillSources).set({ sourceId: source!.id }).where(eq(projectSkillSources.id, binding!.id));
|
||||
let unlock!: () => void, locked!: () => void;
|
||||
const release = new Promise<void>(resolve => { unlock = resolve; });
|
||||
const ready = new Promise<void>(resolve => { locked = resolve; });
|
||||
let editTx!: typeof db;
|
||||
const edit = db.transaction(async tx => { editTx = tx as unknown as typeof db; await lockSkillImportQueue(editTx, companyId); locked(); await release; });
|
||||
await ready;
|
||||
const adoption = withSkillActor({ type: 'board', source: 'local_implicit', companyIds: [companyId] },
|
||||
() => adoptProjectSkillRepository(db, companyId, row.id, context.authorize));
|
||||
try {
|
||||
await vi.waitFor(async () => {
|
||||
const result = await db.execute(sql`select 1 from pg_stat_activity where wait_event_type = 'Lock' and query ilike '%companies%for no key update%'`);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
// A repository edit holding the company lock must still be able to lock
|
||||
// this binding. The old inverse adoption order fails with lock_not_available.
|
||||
await editTx.execute(sql`select id from project_skill_sources where id = ${binding!.id} for update nowait`);
|
||||
} finally { unlock(); await edit; await adoption; }
|
||||
});
|
||||
|
||||
it('meters failed initial downloads across workers and retries before provider reads', async () => {
|
||||
const budgetCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: budgetCompany, name: 'Initial work credits', issuePrefix: 'IWC' });
|
||||
const [project] = await db.insert(projects).values({ companyId: budgetCompany, name: 'Repeated failed downloads' }).returning();
|
||||
const rows = await db.insert(projectWorkspaces).values(Array.from({ length: 5 }, (_, index) => ({ companyId: budgetCompany,
|
||||
projectId: project!.id, name: `Failed ${index}`, repoUrl: 'https://github.com/acme/skills', defaultRef: `failed-${index}` }))).returning();
|
||||
for (const row of rows) await enqueueProjectSkillSource(db, row);
|
||||
const reads = vi.fn(() => {
|
||||
const reader = githubFixture({});
|
||||
reader.openSnapshot = async () => { throw unprocessable('Git download exceeds 128 MiB.'); };
|
||||
return reader;
|
||||
});
|
||||
const factory = async (setup: { companyId: string }) => setup.companyId === budgetCompany ? { ...context, read: reads } : context;
|
||||
const now = new Date();
|
||||
try {
|
||||
await skillSourceSyncService(db, { context: factory }).tick(now);
|
||||
expect(reads).toHaveBeenCalledTimes(4);
|
||||
const [budget] = await db.select().from(companySkillImportBudgets).where(eq(companySkillImportBudgets.companyId, budgetCompany));
|
||||
expect(budget!.availableBytes).toBe(0);
|
||||
const worker = skillSourceSyncService(db, { context: factory });
|
||||
await worker.tick(new Date(now.getTime() + 5 * 60_000));
|
||||
expect(reads).toHaveBeenCalledTimes(4);
|
||||
const waiting = await db.select().from(projectSkillSources).where(eq(projectSkillSources.companyId, budgetCompany));
|
||||
expect(waiting.every(row => row.active && row.status === 'error' && row.lastError?.includes('limited'))).toBe(true);
|
||||
await worker.tick(new Date(now.getTime() + 15 * 60_000));
|
||||
expect(reads).toHaveBeenCalledTimes(5);
|
||||
} finally { await db.update(companies).set({ status: 'archived' }).where(eq(companies.id, budgetCompany)); }
|
||||
});
|
||||
|
||||
it('refunds measured small initial imports while completed jobs keep a company budget', async () => {
|
||||
const budgetCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: budgetCompany, name: 'Small initial imports', issuePrefix: 'SII' });
|
||||
const [project] = await db.insert(projects).values({ companyId: budgetCompany, name: 'Small repositories' }).returning();
|
||||
const rows = await db.insert(projectWorkspaces).values(Array.from({ length: 6 }, (_, index) => ({ companyId: budgetCompany,
|
||||
projectId: project!.id, name: `Small ${index}`, repoUrl: 'https://github.com/acme/skills', defaultRef: `small-${index}` }))).returning();
|
||||
for (const row of rows) await enqueueProjectSkillSource(db, row);
|
||||
const opened = vi.fn();
|
||||
const reads = vi.fn(() => {
|
||||
const reader = githubFixture({}), open = reader.openSnapshot!;
|
||||
reader.openSnapshot = async (...args) => { opened(); return open(...args); };
|
||||
return reader;
|
||||
});
|
||||
try {
|
||||
await skillSourceSyncService(db, { context: async setup => setup.companyId === budgetCompany ? { ...context, read: reads } : context }).tick();
|
||||
const bindings = await db.select().from(projectSkillSources).where(eq(projectSkillSources.companyId, budgetCompany));
|
||||
expect(bindings.every(row => row.status === 'ready' && row.sourceId)).toBe(true);
|
||||
expect(opened).toHaveBeenCalledTimes(6);
|
||||
const [budget] = await db.select().from(companySkillImportBudgets).where(eq(companySkillImportBudgets.companyId, budgetCompany));
|
||||
expect(budget!.availableBytes).toBeGreaterThan(500 * 1024 * 1024);
|
||||
} finally { await db.update(companies).set({ status: 'archived' }).where(eq(companies.id, budgetCompany)); }
|
||||
});
|
||||
|
||||
it('does not replenish initial work credits when large measured jobs complete', async () => {
|
||||
const budgetCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: budgetCompany, name: 'Completed work credits', issuePrefix: 'CWC' });
|
||||
const [project] = await db.insert(projects).values({ companyId: budgetCompany, name: 'Large measured imports' }).returning();
|
||||
const rows = await db.insert(projectWorkspaces).values(Array.from({ length: 5 }, (_, index) => ({ companyId: budgetCompany,
|
||||
projectId: project!.id, name: `Measured ${index}`, repoUrl: 'https://github.com/acme/skills', defaultRef: `measured-${index}` }))).returning();
|
||||
for (const row of rows) await enqueueProjectSkillSource(db, row);
|
||||
const opened = vi.fn();
|
||||
const reads = vi.fn(() => {
|
||||
const reader = githubFixture({}), open = reader.openSnapshot!;
|
||||
reader.openSnapshot = async (...args) => { opened(); return { ...await open(...args), downloadBytes: 128 * 1024 * 1024 }; };
|
||||
return reader;
|
||||
});
|
||||
try {
|
||||
await skillSourceSyncService(db, { context: async setup => setup.companyId === budgetCompany ? { ...context, read: reads } : context }).tick();
|
||||
const bindings = await db.select().from(projectSkillSources).where(eq(projectSkillSources.companyId, budgetCompany));
|
||||
expect(bindings.filter(row => row.status === 'ready' && row.sourceId)).toHaveLength(4);
|
||||
expect(bindings.filter(row => row.status === 'error' && row.lastError?.includes('limited'))).toHaveLength(1);
|
||||
expect(opened).toHaveBeenCalledTimes(4);
|
||||
const [budget] = await db.select().from(companySkillImportBudgets).where(eq(companySkillImportBudgets.companyId, budgetCompany));
|
||||
expect(budget!.availableBytes).toBe(0);
|
||||
} finally { await db.update(companies).set({ status: 'archived' }).where(eq(companies.id, budgetCompany)); }
|
||||
});
|
||||
|
||||
it('never counts refill time twice when an older sweep reserves after a newer one', async () => {
|
||||
const row = await workspace('monotonic-initial-work');
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
const token = randomUUID();
|
||||
await db.update(projectSkillSources).set({ leaseToken: token }).where(eq(projectSkillSources.id, binding!.id));
|
||||
await db.delete(companySkillImportBudgets).where(eq(companySkillImportBudgets.companyId, companyId));
|
||||
const older = new Date(), newer = new Date(older.getTime() + 15 * 60_000);
|
||||
try {
|
||||
await reserveInitialSkillWork(db, binding!, token, newer);
|
||||
await reserveInitialSkillWork(db, binding!, token, older);
|
||||
const [afterOlder] = await db.select().from(companySkillImportBudgets).where(eq(companySkillImportBudgets.companyId, companyId));
|
||||
expect(afterOlder!.updatedAt).toEqual(newer);
|
||||
await reserveInitialSkillWork(db, binding!, token, newer);
|
||||
const [saved] = await db.select().from(companySkillImportBudgets).where(eq(companySkillImportBudgets.companyId, companyId));
|
||||
expect(saved!.availableBytes).toBe(128 * 1024 * 1024);
|
||||
} finally {
|
||||
await detachProjectSkillSource(db, companyId, row.id);
|
||||
await db.delete(companySkillImportBudgets).where(eq(companySkillImportBudgets.companyId, companyId));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not adopt a stale repository after an editor commits a new selection', async () => {
|
||||
const row = await workspace('adoption-stale-selection');
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
try {
|
||||
await withSkillActor({ type: 'board', source: 'local_implicit', companyIds: [companyId] }, async () => {
|
||||
await expect(adoptProjectSkillRepository(db, companyId, row.id, async () => {
|
||||
await projectService(db).updateWorkspace(row.projectId, row.id, { repoUrl: 'https://github.com/acme/skills-next', defaultRef: 'edited-branch' });
|
||||
})).rejects.toThrow(/repository changed/i);
|
||||
});
|
||||
const saved = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
expect(saved.find(binding => binding.trackingRef === 'edited-branch')!.active).toBe(true);
|
||||
expect(saved.find(binding => binding.trackingRef === row.defaultRef)!.active).toBe(false);
|
||||
} finally { await detachProjectSkillSource(db, companyId, row.id); }
|
||||
});
|
||||
|
||||
it('does not read GitHub or retry an initial job denied by import policy', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
const row = await workspace('denied-preflight');
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
const read = vi.fn(context.read);
|
||||
const worker = skillSourceSyncService(db, { context: async () => ({ ...context, read,
|
||||
authorize: async () => { throw forbidden('Skill import denied by policy.'); } }) });
|
||||
expect((await worker.tick()).imported).toBe(0);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
const [blocked] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
expect(blocked).toMatchObject({ active: false, status: 'error', sourceId: null });
|
||||
await worker.tick(new Date(Date.now() + 60 * 60_000));
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not add readers when an import overlaps private source removal', async () => {
|
||||
files = { 'race/SKILL.md': md('race workflow') };
|
||||
const originalWorkspace = await workspace('removal-race', 'private');
|
||||
await enqueueProjectSkillSource(db, originalWorkspace);
|
||||
await skillSourceSyncService(db, { context: async () => context }).tick();
|
||||
const [origin] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, originalWorkspace.id));
|
||||
const sources = skillSourceService(db), skills = companySkillService(db);
|
||||
const source = await sources.detail(companyId, origin!.sourceId!);
|
||||
const skillId = source.entries[0]!.skillId!;
|
||||
const pendingWorkspace = await workspace('removal-race', 'open');
|
||||
await enqueueProjectSkillSource(db, pendingWorkspace);
|
||||
let removed = false, authorizations = 0;
|
||||
const racingContext = { ...context, authorize: async () => {
|
||||
if (++authorizations !== 2 || removed) return;
|
||||
removed = true;
|
||||
await sources.disconnect(companyId, source.id, context, { revision: source.revision, skills: 'copy' });
|
||||
await skills.updateFile(companyId, skillId, 'SKILL.md', md('Restricted race edits'));
|
||||
} };
|
||||
const worker = skillSourceSyncService(db, { context: async () => racingContext });
|
||||
expect((await worker.tick()).imported).toBe(0);
|
||||
const [pending] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, pendingWorkspace.id));
|
||||
expect(pending).toMatchObject({ sourceId: null, status: 'error' });
|
||||
const readerId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: readerId, name: 'Open reader', email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: 'user', principalId: readerId, membershipRole: 'member', status: 'active' });
|
||||
await withSkillActor(actor(readerId), async () => {
|
||||
expect(await skills.getById(companyId, skillId)).toBeNull();
|
||||
expect(await skills.readFile(companyId, skillId, 'SKILL.md')).toBeNull();
|
||||
});
|
||||
expect((await worker.tick(new Date(Date.now() + 6 * 60_000))).imported).toBe(1);
|
||||
const [retried] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, pendingWorkspace.id));
|
||||
expect(retried!.sourceId).not.toBe(source.id);
|
||||
});
|
||||
|
||||
it('imports after durable enqueue, imports later additions and avoids unchanged downloads', async () => {
|
||||
files = { 'one/SKILL.md': md('one') }; commit = 'a'.repeat(40); downloads.mockClear();
|
||||
const row = await workspace('automatic');
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
expect(await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id))).toHaveLength(1);
|
||||
expect((await db.select().from(companySkillSources)).filter(source => source.trackingRef === 'automatic')).toHaveLength(0);
|
||||
const worker = skillSourceSyncService(db, { context: async () => context });
|
||||
expect((await worker.tick()).imported).toBe(1);
|
||||
const [ready] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, binding!.id));
|
||||
const sourceId = ready!.sourceId!;
|
||||
const first = await skillSourceService(db).detail(companyId, sourceId);
|
||||
expect(first.accessScope).toBe('projects');
|
||||
expect(first.importNewSkills).toBe(true);
|
||||
const installedId = first.entries[0]!.skillId!;
|
||||
const original = (await companySkillService(db).getById(companyId, installedId))!;
|
||||
expect(original.accessSourceId).toBe(sourceId);
|
||||
const downloadCount = downloads.mock.calls.length;
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
expect(downloads).toHaveBeenCalledTimes(downloadCount);
|
||||
files['one/SKILL.md'] = md('one improved'); files['two/SKILL.md'] = md('two'); commit = 'b'.repeat(40);
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
const updated = await skillSourceService(db).detail(companyId, sourceId);
|
||||
expect(updated.entries.filter(entry => entry.skillId)).toHaveLength(2);
|
||||
const latest = (await companySkillService(db).getById(companyId, installedId))!;
|
||||
expect(latest.id).toBe(original.id);
|
||||
expect(latest.currentVersionId).not.toBe(original.currentVersionId);
|
||||
const pinned = await companySkillService(db).getVersion(companyId, installedId, original.currentVersionId!);
|
||||
expect(pinned!.fileInventory.find(file => file.path === 'SKILL.md')!.content).toBe(md('one'));
|
||||
await detachProjectSkillSource(db, companyId, row.id);
|
||||
const before = downloads.mock.calls.length;
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
expect(downloads).toHaveBeenCalledTimes(before);
|
||||
expect(await companySkillService(db).getById(companyId, installedId)).not.toBeNull();
|
||||
expect(await skillSourceService(db).detail(companyId, sourceId)).toMatchObject({ trackingActive: false, syncState: 'paused' });
|
||||
});
|
||||
|
||||
it('watches empty repositories, retains folder exclusions and coalesces worker sweeps', async () => {
|
||||
files = {}; commit = 'c'.repeat(40);
|
||||
const row = await workspace('empty');
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
const worker = skillSourceSyncService(db, { context: async () => context });
|
||||
const first = worker.tick();
|
||||
expect(worker.tick()).toBe(first);
|
||||
await first;
|
||||
const [binding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
expect(binding!.status).toBe('ready');
|
||||
const service = skillSourceService(db);
|
||||
const source = await service.detail(companyId, binding!.sourceId!);
|
||||
await service.refresh(companyId, source.id, context, { revision: source.revision, selectedPaths: [], excludedFolders: ['excluded'] });
|
||||
files = { 'excluded/secret/SKILL.md': md('excluded'), 'new/SKILL.md': md('new') }; commit = 'd'.repeat(40);
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
const result = await service.detail(companyId, source.id);
|
||||
expect(result.entries.find(entry => entry.path === 'new/SKILL.md')!.skillId).toBeTruthy();
|
||||
expect(result.entries.find(entry => entry.path.includes('secret'))!.selection).toBe('excluded');
|
||||
});
|
||||
|
||||
it('inherits either project access, rechecks revocation and fails closed after project deletion', async () => {
|
||||
files = { 'private/SKILL.md': md('private workflow') }; commit = 'e'.repeat(40);
|
||||
const row = await workspace('private', 'private');
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
await skillSourceSyncService(db, { context: async () => context }).tick();
|
||||
const [binding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
expect(binding!.status, binding!.lastError ?? 'initial private binding').toBe('ready');
|
||||
const source = await skillSourceService(db).detail(companyId, binding!.sourceId!);
|
||||
const skillId = source.entries[0]!.skillId!;
|
||||
let privateSnapshotPath = '';
|
||||
const privateDerivatives: string[] = [];
|
||||
for (const user of ['alice', 'bob']) {
|
||||
await db.insert(authUsers).values({ id: user, name: user, email: `${user}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: 'user', principalId: user, membershipRole: 'member', status: 'active' });
|
||||
}
|
||||
await db.insert(projectAccessMembers).values({ companyId, projectId: row.projectId, subjectType: 'user', subjectId: 'alice' });
|
||||
await withSkillActor(actor('alice'), async () => {
|
||||
expect(await companySkillService(db).getById(companyId, skillId)).not.toBeNull();
|
||||
expect((await companySkillService(db).list(companyId)).some(skill => skill.id === skillId)).toBe(true);
|
||||
const skill = (await companySkillService(db).getById(companyId, skillId))!;
|
||||
const [runtime] = await companySkillService(db).listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill.key]) });
|
||||
privateSnapshotPath = runtime!.source;
|
||||
await expect(companySkillService(db).importFromSource(companyId, runtime!.source)).rejects.toThrow(/Project-derived skill/);
|
||||
const fork = await companySkillService(db).forkSkill(companyId, skillId, { slug: 'private-copy' });
|
||||
expect(fork.skill.accessSourceId).not.toBe(source.id);
|
||||
expect(fork.skill.accessSourceId).toBeTruthy();
|
||||
privateDerivatives.push(fork.skill.id);
|
||||
await expect(companySkillService(db).updateSkill(companyId, fork.skill.id, { sharingScope: 'public_link' })).rejects.toThrow(/public link/);
|
||||
const copy = await companySkillService(db).createLocalSkill(companyId, { name: 'Private derived copy', forkedFromSkillId: skillId });
|
||||
expect(copy.accessSourceId).not.toBe(source.id);
|
||||
expect(copy.accessSourceId).toBeTruthy();
|
||||
privateDerivatives.push(copy.id);
|
||||
await expect(companySkillService(db).createLocalSkill(companyId, { name: 'Unsafe publication', forkedFromSkillId: skillId, sharingScope: 'public_link' })).rejects.toThrow(/public link/);
|
||||
});
|
||||
await withSkillActor(actor('bob'), async () => {
|
||||
const branchRead = vi.fn(context.read(null));
|
||||
await expect(skillSourceService(db).branches(companyId, source.id, {}, { ...context, read: () => branchRead })).rejects.toThrow(/not found/);
|
||||
expect(branchRead).not.toHaveBeenCalled();
|
||||
await expect(companySkillService(db).importFromSource(companyId, privateSnapshotPath)).rejects.toThrow(/not found/);
|
||||
expect(await companySkillService(db).getById(companyId, skillId)).toBeNull();
|
||||
expect(await companySkillService(db).readFile(companyId, skillId, 'SKILL.md')).toBeNull();
|
||||
expect((await skillSourceService(db).list(companyId)).some(item => item.id === source.id)).toBe(false);
|
||||
await expect(skillAccessService(db).assertSkill(companyId, skillId)).rejects.toThrow(/not found/);
|
||||
});
|
||||
const [reader] = await db.insert(agents).values({ companyId, name: 'Private skill reader', adapterType: 'codex_local', status: 'idle' }).returning();
|
||||
const [outsider] = await db.insert(agents).values({ companyId, name: 'Unassigned reader', adapterType: 'codex_local', status: 'idle' }).returning();
|
||||
for (const agent of [reader!, outsider!]) await db.insert(companyMemberships).values({ companyId, principalType: 'agent', principalId: agent.id, membershipRole: 'member', status: 'active' });
|
||||
await db.insert(projectAccessMembers).values({ companyId, projectId: row.projectId, subjectType: 'agent', subjectId: reader!.id });
|
||||
const agentActor = (agentId: string, userId: string): AuthorizationActor => ({
|
||||
type: 'agent', source: 'agent_jwt', companyId, agentId, runId: randomUUID(), onBehalfOfUserId: userId,
|
||||
});
|
||||
await withSkillActor(agentActor(reader!.id, 'alice'), async () => {
|
||||
expect(await companySkillService(db).readFile(companyId, skillId, 'SKILL.md')).not.toBeNull();
|
||||
const skill = (await companySkillService(db).getById(companyId, skillId))!;
|
||||
expect(await companySkillService(db).getVersion(companyId, skillId, skill.currentVersionId!)).not.toBeNull();
|
||||
expect((await companySkillService(db).listRuntimeSkillEntries(companyId)).some(entry => entry.key === skill.key)).toBe(true);
|
||||
});
|
||||
for (const denied of [agentActor(reader!.id, 'bob'), agentActor(outsider!.id, 'alice'), { ...agentActor(reader!.id, 'alice'), companyId: randomUUID() }]) {
|
||||
await withSkillActor(denied, async () => {
|
||||
const branchRead = vi.fn(context.read(null));
|
||||
await expect(skillSourceService(db).branches(companyId, source.id, {}, { ...context, read: () => branchRead })).rejects.toThrow(/not found/);
|
||||
expect(branchRead).not.toHaveBeenCalled();
|
||||
expect(await companySkillService(db).readFile(companyId, skillId, 'SKILL.md')).toBeNull();
|
||||
expect((await companySkillService(db).listRuntimeSkillEntries(companyId)).some(entry => entry.key.includes('private'))).toBe(false);
|
||||
});
|
||||
}
|
||||
const second = await workspace('private', 'private');
|
||||
await db.insert(projectAccessMembers).values({ companyId, projectId: second.projectId, subjectType: 'user', subjectId: 'bob' });
|
||||
await enqueueProjectSkillSource(db, second);
|
||||
await skillSourceSyncService(db, { context: async () => context }).tick();
|
||||
const [linked] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, second.id));
|
||||
expect(linked!.status, linked!.lastError ?? 'second project binding').toBe('ready');
|
||||
expect(linked!.sourceId).toBe(source.id);
|
||||
await withSkillActor(actor('bob'), async () => expect(await companySkillService(db).getById(companyId, skillId)).not.toBeNull());
|
||||
await db.delete(projectAccessMembers).where(and(eq(projectAccessMembers.projectId, second.projectId), eq(projectAccessMembers.subjectId, 'bob')));
|
||||
await withSkillActor(actor('bob'), async () => expect(await companySkillService(db).getById(companyId, skillId)).toBeNull());
|
||||
const opened = await workspace('private', 'open');
|
||||
await enqueueProjectSkillSource(db, opened);
|
||||
await skillSourceSyncService(db, { context: async () => context }).tick();
|
||||
await withSkillActor(actor('bob'), async () => {
|
||||
expect(await companySkillService(db).getById(companyId, skillId)).not.toBeNull();
|
||||
for (const derivativeId of privateDerivatives) {
|
||||
expect(await companySkillService(db).getById(companyId, derivativeId)).toBeNull();
|
||||
expect(await companySkillService(db).readFile(companyId, derivativeId, 'SKILL.md')).toBeNull();
|
||||
}
|
||||
});
|
||||
await withSkillActor(agentActor(reader!.id, 'bob'), async () => {
|
||||
expect(await companySkillService(db).getById(companyId, skillId)).not.toBeNull();
|
||||
for (const derivativeId of privateDerivatives) expect(await companySkillService(db).getById(companyId, derivativeId)).toBeNull();
|
||||
});
|
||||
await db.delete(projects).where(eq(projects.id, row.projectId));
|
||||
await db.delete(projects).where(eq(projects.id, second.projectId));
|
||||
await withSkillActor(actor('alice'), async () => {
|
||||
for (const id of privateDerivatives) expect(await companySkillService(db).getById(companyId, id)).toBeNull();
|
||||
});
|
||||
await db.delete(projects).where(eq(projects.id, opened.projectId));
|
||||
await withSkillActor(actor('alice'), async () => expect(await companySkillService(db).getById(companyId, skillId)).toBeNull());
|
||||
expect((await db.select().from(companySkills).where(eq(companySkills.id, skillId)))[0]!.accessSourceId).toBe(source.id);
|
||||
});
|
||||
|
||||
it.each(['copy', 'delete'] as const)('removes project tracking with %s while retaining dynamic copy/fork access', async choice => {
|
||||
files = { 'private/SKILL.md': md('retained private workflow') }; commit = '9'.repeat(40);
|
||||
const row = await workspace(`removed-${choice}`, 'private');
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
const worker = skillSourceSyncService(db, { context: async () => context });
|
||||
await worker.tick();
|
||||
const [binding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
const source = await service.detail(companyId, binding!.sourceId!);
|
||||
const original = (await skills.getById(companyId, source.entries[0]!.skillId!))!;
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: 'Source owner', email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: 'user', principalId: userId, membershipRole: 'member', status: 'active' });
|
||||
await db.insert(projectAccessMembers).values({ companyId, projectId: row.projectId, subjectType: 'user', subjectId: userId });
|
||||
await withSkillActor(actor(userId), async () => {
|
||||
const fork = await skills.forkSkill(companyId, original.id, { slug: `retained-${choice}-fork` });
|
||||
await skills.updateFile(companyId, fork.skill.id, 'SKILL.md', md('Private fork edits') + '\nConfidential original-project notes.\n');
|
||||
const result = await service.disconnect(companyId, source.id, context, { revision: source.revision, skills: choice });
|
||||
expect(result).toMatchObject({ copiedCount: choice === 'copy' ? 1 : 0, deletedCount: choice === 'delete' ? 1 : 0 });
|
||||
await expect(service.detail(companyId, source.id)).rejects.toThrow(/not found/);
|
||||
expect((await service.list(companyId)).some(item => item.id === source.id)).toBe(false);
|
||||
expect((await projectSkillRepositories(db, companyId, actor(userId))).find(item => item.workspaceId === row.id)).toMatchObject({ sourceId: null, status: 'available' });
|
||||
expect(await skills.getById(companyId, fork.skill.id)).not.toBeNull();
|
||||
let retainedVersionId = original.currentVersionId;
|
||||
if (choice === 'copy') {
|
||||
expect(await skills.getById(companyId, original.id)).toMatchObject({ sourceType: 'local_path', accessSourceId: source.id });
|
||||
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/private/SKILL.md'))?.content).toBe(md('retained private workflow'));
|
||||
expect((await skills.readFile(companyId, original.id, 'SKILL.md'))?.content).toContain('.paperclip-repository/private/SKILL.md');
|
||||
await skills.updateFile(companyId, original.id, 'SKILL.md', md('Private copy edits') + '\nConfidential original-project notes.\n');
|
||||
retainedVersionId = (await skills.createVersion(companyId, original.id, { label: 'Private edited copy' })).id;
|
||||
} else expect(await skills.getById(companyId, original.id)).toBeNull();
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false }).where(eq(companySkillSources.companyId, companyId));
|
||||
const before = downloads.mock.calls.length;
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
expect(downloads).toHaveBeenCalledTimes(before);
|
||||
// Reattachment retains disconnection; adoption is the deliberate restart action.
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
expect(downloads).toHaveBeenCalledTimes(before);
|
||||
const legacy = await workspace(`removed-${choice}`, 'open');
|
||||
await db.insert(projectAccessMembers).values({ companyId, projectId: legacy.projectId, subjectType: 'user', subjectId: userId });
|
||||
// This older workspace has no binding/source ID; one explicit adoption must work.
|
||||
await adoptProjectSkillRepository(db, companyId, legacy.id, context.authorize);
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
await expect(service.detail(companyId, source.id)).rejects.toThrow(/not found/);
|
||||
const [adopted] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, legacy.id));
|
||||
expect(adopted!.status, adopted!.lastError ?? 'adopted binding').toBe('ready');
|
||||
expect(adopted!.sourceId).toBeTruthy();
|
||||
expect(adopted!.sourceId).not.toBe(source.id);
|
||||
const restored = await service.detail(companyId, adopted!.sourceId!);
|
||||
expect(restored.enabled).toBe(true);
|
||||
expect(restored.lastError).toBeNull();
|
||||
expect(restored.entries.filter(entry => entry.skillId)).toHaveLength(1);
|
||||
expect(restored.entries[0]!.skillId).not.toBe(original.id);
|
||||
if (choice === 'copy') expect(await skills.getById(companyId, original.id)).toMatchObject({ key: original.key, sourceType: 'local_path', currentVersionId: retainedVersionId });
|
||||
const readerId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: readerId, name: 'Open project reader', email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: 'user', principalId: readerId, membershipRole: 'member', status: 'active' });
|
||||
await withSkillActor(actor(readerId), async () => {
|
||||
const upstream = restored.entries[0]!.skillId!;
|
||||
expect(await skills.getById(companyId, upstream)).not.toBeNull();
|
||||
expect(await skills.getById(companyId, fork.skill.id)).toBeNull();
|
||||
expect(await skills.readFile(companyId, fork.skill.id, 'SKILL.md')).toBeNull();
|
||||
if (choice === 'copy') {
|
||||
expect(await skills.getById(companyId, original.id)).toBeNull();
|
||||
expect(await skills.readFile(companyId, original.id, 'SKILL.md')).toBeNull();
|
||||
expect(await skills.getVersion(companyId, original.id, retainedVersionId!)).toBeNull();
|
||||
}
|
||||
expect((await skills.readFile(companyId, upstream, '.paperclip-repository/private/SKILL.md'))?.content).not.toContain('Confidential');
|
||||
// A guessed hidden slug cannot replace or erase someone else's private fork.
|
||||
const publicFork = await skills.forkSkill(companyId, upstream, { slug: `retained-${choice}-fork` });
|
||||
expect(publicFork.skill.id).not.toBe(fork.skill.id);
|
||||
expect(publicFork.skill.slug).not.toBe(fork.skill.slug);
|
||||
});
|
||||
expect((await skills.readFile(companyId, fork.skill.id, 'SKILL.md'))?.content).toContain('Confidential original-project notes');
|
||||
// Explicit adoption of the original workspace also moves tracking, not provenance.
|
||||
await adoptProjectSkillRepository(db, companyId, row.id, context.authorize);
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
const [rebound] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
expect(rebound!.sourceId).toBe(restored.id);
|
||||
expect(await skills.getById(companyId, fork.skill.id)).not.toBeNull();
|
||||
await db.delete(projects).where(eq(projects.id, legacy.projectId));
|
||||
await db.delete(projectAccessMembers).where(and(eq(projectAccessMembers.projectId, row.projectId), eq(projectAccessMembers.subjectId, userId)));
|
||||
expect(await skills.getById(companyId, fork.skill.id)).toBeNull();
|
||||
if (choice === 'copy') expect(await skills.getById(companyId, original.id)).toBeNull();
|
||||
await db.delete(projects).where(eq(projects.id, row.projectId));
|
||||
expect(await skills.getById(companyId, fork.skill.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers expired jobs, competes through durable leases and respects failed-refresh backoff', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
files = { 'leased/SKILL.md': md('leased') }; commit = 'f'.repeat(40);
|
||||
const row = await workspace('leased');
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
await db.update(projectSkillSources).set({ leaseToken: randomUUID(), leaseExpiresAt: new Date(Date.now() - 1_000) })
|
||||
.where(eq(projectSkillSources.id, binding!.id));
|
||||
let release!: () => void, scanned!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const started = new Promise<void>(resolve => { scanned = resolve; });
|
||||
const blocking: SkillSourceContext = { ...context, read: () => {
|
||||
const fixture = context.read(null), open = fixture.openSnapshot!;
|
||||
fixture.openSnapshot = async (...args) => { scanned(); await gate; return open(...args); };
|
||||
return fixture;
|
||||
} };
|
||||
const first = skillSourceSyncService(db, { context: async () => blocking }).tick();
|
||||
await started;
|
||||
expect((await skillSourceSyncService(db, { context: async () => context }).tick()).imported).toBe(0);
|
||||
release(); expect((await first).imported).toBe(1);
|
||||
const [ready] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, binding!.id));
|
||||
const sourceId = ready!.sourceId!;
|
||||
const service = skillSourceService(db);
|
||||
const original = await service.detail(companyId, sourceId);
|
||||
const failure: SkillSourceContext = { ...context, read: () => (async () => {
|
||||
throw unprocessable('GitHub request limit reached.', { retryAfterMs: 7_200_000 });
|
||||
}) as ReturnType<SkillSourceContext['read']> };
|
||||
const failedAt = new Date();
|
||||
await db.update(companySkillSources).set({ nextSyncAt: failedAt }).where(eq(companySkillSources.id, sourceId));
|
||||
await skillSourceSyncService(db, { context: async () => failure }).tick(failedAt);
|
||||
const [failed] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, sourceId));
|
||||
expect(failed!.failureCount).toBe(1);
|
||||
expect(failed!.nextSyncAt.getTime() - failedAt.getTime()).toBeGreaterThanOrEqual(7_200_000);
|
||||
expect((await service.detail(companyId, sourceId)).entries[0]!.skillId).toBe(original.entries[0]!.skillId);
|
||||
const retryRead = vi.fn(context.read);
|
||||
await skillSourceSyncService(db, { context: async () => ({ ...context, read: retryRead }) })
|
||||
.tick(new Date(failedAt.getTime() + 6 * 60_000));
|
||||
expect(retryRead).not.toHaveBeenCalled();
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false, nextSyncAt: new Date() }).where(eq(companySkillSources.id, sourceId));
|
||||
await skillSourceSyncService(db, { context: async () => ({ ...context, read: retryRead }) }).tick();
|
||||
expect(retryRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips live leases before bounding import and refresh batches', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
files = { 'fair/SKILL.md': md('fair') }; commit = '9'.repeat(40);
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 60 * 60_000);
|
||||
const busyJobs: string[] = [];
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const row = await workspace(`busy-import-${index}`);
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
busyJobs.push(binding!.id);
|
||||
await db.update(projectSkillSources).set({ leaseToken: randomUUID(), leaseExpiresAt: expires,
|
||||
nextSyncAt: new Date(now.getTime() - (20 - index) * 1_000) }).where(eq(projectSkillSources.id, binding!.id));
|
||||
}
|
||||
const ready = await enqueueProjectSkillSource(db, await workspace('ready-import'));
|
||||
await db.update(projectSkillSources).set({ nextSyncAt: now }).where(eq(projectSkillSources.id, ready!.id));
|
||||
const worker = skillSourceSyncService(db, { context: async () => context });
|
||||
expect((await worker.tick(now)).imported).toBe(1);
|
||||
const [imported] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, ready!.id));
|
||||
expect(imported!.sourceId).toBeTruthy();
|
||||
for (const id of busyJobs) {
|
||||
const [job] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, id));
|
||||
expect(job).toMatchObject({ sourceId: null, leaseExpiresAt: expires, status: 'pending' });
|
||||
}
|
||||
|
||||
const [template] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, imported!.sourceId!));
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
const readySourceId = randomUUID();
|
||||
await db.insert(companySkillSources).values(Array.from({ length: 11 }, (_, index) => ({
|
||||
...template!, id: index === 10 ? readySourceId : randomUUID(), trackingRef: `fair-refresh-${index}`,
|
||||
accessScope: 'company' as const, automaticUpdates: true, nextSyncAt: new Date(now.getTime() - (20 - index) * 1_000),
|
||||
leaseToken: index === 10 ? null : randomUUID(), leaseExpiresAt: index === 10 ? null : expires,
|
||||
})));
|
||||
expect((await worker.tick(now)).checked).toBe(1);
|
||||
const [refreshed] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, readySourceId));
|
||||
expect(refreshed!.nextSyncAt.getTime()).toBeGreaterThan(now.getTime());
|
||||
});
|
||||
|
||||
it('releases interrupted initial imports so a new worker can recover them', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
files = { 'recovered/SKILL.md': md('recovered') }; commit = '1'.repeat(40);
|
||||
const row = await workspace('restart');
|
||||
const binding = await enqueueProjectSkillSource(db, row);
|
||||
let scanned!: () => void;
|
||||
const started = new Promise<void>(resolve => { scanned = resolve; });
|
||||
const blocking: SkillSourceContext = { ...context, read: () => {
|
||||
const fixture = context.read(null);
|
||||
fixture.openSnapshot = async (_input, options) => {
|
||||
scanned();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason), { once: true });
|
||||
});
|
||||
throw new Error('Unreachable');
|
||||
};
|
||||
return fixture;
|
||||
} };
|
||||
const worker = skillSourceSyncService(db, { context: async () => blocking });
|
||||
const sweep = worker.tick();
|
||||
await started;
|
||||
await worker.stop();
|
||||
await sweep;
|
||||
const [interrupted] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.id, binding!.id));
|
||||
expect(interrupted).toMatchObject({ status: 'pending', failureCount: 0, sourceId: null, leaseToken: null, lastError: null });
|
||||
expect((await skillSourceSyncService(db, { context: async () => context }).tick()).imported).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps deleted setup agents attributable and requires explicit connection repair', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
files = { 'owned/SKILL.md': md('owned') }; commit = '2'.repeat(40);
|
||||
const row = await workspace('deleted-owner');
|
||||
const [owner] = await db.insert(agents).values({ companyId, name: 'Source setup agent', adapterType: 'codex_local', status: 'idle' }).returning();
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
const ownerContext: SkillSourceContext = { ...context, setup: { userId: 'local-board', localTrusted: true, agentId: owner!.id } };
|
||||
await skillSourceSyncService(db, { context: async () => ownerContext }).tick();
|
||||
const [binding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
const sourceId = binding!.sourceId!;
|
||||
await db.delete(agents).where(eq(agents.id, owner!.id));
|
||||
await db.update(companySkillSources).set({ nextSyncAt: new Date() }).where(eq(companySkillSources.id, sourceId));
|
||||
const [before] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, sourceId));
|
||||
expect(before!.refreshAgentId).toBe(owner!.id);
|
||||
await skillSourceSyncService(db).tick();
|
||||
const [failed] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, sourceId));
|
||||
expect(failed!.lastError).toContain('agent who connected');
|
||||
expect(failed!.failureCount).toBe(1);
|
||||
expect((await skillSourceService(db).detail(companyId, sourceId)).entries[0]!.skillId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('backs off a policy denial before acquiring a source lease', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
files = { 'policy/SKILL.md': md('policy') }; commit = '3'.repeat(40);
|
||||
const row = await workspace('policy-denial');
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
await skillSourceSyncService(db, { context: async () => context }).tick();
|
||||
const [binding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
const now = new Date();
|
||||
await db.update(companySkillSources).set({ nextSyncAt: now }).where(eq(companySkillSources.id, binding!.sourceId!));
|
||||
const authorize = vi.fn(async () => { throw unprocessable('Automatic updates are denied by company policy.'); });
|
||||
const worker = skillSourceSyncService(db, { context: async () => ({ ...context, authorize }) });
|
||||
await worker.tick(now);
|
||||
const [failed] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, binding!.sourceId!));
|
||||
expect(failed).toMatchObject({ failureCount: 1, leaseToken: null, lastError: 'Automatic updates are denied by company policy.' });
|
||||
expect(failed!.nextSyncAt.getTime()).toBeGreaterThan(now.getTime());
|
||||
await worker.tick(new Date(now.getTime() + 1000));
|
||||
expect(authorize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses bounded skill packages when a tracked repository outgrows the repository limit', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
files = { 'small/SKILL.md': md('small') }; commit = '4'.repeat(40);
|
||||
const row = await workspace('growing-repository');
|
||||
await enqueueProjectSkillSource(db, row);
|
||||
await skillSourceSyncService(db, { context: async () => context }).tick();
|
||||
const [binding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row.id));
|
||||
const service = skillSourceService(db), original = await service.detail(companyId, binding!.sourceId!);
|
||||
expect(original.packageMode).toBe('repository');
|
||||
const skillId = original.entries[0]!.skillId!;
|
||||
files['small/SKILL.md'] = md('small updated'); commit = '5'.repeat(40);
|
||||
const growing: SkillSourceContext = { ...context, read: () => {
|
||||
const fixture = context.read(null), open = fixture.openSnapshot;
|
||||
fixture.openSnapshot = async (...args) => {
|
||||
const snapshot = await open(...args);
|
||||
for (let index = 0; index < 10000; index++) snapshot.entries.push({ path: `unrelated/${index}.txt`, type: 'blob', mode: '100644', sha: String(index + 1), size: 0 });
|
||||
return snapshot;
|
||||
};
|
||||
return fixture;
|
||||
} };
|
||||
const result = await service.refresh(companyId, original.id, growing);
|
||||
expect(result.source.packageMode).toBe('skills');
|
||||
expect(result.warnings.join(' ')).toContain('repository exceeds');
|
||||
expect(result.source.entries[0]!.skillId).toBe(skillId);
|
||||
expect(result.updated).toHaveLength(1);
|
||||
files = { 'small/SKILL.md': md('small with shared guide') + '\nRead [guide](../shared/guide.md).', 'shared/guide.md': 'Now fits in the repository package.' };
|
||||
commit = '6'.repeat(40);
|
||||
const complete = await service.refresh(companyId, original.id, context);
|
||||
expect(complete.source.packageMode).toBe('repository');
|
||||
expect(complete.source.entries.find(entry => entry.path === 'small/SKILL.md')!.skillId).toBe(skillId);
|
||||
expect((await companySkillService(db).readFile(companyId, skillId, '.paperclip-repository/shared/guide.md'))?.content)
|
||||
.toBe('Now fits in the repository package.');
|
||||
});
|
||||
|
||||
it('enqueues repository changes atomically, leaves ordinary legacy edits alone and retains old references', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
const legacy = await workspace('lifecycle');
|
||||
const service = projectService(db, { captureWorkspaceUpdates: false });
|
||||
await service.updateWorkspace(legacy.projectId, legacy.id, { name: 'Renamed older workspace' });
|
||||
expect(await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, legacy.id))).toHaveLength(0);
|
||||
|
||||
await expect(db.transaction(async tx => {
|
||||
await projectService(tx as unknown as typeof db, { captureWorkspaceUpdates: false }).createWorkspace(legacy.projectId,
|
||||
{ repoUrl: 'https://github.com/acme/skills', defaultRef: 'rolled-back' });
|
||||
throw new Error('Required project audit failed');
|
||||
})).rejects.toThrow('Required project audit failed');
|
||||
expect(await db.select().from(projectWorkspaces).where(eq(projectWorkspaces.defaultRef, 'rolled-back'))).toHaveLength(0);
|
||||
expect(await db.select().from(projectSkillSources).where(eq(projectSkillSources.trackingRef, 'rolled-back'))).toHaveLength(0);
|
||||
|
||||
files = { 'lifecycle/SKILL.md': md('lifecycle') }; commit = '6'.repeat(40);
|
||||
const created = (await service.createWorkspace(legacy.projectId,
|
||||
{ repoUrl: 'https://github.com/acme/skills', defaultRef: 'lifecycle-created' }))!;
|
||||
const worker = skillSourceSyncService(db, { context: async () => context });
|
||||
await worker.tick();
|
||||
const [originalBinding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, created.id));
|
||||
const source = await skillSourceService(db).detail(companyId, originalBinding!.sourceId!);
|
||||
const skillId = source.entries[0]!.skillId!;
|
||||
const versionId = (await companySkillService(db).getById(companyId, skillId))!.currentVersionId;
|
||||
|
||||
await service.updateWorkspace(legacy.projectId, created.id, { defaultRef: 'lifecycle-next' });
|
||||
await worker.tick();
|
||||
const bindings = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, created.id));
|
||||
expect(bindings).toHaveLength(2);
|
||||
expect(bindings.find(binding => binding.id === originalBinding!.id)?.active).toBe(false);
|
||||
const current = bindings.find(binding => binding.active)!;
|
||||
expect(current.sourceId).not.toBe(source.id);
|
||||
const retained = (await companySkillService(db).getById(companyId, skillId))!;
|
||||
expect(retained.currentVersionId).toBe(versionId);
|
||||
const newSource = await skillSourceService(db).detail(companyId, current.sourceId!);
|
||||
expect(newSource.entries[0]!.skillId).not.toBe(skillId);
|
||||
|
||||
await service.updateWorkspace(legacy.projectId, created.id, { defaultRef: 'lifecycle-next' });
|
||||
expect(await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, created.id))).toHaveLength(2);
|
||||
await service.removeWorkspace(legacy.projectId, created.id);
|
||||
expect(await skillSourceService(db).detail(companyId, current.sourceId!)).toMatchObject({ trackingActive: false });
|
||||
expect(await companySkillService(db).getById(companyId, skillId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lets API-key agents attach repositories without inventing a setup person', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
const project = await workspace('key-agent-parent');
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: 'API-key project creator', adapterType: 'codex_local', status: 'idle' }).returning();
|
||||
const keyActor: AuthorizationActor = { type: 'agent', source: 'agent_key', agentId: agent!.id, companyId, onBehalfOfUserId: 'unverified-person' };
|
||||
const attached = await withSkillActor(keyActor, () => projectService(db, { captureWorkspaceUpdates: false }).createWorkspace(project.projectId,
|
||||
{ repoUrl: 'https://github.com/acme/skills', defaultRef: 'key-agent' }));
|
||||
expect(attached).not.toBeNull();
|
||||
const [binding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, attached!.id));
|
||||
expect(binding).toMatchObject({ status: 'pending', refreshUserId: null, refreshAgentId: agent!.id, refreshLocalTrusted: false, sourceId: null });
|
||||
await detachProjectSkillSource(db, companyId, attached!.id);
|
||||
});
|
||||
|
||||
it('requires connection repair before scheduled refresh of API-key public imports', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
files = {};
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: 'API-key source importer', adapterType: 'codex_local', status: 'idle' }).returning();
|
||||
const setup = await projectSkillSetupIdentity(db, companyId, { type: 'agent', source: 'agent_key', companyId,
|
||||
agentId: agent!.id, onBehalfOfUserId: 'local-board' });
|
||||
const imported = await skillSourceService(db).create(companyId, { repositoryUrl: 'https://github.com/acme/skills',
|
||||
trackingRef: 'key-generic-public', commitSha: commit, selectedPaths: [] }, { ...context,
|
||||
actor: { type: 'agent', agentId: agent!.id }, setup: { userId: setup.refreshUserId, agentId: setup.refreshAgentId, localTrusted: setup.refreshLocalTrusted } });
|
||||
const [stored] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, imported.source.id));
|
||||
expect(stored).toMatchObject({ refreshUserId: null, refreshAgentId: agent!.id, refreshLocalTrusted: false, connectionId: null });
|
||||
await db.insert(activityLog).values({ companyId, actorType: 'agent', actorId: agent!.id, agentId: agent!.id,
|
||||
responsibleUserId: 'local-board', action: 'company.skill_source_refreshed', entityType: 'company_skill_source', entityId: imported.source.id });
|
||||
await skillSourceSyncService(db).tick(new Date(Date.now() + 6 * 60_000));
|
||||
const [checked] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, imported.source.id));
|
||||
expect(checked!.lastError).toMatch(/Choose a GitHub connection/);
|
||||
expect(checked!.refreshUserId).toBeNull();
|
||||
expect(checked!.lastSuccessAt).toEqual(stored!.lastSuccessAt);
|
||||
// Migrated sources may lack refreshAgentId; API-key audit responsibility
|
||||
// still cannot stand in for an authenticated run's credential authority.
|
||||
await db.update(companySkillSources).set({ refreshAgentId: null }).where(eq(companySkillSources.id, imported.source.id));
|
||||
await skillSourceSyncService(db).tick(new Date(Date.now() + 12 * 60_000));
|
||||
const [legacy] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, imported.source.id));
|
||||
expect(legacy!.refreshUserId).toBeNull();
|
||||
expect(legacy!.lastError).toMatch(/Choose a GitHub connection/);
|
||||
|
||||
});
|
||||
|
||||
it('follows a changed default branch while an explicit branch remains fixed', async () => {
|
||||
await db.update(companySkillSources).set({ automaticUpdates: false });
|
||||
let defaultBranch = 'main';
|
||||
const originalFiles = { 'workflow/SKILL.md': md('original default') }, nextFiles = { 'workflow/SKILL.md': md('new default') };
|
||||
const moving: SkillSourceContext = { ...context, read: () => {
|
||||
const reader = (async (url: string) => {
|
||||
if (url === '/repos/acme/skills') return { id: 42, full_name: 'acme/skills', default_branch: defaultBranch };
|
||||
if (url.startsWith('/repos/acme/skills/commits/')) return { sha: decodeURIComponent(url.split('/commits/')[1]!) === 'main' ? '7'.repeat(40) : '8'.repeat(40) };
|
||||
throw new Error('Unexpected request');
|
||||
}) as ReturnType<SkillSourceContext['read']>;
|
||||
reader.openSnapshot = async (input, options) => githubFixture(input.ref === 'main' ? originalFiles : nextFiles, {}, input.ref === 'main' ? '7'.repeat(40) : '8'.repeat(40)).openSnapshot(input, options);
|
||||
return reader;
|
||||
} };
|
||||
const implicit = await workspace('HEAD');
|
||||
await enqueueProjectSkillSource(db, implicit);
|
||||
const worker = skillSourceSyncService(db, { context: async () => moving });
|
||||
await worker.tick();
|
||||
const [defaultBinding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, implicit.id));
|
||||
const sourceService = skillSourceService(db);
|
||||
const original = await sourceService.detail(companyId, defaultBinding!.sourceId!);
|
||||
expect(original.trackingRef).toBe('HEAD');
|
||||
const skillId = original.entries[0]!.skillId!;
|
||||
const versionId = (await companySkillService(db).getById(companyId, skillId))!.currentVersionId;
|
||||
|
||||
const explicit = await workspace('main');
|
||||
await enqueueProjectSkillSource(db, explicit);
|
||||
await worker.tick();
|
||||
const [fixedBinding] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, explicit.id));
|
||||
expect(fixedBinding!.sourceId).not.toBe(original.id);
|
||||
defaultBranch = 'next-default';
|
||||
await worker.tick(new Date(Date.now() + 6 * 60_000));
|
||||
expect(await sourceService.detail(companyId, original.id)).toMatchObject({ trackingRef: 'HEAD', lastScanCommit: '8'.repeat(40) });
|
||||
expect(await sourceService.detail(companyId, fixedBinding!.sourceId!)).toMatchObject({ trackingRef: 'main', lastScanCommit: '7'.repeat(40) });
|
||||
const latest = (await companySkillService(db).getById(companyId, skillId))!;
|
||||
expect(latest.currentVersionId).not.toBe(versionId);
|
||||
expect(latest.markdown).toBe(md('new default'));
|
||||
expect((await companySkillService(db).getVersion(companyId, skillId, versionId!))!.fileInventory[0]!.content).toBe(md('original default'));
|
||||
const repeated = await workspace('HEAD');
|
||||
await enqueueProjectSkillSource(db, repeated);
|
||||
await worker.tick();
|
||||
const [reused] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, repeated.id));
|
||||
expect(reused!.sourceId).toBe(original.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import express from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMcpApiDispatch } from "../services/public-mcp/capabilities.js";
|
||||
import type { McpPrincipal } from "../services/public-mcp/oauth.js";
|
||||
|
||||
const captureExceptionMock = vi.hoisted(() => vi.fn());
|
||||
const trackErrorHandlerCrashMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../sentry.js", () => ({ captureException: captureExceptionMock }));
|
||||
vi.mock("../telemetry.js", () => ({ getTelemetryClient: () => ({}) }));
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackErrorHandlerCrash: trackErrorHandlerCrashMock,
|
||||
}));
|
||||
|
||||
const principal = { actor: { type: "board", userId: "user-1" } } as unknown as McpPrincipal;
|
||||
|
||||
// The internal dispatch runs each public MCP call in its own Express app, so
|
||||
// the shutdown flag set on the main app must reach that app's error handler.
|
||||
function endedPoolApi() {
|
||||
const api = express.Router();
|
||||
api.get("/tasks", () => {
|
||||
throw new Error('Failed query: select "id" from "issues"', {
|
||||
cause: Object.assign(new Error("write CONNECTION_ENDED db.example:5432"), { code: "CONNECTION_ENDED" }),
|
||||
});
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe("public MCP dispatch during database shutdown", () => {
|
||||
beforeEach(() => {
|
||||
captureExceptionMock.mockReset();
|
||||
trackErrorHandlerCrashMock.mockReset();
|
||||
});
|
||||
|
||||
it("does not report a call that outlived the database pool", async () => {
|
||||
const parentLocals: Record<string, unknown> = {};
|
||||
const dispatch = createMcpApiDispatch(endedPoolApi(), parentLocals);
|
||||
parentLocals.paperclipDatabaseClosing = true;
|
||||
|
||||
await expect(dispatch(principal, "GET", "/tasks", undefined)).rejects.toMatchObject({ status: 503 });
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
expect(trackErrorHandlerCrashMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reports an ended-pool error outside database shutdown", async () => {
|
||||
const dispatch = createMcpApiDispatch(endedPoolApi(), {});
|
||||
|
||||
await expect(dispatch(principal, "GET", "/tasks", undefined)).rejects.toMatchObject({ status: 500 });
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -249,6 +249,95 @@ describeEmbeddedPostgres("recovery sweepStaleIssueLocks", () => {
|
||||
).resolves.toBe("running");
|
||||
});
|
||||
|
||||
async function seedCompletedOnboardingReport(options: {
|
||||
runtimeMode?: "native" | "legacy";
|
||||
variant?: "ordinary" | "cancelled" | "wrong_child" | "wrong_reason" | "wrong_agent" | "unfinished_child" | "foreign_child";
|
||||
} = {}) {
|
||||
const { companyId, agentId, runningRunId } = await seed();
|
||||
const issueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const variant = options.variant;
|
||||
let childCompanyId = companyId;
|
||||
if (variant === "foreign_child") {
|
||||
childCompanyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: childCompanyId,
|
||||
name: "Other company",
|
||||
issuePrefix: `T${childCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
}
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Completed onboarding awaiting its child result",
|
||||
originKind: variant === "ordinary" ? "manual" : "onboarding_first_task",
|
||||
status: variant === "cancelled" ? "cancelled" : "done",
|
||||
assigneeAgentId: variant === "wrong_agent" ? null : agentId,
|
||||
checkoutRunId: runningRunId,
|
||||
executionRunId: runningRunId,
|
||||
executionLockedAt: new Date(),
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: childIssueId,
|
||||
companyId: childCompanyId,
|
||||
title: "Accepted child output",
|
||||
status: variant === "unfinished_child" ? "in_progress" : "done",
|
||||
parentId: variant === "foreign_child" ? null : issueId,
|
||||
});
|
||||
await db.update(heartbeatRuns).set({
|
||||
runtimeMode: options.runtimeMode ?? "native",
|
||||
nativeIssueId: options.runtimeMode === "legacy" ? null : issueId,
|
||||
processPid: 2_000_000_000,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: variant === "wrong_reason" ? "issue_commented" : "issue_children_completed",
|
||||
completedChildIssueId: variant === "wrong_child" ? randomUUID() : childIssueId,
|
||||
onboardingCompletion: true,
|
||||
},
|
||||
}).where(eq(heartbeatRuns.id, runningRunId));
|
||||
return { companyId, agentId, issueId, childIssueId, runningRunId };
|
||||
}
|
||||
|
||||
it.each(["native", "legacy"] as const)("preserves a live %s completed-onboarding reporting turn during delayed startup", async runtimeMode => {
|
||||
const { issueId, runningRunId } = await seedCompletedOnboardingReport({ runtimeMode });
|
||||
const result = await recoveryService(db, {
|
||||
enqueueWakeup: vi.fn(),
|
||||
liveRunExecutions: new Set([runningRunId]),
|
||||
}).sweepStaleIssueLocks();
|
||||
|
||||
expect(result).toEqual({ cleared: 0, issueIds: [], terminalizedRunIds: [] });
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]?.status).toBe("running");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({
|
||||
status: "done", checkoutRunId: runningRunId, executionRunId: runningRunId,
|
||||
});
|
||||
expect(mockTelemetryClient.track).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ordinary", "cancelled", "wrong_child", "wrong_reason", "wrong_agent", "unfinished_child", "foreign_child"] as const)("does not exempt an invalid completed-onboarding reporting turn (%s)", async variant => {
|
||||
const { issueId, runningRunId } = await seedCompletedOnboardingReport({ variant });
|
||||
const result = await recoveryService(db, {
|
||||
enqueueWakeup: vi.fn(),
|
||||
liveRunExecutions: new Set([runningRunId]),
|
||||
}).sweepStaleIssueLocks();
|
||||
|
||||
expect(result.terminalizedRunIds).toEqual([runningRunId]);
|
||||
expect(result.cleared).toBe(1);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]?.status).toBe(variant === "cancelled" ? "cancelled" : "succeeded");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ checkoutRunId: null, executionRunId: null });
|
||||
});
|
||||
|
||||
it("still interrupts a completed-onboarding reporting turn after its process and execution owner are gone", async () => {
|
||||
const { issueId, runningRunId } = await seedCompletedOnboardingReport();
|
||||
const result = await recoveryService(db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks();
|
||||
|
||||
expect(result.terminalizedRunIds).toEqual([runningRunId]);
|
||||
expect(result.cleared).toBe(1);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]).toMatchObject({
|
||||
status: "interrupted", errorCode: "orphaned_running_run",
|
||||
});
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ checkoutRunId: null, executionRunId: null });
|
||||
});
|
||||
|
||||
it("does not clear when checkoutRunId is terminal but executionRunId is still running", async () => {
|
||||
const { companyId, agentId, failedRunId, runningRunId } = await seed();
|
||||
const issueId = randomUUID();
|
||||
|
||||
@@ -237,3 +237,10 @@ describe("stripSecretBearingUrlParts", () => {
|
||||
).toBe("/api/tools/oauth/callback");
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts Grok routine URL and key in setup failure logs", () => {
|
||||
const payload = { webhookUrl: "https://bot.example/routine/url-canary", webhookKey: "key-canary" };
|
||||
expect(JSON.stringify(redactSensitive(payload))).not.toContain("canary");
|
||||
expect(collectSensitiveStringValues(payload)).toContain(payload.webhookUrl);
|
||||
expect(collectSensitiveStringValues(payload)).toContain(payload.webhookKey);
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describePostgres("Resource lifecycle events", () => {
|
||||
|
||||
it("records direct and approval-free hires once, including concurrent duplicate submissions", async () => {
|
||||
const agent = await createAgent();
|
||||
await Promise.all(Array.from({ length: 5 }, () => recordResourceCreationEvent(db, companyId, "agent", agent.id)));
|
||||
await Promise.all(Array.from({ length: 5 }, () => db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "agent", agent.id))));
|
||||
expect(await events()).toEqual([expect.objectContaining({ resourceType: "agent", resourceId: agent.id, action: "create" })]);
|
||||
});
|
||||
|
||||
@@ -235,11 +235,11 @@ describePostgres("Resource lifecycle events", () => {
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-token");
|
||||
const id = randomUUID();
|
||||
await recordResourceCreationEvent(db, companyId, "agent", id);
|
||||
await recordResourceCreationEvent(db, companyId, "project", id);
|
||||
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "agent", id));
|
||||
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "project", id));
|
||||
const otherCompanyId = randomUUID();
|
||||
await db.insert(companies).values({ id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" });
|
||||
await recordResourceCreationEvent(db, otherCompanyId, "agent", id);
|
||||
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, otherCompanyId, "agent", id));
|
||||
expect(await events()).toHaveLength(2);
|
||||
expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toHaveLength(1);
|
||||
await db.delete(companies).where(eq(companies.id, otherCompanyId));
|
||||
@@ -267,6 +267,23 @@ describePostgres("Resource lifecycle events", () => {
|
||||
expect(beforeArchive.sort((a, b) => a.id - b.id).map(row => row.action)).toEqual(["create", "update", "update", "update", "update", "update"]);
|
||||
});
|
||||
|
||||
it("records deletion atomically and retains the event after removing the project", async () => {
|
||||
const service = projectService(db);
|
||||
const project = await service.create(companyId, { name: "Delete fixture" });
|
||||
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_delete CHECK (action <> 'delete') NOT VALID`);
|
||||
try {
|
||||
await expect(service.remove(project.id)).rejects.toThrow();
|
||||
expect(await service.getById(project.id)).not.toBeNull();
|
||||
expect((await events()).map(row => row.action)).toEqual(["create"]);
|
||||
} finally {
|
||||
await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_delete`);
|
||||
}
|
||||
await service.remove(project.id);
|
||||
await service.remove(project.id);
|
||||
expect(await service.getById(project.id)).toBeNull();
|
||||
expect((await events()).map(row => row.action)).toEqual(["create", "delete"]);
|
||||
});
|
||||
|
||||
it("records archive once per transition and update on restore, retaining repositories", async () => {
|
||||
const service = projectService(db);
|
||||
const project = await service.createWithRepositories(companyId, { name: "Archive fixture" }, [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { captureEnvironmentAcquisitionDiagnostic } from "../services/environment-acquisition-diagnostics.js";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
@@ -374,6 +375,8 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
|
||||
await sentryReady;
|
||||
|
||||
const first = {
|
||||
companyId: "55555555-5555-4555-8555-555555555555",
|
||||
agentId: "66666666-6666-4666-8666-666666666666",
|
||||
taskId: "11111111-1111-4111-8111-111111111111",
|
||||
runId: "22222222-2222-4222-8222-222222222222",
|
||||
errorMessage: "first run failed",
|
||||
@@ -432,7 +435,7 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
|
||||
contexts: { run_failure: { runId: run.runId, taskId: run.taskId,
|
||||
errorMessage: run.errorMessage, errorCode: run.errorCode, agentAdapter: run.agentAdapter,
|
||||
exitCode: run.exitCode, signal: run.signal } },
|
||||
fingerprint: [run.errorCode, run.agentAdapter],
|
||||
fingerprint: [run.errorCode, run.agentAdapter, ...(run === first ? ["execute", "ECONNRESET"] : [])],
|
||||
});
|
||||
}
|
||||
expect(captured("malformed process metadata")).toMatchObject({
|
||||
@@ -441,6 +444,7 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
|
||||
});
|
||||
expect(JSON.stringify(events)).not.toContain("private-signal-payload");
|
||||
const firstCapture = captured(first.errorMessage)!;
|
||||
expect(firstCapture).toHaveProperty("contexts.paperclip.runPath", `/api/heartbeat-runs/${first.runId}`);
|
||||
const exceptions = (firstCapture.exception as { values: Array<Record<string, unknown>> }).values;
|
||||
expect(exceptions).toHaveLength(2);
|
||||
expect(JSON.stringify(exceptions)).toContain("originalAdapter");
|
||||
@@ -610,3 +614,6 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
enableRunFailureReporting();
|
||||
@@ -71,12 +71,14 @@ async function importFreshSentryWithGateOpen() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
|
||||
delete process.env[DSN_ENV];
|
||||
delete process.env[FRONTEND_DSN_ENV];
|
||||
delete process.env[BACKEND_DSN_ENV];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
if (originalDsn === undefined) delete process.env[DSN_ENV];
|
||||
else process.env[DSN_ENV] = originalDsn;
|
||||
if (originalFrontendDsn === undefined) delete process.env[FRONTEND_DSN_ENV];
|
||||
@@ -89,6 +91,46 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("captureRunFailure", () => {
|
||||
it.each([undefined, "off", "true", "invalid"])("keeps ordinary server errors enabled while run reporting is disabled: %s", async mode => {
|
||||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", mode);
|
||||
if (mode === undefined) delete process.env.PAPERCLIP_SENTRY_RUN_FAILURES;
|
||||
sentryModule.captureRunFailure(baseEvent());
|
||||
const error = new Error("ordinary server failure");
|
||||
sentryModule.captureException(error);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(error);
|
||||
});
|
||||
|
||||
it("provides authenticated run references and groups repeated causes without leaking scope", async () => {
|
||||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example.com");
|
||||
const event = baseEvent({ companyId: "company", agentId: "agent", taskId: null,
|
||||
diagnostics: { execution: { failurePhase: "setup", originalFailureCode: "tool_name_collision" }, adapter: {}, provider: {}, exceptions: [], truncatedFields: [] } });
|
||||
sentryModule.captureRunFailure(event);
|
||||
sentryModule.captureRunFailure({ ...event, runId: "another-run", errorMessage: "Another tool failed" });
|
||||
sentryModule.captureException(new Error("unrelated"));
|
||||
const first = captureException.mock.calls[0]![1]!;
|
||||
expect(first.tags).toMatchObject({ event_kind: "run_failure", company_id: "company", agent_id: "agent", root_error_code: "tool_name_collision" });
|
||||
expect(first.tags).not.toHaveProperty("task_id");
|
||||
expect(first.contexts.paperclip).toEqual({ schemaVersion: 1, companyId: "company", agentId: "agent",
|
||||
runPath: `/api/heartbeat-runs/${event.runId}`, eventsPath: `/api/heartbeat-runs/${event.runId}/events`, logPath: `/api/heartbeat-runs/${event.runId}/log`,
|
||||
runUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}`, eventsUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}/events`,
|
||||
logUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}/log` });
|
||||
expect(first.fingerprint).toEqual(["process_lost", "claude-code", "setup", "tool_name_collision"]);
|
||||
expect(captureException.mock.calls[1]![1]?.fingerprint).toEqual(first.fingerprint);
|
||||
expect(captureException.mock.calls[2]![1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([undefined, "invalid", "ftp://example.com", "https://user:password@example.com", "https://example.com?token=private", "https://example.com/#private", "https://example.com/private"])("omits unsafe or absent instance URLs: %s", async value => {
|
||||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", value);
|
||||
sentryModule.captureRunFailure(baseEvent({ companyId: "company", agentId: "agent" }));
|
||||
const context = captureException.mock.calls[0]![1]!.contexts.paperclip;
|
||||
expect(context).toHaveProperty("runPath");
|
||||
expect(context).not.toHaveProperty("runUrl");
|
||||
expect(JSON.stringify(context)).not.toMatch(/password|private/);
|
||||
});
|
||||
|
||||
it("sets the fingerprint [errorCode, agentAdapter] in that order", async () => {
|
||||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||||
|
||||
@@ -195,6 +237,7 @@ describe("captureRunFailure", () => {
|
||||
sentryModule.captureRunFailure(event);
|
||||
|
||||
expect(captureException.mock.calls[0]![1]?.tags).toEqual({
|
||||
event_kind: "run_failure",
|
||||
run_id: event.runId,
|
||||
task_id: event.taskId,
|
||||
error_code: event.errorCode,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HarnessDriverBackend } from "../../../packages/paperclip-runner/src/backends/harness-driver-backend.js";
|
||||
import { FakeCodexTransport, makeDriver, result, WORKSPACE } from "../../../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test-support.js";
|
||||
import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js";
|
||||
|
||||
describe("private API output in provider recovery", () => {
|
||||
it.each([false, true])("compares active completion through the saved projection (changed=%s)", async changed => {
|
||||
const first = new FakeCodexTransport();
|
||||
const second = new FakeCodexTransport();
|
||||
const backend = new HarnessDriverBackend(makeDriver([first, second]));
|
||||
const identity = { runId: "api-recovery-run", sessionId: "api-recovery-session", companyId: "company", issueId: "issue", agentId: "agent" };
|
||||
const original = await backend.openSession({ identity, workingDirectory: WORKSPACE });
|
||||
await original.startTurn({ message: { role: "user", text: "Finish the task." } });
|
||||
const receipt = { operationId: "call_api", apiOperationId: "POST /api/companies/{companyId}/exports", callId: "export", ok: true, status: 200, data: { credential: "private-recovery-value" } };
|
||||
const completion = { ...result, summary: JSON.stringify(receipt) };
|
||||
expect(await first.invoke({ id: "finish", method: "item/tool/call", params: {
|
||||
threadId: "thread-1", turnId: "turn-1", callId: "finish", tool: "paperclip_finish", arguments: completion,
|
||||
} })).toMatchObject({ success: true });
|
||||
const raw = await original.snapshot();
|
||||
expect(raw.activeTurnId).toBe("turn-1");
|
||||
expect(raw.semanticResult).toEqual(completion);
|
||||
const checkpoint = redactRunnerApiPayloads(raw);
|
||||
expect(JSON.stringify(checkpoint)).not.toContain("private-recovery-value");
|
||||
expect(JSON.stringify(checkpoint)).toContain("apiPayloadSha256");
|
||||
await original.close({ reason: "simulate restart before the terminal checkpoint" });
|
||||
const providerResult = changed ? { ...completion, summary: JSON.stringify({ ...receipt, data: { credential: "changed-value" } }) } : completion;
|
||||
second.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE,
|
||||
turns: [{ id: "turn-1", status: "completed", items: [{ id: "finish", type: "agentMessage", text: JSON.stringify(providerResult) }] }],
|
||||
} };
|
||||
const recovered = await backend.recoverSession(checkpoint, {
|
||||
signal: new AbortController().signal, normalizeReplayPayload: redactRunnerApiPayloads,
|
||||
});
|
||||
if (changed) {
|
||||
expect(recovered).toMatchObject({ recovered: false, reason: expect.stringContaining("conflicting") });
|
||||
} else {
|
||||
expect(recovered.recovered).toBe(true);
|
||||
expect((await recovered.session!.snapshot()).semanticResult).toEqual(checkpoint.semanticResult);
|
||||
await recovered.session!.close({ reason: "verified recovery" });
|
||||
}
|
||||
expect(second.calls.some(call => call.method === "turn/start")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,12 @@ vi.mock("../app.js", () => ({
|
||||
createApp: createAppMock,
|
||||
}));
|
||||
|
||||
// This suite exercises startup coordination; source persistence and authority
|
||||
// are covered by the database-backed source worker tests.
|
||||
vi.mock("../services/skill-source-sync.js", () => ({
|
||||
skillSourceSyncService: () => ({ tick: vi.fn(async () => ({ imported: 0, checked: 0 })), stop: vi.fn(async () => {}) }),
|
||||
}));
|
||||
|
||||
vi.mock("../services/native-runtime/native-session-executor.js", () => ({
|
||||
verifyStoppedNativeSessionForReplacement: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
@@ -41,6 +41,7 @@ function fixture(options: {
|
||||
const query = {
|
||||
where(value: SQL) { condition = value; return query; },
|
||||
innerJoin() { return query; },
|
||||
leftJoin() { return query; },
|
||||
orderBy(...values: SQL[]) { order = values; return query; },
|
||||
limit(value: number) { limit = value; return query; },
|
||||
then(resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) {
|
||||
@@ -91,7 +92,7 @@ describe("heartbeat list connection recovery", () => {
|
||||
expect(test.attempts).toHaveLength(3);
|
||||
for (const attempt of test.attempts) {
|
||||
expect(attempt.params).toEqual(options.agent ? [companyId, agentId] : [companyId]);
|
||||
expect(attempt.limit).toBe(options.limit);
|
||||
expect(attempt.limit).toBe(options.limit ?? 200);
|
||||
expect(attempt.order).toEqual(['"heartbeat_runs"."created_at" desc']);
|
||||
expect("resultSummary" in attempt.fields).toBe(!options.summary && options.encoding === "UTF8");
|
||||
expect(attempt.fields).toEqual(test.attempts[0].fields);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { skillSourceSettingsSchema } from '@paperclipai/shared';
|
||||
import { listSkillSourceBranches } from '../services/skill-source-branches.js';
|
||||
|
||||
const source = { fullName: 'acme/skills', repositoryId: '42' };
|
||||
function reader(branches: unknown, defaultBranch = 'master', id = 42) {
|
||||
return vi.fn(async (request: string) => request === '/repos/acme/skills' ? { id, default_branch: defaultBranch } : branches);
|
||||
}
|
||||
describe('skill source branch discovery', () => {
|
||||
it('includes the default even when it is outside the bounded first page', async () => {
|
||||
const read = reader(Array.from({ length: 100 }, (_, index) => ({ name: `feature/${index}` })));
|
||||
const result = await listSkillSourceBranches(source, read);
|
||||
expect(result.branches).toHaveLength(101);
|
||||
expect(result.branches[0]).toEqual({ name: 'master', isDefault: true });
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(read).toHaveBeenLastCalledWith('/repos/acme/skills/branches?per_page=100&page=1', undefined);
|
||||
});
|
||||
it('deduplicates branch names and preserves case and slashes', async () => {
|
||||
expect(await listSkillSourceBranches(source, reader([{ name: 'master' }, { name: 'feature/New' }, { name: 'feature/New' }]))).toEqual({
|
||||
defaultBranch: 'master', branches: [{ name: 'master', isDefault: true }, { name: 'feature/New', isDefault: false }], truncated: false,
|
||||
});
|
||||
});
|
||||
it('stops before listing when the repository identity changed', async () => {
|
||||
const read = reader([], 'master', 99);
|
||||
await expect(listSkillSourceBranches(source, read)).rejects.toMatchObject({ status: 409 });
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([{ value: null }, { value: {} }, { value: [{ name: 42 }] }, { value: [{ name: 'bad\0branch' }] }])('rejects malformed provider lists without returning their contents', async ({ value }) => {
|
||||
await expect(listSkillSourceBranches(source, reader(value))).rejects.toMatchObject({ status: 422, message: 'GitHub returned an invalid branch list. Try again.' });
|
||||
});
|
||||
it('accepts a typed ref and the existing HEAD alias but rejects empty and control-character values', () => {
|
||||
expect(skillSourceSettingsSchema.parse({ revision: 2, trackingRef: ' feature/New ' }).trackingRef).toBe('feature/New');
|
||||
expect(skillSourceSettingsSchema.parse({ revision: 2, trackingRef: 'HEAD' }).trackingRef).toBe('HEAD');
|
||||
for (const trackingRef of ['', ' ', 'bad\0ref', 'bad\nref']) expect(skillSourceSettingsSchema.safeParse({ revision: 2, trackingRef }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,14 @@ const db = {} as Db;
|
||||
beforeEach(() => { mocks.grantIds.mockResolvedValue(['grant']); mocks.connectionIds.mockResolvedValue([]); });
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.resetAllMocks(); });
|
||||
describe('GitHub source authorization', () => {
|
||||
it('identifies a missing tracked branch instead of suggesting another connection', async () => {
|
||||
mocks.headers.mockResolvedValue({ Authorization: 'Bearer allowed' });
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('', { status: 422 }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await expect(skillSourceGitHubReader(db, 'missing-ref-company', actor({ type: 'board', userId: 'alice' }), 'connection')('/repos/acme/private/commits/main'))
|
||||
.rejects.toMatchObject({ details: { code: 'skill_source_ref_unavailable' }, message: expect.stringContaining('branch or revision') });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('uses one lease across metadata, snapshot and auditing, then permits another complete scan', async () => {
|
||||
const fixture = githubFixture({ 'SKILL.md': '---\nname: example\ndescription: Useful instructions\n---\nRead the document.' });
|
||||
const fetch = vi.fn(async (url: string) => Response.json(await fixture(new URL(url).pathname)));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { unprocessable } from '../errors.js';
|
||||
import { skillSourceRetryAt } from '../services/skill-source-retry.js';
|
||||
|
||||
describe('skill source retry scheduling', () => {
|
||||
const now = new Date('2026-10-12T12:00:00Z');
|
||||
it('backs off repeated failures and caps ordinary retries at an hour', () => {
|
||||
expect(skillSourceRetryAt(new Error('failed'), 0, now).getTime() - now.getTime()).toBe(300_000);
|
||||
expect(skillSourceRetryAt(new Error('failed'), 2, now).getTime() - now.getTime()).toBe(1_200_000);
|
||||
expect(skillSourceRetryAt(new Error('failed'), 20, now).getTime() - now.getTime()).toBe(3_600_000);
|
||||
});
|
||||
it('honors provider rate limits without accepting unbounded or invalid delays', () => {
|
||||
const retry = (delay: number) => skillSourceRetryAt(unprocessable('Limited', { retryAfterMs: delay }), 0, now).getTime() - now.getTime();
|
||||
expect(retry(7_200_000)).toBe(7_200_000);
|
||||
expect(retry(100)).toBe(300_000);
|
||||
expect(retry(Infinity)).toBe(300_000);
|
||||
expect(retry(10 ** 12)).toBe(86_400_000);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots, createDb } from '@paperclipai/db';
|
||||
import { agents, folders, companySkillSources, companies, companySkills, companySkillVersions, companySkillRepositorySnapshots, createDb } from '@paperclipai/db';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from './helpers/embedded-postgres.js';
|
||||
import { unprocessable } from '../errors.js';
|
||||
@@ -37,6 +37,162 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome;
|
||||
await testDb?.cleanup(); if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
it('explicitly repairs a missing branch while retaining connection, selection and installed versions', async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: 'Branch repair', issuePrefix: 'BRP' });
|
||||
const service = skillSourceService(db);
|
||||
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'missing-branch', commitSha: sha, selectedPaths: ['deep/one/SKILL.md'] }, context);
|
||||
const reader = githubFixture(initial);
|
||||
const read = vi.fn(reader);
|
||||
Object.assign(read, { openSnapshot: reader.openSnapshot });
|
||||
const failedRead = Object.assign(async (request: string) => {
|
||||
if (request.endsWith('/commits/missing-branch')) throw unprocessable('The tracked GitHub branch or revision is unavailable.', { code: 'skill_source_ref_unavailable' });
|
||||
return reader(request);
|
||||
}, { openSnapshot: reader.openSnapshot });
|
||||
await expect(service.settings(companyId, created.source.id, { revision: created.source.revision, connectionId: randomUUID() }, { ...context, read: () => failedRead }))
|
||||
.rejects.toMatchObject({ details: { code: 'skill_source_ref_unavailable' } });
|
||||
expect((await service.detail(companyId, created.source.id)).connectionId).toBe(created.source.connectionId);
|
||||
const repaired = await service.settings(companyId, created.source.id, { revision: created.source.revision, trackingRef: 'HEAD' }, { ...context, read: () => read });
|
||||
expect(repaired.trackingRef).toBe('HEAD');
|
||||
expect(repaired.entries).toEqual(created.source.entries);
|
||||
expect(read).toHaveBeenCalledWith('/repos/acme/skills');
|
||||
expect(read).toHaveBeenCalledWith('/repos/acme/skills/commits/main');
|
||||
const refreshed = await service.refresh(companyId, repaired.id, context);
|
||||
expect(refreshed.source.trackingRef).toBe('HEAD');
|
||||
expect(refreshed.source.entries[0]?.skillId).toBe(created.source.entries[0]?.skillId);
|
||||
});
|
||||
it('validates the selected branch and retains installed content until a successful refresh', async () => {
|
||||
const isolatedCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: isolatedCompany, name: 'Selected branch', issuePrefix: 'SBR' });
|
||||
const service = skillSourceService(db);
|
||||
const created = await service.create(isolatedCompany, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'main', commitSha: sha, selectedPaths: ['deep/one/SKILL.md'], excludedFolders: ['elsewhere'] }, context);
|
||||
const versions = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, isolatedCompany));
|
||||
const read = vi.fn(githubFixture(initial));
|
||||
const repaired = await service.settings(isolatedCompany, created.source.id, { revision: created.source.revision, trackingRef: 'feature/New' }, { ...context, read: () => read });
|
||||
expect(read).toHaveBeenCalledWith('/repos/acme/skills/commits/feature%2FNew');
|
||||
expect(repaired.trackingRef).toBe('feature/New');
|
||||
expect(repaired.entries).toEqual(created.source.entries);
|
||||
expect(repaired.excludedFolders).toEqual(['elsewhere']);
|
||||
expect(repaired.lastScanCommit).toBe(created.source.lastScanCommit);
|
||||
await expect(service.settings(isolatedCompany, created.source.id, { revision: created.source.revision, automaticUpdates: false }, context))
|
||||
.rejects.toMatchObject({ status: 409, details: { code: 'skill_source_settings_changed' } });
|
||||
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, isolatedCompany))).toEqual(versions);
|
||||
const changed = githubFixture({ 'deep/one/SKILL.md': md('new branch version') }, {}, 'f'.repeat(40));
|
||||
await service.refresh(isolatedCompany, repaired.id, { ...context, read: () => changed });
|
||||
const skillId = repaired.entries.find(entry => entry.skillId)!.skillId!;
|
||||
const current = await companySkillService(db).getById(isolatedCompany, skillId);
|
||||
expect(current!.sourceRef).toBe('f'.repeat(40));
|
||||
expect(current!.currentVersionId).not.toBe(versions[0]!.id);
|
||||
expect(await companySkillService(db).getVersion(isolatedCompany, skillId, versions[0]!.id)).not.toBeNull();
|
||||
});
|
||||
it('rejects an unavailable selected branch without changing the source or recording a successful audit', async () => {
|
||||
const isolatedCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: isolatedCompany, name: 'Unavailable branch', issuePrefix: 'UBR' });
|
||||
const service = skillSourceService(db);
|
||||
const created = await service.create(isolatedCompany, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'main', commitSha: sha, selectedPaths: ['deep/one/SKILL.md'] }, context);
|
||||
const original = githubFixture(initial), audit = vi.fn();
|
||||
const read = async (request: string) => {
|
||||
if (request.endsWith('/commits/feature%2Fmissing')) throw unprocessable('Unavailable branch.', { code: 'skill_source_ref_unavailable' });
|
||||
if (request.endsWith('/commits/..')) return { id: 42, default_branch: 'main' };
|
||||
return original(request);
|
||||
};
|
||||
for (const trackingRef of ['feature/missing', '..']) await expect(service.settings(isolatedCompany, created.source.id, { revision: created.source.revision, trackingRef }, { ...context, audit, read: () => read })).rejects.toMatchObject({ details: { code: 'skill_source_ref_unavailable' } });
|
||||
expect(await service.detail(isolatedCompany, created.source.id)).toEqual(created.source);
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
it('requires source edit authorization before reading branches and uses only the requested connection', async () => {
|
||||
const service = skillSourceService(db);
|
||||
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'branch-list', commitSha: sha, selectedPaths: [] }, context);
|
||||
const read = vi.fn(async (request: string) => request === '/repos/acme/skills' ? { id: 42, default_branch: 'main' } : [{ name: 'main' }]);
|
||||
const connection = randomUUID(), choose = vi.fn(() => read);
|
||||
const result = await service.branches(companyId, created.source.id, { connectionId: connection }, { ...context, read: choose });
|
||||
expect(result.defaultBranch).toBe('main'); expect(choose).toHaveBeenCalledWith(connection);
|
||||
read.mockClear();
|
||||
await expect(service.branches(companyId, created.source.id, {}, { ...context, read: choose, authorize: async () => { throw unprocessable('Editing denied.'); } })).rejects.toThrow('Editing denied');
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
it('checks a new commit with an unchanged tree without downloading or replacing versions', async () => {
|
||||
const originalFiles = files, originalCommit = commit;
|
||||
try {
|
||||
const service = skillSourceService(db);
|
||||
files = { 'tree/SKILL.md': md('tree') }; commit = '3'.repeat(40);
|
||||
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'tree-only', commitSha: commit, selectedPaths: ['tree/SKILL.md'] }, context);
|
||||
const skillId = created.source.entries[0]!.skillId!;
|
||||
const previous = (await companySkillService(db).getById(companyId, skillId))!.currentVersionId;
|
||||
await db.update(companySkillSources).set({ lastScanTreeSha: null }).where(eq(companySkillSources.id, created.source.id));
|
||||
const currentReader = context.read(null), currentOpen = vi.spyOn(currentReader, 'openSnapshot');
|
||||
await service.refresh(companyId, created.source.id, { ...context, read: () => currentReader }, undefined, undefined, { checkCommit: true });
|
||||
expect(currentOpen).not.toHaveBeenCalled();
|
||||
const [remembered] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, created.source.id));
|
||||
expect(remembered!.lastScanTreeSha).toMatch(/^[a-f0-9]{40}$/);
|
||||
commit = '4'.repeat(40);
|
||||
const reader = context.read(null), open = vi.spyOn(reader, 'openSnapshot');
|
||||
const result = await service.refresh(companyId, created.source.id, { ...context, read: () => reader }, undefined, undefined, { checkCommit: true });
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(result.source.lastScanCommit).toBe(commit);
|
||||
expect((await companySkillService(db).getById(companyId, skillId))!.currentVersionId).toBe(previous);
|
||||
files['tree/SKILL.md'] = md('tree changed'); commit = '5'.repeat(40);
|
||||
const changed = context.read(null), changedOpen = vi.spyOn(changed, 'openSnapshot');
|
||||
await service.refresh(companyId, created.source.id, { ...context, read: () => changed }, undefined, undefined, { checkCommit: true });
|
||||
expect(changedOpen).toHaveBeenCalledTimes(1);
|
||||
expect((await companySkillService(db).getById(companyId, skillId))!.currentVersionId).not.toBe(previous);
|
||||
} finally { files = originalFiles; commit = originalCommit; }
|
||||
});
|
||||
|
||||
it('persists automatic work limits, refills capacity, and allows explicit refresh', async () => {
|
||||
const originalFiles = files, originalCommit = commit;
|
||||
try {
|
||||
files = { 'budget/SKILL.md': md('budget') }; commit = '6'.repeat(40);
|
||||
let downloads = 0;
|
||||
const budgetContext: SkillSourceContext = { ...context, read: () => {
|
||||
const reader = context.read(null), open = reader.openSnapshot;
|
||||
reader.openSnapshot = async (...args) => { downloads++; return { ...await open(...args), downloadBytes: 128 * 1024 * 1024 }; };
|
||||
return reader;
|
||||
} };
|
||||
const service = skillSourceService(db);
|
||||
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'work-budget', commitSha: commit, selectedPaths: ['budget/SKILL.md'] }, budgetContext);
|
||||
const start = new Date();
|
||||
for (let index = 0; index < 4; index++) {
|
||||
files['budget/SKILL.md'] = md(`budget revision ${index}`); commit = (7 + index).toString(16).repeat(40);
|
||||
await service.refresh(companyId, created.source.id, budgetContext, undefined, undefined, { checkCommit: true, automatic: true, now: start });
|
||||
}
|
||||
const skillId = created.source.entries[0]!.skillId!;
|
||||
const version = (await companySkillService(db).getById(companyId, skillId))!.currentVersionId;
|
||||
files['budget/SKILL.md'] = md('budget next'); commit = 'b'.repeat(40);
|
||||
const before = downloads;
|
||||
await expect(skillSourceService(db).refresh(companyId, created.source.id, budgetContext, undefined, undefined,
|
||||
{ checkCommit: true, automatic: true, now: start })).rejects.toThrow(/temporarily limited/);
|
||||
expect(downloads).toBe(before);
|
||||
expect((await companySkillService(db).getById(companyId, skillId))!.currentVersionId).toBe(version);
|
||||
await service.refresh(companyId, created.source.id, budgetContext, undefined, undefined, { checkCommit: true });
|
||||
expect(downloads).toBe(before + 1);
|
||||
const [budgetRow] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, created.source.id));
|
||||
expect(budgetRow!.automaticBudgetAt.getTime()).toBe(start.getTime());
|
||||
expect(budgetRow!.automaticBudgetBytes).toBe(0);
|
||||
files['budget/SKILL.md'] = md('budget refilled'); commit = 'c'.repeat(40);
|
||||
await skillSourceService(db).refresh(companyId, created.source.id, budgetContext, undefined, undefined,
|
||||
{ checkCommit: true, automatic: true, now: new Date(start.getTime() + 15 * 60_000) });
|
||||
expect(downloads).toBe(before + 2);
|
||||
expect((await companySkillService(db).getById(companyId, skillId))!.currentVersionId).not.toBe(version);
|
||||
} finally { files = originalFiles; commit = originalCommit; }
|
||||
});
|
||||
|
||||
it('refunds unused automatic capacity for small repositories', async () => {
|
||||
const originalFiles = files, originalCommit = commit;
|
||||
try {
|
||||
files = { 'small/SKILL.md': md('small') }; commit = 'd'.repeat(40);
|
||||
const service = skillSourceService(db);
|
||||
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'small-budget', commitSha: commit, selectedPaths: ['small/SKILL.md'] }, context);
|
||||
const now = new Date();
|
||||
for (let index = 0; index < 6; index++) {
|
||||
files['small/SKILL.md'] = md(`small revision ${index}`); commit = String(index + 1).repeat(40);
|
||||
await service.refresh(companyId, created.source.id, context, undefined, undefined, { checkCommit: true, automatic: true, now });
|
||||
}
|
||||
const [stored] = await db.select().from(companySkillSources).where(eq(companySkillSources.id, created.source.id));
|
||||
expect(stored!.automaticBudgetBytes).toBeGreaterThan(500 * 1024 * 1024);
|
||||
} finally { files = originalFiles; commit = originalCommit; }
|
||||
});
|
||||
|
||||
it('imports duplicate names independently, stores complete snapshots and refreshes without duplicates', async () => {
|
||||
const sources = skillSourceService(db), skills = companySkillService(db);
|
||||
const result = await sources.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', commitSha: sha, selectedPaths: ['deep/one/SKILL.md', 'elsewhere/one/SKILL.md'] }, context);
|
||||
@@ -81,8 +237,9 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
expect((await promisify(execFile)(path.join(staged.source, 'scripts/run.sh'))).stdout.trim()).toBe('hello');
|
||||
} finally { fetchSpy.mockRestore(); }
|
||||
await expect(sources.refresh(companyId, result.source.id, context, { revision: 0, selectedPaths: [], excludedFolders: [] })).rejects.toThrow(/changed/);
|
||||
const disconnected = await sources.disconnect(companyId, result.source.id, context);
|
||||
expect(disconnected.enabled).toBe(false);
|
||||
const disconnected = await sources.disconnect(companyId, result.source.id, context, { revision: (await sources.detail(companyId, result.source.id)).revision, skills: 'copy' });
|
||||
expect(disconnected.copiedCount).toBe(2);
|
||||
await expect(sources.detail(companyId, result.source.id)).rejects.toThrow(/not found/);
|
||||
expect(await skills.getById(companyId, skill.id)).not.toBeNull();
|
||||
await expect(sources.detail(randomUUID(), result.source.id)).rejects.toThrow(/not found/);
|
||||
});
|
||||
@@ -92,7 +249,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
await expect(skillSourceService(db).create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'rollback', commitSha: sha, selectedPaths: ['SKILL.md'] }, { ...context, audit: async () => { throw new Error('audit unavailable'); } })).rejects.toThrow('audit unavailable');
|
||||
expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(before.length);
|
||||
expect((await skillSourceService(db).list(companyId)).some(source => source.trackingRef === 'rollback')).toBe(false);
|
||||
expect((await db.select().from(companySkillVersions)).every(version => before.some(skill => skill.id === version.companySkillId))).toBe(true);
|
||||
expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId))).every(version => before.some(skill => skill.id === version.companySkillId))).toBe(true);
|
||||
});
|
||||
it('retains excluded folders, reviews additions, restores removed paths and preserves pinned versions and copies', async () => {
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
@@ -166,7 +323,12 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
{ companyId: legacyCompany, key: 'legacy/custom', slug: 'original-slug', name: 'Original name', markdown: md('legacy'), sourceType: 'github', sourceRef: sha, metadata: { hostname: 'github.com', owner: 'acme', repo: 'skills', trackingRef: 'legacy', repoSkillDir: 'legacy' } },
|
||||
{ companyId: legacyCompany, key: 'paperclipai/paperclip/bundled', slug: 'bundled', name: 'Bundled', markdown: md('bundled'), sourceType: 'github', metadata: { hostname: 'github.com', owner: 'acme', repo: 'skills', sourceKind: 'paperclip_bundled' } },
|
||||
]).returning();
|
||||
const migration = await fs.readFile(new URL('../../../packages/db/src/migrations/0291_conscious_secret_warriors.sql', import.meta.url), 'utf8');
|
||||
// Production runs 0291 before 0341. This harness uses the latest schema,
|
||||
// whose repository/ref uniqueness is partial; retain the same backfill
|
||||
// assertions while specifying that index's inference predicate.
|
||||
const migration = (await fs.readFile(new URL('../../../packages/db/src/migrations/0291_conscious_secret_warriors.sql', import.meta.url), 'utf8'))
|
||||
.replace('ON CONFLICT (company_id, repository_url, tracking_ref) DO NOTHING',
|
||||
'ON CONFLICT (company_id, repository_url, tracking_ref) WHERE removed_at IS NULL DO NOTHING');
|
||||
await db.execute(sql.raw(migration.slice(migration.indexOf('DO $$', migration.indexOf('-- Adopt only')))));
|
||||
await db.execute(sql.raw(migration.slice(migration.indexOf('DO $$', migration.indexOf('-- Adopt only')))));
|
||||
const service = skillSourceService(db);
|
||||
@@ -186,12 +348,37 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
const provider = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Existing reference'));
|
||||
try { expect((await skills.readFile(legacyCompany, legacy!.id, 'reference.md'))?.content).toBe('Existing reference'); }
|
||||
finally { provider.mockRestore(); }
|
||||
const revision = (await service.detail(legacyCompany, adopted[0]!.id)).revision;
|
||||
await expect(service.disconnect(legacyCompany, adopted[0]!.id, context, { revision, skills: 'copy' })).rejects.toThrow(/saved files are incomplete.*Refresh the source/);
|
||||
expect((await skills.getById(legacyCompany, legacy!.id))?.sourceType).toBe('github');
|
||||
expect((await service.detail(legacyCompany, adopted[0]!.id)).revision).toBe(revision);
|
||||
await db.update(companySkills).set({ currentVersionId: null }).where(eq(companySkills.id, legacy!.id));
|
||||
await expect(service.disconnect(legacyCompany, adopted[0]!.id, context, { revision, skills: 'copy' })).rejects.toThrow(/saved files are incomplete/);
|
||||
await db.update(companySkills).set({ currentVersionId: partial!.id }).where(eq(companySkills.id, legacy!.id));
|
||||
files = { 'legacy/SKILL.md': md('updated name') };
|
||||
const refreshed = await service.refresh(legacyCompany, adopted[0]!.id, context);
|
||||
expect(refreshed.updated[0]).toMatchObject({ id: legacy!.id, key: legacy!.key, slug: legacy!.slug });
|
||||
expect((await db.select().from(companySkills).where(eq(companySkills.companyId, legacyCompany))).filter(skill => skill.key === legacy!.key)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not report a copy when an imported skill is deleted before publication', async () => {
|
||||
files = { 'SKILL.md': md('copy-race') }; commit = sha;
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'copy-race', commitSha: sha, selectedPaths: ['SKILL.md'] }, context);
|
||||
let release!: () => void, entered!: () => void;
|
||||
const started = new Promise<void>(resolve => { entered = resolve; });
|
||||
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||
const pending = service.disconnect(companyId, created.source.id, { ...context, authorize: async action => {
|
||||
if (action === 'skills.create') { entered(); await blocked; }
|
||||
} }, { revision: created.source.revision, skills: 'copy' });
|
||||
await started;
|
||||
await skills.deleteSkill(companyId, created.imported[0]!.id);
|
||||
release();
|
||||
await expect(pending).rejects.toThrow(/imported skill changed/);
|
||||
expect((await service.detail(companyId, created.source.id)).id).toBe(created.source.id);
|
||||
expect(await skills.getById(companyId, created.imported[0]!.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves legacy commit-only imports to the default branch without duplicating skills', async () => {
|
||||
const legacyCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: legacyCompany, name: 'Old imports', issuePrefix: 'OLD' });
|
||||
@@ -199,7 +386,12 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
{ companyId: legacyCompany, key: 'legacy/missing-ref', slug: 'old', name: 'Old', markdown: md('old'), sourceType: 'github', sourceRef: sha, metadata: { owner: 'acme', repo: 'skills', ref: sha, repoSkillDir: 'old' } },
|
||||
{ companyId: legacyCompany, key: 'legacy/pinned', slug: 'pinned', name: 'Pinned', markdown: md('pinned'), sourceType: 'github', sourceRef: sha, metadata: { owner: 'acme', repo: 'skills', ref: sha, trackingRef: sha, repoSkillDir: 'pinned' } },
|
||||
]).returning();
|
||||
const migration = await fs.readFile(new URL('../../../packages/db/src/migrations/0291_conscious_secret_warriors.sql', import.meta.url), 'utf8');
|
||||
// Production runs 0291 before 0341. This harness uses the latest schema,
|
||||
// whose repository/ref uniqueness is partial; retain the same backfill
|
||||
// assertions while specifying that index's inference predicate.
|
||||
const migration = (await fs.readFile(new URL('../../../packages/db/src/migrations/0291_conscious_secret_warriors.sql', import.meta.url), 'utf8'))
|
||||
.replace('ON CONFLICT (company_id, repository_url, tracking_ref) DO NOTHING',
|
||||
'ON CONFLICT (company_id, repository_url, tracking_ref) WHERE removed_at IS NULL DO NOTHING');
|
||||
await db.execute(sql.raw(migration.slice(migration.indexOf('DO $$', migration.indexOf('-- Adopt only')))));
|
||||
const service = skillSourceService(db);
|
||||
const adopted = (await service.sourceForSkill(legacyCompany, legacy!.id))!;
|
||||
@@ -213,7 +405,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
return read(request);
|
||||
}, { openSnapshot: read.openSnapshot }) });
|
||||
expect(refreshed.imported[0]).toMatchObject({ id: legacy!.id, sourceRef: commit });
|
||||
expect((await service.sourceForSkill(legacyCompany, legacy!.id))?.trackingRef).toBe('main');
|
||||
expect((await service.sourceForSkill(legacyCompany, legacy!.id))?.trackingRef).toBe('HEAD');
|
||||
expect(await service.list(legacyCompany)).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -224,7 +416,12 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
{ companyId: legacyCompany, key: 'legacy/implicit', slug: 'implicit', name: 'Implicit', markdown: md('old'), sourceType: 'github', sourceRef: sha, metadata: { owner: 'acme', repo: 'skills', ref: sha, repoSkillDir: 'one' } },
|
||||
{ companyId: legacyCompany, key: 'legacy/explicit', slug: 'explicit', name: 'Explicit', markdown: md('old'), sourceType: 'github', sourceRef: sha, metadata: { owner: 'acme', repo: 'skills', trackingRef: 'main', repoSkillDir: 'one' } },
|
||||
]).returning();
|
||||
const migration = await fs.readFile(new URL('../../../packages/db/src/migrations/0291_conscious_secret_warriors.sql', import.meta.url), 'utf8');
|
||||
// Production runs 0291 before 0341. This harness uses the latest schema,
|
||||
// whose repository/ref uniqueness is partial; retain the same backfill
|
||||
// assertions while specifying that index's inference predicate.
|
||||
const migration = (await fs.readFile(new URL('../../../packages/db/src/migrations/0291_conscious_secret_warriors.sql', import.meta.url), 'utf8'))
|
||||
.replace('ON CONFLICT (company_id, repository_url, tracking_ref) DO NOTHING',
|
||||
'ON CONFLICT (company_id, repository_url, tracking_ref) WHERE removed_at IS NULL DO NOTHING');
|
||||
await db.execute(sql.raw(migration.slice(migration.indexOf('DO $$', migration.indexOf('-- Adopt only')))));
|
||||
const service = skillSourceService(db);
|
||||
files = { 'one/SKILL.md': md('updated') }; commit = 'e'.repeat(40);
|
||||
@@ -398,6 +595,52 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
await expect(fs.stat(path.join(runtime.source, '.paperclip-repository/shared/tools'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it.each([false, true])('retains installed repository dependencies after an oversized refresh (scheduled: %s)', async scheduled => {
|
||||
const temporaryCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: temporaryCompany, name: 'Repository fallback', issuePrefix: 'FBK' });
|
||||
let repoFiles = {
|
||||
'skills/main/SKILL.md': md('main') + '\nRead [shared](../../shared/guide.md) and [helper](../helper/SKILL.md).',
|
||||
'skills/helper/SKILL.md': md('helper'),
|
||||
'shared/guide.md': 'Working shared instructions',
|
||||
};
|
||||
let head = sha;
|
||||
const localContext = { ...context, read: () => {
|
||||
const fixture = githubFixture(repoFiles, {}, head);
|
||||
const open = fixture.openSnapshot;
|
||||
fixture.openSnapshot = async (...args) => {
|
||||
const snapshot = await open(...args);
|
||||
for (const entry of snapshot.entries) if (entry.path.startsWith('generated/')) entry.size = 1024 * 1024;
|
||||
return snapshot;
|
||||
};
|
||||
return fixture;
|
||||
} };
|
||||
try {
|
||||
const service = skillSourceService(db), library = companySkillService(db);
|
||||
const installed = await service.create(temporaryCompany, { repositoryUrl: 'https://github.com/acme/skills', packageMode: 'repository',
|
||||
selectedPaths: ['skills/main/SKILL.md', 'skills/helper/SKILL.md'] }, localContext);
|
||||
const main = installed.imported.find(skill => skill.name === 'main')!;
|
||||
const versions = await library.listVersions(temporaryCompany, main.id);
|
||||
head = 'f'.repeat(40);
|
||||
repoFiles = { ...repoFiles, 'shared/guide.md': 'New shared instructions',
|
||||
...Object.fromEntries(Array.from({ length: 62 }, (_, i) => [`generated/${i}.bin`, 'x'])) };
|
||||
await expect(service.refresh(temporaryCompany, installed.source.id, localContext, undefined, undefined, { checkCommit: scheduled }))
|
||||
.rejects.toThrow('Installed repository skills and their dependencies were kept');
|
||||
const retained = await service.detail(temporaryCompany, installed.source.id);
|
||||
expect(retained.packageMode).toBe('repository');
|
||||
expect(retained.lastScanCommit).toBe(sha);
|
||||
expect(retained.lastError).toContain('dependencies were kept');
|
||||
expect(await library.listVersions(temporaryCompany, main.id)).toEqual(versions);
|
||||
expect((await library.readFile(temporaryCompany, main.id, '.paperclip-repository/shared/guide.md'))?.content).toBe('Working shared instructions');
|
||||
expect((await library.readFile(temporaryCompany, main.id, '.paperclip-repository/skills/helper/SKILL.md'))?.content).toBe(md('helper'));
|
||||
repoFiles = { 'skills/main/SKILL.md': repoFiles['skills/main/SKILL.md'], 'skills/helper/SKILL.md': md('helper'), 'shared/guide.md': 'New shared instructions' };
|
||||
await service.refresh(temporaryCompany, installed.source.id, localContext);
|
||||
expect((await service.detail(temporaryCompany, installed.source.id)).lastError).toBeNull();
|
||||
expect((await library.readFile(temporaryCompany, main.id, '.paperclip-repository/shared/guide.md'))?.content).toBe('New shared instructions');
|
||||
} finally {
|
||||
await deleteCompany(db, temporaryCompany);
|
||||
}
|
||||
});
|
||||
|
||||
it('cascades repository snapshots and referencing versions when the owning company is deleted', async () => {
|
||||
const temporaryCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: temporaryCompany, name: 'Temporary repository company', issuePrefix: 'TMP' });
|
||||
@@ -411,4 +654,110 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, temporaryCompany))).toHaveLength(0);
|
||||
});
|
||||
|
||||
async function removalFixture(mode: 'skills' | 'repository' = 'repository') {
|
||||
files = { 'one/SKILL.md': md('one') + '\nRead [reference](reference.md)', 'one/reference.md': 'Local reference',
|
||||
'two/SKILL.md': md('two'), 'shared/check.sh': '#!/bin/sh\necho preserved',
|
||||
'shared/image.png': Buffer.from([0, 255, 137, 80]) };
|
||||
commit = sha;
|
||||
return skillSourceService(db).create(companyId, { repositoryUrl: 'https://github.com/acme/skills',
|
||||
trackingRef: `remove-${randomUUID()}`, packageMode: mode, commitSha: sha, selectedPaths: ['one/SKILL.md', 'two/SKILL.md'] }, context);
|
||||
}
|
||||
|
||||
it('removes legacy disconnected sources and all linked imports, including deselected skills, and allows re-import', async () => {
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
const created = await removalFixture('skills');
|
||||
const changed = await service.refresh(companyId, created.source.id, context, {
|
||||
revision: created.source.revision, selectedPaths: ['one/SKILL.md'], excludedFolders: [] });
|
||||
await db.update(companySkillSources).set({ enabled: false }).where(eq(companySkillSources.id, created.source.id));
|
||||
const audit = vi.fn();
|
||||
const result = await service.disconnect(companyId, created.source.id, { ...context, audit }, { revision: changed.source.revision, skills: 'delete' });
|
||||
expect(result).toMatchObject({ deletedCount: 2, copiedCount: 0 });
|
||||
await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/);
|
||||
for (const skill of created.imported) expect(await skills.getById(companyId, skill.id)).toBeNull();
|
||||
expect(audit).toHaveBeenCalledWith(expect.anything(), created.source.id, 'company.skill_source_disconnected', expect.objectContaining({ deletedCount: 2 }));
|
||||
const reimported = await service.create(companyId, { repositoryUrl: created.source.repositoryUrl, trackingRef: created.source.trackingRef,
|
||||
commitSha: sha, selectedPaths: ['one/SKILL.md'] }, context);
|
||||
expect(reimported.imported).toHaveLength(1);
|
||||
expect(reimported.imported[0]!.id).not.toBe(created.imported[0]!.id);
|
||||
});
|
||||
|
||||
it('keeps complete editable personal copies and assigned identities without requiring GitHub', async () => {
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
const created = await removalFixture();
|
||||
const original = created.imported[0]!;
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: 'Copy user', role: 'engineer',
|
||||
adapterType: 'codex_local', adapterConfig: { paperclipSkillSync: { desiredSkills: [{ key: original.key }] } } }).returning();
|
||||
const result = await service.disconnect(companyId, created.source.id, { ...context, read: () => { throw new Error('GitHub unavailable'); } }, { revision: created.source.revision, skills: 'copy' });
|
||||
expect(result).toMatchObject({ copiedCount: 2, deletedCount: 0 });
|
||||
await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/);
|
||||
const copy = (await skills.detail(companyId, original.id))!;
|
||||
expect(copy).toMatchObject({ id: original.id, key: original.key, sourceType: 'local_path', sourceRef: null, editable: true });
|
||||
expect(copy.metadata?.skillSourceId).toBeUndefined();
|
||||
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/one/reference.md'))?.content).toBe('Local reference');
|
||||
const [personal] = await db.select().from(folders).where(eq(folders.id, copy.folderId!));
|
||||
expect(personal?.systemKey).toBe('my:board');
|
||||
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/shared/image.png'))?.content).toBe(Buffer.from([0,255,137,80]).toString('base64'));
|
||||
const script = await skills.readFile(companyId, original.id, '.paperclip-repository/shared/check.sh');
|
||||
expect(script).toMatchObject({ content: '#!/bin/sh\necho preserved', executable: true });
|
||||
await skills.updateFile(companyId, original.id, '.paperclip-repository/shared/check.sh', '#!/bin/sh\necho edited', context.actor);
|
||||
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/shared/check.sh'))?.content).toBe('#!/bin/sh\necho edited');
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]?.adapterConfig).toEqual(agent!.adapterConfig);
|
||||
await db.delete(agents).where(eq(agents.id, agent!.id));
|
||||
});
|
||||
|
||||
it('does not partly delete a source when an imported skill is assigned to an agent', async () => {
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
const created = await removalFixture();
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: 'Needs this skill', role: 'engineer', adapterType: 'codex_local',
|
||||
adapterConfig: { paperclipSkillSync: { desiredSkills: [{ key: created.imported[1]!.key }] } } }).returning();
|
||||
try {
|
||||
await expect(service.disconnect(companyId, created.source.id, context, { revision: created.source.revision, skills: 'delete' })).rejects.toThrow(/Needs this skill/);
|
||||
expect((await service.detail(companyId, created.source.id)).enabled).toBe(true);
|
||||
for (const skill of created.imported) expect(await skills.getById(companyId, skill.id)).not.toBeNull();
|
||||
} finally { await db.delete(agents).where(eq(agents.id, agent!.id)); }
|
||||
});
|
||||
|
||||
it('rejects stale revisions, other-company targets, and removal denied for a deselected skill', async () => {
|
||||
const service = skillSourceService(db);
|
||||
const created = await removalFixture();
|
||||
const changed = await service.refresh(companyId, created.source.id, context, { revision: created.source.revision, selectedPaths: ['one/SKILL.md'], excludedFolders: [] });
|
||||
await expect(service.disconnect(companyId, created.source.id, context, { revision: created.source.revision, skills: 'delete' })).rejects.toThrow(/changed/);
|
||||
await expect(service.disconnect(randomUUID(), created.source.id, context, { revision: changed.source.revision, skills: 'delete' })).rejects.toThrow(/not found/);
|
||||
const excluded = changed.source.entries.find(entry => entry.selection === 'excluded')!;
|
||||
await expect(service.disconnect(companyId, created.source.id, { ...context, authorize: async (action, resource) => {
|
||||
if (action === 'skills.remove' && resource.skillId === excluded.skillId) throw new Error('Protected skill');
|
||||
} }, { revision: changed.source.revision, skills: 'delete' })).rejects.toThrow('Protected skill');
|
||||
expect((await service.detail(companyId, created.source.id)).entries.filter(entry => entry.skillId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(['copy', 'delete'] as const)('rolls back source and skill changes when %s activity persistence fails', async choice => {
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
const created = await removalFixture();
|
||||
await expect(service.disconnect(companyId, created.source.id, { ...context, audit: async () => { throw new Error('Audit unavailable'); } },
|
||||
{ revision: created.source.revision, skills: choice })).rejects.toThrow('Audit unavailable');
|
||||
expect((await service.detail(companyId, created.source.id)).revision).toBe(created.source.revision);
|
||||
for (const skill of created.imported) {
|
||||
expect(await skills.getById(companyId, skill.id)).toMatchObject({ sourceType: 'github', currentVersionId: skill.currentVersionId });
|
||||
expect(await skills.readFile(companyId, skill.id, '.paperclip-repository/shared/image.png')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a source while its refresh is in flight without resurrecting imports', async () => {
|
||||
const service = skillSourceService(db), skills = companySkillService(db);
|
||||
const created = await removalFixture();
|
||||
let entered!: () => void, release!: () => void;
|
||||
const started = new Promise<void>(resolve => { entered = resolve; });
|
||||
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||
const reader = context.read(null);
|
||||
const refreshing = service.refresh(companyId, created.source.id, { ...context, read: () => Object.assign(async (request: string) => {
|
||||
entered(); await blocked; return reader(request);
|
||||
}, { openSnapshot: reader.openSnapshot }) }).then(() => null, error => error);
|
||||
await started;
|
||||
await service.disconnect(companyId, created.source.id, context, { revision: created.source.revision, skills: 'delete' });
|
||||
release();
|
||||
expect(await refreshing).toBeInstanceOf(Error);
|
||||
await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/);
|
||||
for (const skill of created.imported) expect(await skills.getById(companyId, skill.id)).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
|
||||
import type { LiveEvent } from "@paperclipai/shared";
|
||||
import { taskToastEligible } from "../realtime/task-toast-eligibility.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("personal task toast eligibility (persisted data)", () => {
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
beforeAll(async () => {
|
||||
temp = await startEmbeddedPostgresTestDatabase("paperclip-task-toasts-");
|
||||
db = createDb(temp.connectionString);
|
||||
await db.insert(companies).values({ id: companyId, name: "Toast test", issuePrefix: "TST" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Shared agent", role: "engineer", adapterType: "process" });
|
||||
}, 120_000);
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
|
||||
async function task(overrides: Partial<typeof issues.$inferInsert> = {}) {
|
||||
const id = randomUUID();
|
||||
await db.insert(issues).values({ id, companyId, title: "Task", ...overrides });
|
||||
return id;
|
||||
}
|
||||
function event(issueId: string): LiveEvent {
|
||||
return { id: 1, companyId, type: "activity.logged", createdAt: new Date().toISOString(),
|
||||
payload: { entityType: "issue", entityId: issueId, action: "issue.comment_added", actorType: "agent", actorId: agentId } };
|
||||
}
|
||||
async function onlyAlice(id: string) {
|
||||
expect(await taskToastEligible(db, event(id), "alice")).toBe(true);
|
||||
expect(await taskToastEligible(db, event(id), "bob")).toBe(false);
|
||||
}
|
||||
|
||||
it("qualifies creators and current human assignees independently", async () => {
|
||||
await onlyAlice(await task({ createdByUserId: "alice" }));
|
||||
await onlyAlice(await task({ assigneeUserId: "alice" }));
|
||||
await onlyAlice(await task({ description: "Please ask [Alice](user://alice)." }));
|
||||
});
|
||||
it("qualifies a commenter without relying on browser caches", async () => {
|
||||
const id = await task();
|
||||
await db.insert(issueComments).values({ companyId, issueId: id, authorUserId: "alice", body: "Following this." });
|
||||
await onlyAlice(id);
|
||||
});
|
||||
it("qualifies the first mention and subsequent events, excluding deleted mentions and plain names", async () => {
|
||||
const id = await task({ description: "alice user://alice" });
|
||||
expect(await taskToastEligible(db, event(id), "alice")).toBe(false);
|
||||
const [comment] = await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId,
|
||||
body: "[Alice](user://alice) please review" }).returning();
|
||||
await onlyAlice(id);
|
||||
await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, body: "A later update" });
|
||||
await onlyAlice(id);
|
||||
await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, comment.id));
|
||||
expect(await taskToastEligible(db, event(id), "alice")).toBe(false);
|
||||
});
|
||||
it("counts deliberate edits but not reads, rejected requests, or automatic cleanup", async () => {
|
||||
const id = await task();
|
||||
for (const action of ["issue.read_marked", "issue.resource_access_denied", "issue.recovery_action_resolved"]) {
|
||||
await db.insert(activityLog).values({ companyId, entityType: "issue", entityId: id, actorType: "user", actorId: "alice",
|
||||
action, details: { source: "source_revalidation", trigger: "read_projection" } });
|
||||
}
|
||||
expect(await taskToastEligible(db, event(id), "alice")).toBe(false);
|
||||
await db.insert(activityLog).values({ companyId, entityType: "issue", entityId: id, actorType: "user", actorId: "alice", action: "issue.updated" });
|
||||
await onlyAlice(id);
|
||||
});
|
||||
it("finds an exact historical mention without loading unrelated mention history", async () => {
|
||||
const id = await task();
|
||||
await db.insert(issueComments).values(Array.from({ length: 500 }, (_, index) => ({
|
||||
companyId, issueId: id, authorAgentId: agentId,
|
||||
body: `[Another viewer](user://someone-${index}) alice user://alice [Similar](user://alice-extra)`,
|
||||
})));
|
||||
expect(await taskToastEligible(db, event(id), "alice")).toBe(false);
|
||||
await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId,
|
||||
body: "[Alice](user://alice?source=review#note)" });
|
||||
await onlyAlice(id);
|
||||
});
|
||||
it("escapes regex syntax in viewer IDs and preserves parsed href forms", async () => {
|
||||
const id = await task();
|
||||
await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId,
|
||||
body: "[Different user](user://aliceXX)" });
|
||||
expect(await taskToastEligible(db, event(id), "alice.+")).toBe(false);
|
||||
await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId,
|
||||
body: "[Alice](user:///alice.+) [Bob](user://name@bob)" });
|
||||
expect(await taskToastEligible(db, event(id), "alice.+")).toBe(true);
|
||||
expect(await taskToastEligible(db, event(id), "bob")).toBe(true);
|
||||
});
|
||||
it("does not inherit involvement from responsibility, a shared agent, or a parent", async () => {
|
||||
const parentId = await task({ createdByUserId: "alice" });
|
||||
const id = await task({ parentId, createdByAgentId: agentId, assigneeAgentId: agentId, responsibleUserId: "alice" });
|
||||
await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: agentId, body: "Agent update" });
|
||||
expect(await taskToastEligible(db, event(id), "alice")).toBe(false);
|
||||
});
|
||||
it("uses the persisted run task, never an event task hint or responsible user", async () => {
|
||||
const mine = await task({ createdByUserId: "alice" });
|
||||
const other = await task({ createdByUserId: "bob" });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, issueId: other, scopeKind: "issue", responsibleUserId: "alice", status: "failed" }).returning();
|
||||
const e: LiveEvent = { ...event(mine), type: "heartbeat.run.status", payload: { runId: run.id, issueId: mine, status: "failed" } };
|
||||
expect(await taskToastEligible(db, e, "alice")).toBe(false);
|
||||
expect(await taskToastEligible(db, e, "bob")).toBe(true);
|
||||
await db.update(heartbeatRuns).set({ issueId: null, scopeKind: "company" }).where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await taskToastEligible(db, e, "bob")).toBe(false);
|
||||
});
|
||||
it("fails closed for missing identity, company mismatch, and generic agent errors; supports local-board", async () => {
|
||||
const id = await task({ createdByUserId: "local-board" });
|
||||
expect(await taskToastEligible(db, event(id), "local-board")).toBe(true);
|
||||
expect(await taskToastEligible(db, event(id), null)).toBe(false);
|
||||
expect(await taskToastEligible(db, { ...event(id), companyId: randomUUID() }, "local-board")).toBe(false);
|
||||
expect(await taskToastEligible(db, { ...event(id), type: "agent.status" }, "local-board")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { notifyTerminalWorkspaceWork, registerTerminalWorkspaceWork, TERMINAL_WORKSPACE_RETRY_MS } from "../services/terminal-workspace-work.js";
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
|
||||
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
|
||||
afterEach(async () => {
|
||||
for (const coordinator of coordinators.splice(0)) await coordinator.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function setup() {
|
||||
vi.useFakeTimers();
|
||||
const execute = vi.fn(async () => [{ xid: "42", status: "committed" }]);
|
||||
const owner = installDatabaseWorkSignals({ execute, async transaction(callback: (tx: any) => Promise<any>) {
|
||||
return callback({ execute });
|
||||
} });
|
||||
const enabled = vi.fn(() => true), primary = vi.fn(() => true), onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner,
|
||||
canRun: () => enabled() && primary(), canReconcile: primary, onError,
|
||||
});
|
||||
coordinators.push(coordinator);
|
||||
const service = {
|
||||
sweepTerminalWorkspaces: vi.fn(async (_limit?: number, _signal?: AbortSignal) => ({ checked: 0 })),
|
||||
hasTerminalWorkspaceSweepContinuation: vi.fn(() => false),
|
||||
nextTerminalWorkspaceSweepAt: vi.fn(async (): Promise<number | null> => null),
|
||||
};
|
||||
return { owner, coordinator, service, enabled, primary, onError,
|
||||
start: () => registerTerminalWorkspaceWork(coordinator, service, enabled) };
|
||||
}
|
||||
|
||||
describe("terminal workspace work", () => {
|
||||
it("recovers once at startup and makes no scans or SQL for an empty lane", async () => {
|
||||
const s = setup();
|
||||
const worker = s.start();
|
||||
await worker.ready;
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
expect(s.service.sweepTerminalWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(s.service.nextTerminalWorkspaceSweepAt).toHaveBeenCalledTimes(1);
|
||||
expect(s.coordinator.nextWakeAt()).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
expect(worker.hasUnrecoveredWork()).toBe(false);
|
||||
});
|
||||
|
||||
it("restores the cooldown deadline, lets commits preempt it, and parks after cleanup", async () => {
|
||||
const s = setup(), due = Date.now() + 86_400_000;
|
||||
s.service.nextTerminalWorkspaceSweepAt.mockResolvedValue(due);
|
||||
const worker = s.start();
|
||||
await worker.ready;
|
||||
expect(worker.hasUnrecoveredWork()).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
expect(s.service.sweepTerminalWorkspaces).toHaveBeenCalledTimes(1);
|
||||
await s.owner.transaction(tx => notifyTerminalWorkspaceWork(tx));
|
||||
await s.owner.transaction(tx => notifyTerminalWorkspaceWork(tx));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.sweepTerminalWorkspaces).toHaveBeenCalledTimes(2);
|
||||
expect(s.coordinator.nextWakeAt()).toBe(due);
|
||||
s.service.nextTerminalWorkspaceSweepAt.mockResolvedValue(null);
|
||||
await vi.advanceTimersByTimeAsync(due - Date.now());
|
||||
expect(s.service.sweepTerminalWorkspaces).toHaveBeenCalledTimes(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("finishes bounded recovery pages then retries only existing overdue work", async () => {
|
||||
const s = setup();
|
||||
s.service.hasTerminalWorkspaceSweepContinuation.mockReturnValueOnce(true);
|
||||
s.service.nextTerminalWorkspaceSweepAt.mockResolvedValue(Date.now() - 1);
|
||||
await s.start().ready;
|
||||
expect(s.coordinator.nextWakeAt()).toBe(Date.now() + 1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(s.coordinator.nextWakeAt()).toBe(Date.now() + TERMINAL_WORKSPACE_RETRY_MS);
|
||||
s.service.nextTerminalWorkspaceSweepAt.mockResolvedValue(null);
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_WORKSPACE_RETRY_MS);
|
||||
expect(s.service.sweepTerminalWorkspaces).toHaveBeenCalledTimes(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps failed recovery visible to sleep admission until a successful empty pass", async () => {
|
||||
const s = setup();
|
||||
s.service.sweepTerminalWorkspaces.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
const worker = s.start();
|
||||
await worker.ready;
|
||||
expect(s.onError).toHaveBeenCalledTimes(1);
|
||||
expect(worker.hasUnrecoveredWork()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_WORKSPACE_RETRY_MS);
|
||||
expect(worker.hasUnrecoveredWork()).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("defers scans and deadline SQL during standby and drain", async () => {
|
||||
const s = setup();
|
||||
s.primary.mockReturnValue(false);
|
||||
await s.start().ready;
|
||||
s.primary.mockReturnValue(true);
|
||||
s.enabled.mockReturnValue(false);
|
||||
await s.owner.transaction(tx => notifyTerminalWorkspaceWork(tx));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.sweepTerminalWorkspaces).not.toHaveBeenCalled();
|
||||
expect(s.service.nextTerminalWorkspaceSweepAt).not.toHaveBeenCalled();
|
||||
s.enabled.mockReturnValue(true);
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_WORKSPACE_RETRY_MS);
|
||||
expect(s.service.sweepTerminalWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("holds admission until accepted cleanup finishes and waits for it during stop", async () => {
|
||||
const s = setup();
|
||||
let release!: () => void;
|
||||
s.service.sweepTerminalWorkspaces.mockImplementationOnce(async (_limit, signal) => {
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
expect(signal?.aborted).toBe(true);
|
||||
return { checked: 1 };
|
||||
});
|
||||
const worker = s.start();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(idleWorkSnapshot().active).toBe(1);
|
||||
let stopped = false;
|
||||
const stopping = s.coordinator.stop().then(() => { stopped = true; });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(stopped).toBe(false);
|
||||
release();
|
||||
await stopping;
|
||||
await worker.ready;
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
expect(s.service.nextTerminalWorkspaceSweepAt).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -5856,7 +5856,7 @@ rl.on("line", (line) => {
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps plugin tool discovery and execution behind the same gateway policy", async () => {
|
||||
it.each([1, 2])("wraps plugin tool discovery and execution behind the same gateway policy (%i copies)", async copies => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run, project } = await createIssueAndRun(db, company.id, agent.id);
|
||||
@@ -5864,15 +5864,13 @@ rl.on("line", (line) => {
|
||||
const dispatcher: PluginToolDispatcher = {
|
||||
initialize: async () => {},
|
||||
teardown: () => {},
|
||||
listToolsForAgent: () => [
|
||||
{
|
||||
listToolsForAgent: () => Array.from({ length: copies }, () => ({
|
||||
name: "demo-plugin:read_status",
|
||||
displayName: "Read status",
|
||||
description: "Read status through a plugin tool.",
|
||||
parametersSchema: { type: "object" },
|
||||
pluginId: "demo-plugin",
|
||||
},
|
||||
],
|
||||
})),
|
||||
getTool: () => null,
|
||||
executeTool: async (tool, parameters, runContext) => {
|
||||
calls.push({ tool, parameters, runContext });
|
||||
@@ -5906,6 +5904,10 @@ rl.on("line", (line) => {
|
||||
|
||||
await allowToolsForAgent(db, company.id, agent.id, ["demo-plugin:read_status"]);
|
||||
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, projectId: project.id });
|
||||
expect((await gateway.listToolsForSession(session.token)).filter(tool => tool.pluginId === "demo-plugin"))
|
||||
.toEqual([expect.objectContaining({ name: "demo-plugin:read_status" })]);
|
||||
|
||||
await expect(gateway.listPluginToolsForAgent({ companyId: company.id, agentId: agent.id })).resolves.toEqual([
|
||||
expect.objectContaining({ name: "demo-plugin:read_status" }),
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { voiceSessionRoutes } from "../routes/voice-sessions.js";
|
||||
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -7,7 +11,7 @@ import { VOICE_RESULT_NOTIFICATION } from "@paperclipai/shared";
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { chatActions, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { chatActions, instanceUserRoles, principalPermissionGrants, environments, chatVoiceInboundCalls, chatVoicePhoneLines, chatVoiceReports, heartbeatRuns, toolProfileBindings, chatVoiceCallbacks, agentWakeupRequests, issueThreadInteractions, issueQuestionResponseDeliveries, issueComments, agents, chatConversations, chatDeliveries, chatEndpoints, chatPublications, chatVoiceReplies, chatVoiceSessions, chatVoiceToolCalls, companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, issues, toolApplications, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { configureSpekoSessionTools } from "../services/voice/speko-tool-setup.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { chatChannelService } from "../services/chat-channels.js";
|
||||
@@ -522,6 +526,34 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
return { ...f, service, transport, credentials, input };
|
||||
}
|
||||
|
||||
it("loads history and starts calls for Cloud owners without local admin roles, retaining membership revocation", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||
const f = await nativeServiceFixture();
|
||||
await db.update(companyMemberships).set({ membershipRole: "owner" }).where(eq(companyMemberships.principalId, f.callerId));
|
||||
expect(await db.select().from(instanceUserRoles).where(eq(instanceUserRoles.userId, f.callerId))).toEqual([]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", source: "cloud_tenant", userId: f.callerId, isInstanceAdmin: true, companyIds: [f.companyId] };
|
||||
next();
|
||||
});
|
||||
app.use("/api", voiceSessionRoutes(db, f.service));
|
||||
app.use(errorHandler);
|
||||
const historyPath = `/api/companies/${f.companyId}/voice-history/${f.endpointId}`;
|
||||
const history = await request(app).get(historyPath).expect(200);
|
||||
expect(history.body).toHaveLength(1);
|
||||
const started = await request(app).post(`/api/companies/${f.companyId}/voice-sessions`)
|
||||
.send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(201);
|
||||
const [persisted] = await db.select().from(chatVoiceSessions).where(eq(chatVoiceSessions.id, started.body.session.id));
|
||||
expect(persisted).toMatchObject({ callerId: f.callerId, callerAuthority: "member", companyId: f.companyId });
|
||||
expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1);
|
||||
await db.update(companyMemberships).set({ status: "removed" }).where(eq(companyMemberships.principalId, f.callerId));
|
||||
await request(app).get(historyPath).expect(403);
|
||||
await request(app).post(`/api/companies/${f.companyId}/voice-sessions`)
|
||||
.send({ endpointId: f.endpointId, issueId: f.issueId, idempotencyKey: randomUUID() }).expect(403);
|
||||
expect(f.transport.createBrowserSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
async function pushFixture() {
|
||||
await instanceSettingsService(db).updateExperimental({enableChatConnectors: true});
|
||||
const f = await nativeServiceFixture();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createWorkspaceGitInspectionCache } from "../services/workspace-git-ins
|
||||
const workspace = { id: "workspace", companyId: "company", cwd: "/repo", baseRef: "master" } as ExecutionWorkspace;
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("coalesces concurrent display reads and expires after five seconds", async () => {
|
||||
it("coalesces concurrent display reads and expires after thirty seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const inspect = vi.fn(async () => ({ dirty: false }));
|
||||
const read = createWorkspaceGitInspectionCache(inspect);
|
||||
@@ -13,7 +13,10 @@ it("coalesces concurrent display reads and expires after five seconds", async ()
|
||||
expect(inspect).toHaveBeenCalledTimes(1);
|
||||
await read(workspace);
|
||||
expect(inspect).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
vi.advanceTimersByTime(29_999);
|
||||
await read(workspace);
|
||||
expect(inspect).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
await read(workspace);
|
||||
expect(inspect).toHaveBeenCalledTimes(2);
|
||||
// Callers that authorize cleanup retain the uncached inspector.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog, agents, agentWakeupRequests, companies, createDb,
|
||||
environmentLeases, heartbeatRuns, issueRecoveryActions, issues,
|
||||
environmentLeases, heartbeatRuns, issueRecoveryActions, issues, chatConversations, chatEndpoints, toolApplications, toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
@@ -93,6 +93,23 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
return { app, wake };
|
||||
}
|
||||
|
||||
it.each(["active", "unavailable", "endpoint_removed"])("keeps original file recovery instructions for a %s chat", async state => {
|
||||
const f = await seed();
|
||||
const endpointId = randomUUID();
|
||||
const applicationId = randomUUID(), connectionId = randomUUID();
|
||||
await db.insert(toolApplications).values({ id: applicationId, companyId: f.companyId, name: "Chat recovery", type: "chat" });
|
||||
await db.insert(toolConnections).values({ id: connectionId, companyId: f.companyId, applicationId,
|
||||
name: "Chat recovery", uid: randomUUID(), transport: "chat_sdk", connectionPurpose: "channel", status: "active", enabled: true });
|
||||
await db.insert(chatEndpoints).values({ id: endpointId, companyId: f.companyId, connectionId,
|
||||
provider: "slack", publicId: randomUUID(), assignedAgentId: f.currentAgentId, status: "active" });
|
||||
await db.insert(chatConversations).values({ companyId: f.companyId, endpointId, issueId: f.issueId,
|
||||
externalConversationId: randomUUID(), externalLabel: "Recovery conversation", state });
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({
|
||||
recoveryActionId: f.actionId, workspaceRepairRequired: true, canRecordWorkspaceRepair: true,
|
||||
nextAction: "Repair the retained workspace before continuing.", canRetry: false, canContinue: false,
|
||||
});
|
||||
});
|
||||
|
||||
async function assertSourceUnchanged(f: Awaited<ReturnType<typeof seed>>) {
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.issueBefore);
|
||||
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.leaseId)))[0]).toEqual(f.leaseBefore);
|
||||
@@ -100,8 +117,12 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect(await db.select().from(heartbeatRuns).where(inArray(heartbeatRuns.id, [f.sourceRunId, f.currentRunId]))).toHaveLength(2);
|
||||
}
|
||||
|
||||
it.each(["reassigned", "conversation_reset", "unassigned"])("records board repair after %s without restarting or changing the current task", async scenario => {
|
||||
it.each(["reassigned", "conversation_reset", "unassigned", "successful_source"])("records board repair after %s without restarting or changing the current task", async scenario => {
|
||||
const f = await seed({ conversationReset: scenario === "conversation_reset", noCurrentOwner: scenario === "unassigned" });
|
||||
if (scenario === "successful_source") {
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
}
|
||||
const [sourceBefore] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const { app, wake } = appFor();
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
|
||||
await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(200);
|
||||
@@ -120,6 +141,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId))).toEqual(auditBefore);
|
||||
await assertSourceUnchanged(f);
|
||||
expect(wake).not.toHaveBeenCalled();
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)))[0]).toEqual(sourceBefore);
|
||||
});
|
||||
|
||||
it.each(["blocked", "done", "cancelled", "backlog"])("records repair without changing the current %s task", async status => {
|
||||
|
||||
@@ -120,6 +120,44 @@ describe("managed runtime start terminality", () => {
|
||||
expect(probes).toBeLessThan(12);
|
||||
});
|
||||
|
||||
it("preserves the last HTTP failure when the deadline-clamped probe aborts", async () => {
|
||||
// The readiness budget (1s) is smaller than the per-probe timeout, so every probe is bounded
|
||||
// by the deadline. The first probe observes the real failure (503); the second is admitted
|
||||
// with 1ms left and aborts immediately. That abort says the budget ran out, not that the
|
||||
// service misbehaved, so it must not overwrite the 503 in the thrown message.
|
||||
let nowMs = 0;
|
||||
let probes = 0;
|
||||
const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
probes += 1;
|
||||
if (probes === 1) {
|
||||
nowMs = 999;
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
const rejectOnAbort = () => {
|
||||
nowMs = 1_000;
|
||||
reject(new Error("The operation was aborted"));
|
||||
};
|
||||
if (init?.signal?.aborted) {
|
||||
rejectOnAbort();
|
||||
return;
|
||||
}
|
||||
init?.signal?.addEventListener("abort", rejectOnAbort, { once: true });
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
waitForRuntimeServiceReadiness({
|
||||
service: { readiness: { type: "http", timeoutSec: 1, intervalMs: 100 } },
|
||||
url: "http://127.0.0.1:45439/",
|
||||
readinessUrl: null,
|
||||
fetchImpl,
|
||||
now: () => nowMs,
|
||||
}),
|
||||
).rejects.toThrow("Readiness check failed for http://127.0.0.1:45439/: received HTTP 503");
|
||||
expect(probes).toBe(2);
|
||||
});
|
||||
|
||||
it("hands two concurrent starts distinct ports even when the kernel repeats a candidate", async () => {
|
||||
// The kernel really does hand the same ephemeral port to two `listen(0)` probes once the
|
||||
// first probe socket has closed, which is exactly the reallocation collision in the report.
|
||||
|
||||
@@ -3921,6 +3921,7 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
companyId: "company-1",
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
@@ -3997,6 +3998,7 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
companyId: "company-1",
|
||||
id: "execution-workspace-reused-cleanup",
|
||||
cwd: reused.cwd,
|
||||
providerType: "git_worktree",
|
||||
@@ -4065,6 +4067,7 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
companyId: "company-1",
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
@@ -4129,6 +4132,7 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod
|
||||
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
companyId: "company-1",
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
@@ -4205,6 +4209,7 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod
|
||||
|
||||
await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
companyId: "company-1",
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
@@ -10011,6 +10016,7 @@ describe("realizeExecutionWorkspace with an exact existing branch", () => {
|
||||
) {
|
||||
return {
|
||||
workspace: {
|
||||
companyId: "company-1",
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
|
||||
+26
-7
@@ -19,8 +19,10 @@ import { registerBrowserUseCleanup } from "./services/browser-use-work.js";
|
||||
import { browserUseService } from "./services/browser-use.js";
|
||||
import { slackToolRoutes } from "./routes/slack-tools.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js";
|
||||
import { createDotRunnerMcpTools } from "./services/dot-runner-broker.js";
|
||||
import { createDotRunnerMcpTools, dotRunnerBroker } from "./services/dot-runner-broker.js";
|
||||
import { agentProfileAvatarRoutes } from "./routes/agent-profile-avatar.js";
|
||||
import { grokBotRoutes } from "./routes/grok-bot.js";
|
||||
import { createGrokRoutineDispatcher } from "./services/grok-bot/dispatcher.js";
|
||||
import { dotRunnerRoutes } from "./routes/dot-runner.js";
|
||||
import { createPublicMcpTransfers } from "./services/public-mcp/file-transfers.js";
|
||||
import { createMcpApiDispatch, createPublicMcpExecutor } from "./services/public-mcp/capabilities.js";
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
import type { InspectDatabaseBackupHealthOptions } from "./services/database-backup-health.js";
|
||||
import type { StorageService } from "./storage/types.js";
|
||||
import { httpLogger, errorHandler } from "./middleware/index.js";
|
||||
import { skillAccessRequestContext } from "./services/skill-access.js";
|
||||
import { actorMiddleware } from "./middleware/auth.js";
|
||||
import { boardMutationGuard } from "./middleware/board-mutation-guard.js";
|
||||
import {
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
} from "./services/company-import-transfers.js";
|
||||
import { companyTransferRunService } from "./services/company-transfer-runs.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { livenessRoutes } from "./routes/liveness.js";
|
||||
import { cloudRuntimeIdentityMiddleware } from "./middleware/cloud-runtime-identity.js";
|
||||
import { cloudControlMiddleware } from "./middleware/cloud-control.js";
|
||||
import { cloudRoutes } from "./routes/cloud.js";
|
||||
@@ -191,6 +195,7 @@ import {
|
||||
import { subscribeAllCompanyLiveEvents } from "./services/live-events.js";
|
||||
import { heartbeatService } from "./services/heartbeat.js";
|
||||
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
||||
import { registerPluginLifecycleDelivery } from "./services/plugin-lifecycle-delivery.js";
|
||||
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
||||
import {
|
||||
buildHostServices,
|
||||
@@ -582,7 +587,7 @@ export async function createApp(
|
||||
);
|
||||
const jsonBodyParser = express.json({ limit: DEFAULT_JSON_BODY_LIMIT, verify: captureRawBody });
|
||||
// File tickets authenticate before parsing bytes; JSON attachments must remain bytes too.
|
||||
app.use((req, res, next) => req.path === "/mcp/files/upload" ? next() : jsonBodyParser(req, res, next));
|
||||
app.use((req, res, next) => ["/mcp/files/upload", "/mcp/runner/files/upload"].includes(req.path) ? next() : jsonBodyParser(req, res, next));
|
||||
app.use("/api", apiCompression());
|
||||
app.use(httpLogger);
|
||||
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
|
||||
@@ -607,6 +612,9 @@ export async function createApp(
|
||||
const publicMcpIngress = Router();
|
||||
|
||||
app.use(cloudRuntimeIdentityMiddleware(db));
|
||||
// Routine probes stop before session/actor resolution, even after claim.
|
||||
// Keep hostname validation and the signed-bootstrap endpoint guard above.
|
||||
app.use("/api/liveness", livenessRoutes());
|
||||
// A signed claim above commits identity before any normal request can seed
|
||||
// company data. Unclaimed probes bypass session resolution as well as SQL.
|
||||
app.use(cloudWarmStandbyMiddleware(isWarmStandby, health, staticUi));
|
||||
@@ -651,6 +659,7 @@ export async function createApp(
|
||||
// REPLACES whatever actor the request otherwise resolved to, and only on
|
||||
// the one endpoint it authorizes (see the middleware for the contract).
|
||||
app.use(cloudControlMiddleware());
|
||||
app.use(skillAccessRequestContext);
|
||||
app.use("/api/auth", authRoutes(db));
|
||||
if (opts.betterAuthHandler) {
|
||||
app.all("/api/auth/{*authPath}", opts.betterAuthHandler);
|
||||
@@ -1054,9 +1063,10 @@ export async function createApp(
|
||||
}),
|
||||
);
|
||||
let publicMcpEvents: PublicMcpEvents | null = null;
|
||||
const grokDispatcher = createGrokRoutineDispatcher(db, () => !isWarmStandby() && !isIdleTaskDrainActive());
|
||||
let dotMcpEvents: PublicMcpEvents | null = null;
|
||||
if (publicMcpOAuth) {
|
||||
const dispatch = createMcpApiDispatch(api);
|
||||
const dispatch = createMcpApiDispatch(api, app.locals);
|
||||
publicMcpEvents = createPublicMcpEvents(db, publicMcpOAuth, dispatch, {
|
||||
isBackgroundWorkEnabled: () => !isWarmStandby() && !isIdleTaskDrainActive(),
|
||||
});
|
||||
@@ -1067,9 +1077,14 @@ export async function createApp(
|
||||
const dotOAuth = createPublicMcpOAuth(db, { ...publicMcpOAuth.config, resource: publicMcpOAuth.config.origin + "/mcp/runner" });
|
||||
dotMcpEvents = createPublicMcpEvents(db, dotOAuth, dispatch, { enableDotRunner: true, isBackgroundWorkEnabled: () => !isWarmStandby() && !isIdleTaskDrainActive() });
|
||||
dotMcpEvents.start();
|
||||
publicMcpIngress.use(publicMcpIngressRoutes(dotOAuth, createPublicMcpExecutor(db, dotOAuth, dispatch), dotMcpEvents, createDotRunnerMcpTools(db, connectionIntentHeartbeat)));
|
||||
const grokTransfers = createPublicMcpTransfers(db, dotOAuth, dispatch, opts.storageService, { pathPrefix: "/mcp/runner/files",
|
||||
agentSession: { authorize: (principal, sessionId, taskId, write, database) => dotRunnerBroker(database ?? db).authorizeFileSession(principal, sessionId, taskId, write) } });
|
||||
publicMcpIngress.use(grokTransfers.router);
|
||||
publicMcpIngress.use(publicMcpIngressRoutes(dotOAuth, createPublicMcpExecutor(db, dotOAuth, dispatch), dotMcpEvents, createDotRunnerMcpTools(db, connectionIntentHeartbeat, grokTransfers)));
|
||||
api.use(publicMcpManagementRoutes(publicMcpOAuth, dotOAuth));
|
||||
api.use(dotRunnerRoutes(db, publicMcpOAuth.config.origin + "/mcp/runner"));
|
||||
api.use(grokBotRoutes(db, publicMcpOAuth.config.origin + "/mcp/runner"));
|
||||
grokDispatcher.start();
|
||||
}
|
||||
|
||||
app.use("/api", api);
|
||||
@@ -1249,6 +1264,7 @@ export async function createApp(
|
||||
onError: (err, queue) => logger.error({ err, queue }, "Delivery reconciliation failed"),
|
||||
});
|
||||
app.locals.deliveryWork = deliveryWork;
|
||||
const resourceLifecycleDelivery = registerPluginLifecycleDelivery(deliveryWork, db, workerManager, lifecycle);
|
||||
if (opts.feedbackExportService) {
|
||||
deliveryWork.register(DELIVERY_QUEUES.feedback, {
|
||||
retryMs: FEEDBACK_EXPORT_FLUSH_INTERVAL_MS,
|
||||
@@ -1267,7 +1283,9 @@ export async function createApp(
|
||||
await chatChannels.reconcileProviderRuntimes();
|
||||
await chatChannels.voice.reconcile();
|
||||
},
|
||||
processPendingDeliveries: () => chatChannels.processPendingChatMaintenance(),
|
||||
// Chat delivery, action, and Telegram discovery workers now own these
|
||||
// duties. Keep the coordinator's other maintenance lanes independent.
|
||||
processPendingDeliveries: async () => undefined,
|
||||
processFailedGitHubWebhookDeliveries: () =>
|
||||
chatChannels.processFailedGitHubWebhookDeliveries(),
|
||||
projectRunMilestones: () =>
|
||||
@@ -1405,7 +1423,7 @@ export async function createApp(
|
||||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
});
|
||||
app.locals.bundledPluginsStartup = trackIdleWork(bundledPluginsStartup);
|
||||
void bundledPluginsStartup.then(() => { lifecyclePluginsReady = true; return agentLifecycle.sweep(); }).catch(() => logger.warn("Agent lifecycle recovery failed; retrying."));
|
||||
void bundledPluginsStartup.then(() => { lifecyclePluginsReady = true; resourceLifecycleDelivery.wake(); return agentLifecycle.sweep(); }).catch(() => logger.warn("Agent lifecycle recovery failed; retrying."));
|
||||
// The shutdown hook runs at most once. It caches the in-flight promise, so a
|
||||
// second caller (for example the `exit` handler) awaits the same completion
|
||||
// instead of starting a second teardown.
|
||||
@@ -1418,8 +1436,9 @@ export async function createApp(
|
||||
scheduler.stop();
|
||||
await agentLifecycle.stop();
|
||||
await publicMcpEvents?.stop();
|
||||
await dotMcpEvents?.stop();
|
||||
await dotMcpEvents?.stop(); grokDispatcher.stop();
|
||||
jobCoordinator.stop();
|
||||
resourceLifecycleDelivery.dispose();
|
||||
await deliveryWork.stop();
|
||||
unsubscribeChatPublicationSignals();
|
||||
chatReconciliation.stop();
|
||||
|
||||
@@ -6,14 +6,27 @@ const transientDbConnectionCodes = new Set([
|
||||
"CONNECTION_DESTROYED",
|
||||
]);
|
||||
|
||||
export function isTransientDbConnectionError(error: unknown): boolean {
|
||||
function hasDbConnectionErrorCode(error: unknown, matches: (code: string) => boolean): boolean {
|
||||
for (let current: unknown = error; current instanceof Error; current = current.cause) {
|
||||
const code = (current as { code?: unknown }).code;
|
||||
if (typeof code === "string" && transientDbConnectionCodes.has(code)) return true;
|
||||
if (typeof code === "string" && matches(code)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isTransientDbConnectionError(error: unknown): boolean {
|
||||
return hasDbConnectionErrorCode(error, (code) => transientDbConnectionCodes.has(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* postgres.js rejects every query issued after this process called
|
||||
* `sql.end()` with `CONNECTION_ENDED`. Unlike a dropped socket, it never comes
|
||||
* from the database or the network: the pool was ended locally.
|
||||
*/
|
||||
export function isEndedDbPoolError(error: unknown): boolean {
|
||||
return hasDbConnectionErrorCode(error, (code) => code === "CONNECTION_ENDED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry only an explicitly idempotent operation, such as a known read-only
|
||||
* lookup or receipt-protected synchronization. Neither a SQL prefix nor a
|
||||
|
||||
@@ -49,8 +49,6 @@ async function main(): Promise<void> {
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
// New Codex agents can use runnerd immediately, including on instances
|
||||
// that have disabled the experimental providers.
|
||||
nativeRunnerRequired: localPaperclipRunnerSupported()
|
||||
|| experimental.enableNativeRunner === true
|
||||
|| experimental.enableOpenAiDot === true
|
||||
|
||||
+40
-32
@@ -1,8 +1,10 @@
|
||||
import { skillSourceSyncService } from "./services/skill-source-sync.js";
|
||||
import { subscribeAllCompanyLiveEvents } from "./services/live-events.js";
|
||||
import { DELIVERY_QUEUES } from "./services/delivery-work-notifications.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./services/delivery-work-coordinator.js";
|
||||
import { registerTerminalWorkspaceWork, TERMINAL_WORKSPACE_RETRY_MS } from "./services/terminal-workspace-work.js";
|
||||
import { isIdleTaskDrainActive, beginIdleTrackedWork, trackIdleWork } from "./services/task-admission.js";
|
||||
import { markIdleStartupComplete } from "./services/idle-local-work.js";
|
||||
import { markIdleStartupComplete, registerIdleLocalWorkProbe } from "./services/idle-local-work.js";
|
||||
import { clearIdleBackupWakeRequired, idleBackupWakeMarker, markIdleBackupWakeRequired, verifyIdleDatabaseBackup } from "./services/idle-database-backup.js";
|
||||
import { cloudWarmStandbyServerOptions } from "./middleware/cloud-warm-standby.js";
|
||||
import { createCloudWarmStandby } from "./services/cloud-warm-standby.js";
|
||||
@@ -1212,11 +1214,22 @@ async function startServerWithDatabaseTeardown(
|
||||
}, config.heartbeatSchedulerIntervalMs);
|
||||
heartbeatSchedulerInterval?.unref?.();
|
||||
};
|
||||
const skillSourceSync = skillSourceSyncService(db as any);
|
||||
const sweepSkillSources = () => {
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped || isWarmStandby()) return;
|
||||
trackHeartbeatSchedulerWork(skillSourceSync.tick().catch(err => {
|
||||
logger.error({ err }, "skill source synchronization failed");
|
||||
}));
|
||||
};
|
||||
const skillSourceSyncInterval = setInterval(sweepSkillSources, 5000);
|
||||
skillSourceSyncInterval.unref?.();
|
||||
sweepSkillSources();
|
||||
const externalObjects = externalObjectService(db as any, {
|
||||
pluginWorkerManager,
|
||||
enabled: async () => (await instanceSettingsService(db).getExperimental()).enableExternalObjects === true,
|
||||
});
|
||||
const scheduleExternalObjectRefreshSweep = (now = new Date()) => {
|
||||
sweepSkillSources();
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork(externalObjects
|
||||
.refreshDueObjectsForActiveCompanies(50, now)
|
||||
@@ -1260,6 +1273,7 @@ async function startServerWithDatabaseTeardown(
|
||||
resolveNativeQuestion: (interaction) => deliverNativeQuestionResponse(db as any, interaction),
|
||||
});
|
||||
const deliveryWork = app.locals.deliveryWork as ReturnType<typeof createDeliveryWorkCoordinator>;
|
||||
let unregisterTerminalWorkspaceProbe: (() => void) | undefined;
|
||||
const fastResponses = app.locals.fastResponses as ReturnType<typeof import("./services/fast-responses.js").fastResponseService>;
|
||||
const fastResponseWorker = deliveryWork.register(DELIVERY_QUEUES.fastResponse, {
|
||||
retryMs: 100, run: (signal) => fastResponses.sweepPending(signal), hasPending: () => fastResponses.hasPending(),
|
||||
@@ -1360,6 +1374,7 @@ async function startServerWithDatabaseTeardown(
|
||||
});
|
||||
const terminalWorkspaces = executionWorkspaceService(db as any, {
|
||||
workspaceReaperCooldownDays: config.workspaceReaperCooldownDays,
|
||||
terminalWorkspaceInspectionBackoffMs: TERMINAL_WORKSPACE_RETRY_MS,
|
||||
});
|
||||
const scheduleMergedPullRequestConfirmationSweep = () => {
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
@@ -1374,36 +1389,22 @@ async function startServerWithDatabaseTeardown(
|
||||
logger.error({ err }, "merged pull-request confirmation sweep failed");
|
||||
}));
|
||||
};
|
||||
// Emit a periodic signal when the reaper inspects candidates but archives
|
||||
// none, so an inert reaper that skips every candidate is never fully silent.
|
||||
// The throttle keeps the 30s cadence from flooding the log.
|
||||
let lastTerminalWorkspaceSkipLogAt = 0;
|
||||
const terminalWorkspaceSkipLogIntervalMs = 10 * 60 * 1000;
|
||||
const scheduleTerminalWorkspaceSweep = () => {
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork(terminalWorkspaces
|
||||
.sweepTerminalWorkspaces()
|
||||
.then((result) => {
|
||||
if (result.archived > 0 || result.cleanupFailed > 0) {
|
||||
logger.info(result, "terminal issue workspace reaper changed workspace state");
|
||||
return;
|
||||
}
|
||||
const skipped =
|
||||
result.skippedActiveRun
|
||||
+ result.skippedNonTerminalTree
|
||||
+ result.skippedUndelivered
|
||||
+ result.skippedRace
|
||||
+ result.skippedCooldown;
|
||||
const nowMs = Date.now();
|
||||
if (skipped > 0 && nowMs - lastTerminalWorkspaceSkipLogAt >= terminalWorkspaceSkipLogIntervalMs) {
|
||||
lastTerminalWorkspaceSkipLogAt = nowMs;
|
||||
logger.info(result, "terminal issue workspace reaper skipped all candidates");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "terminal issue workspace reaper failed");
|
||||
}));
|
||||
};
|
||||
const terminalWorkspaceWorker = registerTerminalWorkspaceWork(deliveryWork, {
|
||||
...terminalWorkspaces,
|
||||
sweepTerminalWorkspaces: async (limit, signal) => {
|
||||
const result = await terminalWorkspaces.sweepTerminalWorkspaces(limit, signal);
|
||||
if (result.archived > 0 || result.cleanupFailed > 0) {
|
||||
logger.info(result, "terminal issue workspace reaper changed workspace state");
|
||||
} else if (result.checked > 0 && Date.now() - lastTerminalWorkspaceSkipLogAt >= 10 * 60 * 1000) {
|
||||
lastTerminalWorkspaceSkipLogAt = Date.now();
|
||||
logger.info(result, "terminal issue workspace reaper skipped all candidates");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}, () => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped);
|
||||
unregisterTerminalWorkspaceProbe = registerIdleLocalWorkProbe(() => terminalWorkspaceWorker.hasUnrecoveredWork() ? "present" : "none");
|
||||
await terminalWorkspaceWorker.ready;
|
||||
|
||||
// The restart-safe cleanup backstop for adapter login sessions. The
|
||||
// in-process five-minute timer stays the primary control. This reaper runs
|
||||
@@ -1711,7 +1712,6 @@ async function startServerWithDatabaseTeardown(
|
||||
scheduleMergedPullRequestConfirmationSweep();
|
||||
scheduleGitHubConnectionEventPoll();
|
||||
scheduleGitHubConnectionContinuitySweep();
|
||||
scheduleTerminalWorkspaceSweep();
|
||||
scheduleAdapterLoginReaperSweep();
|
||||
scheduleSetupTokenReaperSweep();
|
||||
scheduleEnvironmentLeaseCleanupSweep();
|
||||
@@ -1968,8 +1968,11 @@ async function startServerWithDatabaseTeardown(
|
||||
) => {
|
||||
await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]);
|
||||
heartbeatSchedulerStopped = true;
|
||||
clearInterval(skillSourceSyncInterval);
|
||||
await skillSourceSync.stop();
|
||||
unsubscribeChatCompletions();
|
||||
await deliveryWork.stop();
|
||||
unregisterTerminalWorkspaceProbe?.();
|
||||
clearInterval(executionControlInterval);
|
||||
if (heartbeatSchedulerInterval) {
|
||||
clearInterval(heartbeatSchedulerInterval);
|
||||
@@ -2048,7 +2051,12 @@ async function startServerWithDatabaseTeardown(
|
||||
closeHttpListener: () =>
|
||||
closeHttpListenerForShutdown({ server, signal, log: logger }),
|
||||
drainPendingRunFailureReports: waitForPendingRunFailureReports,
|
||||
closeDatabase: closeDatabaseClients,
|
||||
// Mark the pool as ending before `sql.end()` rejects the first query,
|
||||
// so the error handler recognizes a request that outlived the drain.
|
||||
closeDatabase: () => {
|
||||
(app.locals as { paperclipDatabaseClosing?: boolean }).paperclipDatabaseClosing = true;
|
||||
return closeDatabaseClients();
|
||||
},
|
||||
stopEmbeddedPostgres,
|
||||
shutdownInstrumentation,
|
||||
shutdownSentry,
|
||||
|
||||
@@ -87,7 +87,6 @@ export type AgentConfigurationPatch = Partial<Pick<typeof agents.$inferInsert, t
|
||||
export type AgentHireRecord = Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType" | "adapterConfig" | "permissions" | "metadata" | "status">;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
/** Reviewed/historical execution already resolved by a governed internal caller. */
|
||||
runnerResolved?: boolean;
|
||||
responsibleUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
|
||||
|
||||
/** Inspect only libc identity; never retain or log Node's diagnostic report. */
|
||||
export function localAgentRunnerTarget(): { driver: "local"; platform: NodeJS.Platform; architecture: string; libc?: "glibc" | "unknown"; glibcVersion?: string } {
|
||||
if (process.platform !== "linux") return { driver: "local", platform: process.platform, architecture: process.arch };
|
||||
let libc: "glibc" | "unknown" = "unknown";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./redact-sensitive.js";
|
||||
import { recordResponsibleUserDenialOnActiveRun } from "../services/responsible-user-denial-run-outcomes.js";
|
||||
import { isExpectedMcpConnectionFailure } from "../services/mcp-connection-failure.js";
|
||||
import { isEndedDbPoolError } from "../database-retry.js";
|
||||
|
||||
export interface ErrorContext {
|
||||
error: {
|
||||
@@ -93,6 +94,12 @@ function reportCrash(error: Error, reportToSentry = true): void {
|
||||
if (reportToSentry) captureException(error);
|
||||
}
|
||||
|
||||
/** Set by the orderly shutdown just before it ends the database pools. */
|
||||
function isDatabaseClosing(req: Request): boolean {
|
||||
const locals = req.app?.locals as { paperclipDatabaseClosing?: boolean } | undefined;
|
||||
return locals?.paperclipDatabaseClosing === true;
|
||||
}
|
||||
|
||||
function getPaperclipDb(req: Request): Db | null {
|
||||
const locals = req.app?.locals as { paperclipDb?: Db; db?: Db } | undefined;
|
||||
return locals?.paperclipDb ?? locals?.db ?? null;
|
||||
@@ -266,6 +273,21 @@ export function errorHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
// Shutdown drains the HTTP listener before it ends the pool, but a handler
|
||||
// whose client already disconnected (or whose socket the drain timeout
|
||||
// closed) keeps running. Its next query then fails with CONNECTION_ENDED
|
||||
// against the pool this process deliberately ended. That is shutdown, not a
|
||||
// server fault, so skip the error sinks. Any other error during shutdown is
|
||||
// still reported.
|
||||
if (isDatabaseClosing(req) && isEndedDbPoolError(rootError)) {
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({ error: "Server is shutting down" });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reportableError = sanitizeSecretSensitiveError(req, rootError);
|
||||
attachErrorContext(
|
||||
req,
|
||||
|
||||
@@ -25,6 +25,12 @@ function isControlRequest(req: Request) {
|
||||
|
||||
/** Install before body parsers, auth, webhooks and tool ingress. */
|
||||
export const idleAdmissionMiddleware: RequestHandler = (req, res, next) => {
|
||||
// This exact route is synchronous and has no auth, SQL, or accepted work.
|
||||
// Probing it must not prevent an otherwise quiescent instance from draining.
|
||||
if (req.path.replace(/\/$/, "") === "/api/liveness" && ["GET", "HEAD"].includes(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const controlRequest = isControlRequest(req);
|
||||
if (!controlRequest && isIdleTaskDrainActive()) {
|
||||
res.set("Retry-After", "1").status(503).json({ error: "instance_preparing_to_sleep" });
|
||||
|
||||
@@ -76,6 +76,8 @@ const SENSITIVE_KEYS = new Set<string>([
|
||||
"signingsecret",
|
||||
"webhook_secret",
|
||||
"webhooksecret",
|
||||
"webhookkey",
|
||||
"webhookurl",
|
||||
"paperclip_capability",
|
||||
// The Claude setup-token login fields. `browserCode` carries the one-time
|
||||
// sign-in code and `authorization_code` carries the OAuth code; neither may
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
activityLog,
|
||||
heartbeatRunWatchdogDecisions,
|
||||
heartbeatRuns,
|
||||
environmentLeases,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import { parseObject } from "../../../adapters/utils.js";
|
||||
import { visibleIssueCondition } from "../../../services/issue-visibility.js";
|
||||
@@ -65,6 +67,27 @@ function toRunSnapshot(row: typeof heartbeatRuns.$inferSelect): RunSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSettlementPending(companyId: string, runId: string) {
|
||||
return sql<boolean>`(
|
||||
${heartbeatRuns.runtimeMode} = 'native'
|
||||
or coalesce(${heartbeatRuns.resultJson}->>'workspaceRestoreFailure', '') <> ''
|
||||
or exists (
|
||||
select 1 from ${environmentLeases}
|
||||
where ${environmentLeases.companyId} = ${companyId}
|
||||
and ${environmentLeases.heartbeatRunId} = ${runId}
|
||||
and ${environmentLeases.providerLeaseId} is not null
|
||||
and ${environmentLeases.status} in ('active', 'acquiring')
|
||||
)
|
||||
or exists (
|
||||
select 1 from ${workspaceOperations}
|
||||
where ${workspaceOperations.companyId} = ${companyId}
|
||||
and ${workspaceOperations.heartbeatRunId} = ${runId}
|
||||
and ${workspaceOperations.phase} = 'workspace_finalize'
|
||||
and ${workspaceOperations.status} in ('running', 'failed')
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & WatchdogWriter {
|
||||
const issuesSvc = issueService(db);
|
||||
const recoveryActionsSvc = issueRecoveryActionService(db);
|
||||
@@ -119,6 +142,13 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
return row ? toRunSnapshot(row) : null;
|
||||
}
|
||||
|
||||
async function isRuntimeSettlementPending(companyId: string, runId: string): Promise<boolean> {
|
||||
const [row] = await db.select({
|
||||
pending: sql<boolean>`${heartbeatRuns.status} <> 'running' or ${runtimeSettlementPending(companyId, runId)}`,
|
||||
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
||||
return row?.pending ?? true;
|
||||
}
|
||||
|
||||
async function findLatestDecision(companyId: string, runId: string, now: Date) {
|
||||
const [quietUntilRows, dismissedRows] = await Promise.all([
|
||||
db
|
||||
@@ -321,6 +351,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
eq(heartbeatRuns.id, input.run.id),
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.status, "running"),
|
||||
sql`not ${runtimeSettlementPending(companyId, input.run.id)}`,
|
||||
))
|
||||
.returning();
|
||||
if (!updatedRun) return null;
|
||||
@@ -477,6 +508,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
return {
|
||||
findCandidateSilentRuns,
|
||||
findRunForCompany,
|
||||
isRuntimeSettlementPending,
|
||||
findLatestDecision,
|
||||
findOpenStaleRunEvaluation,
|
||||
findEvaluationIssueById,
|
||||
|
||||
@@ -53,6 +53,7 @@ export type RunningAgentSnapshot = {
|
||||
export interface WatchdogRunReader {
|
||||
findCandidateSilentRuns(input: FindCandidateSilentRunsInput): Promise<RunSnapshot[]>;
|
||||
findRunForCompany(companyId: string, runId: string): Promise<RunSnapshot | null>;
|
||||
isRuntimeSettlementPending(companyId: string, runId: string): Promise<boolean>;
|
||||
findLatestDecision(companyId: string, runId: string, now: Date): Promise<FindLatestDecisionResult>;
|
||||
findOpenStaleRunEvaluation(companyId: string, runId: string): Promise<EvaluationIssueSnapshot | null>;
|
||||
findLatestSameRunTerminalEvidence(
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("application", () => {
|
||||
return {
|
||||
findCandidateSilentRuns: async () => [],
|
||||
findRunForCompany: async () => null,
|
||||
isRuntimeSettlementPending: async () => false,
|
||||
findLatestDecision: async () => ({ quietUntilDecision: null, dismissedFalsePositive: false }),
|
||||
findOpenStaleRunEvaluation: async () => null,
|
||||
findLatestSameRunTerminalEvidence: async () => null,
|
||||
@@ -134,6 +135,27 @@ describe("application", () => {
|
||||
};
|
||||
|
||||
describe("createFoldSourceResolvedRun", () => {
|
||||
it("does not terminate a process or settle the run while its runtime owns finalization", async () => {
|
||||
const calls: string[] = [];
|
||||
const fold = createFoldSourceResolvedRun({
|
||||
reader: makeReader({ isRuntimeSettlementPending: async (companyId, runId) => {
|
||||
expect([companyId, runId]).toEqual(["company-1", "run-1"]);
|
||||
return true;
|
||||
} }),
|
||||
processController: { cleanupRunProcess: async () => {
|
||||
calls.push("cleanup");
|
||||
return { attempted: false, outcome: "no_process_metadata", adapterType: "grok_local" };
|
||||
} },
|
||||
writer: { recordDecision: async () => { throw new Error("unexpected decision"); },
|
||||
foldSourceResolvedRun: async () => { calls.push("fold"); return { kind: "folded", evaluationIssueId: null }; } },
|
||||
});
|
||||
expect(await fold({ run: makeRun({ processPid: 4242 }), runningAgentAdapterType: "grok_local",
|
||||
sourceIssue: foldSourceIssue, evidence: foldEvidence, existingEvaluation: null,
|
||||
silenceStartedAt: null, silenceAgeMs: null, now: new Date("2026-01-01T05:00:00Z") }))
|
||||
.toEqual({ kind: "stale" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans up the process before the folding persistence call", async () => {
|
||||
const calls: string[] = [];
|
||||
const processController: RunProcessController = {
|
||||
@@ -154,7 +176,7 @@ describe("application", () => {
|
||||
return { kind: "folded", evaluationIssueId: null };
|
||||
},
|
||||
};
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ writer, processController });
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ reader: makeReader(), writer, processController });
|
||||
|
||||
const outcome = await foldSourceResolvedRun({
|
||||
run: makeRun({ processPid: 4242 }),
|
||||
@@ -187,7 +209,7 @@ describe("application", () => {
|
||||
},
|
||||
foldSourceResolvedRun: async () => ({ kind: "stale" }),
|
||||
};
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ writer, processController });
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ reader: makeReader(), writer, processController });
|
||||
|
||||
const outcome = await foldSourceResolvedRun({
|
||||
run: makeRun({ processPid: 4242 }),
|
||||
@@ -396,7 +418,7 @@ describe("application", () => {
|
||||
foldSourceResolvedRun: async () => ({ kind: "folded", evaluationIssueId: null }),
|
||||
...writerOverrides,
|
||||
};
|
||||
return createFoldSourceResolvedRun({ writer, processController });
|
||||
return createFoldSourceResolvedRun({ reader: makeReader(), writer, processController });
|
||||
}
|
||||
|
||||
describe("createScanSilentActiveRuns", () => {
|
||||
|
||||
@@ -72,6 +72,7 @@ export function createBuildRunOutputSilence(deps: BuildRunOutputSilenceDeps) {
|
||||
}
|
||||
|
||||
export type FoldSourceResolvedRunDeps = {
|
||||
reader: Pick<WatchdogRunReader, "isRuntimeSettlementPending">;
|
||||
writer: WatchdogWriter;
|
||||
processController: RunProcessController;
|
||||
};
|
||||
@@ -89,6 +90,9 @@ export type FoldSourceResolvedRunUseCaseInput = {
|
||||
|
||||
export function createFoldSourceResolvedRun(deps: FoldSourceResolvedRunDeps) {
|
||||
return async function foldSourceResolvedRun(input: FoldSourceResolvedRunUseCaseInput): Promise<FoldOutcome> {
|
||||
if (await deps.reader.isRuntimeSettlementPending(input.run.companyId, input.run.id)) {
|
||||
return { kind: "stale" };
|
||||
}
|
||||
const cleanup = await deps.processController.cleanupRunProcess({
|
||||
runId: input.run.id,
|
||||
adapterType: input.runningAgentAdapterType,
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createActiveRunWatchdog(db: Db, config: ActiveRunWatchdogConfig)
|
||||
const processController = createProcessAdapter();
|
||||
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({
|
||||
reader: postgresAdapter,
|
||||
writer: postgresAdapter,
|
||||
processController,
|
||||
});
|
||||
|
||||
@@ -62,6 +62,36 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
}
|
||||
});
|
||||
|
||||
it("records the human creator from options and ignores a creator in the insert data", async () => {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const created = await lifecycle.requestHire(companyId, { name: "Created by member", createdByUserId: "spoofed-user" }, { createdByUserId: "member-1" });
|
||||
expect(created.createdByUserId).toBe("member-1");
|
||||
expect((await current(created.id)).createdByUserId).toBe("member-1");
|
||||
const system = await lifecycle.requestHire(companyId, { name: "System hire", createdByUserId: "spoofed-user" });
|
||||
expect((await current(system.id)).createdByUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("backfills the creator from the earliest user creation activity in the same company", async () => {
|
||||
const [byUser, byAgent, foreign] = await db.insert(agents).values(["By user", "By agent", "Foreign log"].map(name => ({ companyId, name }))).returning();
|
||||
const otherCompanyId = randomUUID();
|
||||
await db.insert(companies).values({ id: otherCompanyId, name: "Other", issuePrefix: `O${otherCompanyId.slice(0, 7)}` });
|
||||
await db.insert(activityLog).values([
|
||||
{ companyId, actorType: "user", actorId: "first-user", action: "agent.hire_created", entityType: "agent", entityId: byUser.id, createdAt: new Date("2026-01-01T00:00:00Z") },
|
||||
{ companyId, actorType: "user", actorId: "later-user", action: "agent.created", entityType: "agent", entityId: byUser.id, createdAt: new Date("2026-01-02T00:00:00Z") },
|
||||
{ companyId, actorType: "agent", actorId: randomUUID(), action: "agent.hire_created", entityType: "agent", entityId: byAgent.id },
|
||||
{ companyId: otherCompanyId, actorType: "user", actorId: "foreign-user", action: "agent.created", entityType: "agent", entityId: foreign.id },
|
||||
]);
|
||||
const migration = readFileSync(new URL("../../../../../packages/db/src/migrations/0343_new_mentor.sql", import.meta.url), "utf8");
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
for (const statement of migration.split("--> statement-breakpoint")) {
|
||||
await db.execute(sql.raw(statement));
|
||||
}
|
||||
}
|
||||
expect((await current(byUser.id)).createdByUserId).toBe("first-user");
|
||||
expect((await current(byAgent.id)).createdByUserId).toBeNull();
|
||||
expect((await current(foreign.id)).createdByUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("commits the record before preparation and verifies before admission", async () => {
|
||||
const observed: string[] = [];
|
||||
const work = worker(async agent => {
|
||||
@@ -481,7 +511,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await db.update(pluginCompanySettings).set({ enabled: true }).where(eq(pluginCompanySettings.pluginId, pluginId));
|
||||
expect(await driver.runPlugin(snapshot, pluginId)).toBe("complete");
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "agentLifecycle", { companyId, agentId: agent.id,
|
||||
operationId: snapshot.lifecycleOperation!.id, version: snapshot.lifecycleVersion, phase: "paused" }, 30_000);
|
||||
operationId: snapshot.lifecycleOperation!.id, version: snapshot.lifecycleVersion, phase: "paused", instanceSecrets: {} }, 30_000);
|
||||
call.mockResolvedValueOnce({ operationId: "stale", version: snapshot.lifecycleVersion, status: "complete" });
|
||||
await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("Invalid lifecycle result");
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
|
||||
.insert(agents)
|
||||
.values({
|
||||
...prepared,
|
||||
createdByUserId: options?.createdByUserId ?? null,
|
||||
lifecycleState: data.status === "pending_approval" ? "pending_approval" : data.status === "terminated" ? "terminated" : data.status === "paused" ? "paused" : "preparing",
|
||||
lifecycleVersion: 1,
|
||||
lifecycleHolds: data.status === "paused" ? [data.pauseReason ?? "manual"] : [],
|
||||
|
||||
@@ -48,5 +48,8 @@ Work in this order.
|
||||
4. Carry out the accepted scope.
|
||||
|
||||
- For a plan-only request, retain the accepted plan on this task. Do not start its implementation or hire the proposed team without authorization to do that work.
|
||||
- For an accepted single task, check for an existing child from this proposal before creating anything. Create exactly one child linked to this onboarding task, assign it to yourself, and execute it. On later wakes, continue that same child instead of creating another.
|
||||
- Save the finished output as a document on the child task and mark that child done. Link it from the onboarding conversation. Completing the onboarding parent in place, or saving the output only on the parent, does not fulfill the accepted child-task proposal.
|
||||
- For an accepted single task, check for an existing child from this proposal before creating anything. Create exactly one child linked to this onboarding task and assign it to yourself. On later wakes, continue that same child instead of creating another.
|
||||
- If the accepted child work requires a file deliverable, include the approved scope, document requirements, and requested filenames in a self-contained child description. Delegate the work to the child's own Paperclip execution, even when the child is assigned to yourself. That execution must save the document and publish the requested file on the child before marking it done. Do not create or register that file from the onboarding run, or mark the child done before its execution delivers the file. Native `register_deliverable` is bound to its current task.
|
||||
- While waiting for that file-producing child, preserve existing blocker IDs and add the child as a dependency. Native runners use `set_dependencies`, then `paperclip_block` with the child agent as owner and child completion as the unblock action; legacy runners use the normal issue API with `blockedByIssueIds` and a blocked disposition. End the turn so the child can execute. Do not poll for its result while holding the onboarding run.
|
||||
- When child completion resumes the onboarding task, verify the saved child document and retrievable file, then reply with the actual child task and download links. Reuse the recorded result instead of producing another file on the parent or creating another child.
|
||||
- For document-only child work, you may execute it directly. Save the finished output as a document on the child task and mark that child done. Link it from the onboarding conversation. Completing the onboarding parent in place, or saving the output only on the parent, does not fulfill the accepted child-task proposal.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { skillAccessService } from "../services/skill-access.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -9,6 +10,7 @@ import type { DeploymentMode, LiveEvent } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { activityToastIdentity } from "./activity-toast-identity.js";
|
||||
import { taskToastEligible } from "./task-toast-eligibility.js";
|
||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||
import { trackIdleWork } from "../services/task-admission.js";
|
||||
|
||||
@@ -302,6 +304,13 @@ export function setupLiveEventsWebSocketServer(
|
||||
if (!issue || !(await access.decide({ actor: context!.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: event.companyId, issueId } })).allowed) return null;
|
||||
}
|
||||
if (["company_skill", "company_skill_source"].includes(String(payload.entityType)) && typeof payload.entityId === "string") {
|
||||
try {
|
||||
const skills = skillAccessService(db, context!.actor);
|
||||
if (payload.entityType === "company_skill") await skills.assertSkill(event.companyId, payload.entityId);
|
||||
else await skills.assertSource(event.companyId, payload.entityId);
|
||||
} catch { return null; }
|
||||
}
|
||||
if (payload.entityType === "project" && typeof payload.entityId === "string") {
|
||||
const [project] = await db.select({ id: projects.id }).from(projects)
|
||||
.where(and(eq(projects.id, payload.entityId), eq(projects.companyId, event.companyId))).limit(1);
|
||||
@@ -338,7 +347,18 @@ export function setupLiveEventsWebSocketServer(
|
||||
// Check at delivery time, including after a grant is revoked while the
|
||||
// subscription remains open. Never cache successful privacy decisions.
|
||||
const visible = await visibleEvent(event);
|
||||
if (visible && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(visible));
|
||||
if (visible) {
|
||||
// Relevance is independent of authorization and cache delivery. A
|
||||
// failed relevance lookup must keep the authorized update flowing.
|
||||
let toastEligible = false;
|
||||
try {
|
||||
toastEligible = await taskToastEligible(db, visible,
|
||||
context.actor.type === "board" ? context.actor.userId ?? null : null);
|
||||
} catch (err) {
|
||||
logger.warn({ err, companyId: context.companyId }, "task toast eligibility failed");
|
||||
}
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ ...visible, toastEligible }));
|
||||
}
|
||||
}).catch(err => logger.warn({ err, companyId: context.companyId }, "live event authorization failed"))
|
||||
.finally(() => { pending -= 1; });
|
||||
});
|
||||
|
||||
@@ -8,15 +8,46 @@ import {
|
||||
WsJsonWireConnection,
|
||||
} from "./runner-prp-outbound.js";
|
||||
|
||||
const sockets = vi.hoisted(() => ({ create: null as (() => unknown) | null }));
|
||||
|
||||
vi.mock("ws", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ws")>();
|
||||
return {
|
||||
...actual,
|
||||
default: Object.assign(vi.fn(function () { return sockets.create!(); }), {
|
||||
CONNECTING: actual.default.CONNECTING,
|
||||
OPEN: actual.default.OPEN,
|
||||
CLOSING: actual.default.CLOSING,
|
||||
CLOSED: actual.default.CLOSED,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
readyState: number = WebSocket.OPEN;
|
||||
send = vi.fn();
|
||||
terminate = vi.fn(() => {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.emit("close", 1006, Buffer.alloc(0));
|
||||
});
|
||||
close = vi.fn((code?: number) => {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.emit("close", code ?? 1000, Buffer.alloc(0));
|
||||
});
|
||||
}
|
||||
|
||||
function openingSocket(socket = new FakeSocket()): FakeSocket {
|
||||
socket.readyState = WebSocket.CONNECTING;
|
||||
queueMicrotask(() => {
|
||||
if (socket.readyState !== WebSocket.CONNECTING) return;
|
||||
socket.readyState = WebSocket.OPEN;
|
||||
socket.emit("open");
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
sockets.create = () => openingSocket();
|
||||
|
||||
describe("runner provider-ingress WebSocket wire", () => {
|
||||
it("bounds credential refresh by the fixed deadline and cancellation", async () => {
|
||||
const signal = new AbortController().signal;
|
||||
@@ -140,6 +171,200 @@ describe("runner provider-ingress WebSocket wire", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["delayed_ack", "no_ack", "unconfirmed_termination", "error_before_close"] as const)("retires authenticated %s ingress within the native setup close budget", async (mode) => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeSocket();
|
||||
let activeConnections = 0;
|
||||
const closeFrameOwners: number[] = [];
|
||||
socket.close = vi.fn(() => {
|
||||
closeFrameOwners.push(activeConnections);
|
||||
socket.readyState = WebSocket.CLOSING;
|
||||
if (mode === "delayed_ack") {
|
||||
setTimeout(() => {
|
||||
socket.readyState = WebSocket.CLOSED;
|
||||
socket.emit("close", 1001, Buffer.alloc(0));
|
||||
}, 250);
|
||||
}
|
||||
});
|
||||
const finishClose = () => {
|
||||
socket.readyState = WebSocket.CLOSED;
|
||||
socket.emit("close", 1006, Buffer.alloc(0));
|
||||
};
|
||||
const terminate = vi.fn(() => {
|
||||
if (mode === "error_before_close") socket.emit("error", new Error("fixture termination pending"));
|
||||
else if (mode !== "unconfirmed_termination") finishClose();
|
||||
});
|
||||
Object.assign(socket, { terminate });
|
||||
const create = sockets.create;
|
||||
sockets.create = () => openingSocket(socket);
|
||||
const endpoint = {
|
||||
kind: "authenticated_websocket" as const,
|
||||
websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-authenticated",
|
||||
secretHeaders: [{ name: "Authorization", value: "fixture-only" }],
|
||||
generation: "generation-authenticated",
|
||||
refresh: vi.fn(async () => endpoint),
|
||||
close: vi.fn(async () => { expect(activeConnections).toBe(0); }),
|
||||
};
|
||||
const attachWireConnection = vi.fn((wire: WsJsonWireConnection) => {
|
||||
expect(activeConnections).toBe(0);
|
||||
activeConnections += 1;
|
||||
wire.onClose(() => { activeConnections -= 1; });
|
||||
return { isAuthenticated: () => true };
|
||||
});
|
||||
const handle = connectRunnerPrpIngress({
|
||||
authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane,
|
||||
endpoint,
|
||||
startupDeadlineMs: 60_000,
|
||||
recoveryGraceMs: 60_000,
|
||||
});
|
||||
const failureObserver = vi.fn();
|
||||
void handle.failure.catch(failureObserver);
|
||||
let closing: Promise<void> | undefined;
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await handle.ready;
|
||||
expect(activeConnections).toBe(1);
|
||||
closing = handle.close();
|
||||
expect(closeFrameOwners).toEqual([1]);
|
||||
if (socket.readyState !== WebSocket.CLOSED) {
|
||||
expect(activeConnections).toBe(mode === "error_before_close" ? 0 : 1);
|
||||
expect(endpoint.close).not.toHaveBeenCalled();
|
||||
}
|
||||
const outcome = Promise.race([
|
||||
closing.then(() => "closed"),
|
||||
new Promise<string>((resolve) => { deadline = setTimeout(() => resolve("setup_cleanup_deadline"), 10_000); }),
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(terminate).toHaveBeenCalledOnce();
|
||||
expect({ outcome: await outcome, activeConnections, endpointReleased: endpoint.close.mock.calls.length }).toEqual(["unconfirmed_termination", "error_before_close"].includes(mode)
|
||||
? { outcome: "setup_cleanup_deadline", activeConnections: mode === "error_before_close" ? 0 : 1, endpointReleased: 0 }
|
||||
: { outcome: "closed", activeConnections: 0, endpointReleased: 1 });
|
||||
expect(attachWireConnection).toHaveBeenCalledOnce();
|
||||
expect(endpoint.refresh).not.toHaveBeenCalled();
|
||||
expect(failureObserver).not.toHaveBeenCalled();
|
||||
if (["unconfirmed_termination", "error_before_close"].includes(mode)) return;
|
||||
sockets.create = () => openingSocket();
|
||||
const successor = connectRunnerPrpIngress({
|
||||
authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane,
|
||||
endpoint,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await successor.ready;
|
||||
expect(activeConnections).toBe(1);
|
||||
await successor.close();
|
||||
expect(activeConnections).toBe(0);
|
||||
expect(attachWireConnection).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
if (deadline) clearTimeout(deadline);
|
||||
finishClose();
|
||||
await (closing ?? handle.close());
|
||||
sockets.create = create;
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains an errored prior socket until physical close during reconnect shutdown", async () => {
|
||||
const prior = new FakeSocket();
|
||||
prior.close = vi.fn(() => { prior.readyState = WebSocket.CLOSING; });
|
||||
const pending = new FakeSocket();
|
||||
const create = sockets.create;
|
||||
let activeConnections = 0;
|
||||
let opens = 0;
|
||||
sockets.create = () => {
|
||||
opens += 1;
|
||||
if (opens === 1) return openingSocket(prior);
|
||||
pending.readyState = WebSocket.CONNECTING;
|
||||
return pending;
|
||||
};
|
||||
const endpointCloseStates: number[][] = [];
|
||||
const endpoint = {
|
||||
kind: "authenticated_websocket" as const,
|
||||
websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-reconnecting",
|
||||
secretHeaders: [],
|
||||
generation: "generation-reconnecting",
|
||||
refresh: vi.fn(async () => endpoint),
|
||||
close: vi.fn(async () => { endpointCloseStates.push([prior.readyState, pending.readyState]); }),
|
||||
};
|
||||
const attachWireConnection = vi.fn((wire: WsJsonWireConnection) => {
|
||||
activeConnections += 1;
|
||||
wire.onClose(() => { activeConnections -= 1; });
|
||||
return { isAuthenticated: () => true };
|
||||
});
|
||||
const handle = connectRunnerPrpIngress({
|
||||
authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane,
|
||||
endpoint,
|
||||
});
|
||||
const failureObserver = vi.fn();
|
||||
void handle.failure.catch(failureObserver);
|
||||
let closing: Promise<void> | undefined;
|
||||
try {
|
||||
await handle.ready;
|
||||
prior.readyState = WebSocket.CLOSING;
|
||||
prior.emit("error", new Error("fixture prior socket error"));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(opens).toBe(2);
|
||||
expect(activeConnections).toBe(0);
|
||||
expect(prior.readyState).toBe(WebSocket.CLOSING);
|
||||
closing = handle.close();
|
||||
await closing;
|
||||
expect({ priorTerminations: prior.terminate.mock.calls.length, endpointCloseStates: endpointCloseStates.map(states => [...states]) }).toEqual({
|
||||
priorTerminations: 1, endpointCloseStates: [[WebSocket.CLOSED, WebSocket.CLOSED]],
|
||||
});
|
||||
expect(attachWireConnection).toHaveBeenCalledOnce();
|
||||
expect(failureObserver).not.toHaveBeenCalled();
|
||||
expect(endpoint.refresh).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
prior.terminate();
|
||||
pending.terminate();
|
||||
await (closing ?? handle.close());
|
||||
sockets.create = create;
|
||||
}
|
||||
});
|
||||
|
||||
it("joins a just-opened socket without attaching authority after intentional close", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const create = sockets.create;
|
||||
let closing: Promise<void> | undefined;
|
||||
sockets.create = () => {
|
||||
socket.readyState = WebSocket.CONNECTING;
|
||||
queueMicrotask(() => {
|
||||
socket.readyState = WebSocket.OPEN;
|
||||
socket.emit("open");
|
||||
closing = handle.close();
|
||||
});
|
||||
return socket;
|
||||
};
|
||||
const endpoint = {
|
||||
kind: "authenticated_websocket" as const,
|
||||
websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-opening",
|
||||
secretHeaders: [],
|
||||
generation: "generation-opening",
|
||||
refresh: vi.fn(async () => endpoint),
|
||||
close: vi.fn(async () => { expect(socket.readyState).toBe(WebSocket.CLOSED); }),
|
||||
};
|
||||
const attachWireConnection = vi.fn(() => ({ isAuthenticated: () => true }));
|
||||
const handle = connectRunnerPrpIngress({
|
||||
authority: { attachWireConnection, activeRunnerConnectionCount: () => 0 } as unknown as DurablePrpControlPlane,
|
||||
endpoint,
|
||||
});
|
||||
const failureObserver = vi.fn();
|
||||
void handle.failure.catch(failureObserver);
|
||||
try {
|
||||
await expect(handle.ready).rejects.toThrow("closed before it became ready");
|
||||
await closing;
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(1001);
|
||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
||||
expect(attachWireConnection).not.toHaveBeenCalled();
|
||||
expect(endpoint.close).toHaveBeenCalledOnce();
|
||||
expect(endpoint.refresh).not.toHaveBeenCalled();
|
||||
expect(failureObserver).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await (closing ?? handle.close());
|
||||
sockets.create = create;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not report intentional close as an active-run ingress failure", async () => {
|
||||
const endpoint = {
|
||||
kind: "authenticated_websocket" as const,
|
||||
|
||||
@@ -167,9 +167,19 @@ async function awaitWithinDeadline<T>(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeOwnedWire(wire: WsJsonWireConnection): Promise<void> {
|
||||
const closed = wire.socket.readyState === WebSocket.CLOSED
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => wire.socket.once("close", () => resolve()));
|
||||
wire.close(1001);
|
||||
wire.socket.terminate();
|
||||
await closed;
|
||||
}
|
||||
|
||||
async function openEndpoint(input: {
|
||||
endpoint: RunnerIngressEndpoint;
|
||||
signal: AbortSignal;
|
||||
onWire: (wire: WsJsonWireConnection) => void;
|
||||
}): Promise<{ wire: WsJsonWireConnection; statusCode: number | null }> {
|
||||
validateEndpoint(input.endpoint);
|
||||
return await new Promise((resolve, reject) => {
|
||||
@@ -211,6 +221,7 @@ async function openEndpoint(input: {
|
||||
input.signal.removeEventListener("abort", onAbort);
|
||||
reject(Object.assign(error, { statusCode }));
|
||||
});
|
||||
input.onWire(wire);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,7 +269,7 @@ export function connectRunnerPrpIngress(input: {
|
||||
) => void;
|
||||
}): RunnerPrpOutboundHandle {
|
||||
const abort = new AbortController();
|
||||
let activeWire: WsJsonWireConnection | null = null;
|
||||
const ownedWires = new Set<WsJsonWireConnection>();
|
||||
let endpoint = input.endpoint;
|
||||
const startupDeadline =
|
||||
Date.now() + (input.startupDeadlineMs ?? DEFAULT_STARTUP_DEADLINE_MS);
|
||||
@@ -288,8 +299,16 @@ export function connectRunnerPrpIngress(input: {
|
||||
while (!abort.signal.aborted) {
|
||||
try {
|
||||
input.onStateChange?.(attempt === 0 ? "connecting" : "reconnecting");
|
||||
const opened = await openEndpoint({ endpoint, signal: abort.signal });
|
||||
activeWire = opened.wire;
|
||||
if (abort.signal.aborted) return;
|
||||
const opened = await openEndpoint({
|
||||
endpoint,
|
||||
signal: abort.signal,
|
||||
onWire: (wire) => {
|
||||
ownedWires.add(wire);
|
||||
wire.socket.once("close", () => ownedWires.delete(wire));
|
||||
},
|
||||
});
|
||||
if (abort.signal.aborted) return;
|
||||
const attachment = input.authority.attachWireConnection(opened.wire);
|
||||
const authenticated = await waitForPrpReady({
|
||||
isAuthenticated: attachment.isAuthenticated,
|
||||
@@ -311,10 +330,8 @@ export function connectRunnerPrpIngress(input: {
|
||||
input.onStateChange?.("authenticated");
|
||||
attempt = 0;
|
||||
await socketClosed(opened.wire);
|
||||
activeWire = null;
|
||||
recoveryDeadline = Date.now() + recoveryGraceMs;
|
||||
} catch (error) {
|
||||
activeWire = null;
|
||||
// close() owns intentional shutdown. openEndpoint rejects its pending
|
||||
// handshake when the abort signal fires, but that cancellation is not
|
||||
// an active-run transport failure and must not reject the long-lived
|
||||
@@ -380,7 +397,7 @@ export function connectRunnerPrpIngress(input: {
|
||||
failure,
|
||||
async close() {
|
||||
abort.abort();
|
||||
activeWire?.close(1001);
|
||||
await Promise.all([...ownedWires].map(closeOwnedWire));
|
||||
await loop;
|
||||
await endpoint.close();
|
||||
input.onStateChange?.("closed");
|
||||
|
||||
Loaded 100 of 1176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user