triflux 3.2.0-dev.9 → 3.3.0-dev.3

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
@@ -14,6 +14,15 @@ import { createRouter } from './router.mjs';
14
14
  import { createHitlManager } from './hitl.mjs';
15
15
  import { createPipeServer } from './pipe.mjs';
16
16
  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';
17
26
  import {
18
27
  teamInfo,
19
28
  teamTaskList,
@@ -43,6 +52,14 @@ async function parseBody(req) {
43
52
 
44
53
  const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
45
54
  const PID_FILE = join(PID_DIR, 'hub.pid');
55
+ const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
56
+
57
+ // localhost 계열 Origin만 허용
58
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
59
+
60
+ function isAllowedOrigin(origin) {
61
+ return origin && ALLOWED_ORIGIN_RE.test(origin);
62
+ }
46
63
 
47
64
  /**
48
65
  * tfx-hub 시작
@@ -57,6 +74,11 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
57
74
  dbPath = join(PID_DIR, 'state.db');
58
75
  }
59
76
 
77
+ // 인증 토큰 생성 (환경변수 우선, 없으면 자동 생성)
78
+ const HUB_TOKEN = process.env.TFX_HUB_TOKEN || randomUUID();
79
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
80
+ writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
81
+
60
82
  const store = createStore(dbPath);
61
83
  const router = createRouter(store);
62
84
  const pipe = createPipeServer({ router, store, sessionId });
@@ -100,12 +122,16 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
100
122
  }
101
123
 
102
124
  const httpServer = createHttpServer(async (req, res) => {
103
- res.setHeader('Access-Control-Allow-Origin', '*');
104
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
105
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Last-Event-ID');
125
+ // CORS: localhost 계열 Origin 허용
126
+ const origin = req.headers['origin'];
127
+ if (isAllowedOrigin(origin)) {
128
+ res.setHeader('Access-Control-Allow-Origin', origin);
129
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
130
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
131
+ }
106
132
 
107
133
  if (req.method === 'OPTIONS') {
108
- res.writeHead(204);
134
+ res.writeHead(isAllowedOrigin(origin) ? 204 : 403);
109
135
  return res.end();
110
136
  }
111
137
 
@@ -132,6 +158,14 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
132
158
  if (req.url.startsWith('/bridge')) {
133
159
  res.setHeader('Content-Type', 'application/json');
134
160
 
161
+ // Bearer 토큰 인증
162
+ const authHeader = req.headers['authorization'] || '';
163
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
164
+ if (bearerToken !== HUB_TOKEN) {
165
+ res.writeHead(401);
166
+ return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
167
+ }
168
+
135
169
  if (req.method !== 'POST' && req.method !== 'DELETE') {
136
170
  res.writeHead(405);
137
171
  return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
@@ -211,16 +245,111 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
211
245
  return res.end(JSON.stringify(result));
212
246
  }
213
247
 
248
+ if (path === '/bridge/assign/async' && req.method === 'POST') {
249
+ const {
250
+ supervisor_agent,
251
+ worker_agent,
252
+ task,
253
+ topic = 'assign.job',
254
+ payload = {},
255
+ priority = 5,
256
+ ttl_ms = 600000,
257
+ timeout_ms = 600000,
258
+ max_retries = 0,
259
+ trace_id,
260
+ correlation_id,
261
+ } = body;
262
+
263
+ if (!supervisor_agent || !worker_agent || !task) {
264
+ res.writeHead(400);
265
+ return res.end(JSON.stringify({ ok: false, error: 'supervisor_agent, worker_agent, task 필수' }));
266
+ }
267
+
268
+ const result = await pipe.executeCommand('assign', {
269
+ supervisor_agent,
270
+ worker_agent,
271
+ task,
272
+ topic,
273
+ payload,
274
+ priority,
275
+ ttl_ms,
276
+ timeout_ms,
277
+ max_retries,
278
+ trace_id,
279
+ correlation_id,
280
+ });
281
+ res.writeHead(result.ok ? 200 : 400);
282
+ return res.end(JSON.stringify(result));
283
+ }
284
+
285
+ if (path === '/bridge/assign/result' && req.method === 'POST') {
286
+ const {
287
+ job_id,
288
+ worker_agent,
289
+ status,
290
+ attempt,
291
+ result: assignResult,
292
+ error: assignError,
293
+ payload = {},
294
+ metadata = {},
295
+ } = body;
296
+
297
+ if (!job_id || !status) {
298
+ res.writeHead(400);
299
+ return res.end(JSON.stringify({ ok: false, error: 'job_id, status 필수' }));
300
+ }
301
+
302
+ const result = await pipe.executeCommand('assign_result', {
303
+ job_id,
304
+ worker_agent,
305
+ status,
306
+ attempt,
307
+ result: assignResult,
308
+ error: assignError,
309
+ payload,
310
+ metadata,
311
+ });
312
+ res.writeHead(result.ok ? 200 : 409);
313
+ return res.end(JSON.stringify(result));
314
+ }
315
+
316
+ if (path === '/bridge/assign/status' && req.method === 'POST') {
317
+ const result = await pipe.executeQuery('assign_status', body);
318
+ const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
319
+ res.writeHead(statusCode);
320
+ return res.end(JSON.stringify(result));
321
+ }
322
+
323
+ if (path === '/bridge/assign/retry' && req.method === 'POST') {
324
+ const { job_id, reason, requested_by } = body;
325
+ if (!job_id) {
326
+ res.writeHead(400);
327
+ return res.end(JSON.stringify({ ok: false, error: 'job_id 필수' }));
328
+ }
329
+
330
+ const result = await pipe.executeCommand('assign_retry', {
331
+ job_id,
332
+ reason,
333
+ requested_by,
334
+ });
335
+ const statusCode = result.ok ? 200
336
+ : result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
337
+ : result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
338
+ : 400;
339
+ res.writeHead(statusCode);
340
+ return res.end(JSON.stringify(result));
341
+ }
342
+
214
343
  if (req.method === 'POST') {
215
344
  let teamResult = null;
216
345
  if (path === '/bridge/team/info' || path === '/bridge/team-info') {
217
- teamResult = teamInfo(body);
346
+ teamResult = await teamInfo(body);
218
347
  } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
219
- teamResult = teamTaskList(body);
348
+ teamResult = await teamTaskList(body);
220
349
  } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
221
- teamResult = teamTaskUpdate(body);
350
+ teamResult = await teamTaskUpdate(body);
222
351
  } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
223
- teamResult = teamSendMessage(body);
352
+ teamResult = await teamSendMessage(body);
224
353
  }
225
354
 
226
355
  if (teamResult) {
@@ -240,6 +369,41 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
240
369
  res.writeHead(404);
241
370
  return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
242
371
  }
372
+
373
+ // ── 파이프라인 엔드포인트 ──
374
+ if (path === '/bridge/pipeline/state' && req.method === 'POST') {
375
+ ensurePipelineTable(store.db);
376
+ const { team_name } = body;
377
+ const state = readPipelineState(store.db, team_name);
378
+ res.writeHead(state ? 200 : 404);
379
+ return res.end(JSON.stringify(state
380
+ ? { ok: true, data: state }
381
+ : { ok: false, error: 'pipeline_not_found' }));
382
+ }
383
+
384
+ if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
385
+ ensurePipelineTable(store.db);
386
+ const { team_name, phase } = body;
387
+ const pipeline = createPipeline(store.db, team_name);
388
+ const result = pipeline.advance(phase);
389
+ res.writeHead(result.ok ? 200 : 400);
390
+ return res.end(JSON.stringify(result));
391
+ }
392
+
393
+ if (path === '/bridge/pipeline/init' && req.method === 'POST') {
394
+ ensurePipelineTable(store.db);
395
+ const { team_name, fix_max, ralph_max } = body;
396
+ const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
397
+ res.writeHead(200);
398
+ return res.end(JSON.stringify({ ok: true, data: state }));
399
+ }
400
+
401
+ if (path === '/bridge/pipeline/list' && req.method === 'POST') {
402
+ ensurePipelineTable(store.db);
403
+ const states = listPipelineStates(store.db);
404
+ res.writeHead(200);
405
+ return res.end(JSON.stringify({ ok: true, data: states }));
406
+ }
243
407
  }
244
408
 
245
409
  if (path === '/bridge/context' && req.method === 'POST') {
@@ -408,6 +572,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
408
572
  await pipe.stop();
409
573
  store.close();
410
574
  try { unlinkSync(PID_FILE); } catch {}
575
+ try { unlinkSync(TOKEN_FILE); } catch {}
411
576
  await new Promise((resolveClose) => httpServer.close(resolveClose));
412
577
  };
413
578
 
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,6 +219,8 @@ 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
 
174
226
  function clampMaxMessages(value, fallback = 20) {
@@ -177,6 +229,18 @@ export function createStore(dbPath) {
177
229
  return Math.max(1, Math.min(Math.trunc(num), 100));
178
230
  }
179
231
 
232
+ function clampPriority(value, fallback = 5) {
233
+ const num = Number(value);
234
+ if (!Number.isFinite(num)) return fallback;
235
+ return Math.max(1, Math.min(Math.trunc(num), 9));
236
+ }
237
+
238
+ function clampDuration(value, fallback = 600000, min = 1000, max = 86400000) {
239
+ const num = Number(value);
240
+ if (!Number.isFinite(num)) return fallback;
241
+ return Math.max(min, Math.min(Math.trunc(num), max));
242
+ }
243
+
180
244
  const store = {
181
245
  db,
182
246
  uuidv7,
@@ -365,6 +429,195 @@ export function createStore(dbPath) {
365
429
  return S.getDL.all(limit);
366
430
  },
367
431
 
432
+ createAssign({
433
+ job_id,
434
+ supervisor_agent,
435
+ worker_agent,
436
+ topic = 'assign.job',
437
+ task = '',
438
+ payload = {},
439
+ status = 'queued',
440
+ attempt = 1,
441
+ retry_count = 0,
442
+ max_retries = 0,
443
+ priority = 5,
444
+ ttl_ms = 600000,
445
+ timeout_ms = 600000,
446
+ deadline_ms,
447
+ trace_id,
448
+ correlation_id,
449
+ last_message_id = null,
450
+ result = null,
451
+ error = null,
452
+ }) {
453
+ const now = Date.now();
454
+ const normalizedTimeout = clampDuration(timeout_ms, 600000);
455
+ const row = {
456
+ job_id: job_id || uuidv7(),
457
+ supervisor_agent,
458
+ worker_agent,
459
+ topic: String(topic || 'assign.job'),
460
+ task: String(task || ''),
461
+ payload_json: JSON.stringify(payload || {}),
462
+ status,
463
+ attempt: Math.max(1, Number(attempt) || 1),
464
+ retry_count: Math.max(0, Number(retry_count) || 0),
465
+ max_retries: Math.max(0, Number(max_retries) || 0),
466
+ priority: clampPriority(priority, 5),
467
+ ttl_ms: clampDuration(ttl_ms, normalizedTimeout),
468
+ timeout_ms: normalizedTimeout,
469
+ deadline_ms: Number.isFinite(Number(deadline_ms))
470
+ ? Math.trunc(Number(deadline_ms))
471
+ : now + normalizedTimeout,
472
+ trace_id: trace_id || uuidv7(),
473
+ correlation_id: correlation_id || uuidv7(),
474
+ last_message_id,
475
+ result_json: result == null ? null : JSON.stringify(result),
476
+ error_json: error == null ? null : JSON.stringify(error),
477
+ created_at_ms: now,
478
+ updated_at_ms: now,
479
+ started_at_ms: status === 'running' ? now : null,
480
+ completed_at_ms: ['succeeded', 'failed', 'timed_out'].includes(status) ? now : null,
481
+ last_retry_at_ms: retry_count > 0 ? now : null,
482
+ };
483
+ S.insertAssign.run(row);
484
+ return store.getAssign(row.job_id);
485
+ },
486
+
487
+ getAssign(jobId) {
488
+ return parseAssignRow(S.getAssign.get(jobId));
489
+ },
490
+
491
+ updateAssignStatus(jobId, status, patch = {}) {
492
+ const current = store.getAssign(jobId);
493
+ if (!current) return null;
494
+
495
+ const now = Date.now();
496
+ const nextStatus = status || current.status;
497
+ const isTerminal = ['succeeded', 'failed', 'timed_out'].includes(nextStatus);
498
+ const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
499
+ const nextRow = {
500
+ job_id: current.job_id,
501
+ supervisor_agent: patch.supervisor_agent ?? current.supervisor_agent,
502
+ worker_agent: patch.worker_agent ?? current.worker_agent,
503
+ topic: patch.topic ?? current.topic,
504
+ task: patch.task ?? current.task,
505
+ payload_json: JSON.stringify(patch.payload ?? current.payload ?? {}),
506
+ status: nextStatus,
507
+ attempt: Math.max(1, Number(patch.attempt ?? current.attempt) || current.attempt || 1),
508
+ retry_count: Math.max(0, Number(patch.retry_count ?? current.retry_count) || 0),
509
+ max_retries: Math.max(0, Number(patch.max_retries ?? current.max_retries) || 0),
510
+ priority: clampPriority(patch.priority ?? current.priority, current.priority || 5),
511
+ ttl_ms: clampDuration(patch.ttl_ms ?? current.ttl_ms, current.ttl_ms || nextTimeout),
512
+ timeout_ms: nextTimeout,
513
+ deadline_ms: (() => {
514
+ if (Object.prototype.hasOwnProperty.call(patch, 'deadline_ms')) {
515
+ return patch.deadline_ms == null ? null : Math.trunc(Number(patch.deadline_ms));
516
+ }
517
+ if (isTerminal) return null;
518
+ if (nextStatus === 'running' && !current.deadline_ms) return now + nextTimeout;
519
+ return current.deadline_ms;
520
+ })(),
521
+ trace_id: patch.trace_id ?? current.trace_id,
522
+ correlation_id: patch.correlation_id ?? current.correlation_id,
523
+ last_message_id: Object.prototype.hasOwnProperty.call(patch, 'last_message_id')
524
+ ? patch.last_message_id
525
+ : current.last_message_id,
526
+ result_json: Object.prototype.hasOwnProperty.call(patch, 'result')
527
+ ? (patch.result == null ? null : JSON.stringify(patch.result))
528
+ : (current.result == null ? null : JSON.stringify(current.result)),
529
+ error_json: Object.prototype.hasOwnProperty.call(patch, 'error')
530
+ ? (patch.error == null ? null : JSON.stringify(patch.error))
531
+ : (current.error == null ? null : JSON.stringify(current.error)),
532
+ updated_at_ms: now,
533
+ started_at_ms: Object.prototype.hasOwnProperty.call(patch, 'started_at_ms')
534
+ ? patch.started_at_ms
535
+ : (nextStatus === 'running' ? (current.started_at_ms || now) : current.started_at_ms),
536
+ completed_at_ms: Object.prototype.hasOwnProperty.call(patch, 'completed_at_ms')
537
+ ? patch.completed_at_ms
538
+ : (isTerminal ? (current.completed_at_ms || now) : current.completed_at_ms),
539
+ last_retry_at_ms: Object.prototype.hasOwnProperty.call(patch, 'last_retry_at_ms')
540
+ ? patch.last_retry_at_ms
541
+ : current.last_retry_at_ms,
542
+ };
543
+ S.updateAssign.run(nextRow);
544
+ return store.getAssign(jobId);
545
+ },
546
+
547
+ listAssigns({
548
+ supervisor_agent,
549
+ worker_agent,
550
+ status,
551
+ statuses,
552
+ trace_id,
553
+ correlation_id,
554
+ active_before_ms,
555
+ limit = 50,
556
+ } = {}) {
557
+ const clauses = [];
558
+ const values = [];
559
+
560
+ if (supervisor_agent) {
561
+ clauses.push('supervisor_agent = ?');
562
+ values.push(supervisor_agent);
563
+ }
564
+ if (worker_agent) {
565
+ clauses.push('worker_agent = ?');
566
+ values.push(worker_agent);
567
+ }
568
+ if (trace_id) {
569
+ clauses.push('trace_id = ?');
570
+ values.push(trace_id);
571
+ }
572
+ if (correlation_id) {
573
+ clauses.push('correlation_id = ?');
574
+ values.push(correlation_id);
575
+ }
576
+
577
+ const statusList = Array.isArray(statuses) && statuses.length
578
+ ? statuses
579
+ : (status ? [status] : []);
580
+ if (statusList.length) {
581
+ clauses.push(`status IN (${statusList.map(() => '?').join(',')})`);
582
+ values.push(...statusList);
583
+ }
584
+
585
+ if (Number.isFinite(Number(active_before_ms))) {
586
+ clauses.push('deadline_ms IS NOT NULL AND deadline_ms <= ?');
587
+ values.push(Math.trunc(Number(active_before_ms)));
588
+ }
589
+
590
+ const sql = `
591
+ SELECT * FROM assign_jobs
592
+ ${clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''}
593
+ ORDER BY updated_at_ms DESC
594
+ LIMIT ?`;
595
+ values.push(clampMaxMessages(limit, 50));
596
+ return db.prepare(sql).all(...values).map(parseAssignRow);
597
+ },
598
+
599
+ retryAssign(jobId, patch = {}) {
600
+ const current = store.getAssign(jobId);
601
+ if (!current) return null;
602
+
603
+ const nextRetryCount = Math.max(0, Number(patch.retry_count ?? current.retry_count + 1) || 0);
604
+ const nextAttempt = Math.max(current.attempt + 1, Number(patch.attempt ?? current.attempt + 1) || 1);
605
+ const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
606
+ return store.updateAssignStatus(jobId, 'queued', {
607
+ retry_count: nextRetryCount,
608
+ attempt: nextAttempt,
609
+ timeout_ms: nextTimeout,
610
+ ttl_ms: patch.ttl_ms ?? current.ttl_ms,
611
+ deadline_ms: Date.now() + nextTimeout,
612
+ completed_at_ms: null,
613
+ started_at_ms: null,
614
+ last_retry_at_ms: Date.now(),
615
+ result: patch.result ?? null,
616
+ error: Object.prototype.hasOwnProperty.call(patch, 'error') ? patch.error : current.error,
617
+ last_message_id: null,
618
+ });
619
+ },
620
+
368
621
  sweepExpired() {
369
622
  const now = Date.now();
370
623
  return db.transaction(() => {
@@ -397,6 +650,7 @@ export function createStore(dbPath) {
397
650
  return {
398
651
  online_agents: S.onlineCount.get().cnt,
399
652
  total_messages: S.msgCount.get().cnt,
653
+ active_assign_jobs: S.activeAssignCount.get().cnt,
400
654
  ...store.getQueueDepths(),
401
655
  };
402
656
  },
@@ -406,6 +660,10 @@ export function createStore(dbPath) {
406
660
  online_agents: S.onlineCount.get().cnt,
407
661
  total_messages: S.msgCount.get().cnt,
408
662
  dlq: S.dlqDepth.get().cnt,
663
+ assign_queued: S.assignCountByStatus.get('queued').cnt,
664
+ assign_running: S.assignCountByStatus.get('running').cnt,
665
+ assign_failed: S.assignCountByStatus.get('failed').cnt,
666
+ assign_timed_out: S.assignCountByStatus.get('timed_out').cnt,
409
667
  };
410
668
  },
411
669
  };