triflux 3.3.0-dev.1 → 3.3.0-dev.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hub/server.mjs CHANGED
@@ -13,22 +13,8 @@ import { createStore } from './store.mjs';
13
13
  import { createRouter } from './router.mjs';
14
14
  import { createHitlManager } from './hitl.mjs';
15
15
  import { createPipeServer } from './pipe.mjs';
16
+ import { createAssignCallbackServer } from './assign-callbacks.mjs';
16
17
  import { createTools } from './tools.mjs';
17
- import {
18
- ensurePipelineTable,
19
- createPipeline,
20
- } from './pipeline/index.mjs';
21
- import {
22
- readPipelineState,
23
- initPipelineState,
24
- listPipelineStates,
25
- } from './pipeline/state.mjs';
26
- import {
27
- teamInfo,
28
- teamTaskList,
29
- teamTaskUpdate,
30
- teamSendMessage,
31
- } from './team/nativeProxy.mjs';
32
18
 
33
19
  function isInitializeRequest(body) {
34
20
  if (body?.method === 'initialize') return true;
@@ -61,6 +47,22 @@ function isAllowedOrigin(origin) {
61
47
  return origin && ALLOWED_ORIGIN_RE.test(origin);
62
48
  }
63
49
 
50
+ function resolveTeamStatusCode(result) {
51
+ if (result?.ok) return 200;
52
+ const code = result?.error?.code;
53
+ if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
54
+ if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
55
+ if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
56
+ return 500;
57
+ }
58
+
59
+ function resolvePipelineStatusCode(result) {
60
+ if (result?.ok) return 200;
61
+ if (result?.error === 'pipeline_not_found') return 404;
62
+ if (result?.error === 'hub_db_not_found') return 503;
63
+ return 400;
64
+ }
65
+
64
66
  /**
65
67
  * tfx-hub 시작
66
68
  * @param {object} opts
@@ -82,6 +84,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
82
84
  const store = createStore(dbPath);
83
85
  const router = createRouter(store);
84
86
  const pipe = createPipeServer({ router, store, sessionId });
87
+ const assignCallbacks = createAssignCallbackServer({ store, sessionId });
85
88
  const hitl = createHitlManager(store, router);
86
89
  const tools = createTools(store, router, hitl, pipe);
87
90
  const transports = new Map();
@@ -145,6 +148,8 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
145
148
  port,
146
149
  pipe_path: pipe.path,
147
150
  pipe: pipe.getStatus(),
151
+ assign_callback_pipe_path: assignCallbacks.path,
152
+ assign_callback_pipe: assignCallbacks.getStatus(),
148
153
  }));
149
154
  }
150
155
 
@@ -245,28 +250,115 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
245
250
  return res.end(JSON.stringify(result));
246
251
  }
247
252
 
253
+ if (path === '/bridge/assign/async' && req.method === 'POST') {
254
+ const {
255
+ supervisor_agent,
256
+ worker_agent,
257
+ task,
258
+ topic = 'assign.job',
259
+ payload = {},
260
+ priority = 5,
261
+ ttl_ms = 600000,
262
+ timeout_ms = 600000,
263
+ max_retries = 0,
264
+ trace_id,
265
+ correlation_id,
266
+ } = body;
267
+
268
+ if (!supervisor_agent || !worker_agent || !task) {
269
+ res.writeHead(400);
270
+ return res.end(JSON.stringify({ ok: false, error: 'supervisor_agent, worker_agent, task 필수' }));
271
+ }
272
+
273
+ const result = await pipe.executeCommand('assign', {
274
+ supervisor_agent,
275
+ worker_agent,
276
+ task,
277
+ topic,
278
+ payload,
279
+ priority,
280
+ ttl_ms,
281
+ timeout_ms,
282
+ max_retries,
283
+ trace_id,
284
+ correlation_id,
285
+ });
286
+ res.writeHead(result.ok ? 200 : 400);
287
+ return res.end(JSON.stringify(result));
288
+ }
289
+
290
+ if (path === '/bridge/assign/result' && req.method === 'POST') {
291
+ const {
292
+ job_id,
293
+ worker_agent,
294
+ status,
295
+ attempt,
296
+ result: assignResult,
297
+ error: assignError,
298
+ payload = {},
299
+ metadata = {},
300
+ } = body;
301
+
302
+ if (!job_id || !status) {
303
+ res.writeHead(400);
304
+ return res.end(JSON.stringify({ ok: false, error: 'job_id, status 필수' }));
305
+ }
306
+
307
+ const result = await pipe.executeCommand('assign_result', {
308
+ job_id,
309
+ worker_agent,
310
+ status,
311
+ attempt,
312
+ result: assignResult,
313
+ error: assignError,
314
+ payload,
315
+ metadata,
316
+ });
317
+ res.writeHead(result.ok ? 200 : 409);
318
+ return res.end(JSON.stringify(result));
319
+ }
320
+
321
+ if (path === '/bridge/assign/status' && req.method === 'POST') {
322
+ const result = await pipe.executeQuery('assign_status', body);
323
+ const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
324
+ res.writeHead(statusCode);
325
+ return res.end(JSON.stringify(result));
326
+ }
327
+
328
+ if (path === '/bridge/assign/retry' && req.method === 'POST') {
329
+ const { job_id, reason, requested_by } = body;
330
+ if (!job_id) {
331
+ res.writeHead(400);
332
+ return res.end(JSON.stringify({ ok: false, error: 'job_id 필수' }));
333
+ }
334
+
335
+ const result = await pipe.executeCommand('assign_retry', {
336
+ job_id,
337
+ reason,
338
+ requested_by,
339
+ });
340
+ const statusCode = result.ok ? 200
341
+ : result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
342
+ : result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
343
+ : 400;
344
+ res.writeHead(statusCode);
345
+ return res.end(JSON.stringify(result));
346
+ }
347
+
248
348
  if (req.method === 'POST') {
249
349
  let teamResult = null;
250
350
  if (path === '/bridge/team/info' || path === '/bridge/team-info') {
251
- teamResult = await teamInfo(body);
351
+ teamResult = await pipe.executeQuery('team_info', body);
252
352
  } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
253
- teamResult = await teamTaskList(body);
353
+ teamResult = await pipe.executeQuery('team_task_list', body);
254
354
  } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
255
- teamResult = await teamTaskUpdate(body);
355
+ teamResult = await pipe.executeCommand('team_task_update', body);
256
356
  } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
257
- teamResult = await teamSendMessage(body);
357
+ teamResult = await pipe.executeCommand('team_send_message', body);
258
358
  }
259
359
 
260
360
  if (teamResult) {
261
- let status = 200;
262
- const code = teamResult?.error?.code;
263
- if (!teamResult.ok) {
264
- if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') status = 404;
265
- else if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') status = 409;
266
- else if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') status = 400;
267
- else status = 500;
268
- }
269
- res.writeHead(status);
361
+ res.writeHead(resolveTeamStatusCode(teamResult));
270
362
  return res.end(JSON.stringify(teamResult));
271
363
  }
272
364
 
@@ -277,51 +369,42 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
277
369
 
278
370
  // ── 파이프라인 엔드포인트 ──
279
371
  if (path === '/bridge/pipeline/state' && req.method === 'POST') {
280
- ensurePipelineTable(store.db);
281
- const { team_name } = body;
282
- const state = readPipelineState(store.db, team_name);
283
- res.writeHead(state ? 200 : 404);
284
- return res.end(JSON.stringify(state
285
- ? { ok: true, data: state }
286
- : { ok: false, error: 'pipeline_not_found' }));
372
+ const result = await pipe.executeQuery('pipeline_state', body);
373
+ res.writeHead(resolvePipelineStatusCode(result));
374
+ return res.end(JSON.stringify(result));
287
375
  }
288
376
 
289
377
  if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
290
- ensurePipelineTable(store.db);
291
- const { team_name, phase } = body;
292
- const pipeline = createPipeline(store.db, team_name);
293
- const result = pipeline.advance(phase);
294
- res.writeHead(result.ok ? 200 : 400);
378
+ const result = await pipe.executeCommand('pipeline_advance', body);
379
+ res.writeHead(resolvePipelineStatusCode(result));
295
380
  return res.end(JSON.stringify(result));
296
381
  }
297
382
 
298
383
  if (path === '/bridge/pipeline/init' && req.method === 'POST') {
299
- ensurePipelineTable(store.db);
300
- const { team_name, fix_max, ralph_max } = body;
301
- const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
302
- res.writeHead(200);
303
- return res.end(JSON.stringify({ ok: true, data: state }));
384
+ const result = await pipe.executeCommand('pipeline_init', body);
385
+ res.writeHead(resolvePipelineStatusCode(result));
386
+ return res.end(JSON.stringify(result));
304
387
  }
305
388
 
306
389
  if (path === '/bridge/pipeline/list' && req.method === 'POST') {
307
- ensurePipelineTable(store.db);
308
- const states = listPipelineStates(store.db);
309
- res.writeHead(200);
310
- return res.end(JSON.stringify({ ok: true, data: states }));
390
+ const result = await pipe.executeQuery('pipeline_list', body);
391
+ res.writeHead(resolvePipelineStatusCode(result));
392
+ return res.end(JSON.stringify(result));
311
393
  }
312
394
  }
313
395
 
314
396
  if (path === '/bridge/context' && req.method === 'POST') {
315
- const { agent_id, topics, max_messages = 10 } = body;
397
+ const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
316
398
  if (!agent_id) {
317
399
  res.writeHead(400);
318
400
  return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
319
401
  }
320
402
 
321
- const result = await pipe.executeQuery('context', {
403
+ const result = await pipe.executeQuery('drain', {
322
404
  agent_id,
323
405
  topics,
324
406
  max_messages,
407
+ auto_ack,
325
408
  });
326
409
  res.writeHead(200);
327
410
  return res.end(JSON.stringify(result));
@@ -441,6 +524,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
441
524
 
442
525
  mkdirSync(PID_DIR, { recursive: true });
443
526
  await pipe.start();
527
+ await assignCallbacks.start();
444
528
 
445
529
  return new Promise((resolve, reject) => {
446
530
  httpServer.listen(port, host, () => {
@@ -449,9 +533,12 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
449
533
  host,
450
534
  dbPath,
451
535
  pid: process.pid,
536
+ hubToken: HUB_TOKEN,
452
537
  url: `http://${host}:${port}/mcp`,
453
538
  pipe_path: pipe.path,
454
539
  pipePath: pipe.path,
540
+ assign_callback_pipe_path: assignCallbacks.path,
541
+ assignCallbackPipePath: assignCallbacks.path,
455
542
  };
456
543
 
457
544
  writeFileSync(PID_FILE, JSON.stringify({
@@ -461,10 +548,11 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
461
548
  url: info.url,
462
549
  pipe_path: pipe.path,
463
550
  pipePath: pipe.path,
551
+ assign_callback_pipe_path: assignCallbacks.path,
464
552
  started: Date.now(),
465
553
  }));
466
554
 
467
- console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} (PID ${process.pid})`);
555
+ console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
468
556
 
469
557
  const stopFn = async () => {
470
558
  router.stopSweeper();
@@ -475,13 +563,23 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
475
563
  }
476
564
  transports.clear();
477
565
  await pipe.stop();
566
+ await assignCallbacks.stop();
478
567
  store.close();
479
568
  try { unlinkSync(PID_FILE); } catch {}
480
569
  try { unlinkSync(TOKEN_FILE); } catch {}
481
570
  await new Promise((resolveClose) => httpServer.close(resolveClose));
482
571
  };
483
572
 
484
- resolve({ ...info, httpServer, store, router, hitl, pipe, stop: stopFn });
573
+ resolve({
574
+ ...info,
575
+ httpServer,
576
+ store,
577
+ router,
578
+ hitl,
579
+ pipe,
580
+ assignCallbacks,
581
+ stop: stopFn,
582
+ });
485
583
  });
486
584
  httpServer.on('error', reject);
487
585
  });
package/hub/store.mjs CHANGED
@@ -80,6 +80,17 @@ function parseHumanRequestRow(row) {
80
80
  };
81
81
  }
82
82
 
83
+ function parseAssignRow(row) {
84
+ if (!row) return null;
85
+ const { payload_json, result_json, error_json, ...rest } = row;
86
+ return {
87
+ ...rest,
88
+ payload: parseJson(payload_json, {}),
89
+ result: parseJson(result_json, null),
90
+ error: parseJson(error_json, null),
91
+ };
92
+ }
93
+
83
94
  /**
84
95
  * 저장소 생성
85
96
  * @param {string} dbPath
@@ -96,7 +107,7 @@ export function createStore(dbPath) {
96
107
 
97
108
  const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
98
109
  db.exec("CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)");
99
- const SCHEMA_VERSION = '1';
110
+ const SCHEMA_VERSION = '2';
100
111
  const curVer = (() => {
101
112
  try { return db.prepare("SELECT value FROM _meta WHERE key='schema_version'").pluck().get(); }
102
113
  catch { return null; }
@@ -162,6 +173,45 @@ export function createStore(dbPath) {
162
173
  insertDL: db.prepare('INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)'),
163
174
  getDL: db.prepare('SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?'),
164
175
 
176
+ insertAssign: db.prepare(`
177
+ INSERT INTO assign_jobs (
178
+ job_id, supervisor_agent, worker_agent, topic, task, payload_json,
179
+ status, attempt, retry_count, max_retries, priority, ttl_ms, timeout_ms, deadline_ms,
180
+ trace_id, correlation_id, last_message_id, result_json, error_json,
181
+ created_at_ms, updated_at_ms, started_at_ms, completed_at_ms, last_retry_at_ms
182
+ ) VALUES (
183
+ @job_id, @supervisor_agent, @worker_agent, @topic, @task, @payload_json,
184
+ @status, @attempt, @retry_count, @max_retries, @priority, @ttl_ms, @timeout_ms, @deadline_ms,
185
+ @trace_id, @correlation_id, @last_message_id, @result_json, @error_json,
186
+ @created_at_ms, @updated_at_ms, @started_at_ms, @completed_at_ms, @last_retry_at_ms
187
+ )`),
188
+ getAssign: db.prepare('SELECT * FROM assign_jobs WHERE job_id = ?'),
189
+ updateAssign: db.prepare(`
190
+ UPDATE assign_jobs SET
191
+ supervisor_agent=@supervisor_agent,
192
+ worker_agent=@worker_agent,
193
+ topic=@topic,
194
+ task=@task,
195
+ payload_json=@payload_json,
196
+ status=@status,
197
+ attempt=@attempt,
198
+ retry_count=@retry_count,
199
+ max_retries=@max_retries,
200
+ priority=@priority,
201
+ ttl_ms=@ttl_ms,
202
+ timeout_ms=@timeout_ms,
203
+ deadline_ms=@deadline_ms,
204
+ trace_id=@trace_id,
205
+ correlation_id=@correlation_id,
206
+ last_message_id=@last_message_id,
207
+ result_json=@result_json,
208
+ error_json=@error_json,
209
+ updated_at_ms=@updated_at_ms,
210
+ started_at_ms=@started_at_ms,
211
+ completed_at_ms=@completed_at_ms,
212
+ last_retry_at_ms=@last_retry_at_ms
213
+ WHERE job_id=@job_id`),
214
+
165
215
  findExpired: db.prepare("SELECT id FROM messages WHERE status='queued' AND expires_at_ms < ?"),
166
216
  urgentDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority >= 7"),
167
217
  normalDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority < 7"),
@@ -169,14 +219,46 @@ export function createStore(dbPath) {
169
219
  msgCount: db.prepare('SELECT COUNT(*) as cnt FROM messages'),
170
220
  dlqDepth: db.prepare('SELECT COUNT(*) as cnt FROM dead_letters'),
171
221
  ackedRecent: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='acked' AND created_at_ms > ? - 300000"),
222
+ assignCountByStatus: db.prepare('SELECT COUNT(*) as cnt FROM assign_jobs WHERE status = ?'),
223
+ activeAssignCount: db.prepare("SELECT COUNT(*) as cnt FROM assign_jobs WHERE status IN ('queued','running')"),
172
224
  };
173
225
 
226
+ const assignStatusListeners = new Set();
227
+
228
+ function buildAssignCallbackEvent(row) {
229
+ return {
230
+ job_id: row.job_id,
231
+ status: row.status,
232
+ result: row.result ?? row.error ?? null,
233
+ timestamp: new Date(row.updated_at_ms || Date.now()).toISOString(),
234
+ };
235
+ }
236
+
237
+ function notifyAssignStatusListeners(row) {
238
+ const event = buildAssignCallbackEvent(row);
239
+ for (const listener of Array.from(assignStatusListeners)) {
240
+ try { listener(event, row); } catch {}
241
+ }
242
+ }
243
+
174
244
  function clampMaxMessages(value, fallback = 20) {
175
245
  const num = Number(value);
176
246
  if (!Number.isFinite(num)) return fallback;
177
247
  return Math.max(1, Math.min(Math.trunc(num), 100));
178
248
  }
179
249
 
250
+ function clampPriority(value, fallback = 5) {
251
+ const num = Number(value);
252
+ if (!Number.isFinite(num)) return fallback;
253
+ return Math.max(1, Math.min(Math.trunc(num), 9));
254
+ }
255
+
256
+ function clampDuration(value, fallback = 600000, min = 1000, max = 86400000) {
257
+ const num = Number(value);
258
+ if (!Number.isFinite(num)) return fallback;
259
+ return Math.max(min, Math.min(Math.trunc(num), max));
260
+ }
261
+
180
262
  const store = {
181
263
  db,
182
264
  uuidv7,
@@ -365,6 +447,201 @@ export function createStore(dbPath) {
365
447
  return S.getDL.all(limit);
366
448
  },
367
449
 
450
+ createAssign({
451
+ job_id,
452
+ supervisor_agent,
453
+ worker_agent,
454
+ topic = 'assign.job',
455
+ task = '',
456
+ payload = {},
457
+ status = 'queued',
458
+ attempt = 1,
459
+ retry_count = 0,
460
+ max_retries = 0,
461
+ priority = 5,
462
+ ttl_ms = 600000,
463
+ timeout_ms = 600000,
464
+ deadline_ms,
465
+ trace_id,
466
+ correlation_id,
467
+ last_message_id = null,
468
+ result = null,
469
+ error = null,
470
+ }) {
471
+ const now = Date.now();
472
+ const normalizedTimeout = clampDuration(timeout_ms, 600000);
473
+ const row = {
474
+ job_id: job_id || uuidv7(),
475
+ supervisor_agent,
476
+ worker_agent,
477
+ topic: String(topic || 'assign.job'),
478
+ task: String(task || ''),
479
+ payload_json: JSON.stringify(payload || {}),
480
+ status,
481
+ attempt: Math.max(1, Number(attempt) || 1),
482
+ retry_count: Math.max(0, Number(retry_count) || 0),
483
+ max_retries: Math.max(0, Number(max_retries) || 0),
484
+ priority: clampPriority(priority, 5),
485
+ ttl_ms: clampDuration(ttl_ms, normalizedTimeout),
486
+ timeout_ms: normalizedTimeout,
487
+ deadline_ms: Number.isFinite(Number(deadline_ms))
488
+ ? Math.trunc(Number(deadline_ms))
489
+ : now + normalizedTimeout,
490
+ trace_id: trace_id || uuidv7(),
491
+ correlation_id: correlation_id || uuidv7(),
492
+ last_message_id,
493
+ result_json: result == null ? null : JSON.stringify(result),
494
+ error_json: error == null ? null : JSON.stringify(error),
495
+ created_at_ms: now,
496
+ updated_at_ms: now,
497
+ started_at_ms: status === 'running' ? now : null,
498
+ completed_at_ms: ['succeeded', 'failed', 'timed_out'].includes(status) ? now : null,
499
+ last_retry_at_ms: retry_count > 0 ? now : null,
500
+ };
501
+ S.insertAssign.run(row);
502
+ const inserted = store.getAssign(row.job_id);
503
+ notifyAssignStatusListeners(inserted);
504
+ return inserted;
505
+ },
506
+
507
+ getAssign(jobId) {
508
+ return parseAssignRow(S.getAssign.get(jobId));
509
+ },
510
+
511
+ updateAssignStatus(jobId, status, patch = {}) {
512
+ const current = store.getAssign(jobId);
513
+ if (!current) return null;
514
+
515
+ const now = Date.now();
516
+ const nextStatus = status || current.status;
517
+ const isTerminal = ['succeeded', 'failed', 'timed_out'].includes(nextStatus);
518
+ const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
519
+ const nextRow = {
520
+ job_id: current.job_id,
521
+ supervisor_agent: patch.supervisor_agent ?? current.supervisor_agent,
522
+ worker_agent: patch.worker_agent ?? current.worker_agent,
523
+ topic: patch.topic ?? current.topic,
524
+ task: patch.task ?? current.task,
525
+ payload_json: JSON.stringify(patch.payload ?? current.payload ?? {}),
526
+ status: nextStatus,
527
+ attempt: Math.max(1, Number(patch.attempt ?? current.attempt) || current.attempt || 1),
528
+ retry_count: Math.max(0, Number(patch.retry_count ?? current.retry_count) || 0),
529
+ max_retries: Math.max(0, Number(patch.max_retries ?? current.max_retries) || 0),
530
+ priority: clampPriority(patch.priority ?? current.priority, current.priority || 5),
531
+ ttl_ms: clampDuration(patch.ttl_ms ?? current.ttl_ms, current.ttl_ms || nextTimeout),
532
+ timeout_ms: nextTimeout,
533
+ deadline_ms: (() => {
534
+ if (Object.prototype.hasOwnProperty.call(patch, 'deadline_ms')) {
535
+ return patch.deadline_ms == null ? null : Math.trunc(Number(patch.deadline_ms));
536
+ }
537
+ if (isTerminal) return null;
538
+ if (nextStatus === 'running' && !current.deadline_ms) return now + nextTimeout;
539
+ return current.deadline_ms;
540
+ })(),
541
+ trace_id: patch.trace_id ?? current.trace_id,
542
+ correlation_id: patch.correlation_id ?? current.correlation_id,
543
+ last_message_id: Object.prototype.hasOwnProperty.call(patch, 'last_message_id')
544
+ ? patch.last_message_id
545
+ : current.last_message_id,
546
+ result_json: Object.prototype.hasOwnProperty.call(patch, 'result')
547
+ ? (patch.result == null ? null : JSON.stringify(patch.result))
548
+ : (current.result == null ? null : JSON.stringify(current.result)),
549
+ error_json: Object.prototype.hasOwnProperty.call(patch, 'error')
550
+ ? (patch.error == null ? null : JSON.stringify(patch.error))
551
+ : (current.error == null ? null : JSON.stringify(current.error)),
552
+ updated_at_ms: now,
553
+ started_at_ms: Object.prototype.hasOwnProperty.call(patch, 'started_at_ms')
554
+ ? patch.started_at_ms
555
+ : (nextStatus === 'running' ? (current.started_at_ms || now) : current.started_at_ms),
556
+ completed_at_ms: Object.prototype.hasOwnProperty.call(patch, 'completed_at_ms')
557
+ ? patch.completed_at_ms
558
+ : (isTerminal ? (current.completed_at_ms || now) : current.completed_at_ms),
559
+ last_retry_at_ms: Object.prototype.hasOwnProperty.call(patch, 'last_retry_at_ms')
560
+ ? patch.last_retry_at_ms
561
+ : current.last_retry_at_ms,
562
+ };
563
+ S.updateAssign.run(nextRow);
564
+ const updated = store.getAssign(jobId);
565
+ if (updated && current.status !== updated.status) {
566
+ notifyAssignStatusListeners(updated);
567
+ }
568
+ return updated;
569
+ },
570
+
571
+ listAssigns({
572
+ supervisor_agent,
573
+ worker_agent,
574
+ status,
575
+ statuses,
576
+ trace_id,
577
+ correlation_id,
578
+ active_before_ms,
579
+ limit = 50,
580
+ } = {}) {
581
+ const clauses = [];
582
+ const values = [];
583
+
584
+ if (supervisor_agent) {
585
+ clauses.push('supervisor_agent = ?');
586
+ values.push(supervisor_agent);
587
+ }
588
+ if (worker_agent) {
589
+ clauses.push('worker_agent = ?');
590
+ values.push(worker_agent);
591
+ }
592
+ if (trace_id) {
593
+ clauses.push('trace_id = ?');
594
+ values.push(trace_id);
595
+ }
596
+ if (correlation_id) {
597
+ clauses.push('correlation_id = ?');
598
+ values.push(correlation_id);
599
+ }
600
+
601
+ const statusList = Array.isArray(statuses) && statuses.length
602
+ ? statuses
603
+ : (status ? [status] : []);
604
+ if (statusList.length) {
605
+ clauses.push(`status IN (${statusList.map(() => '?').join(',')})`);
606
+ values.push(...statusList);
607
+ }
608
+
609
+ if (Number.isFinite(Number(active_before_ms))) {
610
+ clauses.push('deadline_ms IS NOT NULL AND deadline_ms <= ?');
611
+ values.push(Math.trunc(Number(active_before_ms)));
612
+ }
613
+
614
+ const sql = `
615
+ SELECT * FROM assign_jobs
616
+ ${clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''}
617
+ ORDER BY updated_at_ms DESC
618
+ LIMIT ?`;
619
+ values.push(clampMaxMessages(limit, 50));
620
+ return db.prepare(sql).all(...values).map(parseAssignRow);
621
+ },
622
+
623
+ retryAssign(jobId, patch = {}) {
624
+ const current = store.getAssign(jobId);
625
+ if (!current) return null;
626
+
627
+ const nextRetryCount = Math.max(0, Number(patch.retry_count ?? current.retry_count + 1) || 0);
628
+ const nextAttempt = Math.max(current.attempt + 1, Number(patch.attempt ?? current.attempt + 1) || 1);
629
+ const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
630
+ return store.updateAssignStatus(jobId, 'queued', {
631
+ retry_count: nextRetryCount,
632
+ attempt: nextAttempt,
633
+ timeout_ms: nextTimeout,
634
+ ttl_ms: patch.ttl_ms ?? current.ttl_ms,
635
+ deadline_ms: Date.now() + nextTimeout,
636
+ completed_at_ms: null,
637
+ started_at_ms: null,
638
+ last_retry_at_ms: Date.now(),
639
+ result: patch.result ?? null,
640
+ error: Object.prototype.hasOwnProperty.call(patch, 'error') ? patch.error : current.error,
641
+ last_message_id: null,
642
+ });
643
+ },
644
+
368
645
  sweepExpired() {
369
646
  const now = Date.now();
370
647
  return db.transaction(() => {
@@ -386,6 +663,16 @@ export function createStore(dbPath) {
386
663
  };
387
664
  },
388
665
 
666
+ onAssignStatusChange(listener) {
667
+ if (typeof listener !== 'function') {
668
+ return () => {};
669
+ }
670
+ assignStatusListeners.add(listener);
671
+ return () => {
672
+ assignStatusListeners.delete(listener);
673
+ };
674
+ },
675
+
389
676
  getDeliveryStats() {
390
677
  return {
391
678
  total_deliveries: S.ackedRecent.get(Date.now()).cnt,
@@ -397,6 +684,7 @@ export function createStore(dbPath) {
397
684
  return {
398
685
  online_agents: S.onlineCount.get().cnt,
399
686
  total_messages: S.msgCount.get().cnt,
687
+ active_assign_jobs: S.activeAssignCount.get().cnt,
400
688
  ...store.getQueueDepths(),
401
689
  };
402
690
  },
@@ -406,6 +694,10 @@ export function createStore(dbPath) {
406
694
  online_agents: S.onlineCount.get().cnt,
407
695
  total_messages: S.msgCount.get().cnt,
408
696
  dlq: S.dlqDepth.get().cnt,
697
+ assign_queued: S.assignCountByStatus.get('queued').cnt,
698
+ assign_running: S.assignCountByStatus.get('running').cnt,
699
+ assign_failed: S.assignCountByStatus.get('failed').cnt,
700
+ assign_timed_out: S.assignCountByStatus.get('timed_out').cnt,
409
701
  };
410
702
  },
411
703
  };