paperclip/server/src/__tests__/issues-goal-context-routes.test.ts
Dotta 236d11d36f
[codex] Add run liveness continuations (#4083)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.

## What Changed

- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.

## Verification

- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.

## Risks

- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00

275 lines
8.5 KiB
TypeScript

import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getAncestors: vi.fn(),
getRelationSummaries: vi.fn(),
findMentionedProjectIds: vi.fn(),
getCommentCursor: vi.fn(),
getComment: vi.fn(),
listAttachments: vi.fn(),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(),
listByIds: vi.fn(),
}));
const mockGoalService = vi.hoisted(() => ({
getById: vi.fn(),
getDefaultCompanyGoal: vi.fn(),
}));
const mockDocumentsService = vi.hoisted(() => ({
getIssueDocumentPayload: vi.fn(),
getIssueDocumentByKey: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({
getById: vi.fn(),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => mockGoalService,
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
const legacyProjectLinkedIssue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-581",
title: "Legacy onboarding task",
description: "Seed the first CEO task",
status: "todo",
priority: "medium",
projectId: "22222222-2222-4222-8222-222222222222",
goalId: null,
parentId: null,
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
updatedAt: new Date("2026-03-24T12:00:00Z"),
executionWorkspaceId: null,
labels: [],
labelIds: [],
};
const projectGoal = {
id: "44444444-4444-4444-8444-444444444444",
companyId: "company-1",
title: "Launch the company",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-20T00:00:00Z"),
updatedAt: new Date("2026-03-20T00:00:00Z"),
};
describe("issue goal context routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
mockIssueService.getCommentCursor.mockResolvedValue({
totalComments: 0,
latestCommentId: null,
latestCommentAt: null,
});
mockIssueService.getComment.mockResolvedValue(null);
mockIssueService.listAttachments.mockResolvedValue([]);
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",
urlKey: "onboarding",
goalId: projectGoal.id,
goalIds: [projectGoal.id],
goals: [{ id: projectGoal.id, title: projectGoal.title }],
name: "Onboarding",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/company-1/project-1",
effectiveLocalFolder: "/tmp/company-1/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date("2026-03-20T00:00:00Z"),
updatedAt: new Date("2026-03-20T00:00:00Z"),
});
mockProjectService.listByIds.mockResolvedValue([]);
mockGoalService.getById.mockImplementation(async (id: string) =>
id === projectGoal.id ? projectGoal : null,
);
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
});
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
const res = await request(await createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
expect(res.status).toBe(200);
expect(res.body.goalId).toBe(projectGoal.id);
expect(res.body.goal).toEqual(
expect.objectContaining({
id: projectGoal.id,
title: projectGoal.title,
}),
);
expect(mockIssueService.findMentionedProjectIds).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ includeCommentBodies: false },
);
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
});
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
expect(res.status).toBe(200);
expect(res.body.issue.goalId).toBe(projectGoal.id);
expect(res.body.goal).toEqual(
expect.objectContaining({
id: projectGoal.id,
title: projectGoal.title,
}),
);
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
expect(res.body.attachments).toEqual([]);
});
it("preserves direct continuation summary lookup in GET /issues/:id/heartbeat-context", async () => {
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue({
key: "continuation-summary",
title: "Continuation Summary",
body: "# Handoff",
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
updatedAt: new Date("2026-04-19T12:00:00.000Z"),
});
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
expect(res.status).toBe(200);
expect(mockDocumentsService.getIssueDocumentByKey).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"continuation-summary",
);
expect(res.body.continuationSummary).toEqual(expect.objectContaining({
key: "continuation-summary",
body: "# Handoff",
}));
});
it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => {
mockIssueService.getRelationSummaries.mockResolvedValue({
blockedBy: [
{
id: "55555555-5555-4555-8555-555555555555",
identifier: "PAP-580",
title: "Finish wakeup plumbing",
status: "done",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
});
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
expect(res.status).toBe(200);
expect(res.body.issue.blockedBy).toEqual([
expect.objectContaining({
id: "55555555-5555-4555-8555-555555555555",
identifier: "PAP-580",
}),
]);
});
});