87 lines
6.5 KiB
JavaScript
87 lines
6.5 KiB
JavaScript
import {DatabaseSync} from 'node:sqlite';
|
||
import {mkdirSync,chmodSync} from 'node:fs';
|
||
import {dirname,join} from 'node:path';
|
||
import {fileURLToPath} from 'node:url';
|
||
import {randomUUID,createHash} from 'node:crypto';
|
||
const root=process.env.IHALEIST_USER_DATA_DIR || join(dirname(fileURLToPath(import.meta.url)),'data');
|
||
const operations=new Set(['analiz','belge-sor','teklif-denetim','teklif-taslak']);
|
||
function parseObjectJson(raw,label) {
|
||
try { const value=JSON.parse(raw); if(!value || typeof value!=='object' || Array.isArray(value))throw new Error('nesne değil'); return value; }
|
||
catch { throw new Error(`${label} verisi bozuk; AI işi durduruldu.`); }
|
||
}
|
||
function dbWork(fn) {
|
||
mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'ai-jobs.sqlite'));chmodSync(join(root,'ai-jobs.sqlite'),0o600);
|
||
try {
|
||
db.exec(`PRAGMA busy_timeout=5000;
|
||
CREATE TABLE IF NOT EXISTS ai_jobs(id TEXT PRIMARY KEY,email TEXT NOT NULL,request_id TEXT NOT NULL,operation TEXT NOT NULL,ikn TEXT,
|
||
input TEXT NOT NULL,input_hash TEXT NOT NULL,state TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,
|
||
result TEXT,error TEXT,run_id TEXT,heartbeat INTEGER,UNIQUE(email,request_id));
|
||
CREATE INDEX IF NOT EXISTS job_owner ON ai_jobs(email,created_at);
|
||
CREATE INDEX IF NOT EXISTS job_queue ON ai_jobs(state,created_at);`);
|
||
// Serialize schema inspection with migration across server processes.
|
||
db.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
if(!db.prepare('PRAGMA table_info(ai_jobs)').all().some(column=>column.name==='resolved_input'))db.exec('ALTER TABLE ai_jobs ADD COLUMN resolved_input TEXT');
|
||
db.exec('COMMIT');
|
||
}catch(error){db.exec('ROLLBACK');throw error;}
|
||
return fn(db);
|
||
}finally {db.close();}
|
||
}
|
||
function publicJob(row) {
|
||
if(!row)return null;
|
||
return {id:row.id,operation:row.operation,ikn:row.ikn,state:row.state,createdAt:row.created_at,updatedAt:row.updated_at,
|
||
result:row.result?parseObjectJson(row.result,'AI sonucu'):null,error:row.error,inputHash:row.input_hash};
|
||
}
|
||
export function createAiJob(email,requestId,operation,input) {
|
||
if(typeof requestId!=='string'||requestId.length<8||requestId.length>100||!operations.has(operation)||!input||typeof input!=='object'||Array.isArray(input))throw new Error('Geçersiz AI işi.');
|
||
const serialized=JSON.stringify(input),hash=createHash('sha256').update(operation+'\n'+serialized).digest('hex');
|
||
if(Buffer.byteLength(serialized)>2*1024*1024)throw new Error('AI kaynak verisi çok büyük.');
|
||
return dbWork(db=>{
|
||
db.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
const previous=db.prepare('SELECT * FROM ai_jobs WHERE email=? AND request_id=?').get(email,requestId);
|
||
if(previous) {if(previous.input_hash!==hash)throw new Error('İstek kimliği farklı kaynak için kullanılmış.');db.exec('COMMIT');return publicJob(previous);}
|
||
if(db.prepare("SELECT COUNT(*) n FROM ai_jobs WHERE email=? AND state IN ('queued','running')").get(email).n>=5)throw new Error('En fazla beş etkin AI işi olabilir. Mevcut işleri tamamlayın veya iptal edin.');
|
||
const id=randomUUID(),now=new Date().toISOString();
|
||
db.prepare("INSERT INTO ai_jobs(id,email,request_id,operation,ikn,input,input_hash,state,created_at,updated_at) VALUES (?,?,?,?,?,?,?,'queued',?,?)")
|
||
.run(id,email,requestId,operation,input.ikn||input.tender?.ikn||null,serialized,hash,now,now);
|
||
const result=publicJob(db.prepare('SELECT * FROM ai_jobs WHERE id=?').get(id));db.exec('COMMIT');return result;
|
||
} catch(error){db.exec('ROLLBACK');throw error;}
|
||
});
|
||
}
|
||
export const getAiJob=(email,id)=>dbWork(db=>{const row=db.prepare('SELECT * FROM ai_jobs WHERE email=? AND id=?').get(email,id);return row?{...publicJob(row),input:parseObjectJson(row.resolved_input || row.input,'AI girdisi')}:null;});
|
||
export const listAiJobs=(email,ikn)=>dbWork(db=>db.prepare('SELECT * FROM ai_jobs WHERE email=? AND (? IS NULL OR ikn=?) ORDER BY created_at DESC LIMIT 50').all(email,ikn||null,ikn||null).map(publicJob));
|
||
export function claimAiJob(clock=Date.now()) {
|
||
return dbWork(db=>{
|
||
db.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
// Do not replay an uncertain provider call automatically after a lost worker.
|
||
db.prepare("UPDATE ai_jobs SET state='interrupted',error=?,updated_at=? WHERE state='running' AND heartbeat<?")
|
||
.run('İşlem kesildi. Kaynak korunuyor; yeniden başlatabilirsiniz.',new Date(clock).toISOString(),clock-60000);
|
||
const row=db.prepare("SELECT * FROM ai_jobs WHERE state='queued' ORDER BY created_at LIMIT 1").get();
|
||
if(!row){db.exec('COMMIT');return null;}
|
||
const runId=randomUUID();
|
||
db.prepare("UPDATE ai_jobs SET state='running',run_id=?,heartbeat=?,updated_at=? WHERE id=?").run(runId,clock,new Date(clock).toISOString(),row.id);
|
||
const input=parseObjectJson(row.resolved_input || row.input,'AI girdisi');
|
||
db.exec('COMMIT');return {id:row.id,email:row.email,operation:row.operation,input,sourcePinned:row.resolved_input!==null,runId};
|
||
}catch(error){db.exec('ROLLBACK');throw error;}
|
||
});
|
||
}
|
||
export const heartbeatAiJob=(id,runId)=>dbWork(db=>db.prepare("UPDATE ai_jobs SET heartbeat=? WHERE id=? AND run_id=? AND state='running'").run(Date.now(),id,runId).changes>0);
|
||
export const finishAiJob=(id,runId,result,error=null)=>dbWork(db=>db.prepare("UPDATE ai_jobs SET state=?,result=?,error=?,updated_at=? WHERE id=? AND run_id=? AND state='running'")
|
||
.run(error?'failed':'completed',error?null:JSON.stringify(result),error,new Date().toISOString(),id,runId).changes>0);
|
||
export const cancelAiJob=(email,id)=>dbWork(db=>db.prepare("UPDATE ai_jobs SET state='cancelled',error=NULL,updated_at=? WHERE id=? AND email=? AND state IN ('queued','running','interrupted','failed')").run(new Date().toISOString(),id,email).changes>0);
|
||
export const retryAiJob=(email,id)=>dbWork(db=>{
|
||
db.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
const active=db.prepare("SELECT COUNT(*) n FROM ai_jobs WHERE email=? AND state IN ('queued','running')").get(email).n;
|
||
if(active>=5)throw new Error('En fazla beş etkin AI işi olabilir.');
|
||
const changed=db.prepare("UPDATE ai_jobs SET state='queued',error=NULL,result=NULL,run_id=NULL,updated_at=? WHERE id=? AND email=? AND state IN ('interrupted','failed')").run(new Date().toISOString(),id,email).changes>0;
|
||
db.exec('COMMIT');return changed;
|
||
}catch(error){db.exec('ROLLBACK');throw error;}
|
||
});
|
||
|
||
// Only the current worker may pin source content; retries cannot replace it.
|
||
export const pinAiJobInput=(id,runId,input)=>dbWork(db=>db.prepare("UPDATE ai_jobs SET resolved_input=?,updated_at=? WHERE id=? AND run_id=? AND state='running' AND resolved_input IS NULL")
|
||
.run(JSON.stringify(input),new Date().toISOString(),id,runId).changes>0);
|