feat(server): preserve task timestamps and hierarchy through company import/export (#11193)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Company export/import moves a whole company — agents, tasks,
comments — between instances as a portable bundle
> - The bundle never carried task timestamps or parent links: the export
writes neither, the importer lets database defaults stamp "now", and
sub-tasks arrive flattened
> - Boards sort by recency, so every imported task showing "created just
now" collapses the task list into import order, and the task hierarchy
the user built is gone
> - This pull request adds created/updated/started/completed/cancelled
timestamps and a parent link to the bundle (schema v7), preserves them
end to end on import, and keeps comment imports from clobbering a
preserved updated time
> - The benefit is that an imported company reads like the company the
user left: same recency order, same task tree

## Linked Issues or Issue Description

**What happened?**

After a company import, every task showed as created at import time.
Recency sorting collapsed to import order, and parent/child task nesting
disappeared. The user called out losing "the meaningful task hierarchy
and recency sorting". Cause: the export bundle has no fields for task
timestamps or parent links, the importer lets `defaultNow()` win on
insert, and the comment importer bumps every touched task's `updatedAt`
to now.

**Expected behavior**

An imported company preserves each task's
creation/update/start/completion times and its position in the task
tree, so sorting and nesting on the destination match the source.

**Steps to reproduce**

1. On a source instance, create tasks over several days, including
sub-tasks nested under parents.
2. Export the company and import it into another instance.
3. Every task shows the import moment as its creation/update time and
all tasks are top-level.

## What Changed

- Export writes
`createdAt`/`updatedAt`/`startedAt`/`completedAt`/`cancelledAt` (ISO,
only when set) and `parent: <taskSlug>` into each task's bundle
extension; a parent outside the export selection drops the edge with an
aggregate warning, mirroring the existing blocker-edge warning
(`server/src/services/company-portability.ts`).
- Bundle schema version 6 → 7. All new fields are optional: v5/v6
bundles import unchanged with a version-aware downlevel warning; bundles
newer than the board still fail closed.
- Manifest parsing validates the new timestamps like comment timestamps
(invalid → warn and ignore, never a hard failure); shared types and the
zod validator carry the new optional fields.
- Import resolves parent slugs to pre-generated destination ids, drops
self-references and cycles from tampered bundles with warnings, and
orders rows parents-first because the self-referencing FK is checked per
insert chunk.
- `importIssues` writes the preserved timestamps (falling back to insert
time when absent; `startedAt` stays null unless bundle-carried, per
#11191's semantics) and `parentId`.
- `addImportedComments` no longer blanket-bumps `updatedAt = now()`; it
takes `GREATEST(updated_at, newest imported comment createdAt)`, so a
preserved update time never regresses while unpreserved rows keep the
old behavior.

## Verification

- `pnpm vitest run server/src/__tests__/company-portability.test.ts
server/src/__tests__/company-portability-import-batching.test.ts
server/src/__tests__/productivity-review-service.test.ts` — 102 passed,
1 pre-existing opt-in benchmark skip. Includes: full round-trip with
exact timestamp equality and a 3-deep parent chain against embedded
Postgres; v6 back-compat (defaults + warning); forward-compat rejection
(v8); cycle/self-reference/invalid-timestamp tampered-bundle handling;
comment-bump preserve-awareness in both directions.
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
@paperclipai/shared typecheck` — clean.

## Risks

- **Rollout ordering**: a board on the previous build (max schema v6)
refuses bundles exported by this build (stamped v7) — the existing
newer-than-supported rejection, working as designed. Cross-instance
moves need the importing board upgraded first. Called out here so
operators aren't surprised during the transition window.
- Parent edges from tampered bundles are dropped with warnings rather
than failing the import; blocker relations already behave this way.
- Timestamps are data-only; no destination schema migration.

Stacked on #11191 (its commit is included here) — merge #11191 first;
this PR then shows only the v7 changes.

## Model Used

- Claude Fable 5 (`claude-fable-5`) via Claude Code CLI, with extended
thinking and tool use (multi-agent implementation with independent
verification).

## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [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-08-10 17:01:33 -07:00 committed by GitHub
parent 4c062a0eb2
commit 35aaaa0bd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 543 additions and 19 deletions

View File

@ -200,6 +200,14 @@ export interface CompanyPortabilityIssueManifestEntry {
workProducts?: CompanyPortabilityIssueWorkProductManifestEntry[];
monitor?: CompanyPortabilityIssueMonitorManifestEntry | null;
attachments?: CompanyPortabilityIssueAttachmentManifestEntry[];
/** Slug of the parent task when it is part of the same bundle (schemaVersion >= 7). */
parentSlug?: string | null;
/** Preserved source timestamps as ISO strings (schemaVersion >= 7); absent in older bundles. */
createdAt?: string | null;
updatedAt?: string | null;
startedAt?: string | null;
completedAt?: string | null;
cancelledAt?: string | null;
metadata: Record<string, unknown> | null;
}

View File

@ -222,6 +222,12 @@ export const portabilityIssueManifestEntrySchema = z.object({
workProducts: z.array(portabilityIssueWorkProductManifestEntrySchema).default([]),
monitor: portabilityIssueMonitorManifestEntrySchema.nullable().default(null),
attachments: z.array(portabilityIssueAttachmentManifestEntrySchema).default([]),
parentSlug: z.string().min(1).nullable().optional(),
createdAt: z.string().datetime().nullable().optional(),
updatedAt: z.string().datetime().nullable().optional(),
startedAt: z.string().datetime().nullable().optional(),
completedAt: z.string().datetime().nullable().optional(),
cancelledAt: z.string().datetime().nullable().optional(),
metadata: z.record(z.string(), z.unknown()).nullable(),
});

View File

@ -157,7 +157,7 @@ interface SyntheticBundleOptions {
documentsPerIssue: number;
}
/** Build an inline import bundle with the requested shape, valid at schemaVersion 6. */
/** Build an inline import bundle with the requested shape, valid at schemaVersion 7. */
function buildSyntheticBundle(options: SyntheticBundleOptions): {
rootPath: string;
files: Record<string, CompanyPortabilityFileEntry>;
@ -193,7 +193,7 @@ function buildSyntheticBundle(options: SyntheticBundleOptions): {
};
}
files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 6, tasks });
files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 7, tasks });
return { rootPath: "batching-bench", files };
}
@ -223,7 +223,7 @@ function buildTouchedIssuesBundle(issueCount: number): {
};
}
files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 6, tasks });
files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 7, tasks });
return { rootPath: "inbox-flood", files };
}
@ -423,6 +423,152 @@ describeEmbeddedPostgres("company import batches inserts", () => {
expect(imported?.startedAt).toBeNull();
});
it("preserves bundle timestamps and parent hierarchy through a real import", async () => {
const files: Record<string, CompanyPortabilityFileEntry> = {};
files["COMPANY.md"] = ['---', 'schema: "agentcompanies/v1"', 'name: "Timestamp Fidelity Co"', '---', '', 'Synthetic import bundle.', ''].join("\n");
const taskFile = (name: string) => ['---', `name: "${name}"`, "kind: task", '---', '', `${name} body.`, ''].join("\n");
files["tasks/a-child/TASK.md"] = taskFile("Child");
files["tasks/m-grandchild/TASK.md"] = taskFile("Grandchild");
files["tasks/z-parent/TASK.md"] = taskFile("Parent");
files[".paperclip.yaml"] = renderPaperclipYaml({
schemaVersion: 7,
tasks: {
// The child slug sorts before its parent so the importer's
// parents-first row ordering (not manifest order) must satisfy the
// parent foreign key.
"a-child": {
status: "todo",
priority: "medium",
parent: "z-parent",
createdAt: "2026-01-05T00:00:00.000Z",
updatedAt: "2026-03-05T00:00:00.000Z",
comments: [{
body: "Older than the preserved updatedAt.",
authorType: "system",
createdAt: "2026-02-01T00:00:00.000Z",
}],
},
"m-grandchild": {
status: "todo",
priority: "medium",
parent: "a-child",
createdAt: "2026-01-06T00:00:00.000Z",
updatedAt: "2026-01-07T00:00:00.000Z",
},
"z-parent": {
status: "done",
priority: "medium",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
completedAt: "2026-02-20T00:00:00.000Z",
comments: [{
body: "Newer than the preserved updatedAt.",
authorType: "system",
createdAt: "2026-04-01T00:00:00.000Z",
}],
},
},
});
const portability = companyPortabilityService(db);
const result = await portability.importBundle(
{
source: { type: "inline", rootPath: "timestamp-fidelity", files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Timestamp Fidelity Co" },
collisionStrategy: "rename",
},
"user-timestamp-fidelity",
);
expect(result.warnings).toEqual([]);
const rows = await db
.select({
id: issues.id,
title: issues.title,
parentId: issues.parentId,
createdAt: issues.createdAt,
updatedAt: issues.updatedAt,
startedAt: issues.startedAt,
completedAt: issues.completedAt,
})
.from(issues)
.where(eq(issues.companyId, result.company.id));
const parent = rows.find((row) => row.title === "Parent")!;
const child = rows.find((row) => row.title === "Child")!;
const grandchild = rows.find((row) => row.title === "Grandchild")!;
// The parent chain of three lands intact.
expect(parent.parentId).toBeNull();
expect(child.parentId).toBe(parent.id);
expect(grandchild.parentId).toBe(child.id);
expect(parent.createdAt).toEqual(new Date("2026-01-01T00:00:00.000Z"));
expect(parent.completedAt).toEqual(new Date("2026-02-20T00:00:00.000Z"));
expect(parent.startedAt).toBeNull();
expect(child.createdAt).toEqual(new Date("2026-01-05T00:00:00.000Z"));
expect(grandchild.createdAt).toEqual(new Date("2026-01-06T00:00:00.000Z"));
expect(grandchild.updatedAt).toEqual(new Date("2026-01-07T00:00:00.000Z"));
// A comment older than the preserved updatedAt must not regress it...
expect(child.updatedAt).toEqual(new Date("2026-03-05T00:00:00.000Z"));
// ...while a newer imported comment still bumps recency forward.
expect(parent.updatedAt).toEqual(new Date("2026-04-01T00:00:00.000Z"));
});
it("imports a v6 bundle without the timestamp fields using import-time defaults", async () => {
// A little slack absorbs clock drift between this process and Postgres.
const importStart = Date.now() - 1_000;
const files: Record<string, CompanyPortabilityFileEntry> = {};
files["COMPANY.md"] = ['---', 'schema: "agentcompanies/v1"', 'name: "Legacy Shape Co"', '---', '', 'Synthetic import bundle.', ''].join("\n");
files["tasks/legacy-task/TASK.md"] = ['---', 'name: "Legacy task"', "kind: task", '---', '', 'Legacy body.', ''].join("\n");
files[".paperclip.yaml"] = renderPaperclipYaml({
schemaVersion: 6,
tasks: {
"legacy-task": {
status: "todo",
priority: "medium",
comments: [{
body: "Predates the import.",
authorType: "system",
createdAt: "2026-01-01T00:00:00.000Z",
}],
},
},
});
const portability = companyPortabilityService(db);
const result = await portability.importBundle(
{
source: { type: "inline", rootPath: "legacy-shape", files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Legacy Shape Co" },
collisionStrategy: "rename",
},
"user-legacy-shape",
);
expect(result.warnings).toContain(
"This package declares schemaVersion 6 and predates task timestamp and parent link transfer; that task data imports only if the bundle carries it.",
);
const [row] = await db
.select({
parentId: issues.parentId,
createdAt: issues.createdAt,
updatedAt: issues.updatedAt,
startedAt: issues.startedAt,
})
.from(issues)
.where(eq(issues.companyId, result.company.id));
expect(row?.parentId).toBeNull();
expect(row?.startedAt).toBeNull();
// Without preserved timestamps the row keeps the old behavior: created and
// updated at import time, and the comment bump does not drag updatedAt
// back to the older comment createdAt.
expect(row!.createdAt.getTime()).toBeGreaterThanOrEqual(importStart);
expect(row!.updatedAt.getTime()).toBeGreaterThanOrEqual(importStart);
});
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

@ -3579,8 +3579,8 @@ describe("company portability", () => {
expect(extension).not.toContain("labelIds");
expect(extension).not.toContain("label-a");
// Fresh exports declare the current bundle shape end-to-end.
expect(extension).toContain("schemaVersion: 6");
expect(exported.manifest.schemaVersion).toBe(6);
expect(extension).toContain("schemaVersion: 7");
expect(exported.manifest.schemaVersion).toBe(7);
expect(exported.manifest.labels).toEqual([
{ name: "bug", color: "#ff0000" },
{ name: "urgent", color: "#00ff00" },
@ -4030,6 +4030,207 @@ describe("company portability", () => {
);
});
function mockTaskHierarchyExportSources() {
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
const baseIssue = {
description: null,
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
};
issueSvc.list.mockResolvedValue([
{
...baseIssue,
id: "issue-1",
identifier: "PAP-1",
title: "Alpha task",
status: "done",
parentId: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
startedAt: new Date("2026-01-02T00:00:00.000Z"),
completedAt: new Date("2026-02-01T00:00:00.000Z"),
},
{
...baseIssue,
id: "issue-2",
identifier: "PAP-2",
title: "Beta task",
status: "todo",
parentId: "issue-1",
createdAt: new Date("2026-01-05T00:00:00.000Z"),
updatedAt: new Date("2026-01-06T00:00:00.000Z"),
},
{
...baseIssue,
id: "issue-3",
identifier: "PAP-3",
title: "Gamma task",
status: "todo",
parentId: "issue-2",
createdAt: new Date("2026-01-07T00:00:00.000Z"),
updatedAt: new Date("2026-01-08T00:00:00.000Z"),
},
{
...baseIssue,
id: "issue-4",
identifier: "PAP-4",
title: "Delta task",
status: "todo",
parentId: "issue-outside",
},
]);
}
it("carries task timestamps and parent links through export and import", async () => {
const { db } = fakeImportDb();
const portability = companyPortabilityService(db);
mockTaskHierarchyExportSources();
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("schemaVersion: 7");
expect(extension).toContain('parent: "pap-1"');
expect(extension).toContain('createdAt: "2026-01-01T00:00:00.000Z"');
expect(exported.warnings).toContain(
"1 parent relation references a task outside this export and was not included.",
);
const alphaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-1");
const betaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-2");
const gammaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-3");
const deltaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-4");
expect(alphaEntry).toEqual(expect.objectContaining({
parentSlug: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
startedAt: "2026-01-02T00:00:00.000Z",
completedAt: "2026-02-01T00:00:00.000Z",
cancelledAt: null,
}));
expect(betaEntry?.parentSlug).toBe("pap-1");
expect(gammaEntry?.parentSlug).toBe("pap-2");
// The unexported parent edge is dropped, not carried by raw id.
expect(deltaEntry?.parentSlug).toBeNull();
expect(extension).not.toContain("issue-outside");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
const importedRows = issueSvc.importIssues.mock.calls[0]![1] as Array<{
id: string;
title: string;
parentId: string | null;
createdAt: Date | null;
updatedAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
cancelledAt: Date | null;
}>;
const alphaRow = importedRows.find((row) => row.title === "Alpha task")!;
const betaRow = importedRows.find((row) => row.title === "Beta task")!;
const gammaRow = importedRows.find((row) => row.title === "Gamma task")!;
const deltaRow = importedRows.find((row) => row.title === "Delta task")!;
// Timestamps survive the round trip as concrete dates.
expect(alphaRow.createdAt).toEqual(new Date("2026-01-01T00:00:00.000Z"));
expect(alphaRow.updatedAt).toEqual(new Date("2026-03-01T00:00:00.000Z"));
expect(alphaRow.startedAt).toEqual(new Date("2026-01-02T00:00:00.000Z"));
expect(alphaRow.completedAt).toEqual(new Date("2026-02-01T00:00:00.000Z"));
expect(alphaRow.cancelledAt).toBeNull();
expect(betaRow.createdAt).toEqual(new Date("2026-01-05T00:00:00.000Z"));
// The parent chain of three is intact against the pre-generated ids.
expect(alphaRow.parentId).toBeNull();
expect(betaRow.parentId).toBe(alphaRow.id);
expect(gammaRow.parentId).toBe(betaRow.id);
expect(deltaRow.parentId).toBeNull();
// Rows arrive parents-first so chunked inserts satisfy the parent FK.
expect(importedRows.indexOf(alphaRow)).toBeLessThan(importedRows.indexOf(betaRow));
expect(importedRows.indexOf(betaRow)).toBeLessThan(importedRows.indexOf(gammaRow));
});
it("drops self-referencing and cyclic parent links from a hand-built bundle with warnings", async () => {
const { db } = fakeImportDb();
const portability = companyPortabilityService(db);
const taskFile = (name: string) => ["---", `name: "${name}"`, "kind: task", "---", "", `${name} body.`, ""].join("\n");
const files = {
"COMPANY.md": ["---", 'schema: "agentcompanies/v1"', 'name: "Tampered Import"', "---", ""].join("\n"),
"tasks/task-a/TASK.md": taskFile("Task A"),
"tasks/task-b/TASK.md": taskFile("Task B"),
"tasks/task-c/TASK.md": taskFile("Task C"),
".paperclip.yaml": [
'schema: "paperclip/v1"',
"schemaVersion: 7",
"tasks:",
" task-a:",
' status: "todo"',
' parent: "task-b"',
" task-b:",
' status: "todo"',
' parent: "task-a"',
" task-c:",
' status: "todo"',
' parent: "task-c"',
' createdAt: "not-a-timestamp"',
"",
].join("\n"),
};
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Tampered Import" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
const result = await portability.importBundle({
source: { type: "inline", rootPath: "tampered-package", files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Tampered Import" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
const importedRows = issueSvc.importIssues.mock.calls[0]![1] as Array<{
title: string;
parentId: string | null;
createdAt: Date | null;
}>;
const rowA = importedRows.find((row) => row.title === "Task A")!;
const rowB = importedRows.find((row) => row.title === "Task B")!;
const rowC = importedRows.find((row) => row.title === "Task C")!;
// One direction of the two-task cycle survives; the closing edge drops.
expect([rowA.parentId, rowB.parentId].filter((parentId) => parentId !== null)).toHaveLength(1);
expect(rowC.parentId).toBeNull();
expect(rowC.createdAt).toBeNull();
expect(result.warnings.filter((warning) => warning.includes("would create a parent cycle"))).toHaveLength(2);
expect(result.warnings).toContain(
"Task task-c parent task-c was skipped because it would create a parent cycle.",
);
expect(result.warnings).toContain(
"Task task-c createdAt was ignored because it is not a valid timestamp.",
);
});
const attachmentBytesByObjectKey: Record<string, string> = {
"issues/issue-1/notes.bin": "png-bytes",
"issues/issue-1/screenshot.png": "png-bytes",
@ -4625,7 +4826,7 @@ describe("company portability", () => {
it("imports unstamped v5 packages with an info warning about task data they predate", async () => {
const portability = companyPortabilityService({} as any);
const v5Warning =
"This package declares schemaVersion 5 and predates label, blocker, document, work product, monitor, attachment, and embedded image transfer; that task data imports only if the bundle carries it.";
"This package declares schemaVersion 5 and predates label, blocker, document, work product, monitor, attachment, embedded image, task timestamp, and parent link transfer; that task data imports only if the bundle carries it.";
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Legacy Import" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
@ -4674,7 +4875,7 @@ describe("company portability", () => {
const portability = companyPortabilityService({} as any);
await expect(portability.importBundle({
source: { type: "inline", rootPath: "future-package", files: legacyPackageFiles(["schemaVersion: 7"]) },
source: { type: "inline", rootPath: "future-package", files: legacyPackageFiles(["schemaVersion: 8"]) },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Future Import" },
agents: "all",

View File

@ -161,8 +161,10 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
// The bundle shape this build reads and writes. Bundles began declaring
// their schemaVersion in the .paperclip.yaml extension at 6; undeclared
// bundles are read as 5, the last unstamped shape.
const BUNDLE_SCHEMA_VERSION = 6;
// bundles are read as 5, the last unstamped shape. 7 adds preserved task
// timestamps and parent links; 5/6 bundles still import, with those fields
// falling back to import-time defaults.
const BUNDLE_SCHEMA_VERSION = 7;
const UNSTAMPED_BUNDLE_SCHEMA_VERSION = 5;
const DEFAULT_IMPORTED_LABEL_COLOR = "#6366f1";
// Blob entries are content-addressed by sha256; the store itself is
@ -899,6 +901,38 @@ function readPortableIssueLabelNames(value: unknown): string[] {
return names;
}
/**
* Read a preserved issue timestamp from the extension, validated the same way
* comment createdAt is (Date.parse). Invalid values are ignored with a
* warning never a hard failure so a hand-edited bundle still imports.
*/
function readPortableIssueTimestamp(
value: unknown,
warnings: string[],
sourceLabel: string,
fieldLabel: string,
): string | null {
if (value === undefined || value === null) return null;
const raw = asString(value);
if (raw && !Number.isNaN(Date.parse(raw))) return raw;
warnings.push(`${sourceLabel} ${fieldLabel} was ignored because it is not a valid timestamp.`);
return null;
}
/** Render a stored timestamp as a portable ISO string, or omit it when unset/invalid. */
function toPortableTimestamp(value: Date | string | null | undefined): string | undefined {
if (value == null) return undefined;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
/** Convert a manifest timestamp (already validated at parse time) to a Date, or null. */
function portableManifestDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function readPortableIssueBlockedBy(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const slugs: string[] = [];
@ -3353,6 +3387,12 @@ function buildManifestFromPackageFiles(
workProducts: normalizePortableIssueWorkProducts(extension.workProducts),
monitor: normalizePortableIssueMonitor(extension.monitor),
attachments: normalizePortableIssueAttachments(extension.attachments, warnings, `Task ${slug}`),
parentSlug: asString(extension.parent),
createdAt: readPortableIssueTimestamp(extension.createdAt, warnings, `Task ${slug}`, "createdAt"),
updatedAt: readPortableIssueTimestamp(extension.updatedAt, warnings, `Task ${slug}`, "updatedAt"),
startedAt: readPortableIssueTimestamp(extension.startedAt, warnings, `Task ${slug}`, "startedAt"),
completedAt: readPortableIssueTimestamp(extension.completedAt, warnings, `Task ${slug}`, "completedAt"),
cancelledAt: readPortableIssueTimestamp(extension.cancelledAt, warnings, `Task ${slug}`, "cancelledAt"),
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
if (frontmatter.kind && frontmatter.kind !== "task") {
@ -3361,7 +3401,10 @@ function buildManifestFromPackageFiles(
}
if (bundleSchemaVersion < BUNDLE_SCHEMA_VERSION && manifest.issues.length > 0) {
warnings.push(`This package declares schemaVersion ${bundleSchemaVersion} and predates label, blocker, document, work product, monitor, attachment, and embedded image transfer; that task data imports only if the bundle carries it.`);
const predated = bundleSchemaVersion < 6
? "label, blocker, document, work product, monitor, attachment, embedded image, task timestamp, and parent link transfer"
: "task timestamp and parent link transfer";
warnings.push(`This package declares schemaVersion ${bundleSchemaVersion} and predates ${predated}; that task data imports only if the bundle carries it.`);
}
manifest.envInputs = dedupeEnvInputs(manifest.envInputs);
@ -4216,6 +4259,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
let unexportedBlockerEdgeCount = 0;
let unexportedParentEdgeCount = 0;
let unportableWorkProductRefCount = 0;
const exportedBlobs = new Map<string, CompanyPortabilityBlobManifestEntry>();
for (const issue of selectedIssueRows) {
@ -4256,6 +4300,13 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
unexportedBlockerEdgeCount += relationSummaries.blocks
.filter((blocked) => !taskSlugByIssueId.has(blocked.id))
.length;
// Parent links travel by task slug like blockers; a parent outside the
// export selection drops the edge and is counted for one warning.
let parentTaskSlug: string | null = null;
if (issue.parentId) {
parentTaskSlug = taskSlugByIssueId.get(issue.parentId) ?? null;
if (!parentTaskSlug) unexportedParentEdgeCount += 1;
}
const issueDocumentRows = await documentsSvc.listIssueDocuments(issue.id, { includeSystem: true });
const documentEntries = issueDocumentRows.map((document) => {
const documentPath = `tasks/${taskSlug}/documents/${document.key}.md`;
@ -4339,6 +4390,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
identifier: issue.identifier,
status: issue.status,
priority: issue.priority,
parent: parentTaskSlug ?? undefined,
createdAt: toPortableTimestamp(issue.createdAt),
updatedAt: toPortableTimestamp(issue.updatedAt),
startedAt: toPortableTimestamp(issue.startedAt),
completedAt: toPortableTimestamp(issue.completedAt),
cancelledAt: toPortableTimestamp(issue.cancelledAt),
// Labels travel by name (their natural key); the bundle-level labels
// section carries the matching color definitions.
labels: (issue.labelIds ?? [])
@ -4380,6 +4437,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (unexportedBlockerEdgeCount > 0) {
warnings.push(`${unexportedBlockerEdgeCount} blocker relation${unexportedBlockerEdgeCount === 1 ? " references a task" : "s reference tasks"} outside this export and ${unexportedBlockerEdgeCount === 1 ? "was" : "were"} not included.`);
}
if (unexportedParentEdgeCount > 0) {
warnings.push(`${unexportedParentEdgeCount} parent relation${unexportedParentEdgeCount === 1 ? " references a task" : "s reference tasks"} outside this export and ${unexportedParentEdgeCount === 1 ? "was" : "were"} not included.`);
}
if (unportableWorkProductRefCount > 0) {
warnings.push(`${unportableWorkProductRefCount} work product${unportableWorkProductRefCount === 1 ? " references" : "s reference"} execution workspaces or runs that are not portable; those references were omitted from the export.`);
}
@ -5721,6 +5781,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const importedIssueIdBySlug = new Map<string, string>();
const blockedByBySlug = new Map<string, string[]>();
const parentSlugBySlug = new Map<string, string>();
let unarmedMonitorCount = 0;
let attachmentsSkippedNoStorage = 0;
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(targetCompany.attachmentMaxBytes ?? null);
@ -5924,6 +5985,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if ((manifestIssue.blockedBy ?? []).length > 0) {
blockedByBySlug.set(manifestIssue.slug, manifestIssue.blockedBy ?? []);
}
if (manifestIssue.parentSlug) {
parentSlugBySlug.set(manifestIssue.slug, manifestIssue.parentSlug);
}
for (const documentEntry of manifestIssue.documents ?? []) {
const documentBody = readPortableTextFile(plan.source.files, documentEntry.path);
if (documentBody === null) {
@ -6050,9 +6114,70 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
labelIds: resolvedLabelIds,
monitorNotes,
monitorScheduledBy,
parentId: null,
createdAt: portableManifestDate(manifestIssue.createdAt),
updatedAt: portableManifestDate(manifestIssue.updatedAt),
startedAt: portableManifestDate(manifestIssue.startedAt),
completedAt: portableManifestDate(manifestIssue.completedAt),
cancelledAt: portableManifestDate(manifestIssue.cancelledAt),
});
}
// Parent links resolve against the pre-generated ids before the flush
// so each issue row carries its parentId on insert. Edges whose parent
// was not imported, self-references, and cycles (possible only in a
// hand-edited bundle) drop with a warning, mirroring blocker handling.
if (parentSlugBySlug.size > 0) {
const rowBySlug = new Map(issueRows.map((row) => [row.ref, row] as const));
const acceptedParentById = new Map<string, string>();
const wouldCreateParentCycle = (childId: string, parentId: string) => {
let current: string | undefined = parentId;
const visited = new Set<string>();
while (current) {
if (current === childId) return true;
if (visited.has(current)) return false;
visited.add(current);
current = acceptedParentById.get(current);
}
return false;
};
for (const [slug, parentSlug] of parentSlugBySlug) {
const row = rowBySlug.get(slug);
if (!row) continue;
const parentId = importedIssueIdBySlug.get(parentSlug);
if (!parentId) {
warnings.push(`Task ${slug} parent ${parentSlug} was skipped because that task was not imported.`);
continue;
}
if (parentId === row.id || wouldCreateParentCycle(row.id, parentId)) {
warnings.push(`Task ${slug} parent ${parentSlug} was skipped because it would create a parent cycle.`);
continue;
}
acceptedParentById.set(row.id, parentId);
row.parentId = parentId;
}
// The parent foreign key is checked per insert statement, so order
// rows parents-first: a child must never land in an earlier chunk
// than its parent.
if (acceptedParentById.size > 0) {
const rowById = new Map(issueRows.map((row) => [row.id, row] as const));
const orderedRows: typeof issueRows = [];
const emitted = new Set<string>();
for (const row of issueRows) {
const chain: typeof issueRows = [];
let current: (typeof issueRows)[number] | undefined = row;
while (current && !emitted.has(current.id)) {
emitted.add(current.id);
chain.push(current);
current = current.parentId ? rowById.get(current.parentId) : undefined;
}
for (const entry of chain.reverse()) orderedRows.push(entry);
}
issueRows.length = 0;
issueRows.push(...orderedRows);
}
}
// Flush the buffered rows in dependency order: issues first (parents of
// every other row), then comments (attachments may reference them), then
// the remaining children. Each writer inserts in chunked multi-row

View File

@ -35,6 +35,18 @@ export interface ImportIssueRow {
/** Imported monitors land un-armed; only notes/provenance are restored. */
monitorNotes: string | null;
monitorScheduledBy: string | null;
/** Parent issue id resolved within the same import batch (pre-generated), or absent for roots. */
parentId?: string | null;
/**
* Preserved source timestamps carried by schemaVersion >= 7 bundles.
* Absent/null createdAt and updatedAt fall back to the insert time;
* absent startedAt stays null the writer never fabricates it.
*/
createdAt?: Date | null;
updatedAt?: Date | null;
startedAt?: Date | null;
completedAt?: Date | null;
cancelledAt?: Date | null;
}
/** A resolved comment row with a pre-generated id, ready for batch insert. */

View File

@ -94,7 +94,7 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
import { getRunLogStore } from "./run-log-store.js";
import { getDefaultCompanyGoal } from "./goals.js";
import { assertAssignableAgent } from "./agent-assignability.js";
import { insertRowsInChunks } from "./batch-insert.js";
import { DEFAULT_INSERT_CHUNK_ROWS, insertRowsInChunks } from "./batch-insert.js";
import type {
ImportIssueRow,
ImportIssueCommentRow,
@ -7352,11 +7352,19 @@ export function issueService(db: Db) {
responsibleUserId: null,
requestDepth: clampIssueRequestDepth(undefined),
originKind: "manual",
// The caller resolves parentId against ids in this same batch, so a
// parent always lands in the same insert (rows arrive parents-first).
parentId: row.parentId ?? null,
// Preserved bundle timestamps win; without them createdAt/updatedAt
// fall back to the insert time (the old defaultNow() behavior).
createdAt: row.createdAt ?? new Date(),
updatedAt: row.updatedAt ?? new Date(),
// Imported in-progress work did not start at import time; fabricating
// startedAt here trips duration-based sweeps (e.g. productivity review).
startedAt: null,
completedAt: row.status === "done" ? new Date() : null,
cancelledAt: row.status === "cancelled" ? new Date() : null,
// startedAt here trips duration-based sweeps (e.g. productivity
// review). Only a bundle-carried startedAt is written.
startedAt: row.startedAt ?? null,
completedAt: row.completedAt ?? (row.status === "done" ? new Date() : null),
cancelledAt: row.cancelledAt ?? (row.status === "cancelled" ? new Date() : null),
monitorNotes: row.monitorNotes ?? null,
monitorScheduledBy: row.monitorScheduledBy ?? null,
});
@ -7396,10 +7404,28 @@ export function issueService(db: Db) {
};
});
await insertRowsInChunks(tx, issueComments, commentRows);
// Mirror addComment's recency bump, once per affected issue.
const issueIds = [...new Set(rows.map((row) => row.issueId))];
if (issueIds.length > 0) {
await tx.update(issues).set({ updatedAt: new Date() }).where(inArray(issues.id, issueIds));
// Mirror addComment's recency bump, once per affected issue — but never
// backwards. An issue imported with a preserved updatedAt keeps it
// unless a newer imported comment outdates it; issues without preserved
// timestamps carry an insert-time updatedAt, so GREATEST reproduces the
// old "bump to now" behavior for them.
const bumpAtByIssueId = new Map<string, Date>();
for (const row of commentRows) {
const existing = bumpAtByIssueId.get(row.issueId);
if (!existing || row.createdAt > existing) bumpAtByIssueId.set(row.issueId, row.createdAt);
}
const bumpEntries = [...bumpAtByIssueId.entries()];
for (let start = 0; start < bumpEntries.length; start += DEFAULT_INSERT_CHUNK_ROWS) {
const chunk = bumpEntries.slice(start, start + DEFAULT_INSERT_CHUNK_ROWS);
await tx.execute(sql`
update ${issues}
set updated_at = greatest(${issues.updatedAt}, bumps.bump_at)
from (values ${sql.join(
chunk.map(([issueId, bumpAt]) => sql`(${issueId}::uuid, ${bumpAt.toISOString()}::timestamptz)`),
sql`, `,
)}) as bumps(issue_id, bump_at)
where ${issues.id} = bumps.issue_id
`);
}
});
},