@goodandready/dsh-goal 0.2.11 → 0.2.13

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/lib/routes.js CHANGED
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { MilestoneStatus, detectLanguage, sessionIdOf } from './goal-engine-constants.js';
4
4
  import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
5
- import { getTemplatesCatalogue } from './engine-templates.js';
5
+ import { getTemplatesCatalogue, instantiateTemplate } from './engine-templates.js';
6
+ import { parseIssueChecklist } from './engine-issue-sync.js';
6
7
 
7
8
  export function isLoopback(value) {
8
9
  if (!value) return false;
@@ -84,71 +85,86 @@ export function getSafeCwd(requestedCwd, allowedRoots = []) {
84
85
 
85
86
  /**
86
87
  * Register webServer HTTP routes and SSE stream for DSH Goal
87
- * @param {any} ctx Cordis context
88
- * @param {object} deps Dependencies
89
- * @param {any} deps.engine GoalEngine instance
90
- * @param {Function} deps.getConfig Function returning current config
91
- * @param {Function} deps.stopRunningAgents Stop agents function
92
- * @param {Function} deps.resumeActiveAgent Resume agent function
93
- * @param {Map} deps.sessionAgents Session agent map
94
- * @param {Map} [deps.sseClients] SSE clients map (sid -> Set)
95
88
  */
96
89
  export function registerRoutes(ctx, {
97
90
  engine,
91
+ goalsService,
98
92
  getConfig,
99
93
  stopRunningAgents,
100
94
  resumeActiveAgent,
101
95
  sessionAgents,
102
96
  sseClients = new Map(),
103
- }) {
104
- // Subscribe to engine changes for realtime Server-Sent Events broadcasting
105
- engine.subscribe((snapshot, sid) => {
106
- const clients = sseClients.get(sid);
107
- if (clients && clients.size > 0) {
108
- const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
109
- for (const clientRes of Array.from(clients)) {
110
- try {
111
- clientRes.write(payload);
112
- } catch (err) {
113
- clients.delete(clientRes);
114
- try { clientRes.end(); } catch (e) { /* closed */ }
115
- }
116
- }
117
- if (clients.size === 0) sseClients.delete(sid);
118
- }
119
- if (sid !== 'default' && sseClients.has('default')) {
120
- const defClients = sseClients.get('default');
121
- if (defClients && defClients.size > 0) {
97
+ addonStore,
98
+ budgetGuard,
99
+ snapshotManager,
100
+ milestoneManager,
101
+ logger = console,
102
+ } = {}) {
103
+ // Subscribe to engine if provided (backward compatibility)
104
+ if (engine && typeof engine.subscribe === 'function') {
105
+ engine.subscribe((snapshot, sid) => {
106
+ const clients = sseClients.get(sid);
107
+ if (clients && clients.size > 0) {
122
108
  const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
123
- for (const clientRes of Array.from(defClients)) {
109
+ for (const clientRes of Array.from(clients)) {
124
110
  try {
125
111
  clientRes.write(payload);
126
112
  } catch (err) {
127
- defClients.delete(clientRes);
113
+ clients.delete(clientRes);
128
114
  try { clientRes.end(); } catch (e) { /* closed */ }
129
115
  }
130
116
  }
131
- if (defClients.size === 0) sseClients.delete('default');
117
+ if (clients.size === 0) sseClients.delete(sid);
118
+ }
119
+ if (sid !== 'default' && sseClients.has('default')) {
120
+ const defClients = sseClients.get('default');
121
+ if (defClients && defClients.size > 0) {
122
+ const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
123
+ for (const clientRes of Array.from(defClients)) {
124
+ try {
125
+ clientRes.write(payload);
126
+ } catch (err) {
127
+ defClients.delete(clientRes);
128
+ try { clientRes.end(); } catch (e) { /* closed */ }
129
+ }
130
+ }
131
+ if (defClients.size === 0) sseClients.delete('default');
132
+ }
133
+ }
134
+ });
135
+ }
136
+
137
+ const broadcastEvent = (event, data) => {
138
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
139
+ for (const clientSet of sseClients.values()) {
140
+ if (clientSet instanceof Set) {
141
+ for (const res of clientSet) {
142
+ try { res.write(payload); } catch (e) { clientSet.delete(res); }
143
+ }
144
+ } else if (clientSet && typeof clientSet.write === 'function') {
145
+ try { clientSet.write(payload); } catch (e) { /* closed */ }
132
146
  }
133
147
  }
134
- });
148
+ };
135
149
 
136
- ctx.effect(() => {
137
- if (!ctx.webServer?.register) return () => {};
150
+ const setupWebServer = () => {
151
+ if (!ctx?.webServer?.register) return () => {};
138
152
 
139
153
  // Keepalive ping timer for SSE connections (every 20s)
140
154
  const keepaliveTimer = setInterval(() => {
141
155
  for (const [sid, clients] of sseClients.entries()) {
142
- for (const res of Array.from(clients)) {
143
- try {
144
- res.write(': keepalive\n\n');
145
- } catch (err) {
146
- clients.delete(res);
147
- try { res.end(); } catch (e) { /* closed */ }
156
+ if (clients instanceof Set) {
157
+ for (const res of Array.from(clients)) {
158
+ try {
159
+ res.write(': keepalive\n\n');
160
+ } catch (err) {
161
+ clients.delete(res);
162
+ try { res.end(); } catch (e) { /* closed */ }
163
+ }
164
+ }
165
+ if (clients.size === 0) {
166
+ sseClients.delete(sid);
148
167
  }
149
- }
150
- if (clients.size === 0) {
151
- sseClients.delete(sid);
152
168
  }
153
169
  }
154
170
  }, 20000);
@@ -165,36 +181,48 @@ export function registerRoutes(ctx, {
165
181
  if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
166
182
  if (!isTrustedCaller(req)) {
167
183
  res.statusCode = 403;
168
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
184
+ if (typeof res.setHeader === 'function') res.setHeader('Content-Type', 'application/json; charset=utf-8');
169
185
  return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
170
186
  }
171
187
  const sid = sessionIdOf(req, 'default');
172
- res.writeHead(200, {
173
- 'Content-Type': 'text/event-stream',
174
- 'Cache-Control': 'no-cache, no-transform',
175
- 'Connection': 'keep-alive',
176
- 'X-Accel-Buffering': 'no',
177
- });
188
+ if (typeof res.writeHead === 'function') {
189
+ res.writeHead(200, {
190
+ 'Content-Type': 'text/event-stream',
191
+ 'Cache-Control': 'no-cache, no-transform',
192
+ 'Connection': 'keep-alive',
193
+ 'X-Accel-Buffering': 'no',
194
+ });
195
+ } else {
196
+ res.statusCode = 200;
197
+ if (typeof res.setHeader === 'function') {
198
+ res.setHeader('Content-Type', 'text/event-stream');
199
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
200
+ }
201
+ }
178
202
 
179
203
  if (!sseClients.has(sid)) {
180
204
  sseClients.set(sid, new Set());
181
205
  }
182
206
  sseClients.get(sid).add(res);
183
207
 
184
- const initialSnap = engine.getSnapshot(sid);
208
+ const initialSnap = engine ? engine.getSnapshot(sid) : { sessionId: sid, hasActiveGoal: false, status: 'IDLE' };
185
209
  res.write(`data: ${JSON.stringify(initialSnap)}\n\n`);
186
210
 
187
- req.on('close', () => {
188
- const set = sseClients.get(sid);
189
- if (set) {
190
- set.delete(res);
191
- if (set.size === 0) sseClients.delete(sid);
192
- }
193
- });
211
+ if (typeof req.on === 'function') {
212
+ req.on('close', () => {
213
+ const set = sseClients.get(sid);
214
+ if (set) {
215
+ set.delete(res);
216
+ if (set.size === 0) sseClients.delete(sid);
217
+ }
218
+ });
219
+ }
194
220
  return;
195
221
  }
196
222
 
197
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
223
+ if (typeof res.setHeader === 'function') {
224
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
225
+ }
198
226
 
199
227
  // GET /dsh-goal/templates
200
228
  if (req.method === 'GET' && (pathname === '/dsh-goal/templates' || pathname === '/dsh-goal/templates/')) {
@@ -214,7 +242,15 @@ export function registerRoutes(ctx, {
214
242
  }
215
243
  const sid = sessionIdOf(req, 'default');
216
244
  res.statusCode = 200;
217
- return res.end(JSON.stringify(engine.getSnapshot(sid)));
245
+ if (engine) {
246
+ return res.end(JSON.stringify(engine.getSnapshot(sid)));
247
+ }
248
+ return res.end(JSON.stringify({
249
+ sessionId: sid,
250
+ hasActiveGoal: false,
251
+ state: 'IDLE',
252
+ milestones: [],
253
+ }));
218
254
  }
219
255
 
220
256
  // POST /dsh-goal/action
@@ -251,18 +287,18 @@ export function registerRoutes(ctx, {
251
287
  const MAX_PAYLOAD_BYTES = 256 * 1024;
252
288
  let limitExceeded = false;
253
289
 
254
- req.on('data', (chunk) => {
290
+ const onData = (chunk) => {
255
291
  bodySize += chunk.length;
256
292
  if (bodySize > MAX_PAYLOAD_BYTES) {
257
293
  limitExceeded = true;
258
- req.pause();
294
+ if (typeof req.pause === 'function') req.pause();
259
295
  res.statusCode = 413;
260
296
  return res.end(JSON.stringify({ error: 'Payload too large: max 256 KB allowed' }));
261
297
  }
262
298
  body += chunk;
263
- });
299
+ };
264
300
 
265
- req.on('end', () => {
301
+ const onEnd = () => {
266
302
  if (limitExceeded) return;
267
303
  try {
268
304
  const data = JSON.parse(body || '{}');
@@ -292,16 +328,32 @@ export function registerRoutes(ctx, {
292
328
  }
293
329
  const detectedLang = data.lang || detectLanguage(cleanTitle);
294
330
  const conf = typeof getConfig === 'function' ? getConfig() : { maxIterations: 25 };
295
- result = engine.startGoal(cleanTitle, {
296
- description: typeof description === 'string' ? description.trim() : '',
297
- maxIterations: conf.maxIterations || 25,
298
- lang: detectedLang,
299
- cwd: safeStartCwd,
300
- }, sid);
331
+
332
+ if (engine) {
333
+ result = engine.startGoal(cleanTitle, {
334
+ description: typeof description === 'string' ? description.trim() : '',
335
+ maxIterations: conf.maxIterations || 25,
336
+ lang: detectedLang,
337
+ cwd: safeStartCwd,
338
+ issueBody: data.issueBody,
339
+ }, sid);
340
+ } else {
341
+ let parsedMilestones = [];
342
+ if (data.issueBody) {
343
+ parsedMilestones = parseIssueChecklist(data.issueBody);
344
+ }
345
+ result = {
346
+ title: cleanTitle,
347
+ sessionId: sid,
348
+ hasActiveGoal: true,
349
+ state: 'RUNNING',
350
+ milestones: parsedMilestones,
351
+ };
352
+ }
301
353
 
302
354
  const startPrompt = detectedLang === 'zh'
303
- ? `🎯 目标已确立:“${cleanTitle}”。请立即通过 goal_set_milestones 制定工作计划(3-7个具体步骤)并开始执行。`
304
- : `🎯 Goal established: "${cleanTitle}". Immediately formulate a work plan (3-7 concrete steps) via tool goal_set_milestones and start executing it.`;
355
+ ? `🎯 目标已确立:“${cleanTitle}”。请立即通过 goal_milestones 制定工作计划并开始执行。`
356
+ : `🎯 Goal established: "${cleanTitle}". Formulate milestones via goal_milestones and proceed.`;
305
357
 
306
358
  if (typeof resumeActiveAgent === 'function') {
307
359
  resumeActiveAgent(startPrompt, sid);
@@ -315,40 +367,52 @@ export function registerRoutes(ctx, {
315
367
  res.statusCode = 400;
316
368
  return res.end(JSON.stringify({ error: 'Nudge text cannot be empty' }));
317
369
  }
318
- result = engine.nudge(text, sid);
319
- if (data.resume) {
320
- engine.resume(sid);
321
- const prompt = engine.getStatePromptInjection(sid);
322
- if (typeof resumeActiveAgent === 'function') {
323
- resumeActiveAgent(prompt, sid);
370
+ if (engine) {
371
+ result = engine.nudge(text, sid);
372
+ if (data.resume) {
373
+ engine.resume(sid);
374
+ const prompt = engine.getStatePromptInjection(sid);
375
+ if (typeof resumeActiveAgent === 'function') {
376
+ resumeActiveAgent(prompt, sid);
377
+ }
324
378
  }
379
+ } else {
380
+ result = { nudged: true, text };
325
381
  }
326
382
  break;
327
383
  }
328
384
 
329
385
  case 'pause':
330
- result = engine.pause(reason || 'Paused by user interface', sid);
386
+ if (engine) {
387
+ result = engine.pause(reason || 'Paused by user interface', sid);
388
+ }
331
389
  if (typeof stopRunningAgents === 'function') {
332
390
  stopRunningAgents(sid);
333
391
  }
334
392
  break;
335
393
 
336
394
  case 'resume':
337
- result = engine.resume(sid);
395
+ if (engine) {
396
+ result = engine.resume(sid);
397
+ }
338
398
  if (typeof resumeActiveAgent === 'function') {
339
399
  resumeActiveAgent(undefined, sid);
340
400
  }
341
401
  break;
342
402
 
343
403
  case 'cancel':
344
- result = engine.cancel(reason || 'Goal cancelled by user', sid);
404
+ if (engine) {
405
+ result = engine.cancel(reason || 'Goal cancelled by user', sid);
406
+ }
345
407
  if (typeof stopRunningAgents === 'function') {
346
408
  stopRunningAgents(sid);
347
409
  }
348
410
  break;
349
411
 
350
412
  case 'clear':
351
- result = engine.clear(sid);
413
+ if (engine) {
414
+ result = engine.clear(sid);
415
+ }
352
416
  if (typeof stopRunningAgents === 'function') {
353
417
  stopRunningAgents(sid);
354
418
  }
@@ -367,12 +431,21 @@ export function registerRoutes(ctx, {
367
431
  res.statusCode = 400;
368
432
  return res.end(JSON.stringify({ error: `Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}` }));
369
433
  }
370
- const ok = engine.updateMilestone(milestoneId, status, notes, sid, data.checklist);
371
- if (!ok) {
372
- res.statusCode = 404;
373
- return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
434
+ if (engine) {
435
+ const ok = engine.updateMilestone(milestoneId, status, notes, sid, data.checklist);
436
+ if (!ok) {
437
+ res.statusCode = 404;
438
+ return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
439
+ }
440
+ result = engine.getSnapshot(sid);
441
+ } else if (milestoneManager) {
442
+ const ok = milestoneManager.updateMilestone(sid, milestoneId, { status, notes });
443
+ if (!ok) {
444
+ res.statusCode = 404;
445
+ return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
446
+ }
447
+ result = { ok: true, milestones: milestoneManager.getMilestones(sid) };
374
448
  }
375
- result = engine.getSnapshot(sid);
376
449
  break;
377
450
  }
378
451
 
@@ -381,26 +454,40 @@ export function registerRoutes(ctx, {
381
454
  res.statusCode = 400;
382
455
  return res.end(JSON.stringify({ error: 'milestoneId and itemIndex are required' }));
383
456
  }
384
- const ok = engine.toggleChecklistItem(milestoneId, data.itemIndex, data.done, sid);
385
- if (!ok) {
386
- res.statusCode = 404;
387
- return res.end(JSON.stringify({ error: `Milestone checklist item not found` }));
457
+ if (engine) {
458
+ const ok = engine.toggleChecklistItem(milestoneId, data.itemIndex, data.done, sid);
459
+ if (!ok) {
460
+ res.statusCode = 404;
461
+ return res.end(JSON.stringify({ error: `Milestone checklist item not found` }));
462
+ }
463
+ result = engine.getSnapshot(sid);
464
+ } else if (milestoneManager) {
465
+ const ok = milestoneManager.toggleChecklistItem(sid, milestoneId, data.itemIndex, data.done);
466
+ if (!ok) {
467
+ res.statusCode = 404;
468
+ return res.end(JSON.stringify({ error: `Milestone checklist item not found` }));
469
+ }
470
+ result = { ok: true, milestones: milestoneManager.getMilestones(sid) };
388
471
  }
389
- result = engine.getSnapshot(sid);
390
472
  break;
391
473
  }
392
474
 
393
475
  case 'extend_budget': {
394
476
  const addTokens = data.addTokens || 50000;
395
- const ok = engine.extendBudget(addTokens, sid);
396
- if (!ok) {
397
- res.statusCode = 404;
398
- return res.end(JSON.stringify({ error: 'No active goal found to extend budget' }));
399
- }
400
- if (typeof resumeActiveAgent === 'function') {
401
- resumeActiveAgent(undefined, sid);
477
+ if (engine) {
478
+ const ok = engine.extendBudget(addTokens, sid);
479
+ if (!ok) {
480
+ res.statusCode = 404;
481
+ return res.end(JSON.stringify({ error: 'No active goal found to extend budget' }));
482
+ }
483
+ if (typeof resumeActiveAgent === 'function') {
484
+ resumeActiveAgent(undefined, sid);
485
+ }
486
+ result = engine.getSnapshot(sid);
487
+ } else if (budgetGuard) {
488
+ budgetGuard.extendBudget(sid, addTokens);
489
+ result = { ok: true, extendedBy: addTokens };
402
490
  }
403
- result = engine.getSnapshot(sid);
404
491
  break;
405
492
  }
406
493
 
@@ -410,7 +497,7 @@ export function registerRoutes(ctx, {
410
497
  res.statusCode = 400;
411
498
  return res.end(JSON.stringify({ error: 'commit hash is required for rollback' }));
412
499
  }
413
- const sessionGoal = engine.getGoal(sid);
500
+ const sessionGoal = engine ? engine.getGoal(sid) : null;
414
501
  const allowedRoots = [process.cwd()];
415
502
  if (process.env.DSH_WORKSPACE_ROOT) {
416
503
  allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
@@ -431,17 +518,17 @@ export function registerRoutes(ctx, {
431
518
  res.statusCode = 500;
432
519
  return res.end(JSON.stringify({ error: `Failed to rollback to checkpoint ${commit}` }));
433
520
  }
434
- result = engine.getSnapshot(sid);
521
+ result = engine ? engine.getSnapshot(sid) : { ok: true, rolledBack: commit };
435
522
  break;
436
523
  }
437
524
 
438
525
  case 'save_artifact': {
439
- const snap = engine.getSnapshot(sid);
440
- if (!snap || !snap.hasActiveGoal) {
526
+ const snap = engine ? engine.getSnapshot(sid) : null;
527
+ if (engine && (!snap || !snap.hasActiveGoal)) {
441
528
  res.statusCode = 404;
442
529
  return res.end(JSON.stringify({ error: 'No active goal to save artifact for' }));
443
530
  }
444
- const sessionGoal = engine.getGoal(sid);
531
+ const sessionGoal = engine ? engine.getGoal(sid) : null;
445
532
  const allowedRoots = [process.cwd()];
446
533
  if (process.env.DSH_WORKSPACE_ROOT) {
447
534
  allowedRoots.push(process.env.DSH_WORKSPACE_ROOT);
@@ -457,32 +544,38 @@ export function registerRoutes(ctx, {
457
544
  res.statusCode = 400;
458
545
  return res.end(JSON.stringify({ error: 'Invalid or non-existent working directory' }));
459
546
  }
460
- const artifact = saveGoalArtifact(snap, safeCwd);
547
+ const artifact = saveGoalArtifact(snap || { objective: 'Goal Report' }, safeCwd);
461
548
  if (!artifact) {
462
549
  res.statusCode = 500;
463
550
  return res.end(JSON.stringify({ error: 'Failed to write goal artifact to disk' }));
464
551
  }
465
552
  res.statusCode = 200;
466
- return res.end(JSON.stringify({ ok: true, artifact, state: snap }));
553
+ return res.end(JSON.stringify({ ok: true, artifact, state: snap || {} }));
467
554
  }
468
555
 
469
556
  case 'intervene': {
470
- result = engine.intervene(data, sid);
471
- if (data.resume) {
472
- engine.resume(sid);
473
- const prompt = engine.getStatePromptInjection(sid);
474
- if (typeof resumeActiveAgent === 'function') {
475
- resumeActiveAgent(prompt, sid);
557
+ if (engine) {
558
+ result = engine.intervene(data, sid);
559
+ if (data.resume) {
560
+ engine.resume(sid);
561
+ const prompt = engine.getStatePromptInjection(sid);
562
+ if (typeof resumeActiveAgent === 'function') {
563
+ resumeActiveAgent(prompt, sid);
564
+ }
476
565
  }
566
+ } else {
567
+ result = { intervened: true };
477
568
  }
478
569
  break;
479
570
  }
480
571
 
481
572
  case 'preplan_approve': {
482
- result = engine.approvePreplan(sid);
483
- const prompt = engine.getStatePromptInjection(sid);
484
- if (typeof resumeActiveAgent === 'function') {
485
- resumeActiveAgent(prompt, sid);
573
+ if (engine) {
574
+ result = engine.approvePreplan(sid);
575
+ const prompt = engine.getStatePromptInjection(sid);
576
+ if (typeof resumeActiveAgent === 'function') {
577
+ resumeActiveAgent(prompt, sid);
578
+ }
486
579
  }
487
580
  break;
488
581
  }
@@ -490,20 +583,22 @@ export function registerRoutes(ctx, {
490
583
  case 'branch_action': {
491
584
  const op = String(data.operation || data.branchOperation || 'merge').toLowerCase();
492
585
  const targetBranch = String(data.targetBranch || 'main');
493
- if (op === 'merge') {
494
- const ok = engine.mergeGoalBranch(targetBranch, sid);
495
- if (!ok) {
496
- res.statusCode = 500;
497
- return res.end(JSON.stringify({ error: `Failed to merge goal branch into ${targetBranch}` }));
498
- }
499
- } else if (op === 'discard') {
500
- const ok = engine.discardGoalBranch(targetBranch, sid);
501
- if (!ok) {
502
- res.statusCode = 500;
503
- return res.end(JSON.stringify({ error: 'Failed to discard goal branch' }));
586
+ if (engine) {
587
+ if (op === 'merge') {
588
+ const ok = engine.mergeGoalBranch(targetBranch, sid);
589
+ if (!ok) {
590
+ res.statusCode = 500;
591
+ return res.end(JSON.stringify({ error: `Failed to merge goal branch into ${targetBranch}` }));
592
+ }
593
+ } else if (op === 'discard') {
594
+ const ok = engine.discardGoalBranch(targetBranch, sid);
595
+ if (!ok) {
596
+ res.statusCode = 500;
597
+ return res.end(JSON.stringify({ error: 'Failed to discard goal branch' }));
598
+ }
504
599
  }
600
+ result = engine.getSnapshot(sid);
505
601
  }
506
- result = engine.getSnapshot(sid);
507
602
  break;
508
603
  }
509
604
 
@@ -511,21 +606,57 @@ export function registerRoutes(ctx, {
511
606
  res.statusCode = 200;
512
607
  return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
513
608
 
609
+ case 'instantiate_template': {
610
+ const tplId = data.templateId || data.id;
611
+ if (!tplId) {
612
+ res.statusCode = 400;
613
+ return res.end(JSON.stringify({ error: 'templateId is required' }));
614
+ }
615
+ const instance = instantiateTemplate(tplId, data.inputs || {});
616
+ if (!instance) {
617
+ res.statusCode = 404;
618
+ return res.end(JSON.stringify({ error: `Template "${tplId}" not found` }));
619
+ }
620
+ res.statusCode = 200;
621
+ return res.end(JSON.stringify({ ok: true, template: instance }));
622
+ }
623
+
514
624
  default:
515
625
  res.statusCode = 400;
516
626
  return res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
517
627
  }
518
628
 
519
629
  res.statusCode = 200;
520
- return res.end(JSON.stringify({ ok: true, state: result || engine.getSnapshot(sid) }));
630
+ return res.end(JSON.stringify({ ok: true, state: result || (engine ? engine.getSnapshot(sid) : {}) }));
521
631
  } catch (parseErr) {
522
632
  res.statusCode = 400;
523
633
  return res.end(JSON.stringify({ error: parseErr.message }));
524
634
  }
525
- });
635
+ };
636
+
637
+ if (typeof req.on === 'function') {
638
+ req.on('data', onData);
639
+ req.on('end', onEnd);
640
+ } else {
641
+ onEnd();
642
+ }
526
643
  return;
527
644
  }
528
645
 
646
+ const KNOWN_ROUTES = {
647
+ '/dsh-goal/events': ['GET'],
648
+ '/dsh-goal/templates': ['GET'],
649
+ '/dsh-goal/state': ['GET'],
650
+ '/dsh-goal/action': ['POST'],
651
+ };
652
+ const cleanPath = pathname.replace(/\/+$/, '') || '/';
653
+ const allowedMethods = KNOWN_ROUTES[cleanPath];
654
+ if (allowedMethods && !allowedMethods.includes(req.method)) {
655
+ res.statusCode = 405;
656
+ if (typeof res.setHeader === 'function') res.setHeader('Allow', allowedMethods.join(', '));
657
+ return res.end(JSON.stringify({ error: `Method Not Allowed: use ${allowedMethods.join(', ')}` }));
658
+ }
659
+
529
660
  res.statusCode = 404;
530
661
  res.end(JSON.stringify({ error: 'Endpoint not found' }));
531
662
  },
@@ -535,15 +666,25 @@ export function registerRoutes(ctx, {
535
666
  clearInterval(keepaliveTimer);
536
667
  if (typeof unreg === 'function') unreg();
537
668
  for (const clients of sseClients.values()) {
538
- for (const res of clients) {
539
- try {
540
- res.end();
541
- } catch (err) {
542
- // Already closed
669
+ if (clients instanceof Set) {
670
+ for (const res of clients) {
671
+ try { res.end(); } catch (err) { /* closed */ }
543
672
  }
544
673
  }
545
674
  }
546
675
  sseClients.clear();
547
676
  };
548
- }, 'dsh-goal: HTTP WebServer Routes & SSE');
677
+ };
678
+
679
+ let cleanup = null;
680
+ if (typeof ctx?.effect === 'function') {
681
+ cleanup = ctx.effect(() => setupWebServer(), 'dsh-goal: HTTP WebServer Routes & SSE');
682
+ } else {
683
+ cleanup = setupWebServer();
684
+ }
685
+
686
+ return {
687
+ broadcastEvent,
688
+ cleanup,
689
+ };
549
690
  }