From b6187718ea09eeec5c2d53b4d9fb9e3bcead0c7c Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:53:38 +0800 Subject: [PATCH] feat(server): add cron job for session cleanup (#13181) fix AI-338 --- .../__snapshots__/copilot.spec.ts.md | 65 +++++++ .../__snapshots__/copilot.spec.ts.snap | Bin 1782 -> 2288 bytes .../src/__tests__/copilot-provider.spec.ts | 18 +- .../server/src/__tests__/copilot.spec.ts | 72 +++++++ .../__snapshots__/copilot-session.spec.ts.md | 62 +++++++ .../copilot-session.spec.ts.snap | Bin 3638 -> 4065 bytes .../__tests__/models/copilot-session.spec.ts | 175 ++++++++++++++++++ .../server/src/models/copilot-session.ts | 53 ++++++ .../server/src/plugins/copilot/cron.ts | 67 +++++++ .../server/src/plugins/copilot/index.ts | 3 + .../src/plugins/copilot/prompt/prompts.ts | 1 + 11 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 packages/backend/server/src/plugins/copilot/cron.ts diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md index 26e89b1734..a5c2141d7d 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.md @@ -372,3 +372,68 @@ Generated by [AVA](https://avajs.dev). [assistant]: Quantum computing uses quantum mechanics principles.`, promptName: 'Summary as title', } + +## should handle copilot cron jobs correctly + +> daily job scheduling calls + + [ + { + args: [ + 'copilot.session.cleanupEmptySessions', + {}, + { + jobId: 'daily-copilot-cleanup-empty-sessions', + }, + ], + }, + { + args: [ + 'copilot.session.generateMissingTitles', + {}, + { + jobId: 'daily-copilot-generate-missing-titles', + }, + ], + }, + ] + +> cleanup empty sessions calls + + [ + { + args: [ + 'Date', + ], + }, + ] + +> title generation calls + + { + jobCalls: [ + { + args: [ + 'copilot.session.generateTitle', + { + sessionId: 'session1', + }, + ], + }, + { + args: [ + 'copilot.session.generateTitle', + { + sessionId: 'session2', + }, + ], + }, + ], + modelCalls: [ + { + args: [ + 100, + ], + }, + ], + } diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/copilot.spec.ts.snap index 04b188d87dfea65cc69e0100152a1f39100d848a..afecb021420f843fbea1bc310cd771349db5bb8f 100644 GIT binary patch literal 2288 zcmVzIVZ#RlARgB)h(zJv zO3)H$WFLzN00000000B+T3u`$R~7!wow2>P8`~ij&??ZOBx(TLsS^hhE20!?@zaJ< zQYlG8D&3vEyF1SOZ0F9}UO*t)q*4K?4@E$$O0*FzQqzYbfv5AHc!)qn zw5kMsq2i$u6`q+pv+LQ-PS%do1Z^HRv%Y8Ux#!&PeD|EY=kapU_RQn*{!=2b0$VI^ zmsPKBmyDt?c+nO{;292gc|{mzl?R3tQd*vCNtf4T)eGdkr%ELr@FVoJh9QLis6g6{ z06q%f69Bf*;AR@Q+4p&aD0vzm9|w(q7-*E}*7(IyqA!R4Q#gnI1c1{3UIOq&GSA@c z#RXvo#L~x!iHS(%W&++rz<$bz>Z?UJfi%!Kwh+5Id>YtHL#F2j!VQw|qg7$sp0VWl zb_p~_bc}#xaInUG?#Rp(y6@Q{6_E`Y2q)6Ta&!$uy1LD#*QM}76WIv@zL_;oYcr3s zvn_dkDb*KQC2U?4_VNeRW>#lRs0yQOx!g7!AtkSf{_bA6ySpd5yCth!7QS$U zWOt1vE2tXW^@6JKjo9>r2tL?O1ZB@ZejXY)p@70pw*W<-1uzHT767;BfklfTa#sqL z2NYOFiJlD~Sv2Kq0R9Ewy#(Bt^;R2h^n(N(A>eBSd^ZO>1=4>;z%L1Sg@CuR;)Cml zn!(l#wLD!3)W(QD-J2E#!3)vRv_$l#W0*^6$$+~-g!TIv@Br(M^}_1Wfv%%Nmx2ZR z;O>L_{}$Z(WDB?l_XGGOfZG5Z0#IB*rjrNqfe6pbDae1+1o=w}V z#elEma+a2G*4eCRdv4k@0lmKVx*_lWg=Q`+GIQtI)dP{_PPY3ZEo!z0Qkc+ycV|zJ z_1rG)P$5_4i(z2;LL^})jiM-fzEIJa6wg0y3Alm106q@jGXM?(xH~V{^eBR?G}yl1 z47Rh)V0%LaTY>az2)K@b&#hFv#rz06Nx-){We4pM_B#UpM8KO_(e?cDEiwoxK3y#jJvkP97urdy4|?xM)PnyehnsUP+nw;CYQ!I0*W{YXlb9M$o22+uDrx$7!^$Jn)&ieyinVB>K zUfC6W?v6fZqt88)hT7?^*?YQpyJX-C?j$U7;G(g}H<~Q+WXdA!*8qN#+epOT1n^cy zqqa(8&p*BnIbKQFpG~7+r0LWar0J9xzng%41S}Eo;QITSa`q^K(lfo0Rr3UTnSgWo z>=^_0-z{47fLCOM*sUx1_Vo<-7+dZ2!Cb_KbIi73GW_cvg}e`^9OIURAKeeve9x%` zac5I(slh`n3B$~%$(~!XMT*&jaYxggp-l7M*r-SIE^^=U>e4Vhw`2vD=gKApos0o~ z_)IIl!|u3{T0&Aekp(5?Zt>M&lhq+BsD2?b=2P6ZMGUW@O&QTav24jeMqk-@W^f=f zIr(|SN8>jfPRRxNG@w|&7&4Wr6sJgn?~?DDo-+mz`7JfEGeXN1w- ztau^?SD2A=`9`+oAFD0>Piuo~m^_4K-PwFA^K!7`M?>!(n=|H@Yc{uBQwws4V)HDBF#Zqv;Zl%^7#JiPfc$>D!JltK79rY1DknHLaR0D4UT3k=iuTw}p2%1%GP z-IpW;uV0jwVn1yuW)B1SL4F4CQvheOV(mq2e5C!MB?r^FA0BDx*xT)61=2r4zzhLp z0`8%%i3aIkCE#8Ho+aRHMtG=09<=&=+@>5pt+;C1v;}wTwRxu&G}M76BQL#3Y0C6Z zic_Xe6K>B=n8eZnOG?YFMEn`gxP_%N&M=jP<&%YW{S40#4WoqrOcgDewz}Z4gQa7@j6F8u?z!$Vil#tFZ!UzX0$n0Kd(c$1-urUIuV3 zBQ)HZie5{pXiNoJx-EN>l%IX6)6HGBC4<milKI>@Qu$ z80!EI?FI%+G2kfcR(=*pD>J}mz$2_1s}+bn!GO~lq0!vZ;C<&~s)(W@@1J4mcC6*~ zS6J(Aw!E%WDO&ij28?H~q1*0F*}hI~TZLS{8dfW7a!o<~Wu`~@U2#+{W)JZ=9RCLr K2GQ*SHUI$P0dtZ7 literal 1782 zcmVMNzEyU|%dg6cjVF zGqao7G}BF*US#vIna%m<{PX|k`@Z?l$$uoDvE1x_dGM%Ern1EF-LmMGtsKh;#xs^+ z%4IfpctNmikt-&Jl&0&L(%~gpbd`MJXfDSU-$}=Hw88(OcG8yuxDUXC0OnBZY-(BO zd3=N@y6W!k26ccmP$$ub%8i{w&-lN|pTl?;z%c-40GyBJY2BRJC$fsP__C*`CvZ8N zfLjPyLkUx3tm!O}4(i4n(&qYCEwiaD>pDs}D*E186qe<(A=k5Vpe~|41Vn?aCGK%s zCZ;ev*AlUb9Fa=cfiEq^S4-e)qS=hH6rLZVc7TAFli}%g;Zbt7A=k^r{(`84#WTVh zz9U@hf^dY#m0+qUSl)EF#cUxZFNo&q-ngr~GP$}rGoKfpa8$IqY{*na#vNA`g~uwP zM?!FCy%6MGZ~rA^;6Mlz?N|*^jD7$E03HFbISnj&0FhlWSPq3?=_LBZztn;$e+Td< zfLjSzn#@)mZj7x2>?Ghh0^Uf$PCFSN6Yx0!zYuUSY2G?@s4;EQP)qX_L9L7E(Z;-l z5WE~8)ks8RK4x$!O{ut}0<6EN0f)4SvEDv*v}MB4wyU9nO>j5C{l5jb(OU!V)-?bg z0zd{36YrtL&7)+YC)|tPp0Y^39lm?tlnzz@P|EmFp4&0*yYxRVA$JB`> z^s7OvR~z#(DPn!ln2%awU4|o_a~`-kJzb&bnM@||aBlK}jFx(a0dyGCrx1zJ8553n zHr1u6E=_f5s>_Y2F5zZ%L1^iTLE%-mw^`ToL{?cN)s5;)p&;q2rc;oSzi&u8f1zcU$IR4umNy_Isgtd^LJ!s7fGG6nz|G0G$5c+27KM~6@xqz!(dPNX8p5@#0`LHU#{g^vus4ltM!+C5Cfl1;vYo7w?R-eKb~5G>uz-Lk zMt8iKbcP)y;Fa;JgL;O2O~CgATu7SMGwe1E@VCY5QW!QF+GLuv8TTC3-+CWT*?u>K z^xpX8V!MNa#ZMi-W_Vr+FM;+Y({WVvQT3?kCjh?z=pkTHI=nX!u!R6G9p0}I@D>4| zroekLnEiSBj@qw2X4^@NUbfd1Yt2%oS;}0`n%pd9nx#y$lxdbS%~IyN#q6~&Wx|7; zyXw0Ryt2bg+rL2!i)>lB-Z*s1gQW} zBc)2ZjSAGY(*V9nIv6*}T|~duIMDZtk>D|B`bW~ECDeKv8=o;w@6AmAVd5zk7L_W= zfh9}CFtpk>ifCT~_$sxLNV@>wV#1?7#$&_(d~LJcoUk5`S0^>MN1&X*QnR1PFTNvTUb z*Ke}gZmQx_K`;+<%MulMwarSHwu)g>Dj9s$x)XyfLCDd!dE5QgEmN{hJXn+^XucVJlJvKs>H4JOg`?e^}ea;6j-pjVX&*hU>#SD z8*td-#<=APo*Q9BE-S-f-0xi9aqF`ZU4WO0({Y*4_Ueu&aBzbKIglP?UHvgO(!XmP zT>av~-`1T>k20+W+rQh-?w$d*ez;_D(_v4SxueQ9%er={9H27enIdnEbnK2FanAtT zQfF3{LdJL7B3tB+nU$>MnNHR$Swi+D9;Krqg->!jrb5rwvTbv3ghgNu^Vb*VuPkHn Yi-a`qdSNER#G0=2A2zLTtTrwH0IivR!s diff --git a/packages/backend/server/src/__tests__/copilot-provider.spec.ts b/packages/backend/server/src/__tests__/copilot-provider.spec.ts index 7cb9d89c0b..2d40e69bbf 100644 --- a/packages/backend/server/src/__tests__/copilot-provider.spec.ts +++ b/packages/backend/server/src/__tests__/copilot-provider.spec.ts @@ -351,10 +351,10 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca params: { files: [ { - blobId: 'euclidean_distance', - fileName: 'euclidean_distance.rs', - fileType: 'text/rust', - fileContent: TestAssets.Code, + blobId: 'todo_md', + fileName: 'todo.md', + fileType: 'text/markdown', + fileContent: TestAssets.TODO, }, ], }, @@ -476,6 +476,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca }, }, ], + config: { model: 'gemini-2.5-pro' }, verifier: (t: ExecutionContext, result: string) => { t.notThrows(() => { TranscriptionResponseSchema.parse(JSON.parse(result)); @@ -697,11 +698,12 @@ for (const { t.truthy(provider, 'should have provider'); await retry(`action: ${promptName}`, t, async t => { const finalConfig = Object.assign({}, prompt.config, config); + const modelId = finalConfig.model || prompt.model; switch (type) { case 'text': { const result = await provider.text( - { modelId: prompt.model }, + { modelId }, [ ...prompt.finish( messages.reduce( @@ -720,7 +722,7 @@ for (const { } case 'structured': { const result = await provider.structure( - { modelId: prompt.model }, + { modelId }, [ ...prompt.finish( messages.reduce( @@ -739,7 +741,7 @@ for (const { case 'object': { const streamObjects: StreamObject[] = []; for await (const chunk of provider.streamObject( - { modelId: prompt.model }, + { modelId }, [ ...prompt.finish( messages.reduce( @@ -771,7 +773,7 @@ for (const { }); } const stream = provider.streamImages( - { modelId: prompt.model }, + { modelId }, [ ...prompt.finish( finalMessage.reduce( diff --git a/packages/backend/server/src/__tests__/copilot.spec.ts b/packages/backend/server/src/__tests__/copilot.spec.ts index b44ca678f1..2718214ccc 100644 --- a/packages/backend/server/src/__tests__/copilot.spec.ts +++ b/packages/backend/server/src/__tests__/copilot.spec.ts @@ -18,6 +18,7 @@ import { } from '../models'; import { CopilotModule } from '../plugins/copilot'; import { CopilotContextService } from '../plugins/copilot/context'; +import { CopilotCronJobs } from '../plugins/copilot/cron'; import { CopilotEmbeddingJob, MockEmbeddingClient, @@ -77,6 +78,7 @@ type Context = { jobs: CopilotEmbeddingJob; storage: CopilotStorage; workflow: CopilotWorkflowService; + cronJobs: CopilotCronJobs; executors: { image: CopilotChatImageExecutor; text: CopilotChatTextExecutor; @@ -137,6 +139,7 @@ test.before(async t => { const jobs = module.get(CopilotEmbeddingJob); const transcript = module.get(CopilotTranscriptionService); const workspaceEmbedding = module.get(CopilotWorkspaceService); + const cronJobs = module.get(CopilotCronJobs); t.context.module = module; t.context.auth = auth; @@ -153,6 +156,7 @@ test.before(async t => { t.context.jobs = jobs; t.context.transcript = transcript; t.context.workspaceEmbedding = workspaceEmbedding; + t.context.cronJobs = cronJobs; t.context.executors = { image: module.get(CopilotChatImageExecutor), @@ -1931,3 +1935,71 @@ test('should handle generateSessionTitle correctly under various conditions', as ); } }); + +test('should handle copilot cron jobs correctly', async t => { + const { cronJobs, copilotSession } = t.context; + + // mock calls + const mockCleanupResult = { removed: 2, cleaned: 3 }; + const mockSessions = [ + { id: 'session1', _count: { messages: 1 } }, + { id: 'session2', _count: { messages: 2 } }, + ]; + const cleanupStub = Sinon.stub( + copilotSession, + 'cleanupEmptySessions' + ).resolves(mockCleanupResult); + const toBeGenerateStub = Sinon.stub( + copilotSession, + 'toBeGenerateTitle' + ).resolves(mockSessions); + const jobAddStub = Sinon.stub(cronJobs['jobs'], 'add').resolves(); + + // daily cleanup job scheduling + { + await cronJobs.dailyCleanupJob(); + t.snapshot( + jobAddStub.getCalls().map(call => ({ + args: call.args, + })), + 'daily job scheduling calls' + ); + + jobAddStub.reset(); + cleanupStub.reset(); + toBeGenerateStub.reset(); + } + + // cleanup empty sessions + { + // mock + cleanupStub.resolves(mockCleanupResult); + toBeGenerateStub.resolves(mockSessions); + + await cronJobs.cleanupEmptySessions(); + t.snapshot( + cleanupStub.getCalls().map(call => ({ + args: call.args.map(arg => (arg instanceof Date ? 'Date' : arg)), // Replace Date with string for stable snapshot + })), + 'cleanup empty sessions calls' + ); + } + + // generate missing titles + await cronJobs.generateMissingTitles(); + t.snapshot( + { + modelCalls: toBeGenerateStub.getCalls().map(call => ({ + args: call.args, + })), + jobCalls: jobAddStub.getCalls().map(call => ({ + args: call.args, + })), + }, + 'title generation calls' + ); + + cleanupStub.restore(); + toBeGenerateStub.restore(); + jobAddStub.restore(); +}); diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md index b77759cfbb..3042b6e577 100644 --- a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md +++ b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md @@ -565,3 +565,65 @@ Generated by [AVA](https://avajs.dev). workspaceSessionExists: true, }, } + +## should cleanup empty sessions correctly + +> cleanup empty sessions results + + { + cleanupResult: { + cleaned: 0, + removed: 0, + }, + remainingSessions: [ + { + deleted: false, + pinned: false, + type: 'zeroCost', + }, + { + deleted: false, + pinned: false, + type: 'zeroCost', + }, + { + deleted: false, + pinned: false, + type: 'noMessages', + }, + { + deleted: false, + pinned: false, + type: 'noMessages', + }, + { + deleted: false, + pinned: false, + type: 'recent', + }, + { + deleted: false, + pinned: false, + type: 'withMessages', + }, + ], + } + +## should get sessions for title generation correctly + +> sessions for title generation results + + { + onlyValidSessionsReturned: true, + sessions: [ + { + assistantMessageCount: 1, + isValid: true, + }, + { + assistantMessageCount: 2, + isValid: true, + }, + ], + total: 2, + } diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap index e57406bc5a7920ba3e1b9f4636b33986e21e35b4..59064e0446d8d9f7370a74722096cb29195cd7b5 100644 GIT binary patch literal 4065 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 TBj(nSP__OK_|4fAY*7FJvxxCs literal 3638 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 IL%c}<06rQU { 'attach and detach operation results' ); }); + +test('should cleanup empty sessions correctly', async t => { + const { copilotSession, db } = t.context; + await createTestPrompts(copilotSession, db); + + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + + // should be deleted + const neverUsedSessionIds: string[] = [randomUUID(), randomUUID()]; + await Promise.all( + neverUsedSessionIds.map(async id => { + await createTestSession(t, { sessionId: id }); + await db.aiSession.update({ + where: { id }, + data: { messageCost: 0, updatedAt: oneDayAgo }, + }); + }) + ); + + // should be marked as deleted + const emptySessionIds: string[] = [randomUUID(), randomUUID()]; + await Promise.all( + emptySessionIds.map(async id => { + await createTestSession(t, { sessionId: id }); + await db.aiSession.update({ + where: { id }, + data: { messageCost: 100, updatedAt: oneDayAgo }, + }); + }) + ); + + // should not be affected + const recentSessionId = randomUUID(); + await createTestSession(t, { sessionId: recentSessionId }); + await db.aiSession.update({ + where: { id: recentSessionId }, + data: { messageCost: 0, updatedAt: twoHoursAgo }, + }); + + // Create session with messages (should not be affected) + const sessionWithMsgId = randomUUID(); + await createSessionWithMessages( + t, + { sessionId: sessionWithMsgId }, + 'test message' + ); + + const result = await copilotSession.cleanupEmptySessions(oneDayAgo); + + const remainingSessions = await db.aiSession.findMany({ + where: { + id: { + in: [ + ...neverUsedSessionIds, + ...emptySessionIds, + recentSessionId, + sessionWithMsgId, + ], + }, + }, + select: { id: true, deletedAt: true, pinned: true }, + }); + + t.snapshot( + { + cleanupResult: result, + remainingSessions: remainingSessions.map(s => ({ + deleted: !!s.deletedAt, + pinned: s.pinned, + type: neverUsedSessionIds.includes(s.id) + ? 'zeroCost' + : emptySessionIds.includes(s.id) + ? 'noMessages' + : s.id === recentSessionId + ? 'recent' + : 'withMessages', + })), + }, + 'cleanup empty sessions results' + ); +}); + +test('should get sessions for title generation correctly', async t => { + const { copilotSession, db } = t.context; + await createTestPrompts(copilotSession, db); + + // create valid sessions with messages + const sessionIds: string[] = [randomUUID(), randomUUID()]; + await Promise.all( + sessionIds.map(async (id, index) => { + await createTestSession(t, { sessionId: id }); + await db.aiSession.update({ + where: { id }, + data: { + updatedAt: new Date(Date.now() - index * 1000), + messages: { + create: Array.from({ length: index + 1 }, (_, i) => ({ + role: 'assistant', + content: `assistant message ${i}`, + })), + }, + }, + }); + }) + ); + + // create excluded sessions + const excludedSessions = [ + { + reason: 'hasTitle', + setupFn: async (id: string) => { + await createTestSession(t, { sessionId: id }); + await db.aiSession.update({ + where: { id }, + data: { title: 'Existing Title' }, + }); + }, + }, + { + reason: 'isDeleted', + setupFn: async (id: string) => { + await createTestSession(t, { sessionId: id }); + await db.aiSession.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + }, + }, + { + reason: 'noMessages', + setupFn: async (id: string) => { + await createTestSession(t, { sessionId: id }); + }, + }, + { + reason: 'isAction', + setupFn: async (id: string) => { + await createTestSession(t, { + sessionId: id, + promptName: TEST_PROMPTS.ACTION, + }); + }, + }, + { + reason: 'noAssistantMessages', + setupFn: async (id: string) => { + await createTestSession(t, { sessionId: id }); + await db.aiSessionMessage.create({ + data: { sessionId: id, role: 'user', content: 'User message only' }, + }); + }, + }, + ]; + + await Promise.all( + excludedSessions.map(async session => { + await session.setupFn(randomUUID()); + }) + ); + + const result = await copilotSession.toBeGenerateTitle(10); + + t.snapshot( + { + total: result.length, + sessions: result.map(s => ({ + assistantMessageCount: s._count.messages, + isValid: sessionIds.includes(s.id), + })), + onlyValidSessionsReturned: result.every(s => sessionIds.includes(s.id)), + }, + 'sessions for title generation results' + ); +}); diff --git a/packages/backend/server/src/models/copilot-session.ts b/packages/backend/server/src/models/copilot-session.ts index f64b81fefd..4bb85c48f3 100644 --- a/packages/backend/server/src/models/copilot-session.ts +++ b/packages/backend/server/src/models/copilot-session.ts @@ -582,4 +582,57 @@ export class CopilotSessionModel extends BaseModel { .map(({ messageCost, prompt: { action } }) => (action ? 1 : messageCost)) .reduce((prev, cost) => prev + cost, 0); } + + @Transactional() + async cleanupEmptySessions(earlyThen: Date) { + // delete never used sessions + const { count: removed } = await this.db.aiSession.deleteMany({ + where: { + messageCost: 0, + deletedAt: null, + // filter session updated more than 24 hours ago + updatedAt: { lt: earlyThen }, + }, + }); + + // mark empty sessions as deleted + const { count: cleaned } = await this.db.aiSession.updateMany({ + where: { + deletedAt: null, + messages: { none: {} }, + // filter session updated more than 24 hours ago + updatedAt: { lt: earlyThen }, + }, + data: { + deletedAt: new Date(), + pinned: false, + }, + }); + + return { removed, cleaned }; + } + + @Transactional() + async toBeGenerateTitle(take: number) { + const sessions = await this.db.aiSession + .findMany({ + where: { + title: null, + deletedAt: null, + messages: { some: {} }, + // only generate titles for non-actions sessions + prompt: { action: null }, + }, + select: { + id: true, + // count assistant messages + _count: { select: { messages: { where: { role: 'assistant' } } } }, + }, + take, + orderBy: { updatedAt: 'desc' }, + }) + .then(s => s.filter(s => s._count.messages > 0)); + + return sessions; + } } diff --git a/packages/backend/server/src/plugins/copilot/cron.ts b/packages/backend/server/src/plugins/copilot/cron.ts new file mode 100644 index 0000000000..de8cd1eec4 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/cron.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { JobQueue, OneDay, OnJob } from '../../base'; +import { Models } from '../../models'; + +declare global { + interface Jobs { + 'copilot.session.cleanupEmptySessions': {}; + 'copilot.session.generateMissingTitles': {}; + } +} + +const GENERATE_TITLES_BATCH_SIZE = 100; + +@Injectable() +export class CopilotCronJobs { + private readonly logger = new Logger(CopilotCronJobs.name); + + constructor( + private readonly models: Models, + private readonly jobs: JobQueue + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async dailyCleanupJob() { + await this.jobs.add( + 'copilot.session.cleanupEmptySessions', + {}, + { jobId: 'daily-copilot-cleanup-empty-sessions' } + ); + + await this.jobs.add( + 'copilot.session.generateMissingTitles', + {}, + { jobId: 'daily-copilot-generate-missing-titles' } + ); + } + + @OnJob('copilot.session.cleanupEmptySessions') + async cleanupEmptySessions() { + const { removed, cleaned } = + await this.models.copilotSession.cleanupEmptySessions( + new Date(Date.now() - OneDay) + ); + + this.logger.log( + `Cleanup completed: ${removed} sessions deleted, ${cleaned} sessions marked as deleted` + ); + } + + @OnJob('copilot.session.generateMissingTitles') + async generateMissingTitles() { + const sessions = await this.models.copilotSession.toBeGenerateTitle( + GENERATE_TITLES_BATCH_SIZE + ); + + for (const session of sessions) { + await this.jobs.add('copilot.session.generateTitle', { + sessionId: session.id, + }); + } + this.logger.log( + `Scheduled title generation for ${sessions.length} sessions` + ); + } +} diff --git a/packages/backend/server/src/plugins/copilot/index.ts b/packages/backend/server/src/plugins/copilot/index.ts index 79bfe179df..bee64e4828 100644 --- a/packages/backend/server/src/plugins/copilot/index.ts +++ b/packages/backend/server/src/plugins/copilot/index.ts @@ -15,6 +15,7 @@ import { CopilotContextService, } from './context'; import { CopilotController } from './controller'; +import { CopilotCronJobs } from './cron'; import { CopilotEmbeddingJob } from './embedding'; import { ChatMessageCache } from './message'; import { PromptService } from './prompt'; @@ -64,6 +65,8 @@ import { CopilotContextResolver, CopilotContextService, CopilotEmbeddingJob, + // cron jobs + CopilotCronJobs, // transcription CopilotTranscriptionService, CopilotTranscriptionResolver, diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index ab7faa4fcf..d276cdbe12 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -304,6 +304,7 @@ const textActions: Prompt[] = [ name: 'Transcript audio', action: 'Transcript audio', model: 'gemini-2.5-flash', + optionalModels: ['gemini-2.5-flash', 'gemini-2.5-pro'], messages: [ { role: 'system',