Files
PaperClipAI/server/src/__tests__/heartbeat-runtime-services.test.ts
T
DottaandPaperclip 94e4218310 refactor(heartbeat): extract runtime service coordination (#15980)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Heartbeat runs start workspace services and receive service reports
from adapters.
> - Service context updates and readiness notices are spread through the
executor.
> - These steps share a clear responsibility, while process and lease
ownership already live elsewhere.
> - This pull request puts service coordination in one module.
> - The executor retains the same dispatch and cleanup order.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Maintenance of heartbeat execution. This is the first of two final
focused extractions after #15973. Related #15653 changes runtime
callback origins; this PR preserves that policy.

**Current behavior**
`heartbeat.ts` has 4,372 lines and 178,459 bytes. It validates service
intents, starts services, persists adapter reports and primary URLs, and
posts readiness notices inline.

**Proposed behavior**
`heartbeat/runtime-services.ts` owns those steps in 212 lines.
`heartbeat.ts` becomes 4,215 lines and 173,657 bytes. Process registries
and leases stay in `workspace-runtime.ts`. The executor still releases
services in its original cleanup path.

**Reason and benefit**
Service coordination has named preparation and result phases. The
execution path is easier to read without changing service lifetime or
moving cleanup policy.

**Breaking changes**
None intended. All 140 named public heartbeat exports remain available,
including `postWorkspaceReadyComment`.

## What Changed

- Move service-intent validation, startup, adapter-result persistence,
and readiness notices into one module.
- Preserve mutable run context, primary URL selection, notice error
handling, and release ordering.
- Add four public-path tests using disposable PostgreSQL, real HTTP
service processes, and a fixture adapter.
- Extend browser recovery acceptance to fetch the actual service URL,
check the readiness notice, and verify listener cleanup.
- Update the module map and acceptance instructions.

## Verification

- Before extraction: all five tests pass, including the four new
characterization cases and existing notice formatting test.
- After extraction: all 17 tests pass across service coordination,
notice formatting, output recording, and runtime authorization.
- Structural comparison confirms that all moved bodies and the
reconstructed executor match the baseline. Module boundary checks pass.
- `pnpm -r typecheck` and `pnpm build` pass.
- Extended isolated browser acceptance: **2/2 pass** (3.7 minutes),
covering actual service readiness, recovery, bounded failure, and
cleanup. At `8e33e2032de681082f128470eb8ab0711b382afe`, all 55
current-head checks pass (two configured skips), including hosted
full-suite tests. Greptile is 5/5 with no findings or review threads.

## Risks

- Service context must be persisted before adapter dispatch and before
run completion. The tests inspect both phases and preserve the first
service URL when adapter reports arrive.
- Notice failures must remain nonfatal. The public-path test checks both
startup and result notices.
- Shared services must survive run cleanup; ephemeral services must stop
after success or failure. Tests use actual processes and HTTP requests.
- The fixture supplies UUID service report IDs, as required by the
database. The existing generated-ID fallback for reports without IDs is
outside this extraction.
- Test adapters and services are local fixtures; these tests make no
live provider calls.

## Model Used

OpenAI Codex, based on GPT-6, assisted with reasoning, code edits, tool
execution, and verification. The exact deployment model ID and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-12 10:40:42 -05:00

153 lines
10 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueComments, issues, workspaceRuntimeServices } from "@paperclipai/db";
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
import { heartbeatService } from "../services/heartbeat.js";
import { stopRuntimeServicesForExecutionWorkspace, type RuntimeServiceRef } from "../services/workspace-runtime.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const failedNotices = vi.hoisted(() => new Set<string>());
vi.mock("../services/issues.js", async importOriginal => {
const actual = await importOriginal<typeof import("../services/issues.js")>();
return { ...actual, issueService: (...args: Parameters<typeof actual.issueService>) => {
const service = actual.issueService(...args);
return { ...service, addComment: (...input: Parameters<typeof service.addComment>) => {
if (failedNotices.has(input[0]) && input[1].startsWith("## Workspace Ready")) {
throw new Error("Fixture notice unavailable");
}
return service.addComment(...input);
} };
} };
});
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
if (!support.supported) console.warn(`Skipping heartbeat runtime services tests: ${support.reason}`);
describePostgres("heartbeat runtime services through adapter dispatch", () => {
const adapterType = "runtime_services_test";
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let heartbeat: ReturnType<typeof heartbeatService>;
const roots: string[] = [];
const captured = new Map<string, { services: RuntimeServiceRef[]; primaryUrl: unknown; intents: unknown }>();
const failures = new Set<string>();
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-runtime-services-");
db = createDb(temporary.connectionString);
heartbeat = heartbeatService(db);
registerServerAdapter({
type: adapterType, supportsLocalAgentJwt: false,
execute: async (input: AdapterExecutionContext) => {
const services = input.context.paperclipRuntimeServices as RuntimeServiceRef[];
captured.set(input.runId, { services: structuredClone(services), primaryUrl: input.context.paperclipRuntimePrimaryUrl, intents: input.context.paperclipRuntimeServiceIntents });
expect(await fetch(services[0]!.url!).then(response => response.text())).toBe("heartbeat runtime fixture");
// Stop unfinished-task recovery from racing subsequent fixture runs.
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, String(input.context.issueId)));
if (failures.has(input.agent.id)) throw new Error("Invalid fixture adapter configuration");
return { exitCode: 0, signal: null, timedOut: false, resultJson: {}, runtimeServices: [{ id: randomUUID(), serviceName: "adapter-preview", url: "https://preview.example.test/fixture", providerRef: "fixture-preview" }] };
},
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
});
}, 30_000);
afterAll(async () => {
await heartbeat?.drainActiveRunExecutions();
for (const root of roots) {
await stopRuntimeServicesForExecutionWorkspace({ db, executionWorkspaceId: randomUUID(), workspaceCwd: root });
await rm(root, { recursive: true, force: true });
}
unregisterServerAdapter(adapterType);
await db?.$client.end({ timeout: 0 });
await temporary?.cleanup();
});
async function seed(shared = false) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const root = await mkdtemp(path.join(os.tmpdir(), "heartbeat-runtime-service-"));
roots.push(root);
await writeFile(path.join(root, "serve.cjs"), "require('node:http').createServer((req,res)=>res.end('heartbeat runtime fixture')).listen(Number(process.env.PORT),'127.0.0.1');\n");
await db.insert(companies).values({ id: companyId, name: "Runtime service fixture", issuePrefix: `R${companyId.slice(0, 6)}` });
const userId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Runtime user", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values([{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" }, { companyId, principalType: "agent", principalId: agentId, membershipRole: "member" }]);
await db.insert(agents).values({ id: agentId, companyId, name: "Runtime service reader", adapterType, status: "idle", adapterConfig: {
cwd: root, workspaceRuntime: { services: [{ name: "web", command: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(root, "serve.cjs"))}`,
port: { type: "auto" }, readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 10, intervalMs: 100 },
lifecycle: shared ? "shared" : "ephemeral", reuseScope: "agent", stopPolicy: { type: shared ? "manual" : "on_run_finish" },
}] },
} });
await db.insert(issues).values({ id: issueId, companyId, title: "Runtime services fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1 });
return { companyId, agentId, issueId, root };
}
async function execute(f: Awaited<ReturnType<typeof seed>>) {
const run = await heartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual", payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId } });
expect(run).not.toBeNull();
await heartbeat.drainActiveRunExecutions();
const [finished] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id));
const services = await db.select().from(workspaceRuntimeServices).where(eq(workspaceRuntimeServices.companyId, f.companyId));
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId));
const input = captured.get(run!.id)!;
return { finished: finished!, services: services.filter(service => service.id === input.services[0]!.id || service.startedByRunId === run!.id),
notices: comments.filter(comment => comment.createdByRunId === run!.id && comment.body.startsWith("## Workspace Ready")), input };
}
it("starts a real service before dispatch, retains its primary URL, and persists adapter-reported services", async () => {
const f = await seed();
const { finished, input, services, notices } = await execute(f);
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
expect(input.intents).toEqual([expect.objectContaining({ name: "web" })]);
expect(input.services).toEqual([expect.objectContaining({ companyId: f.companyId, serviceName: "web", status: "running", reused: false, startedByRunId: finished.id })]);
expect(finished.contextSnapshot?.paperclipRuntimePrimaryUrl).toBe(input.primaryUrl);
expect(finished.contextSnapshot?.paperclipRuntimeServices).toEqual([
expect.objectContaining({ id: input.services[0]!.id }),
expect.objectContaining({ serviceName: "adapter-preview", provider: "adapter_managed", url: "https://preview.example.test/fixture" }),
]);
expect(services, finished.stderrExcerpt ?? undefined).toHaveLength(2);
expect(services.find(service => service.provider === "local_process")).toMatchObject({ id: input.services[0]!.id, status: "stopped" });
expect(services.find(service => service.provider === "adapter_managed")).toMatchObject({ companyId: f.companyId, issueId: f.issueId, status: "running", startedByRunId: finished.id });
expect(notices).toHaveLength(2);
expect(notices.every(notice => notice.authorAgentId === f.agentId && notice.createdByRunId === finished.id)).toBe(true);
expect(notices.some(notice => notice.body.includes(String(input.primaryUrl)))).toBe(true);
expect(notices.some(notice => notice.body.includes("https://preview.example.test/fixture"))).toBe(true);
});
it("reuses a shared service without posting another startup notice", async () => {
const f = await seed(true);
const first = await execute(f);
const second = await execute(f);
expect(second.finished.status, second.finished.error ?? undefined).toBe("succeeded");
expect(second.input.services[0]).toMatchObject({ id: first.input.services[0]!.id, reused: true });
expect(second.services.find(service => service.provider === "local_process")?.status).toBe("running");
expect(first.notices).toHaveLength(2);
expect(second.notices, second.finished.stderrExcerpt ?? undefined).toHaveLength(1);
expect(second.notices[0]!.body).toContain("https://preview.example.test/fixture");
});
it("releases a started ephemeral service when adapter execution fails", async () => {
const f = await seed();
failures.add(f.agentId);
const { finished, services } = await execute(f);
expect(finished.status).toBe("failed");
expect(finished.error).toContain("Invalid fixture adapter configuration");
expect(services).toEqual([expect.objectContaining({ provider: "local_process", status: "stopped" })]);
});
it("keeps both service records and a successful result when readiness notices fail", async () => {
const f = await seed();
failedNotices.add(f.issueId);
const { finished, services, notices } = await execute(f);
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
expect(services, finished.stderrExcerpt ?? undefined).toHaveLength(2);
expect(notices).toEqual([]);
expect(finished.stderrExcerpt).toContain("Failed to post workspace-ready comment: Fixture notice unavailable");
expect(finished.stderrExcerpt).toContain("Failed to post adapter-managed runtime comment: Fixture notice unavailable");
});
});