@goodandready/dsh-goal 0.1.4 → 0.1.6
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/docs/design/DESIGN.md +16 -0
- package/lib/client.js +439 -216
- package/lib/goal-engine.js +643 -536
- package/lib/index.js +122 -6
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -41,7 +41,7 @@ function sessionIdOf(invocationOrReq, fallback = 'default') {
|
|
|
41
41
|
export const Config = z.object({
|
|
42
42
|
maxIterations: z.number().default(25).description('Safety limit: max autonomous iterations per goal'),
|
|
43
43
|
autoDrive: z.boolean().default(true).description('Automatically continue the loop after each turn'),
|
|
44
|
-
enableSound: z.boolean().default(true).description('Play
|
|
44
|
+
enableSound: z.boolean().default(true).description('Play synthesized audio chime on goal completion or failure'),
|
|
45
45
|
storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
|
|
46
46
|
});
|
|
47
47
|
|
|
@@ -52,6 +52,9 @@ export function apply(ctx, config = {}) {
|
|
|
52
52
|
const sessionAgents = new Map(); // sessionId -> agent
|
|
53
53
|
let lastActiveAgent = null;
|
|
54
54
|
|
|
55
|
+
// SSE clients: sid -> Set of res objects
|
|
56
|
+
const sseClients = new Map();
|
|
57
|
+
|
|
55
58
|
let currentSettings = {
|
|
56
59
|
maxIterations: config?.maxIterations ?? 25,
|
|
57
60
|
autoDrive: config?.autoDrive ?? true,
|
|
@@ -71,6 +74,30 @@ export function apply(ctx, config = {}) {
|
|
|
71
74
|
storagePath,
|
|
72
75
|
});
|
|
73
76
|
|
|
77
|
+
// Subscribe to engine changes for realtime Server-Sent Events broadcasting
|
|
78
|
+
engine.subscribe((snapshot, sid) => {
|
|
79
|
+
const clients = sseClients.get(sid);
|
|
80
|
+
if (clients && clients.size > 0) {
|
|
81
|
+
const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
|
|
82
|
+
for (const clientRes of clients) {
|
|
83
|
+
try {
|
|
84
|
+
clientRes.write(payload);
|
|
85
|
+
} catch (_) {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (sid !== 'default' && sseClients.has('default')) {
|
|
89
|
+
const defClients = sseClients.get('default');
|
|
90
|
+
if (defClients && defClients.size > 0) {
|
|
91
|
+
const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
|
|
92
|
+
for (const clientRes of defClients) {
|
|
93
|
+
try {
|
|
94
|
+
clientRes.write(payload);
|
|
95
|
+
} catch (_) {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
74
101
|
// Динамическое внедрение сервиса agents для управления жизненным циклом
|
|
75
102
|
ctx.inject(['agents'], (actx) => {
|
|
76
103
|
agentsService = actx.agents;
|
|
@@ -87,6 +114,13 @@ export function apply(ctx, config = {}) {
|
|
|
87
114
|
}
|
|
88
115
|
} else {
|
|
89
116
|
runningAgents.delete(agent);
|
|
117
|
+
if (lastActiveAgent === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
|
|
118
|
+
lastActiveAgent = null;
|
|
119
|
+
}
|
|
120
|
+
const sid = sessionIdOf(agent, null);
|
|
121
|
+
if (sid && sessionAgents.get(sid) === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
|
|
122
|
+
sessionAgents.delete(sid);
|
|
123
|
+
}
|
|
90
124
|
}
|
|
91
125
|
});
|
|
92
126
|
|
|
@@ -383,10 +417,22 @@ export function apply(ctx, config = {}) {
|
|
|
383
417
|
});
|
|
384
418
|
});
|
|
385
419
|
|
|
386
|
-
// 5. Регистрация HTTP REST API маршрутов
|
|
420
|
+
// 5. Регистрация HTTP REST API маршрутов и SSE событий
|
|
387
421
|
ctx.effect(() => {
|
|
388
422
|
if (!ctx.webServer?.register) return () => {};
|
|
389
423
|
|
|
424
|
+
// Keepalive ping timer for SSE connections (every 20s)
|
|
425
|
+
const keepaliveTimer = setInterval(() => {
|
|
426
|
+
for (const clients of sseClients.values()) {
|
|
427
|
+
for (const res of clients) {
|
|
428
|
+
try {
|
|
429
|
+
res.write(': keepalive\n\n');
|
|
430
|
+
} catch (_) {}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}, 20000);
|
|
434
|
+
if (typeof keepaliveTimer.unref === 'function') keepaliveTimer.unref();
|
|
435
|
+
|
|
390
436
|
const unreg = ctx.webServer.register({
|
|
391
437
|
kind: 'prefix',
|
|
392
438
|
path: '/dsh-goal',
|
|
@@ -394,6 +440,34 @@ export function apply(ctx, config = {}) {
|
|
|
394
440
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
395
441
|
const pathname = url.pathname;
|
|
396
442
|
|
|
443
|
+
// GET /dsh-goal/events — Server-Sent Events realtime snapshot stream
|
|
444
|
+
if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
|
|
445
|
+
const sid = sessionIdOf(req, 'default');
|
|
446
|
+
res.writeHead(200, {
|
|
447
|
+
'Content-Type': 'text/event-stream',
|
|
448
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
449
|
+
'Connection': 'keep-alive',
|
|
450
|
+
'X-Accel-Buffering': 'no',
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (!sseClients.has(sid)) {
|
|
454
|
+
sseClients.set(sid, new Set());
|
|
455
|
+
}
|
|
456
|
+
sseClients.get(sid).add(res);
|
|
457
|
+
|
|
458
|
+
const initialSnap = engine.getSnapshot(sid);
|
|
459
|
+
res.write(`data: ${JSON.stringify(initialSnap)}\n\n`);
|
|
460
|
+
|
|
461
|
+
req.on('close', () => {
|
|
462
|
+
const set = sseClients.get(sid);
|
|
463
|
+
if (set) {
|
|
464
|
+
set.delete(res);
|
|
465
|
+
if (set.size === 0) sseClients.delete(sid);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
397
471
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
398
472
|
|
|
399
473
|
// GET /dsh-goal/state
|
|
@@ -428,8 +502,23 @@ export function apply(ctx, config = {}) {
|
|
|
428
502
|
}
|
|
429
503
|
|
|
430
504
|
let body = '';
|
|
431
|
-
|
|
505
|
+
let bodySize = 0;
|
|
506
|
+
const MAX_PAYLOAD_BYTES = 256 * 1024;
|
|
507
|
+
let limitExceeded = false;
|
|
508
|
+
|
|
509
|
+
req.on('data', (chunk) => {
|
|
510
|
+
bodySize += chunk.length;
|
|
511
|
+
if (bodySize > MAX_PAYLOAD_BYTES) {
|
|
512
|
+
limitExceeded = true;
|
|
513
|
+
req.pause();
|
|
514
|
+
res.statusCode = 413;
|
|
515
|
+
return res.end(JSON.stringify({ error: 'Payload too large: max 256 KB allowed' }));
|
|
516
|
+
}
|
|
517
|
+
body += chunk;
|
|
518
|
+
});
|
|
519
|
+
|
|
432
520
|
req.on('end', () => {
|
|
521
|
+
if (limitExceeded) return;
|
|
433
522
|
try {
|
|
434
523
|
const data = JSON.parse(body || '{}');
|
|
435
524
|
const sid = data.sessionId || sessionIdOf(req, 'default');
|
|
@@ -461,6 +550,10 @@ export function apply(ctx, config = {}) {
|
|
|
461
550
|
sessionAgents.delete(sid);
|
|
462
551
|
break;
|
|
463
552
|
case 'update_milestone':
|
|
553
|
+
if (!milestoneId || !status) {
|
|
554
|
+
res.statusCode = 400;
|
|
555
|
+
return res.end(JSON.stringify({ error: 'milestoneId and status are required' }));
|
|
556
|
+
}
|
|
464
557
|
engine.updateMilestone(milestoneId, status, notes, sid);
|
|
465
558
|
result = engine.getSnapshot(sid);
|
|
466
559
|
break;
|
|
@@ -485,9 +578,18 @@ export function apply(ctx, config = {}) {
|
|
|
485
578
|
});
|
|
486
579
|
|
|
487
580
|
return () => {
|
|
581
|
+
clearInterval(keepaliveTimer);
|
|
488
582
|
if (typeof unreg === 'function') unreg();
|
|
583
|
+
for (const clients of sseClients.values()) {
|
|
584
|
+
for (const res of clients) {
|
|
585
|
+
try {
|
|
586
|
+
res.end();
|
|
587
|
+
} catch (_) {}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
sseClients.clear();
|
|
489
591
|
};
|
|
490
|
-
}, 'dsh-goal: HTTP WebServer Routes');
|
|
592
|
+
}, 'dsh-goal: HTTP WebServer Routes & SSE');
|
|
491
593
|
|
|
492
594
|
// 6. Подписка на события сессии (автономный цикл)
|
|
493
595
|
ctx.effect(() => {
|
|
@@ -504,10 +606,18 @@ export function apply(ctx, config = {}) {
|
|
|
504
606
|
return;
|
|
505
607
|
}
|
|
506
608
|
|
|
609
|
+
// Item 3: Smart Progress Guard — detect idle loops without progress
|
|
610
|
+
const stallCount = engine.incrementStallCount(sid);
|
|
611
|
+
if (stallCount >= 2) {
|
|
612
|
+
engine.pause('Агент не продвинулся по плану за последние 2 итерации — требуется внимание оператора', sid);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
507
616
|
const canContinue = engine.incrementIteration(sid);
|
|
508
617
|
if (!canContinue) return;
|
|
509
618
|
|
|
510
|
-
|
|
619
|
+
// Item 2: Low-Latency AutoDrive — setImmediate instead of static setTimeout
|
|
620
|
+
const triggerNextTurn = () => {
|
|
511
621
|
const currentSnap = engine.getSnapshot(sid);
|
|
512
622
|
if (currentSnap.hasActiveGoal && currentSnap.state === GoalState.RUNNING) {
|
|
513
623
|
const promptMsg = createGoalUserMessage(
|
|
@@ -543,7 +653,13 @@ export function apply(ctx, config = {}) {
|
|
|
543
653
|
}
|
|
544
654
|
}
|
|
545
655
|
}
|
|
546
|
-
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
if (typeof setImmediate === 'function') {
|
|
659
|
+
setImmediate(triggerNextTurn);
|
|
660
|
+
} else {
|
|
661
|
+
setTimeout(triggerNextTurn, 0);
|
|
662
|
+
}
|
|
547
663
|
}
|
|
548
664
|
};
|
|
549
665
|
|