返回 oh-my-ppt
page-edit-job-service.ts
根目录 / src / main / edit-jobs / page-edit-job-service.ts
1 import { ipcMain } from 'electron'
2 import crypto from 'crypto'
3 import fs from 'fs'
4 import log from 'electron-log/main.js'
5 import path from 'path'
6 import type { IpcContext } from '../ipc/context'
7 import { assessPageEdit, executeEditGeneration, resolveEditContext } from '../generation/edit-flow'
8 import { createEmitAssistantMessage } from '../generation/generation-utils'
9 import { createGenerationContext, normalizeGeneratePayload } from '../generation/context'
10 import type { EditContext } from '../generation/types'
11 import { isCancellationMessage, normalizeRestoredSessionStatus } from '../generation/status-utils'
12 import { resolvePageHtmlPath } from '../generation/generation-utils'
13 import { JobCoordinator, sessionLockKey, type JobLease } from '../agent-runtime'
14 import { settleEditJobFailure, settleEditJobSuccess } from './edit-job-finalization'
15 import { restorePageEditSnapshots, type PageEditFileSnapshot } from './page-edit-rollback'
16
17 type ActivePageEditJob = {
18 sessionId: string
19 runId: string
20 lease: JobLease
21 context: EditContext
22 }
23
24 type ActivePageEditAssessment = {
25 jobId: string
26 cancelled: boolean
27 settled: Promise<void>
28 settle(): void
29 }
30
31 type PageEditRunSnapshot = {
32 sessionId: string
33 runId: string | null
34 status: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
35 hasActiveRun: boolean
36 progress: number
37 totalPages: number
38 completedPageCount: number
39 failedPageCount: number
40 events: never[]
41 error: string | null
42 startedAt: number | null
43 updatedAt: number | null
44 kind: 'page-edit'
45 targetPageId?: string
46 targetPageNumber?: number
47 }
48
49 export class PageEditJobService {
50 private activeJobs = new Map<string, ActivePageEditJob>()
51 private reservedJobIds = new Map<string, string>()
52 private activeAssessments = new Map<string, ActivePageEditAssessment>()
53
54 constructor(private ctx: IpcContext, private coordinator: JobCoordinator) {}
55
56 async start(event: Electron.IpcMainInvokeEvent, payload: unknown): Promise<{
57 success: boolean
58 runId?: string
59 alreadyRunning?: boolean
60 }> {
61 const input = normalizeGeneratePayload(payload)
62 if (!input.sessionId) throw new Error('sessionId 不能为空')
63 if (input.requestedType !== 'page' || input.chatType !== 'page') {
64 throw new Error('page-edit:start 仅支持当前页面编辑')
65 }
66 if (!input.approvedPlan && !input.autoApply) {
67 throw new Error('请先确认页面修改计划,再执行编辑。')
68 }
69
70 await this.cancelAssessment(input.sessionId)
71
72 const runId = crypto.randomUUID()
73 const reservation = await this.coordinator.reserve({
74 jobId: runId,
75 domain: 'edit',
76 owner: { kind: 'session', id: input.sessionId },
77 claims: { write: [sessionLockKey(input.sessionId)] },
78 wait: 'fail'
79 })
80 if (reservation.status === 'busy') {
81 return { success: true, runId: reservation.conflictingJobId, alreadyRunning: true }
82 }
83 const lease = reservation.lease
84 this.reservedJobIds.set(input.sessionId, lease.jobId)
85 let context: EditContext | null = null
86 let jobCreated = false
87 try {
88 const editContext = await resolveEditContext(createGenerationContext(this.ctx), event, payload, {
89 runId: lease.jobId,
90 abortSignal: lease.signal
91 })
92 context = editContext
93 if (lease.signal.aborted) throw new Error('生成已取消')
94 if (editContext.runId !== lease.jobId) {
95 throw new Error('页面编辑 runId 与 JobCoordinator lease 不一致')
96 }
97 const targetPage = (await this.ctx.db.listSessionPages(editContext.sessionId)).find(
98 (page) =>
99 page.id === editContext.selectedPageId || page.file_slug === editContext.selectedPageId
100 )
101 if (!targetPage) throw new Error('页面编辑任务缺少目标页面')
102
103 await this.ctx.db.createGenerationRunWithSessionJob({
104 run: {
105 id: editContext.runId,
106 sessionId: editContext.sessionId,
107 mode: 'edit',
108 totalPages: 1,
109 modelConfigId: editContext.modelConfigId,
110 metadata: {
111 jobType: 'page-edit',
112 targetPageId: targetPage.file_slug,
113 targetPageNumber: targetPage.page_number,
114 selector: editContext.selector || null
115 }
116 },
117 job: {
118 id: editContext.runId,
119 sessionId: editContext.sessionId,
120 kind: 'page-edit',
121 status: 'active',
122 targetPageId: targetPage.file_slug,
123 targetPageNumber: targetPage.page_number,
124 selector: editContext.selector,
125 totalPages: 1,
126 previousSessionStatus: normalizeRestoredSessionStatus(editContext.previousSessionStatus)
127 }
128 })
129 jobCreated = true
130 if (lease.signal.aborted) throw new Error('生成已取消')
131 this.ctx.beginSessionRunState({
132 sessionId: editContext.sessionId,
133 runId: editContext.runId,
134 mode: 'edit',
135 kind: 'page-edit',
136 activityKind: 'page-edit',
137 targetPageId: targetPage.file_slug,
138 targetPageNumber: targetPage.page_number,
139 totalPages: 1,
140 previousSessionStatus: editContext.previousSessionStatus,
141 status: 'running'
142 })
143
144 const job: ActivePageEditJob = {
145 sessionId: editContext.sessionId,
146 runId: editContext.runId,
147 lease,
148 context: editContext
149 }
150 this.activeJobs.set(editContext.sessionId, job)
151 void this.run(job)
152 return { success: true, runId: editContext.runId }
153 } catch (error) {
154 try {
155 if (context) {
156 const message = error instanceof Error ? error.message : String(error || '')
157 await settleEditJobFailure({
158 ctx: this.ctx,
159 context,
160 error,
161 cancelled: lease.signal.aborted || isCancellationMessage(message),
162 hasPersistedJob: jobCreated,
163 logPrefix: '[page-edit:job]'
164 })
165 }
166 } finally {
167 lease.release()
168 this.reservedJobIds.delete(input.sessionId)
169 if (context) this.ctx.agentManager.removeSession(context.sessionId)
170 }
171 throw error
172 }
173 }
174
175 async assess(payload: unknown) {
176 const input = normalizeGeneratePayload(payload)
177 if (!input.sessionId) throw new Error('sessionId 不能为空')
178 await this.cancelAssessment(input.sessionId)
179
180 const activeRun = this.ctx.sessionRunStates.get(input.sessionId)
181 if (
182 this.coordinator.getByOwner({ kind: 'session', id: input.sessionId }) ||
183 activeRun?.status === 'queued' ||
184 activeRun?.status === 'running'
185 ) {
186 throw new Error('当前有页面修改任务正在执行')
187 }
188
189 let settle!: () => void
190 const assessment: ActivePageEditAssessment = {
191 jobId: crypto.randomUUID(),
192 cancelled: false,
193 settled: new Promise<void>((resolve) => {
194 settle = resolve
195 }),
196 settle
197 }
198 this.activeAssessments.set(input.sessionId, assessment)
199 let lease: JobLease | null = null
200 try {
201 const reservation = await this.coordinator.reserve({
202 jobId: assessment.jobId,
203 domain: 'edit',
204 owner: { kind: 'session', id: input.sessionId },
205 claims: { read: [sessionLockKey(input.sessionId)] },
206 wait: 'fail'
207 })
208 if (reservation.status === 'busy') throw new Error('当前有页面修改任务正在执行')
209 lease = reservation.lease
210 return await assessPageEdit(createGenerationContext(this.ctx), payload, lease.signal)
211 } catch (error) {
212 if (
213 assessment.cancelled ||
214 lease?.signal.aborted ||
215 (error instanceof Error && error.name === 'AbortError')
216 ) {
217 throw new Error('生成已取消')
218 }
219 throw error
220 } finally {
221 lease?.release()
222 if (this.activeAssessments.get(input.sessionId) === assessment) {
223 this.activeAssessments.delete(input.sessionId)
224 }
225 assessment.settle()
226 }
227 }
228
229 async cancel(sessionId: string): Promise<boolean> {
230 if (await this.cancelAssessment(sessionId)) return true
231 const job = this.activeJobs.get(sessionId)
232 if (!job) {
233 const jobId = this.reservedJobIds.get(sessionId)
234 return jobId ? this.coordinator.cancel(jobId) : false
235 }
236 return this.coordinator.cancel(job.lease.jobId)
237 }
238
239 private async cancelAssessment(sessionId: string): Promise<boolean> {
240 const assessment = this.activeAssessments.get(sessionId)
241 if (!assessment) return false
242 assessment.cancelled = true
243 const cancelled = this.coordinator.cancel(assessment.jobId)
244 await assessment.settled
245 return cancelled
246 }
247
248 async getState(sessionId: string): Promise<PageEditRunSnapshot> {
249 const activeState = this.ctx.sessionRunStates.get(sessionId)
250 if (activeState?.activityKind === 'page-edit') {
251 return {
252 sessionId,
253 runId: activeState.runId,
254 status: activeState.status,
255 hasActiveRun: activeState.status === 'queued' || activeState.status === 'running',
256 progress: activeState.progress,
257 totalPages: activeState.totalPages,
258 completedPageCount: activeState.completedPageKeys.length,
259 failedPageCount: activeState.failedPageKeys.length,
260 events: [],
261 error: activeState.error,
262 startedAt: activeState.startedAt,
263 updatedAt: activeState.updatedAt,
264 kind: 'page-edit',
265 targetPageId: activeState.targetPageId,
266 targetPageNumber: activeState.targetPageNumber
267 }
268 }
269
270 const job = await this.ctx.db.getLatestSessionJob(sessionId, ['page-edit'])
271 if (job?.status === 'active') {
272 return {
273 sessionId,
274 runId: job.id,
275 status: 'running',
276 hasActiveRun: true,
277 progress: 0,
278 totalPages: 1,
279 completedPageCount: 0,
280 failedPageCount: 0,
281 events: [],
282 error: null,
283 startedAt: job.activated_at || job.created_at,
284 updatedAt: job.updated_at,
285 kind: 'page-edit',
286 targetPageId: job.target_page_id || undefined,
287 targetPageNumber: job.target_page_number || undefined
288 }
289 }
290
291 return {
292 sessionId,
293 runId: job?.id || null,
294 status: job?.status === 'aborted' ? 'cancelled' : 'idle',
295 hasActiveRun: false,
296 progress: 0,
297 totalPages: 1,
298 completedPageCount: 0,
299 failedPageCount: 0,
300 events: [],
301 error: job?.abort_reason || null,
302 startedAt: job?.created_at || null,
303 updatedAt: job?.updated_at || null,
304 kind: 'page-edit',
305 targetPageId: job?.target_page_id || undefined,
306 targetPageNumber: job?.target_page_number || undefined
307 }
308 }
309
310 async listActive(): Promise<PageEditRunSnapshot[]> {
311 const jobs = await this.ctx.db.listActiveSessionJobs(['page-edit'])
312 return Promise.all(jobs.map((job) => this.getState(job.session_id)))
313 }
314
315 async abortInterruptedJobs(reason: string): Promise<void> {
316 const jobs = await this.ctx.db.listActiveSessionJobs(['page-edit'])
317 for (const job of jobs) {
318 if (this.activeJobs.has(job.session_id)) continue
319 await this.ctx.db.updateSessionJobStatus(job.id, 'aborted', { abortReason: reason })
320 await this.ctx.db.updateGenerationRunStatus(job.id, 'failed', reason)
321 await this.ctx.db.updateSessionStatus(
322 job.session_id,
323 normalizeRestoredSessionStatus(job.previous_session_status)
324 )
325 }
326 }
327
328 private async run(job: ActivePageEditJob): Promise<void> {
329 const emitAssistant = createEmitAssistantMessage(this.ctx.db, this.ctx.emitGenerateChunk)
330 let snapshots: PageEditFileSnapshot[] = []
331 try {
332 const pages = await this.ctx.db.listSessionPages(job.sessionId)
333 const targetPage = pages.find(
334 (page) => page.id === job.context.selectedPageId || page.file_slug === job.context.selectedPageId
335 )
336 const targetPagePath = targetPage
337 ? resolvePageHtmlPath({
338 projectDir: job.context.projectDir,
339 fileSlug: targetPage.file_slug,
340 candidates: [targetPage.html_path]
341 })
342 : null
343 const snapshotPaths = Array.from(
344 new Set([targetPagePath, path.join(job.context.projectDir, 'index.html')].filter(Boolean))
345 ) as string[]
346 snapshots = await Promise.all(
347 snapshotPaths.map(async (filePath) => ({
348 path: filePath,
349 exists: fs.existsSync(filePath),
350 content: fs.existsSync(filePath) ? await fs.promises.readFile(filePath, 'utf-8') : ''
351 }))
352 )
353 await executeEditGeneration(createGenerationContext(this.ctx), emitAssistant, job.context)
354 await settleEditJobSuccess({ ctx: this.ctx, context: job.context })
355 } catch (error) {
356 const message = error instanceof Error ? error.message : String(error || '')
357 const cancelled = job.lease.signal.aborted || isCancellationMessage(message)
358 if (cancelled) {
359 const rollbackFailures = await restorePageEditSnapshots(snapshots)
360 rollbackFailures.forEach((failure) => {
361 log.error('[page-edit:job] failed to restore cancelled file', {
362 sessionId: job.sessionId,
363 runId: job.runId,
364 path: failure.path,
365 message:
366 failure.error instanceof Error ? failure.error.message : String(failure.error || '')
367 })
368 })
369 }
370 await settleEditJobFailure({
371 ctx: this.ctx,
372 context: job.context,
373 error,
374 cancelled,
375 hasPersistedJob: true,
376 logPrefix: '[page-edit:job]'
377 })
378 } finally {
379 this.ctx.agentManager.removeSession(job.sessionId)
380 this.activeJobs.delete(job.sessionId)
381 this.reservedJobIds.delete(job.sessionId)
382 job.lease.release()
383 }
384 }
385 }
386
387 export function registerPageEditJobHandlers(
388 ctx: IpcContext,
389 coordinator: JobCoordinator
390 ): PageEditJobService {
391 const service = new PageEditJobService(ctx, coordinator)
392 const interruptedReady = service.abortInterruptedJobs('应用退出导致页面编辑中断,可重新发起').catch((error) => {
393 log.warn('[page-edit:job] failed to abort interrupted jobs', {
394 message: error instanceof Error ? error.message : String(error)
395 })
396 })
397
398 ipcMain.handle('page-edit:assess', async (_event, payload) => {
399 await interruptedReady
400 return service.assess(payload)
401 })
402 ipcMain.handle('page-edit:start', async (event, payload) => {
403 await interruptedReady
404 return service.start(event, payload)
405 })
406 ipcMain.handle('page-edit:cancel', async (_event, rawSessionId) => {
407 await interruptedReady
408 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
409 return { success: sessionId ? await service.cancel(sessionId) : true }
410 })
411 ipcMain.handle('page-edit:state', async (_event, rawSessionId) => {
412 await interruptedReady
413 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
414 if (!sessionId) throw new Error('sessionId 不能为空')
415 return service.getState(sessionId)
416 })
417 ipcMain.handle('page-edit:listActive', async () => {
418 await interruptedReady
419 return service.listActive()
420 })
421 return service
422 }
423
423 lines TYPESCRIPT