diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c9290369154..bb7316012d2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -644,6 +644,25 @@ pnpm dev If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL. +## Activity Log Retention + +Activity log rows are kept forever by default. Board users can set a company-specific +retention window through the company update API: + +```json +{ "activityLogRetentionDays": 30 } +``` + +Set `activityLogRetentionDays` to `null` to restore keep-forever behavior. Accepted +retention values are whole days from 1 through 36500. The server runs one sweep at +startup and then hourly, deleting only rows strictly older than the configured window +in bounded, company-scoped batches. + +The `activity_log.details` payload is capped at 64 KiB of serialized UTF-8 JSON. +Oversized details do not reject the activity write; they are replaced with a marker +containing `_paperclipTruncated`, `_paperclipOriginalBytes`, +`_paperclipMaxBytes`, and a bounded `_paperclipPreview` string. + ## Automatic DB Backups Paperclip can run automatic logical database backups on a timer. These backups cover diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 6ad5fb24742..6727adfd6f0 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -140,6 +140,7 @@ Human auth tables (`users`, `sessions`, and provider-specific auth artifacts) ar - `budget_monthly_cents` int not null default 0 - `spent_monthly_cents` int not null default 0 - `attachment_max_bytes` int not null +- `activity_log_retention_days` int null (null means keep forever) - `require_board_approval_for_new_agents` boolean not null default false - feedback sharing consent fields - branding fields such as `brand_color` @@ -325,6 +326,8 @@ Invariant: each event must attach to agent and company; rollups are aggregation, - `details` jsonb null - `created_at` timestamptz not null default now() +Activity details are capped at 64 KiB of serialized UTF-8 JSON at write time. Oversized payloads are replaced by a bounded marker containing the original byte count and a JSON preview. A scheduled maintenance sweep deletes rows older than each company's optional retention policy without crossing company boundaries; null retention keeps rows forever. + ## 7.12 `project_memberships` + `agent_memberships` Per-user project/agent membership is personal visibility state for board users. It only controls whether a resource appears in the current user's sidebar; it must not grant or revoke access to all-pages, detail pages, selectors, assignment flows, search, or existing permissions. diff --git a/packages/db/src/migrations/0178_activity_log_retention.sql b/packages/db/src/migrations/0178_activity_log_retention.sql new file mode 100644 index 00000000000..c04f9a4f69d --- /dev/null +++ b/packages/db/src/migrations/0178_activity_log_retention.sql @@ -0,0 +1,2 @@ +ALTER TABLE "companies" + ADD COLUMN IF NOT EXISTS "activity_log_retention_days" integer; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 197e97daded..564e039d98a 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1233,6 +1233,13 @@ "when": 1784241826832, "tag": "0177_activity_log_responsible_user", "breakpoints": true + }, + { + "idx": 178, + "version": "7", + "when": 1784241827832, + "tag": "0178_activity_log_retention", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/companies.ts b/packages/db/src/schema/companies.ts index c4c9c3ce971..c2010b54eea 100644 --- a/packages/db/src/schema/companies.ts +++ b/packages/db/src/schema/companies.ts @@ -16,6 +16,7 @@ export const companies = pgTable( attachmentMaxBytes: integer("attachment_max_bytes") .notNull() .default(10 * 1024 * 1024), + activityLogRetentionDays: integer("activity_log_retention_days"), defaultResponsibleUserId: text("default_responsible_user_id"), requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents") .notNull() diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 1434ab733a2..1771b9e28ca 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -37,6 +37,7 @@ export interface CompanyPortabilityCompanyManifestEntry { brandColor: string | null; logoPath: string | null; attachmentMaxBytes: number | null; + activityLogRetentionDays: number | null; requireBoardApprovalForNewAgents: boolean; feedbackDataSharingEnabled: boolean; feedbackDataSharingConsentAt: string | null; diff --git a/packages/shared/src/types/company.ts b/packages/shared/src/types/company.ts index 771c63f17f6..7647b02db7e 100644 --- a/packages/shared/src/types/company.ts +++ b/packages/shared/src/types/company.ts @@ -12,6 +12,7 @@ export interface Company { budgetMonthlyCents: number; spentMonthlyCents: number; attachmentMaxBytes: number; + activityLogRetentionDays: number | null; defaultResponsibleUserId: string | null; requireBoardApprovalForNewAgents: boolean; feedbackDataSharingEnabled: boolean; diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts index 9f682edb2c9..ef626ca7341 100644 --- a/packages/shared/src/validators/company.ts +++ b/packages/shared/src/validators/company.ts @@ -12,12 +12,14 @@ const attachmentMaxBytesSchema = z .int() .min(1) .max(MAX_COMPANY_ATTACHMENT_MAX_BYTES); +const activityLogRetentionDaysSchema = z.number().int().min(1).max(36_500).nullable(); export const createCompanySchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), attachmentMaxBytes: attachmentMaxBytesSchema.optional(), + activityLogRetentionDays: activityLogRetentionDaysSchema.optional(), defaultResponsibleUserId: z.string().min(1).nullable().optional(), }); @@ -36,6 +38,7 @@ export const updateCompanySchema = createCompanySchema brandColor: brandColorSchema, logoAssetId: logoAssetIdSchema, attachmentMaxBytes: attachmentMaxBytesSchema.optional(), + activityLogRetentionDays: activityLogRetentionDaysSchema.optional(), }); export type UpdateCompany = z.infer; diff --git a/server/src/__tests__/activity-log-retention.test.ts b/server/src/__tests__/activity-log-retention.test.ts new file mode 100644 index 00000000000..b435d90778a --- /dev/null +++ b/server/src/__tests__/activity-log-retention.test.ts @@ -0,0 +1,181 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { asc, eq } from "drizzle-orm"; +import { activityLog, companies, createDb } from "@paperclipai/db"; +import { + capActivityDetails, + logActivity, + MAX_ACTIVITY_DETAILS_BYTES, +} from "../services/activity-log.js"; +import { pruneActivityLog } from "../services/activity-log-retention.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres activity retention tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("capActivityDetails", () => { + it("leaves payloads under the cap unchanged", () => { + const details = { outcome: "ok" }; + expect(capActivityDetails(details)).toBe(details); + }); + + it("replaces oversized payloads with a bounded truncation marker", () => { + const details = { message: `start-${"😀\"\\\n".repeat(30_000)}-end` }; + const capped = capActivityDetails(details); + + expect(capped).toMatchObject({ + _paperclipTruncated: true, + _paperclipMaxBytes: MAX_ACTIVITY_DETAILS_BYTES, + }); + expect(capped?._paperclipOriginalBytes).toBeGreaterThan(MAX_ACTIVITY_DETAILS_BYTES); + expect(capped?._paperclipPreview).toEqual(expect.stringContaining("start-")); + expect(Buffer.byteLength(JSON.stringify(capped), "utf8")).toBeLessThanOrEqual( + MAX_ACTIVITY_DETAILS_BYTES, + ); + }); +}); + +describeEmbeddedPostgres("activity log retention", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-activity-retention-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("prunes per-company policies without crossing company boundaries", async () => { + const retainedCompanyId = randomUUID(); + const foreverCompanyId = randomUUID(); + const shortRetentionCompanyId = randomUUID(); + const now = new Date("2026-07-17T12:00:00.000Z"); + + await db.insert(companies).values([ + { + id: retainedCompanyId, + name: "Thirty days", + issuePrefix: "THD", + activityLogRetentionDays: 30, + }, + { + id: foreverCompanyId, + name: "Forever", + issuePrefix: "FOR", + activityLogRetentionDays: null, + }, + { + id: shortRetentionCompanyId, + name: "Seven days", + issuePrefix: "SEV", + activityLogRetentionDays: 7, + }, + ]); + + await db.insert(activityLog).values([ + { + companyId: retainedCompanyId, + actorId: "system", + action: "retained.expired", + entityType: "company", + entityId: retainedCompanyId, + createdAt: new Date("2026-06-16T11:59:59.000Z"), + }, + { + companyId: retainedCompanyId, + actorId: "system", + action: "retained.cutoff", + entityType: "company", + entityId: retainedCompanyId, + createdAt: new Date("2026-06-17T12:00:00.000Z"), + }, + { + companyId: foreverCompanyId, + actorId: "system", + action: "forever.old", + entityType: "company", + entityId: foreverCompanyId, + createdAt: new Date("2020-01-01T00:00:00.000Z"), + }, + { + companyId: shortRetentionCompanyId, + actorId: "system", + action: "short.expired", + entityType: "company", + entityId: shortRetentionCompanyId, + createdAt: new Date("2026-07-10T11:59:59.000Z"), + }, + { + companyId: shortRetentionCompanyId, + actorId: "system", + action: "short.current", + entityType: "company", + entityId: shortRetentionCompanyId, + createdAt: new Date("2026-07-10T12:00:00.000Z"), + }, + ]); + + await expect(pruneActivityLog(db, now)).resolves.toBe(2); + + const remaining = await db + .select({ companyId: activityLog.companyId, action: activityLog.action }) + .from(activityLog) + .orderBy(asc(activityLog.action)); + + expect(remaining).toEqual([ + { companyId: foreverCompanyId, action: "forever.old" }, + { companyId: retainedCompanyId, action: "retained.cutoff" }, + { companyId: shortRetentionCompanyId, action: "short.current" }, + ]); + }); + + it("caps details on the centralized activity write path", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Capped details", + issuePrefix: "CAP", + }); + + await logActivity(db, { + companyId, + actorType: "system", + actorId: "system", + action: "test.oversized", + entityType: "company", + entityId: companyId, + details: { output: "x".repeat(MAX_ACTIVITY_DETAILS_BYTES * 2) }, + }); + + const row = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(eq(activityLog.companyId, companyId)) + .then((rows) => rows[0]); + + expect(row?.details).toMatchObject({ + _paperclipTruncated: true, + _paperclipMaxBytes: MAX_ACTIVITY_DETAILS_BYTES, + }); + expect(Buffer.byteLength(JSON.stringify(row?.details), "utf8")).toBeLessThanOrEqual( + MAX_ACTIVITY_DETAILS_BYTES, + ); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 2755cc9177c..f312dd9d135 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -51,6 +51,7 @@ import { toolAccessService, } from "./services/index.js"; import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js"; +import { startActivityLogRetention } from "./services/activity-log-retention.js"; import { parseAdapterRegistryEnv, reconcileAdapterAvailability, @@ -826,6 +827,7 @@ export async function startServer(): Promise { let prepareHotRestartShutdown: ((signal: "SIGINT" | "SIGTERM") => Promise<{ skipDrain: boolean }>) | null = null; let heartbeatSchedulerStopped = false; let heartbeatSchedulerInterval: ReturnType | null = null; + const stopActivityLogRetention = startActivityLogRetention(db as any); const heartbeatSchedulerInFlight = new Set>(); const trackHeartbeatSchedulerWork = (work: Promise) => { let tracked: Promise; @@ -1200,6 +1202,7 @@ export async function startServer(): Promise { { const shutdown = async (signal: "SIGINT" | "SIGTERM") => { heartbeatSchedulerStopped = true; + stopActivityLogRetention(); if (heartbeatSchedulerInterval) { clearInterval(heartbeatSchedulerInterval); heartbeatSchedulerInterval = null; diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 7b68a395ed5..e088e92b661 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -18,6 +18,7 @@ import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "./logger.js"; import { boardAuthService } from "../services/board-auth.js"; import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; +import { capActivityDetails } from "../services/activity-log.js"; import { forbidden, unprocessable } from "../errors.js"; function hashToken(token: string) { @@ -91,12 +92,12 @@ async function auditAgentJwtRunHeaderMismatch( entityId: input.claimRunId, ...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}), ...(isUuidLike(input.claimRunId) ? { runId: input.claimRunId } : {}), - details: { + details: capActivityDetails({ claimRunId: input.claimRunId, headerRunId: input.headerRunId, method: input.method, url: input.url, - }, + }), }); } catch (err) { logger.warn( @@ -119,10 +120,10 @@ async function auditAgentKeyMissingResponsibleUser( entityType: "agent_api_key", entityId: input.keyId, ...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}), - details: { + details: capActivityDetails({ method: input.method, url: input.url, - }, + }), }); } catch (err) { logger.warn( diff --git a/server/src/services/activity-log-retention.ts b/server/src/services/activity-log-retention.ts new file mode 100644 index 00000000000..736f5c814b0 --- /dev/null +++ b/server/src/services/activity-log-retention.ts @@ -0,0 +1,109 @@ +import { and, asc, eq, inArray, isNotNull, lt } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { activityLog, companies } from "@paperclipai/db"; +import { logger } from "../middleware/logger.js"; + +const ACTIVITY_LOG_RETENTION_INTERVAL_MS = 60 * 60 * 1_000; +const DELETE_BATCH_SIZE = 1_000; +const MAX_BATCHES_PER_COMPANY = 100; +const DAY_MS = 24 * 60 * 60 * 1_000; + +export async function pruneActivityLog(db: Db, now = new Date()): Promise { + const retentionPolicies = await db + .select({ + companyId: companies.id, + retentionDays: companies.activityLogRetentionDays, + }) + .from(companies) + .where(isNotNull(companies.activityLogRetentionDays)); + + let totalDeleted = 0; + for (const policy of retentionPolicies) { + if ( + policy.retentionDays === null + || !Number.isInteger(policy.retentionDays) + || policy.retentionDays < 1 + || policy.retentionDays > 36_500 + ) { + logger.warn( + { companyId: policy.companyId, retentionDays: policy.retentionDays }, + "Skipping invalid activity log retention policy", + ); + continue; + } + const cutoff = new Date(now.getTime() - policy.retentionDays * DAY_MS); + let batches = 0; + let companyDeleted = 0; + + while (batches < MAX_BATCHES_PER_COMPANY) { + const expiredIds = await db + .select({ id: activityLog.id }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, policy.companyId), + lt(activityLog.createdAt, cutoff), + )) + .orderBy(asc(activityLog.createdAt), asc(activityLog.id)) + .limit(DELETE_BATCH_SIZE) + .then((rows) => rows.map((row) => row.id)); + + if (expiredIds.length === 0) break; + + const deleted = await db + .delete(activityLog) + .where(and( + eq(activityLog.companyId, policy.companyId), + inArray(activityLog.id, expiredIds), + )) + .returning({ id: activityLog.id }); + + companyDeleted += deleted.length; + totalDeleted += deleted.length; + batches += 1; + if (expiredIds.length < DELETE_BATCH_SIZE) break; + } + + if (batches >= MAX_BATCHES_PER_COMPANY) { + logger.warn( + { companyId: policy.companyId, retentionDays: policy.retentionDays, companyDeleted }, + "Activity log retention hit the per-company batch limit", + ); + } else if (companyDeleted > 0) { + logger.info( + { companyId: policy.companyId, retentionDays: policy.retentionDays, companyDeleted }, + "Pruned expired activity log rows", + ); + } + } + + return totalDeleted; +} + +export function startActivityLogRetention( + db: Db, + intervalMs: number = ACTIVITY_LOG_RETENTION_INTERVAL_MS, +): () => void { + let stopped = false; + let sweepInFlight: Promise | null = null; + + const runSweep = () => { + if (stopped || sweepInFlight) return; + sweepInFlight = pruneActivityLog(db) + .then(() => undefined) + .catch((err) => { + logger.warn({ err }, "Activity log retention sweep failed"); + }) + .finally(() => { + sweepInFlight = null; + }); + }; + + runSweep(); + const timer = setInterval(runSweep, intervalMs); + timer.unref?.(); + + return () => { + stopped = true; + clearInterval(timer); + }; +} diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index fb27951a5c8..63a53603b58 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -12,6 +12,7 @@ import type { PluginEventBus } from "./plugin-event-bus.js"; import { instanceSettingsService } from "./instance-settings.js"; const PLUGIN_EVENT_SET: ReadonlySet = new Set(PLUGIN_EVENT_TYPES); +export const MAX_ACTIVITY_DETAILS_BYTES = 64 * 1024; const ACTIVITY_ACTION_TO_PLUGIN_EVENT: Readonly> = { issue_comment_added: "issue.comment.created", issue_comment_created: "issue.comment.created", @@ -69,6 +70,38 @@ function readNonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +export function capActivityDetails( + details: Record | null, + maxBytes: number = MAX_ACTIVITY_DETAILS_BYTES, +): Record | null { + if (!details) return null; + + const serialized = JSON.stringify(details); + const originalBytes = Buffer.byteLength(serialized, "utf8"); + if (originalBytes <= maxBytes) return details; + + const buildMarker = (preview: string) => ({ + _paperclipTruncated: true, + _paperclipOriginalBytes: originalBytes, + _paperclipMaxBytes: maxBytes, + _paperclipPreview: preview, + }); + + let low = 0; + let high = serialized.length; + while (low < high) { + const midpoint = Math.ceil((low + high) / 2); + const candidate = buildMarker(serialized.slice(0, midpoint)); + if (Buffer.byteLength(JSON.stringify(candidate), "utf8") <= maxBytes) { + low = midpoint; + } else { + high = midpoint - 1; + } + } + + return buildMarker(serialized.slice(0, low)); +} + export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActivityInput) { if (input.actorType === "user") return readNonEmptyString(input.actorId); @@ -132,6 +165,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions) : null; + const cappedDetails = capActivityDetails(redactedDetails); const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input); await db.insert(activityLog).values({ companyId: input.companyId, @@ -143,7 +177,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { agentId: input.agentId ?? null, runId: input.runId ?? null, responsibleUserId, - details: redactedDetails, + details: cappedDetails, }); publishLiveEvent({ @@ -158,7 +192,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { agentId: input.agentId ?? null, runId: input.runId ?? null, responsibleUserId, - details: redactedDetails, + details: cappedDetails, }, }); @@ -174,7 +208,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { entityType: input.entityType, companyId: input.companyId, payload: { - ...redactedDetails, + ...cappedDetails, agentId: input.agentId ?? null, runId: input.runId ?? null, responsibleUserId, diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index a9c6f592dd0..7865d48f717 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -18,6 +18,7 @@ import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; import { logger } from "../middleware/logger.js"; import { visibleIssueCondition } from "./issue-visibility.js"; import { classifyRunLiveness } from "./run-liveness.js"; +import { capActivityDetails } from "./activity-log.js"; export interface ActivityFilters { companyId: string; @@ -582,7 +583,10 @@ export function activityService(db: Db) { create: (data: typeof activityLog.$inferInsert) => db .insert(activityLog) - .values(data) + .values({ + ...data, + details: capActivityDetails(data.details ?? null), + }) .returning() .then((rows) => rows[0]), }; diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 95626af307e..6b0f632c4e3 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -137,6 +137,7 @@ export function companyService(db: Db) { budgetMonthlyCents: companies.budgetMonthlyCents, spentMonthlyCents: companies.spentMonthlyCents, attachmentMaxBytes: companies.attachmentMaxBytes, + activityLogRetentionDays: companies.activityLogRetentionDays, defaultResponsibleUserId: companies.defaultResponsibleUserId, requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents, feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 01a32c287a7..ca39d95d920 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -2669,6 +2669,10 @@ function buildManifestFromPackageFiles( typeof paperclipCompany.attachmentMaxBytes === "number" && Number.isFinite(paperclipCompany.attachmentMaxBytes) ? Math.max(1, Math.floor(paperclipCompany.attachmentMaxBytes)) : null, + activityLogRetentionDays: + typeof paperclipCompany.activityLogRetentionDays === "number" && Number.isFinite(paperclipCompany.activityLogRetentionDays) + ? Math.max(1, Math.min(36_500, Math.floor(paperclipCompany.activityLogRetentionDays))) + : null, requireBoardApprovalForNewAgents: typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean" ? paperclipCompany.requireBoardApprovalForNewAgents @@ -3896,6 +3900,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { brandColor: company.brandColor ?? null, logoPath: companyLogoPath, attachmentMaxBytes: company.attachmentMaxBytes, + activityLogRetentionDays: company.activityLogRetentionDays, requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? true : undefined, feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined, feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null, @@ -4433,6 +4438,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { name: string; requireBoardApprovalForNewAgents?: boolean | null; attachmentMaxBytes?: number | null; + activityLogRetentionDays?: number | null; } | null = null; let companyAction: "created" | "updated" | "unchanged" = "unchanged"; @@ -4458,6 +4464,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { attachmentMaxBytes: include.company ? (sourceManifest.company?.attachmentMaxBytes ?? undefined) : undefined, + activityLogRetentionDays: include.company + ? (sourceManifest.company?.activityLogRetentionDays ?? undefined) + : undefined, requireBoardApprovalForNewAgents: include.company ? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? false) : false, @@ -4497,6 +4506,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { description: sourceManifest.company.description, brandColor: sourceManifest.company.brandColor, attachmentMaxBytes: sourceManifest.company.attachmentMaxBytes ?? undefined, + activityLogRetentionDays: sourceManifest.company.activityLogRetentionDays, requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents, feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled, feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt