Merge remote-tracking branch 'origin/master'

# Conflicts:
#	packages/db/src/backup-lib.test.ts
#	packages/db/src/backup-lib.ts
#	packages/plugins/plugin-workspace-diff/src/ui/index.tsx
#	packages/plugins/plugin-workspace-diff/src/workspace-diff.ts
#	server/src/__tests__/body-limits.test.ts
#	server/src/__tests__/issue-comment-reopen-routes.test.ts
#	server/src/__tests__/issue-thread-interactions-service.test.ts
#	server/src/__tests__/plugin-lifecycle-restart.test.ts
#	server/src/app.ts
#	server/src/http/body-limits.ts
#	server/src/middleware/error-handler.ts
#	server/src/routes/companies.ts
#	server/src/routes/issues.ts
#	server/src/services/issue-thread-interactions.ts
#	server/src/services/plugin-lifecycle.ts
#	ui/src/components/DevRestartBanner.test.tsx
#	ui/src/components/DevRestartBanner.tsx
#	ui/src/components/MarkdownEditor.test.tsx
#	ui/src/components/MarkdownEditor.tsx
#	ui/src/pages/Inbox.test.tsx
This commit is contained in:
Dotta committed 2026-05-21 03:27:24 +00:00
commit 9835cbe1f5
38 files changed
+505 -104

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+2 -2
View File
@@ -352,7 +352,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'signal',
ARRAY['upstream_import:preview', 'scope with space', 'quoted "scope"'],
ARRAY['upstream_import:preview', 'scope with space', 'quoted "scope"', 'NULL', 'null'],
jsonb_build_array('json warning', jsonb_build_object('code', 'quoted "value"'))
);
`);
@@ -386,7 +386,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
{
signal: "signal",
title: "case",
scopes: ["upstream_import:preview", "scope with space", 'quoted "scope"'],
scopes: ["upstream_import:preview", "scope with space", 'quoted "scope"', "NULL", "null"],
warnings: ["json warning", { code: 'quoted "value"' }],
},
]);
+1 -1
View File
@@ -257,7 +257,7 @@ function formatPostgresArrayElement(value: unknown): string {
: typeof value === "object"
? JSON.stringify(value)
: String(value);
if (raw.length === 0 || /[{}\s,"\\]/.test(raw)) {
if (raw.length === 0 || /^null$/i.test(raw) || /[{}\s,"\\]/.test(raw)) {
return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
return raw;
@@ -129,14 +129,16 @@ function writeStoredFileSidebarWidth(width: number) {
}
function useIsDesktopDiffLayout() {
const [isDesktop, setIsDesktop] = useState(false);
const [isDesktop, setIsDesktop] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(min-width: 1024px)").matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(min-width: 1024px)");
const update = () => setIsDesktop(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
@@ -576,10 +578,6 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
};
}, []);
useEffect(() => {
fileSidebarWidthRef.current = fileSidebarWidth;
}, [fileSidebarWidth]);
useEffect(() => {
if (!fileSidebarResizing || typeof document === "undefined") return;
@@ -644,7 +642,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
<button
key="working-tree"
type="button"
className={buttonClass(view === "working-tree")}
className={buttonClass(effectiveView === "working-tree")}
onClick={() => {
viewTouchedRef.current = true;
setView("working-tree");
@@ -655,7 +653,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
<button
key="head"
type="button"
className={buttonClass(view === "head")}
className={buttonClass(effectiveView === "head")}
onClick={() => {
viewTouchedRef.current = true;
setView("head");
@@ -681,8 +681,11 @@ async function resolveInferredDefaultBaseRef(cwd: string) {
const upstream = await resolveGitUpstreamRef(cwd);
if (upstream) return upstream;
for (const candidate of ["origin/master", "origin/main", "master", "main"]) {
const resolved = await resolveVerifiedGitRef(cwd, candidate);
const candidates = ["origin/master", "origin/main", "master", "main"];
const resolvedCandidates = await Promise.all(
candidates.map((candidate) => resolveVerifiedGitRef(cwd, candidate)),
);
for (const resolved of resolvedCandidates) {
if (resolved) return resolved;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+5
View File
@@ -1,11 +1,16 @@
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);
@@ -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);
});
});
@@ -635,6 +635,40 @@ describe.sequential("issue comment reopen routes", () => {
));
});
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);
@@ -874,6 +908,40 @@ describe.sequential("issue comment reopen routes", () => {
));
});
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,
@@ -816,7 +816,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("expires request confirmations by default when a user comments after creation", async () => {
it("expires request confirmations opted into user-comment supersede after creation", async () => {
const { companyId, issueId } = await seedConfirmationIssue();
const commentId = randomUUID();
@@ -828,6 +828,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
@@ -857,7 +858,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("keeps request confirmations pending when user-comment supersede is explicitly disabled", async () => {
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({
@@ -868,7 +869,6 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: false,
},
}, {
userId: "local-board",
@@ -902,6 +902,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
@@ -957,6 +958,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
@@ -966,6 +968,15 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
.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,
@@ -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 () => {
@@ -79,6 +79,10 @@ describe("pluginLifecycleManager.restartWorker", () => {
{} 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");
@@ -87,6 +91,8 @@ describe("pluginLifecycleManager.restartWorker", () => {
// 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 () => {
@@ -105,11 +111,19 @@ describe("pluginLifecycleManager.restartWorker", () => {
{} 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" });
});
});
+52 -18
View File
@@ -30,7 +30,6 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { cloudUpstreamRoutes } from "./routes/cloud-upstreams.js";
import {
instanceDatabaseBackupRoutes,
type InstanceDatabaseBackupService,
@@ -60,7 +59,8 @@ import { pluginRegistryService } from "./services/plugin-registry.js";
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js";
import { PORTABLE_JSON_BODY_LIMIT } from "./http/body-limits.js";
import { DEFAULT_JSON_BODY_LIMIT, PORTABLE_JSON_BODY_LIMIT } from "./http/body-limits.js";
import { COMPANY_IMPORT_API_PATH } from "./routes/company-import-paths.js";
type UiMode = "none" | "static" | "vite-dev";
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
@@ -83,6 +83,12 @@ const VITE_DEV_STATIC_PATHS = new Set([
"/sw.js",
]);
export function isDatabaseConnectionUnavailableError(err: unknown): boolean {
const error = err as { code?: unknown; message?: unknown; cause?: unknown };
if (error?.code === "ECONNREFUSED") return true;
return Boolean(error?.cause && isDatabaseConnectionUnavailableError(error.cause));
}
export function resolveViteHmrPort(serverPort: number): number {
if (serverPort <= 55_535) {
return serverPort + 10_000;
@@ -138,13 +144,17 @@ export async function createApp(
},
) {
const app = express();
const captureRawBody = (req: express.Request, _res: express.Response, buf: Buffer) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
};
app.use(express.json({
// Company import/export payloads can inline full portable packages.
app.use(COMPANY_IMPORT_API_PATH, express.json({
limit: PORTABLE_JSON_BODY_LIMIT,
verify: (req, _res, buf) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
},
verify: captureRawBody,
}));
app.use(express.json({
limit: DEFAULT_JSON_BODY_LIMIT,
verify: captureRawBody,
}));
app.use(httpLogger);
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
@@ -213,7 +223,6 @@ export async function createApp(
api.use(sidebarPreferenceRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
api.use(cloudUpstreamRoutes(db, { instanceId: opts.instanceId }));
if (opts.databaseBackupService) {
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
}
@@ -407,18 +416,37 @@ export async function createApp(
jobCoordinator.start();
scheduler.start();
const feedbackExportTimer = opts.feedbackExportService
let feedbackExportShuttingDown = false;
let feedbackExportTimer: ReturnType<typeof setInterval> | null = null;
const disableFeedbackExportFlushes = () => {
feedbackExportShuttingDown = true;
if (feedbackExportTimer) {
clearInterval(feedbackExportTimer);
feedbackExportTimer = null;
}
};
const flushPendingFeedbackExports = async () => {
if (feedbackExportShuttingDown) return;
try {
await opts.feedbackExportService?.flushPendingFeedbackTraces();
} catch (err) {
if (isDatabaseConnectionUnavailableError(err)) {
disableFeedbackExportFlushes();
logger.warn({ err }, "Disabling pending feedback export flushes because the database is unavailable");
return;
}
logger.error({ err }, "Failed to flush pending feedback exports");
}
};
feedbackExportTimer = opts.feedbackExportService
? setInterval(() => {
void opts.feedbackExportService?.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
void flushPendingFeedbackExports();
}, FEEDBACK_EXPORT_FLUSH_INTERVAL_MS)
: null;
feedbackExportTimer?.unref?.();
if (opts.feedbackExportService) {
void opts.feedbackExportService.flushPendingFeedbackTraces().catch((err) => {
logger.error({ err }, "Failed to flush pending feedback exports");
});
void flushPendingFeedbackExports();
}
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
@@ -437,13 +465,19 @@ export async function createApp(
}).catch((err) => {
logger.error({ err }, "Failed to load ready plugins on startup");
});
process.once("exit", () => {
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
let appServicesShutdown = false;
const shutdownAppServices = () => {
if (appServicesShutdown) return;
appServicesShutdown = true;
disableFeedbackExportFlushes();
devWatcher?.close();
viteHtmlRenderer?.dispose();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();
});
};
app.locals.paperclipShutdown = shutdownAppServices;
process.once("exit", shutdownAppServices);
process.once("beforeExit", () => {
void flushPluginLogBuffer();
});
+1
View File
@@ -1,2 +1,3 @@
export const DEFAULT_JSON_BODY_LIMIT = "10mb";
export const PORTABLE_JSON_BODY_LIMIT = "64mb";
export const PORTABLE_JSON_BODY_LIMIT_BYTES = 64 * 1024 * 1024;
+3
View File
@@ -904,6 +904,9 @@ export async function startServer(): Promise<StartedServer> {
await telemetryClient.flush();
}
const appShutdown = (app as { locals?: { paperclipShutdown?: () => void } }).locals?.paperclipShutdown;
appShutdown?.();
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
logger.info({ signal }, "Stopping embedded PostgreSQL");
try {
+2 -1
View File
@@ -3,6 +3,7 @@ import { ZodError } from "zod";
import { HttpError } from "../errors.js";
import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import { COMPANY_IMPORT_API_PATH } from "../routes/company-import-paths.js";
export interface ErrorContext {
error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown };
@@ -83,5 +84,5 @@ export function errorHandler(
function shouldExposeTrustedCloudTenantImportError(req: Request) {
return req.actor?.source === "cloud_tenant"
&& req.method === "POST"
&& req.originalUrl.split("?")[0] === "/api/companies/import";
&& req.originalUrl.split("?")[0] === COMPANY_IMPORT_API_PATH;
}
+2 -1
View File
@@ -26,6 +26,7 @@ import {
} from "../services/index.js";
import type { StorageService } from "../storage/types.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js";
export function companyRoutes(db: Db, storage?: StorageService) {
const router = Router();
@@ -188,7 +189,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.json(importJobResponse(job));
});
router.post("/import", async (req, res) => {
router.post(COMPANY_IMPORT_ROUTE_PATH, async (req, res) => {
assertBoard(req);
const rawImportBody: unknown = req.body;
const actor = getActorInfo(req);
@@ -0,0 +1,2 @@
export const COMPANY_IMPORT_ROUTE_PATH = "/import";
export const COMPANY_IMPORT_API_PATH = `/api/companies${COMPANY_IMPORT_ROUTE_PATH}`;
+22 -17
View File
@@ -912,11 +912,11 @@ export function issueRoutes(
});
return cancelledRunId;
} catch (err) {
logger.warn(
logger.error(
{ err, issueId: input.issue.id, runId: scheduledRetryRunId },
"failed to cancel scheduled retry superseded by issue comment",
);
return null;
throw err;
}
}
@@ -3534,6 +3534,18 @@ export function issueRoutes(
) {
updateFields.status = "todo";
}
let cancelledScheduledRetryRunId: string | null = null;
if (
commentBody &&
shouldResumeInProgressScheduledRetry &&
updateFields.status === "todo"
) {
cancelledScheduledRetryRunId = await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue: existing,
actor,
});
}
if (req.body.executionPolicy !== undefined) {
updateFields.executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
@@ -3792,13 +3804,6 @@ export function issueRoutes(
previous.status !== undefined &&
existing.status === "in_progress" &&
issue.status === "todo";
const cancelledScheduledRetryRunId = scheduledRetrySupersededByComment
? await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue,
actor,
})
: null;
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
@@ -5125,22 +5130,22 @@ export function issueRoutes(
effectiveMoveToTodoRequested &&
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry)
) {
scheduledRetrySupersededByComment = shouldResumeInProgressScheduledRetry && issue.status === "in_progress";
cancelledScheduledRetryRunId = scheduledRetrySupersededByComment
? await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue,
actor,
})
: null;
const reopenedIssue = await svc.update(id, { status: "todo" });
if (!reopenedIssue) {
res.status(404).json({ error: "Issue not found" });
return;
}
reopened = isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers);
scheduledRetrySupersededByComment = shouldResumeInProgressScheduledRetry && issue.status === "in_progress";
reopenFromStatus = reopened ? issue.status : null;
currentIssue = reopenedIssue;
cancelledScheduledRetryRunId = scheduledRetrySupersededByComment
? await cancelScheduledRetrySupersededByComment({
scheduledRetryRunId: scheduledRetryForHumanComment?.runId,
issue: currentIssue,
actor,
})
: null;
await logActivity(db, {
companyId: currentIssue.companyId,
@@ -1,5 +1,5 @@
import { isDeepStrictEqual } from "node:util";
import { and, asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray, isNotNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
documents,
@@ -159,7 +159,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
}
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) {
return interaction.payload.supersedeOnUserComment !== false;
return interaction.payload.supersedeOnUserComment === true;
}
function isCommentAtOrAfterInteraction(args: {
@@ -1059,45 +1059,64 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueComments.companyId, issue.companyId),
eq(issueComments.issueId, issue.id),
isNotNull(issueComments.authorUserId),
))
.orderBy(asc(issueComments.createdAt)),
]);
const userComments = comments.filter((comment) => comment.authorUserId);
if (rows.length === 0 || userComments.length === 0) return [];
if (rows.length === 0 || comments.length === 0) return [];
const now = new Date();
const expired: IssueThreadInteraction[] = [];
const supersededByComment = new Map<
string,
{
comment: (typeof comments)[number];
rowIds: string[];
}
>();
for (const row of rows) {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
const supersedingComment = userComments.find((comment) => isCommentAtOrAfterInteraction({
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
commentCreatedAt: comment.createdAt,
interactionCreatedAt: row.createdAt,
}));
if (!supersedingComment) continue;
const [updated] = await db
const group = supersededByComment.get(supersedingComment.id);
if (group) {
group.rowIds.push(row.id);
} else {
supersededByComment.set(supersedingComment.id, {
comment: supersedingComment,
rowIds: [row.id],
});
}
}
for (const { comment, rowIds } of supersededByComment.values()) {
const updatedRows = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: supersedingComment.id,
commentId: comment.id,
},
resolvedByAgentId: null,
resolvedByUserId: supersedingComment.authorUserId,
resolvedByUserId: comment.authorUserId,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, row.id),
inArray(issueThreadInteractions.id, rowIds),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (updated) expired.push(hydrateInteraction(updated));
expired.push(...updatedRows.map(hydrateInteraction));
}
if (expired.length > 0) {
+2 -3
View File
@@ -810,11 +810,10 @@ export function pluginLifecycleManager(
"plugin lifecycle: restarting worker (runtime services unavailable; skipping migration re-apply)",
);
await handle.restart();
emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey });
emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey });
}
emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey });
emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey });
log.info(
{ pluginId, pluginKey: plugin.pluginKey },
"plugin lifecycle: plugin reloaded",
+3 -1
View File
@@ -653,7 +653,9 @@ export function createPluginWorkerHandle(
// Handle process errors (e.g. spawn failure)
child.on("error", (err) => {
log.error({ err: err.message }, "worker process error");
emitter.emit("error", { pluginId, error: err });
if (emitter.listenerCount("error") > 0) {
emitter.emit("error", { pluginId, error: err });
}
if (status === "starting") {
setStatus("crashed");
rejectAllPending(
@@ -47,6 +47,7 @@ afterEach(() => {
container?.remove();
container = null;
vi.restoreAllMocks();
vi.useRealTimers();
mockHealthApi.requestDevServerRestart.mockReset();
});
@@ -88,4 +89,25 @@ describe("DevRestartBanner", () => {
expect(mockHealthApi.requestDevServerRestart).not.toHaveBeenCalled();
});
it("re-enables the manual restart action when a request does not refresh the page", async () => {
vi.useFakeTimers();
const node = render();
const button = [...node.querySelectorAll("button")]
.find((entry) => entry.textContent?.includes("Restart now")) as HTMLButtonElement | undefined;
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(button?.disabled).toBe(true);
expect(node.textContent).toContain("Restart requested");
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(button?.disabled).toBe(false);
expect(node.textContent).toContain("Restart now");
});
});
+11 -1
View File
@@ -1,7 +1,9 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
import { healthApi, type DevServerHealthStatus } from "../api/health";
const RESTART_PENDING_RESET_MS = 30_000;
function formatRelativeTimestamp(value: string | null): string | null {
if (!value) return null;
const timestamp = new Date(value).getTime();
@@ -29,6 +31,14 @@ function describeReason(devServer: DevServerHealthStatus): string {
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
const [restartPending, setRestartPending] = useState(false);
useEffect(() => {
if (!restartPending) return;
const timeout = window.setTimeout(() => {
setRestartPending(false);
}, RESTART_PENDING_RESET_MS);
return () => window.clearTimeout(timeout);
}, [restartPending]);
if (!devServer?.enabled || !devServer.restartRequired) return null;
const currentDevServer = devServer;
+20
View File
@@ -814,6 +814,26 @@ describe("MarkdownEditor", () => {
});
});
it("caps rendered mention matches while keeping the menu scrollable", async () => {
const handleChange = vi.fn();
const mentions = Array.from({ length: 60 }, (_, index) => ({
id: `project:project-${index}`,
kind: "project" as const,
name: `Paperclip App ${index}`,
projectId: `project-${index}`,
projectColor: "#336699",
}));
const { menu, root } = await openMentionMenuFor(handleChange, mentions);
const options = Array.from(menu.querySelectorAll('button[type="button"]'));
expect(options).toHaveLength(50);
expect(menu.className).toContain("overflow-y-auto");
await act(async () => {
root.unmount();
});
});
it("scrolls the active mention option into view during keyboard navigation", async () => {
const handleChange = vi.fn();
const scrollIntoView = vi.fn();
+6 -2
View File
@@ -212,6 +212,7 @@ const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8;
const MENTION_MENU_ROW_HEIGHT = 34;
const MENTION_MENU_CHROME_HEIGHT = 8;
const MAX_AUTOCOMPLETE_OPTIONS = 50;
/** Roughly one space-width of breathing room between the caret and the menu. */
const MENTION_MENU_CARET_GAP = 10;
@@ -648,10 +649,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
.filter((command) => {
if (!q) return true;
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
});
})
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
}
if (!mentions) return [];
return mentions.filter((m) => m.name.toLowerCase().includes(q));
return mentions
.filter((m) => m.name.toLowerCase().includes(q))
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
}, [mentionState, mentions, slashCommands]);
useImperativeHandle(forwardedRef, () => ({
-32
View File
@@ -295,38 +295,6 @@ describe("Inbox toolbar", () => {
});
});
it("hides workspace grouping when isolated workspaces are disabled", async () => {
routerMock.location.pathname = "/inbox/mine";
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
const groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
expect(groupButton).not.toBeNull();
await act(async () => {
groupButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const groupOptions = Array.from(document.body.querySelectorAll("button")).map((button) => button.textContent);
expect(groupOptions).not.toContain("Workspace");
act(() => {
root.unmount();
});
});
it("syncs hover with j/k selection on inbox rows", async () => {
routerMock.location.pathname = "/inbox/mine";
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
+16
View File
@@ -22,6 +22,8 @@ import {
storybookProjects,
storybookSecretAccessEvents,
storybookSecretBindings,
storybookSecretProviderConfigs,
storybookSecretProviderDiscoveryPreview,
storybookSecretProviderHealth,
storybookSecretProviders,
storybookSecrets,
@@ -187,6 +189,20 @@ function installStorybookApiFixtures() {
return Response.json(storybookSecretProviderHealth);
}
const secretProviderConfigsMatch = url.pathname.match(
/^\/api\/companies\/([^/]+)\/secret-provider-configs$/,
);
if (secretProviderConfigsMatch) {
return Response.json(storybookSecretProviderConfigs);
}
const secretProviderConfigDiscoveryPreviewMatch = url.pathname.match(
/^\/api\/companies\/([^/]+)\/secret-provider-configs\/discovery\/preview$/,
);
if (secretProviderConfigDiscoveryPreviewMatch && init?.method?.toUpperCase() === "POST") {
return Response.json(storybookSecretProviderDiscoveryPreview);
}
const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/);
if (secretUsageMatch) {
const [, secretId] = secretUsageMatch;
+94
View File
@@ -7,6 +7,7 @@ import type {
Company,
CompanySecret,
CompanySecretBinding,
CompanySecretProviderConfig,
DashboardSummary,
ExecutionWorkspace,
Goal,
@@ -15,6 +16,7 @@ import type {
IssueLabel,
Project,
SecretAccessEvent,
SecretProviderConfigDiscoveryPreviewResult,
SecretProviderDescriptor,
SidebarBadges,
WorkspaceRuntimeService,
@@ -1324,6 +1326,98 @@ export const storybookSecretProviders: SecretProviderDescriptor[] = [
{ id: "vault", label: "HashiCorp Vault", requiresExternalRef: false },
];
export const storybookSecretProviderConfigs: CompanySecretProviderConfig[] = [
{
id: "provider-config-local",
companyId: "company-storybook",
provider: "local_encrypted",
displayName: "Local encrypted default",
status: "ready",
isDefault: true,
config: { backupReminderAcknowledged: true },
healthStatus: "ready",
healthCheckedAt: recent(45),
healthMessage: "Local encrypted provider is healthy.",
healthDetails: null,
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-board",
createdAt: recent(2_400),
updatedAt: recent(45),
},
{
id: "provider-config-aws-prod",
companyId: "company-storybook",
provider: "aws_secrets_manager",
displayName: "AWS production",
status: "warning",
isDefault: false,
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
kmsKeyId: "alias/paperclip-secrets",
ownerTag: "platform",
environmentTag: "production",
},
healthStatus: "warning",
healthCheckedAt: recent(18),
healthMessage: "Connected; KMS key rotation policy not yet enforced.",
healthDetails: {
code: "kms_rotation_policy",
message: "Connected; KMS key rotation policy not yet enforced.",
guidance: ["Enable automatic key rotation before using this vault for production agents."],
},
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-board",
createdAt: recent(1_800),
updatedAt: recent(18),
},
];
export const storybookSecretProviderDiscoveryPreview: SecretProviderConfigDiscoveryPreviewResult = {
provider: "aws_secrets_manager",
nextToken: null,
sampledSecretCount: 6,
skippedForeignPaperclipSampleCount: 1,
warnings: ["Skipped 1 Paperclip-managed AWS secret from a different deployment namespace."],
candidates: [
{
provider: "aws_secrets_manager",
displayName: "AWS production",
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
kmsKeyId: "alias/paperclip-secrets",
ownerTag: "platform",
environmentTag: "production",
},
sampleCount: 5,
samples: [
{
name: "paperclip/prod-use1/company-storybook/openai_api_key",
hasKmsKey: true,
tagKeys: ["paperclip:managed-by", "paperclip:environment", "paperclip:provider-owner"],
},
],
signals: {
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: "alias/paperclip-secrets",
hasKmsKey: true,
sampleCount: 5,
paperclipManagedSampleCount: 5,
skippedForeignPaperclipSampleCount: 1,
},
warnings: [],
},
],
};
export const storybookSecrets: CompanySecret[] = [
{
id: "secret-openai",
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DevRestartBanner } from "@/components/DevRestartBanner";
import type { DevServerHealthStatus } from "@/api/health";
const restartRequired: DevServerHealthStatus = {
enabled: true,
restartRequired: true,
reason: "backend_changes_and_pending_migrations",
lastChangedAt: new Date(Date.now() - 7 * 60_000).toISOString(),
changedPathCount: 4,
changedPathsSample: [
"server/src/routes/health.ts",
"server/src/dev-runner.ts",
"packages/shared/src/api.ts",
],
pendingMigrations: ["0042_dev_server_health.sql"],
autoRestartEnabled: false,
activeRunCount: 0,
waitingForIdle: false,
lastRestartAt: new Date(Date.now() - 45 * 60_000).toISOString(),
};
const restartWaitingForIdle: DevServerHealthStatus = {
...restartRequired,
reason: "backend_changes",
pendingMigrations: [],
autoRestartEnabled: true,
activeRunCount: 2,
waitingForIdle: true,
};
function DevOpsSurfacesStory() {
return (
<div className="space-y-6 p-6">
<section className="overflow-hidden border border-border bg-background">
<div className="border-b border-border px-5 py-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Dev server restart banner
</div>
</div>
<DevRestartBanner devServer={restartRequired} />
</section>
<section className="max-w-[390px] overflow-hidden border border-border bg-background">
<div className="border-b border-border px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Mobile waiting state
</div>
</div>
<DevRestartBanner devServer={restartWaitingForIdle} />
</section>
</div>
);
}
const meta = {
title: "Product/Dev Ops Surfaces",
component: DevOpsSurfacesStory,
parameters: {
docs: {
description: {
component:
"Shows local development recovery surfaces, including the restart-required banner and its manual restart action.",
},
},
},
} satisfies Meta<typeof DevOpsSurfacesStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DevOpsSurfaces: Story = {};
-1
View File
@@ -22,7 +22,6 @@ if (typeof window !== "undefined") {
function StorybookSecretsFixtures({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
// Seed query caches synchronously so children hydrate from cache on first render.
queryClient.setQueryData(queryKeys.companies.all, storybookCompanies);
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
const { selectedCompanyId, setSelectedCompanyId } = useCompany();