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
No files matched your search
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 733 KiB |
|
After Width: | Height: | Size: 164 KiB |
@@ -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"' }],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 138 KiB |
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, () => ({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -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();
|
||||
|
||||