reflectt-node 0.1.20 → 0.1.22
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/README.md +64 -0
- package/dist/canvas-interactive.d.ts +53 -0
- package/dist/canvas-interactive.d.ts.map +1 -1
- package/dist/canvas-interactive.js +194 -0
- package/dist/canvas-interactive.js.map +1 -1
- package/dist/canvas-push.d.ts +2 -0
- package/dist/canvas-push.d.ts.map +1 -1
- package/dist/canvas-push.js +89 -0
- package/dist/canvas-push.js.map +1 -1
- package/dist/channels.d.ts +1 -1
- package/dist/intervention-template.d.ts +67 -0
- package/dist/intervention-template.d.ts.map +1 -0
- package/dist/intervention-template.js +290 -0
- package/dist/intervention-template.js.map +1 -0
- package/dist/request-tracker.d.ts +1 -0
- package/dist/request-tracker.d.ts.map +1 -1
- package/dist/request-tracker.js +16 -4
- package/dist/request-tracker.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +157 -4
- package/dist/server.js.map +1 -1
- package/dist/stall-detector.d.ts +109 -0
- package/dist/stall-detector.d.ts.map +1 -0
- package/dist/stall-detector.js +279 -0
- package/dist/stall-detector.js.map +1 -0
- package/package.json +1 -1
- package/public/docs.md +9 -0
- package/dist/agent-config.test.d.ts +0 -2
- package/dist/agent-config.test.d.ts.map +0 -1
- package/dist/agent-config.test.js +0 -91
- package/dist/agent-config.test.js.map +0 -1
- package/dist/agent-exec-guardrail.test.d.ts +0 -2
- package/dist/agent-exec-guardrail.test.d.ts.map +0 -1
- package/dist/agent-exec-guardrail.test.js +0 -55
- package/dist/agent-exec-guardrail.test.js.map +0 -1
- package/dist/agent-memories.test.d.ts +0 -2
- package/dist/agent-memories.test.d.ts.map +0 -1
- package/dist/agent-memories.test.js +0 -327
- package/dist/agent-memories.test.js.map +0 -1
- package/dist/agent-messaging.test.d.ts +0 -2
- package/dist/agent-messaging.test.d.ts.map +0 -1
- package/dist/agent-messaging.test.js +0 -105
- package/dist/agent-messaging.test.js.map +0 -1
- package/dist/agent-runs.test.d.ts +0 -2
- package/dist/agent-runs.test.d.ts.map +0 -1
- package/dist/agent-runs.test.js +0 -386
- package/dist/agent-runs.test.js.map +0 -1
- package/dist/api.test.d.ts +0 -2
- package/dist/api.test.d.ts.map +0 -1
- package/dist/api.test.js +0 -99
- package/dist/api.test.js.map +0 -1
- package/dist/approval-queue.test.d.ts +0 -2
- package/dist/approval-queue.test.d.ts.map +0 -1
- package/dist/approval-queue.test.js +0 -118
- package/dist/approval-queue.test.js.map +0 -1
- package/dist/artifact-store.test.d.ts +0 -2
- package/dist/artifact-store.test.d.ts.map +0 -1
- package/dist/artifact-store.test.js +0 -119
- package/dist/artifact-store.test.js.map +0 -1
- package/dist/canvas-input.test.d.ts +0 -2
- package/dist/canvas-input.test.d.ts.map +0 -1
- package/dist/canvas-input.test.js +0 -96
- package/dist/canvas-input.test.js.map +0 -1
- package/dist/canvas-render.test.d.ts +0 -2
- package/dist/canvas-render.test.d.ts.map +0 -1
- package/dist/canvas-render.test.js +0 -95
- package/dist/canvas-render.test.js.map +0 -1
- package/dist/e2e-loop-proof.test.d.ts +0 -2
- package/dist/e2e-loop-proof.test.d.ts.map +0 -1
- package/dist/e2e-loop-proof.test.js +0 -114
- package/dist/e2e-loop-proof.test.js.map +0 -1
- package/dist/email-sms-send.test.d.ts +0 -2
- package/dist/email-sms-send.test.d.ts.map +0 -1
- package/dist/email-sms-send.test.js +0 -96
- package/dist/email-sms-send.test.js.map +0 -1
- package/dist/handoff-state.test.d.ts +0 -2
- package/dist/handoff-state.test.d.ts.map +0 -1
- package/dist/handoff-state.test.js +0 -102
- package/dist/handoff-state.test.js.map +0 -1
- package/dist/routing-enforcement.test.d.ts +0 -2
- package/dist/routing-enforcement.test.d.ts.map +0 -1
- package/dist/routing-enforcement.test.js +0 -62
- package/dist/routing-enforcement.test.js.map +0 -1
- package/dist/run-retention.test.d.ts +0 -2
- package/dist/run-retention.test.d.ts.map +0 -1
- package/dist/run-retention.test.js +0 -57
- package/dist/run-retention.test.js.map +0 -1
- package/dist/run-stream.test.d.ts +0 -2
- package/dist/run-stream.test.d.ts.map +0 -1
- package/dist/run-stream.test.js +0 -70
- package/dist/run-stream.test.js.map +0 -1
- package/dist/webhook-storage.test.d.ts +0 -2
- package/dist/webhook-storage.test.d.ts.map +0 -1
- package/dist/webhook-storage.test.js +0 -86
- package/dist/webhook-storage.test.js.map +0 -1
- package/dist/workflow-canvas-state.test.d.ts +0 -2
- package/dist/workflow-canvas-state.test.d.ts.map +0 -1
- package/dist/workflow-canvas-state.test.js +0 -53
- package/dist/workflow-canvas-state.test.js.map +0 -1
- package/dist/workflow-templates.test.d.ts +0 -2
- package/dist/workflow-templates.test.d.ts.map +0 -1
- package/dist/workflow-templates.test.js +0 -76
- package/dist/workflow-templates.test.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -12,6 +12,8 @@ import { promises as fs, existsSync, readFileSync, readdirSync } from 'fs';
|
|
|
12
12
|
import { resolve, sep, join } from 'path';
|
|
13
13
|
import { execSync } from 'child_process';
|
|
14
14
|
import { serverConfig, openclawConfig, isDev, REFLECTT_HOME, DATA_DIR } from './config.js';
|
|
15
|
+
import { getStallDetector, emitWorkflowStall, onStallEvent } from './stall-detector.js';
|
|
16
|
+
import { processStallEvent } from './intervention-template.js';
|
|
15
17
|
import { trackRequest, getRequestMetrics } from './request-tracker.js';
|
|
16
18
|
import { getPreflightMetrics, snapshotDailyMetrics, getDailySnapshots, startAutoSnapshot } from './alert-preflight.js';
|
|
17
19
|
// ── Build info (read once at startup) ──────────────────────────────────────
|
|
@@ -325,6 +327,7 @@ const UpdateTaskSchema = z.object({
|
|
|
325
327
|
assignee: z.string().optional(),
|
|
326
328
|
reviewer: z.string().optional(),
|
|
327
329
|
done_criteria: z.array(z.string().min(1)).optional(),
|
|
330
|
+
criteria_verified: z.boolean().optional(), // bypass done_criteria gate for todo→validating
|
|
328
331
|
priority: z.enum(['P0', 'P1', 'P2', 'P3']).optional(),
|
|
329
332
|
blocked_by: z.array(z.string()).optional(),
|
|
330
333
|
epic_id: z.string().optional(),
|
|
@@ -2435,6 +2438,82 @@ export async function createServer() {
|
|
|
2435
2438
|
// Start self-keepalive to prevent container eviction in serverless environments
|
|
2436
2439
|
const serverPort = Number(process.env['PORT'] || process.env['REFLECTT_PORT'] || 4445);
|
|
2437
2440
|
startSelfKeepalive(serverPort);
|
|
2441
|
+
// ── Stall Detector ─────────────────────────────────────────────────────────
|
|
2442
|
+
const sd = getStallDetector();
|
|
2443
|
+
// Register stall event handler: compile intervention and post to chat
|
|
2444
|
+
onStallEvent((event) => {
|
|
2445
|
+
// Adapt stall-detector event to intervention-template expected format
|
|
2446
|
+
const adaptedEvent = {
|
|
2447
|
+
stallId: event.stallId,
|
|
2448
|
+
userId: event.userId,
|
|
2449
|
+
stallType: event.stallType,
|
|
2450
|
+
personalizations: {
|
|
2451
|
+
user_name: event.userId,
|
|
2452
|
+
last_intent: event.context?.lastAction,
|
|
2453
|
+
active_task_title: event.context?.lastAction,
|
|
2454
|
+
last_agent_name: event.context?.lastAgent,
|
|
2455
|
+
},
|
|
2456
|
+
timestamp: event.timestamp,
|
|
2457
|
+
};
|
|
2458
|
+
const result = processStallEvent(adaptedEvent);
|
|
2459
|
+
if (!result.sent) {
|
|
2460
|
+
console.debug('[StallDetector] Intervention not sent:', result.reason);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
// Select an agent to send the intervention (use lastAgent from context, or default)
|
|
2464
|
+
const agentName = event.context?.lastAgent || 'rhythm';
|
|
2465
|
+
const message = result.message || 'Hey! Just checking in — want to pick up where you left off?';
|
|
2466
|
+
// Post to #general as the intervening agent
|
|
2467
|
+
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
|
2468
|
+
fetch(`${baseUrl}/chat/messages`, {
|
|
2469
|
+
method: 'POST',
|
|
2470
|
+
headers: { 'Content-Type': 'application/json', 'x-reflectt-internal': 'true' },
|
|
2471
|
+
body: JSON.stringify({ from: agentName, channel: 'general', content: message }),
|
|
2472
|
+
}).catch((err) => console.error('[StallDetector] Failed to post intervention:', err));
|
|
2473
|
+
});
|
|
2474
|
+
// Start stall detector if enabled in config
|
|
2475
|
+
const stallConfig = serverConfig.stallDetector;
|
|
2476
|
+
if (stallConfig?.enabled)
|
|
2477
|
+
sd.start();
|
|
2478
|
+
app.get('/stall-detector', async () => {
|
|
2479
|
+
return {
|
|
2480
|
+
enabled: sd.getAllStates().length > 0,
|
|
2481
|
+
states: sd.getAllStates().map(s => ({
|
|
2482
|
+
userId: s.userId,
|
|
2483
|
+
phase: s.phase,
|
|
2484
|
+
context: s.context,
|
|
2485
|
+
stallFired: [...s.stallFired],
|
|
2486
|
+
})),
|
|
2487
|
+
};
|
|
2488
|
+
});
|
|
2489
|
+
app.post('/stall-detector/config', async (request) => {
|
|
2490
|
+
const body = (request.body ?? {});
|
|
2491
|
+
// Accept: { enabled, thresholds: { newUserMinutes, inSessionMinutes, setupMinutes } }
|
|
2492
|
+
const newCfg = {};
|
|
2493
|
+
if (typeof body.enabled === 'boolean') {
|
|
2494
|
+
newCfg.enabled = body.enabled;
|
|
2495
|
+
}
|
|
2496
|
+
if (body.thresholds && typeof body.thresholds === 'object') {
|
|
2497
|
+
newCfg.thresholds = body.thresholds;
|
|
2498
|
+
}
|
|
2499
|
+
// Merge into serverConfig
|
|
2500
|
+
;
|
|
2501
|
+
serverConfig.stallDetector = {
|
|
2502
|
+
...(serverConfig.stallDetector ?? {}),
|
|
2503
|
+
...newCfg,
|
|
2504
|
+
};
|
|
2505
|
+
if (newCfg.enabled)
|
|
2506
|
+
sd.start();
|
|
2507
|
+
return { success: true, config: serverConfig.stallDetector };
|
|
2508
|
+
});
|
|
2509
|
+
app.post('/stall-detector/test', async (request) => {
|
|
2510
|
+
// Fire a test stall event for a given userId
|
|
2511
|
+
const { userId } = (request.body ?? {});
|
|
2512
|
+
if (!userId)
|
|
2513
|
+
return { success: false, error: 'userId required' };
|
|
2514
|
+
sd.recordActivity(userId, { phase: 'new_user' });
|
|
2515
|
+
return { success: true, message: `Recorded activity for ${userId}` };
|
|
2516
|
+
});
|
|
2438
2517
|
// Self-keepalive status + warm boot info
|
|
2439
2518
|
app.get('/health/keepalive', async () => {
|
|
2440
2519
|
return getSelfKeepaliveStatus();
|
|
@@ -3549,6 +3628,8 @@ export async function createServer() {
|
|
|
3549
3628
|
if (data.from) {
|
|
3550
3629
|
presenceManager.recordActivity(data.from, 'message');
|
|
3551
3630
|
presenceManager.touchPresence(data.from);
|
|
3631
|
+
// Stall detector: user sent a message — record activity
|
|
3632
|
+
getStallDetector().recordActivity(data.from);
|
|
3552
3633
|
// Activation funnel: first team message
|
|
3553
3634
|
emitActivationEvent('first_team_message_sent', data.from, {
|
|
3554
3635
|
channel: data.channel || 'general',
|
|
@@ -7984,7 +8065,8 @@ export async function createServer() {
|
|
|
7984
8065
|
'in-progress': ['blocked', 'validating', 'done', 'doing', 'todo', 'cancelled'], // legacy, permissive
|
|
7985
8066
|
};
|
|
7986
8067
|
const allowed = ALLOWED_TRANSITIONS[existing.status] ?? [];
|
|
7987
|
-
|
|
8068
|
+
// Allow todo→validating when criteria_verified=true
|
|
8069
|
+
if (!allowed.includes(parsed.status) && !(parsed.status === 'validating' && existing.status === 'todo' && parsed.criteria_verified === true)) {
|
|
7988
8070
|
const meta = (incomingMeta ?? {});
|
|
7989
8071
|
const isReopen = meta.reopen === true;
|
|
7990
8072
|
const reopenReason = typeof meta.reopen_reason === 'string' ? String(meta.reopen_reason).trim() : '';
|
|
@@ -8004,6 +8086,21 @@ export async function createServer() {
|
|
|
8004
8086
|
mergedMeta.reopen_reason = reopenReason;
|
|
8005
8087
|
mergedMeta.reopened_at = Date.now();
|
|
8006
8088
|
mergedMeta.reopened_from = existing.status;
|
|
8089
|
+
// ── Done-criteria verification gate ──
|
|
8090
|
+
// Block todo→validating unless criteria_verified=true is set.
|
|
8091
|
+
if (parsed.status === 'validating' && existing.status === 'todo') {
|
|
8092
|
+
const hasDoneCriteria = Boolean(existing.done_criteria && existing.done_criteria.length > 0);
|
|
8093
|
+
if (hasDoneCriteria && parsed.criteria_verified !== true) {
|
|
8094
|
+
const dc = Array.isArray(existing.done_criteria) ? existing.done_criteria.length : 0;
|
|
8095
|
+
reply.code(422);
|
|
8096
|
+
return {
|
|
8097
|
+
success: false,
|
|
8098
|
+
error: `All ${dc} done criteria must be verified. Set criteria_verified=true in PATCH body to unblock.`,
|
|
8099
|
+
code: 'DONE_CRITERIA_NOT_VERIFIED',
|
|
8100
|
+
gate: 'done_criteria_verification',
|
|
8101
|
+
};
|
|
8102
|
+
}
|
|
8103
|
+
}
|
|
8007
8104
|
// Emit trust signal: forced state bypass
|
|
8008
8105
|
const NORMAL_ESCALATION_PATHS = ['todo→doing', 'doing→validating', 'validating→done'];
|
|
8009
8106
|
const jumpPath = `${existing.status}→${parsed.status}`;
|
|
@@ -8495,6 +8592,19 @@ export async function createServer() {
|
|
|
8495
8592
|
reply.code(404);
|
|
8496
8593
|
return { success: false, error: 'Task not found' };
|
|
8497
8594
|
}
|
|
8595
|
+
// ── Emit workflow stall when task enters review ──
|
|
8596
|
+
if (effectiveTargetStatus === 'validating' &&
|
|
8597
|
+
existing.status !== 'validating' &&
|
|
8598
|
+
existing.status !== 'done') {
|
|
8599
|
+
const reviewer = task.reviewer;
|
|
8600
|
+
if (reviewer) {
|
|
8601
|
+
emitWorkflowStall(reviewer, 'review_pending', {
|
|
8602
|
+
lastAction: `task "${task.title}" submitted for review`,
|
|
8603
|
+
lastAgent: task.assignee || 'unknown',
|
|
8604
|
+
lastActionAt: Date.now(),
|
|
8605
|
+
});
|
|
8606
|
+
}
|
|
8607
|
+
}
|
|
8498
8608
|
// ── Audit ledger: log review-field mutations ──
|
|
8499
8609
|
{
|
|
8500
8610
|
const oldMeta = (existing.metadata || {});
|
|
@@ -8549,6 +8659,10 @@ export async function createServer() {
|
|
|
8549
8659
|
if (parsed.status === 'done') {
|
|
8550
8660
|
presenceManager.recordActivity(task.assignee, 'task_completed');
|
|
8551
8661
|
presenceManager.updatePresence(task.assignee, 'working', null);
|
|
8662
|
+
// Stall detector: agent completed a task — user should respond
|
|
8663
|
+
// Determine who to notify: the task creator / assignee who might be waiting
|
|
8664
|
+
const waitingUserId = task.metadata?.userId || task.assignee;
|
|
8665
|
+
getStallDetector().recordAgentResponse(waitingUserId, task.assignee);
|
|
8552
8666
|
trackTaskEvent('completed');
|
|
8553
8667
|
}
|
|
8554
8668
|
else if (parsed.status === 'doing') {
|
|
@@ -10137,11 +10251,13 @@ export async function createServer() {
|
|
|
10137
10251
|
// ── Canvas interactive routes (extracted to src/canvas-interactive.ts) ─────
|
|
10138
10252
|
// POST /canvas/gaze, POST /canvas/briefing, POST /canvas/victory,
|
|
10139
10253
|
// POST /canvas/spark, POST /canvas/express, GET /canvas/render/stream
|
|
10140
|
-
const { canvasInteractiveRoutes } = await import("./canvas-interactive.js");
|
|
10254
|
+
const { canvasInteractiveRoutes, registerCapabilityRoutes } = await import("./canvas-interactive.js");
|
|
10141
10255
|
await app.register(canvasInteractiveRoutes, {
|
|
10142
10256
|
eventBus,
|
|
10143
10257
|
canvasStateMap,
|
|
10144
10258
|
});
|
|
10259
|
+
// Register capability routes: GET/POST /canvas/capability
|
|
10260
|
+
registerCapabilityRoutes(app);
|
|
10145
10261
|
// ── Canvas activity stream — SSE with backfill ────────────────────────
|
|
10146
10262
|
// New viewers get the last 20 canvas events immediately on connect (backfill),
|
|
10147
10263
|
// then receive live events going forward. Canvas feels alive from frame 1.
|
|
@@ -10314,6 +10430,7 @@ export async function createServer() {
|
|
|
10314
10430
|
await app.register(canvasPushRoutes, {
|
|
10315
10431
|
eventBus,
|
|
10316
10432
|
queueCanvasPushEvent,
|
|
10433
|
+
canvasStateMap,
|
|
10317
10434
|
});
|
|
10318
10435
|
// GET /canvas/pulse — SSE stream emitting a heartbeat tick every 2s with live intensity values
|
|
10319
10436
|
// Drives smooth canvas animation without polling. Each tick includes per-agent orb data + team mood.
|
|
@@ -10359,6 +10476,28 @@ export async function createServer() {
|
|
|
10359
10476
|
}
|
|
10360
10477
|
catch { /* non-blocking */ }
|
|
10361
10478
|
};
|
|
10479
|
+
let canvasMetaCache = null;
|
|
10480
|
+
const getCanvasMeta = () => {
|
|
10481
|
+
const now = Date.now();
|
|
10482
|
+
if (canvasMetaCache && (now - canvasMetaCache.age) < 30_000)
|
|
10483
|
+
return canvasMetaCache;
|
|
10484
|
+
const focus = getFocus();
|
|
10485
|
+
let upcomingEvents = [];
|
|
10486
|
+
try {
|
|
10487
|
+
const events = calendarEvents.listEvents({ from: now, to: now + 24 * 60 * 60 * 1000, limit: 5 });
|
|
10488
|
+
upcomingEvents = events.map(e => ({ id: e.id, summary: e.summary, dtstart: e.dtstart, organizer: e.organizer }));
|
|
10489
|
+
}
|
|
10490
|
+
catch { /* skip */ }
|
|
10491
|
+
let recentActivity = [];
|
|
10492
|
+
try {
|
|
10493
|
+
const twoHoursAgo = now - 2 * 60 * 60 * 1000;
|
|
10494
|
+
const activity = queryActivity({ range: '24h', type: ['task', 'chat'], limit: 10 });
|
|
10495
|
+
recentActivity = activity.events.filter(e => e.ts_ms > twoHoursAgo).slice(0, 5).map(e => ({ ts: e.ts_ms, type: e.type, subject: e.subject }));
|
|
10496
|
+
}
|
|
10497
|
+
catch { /* skip */ }
|
|
10498
|
+
canvasMetaCache = { focus, upcomingEvents, recentActivity, age: now };
|
|
10499
|
+
return canvasMetaCache;
|
|
10500
|
+
};
|
|
10362
10501
|
const emitTick = () => {
|
|
10363
10502
|
if (closed)
|
|
10364
10503
|
return;
|
|
@@ -10411,10 +10550,12 @@ export async function createServer() {
|
|
|
10411
10550
|
break;
|
|
10412
10551
|
}
|
|
10413
10552
|
}
|
|
10553
|
+
// Include focus, calendar, and activity (cached, 30s TTL)
|
|
10554
|
+
const meta = getCanvasMeta();
|
|
10414
10555
|
const tick = {
|
|
10415
10556
|
t: now,
|
|
10416
10557
|
agents,
|
|
10417
|
-
team: { rhythm, tension, ambientPulse, dominantColor },
|
|
10558
|
+
team: { rhythm, tension, ambientPulse, dominantColor, ...meta },
|
|
10418
10559
|
};
|
|
10419
10560
|
try {
|
|
10420
10561
|
reply.raw.write(`data: ${JSON.stringify(tick)}\n\n`);
|
|
@@ -10627,6 +10768,9 @@ export async function createServer() {
|
|
|
10627
10768
|
'Connection': 'keep-alive',
|
|
10628
10769
|
'X-Accel-Buffering': 'no',
|
|
10629
10770
|
});
|
|
10771
|
+
// Track live viewer
|
|
10772
|
+
liveViewerCount++;
|
|
10773
|
+
let viewersDirty = true;
|
|
10630
10774
|
// Derive agents from task board — show ALL agents, not just canvas-state emitters
|
|
10631
10775
|
const allTasks = taskManager.listTasks({});
|
|
10632
10776
|
const agentStates = {};
|
|
@@ -10651,7 +10795,7 @@ export async function createServer() {
|
|
|
10651
10795
|
}
|
|
10652
10796
|
// Send current state as initial snapshot — include all agents from task board
|
|
10653
10797
|
const activeSlots = canvasSlots.getActive();
|
|
10654
|
-
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ slots: activeSlots, agents: agentStates })}\n\n`);
|
|
10798
|
+
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ slots: activeSlots, agents: agentStates, viewers: liveViewerCount })}\n\n`);
|
|
10655
10799
|
// Subscribe to new render events
|
|
10656
10800
|
const unsubscribe = subscribeCanvas((event, slot) => {
|
|
10657
10801
|
try {
|
|
@@ -10672,10 +10816,19 @@ export async function createServer() {
|
|
|
10672
10816
|
}, 15_000);
|
|
10673
10817
|
// Cleanup on disconnect
|
|
10674
10818
|
request.raw.on('close', () => {
|
|
10819
|
+
liveViewerCount = Math.max(0, liveViewerCount - 1);
|
|
10675
10820
|
unsubscribe();
|
|
10676
10821
|
clearInterval(heartbeat);
|
|
10677
10822
|
});
|
|
10678
10823
|
});
|
|
10824
|
+
// ── Live Viewer Counter ─────────────────────────────────────────────
|
|
10825
|
+
// Tracks open SSE connections to /canvas/stream with live=true
|
|
10826
|
+
// Exposed via GET /canvas/viewers
|
|
10827
|
+
let liveViewerCount = 0;
|
|
10828
|
+
app.get('/canvas/viewers', async (_request, reply) => {
|
|
10829
|
+
reply.header('cache-control', 'no-cache');
|
|
10830
|
+
return reply.send({ viewers: liveViewerCount });
|
|
10831
|
+
});
|
|
10679
10832
|
// ── Feedback Collection ─────────────────────────────────────────────
|
|
10680
10833
|
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general']);
|
|
10681
10834
|
app.post('/feedback', async (request, reply) => {
|