UNPKG

@dondonudonjp/vertexai-imagen-mcp-server

Version:

[DEPRECATED] MCP Server for Vertex AI Imagen image generation. Imagen endpoints shut down 2026-06-30; migrate to the nanoBanana MCP Server (Gemini image models).

265 lines 9.9 kB
/** * ジョブキュー管理とワーカー */ import { randomUUID } from 'crypto'; import { generateImage } from '../tools/generateImage.js'; import { editImage } from '../tools/editImage.js'; import { customizeImage } from '../tools/customizeImage.js'; import { upscaleImage } from '../tools/upscaleImage.js'; import { generateAndUpscaleImage } from '../tools/generateAndUpscaleImage.js'; import { logErrorWithStack, getErrorMessage } from './error.js'; export class JobManager { db; auth; resourceManager; maxConcurrentJobs; runningJobs = new Set(); cancelledJobs = new Set(); cleanupInterval = null; processingLock = false; constructor(db, auth, resourceManager, maxConcurrentJobs = 2) { this.db = db; this.auth = auth; this.resourceManager = resourceManager; this.maxConcurrentJobs = maxConcurrentJobs; // 起動時に未完了のジョブを再開 this.resumePendingJobs(); // 定期的にcancelledJobsをクリーンアップ(メモリリーク防止) this.cleanupInterval = setInterval(() => { this.cleanupCancelledJobs(); }, 600000); // 10分ごと } /** * 新しいジョブを作成 */ createJob(type, params) { const jobId = randomUUID(); const job = { id: jobId, type, status: 'pending', createdAt: new Date(), params, }; this.db.createJob(job); // ジョブ実行を開始 this.processJobs(); return jobId; } /** * ジョブを取得 */ getJob(jobId) { return this.db.getJob(jobId); } /** * ジョブ一覧を取得 */ listJobs(status, limit) { return this.db.listJobs(status, limit); } /** * ジョブをキャンセル */ cancelJob(jobId) { const job = this.db.getJob(jobId); if (!job) return false; if (job.status === 'completed' || job.status === 'failed') { return false; // 既に完了またはfailedの場合はキャンセル不可 } this.cancelledJobs.add(jobId); if (job.status === 'pending') { // pendingの場合は即座にfailedに設定 this.db.updateJobError(jobId, 'Job cancelled by user'); } // runningの場合は実行中のジョブが自身でキャンセルをチェック return true; } /** * 未完了のジョブを再開 */ resumePendingJobs() { const pendingJobs = this.db.getRunningJobs(); if (pendingJobs.length > 0) { if (process.env.DEBUG) { console.error(`[DEBUG] Resuming ${pendingJobs.length} pending job(s)`); } // pending/runningステータスのジョブをpendingにリセット(トランザクション内で実行) this.db.transaction(() => { for (const job of pendingJobs) { if (job.status === 'running') { this.db.updateJobStatus(job.id, 'pending'); } } }); this.processJobs(); } } /** * ジョブキューを処理 * * Race condition防止のため、ロック機構を使用して並行実行を防止 */ async processJobs() { // ロックチェック:既に処理中の場合は即座にリターン if (this.processingLock) { return; } this.processingLock = true; try { // 空きスロットがある限り、pendingジョブを1つずつ処理 while (this.runningJobs.size < this.maxConcurrentJobs) { const pendingJobs = this.db.getRunningJobs(); // pending状態で、かつまだrunningJobsに登録されていないジョブを検索 const job = pendingJobs.find(j => j.status === 'pending' && !this.runningJobs.has(j.id)); if (!job) { // 処理可能なジョブがない場合は終了 break; } // アトミックに実行中としてマーク(重複実行を防止) this.runningJobs.add(job.id); this.db.updateJobStatus(job.id, 'running', new Date()); // ジョブを非同期実行(awaitしない = fire and forget) // Promise内部でエラーハンドリングとクリーンアップを実行 this.executeJob(job); } } finally { // 必ずロックを解放 this.processingLock = false; } } /** * ジョブを実行 * * 注意: processJobs()内で既にrunningJobsへの追加とステータス更新が完了しているため、 * このメソッドではそれらの処理は行わない */ async executeJob(job) { const jobId = job.id; // キャンセルチェック if (this.cancelledJobs.has(jobId)) { this.db.updateJobError(jobId, 'Job cancelled by user'); this.cancelledJobs.delete(jobId); this.runningJobs.delete(jobId); // 次のジョブを処理 this.processJobs(); return; } try { const context = { auth: this.auth, resourceManager: this.resourceManager, jobManager: this, // 自身を参照(循環参照だが、実行時には既にインスタンス化されている) historyDb: this.db, // 履歴トラッキング用 }; let result; switch (job.type) { case 'generate': result = await generateImage(context, job.params); break; case 'edit': result = await editImage(context, job.params); break; case 'customize': result = await customizeImage(context, job.params); break; case 'upscale': result = await upscaleImage(context, job.params); break; case 'generate_and_upscale': result = await generateAndUpscaleImage(context, job.params); break; default: throw new Error(`Unknown job type: ${job.type}`); } // 再度キャンセルチェック if (this.cancelledJobs.has(jobId)) { this.db.updateJobError(jobId, 'Job cancelled by user'); this.cancelledJobs.delete(jobId); } else { // 結果からURI情報を抽出 const jobResult = this.extractJobResult(result); this.db.updateJobResult(jobId, jobResult); } } catch (error) { const errorMessage = getErrorMessage(error); this.db.updateJobError(jobId, errorMessage); // エラーをスタックトレース付きでログ出力 logErrorWithStack(`Job ${jobId} failed`, error); } finally { this.runningJobs.delete(jobId); this.cancelledJobs.delete(jobId); // 次のジョブを処理 this.processJobs(); } } /** * cancelledJobsをクリーンアップ(メモリリーク防止) * 完了済みの古いジョブをSetから削除 */ cleanupCancelledJobs() { const OLD_JOB_THRESHOLD = 3600000; // 1時間 const now = Date.now(); for (const jobId of this.cancelledJobs) { const job = this.db.getJob(jobId); // ジョブが存在しない、または完了して1時間以上経過している場合は削除 if (!job || (job.completedAt && (now - job.completedAt.getTime() > OLD_JOB_THRESHOLD))) { this.cancelledJobs.delete(jobId); if (process.env.DEBUG) { console.error(`[DEBUG] Cleaned up cancelled job: ${jobId}`); } } } } /** * JobManagerを破棄(クリーンアップインターバルを停止) */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } /** * ツール実行結果からジョブ結果を抽出 */ extractJobResult(toolResult) { // MCPレスポンス形式から実際のデータを抽出 if (!toolResult || !toolResult.content || toolResult.content.length === 0) { throw new Error('Invalid tool result format'); } const content = toolResult.content[0]; if (content.type === 'resource') { // 単一画像の場合 return { outputPath: content.resource.uri, uri: content.resource.uri, mimeType: content.resource.mimeType, }; } else if (content.type === 'text') { // 複数画像または詳細情報を含むテキスト const text = content.text; // URIを抽出(file://で始まる行を探す) const uriMatches = text.match(/file:\/\/[^\s\n]+/g); if (uriMatches) { return { outputPaths: uriMatches, uris: uriMatches, mimeType: 'image/png', // デフォルト }; } return { message: text, }; } return toolResult; } } //# sourceMappingURL=jobManager.js.map