mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## 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>
153 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|