Persist offer assistant jobs and recover results across closed views

This commit is contained in:
2026-09-06 12:35:30 +03:00
parent 2e16a68565
commit f830625b60
12 changed files with 297 additions and 3 deletions
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {runDurableAiJob} from '../src/services/aiJobs.js';
test('durable client polls separate requests until completion and creates a job only once',async()=>{
let calls=0;const states=[];
const result=await runDurableAiJob('teklif-taslak',{ikn:'2026/1'},{check:()=>{},pollMs:1,onProgress:job=>states.push(job.state),request:async(url,options)=>{
calls++;
if(calls===1){assert.equal(options.method,'POST');assert.ok(JSON.parse(options.body).requestId);return {job:{id:'a',state:'queued'}};}
assert.ok(url.includes('id=a'));assert.equal(options.method,undefined);
return calls===2?{job:{id:'a',state:'running'}}:{job:{id:'a',state:'completed',result:{success:true}}};
}});
assert.deepEqual(states,['queued','running','completed']);assert.equal(calls,3);assert.equal(result.success,true);
});
test('closing the viewer stops polling without deleting a durable job',async()=>{
const controller=new AbortController();let calls=0;
await assert.rejects(runDurableAiJob('belge-sor',{ikn:'2026/1'},{signal:controller.signal,check:()=>controller.signal.throwIfAborted(),pollMs:1,onProgress:()=>controller.abort(),request:async()=>{calls++;return {job:{id:'a',state:'running'}};}}),{name:'AbortError'});
assert.equal(calls,1);
});
+47
View File
@@ -0,0 +1,47 @@
import test,{after} from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,rmSync} from 'node:fs';
import {join} from 'node:path';
import {tmpdir} from 'node:os';
const dir=mkdtempSync(join(tmpdir(),'ihaleist-jobs-'));process.env.IHALEIST_USER_DATA_DIR=dir;
const jobs=await import('../server/ai-job-store.mjs');
after(()=>rmSync(dir,{recursive:true,force:true}));
test('durable jobs are idempotent and account scoped',()=>{
const first=jobs.createAiJob('a','request-1','teklif-denetim',{ikn:'2026/1',taslak:'private'});
assert.equal(jobs.createAiJob('a','request-1','teklif-denetim',{ikn:'2026/1',taslak:'private'}).id,first.id);
assert.throws(()=>jobs.createAiJob('a','request-1','teklif-denetim',{taslak:'other'}),/farklı kaynak/);
assert.equal(jobs.getAiJob('b',first.id),null);assert.deepEqual(jobs.listAiJobs('b'),[]);
assert.equal(jobs.cancelAiJob('b',first.id),false);
const claimed=jobs.claimAiJob();assert.equal(claimed.input.taslak,'private');
assert.equal(jobs.finishAiJob(first.id,'wrong',{success:true}),false);
assert.equal(jobs.finishAiJob(first.id,claimed.runId,{success:true,answer:'done'}),true);
assert.equal(jobs.getAiJob('a',first.id).result.answer,'done');
assert.equal(jobs.getAiJob('a',first.id).input.taslak,'private');
assert.equal(jobs.listAiJobs('a')[0].input,undefined);
});
test('lost workers become interrupted without automatic provider replay; retry and cancellation reject late results',()=>{
const first=jobs.createAiJob('a','request-2','teklif-taslak',{ikn:'2026/2'});
const claimed=jobs.claimAiJob(Date.now()-120000);
assert.equal(jobs.claimAiJob(),null);
assert.equal(jobs.getAiJob('a',first.id).state,'interrupted');
assert.equal(jobs.finishAiJob(first.id,claimed.runId,{success:true}),false);
assert.equal(jobs.retryAiJob('a',first.id),true);
const restarted=jobs.claimAiJob();assert.notEqual(restarted.runId,claimed.runId);
assert.equal(jobs.cancelAiJob('a',first.id),true);
assert.equal(jobs.heartbeatAiJob(first.id,restarted.runId),false);
assert.equal(jobs.finishAiJob(first.id,restarted.runId,{success:true}),false);
assert.equal(jobs.getAiJob('a',first.id).state,'cancelled');
});
test('runner stores results independently of an HTTP connection',async()=>{
const {startAiJobRunner}=await import('../server/ai-job-runner.mjs');
const job=jobs.createAiJob('runner','request-run','belge-sor',{ikn:'2026/8',soru:'teslim'});
let finish;
const runner=startAiJobRunner(async()=>new Promise(resolve=>{finish=resolve;}),{intervalMs:100000});
try {
runner.wake();assert.equal(jobs.getAiJob('runner',job.id).state,'running');
finish({success:true,cevap:'Stored answer'});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(jobs.getAiJob('runner',job.id).result.cevap,'Stored answer');
} finally {runner.stop();}
});
+8
View File
@@ -180,3 +180,11 @@ test('source history requires authentication and distinguishes empty results fro
assert.equal((await request('/api/tenders/source-history?ikn=2026/123&before=bad')).status,400);
assert.equal((await request('/api/tenders/source-history?ikn=2026/123','POST',{})).status,405);
});
test('durable AI job endpoints enforce account isolation without starting provider calls',async()=>{
assert.equal((await request('/api/me/ai-jobs','GET',null,null)).status,401);
const list=await request('/api/me/ai-jobs?ikn=2026/123');assert.equal(list.status,200);assert.deepEqual(list.data.jobs,[]);
assert.equal((await request('/api/me/ai-jobs?id=missing')).status,404);
assert.equal((await request('/api/me/ai-jobs?id=missing','DELETE',null,other)).status,404);
assert.equal((await request('/api/me/ai-jobs','PUT',{})).status,405);
});