Fix import completion UX: inbox flood, false-failure message, stale company list (#10538)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Company Import/Export (#10507, hardened in #10523 and #10531) now
imports a large company end to end via an async job
> - A real 1,418-issue import succeeded, but three rough edges showed up
in that success
> - Imported issues flooded the inbox, a completed import surfaced a
false "failed" message after its in-memory result expired, and the new
company didn't appear in the switcher until a manual refresh
> - This pull request keeps imported issues out of the inbox, treats an
expired-but-completed import as success, and refreshes the company list
on completion
> - The benefit is that a successful import looks and feels successful,
and doesn't bury the user's inbox in historical tasks

## Linked Issues or Issue Description

- Refs #10507 / #10523 / #10531 (Import/Export and its hardening). No
open issue; three post-import bugs described above.

## What Changed

- **Imported issues no longer flood the inbox.** The inbox "mine" tab is
a query: an issue is "touched" if the user authored a comment on it, and
import re-attributes bundled user comments to the importing user — so
every imported issue appeared. Import now seeds a per-user
`issue_inbox_archives` row for each imported issue (via a batched
`issues.archiveImportedInbox`), the exact table the inbox visibility
query excludes. Gated on an actor user id, so agent/system imports and
normal issue creation are untouched; genuine new activity still
resurfaces the issue.
- **A completed import no longer shows a false failure.** The in-memory
job's terminal retention was 5 minutes, so a poll after that 404'd and
the UI showed "failed." Retention is extended to 60 minutes — the real
mitigation for a user who steps away during a long import.
`watchImportJob` additionally treats a *server-confirmed* success whose
full result is no longer retained (a `succeeded` status carrying only
the compact summary — a cloud tenant job, or a board job whose full
in-memory result aged out) as a soft success ("import completed — open
the company"), navigating by the summary's company id. A 404 while the
job is still being watched is *not* treated as success: a running job is
never dropped by the retention sweep, so its disappearance means a
restart mid-import that may not have finished, and it surfaces the
honest "may have restarted while the import ran" error. A first-poll 404
(the id never existed) is likewise a real error.
- **The imported company appears without a refresh.** `onSuccess` now
invalidates the companies/switcher query unconditionally (covering both
the full-result and expired-but-completed paths) and navigates by the
job's company id.

## Verification

- shared/server/ui typechecks clean; 15 UI tests in the touched spec
green, plus the embedded-Postgres import batching and portability-routes
suites.
- New tests: embedded-Postgres test that imported touched issues are
archived for the actor and excluded from the inbox query while a
normally-created issue still appears; job resolvable at the old window+1
and only 404s past 60 min; UI soft success on a server-confirmed
`succeeded` job without a retained full result (no error, list
invalidated, navigates by company id), a running-then-gone job → honest
error (restart mid-import), and a first-poll 404 → error.

## Risks

- Low and import-scoped: the inbox archive only affects imported issues
for the importing user; normal issue creation and non-user
(agent/system) imports are unchanged. Retention extension is a constant;
the async job store remains in-memory by design. A restart mid-import
still 404s and is surfaced honestly as a possible failure (never masked
as success); only a server-confirmed success whose full result has
expired is reported as a soft success.

## Model Used

- Implementation: Claude Fable 5 (`claude-fable-5`, Anthropic). Review
hardening (the confirmed-success narrowing): Claude Opus 4.8
(`claude-opus-4-8`, Anthropic). Both via the Claude Code CLI with
extended thinking + tool use; root-caused against the live import.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-07-30 21:33:02 -07:00 committed by GitHub
parent 9f7565f4ce
commit 075951f6bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 357 additions and 14 deletions

View File

@ -7,17 +7,19 @@ import {
issueAttachments,
issueComments,
issueDocuments,
issueInboxArchives,
issueLabels,
issueRelations,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { companyPortabilityService } from "../services/company-portability.js";
import { issueService } from "../services/issues.js";
import { workProductService } from "../services/work-products.js";
import type { ImportIssueWorkProductRow } from "../services/import-write-types.js";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
@ -194,6 +196,35 @@ function buildSyntheticBundle(options: SyntheticBundleOptions): {
return { rootPath: "batching-bench", files };
}
/**
* Build a bundle whose every task carries a *user*-authored comment. On import,
* user comments are re-attributed to the importing user, so every issue becomes
* "touched by me" the exact shape that used to flood the inbox after an
* import. The inbox seeding is what must keep them hidden.
*/
function buildTouchedIssuesBundle(issueCount: number): {
rootPath: string;
files: Record<string, CompanyPortabilityFileEntry>;
} {
const files: Record<string, CompanyPortabilityFileEntry> = {};
files["COMPANY.md"] = ['---', 'schema: "agentcompanies/v1"', 'name: "Inbox Flood Co"', '---', '', 'Synthetic import bundle.', ''].join("\n");
const tasks: Record<string, unknown> = {};
for (let i = 1; i <= issueCount; i += 1) {
const slug = `task-${String(i).padStart(4, "0")}`;
files[`tasks/${slug}/TASK.md`] = ['---', `name: "Task ${i}"`, "kind: task", '---', '', `Body for task ${i}.`, ''].join("\n");
tasks[slug] = {
status: "todo",
priority: "medium",
comments: [{ body: `A human note on task ${i}.`, authorType: "user" }],
documents: [],
};
}
files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 6, tasks });
return { rootPath: "inbox-flood", files };
}
describeEmbeddedPostgres("company import batches inserts", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db!: ReturnType<typeof createDb>;
@ -270,6 +301,76 @@ describeEmbeddedPostgres("company import batches inserts", () => {
expect(uniqueIdentifiers.size).toBe(issueCount);
});
it("keeps imported issues out of the importing user's inbox", async () => {
const importingUserId = "board-inbox-user";
const bundle = buildTouchedIssuesBundle(3);
const portability = companyPortabilityService(db);
const result = await portability.importBundle(
{
source: { type: "inline", rootPath: bundle.rootPath, files: bundle.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Inbox Flood Co" },
collisionStrategy: "rename",
},
importingUserId,
);
const companyId = result.company.id;
const importedIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.companyId, companyId));
expect(importedIssues.length).toBe(3);
const importedIds = new Set(importedIssues.map((row) => row.id));
// Every imported issue is seeded with a per-user inbox archive, attributed
// to the importing board user (not an agent).
const archives = await db
.select({
issueId: issueInboxArchives.issueId,
archivedByActorType: issueInboxArchives.archivedByActorType,
})
.from(issueInboxArchives)
.where(and(
eq(issueInboxArchives.companyId, companyId),
eq(issueInboxArchives.userId, importingUserId),
));
expect(new Set(archives.map((row) => row.issueId))).toEqual(importedIds);
expect(archives.every((row) => row.archivedByActorType === "user")).toBe(true);
const issuesSvc = issueService(db);
// Sanity: the imported issues really are "touched by me" (their user comment
// was re-attributed to the importing user), so without the archive filter
// they would all flood the inbox.
const touched = await issuesSvc.list(companyId, { touchedByUserId: importingUserId });
expect(new Set(touched.map((issue) => issue.id))).toEqual(importedIds);
// The inbox "mine" query (touched AND not inbox-archived) returns none of
// them — the seeding is load-bearing.
const mineInbox = await issuesSvc.list(companyId, {
touchedByUserId: importingUserId,
inboxArchivedByUserId: importingUserId,
});
expect(mineInbox.filter((issue) => importedIds.has(issue.id))).toEqual([]);
// Normal (non-import) creation is unaffected: an issue the same user creates
// after the import is not archived and shows up in their inbox.
const fresh = await issuesSvc.create(companyId, {
title: "Freshly created work",
status: "todo",
priority: "medium",
createdByUserId: importingUserId,
});
const mineAfterCreate = await issuesSvc.list(companyId, {
touchedByUserId: importingUserId,
inboxArchivedByUserId: importingUserId,
});
expect(mineAfterCreate.map((issue) => issue.id)).toContain(fresh.id);
expect(mineAfterCreate.filter((issue) => importedIds.has(issue.id))).toEqual([]);
});
it("rolls back the whole work-product batch when a later chunk fails", async () => {
// Seed a real company + issue to hang work products off of.
const bundle = buildSyntheticBundle({ issueCount: 1, commentsPerIssue: 0, documentsPerIssue: 0 });

View File

@ -790,7 +790,23 @@ describe.sequential("company portability routes", () => {
}),
}));
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(succeeded.body.job.completedAt) + (5 * 60 * 1000) + 1);
const completedAtMs = Date.parse(succeeded.body.job.completedAt);
// A completed job stays resolvable well past the old 5-minute retention so a
// user who steps away during a long import never polls into a false 404.
const withinWindow = vi.spyOn(Date, "now").mockReturnValue(completedAtMs + (5 * 60 * 1000) + 1);
try {
const stillThere = await request(app).get(accepted.body.statusUrl).set(cloudHeaders);
expect(stillThere.status).toBe(200);
expect(stillThere.body.job.status).toBe("succeeded");
expect(stillThere.body.job.result.companyId).toBe(companyId);
} finally {
withinWindow.mockRestore();
}
// Only past the extended 60-minute window is the in-memory record finally
// dropped, and only then does the status route report the job as gone.
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(completedAtMs + (60 * 60 * 1000) + 1);
try {
const expired = await request(app).get(accepted.body.statusUrl).set(cloudHeaders);
expect(expired.status).toBe(404);

View File

@ -48,6 +48,7 @@ const issueSvc = {
listAttachments: vi.fn(),
createAttachment: vi.fn(),
importIssues: vi.fn(),
archiveImportedInbox: vi.fn(),
addImportedComments: vi.fn(),
addImportedAttachments: vi.fn(),
};

View File

@ -191,7 +191,14 @@ export function companyRoutes(db: Db, storage?: StorageService) {
const artifacts = companyArtifactsService(db, storage);
const feedback = feedbackService(db);
const importJobs = new Map<string, ImportJobRecord>();
const importJobTerminalRetentionMs = 5 * 60 * 1000;
// Terminal jobs are retained in memory for an hour after they settle. A long
// import can outlast a user stepping away, and dropping the completion after
// only a few minutes made a later poll 404 — surfacing a finished, fully
// written import as a scary failure. An hour is long enough to cover a real
// "walk away and come back" gap while keeping the map bounded, and it needs
// no new persistence (jobs remain in-memory-only; a restart still 404s, which
// the client now treats as a soft success once it has seen the job running).
const importJobTerminalRetentionMs = 60 * 60 * 1000;
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";

View File

@ -6041,6 +6041,19 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
// handful per table. Empty buffers are skipped so an issues-free import
// (e.g. routines only) issues no writes at all.
if (issueRows.length > 0) await issues.importIssues(targetCompany.id, issueRows);
// Imported issues are historical work, not new inbox items. Seed a
// per-user inbox archive for the importing board user so a large import
// (a real 1,418-task company shipped every task to the inbox) does not
// flood it: the inbox "mine" query hides archived issues, and genuine
// new activity still resurfaces them. Agent/system imports (no board
// user) have no inbox to protect, so the seeding is skipped.
if (actorUserId && issueRows.length > 0) {
await issues.archiveImportedInbox(
targetCompany.id,
issueRows.map((row) => row.id),
actorUserId,
);
}
if (commentRows.length > 0) await issues.addImportedComments(commentRows);
if (documentRows.length > 0) await documentsSvc.createIssueDocumentsForImport(documentRows);
if (workProductRows.length > 0) await workProductsSvc.createManyForImport(workProductRows);

View File

@ -5220,6 +5220,40 @@ export function issueService(db: Db) {
return row;
},
/**
* Seed inbox archives for a batch of freshly imported issues so a company
* import does not flood the importing user's inbox. Imported issues are
* historical work, not new inbox items, but the inbox "mine" query surfaces
* every touched-and-not-archived issue; a 1000-task import would otherwise
* bury the inbox. Seeding a per-user inbox archive keeps them hidden via
* `inboxVisibleForUserCondition`, while genuine new activity on an imported
* issue still resurfaces it. This runs only on import (mirroring how
* `pauseAutomations` threads an import-only suppression) and never touches
* normal issue creation. Rows carry the same "user"-attributed shape a
* manual inbox archive writes, batched to stay under Postgres bind limits.
*/
archiveImportedInbox: async (
companyId: string,
issueIds: string[],
userId: string,
archivedAt: Date = new Date(),
): Promise<void> => {
if (issueIds.length === 0) return;
const now = new Date();
const rows = issueIds.map((issueId) => ({
companyId,
issueId,
userId,
archivedByActorType: "user" as const,
archivedByAgentId: null,
archivedByRunId: null,
archivedAt,
createdAt: now,
updatedAt: now,
}));
await insertRowsInChunks(db, issueInboxArchives, rows);
},
unarchiveInbox: async (companyId: string, issueId: string, userId: string) => {
const [row] = await db
.delete(issueInboxArchives)

View File

@ -46,6 +46,17 @@ export interface CompanyImportJobStatus {
updatedAt?: string;
completedAt?: string;
error?: { message: string };
/**
* Summary retained for every terminal job (board and cloud tenant); carries
* the imported company id so the page can navigate even when the full
* `importResult` is no longer available.
*/
result?: {
companyId: string;
agentCount?: number;
warningCount?: number;
companyAction?: unknown;
};
/** Board-created jobs carry the full result for parity with the sync response. */
importResult?: CompanyPortabilityImportResult;
};

View File

@ -659,6 +659,87 @@ describe("CompanyImport", () => {
expect(container.textContent).toContain("Import complete");
});
it("treats a server-confirmed success without a full result as a soft success and refreshes the company list", async () => {
// The server reports the job `succeeded` but retains only the compact
// summary (a cloud tenant job, or a board job whose full in-memory result
// aged out): status is a confirmed success, `importResult` is gone, and the
// summary still carries the company id. That is a success we can no longer
// fully read — never a scary failure.
mockCompaniesApi.getImportJob.mockResolvedValue({
job: { id: "job-1", status: "succeeded", result: { companyId: "company-2" } },
});
const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries");
try {
await renderPageAndImport();
// Success-leaning panel, not the failure panel.
expect(container.textContent).toContain("Import completed");
expect(container.textContent).toContain("open it to view it");
expect(container.textContent).not.toContain("Import failed");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "success" }));
// The company list is refreshed so the new company appears in the switcher.
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ["companies"] }));
// The summary's company id still drives navigation into the import.
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
} finally {
invalidateSpy.mockRestore();
}
});
it("surfaces a first-poll 404 as an error because the job never existed", async () => {
// A 404 on the very first poll — before the client ever saw the job
// running — means the id never existed. That stays a hard error.
mockCompaniesApi.getImportJob.mockRejectedValue(
new ApiError("Import job not found", 404, { error: "Import job not found" }),
);
await renderPage();
await enterGithubUrl();
await clickButton((text) => text === "Preview import");
await clickButton((text) => text.startsWith("Import 3 file"));
await settle();
expect(container.textContent).toContain("Import failed:");
expect(container.textContent).toContain("it may have restarted while the import ran");
expect(container.textContent).not.toContain("Import completed");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
});
it("surfaces a running-then-gone job as an error because the server restarted mid-import", async () => {
// The first poll finds the job running; the next poll 404s. A running job is
// never dropped by the retention sweep (only settled jobs age out), so its
// disappearance means the server restarted mid-import — the import never
// reached a confirmed success and may not have finished. Report that
// honestly instead of masking a possibly-incomplete import as completed.
mockCompaniesApi.getImportJob
.mockResolvedValueOnce({ job: { id: "job-1", status: "running" } })
.mockRejectedValue(new ApiError("Import job not found", 404, { error: "Import job not found" }));
await renderPage();
await enterGithubUrl();
await clickButton((text) => text === "Preview import");
await clickButton((text) => text.startsWith("Import 3 file"));
await settle();
expect(container.textContent).toContain("Import failed:");
expect(container.textContent).toContain("it may have restarted while the import ran");
expect(container.textContent).not.toContain("Import completed");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
});
it("refreshes the company list on a full import success", async () => {
const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries");
try {
await renderPageAndImport();
expect(container.textContent).toContain("Import complete");
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ["companies"] }));
} finally {
invalidateSpy.mockRestore();
}
});
it("resumes watching a stored import job on mount", async () => {
// A previous page load persisted a running job; reloading must resume
// watching it rather than showing the stale form.

View File

@ -709,17 +709,39 @@ function runningImportJobFromError(err: unknown): CompanyImportJobAccepted | nul
};
}
/** Poll a job to a terminal state; resolves with the import result or throws the job's error. */
/**
* Terminal outcome of watching an import job.
*
* - `completed` carries the full import result and drives the activation path.
* - `completed-expired` is a *server-confirmed* success whose full result we
* can no longer read: the server reported the job `succeeded` but retained
* only its compact summary a cloud tenant job, or a board job whose full
* in-memory result aged out. The company id lets the page still navigate to
* the imported company. This is never a failure: the server confirmed the
* import succeeded before we stopped being able to read the full result.
*/
type CompanyImportWatchOutcome =
| { status: "completed"; result: CompanyPortabilityImportResult }
| { status: "completed-expired"; companyId: string | null };
/** Poll a job to a terminal state; resolves with the outcome or throws the job's error. */
async function watchImportJob(
jobId: string,
storageKey: string,
): Promise<CompanyPortabilityImportResult> {
): Promise<CompanyImportWatchOutcome> {
for (;;) {
let job: Awaited<ReturnType<typeof companiesApi.getImportJob>>["job"] | null = null;
try {
job = (await companiesApi.getImportJob(jobId)).job;
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
// The server has no record of this job. The retention sweep only drops
// jobs that have already *settled* (a running job is never removed), so
// a 404 while we are still watching means the in-memory record was lost
// to a restart mid-import — the import never reached a confirmed
// `succeeded` state and may not have finished. Report that honestly
// rather than masking a possibly-incomplete import as a success; the
// refreshed company list lets the user confirm what actually landed.
clearStoredImportJob(storageKey);
throw new Error(
"The server no longer reports this import job — it may have restarted while the import ran.",
@ -748,9 +770,15 @@ async function watchImportJob(
if (job?.status === "succeeded") {
clearStoredImportJob(storageKey);
if (!job.importResult) {
throw new Error("The import finished, but its result is no longer available.");
// The server confirmed success but retained only the compact summary
// (a cloud tenant job, or a board job whose full result aged out of
// memory). Still a success — navigate by the summary's company id.
return {
status: "completed-expired",
companyId: job.result?.companyId ?? null,
};
}
return job.importResult;
return { status: "completed", result: job.importResult };
}
if (job?.status === "failed") {
clearStoredImportJob(storageKey);
@ -813,11 +841,16 @@ export function CompanyImport() {
// Post-import success / activation state
const [pauseAutomations, setPauseAutomations] = useState(true);
const [importOutcome, setImportOutcome] = useState<{
result: CompanyPortabilityImportResult;
dashboardPath: string;
pausedAutomations: boolean;
} | null>(null);
const [importOutcome, setImportOutcome] = useState<
| {
kind: "full";
result: CompanyPortabilityImportResult;
dashboardPath: string;
pausedAutomations: boolean;
}
| { kind: "expired" }
| null
>(null);
const [activationChecked, setActivationChecked] = useState<Set<string>>(new Set());
const [activatedKeys, setActivatedKeys] = useState<Set<string>>(new Set());
const [activationFailures, setActivationFailures] = useState<Record<string, string>>({});
@ -1042,9 +1075,36 @@ export function CompanyImport() {
});
return watchImportJob(accepted.job.id, storageKey);
},
onSuccess: async (result, { previewForImport, pauseAutomations: submittedPauseAutomations }) => {
onSuccess: async (outcome, { previewForImport, pauseAutomations: submittedPauseAutomations }) => {
setResumedWatchJobId(null);
// The company list powers the switcher; refresh it on every success path
// so the imported company appears immediately without a manual reload.
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
if (outcome.status === "completed-expired") {
// The import finished and wrote all its data, but the job's result
// expired (or was never retained) before we could read it. This is a
// success, not a failure: surface it gently and let the refreshed
// switcher carry the user into the new company.
if (outcome.companyId) {
try {
const importedCompany = await companiesApi.get(outcome.companyId);
setSelectedCompanyId(importedCompany.id);
} catch {
// The company id may be unreadable (permissions, race); the
// refreshed company list still surfaces the import.
}
}
setImportOutcome({ kind: "expired" });
pushToast({
tone: "success",
title: "Import completed",
body: "Open the company to view it.",
});
return;
}
const result = outcome.result;
const importedCompany = await companiesApi.get(result.company.id);
const refreshedSession = currentUserId
? null
@ -1063,6 +1123,7 @@ export function CompanyImport() {
setActivatedKeys(new Set());
setActivationFailures({});
setImportOutcome({
kind: "full",
result,
dashboardPath: `/${importedCompany.issuePrefix}/dashboard`,
pausedAutomations: submittedPauseAutomations,
@ -1301,7 +1362,7 @@ export function CompanyImport() {
}
async function handleActivateSelected() {
if (!importOutcome || isActivating) return;
if (!importOutcome || importOutcome.kind !== "full" || isActivating) return;
setIsActivating(true);
const nextActivated = new Set(activatedKeys);
const nextFailures: Record<string, string> = {};
@ -1374,6 +1435,24 @@ export function CompanyImport() {
: null;
const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null;
if (importOutcome && importOutcome.kind === "expired") {
// Soft success: the import finished and wrote all its data, but the job's
// in-memory result expired before we could read it. Never a failure — the
// company list has been refreshed, so the imported company is available
// from the switcher.
return (
<div className="px-5 py-5 space-y-4">
<div>
<h2 className="text-base font-semibold">Import completed</h2>
<p className="text-xs text-muted-foreground mt-1">
The import finished and your company is ready. Its detailed summary is no
longer available, but the company has been added open it to view it.
</p>
</div>
</div>
);
}
if (importOutcome) {
const { result, dashboardPath } = importOutcome;
const activationItems = importOutcome.pausedAutomations ? buildActivationItems(result) : [];