mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10:39 +09:00
[codex] Runtime control-plane fixes (#6380)
## Thinking Path > - Paperclip orchestrates AI agents through a server-side control plane > - That control plane depends on reliable issue state transitions, plugin lifecycle behavior, import limits, and startup/shutdown handling > - Several small runtime fixes had accumulated on the working branch and were mixed with larger feature work > - Keeping them separate makes the correctness fixes reviewable and mergeable without waiting for cloud-sync UI work > - This pull request groups the server/runtime control-plane fixes into one standalone branch > - The benefit is a tighter, safer runtime baseline for retries, imports, plugin migrations, feedback flushing, and trusted cloud import handling ## What Changed - Fixed updated issue list pagination sorting and scheduled retry comment handling. - Re-applied pending plugin migrations during hot reload and fixed plugin-schema worktree seed restore. - Hardened public tenant DB startup, portable import body limits, trusted cloud import errors, and trusted cloud tenant import mutation access. - Expired stale request confirmations after user comments. - Added feedback export shutdown hardening so database-unavailable flush loops stop cleanly. - Guarded plugin worker `error` event emission when no listener is registered. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm --filter @paperclipai/plugin-sdk build` - `npm run install --prefix node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts server/src/__tests__/plugin-lifecycle-restart.test.ts server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/body-limits.test.ts server/src/__tests__/feedback-flush-controller.test.ts server/src/__tests__/error-handler.test.ts server/src/__tests__/board-mutation-guard.test.ts packages/db/src/backup-lib.test.ts` initially exposed local setup issues and two 5s test timeouts. - Rerun after local prereq build: `pnpm exec vitest run --testTimeout 15000 server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/feedback-flush-controller.test.ts server/src/__tests__/server-startup-feedback-export.test.ts` passed. - Some embedded Postgres-backed tests skipped on this host because local Postgres init was unavailable. ## Risks - Runtime-touching branch: startup/shutdown and issue interaction behavior should be reviewed carefully. - The feedback export change disables repeated flush attempts only for database connection-refused failures; other upload failures still log normally. - The plugin worker error guard avoids process crashes from unhandled EventEmitter errors but may hide errors from code paths that expected an emitted listener. > 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-based coding agent with local shell/git/tool use. Exact hosted model ID and context-window size are not exposed by the local Paperclip adapter runtime. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f257530537
commit
c91a062326
26 changed files with 1363 additions and 130 deletions
19
server/src/__tests__/body-limits.test.ts
Normal file
19
server/src/__tests__/body-limits.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_JSON_BODY_LIMIT,
|
||||
PORTABLE_JSON_BODY_LIMIT,
|
||||
PORTABLE_JSON_BODY_LIMIT_BYTES,
|
||||
} from "../http/body-limits.js";
|
||||
|
||||
describe("HTTP body limits", () => {
|
||||
it("keeps the global JSON parser at the established ceiling", () => {
|
||||
expect(DEFAULT_JSON_BODY_LIMIT).toBe("10mb");
|
||||
});
|
||||
|
||||
it("allows PAP-scale portable import JSON payloads", () => {
|
||||
expect(PORTABLE_JSON_BODY_LIMIT).toBe("64mb");
|
||||
expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBe(64 * 1024 * 1024);
|
||||
expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBeGreaterThan(10 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,31 @@ describe("errorHandler", () => {
|
|||
expect(res.__errorContext?.error?.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("exposes raw 500 messages for trusted Cloud tenant imports", () => {
|
||||
const req = {
|
||||
...makeReq(),
|
||||
method: "POST",
|
||||
originalUrl: "/api/companies/import",
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "cloud-user",
|
||||
source: "cloud_tenant",
|
||||
},
|
||||
} as unknown as Request;
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = new Error("portable file references missing upload id");
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Internal server error",
|
||||
message: "portable file references missing upload id",
|
||||
});
|
||||
expect(res.err).toBe(err);
|
||||
});
|
||||
|
||||
it("attaches HttpError instances for 500 responses", () => {
|
||||
const req = makeReq();
|
||||
const res = makeRes() as any;
|
||||
|
|
|
|||
24
server/src/__tests__/feedback-flush-controller.test.ts
Normal file
24
server/src/__tests__/feedback-flush-controller.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isDatabaseConnectionUnavailableError } from "../app.js";
|
||||
|
||||
describe("feedback export flush error classification", () => {
|
||||
it("recognizes wrapped database connection-refused errors", () => {
|
||||
const error = new Error("Failed query: select ...: connect ECONNREFUSED 127.0.0.1:54329");
|
||||
(error as { cause?: unknown }).cause = Object.assign(
|
||||
new Error("connect ECONNREFUSED 127.0.0.1:54329"),
|
||||
{ code: "ECONNREFUSED" },
|
||||
);
|
||||
|
||||
expect(isDatabaseConnectionUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify ordinary feedback upload failures as database outages", () => {
|
||||
expect(isDatabaseConnectionUnavailableError(new Error("upstream returned 500"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trust unrelated error messages that mention ECONNREFUSED", () => {
|
||||
expect(isDatabaseConnectionUnavailableError(
|
||||
new Error("feedback upload payload mentioned ECONNREFUSED in user content"),
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
getDependencyReadiness: vi.fn(),
|
||||
getCurrentScheduledRetry: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
|
|
@ -223,6 +224,7 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
mockIssueService.update.mockReset();
|
||||
mockIssueService.addComment.mockReset();
|
||||
mockIssueService.getDependencyReadiness.mockReset();
|
||||
mockIssueService.getCurrentScheduledRetry.mockReset();
|
||||
mockIssueService.findMentionedAgents.mockReset();
|
||||
mockIssueService.listWakeableBlockedDependents.mockReset();
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
|
||||
|
|
@ -300,6 +302,7 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
allBlockersDone: true,
|
||||
isDependencyReady: true,
|
||||
});
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
|
|
@ -564,6 +567,128 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
));
|
||||
});
|
||||
|
||||
it("moves in-progress issues with a scheduled retry back to todo via POST human comments", async () => {
|
||||
const issue = {
|
||||
...makeIssue("in_progress"),
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue({
|
||||
id: "retry-run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "I added the missing detail; please continue." });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
{ status: "todo" },
|
||||
);
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.objectContaining({
|
||||
status: "todo",
|
||||
scheduledRetrySupersededByComment: true,
|
||||
scheduledRetryRunId: "retry-run-1",
|
||||
cancelledScheduledRetryRunId: "retry-run-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
commentId: "comment-1",
|
||||
mutation: "comment",
|
||||
}),
|
||||
contextSnapshot: expect.objectContaining({
|
||||
wakeReason: "issue_commented",
|
||||
source: "issue.comment",
|
||||
}),
|
||||
}),
|
||||
));
|
||||
});
|
||||
|
||||
it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => {
|
||||
const issue = {
|
||||
...makeIssue("in_progress"),
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed"));
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "I added the missing detail; please continue." });
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ action: "issue.updated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps ordinary in-progress POST human comments in progress when no scheduled retry exists", async () => {
|
||||
const issue = makeIssue("in_progress");
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "Checking in without retry state." });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
}),
|
||||
));
|
||||
});
|
||||
|
||||
it("passes validated comment presentation fields to trusted board comment writes", async () => {
|
||||
const app = await installActor(createApp());
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
|
|
@ -727,6 +852,96 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
));
|
||||
});
|
||||
|
||||
it("moves in-progress issues with a scheduled retry back to todo via the PATCH comment path", async () => {
|
||||
const issue = {
|
||||
...makeIssue("in_progress"),
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue({
|
||||
id: "retry-run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "Retry window is over; please continue." });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
status: "todo",
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
|
||||
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
commentId: "comment-1",
|
||||
mutation: "comment",
|
||||
}),
|
||||
}),
|
||||
));
|
||||
});
|
||||
|
||||
it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => {
|
||||
const issue = {
|
||||
...makeIssue("in_progress"),
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed"));
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "Retry window is over; please continue." });
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ action: "issue.updated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-assignee agent PATCH comments on closed issues", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -573,7 +573,10 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
|||
|
||||
it("resolves an active recovery action by returning the source issue to todo", async () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, sourceIssueId));
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
|
||||
.where(eq(issues.id, sourceIssueId));
|
||||
const recoveryActionSvc = issueRecoveryActionService(db);
|
||||
const action = await recoveryActionSvc.upsertSourceScoped({
|
||||
companyId,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const mockInteractionService = vi.hoisted(() => ({
|
|||
acceptSuggestedTasks: vi.fn(),
|
||||
rejectInteraction: vi.fn(),
|
||||
rejectSuggestedTasks: vi.fn(),
|
||||
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
|
||||
answerQuestions: vi.fn(),
|
||||
cancelQuestions: vi.fn(),
|
||||
}));
|
||||
|
|
@ -156,6 +157,7 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(createIssue());
|
||||
mockInteractionService.listForIssue.mockResolvedValue([]);
|
||||
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]);
|
||||
mockInteractionService.create.mockResolvedValue({
|
||||
id: "interaction-1",
|
||||
companyId: "company-1",
|
||||
|
|
@ -288,6 +290,18 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
});
|
||||
|
||||
it("lists and creates board-authored interactions", async () => {
|
||||
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([
|
||||
{
|
||||
id: "interaction-expired",
|
||||
kind: "request_confirmation",
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockInteractionService.listForIssue.mockResolvedValue([
|
||||
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
|
||||
]);
|
||||
|
|
@ -298,6 +312,24 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
expect(listRes.body).toEqual([
|
||||
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
|
||||
]);
|
||||
expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.thread_interaction_expired",
|
||||
details: expect.objectContaining({
|
||||
interactionId: "interaction-expired",
|
||||
interactionKind: "request_confirmation",
|
||||
source: "issue.interactions.catchup_superseded_by_comment",
|
||||
result: expect.objectContaining({
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
documents,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
instanceSettings,
|
||||
issueRelations,
|
||||
|
|
@ -41,6 +42,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
|
|
@ -57,6 +59,37 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedConfirmationIssue(title = "Comment supersede") {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title,
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
return { companyId, goalId, issueId };
|
||||
}
|
||||
|
||||
it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
|
|
@ -783,35 +816,10 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("expires supersedable request confirmations when a user comments", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
it("expires request confirmations opted into user-comment supersede after creation", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue();
|
||||
const commentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Comment supersede",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
|
|
@ -831,6 +839,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
companyId,
|
||||
}, {
|
||||
id: commentId,
|
||||
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
||||
authorUserId: "local-board",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
|
|
@ -849,6 +858,160 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps request confirmations pending unless user-comment supersede is explicitly enabled", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out");
|
||||
|
||||
await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Proceed with the current draft?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(Date.now() + 1_000),
|
||||
authorUserId: "local-board",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(expired).toHaveLength(0);
|
||||
const rows = await db.select().from(issueThreadInteractions);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("does not supersede request confirmations for agent, system, or older user comments", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Proceed with the current draft?",
|
||||
supersedeOnUserComment: true,
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
const createdAtMs = new Date(created.createdAt).getTime();
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(createdAtMs + 1_000),
|
||||
authorUserId: null,
|
||||
}, {
|
||||
agentId: randomUUID(),
|
||||
})).resolves.toHaveLength(0);
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(createdAtMs + 1_000),
|
||||
authorUserId: null,
|
||||
}, {})).resolves.toHaveLength(0);
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(createdAtMs - 1_000),
|
||||
authorUserId: "local-board",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).resolves.toHaveLength(0);
|
||||
|
||||
const rows = await db.select().from(issueThreadInteractions);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("repairs historical request confirmations superseded by later user comments idempotently", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Historical comment supersede");
|
||||
const commentId = randomUUID();
|
||||
const createdAt = new Date("2026-05-18T12:00:00.000Z");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Proceed with the current draft?",
|
||||
supersedeOnUserComment: true,
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ createdAt, updatedAt: createdAt })
|
||||
.where(eq(issueThreadInteractions.id, created.id));
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
authorType: "system",
|
||||
body: "System-side progress note.",
|
||||
createdAt: new Date("2026-05-18T12:00:30.000Z"),
|
||||
updatedAt: new Date("2026-05-18T12:00:30.000Z"),
|
||||
});
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "local-board",
|
||||
authorType: "user",
|
||||
body: "Please revise this first.",
|
||||
createdAt: new Date("2026-05-18T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-05-18T12:01:00.000Z"),
|
||||
});
|
||||
|
||||
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
||||
id: issueId,
|
||||
companyId,
|
||||
});
|
||||
|
||||
expect(expired).toHaveLength(1);
|
||||
expect(expired[0]).toMatchObject({
|
||||
id: created.id,
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId,
|
||||
},
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: "local-board",
|
||||
});
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
||||
id: issueId,
|
||||
companyId,
|
||||
})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("expires request confirmations when the watched issue document revision changes", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
getRelationSummaries: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
getCurrentScheduledRetry: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
|
|
@ -205,6 +206,7 @@ describe("issue update comment wakeups", () => {
|
|||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("includes the new comment in assignment wakes from issue updates", async () => {
|
||||
|
|
|
|||
|
|
@ -380,6 +380,46 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
||||
});
|
||||
|
||||
it("can page issues by most recently updated before priority", async () => {
|
||||
const companyId = randomUUID();
|
||||
const oldCriticalIssueId = randomUUID();
|
||||
const recentMediumIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: oldCriticalIssueId,
|
||||
companyId,
|
||||
title: "Old critical issue",
|
||||
status: "todo",
|
||||
priority: "critical",
|
||||
updatedAt: new Date("2026-05-01T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: recentMediumIssueId,
|
||||
companyId,
|
||||
title: "Recent medium issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
updatedAt: new Date("2026-05-17T21:12:29.993Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
limit: 1,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
|
||||
});
|
||||
|
||||
it("ranks comment matches ahead of description-only matches", async () => {
|
||||
const companyId = randomUUID();
|
||||
const commentMatchId = randomUUID();
|
||||
|
|
|
|||
129
server/src/__tests__/plugin-lifecycle-restart.test.ts
Normal file
129
server/src/__tests__/plugin-lifecycle-restart.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Regression test for PAP-9585.
|
||||
*
|
||||
* `restartWorker` is called by the dev file-watcher whenever a local-path
|
||||
* plugin's source files change. Before PAP-9585 it only bounced the worker
|
||||
* subprocess, which left newly added `migrations/*.sql` files unapplied — the
|
||||
* plugin schema would silently drift out of sync with worker code.
|
||||
*
|
||||
* The fix is for `restartWorker` to do a full deactivate + reactivate cycle
|
||||
* via the plugin loader, which re-reads the manifest from disk and runs
|
||||
* `applyMigrations` (idempotently) before starting the new worker.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pluginRecord = {
|
||||
id: "plugin-1",
|
||||
pluginKey: "example.plugin",
|
||||
status: "ready",
|
||||
manifestJson: { id: "example.plugin", capabilities: [] },
|
||||
packageName: "@example/plugin",
|
||||
version: "1.0.0",
|
||||
packagePath: "/tmp/example-plugin",
|
||||
};
|
||||
|
||||
const mockRegistry = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByKey: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
upsertConfig: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/plugin-registry.js", () => ({
|
||||
pluginRegistryService: () => mockRegistry,
|
||||
}));
|
||||
|
||||
import { pluginLifecycleManager } from "../services/plugin-lifecycle.js";
|
||||
import type { PluginLoader } from "../services/plugin-loader.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
function makeWorkerManagerStub() {
|
||||
const handle = {
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return {
|
||||
handle,
|
||||
workerManager: {
|
||||
getWorker: vi.fn().mockReturnValue(handle),
|
||||
isRunning: vi.fn().mockReturnValue(true),
|
||||
startWorker: vi.fn().mockResolvedValue(undefined),
|
||||
stopWorker: vi.fn().mockResolvedValue(undefined),
|
||||
restartWorker: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as PluginWorkerManager,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pluginLifecycleManager.restartWorker", () => {
|
||||
it("does a full deactivate+reactivate cycle when the loader has runtime services", async () => {
|
||||
mockRegistry.getById.mockResolvedValue(pluginRecord);
|
||||
mockRegistry.updateStatus.mockResolvedValue(pluginRecord);
|
||||
|
||||
const { handle, workerManager } = makeWorkerManagerStub();
|
||||
|
||||
const loader: Partial<PluginLoader> = {
|
||||
hasRuntimeServices: vi.fn().mockReturnValue(true) as PluginLoader["hasRuntimeServices"],
|
||||
loadSingle: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
plugin: pluginRecord,
|
||||
registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 },
|
||||
}) as PluginLoader["loadSingle"],
|
||||
unloadSingle: vi.fn().mockResolvedValue(undefined) as PluginLoader["unloadSingle"],
|
||||
};
|
||||
|
||||
const lifecycle = pluginLifecycleManager(
|
||||
{} as never,
|
||||
{ loader: loader as PluginLoader, workerManager },
|
||||
);
|
||||
const stopped = vi.fn();
|
||||
const started = vi.fn();
|
||||
lifecycle.on("plugin.worker_stopped", stopped);
|
||||
lifecycle.on("plugin.worker_started", started);
|
||||
|
||||
await lifecycle.restartWorker("plugin-1");
|
||||
|
||||
expect(loader.unloadSingle).toHaveBeenCalledWith("plugin-1", "example.plugin");
|
||||
expect(loader.loadSingle).toHaveBeenCalledWith("plugin-1");
|
||||
// The bare worker handle should NOT be bounced — the loader handles
|
||||
// worker (re)start as part of activate.
|
||||
expect(handle.restart).not.toHaveBeenCalled();
|
||||
expect(stopped).not.toHaveBeenCalled();
|
||||
expect(started).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to bouncing the worker handle when the loader has no runtime services", async () => {
|
||||
mockRegistry.getById.mockResolvedValue(pluginRecord);
|
||||
mockRegistry.updateStatus.mockResolvedValue(pluginRecord);
|
||||
|
||||
const { handle, workerManager } = makeWorkerManagerStub();
|
||||
|
||||
const loader: Partial<PluginLoader> = {
|
||||
hasRuntimeServices: vi.fn().mockReturnValue(false) as PluginLoader["hasRuntimeServices"],
|
||||
loadSingle: vi.fn() as PluginLoader["loadSingle"],
|
||||
unloadSingle: vi.fn() as PluginLoader["unloadSingle"],
|
||||
};
|
||||
|
||||
const lifecycle = pluginLifecycleManager(
|
||||
{} as never,
|
||||
{ loader: loader as PluginLoader, workerManager },
|
||||
);
|
||||
const stopped = vi.fn();
|
||||
const started = vi.fn();
|
||||
lifecycle.on("plugin.worker_stopped", stopped);
|
||||
lifecycle.on("plugin.worker_started", started);
|
||||
|
||||
await lifecycle.restartWorker("plugin-1");
|
||||
|
||||
expect(loader.unloadSingle).not.toHaveBeenCalled();
|
||||
expect(loader.loadSingle).not.toHaveBeenCalled();
|
||||
expect(handle.restart).toHaveBeenCalledTimes(1);
|
||||
expect(stopped).toHaveBeenCalledTimes(1);
|
||||
expect(stopped).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" });
|
||||
expect(started).toHaveBeenCalledTimes(1);
|
||||
expect(started).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" });
|
||||
});
|
||||
});
|
||||
|
|
@ -216,6 +216,35 @@ describe("startServer feedback export wiring", () => {
|
|||
serverPort: 3210,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses authenticated public startup without an external database URL", async () => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({
|
||||
deploymentExposure: "public",
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "https://tenant.example.com",
|
||||
databaseMode: "embedded-postgres",
|
||||
databaseUrl: undefined,
|
||||
}));
|
||||
|
||||
await expect(startServer()).rejects.toThrow(
|
||||
"authenticated public deployments require DATABASE_URL or config.database.connectionString",
|
||||
);
|
||||
expect(createDbMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses authenticated public startup when DATABASE_URL is not a postgres URL", async () => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({
|
||||
deploymentExposure: "public",
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "https://tenant.example.com",
|
||||
databaseUrl: "secret://paperclip-cloud/stacks/alpha/database/runtime-url",
|
||||
}));
|
||||
|
||||
await expect(startServer()).rejects.toThrow(
|
||||
"authenticated public deployments require DATABASE_URL to be a postgres/postgresql connection string",
|
||||
);
|
||||
expect(createDbMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("startServer authenticated auth origin setup", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue