| 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 |