hanzi-browse 2.2.1 → 2.2.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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Hanzi Developer Console</title>
7
- <script type="module" crossorigin src="/dashboard/assets/index-B6M8kZZo.js"></script>
7
+ <script type="module" crossorigin src="/dashboard/assets/index-wVMUNuBA.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/dashboard/assets/index-vMQ9aDcG.css">
9
9
  </head>
10
10
  <body>
package/dist/index.js CHANGED
@@ -12,6 +12,27 @@ if (process.argv[2] === 'setup') {
12
12
  catch { /* exit code propagated */ }
13
13
  process.exit(0);
14
14
  }
15
+ // If invoked as `npx hanzi-browse telemetry [on|off]`, handle inline
16
+ if (process.argv[2] === 'telemetry') {
17
+ const { isTelemetryEnabled, setTelemetryEnabled } = await import('./telemetry.js');
18
+ const sub = process.argv[3];
19
+ if (sub === 'on') {
20
+ setTelemetryEnabled(true);
21
+ console.log('Telemetry enabled. Anonymous usage stats help improve Hanzi.');
22
+ }
23
+ else if (sub === 'off') {
24
+ setTelemetryEnabled(false);
25
+ console.log('Telemetry disabled. No data will be collected.');
26
+ }
27
+ else {
28
+ console.log(`Telemetry is ${isTelemetryEnabled() ? 'enabled' : 'disabled'}.`);
29
+ console.log('Usage: hanzi-browse telemetry [on|off]');
30
+ }
31
+ process.exit(0);
32
+ }
33
+ import { initTelemetry, trackEvent, captureException, shutdownTelemetry } from "./telemetry.js";
34
+ initTelemetry();
35
+ trackEvent("mcp_start");
15
36
  /**
16
37
  * Hanzi Browse MCP Server
17
38
  *
@@ -978,6 +999,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
978
999
  session.status = "timeout";
979
1000
  session.error = `Task still running after ${TASK_TIMEOUT_MS / 60000} minutes. Use browser_screenshot to check progress, then browser_message to continue or browser_stop to end.`;
980
1001
  }
1002
+ if (session.status === "complete") {
1003
+ trackEvent("task_completed", {
1004
+ steps: session.steps.length,
1005
+ duration_ms: Date.now() - session.createdAt,
1006
+ });
1007
+ }
1008
+ else {
1009
+ trackEvent("task_failed", {
1010
+ error_category: session.status === "timeout" ? "timeout" : "unknown",
1011
+ steps: session.steps.length,
1012
+ });
1013
+ }
981
1014
  return {
982
1015
  content: [{ type: "text", text: JSON.stringify(formatResult(session), null, 2) }],
983
1016
  isError: session.status === "error",
@@ -1068,6 +1101,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1068
1101
  }
1069
1102
  }
1070
1103
  catch (error) {
1104
+ captureException(error, { tool: name });
1071
1105
  return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
1072
1106
  }
1073
1107
  });
@@ -1110,7 +1144,13 @@ async function main() {
1110
1144
  await server.connect(transport);
1111
1145
  console.error("[MCP] Server running (browser execution: extension-side)");
1112
1146
  }
1147
+ process.on("beforeExit", async () => {
1148
+ await shutdownTelemetry();
1149
+ });
1150
+ process.on("SIGTERM", async () => { await shutdownTelemetry(); process.exit(0); });
1151
+ process.on("SIGINT", async () => { await shutdownTelemetry(); process.exit(0); });
1113
1152
  main().catch((error) => {
1153
+ captureException(error, { context: "fatal_startup" });
1114
1154
  console.error("[MCP] Fatal:", error);
1115
1155
  process.exit(1);
1116
1156
  });
@@ -43,6 +43,20 @@ export declare function initManagedAPI(relay: WebSocketClient, sessionConnectedC
43
43
  * Handle incoming relay messages (tool results from extension).
44
44
  */
45
45
  export declare function handleRelayMessage(message: any): boolean;
46
+ /**
47
+ * Run a task internally (used by scheduler — no HTTP, no auth, no billing).
48
+ * Returns a promise that resolves when the task completes.
49
+ */
50
+ export declare function runInternalTask(params: {
51
+ workspaceId: string;
52
+ browserSessionId: string;
53
+ task: string;
54
+ url?: string;
55
+ }): Promise<{
56
+ taskId: string;
57
+ answer?: string;
58
+ status: string;
59
+ }>;
46
60
  export declare function startManagedAPI(port?: number): void;
47
61
  /**
48
62
  * Graceful shutdown: abort all running tasks and update their status.
@@ -21,6 +21,7 @@
21
21
  import { createServer } from "http";
22
22
  import { randomUUID } from "crypto";
23
23
  import { log } from "./log.js";
24
+ import { trackManagedEvent, captureManagedError } from "./telemetry.js";
24
25
  import { runAgentLoop, } from "../agent/loop.js";
25
26
  import * as fileStore from "./store.js";
26
27
  import { createAuth, resolveSessionToWorkspace, resolveSessionProfile } from "./auth.js";
@@ -35,6 +36,20 @@ let S = fileStore;
35
36
  export function setStoreModule(storeModule) {
36
37
  S = storeModule;
37
38
  }
39
+ function categorizeError(err) {
40
+ const msg = err.message.toLowerCase();
41
+ if (msg.includes("timeout"))
42
+ return "timeout";
43
+ if (msg.includes("disconnected") || msg.includes("not connected"))
44
+ return "browser_disconnected";
45
+ if (msg.includes("rate limit") || msg.includes("429"))
46
+ return "rate_limited";
47
+ if (msg.includes("fetch failed") || msg.includes("llm"))
48
+ return "llm_error";
49
+ if (msg.includes("abort"))
50
+ return "aborted";
51
+ return "internal";
52
+ }
38
53
  let isSessionConnectedFn = null;
39
54
  let relayPort = 7862;
40
55
  // --- State ---
@@ -609,9 +624,11 @@ async function handleCreateTask(body, apiKey, requestId) {
609
624
  context,
610
625
  browserSessionId: browser_session_id,
611
626
  });
627
+ trackManagedEvent("task_created", apiKey.workspaceId, { has_url: !!url, has_context: !!context });
612
628
  const abort = new AbortController();
613
629
  taskAborts.set(taskRun.id, abort);
614
630
  taskWorkspaceMap.set(taskRun.id, { workspaceId: apiKey.workspaceId, startedAt: Date.now() });
631
+ const taskStartedAt = Date.now();
615
632
  // Task-level timeout — abort if agent loop exceeds max duration
616
633
  const taskTimeout = setTimeout(() => {
617
634
  abort.abort();
@@ -720,10 +737,18 @@ async function handleCreateTask(body, apiKey, requestId) {
720
737
  }
721
738
  }
722
739
  if (updated) {
740
+ trackManagedEvent("task_completed", apiKey.workspaceId, {
741
+ steps: result.steps,
742
+ duration_ms: Date.now() - taskStartedAt,
743
+ input_tokens: result.usage.inputTokens,
744
+ output_tokens: result.usage.outputTokens,
745
+ });
723
746
  log.info("Task completed", { requestId, taskId: taskRun.id, workspaceId: apiKey.workspaceId }, { status, steps: result.steps });
724
747
  }
725
748
  })
726
749
  .catch(async (err) => {
750
+ trackManagedEvent("task_failed", apiKey.workspaceId, { error_category: categorizeError(err), duration_ms: Date.now() - taskStartedAt });
751
+ captureManagedError(err, { task_id: taskRun.id, workspace_id: apiKey.workspaceId });
727
752
  for (let attempt = 0; attempt < 2; attempt++) {
728
753
  try {
729
754
  await S.updateTaskRun(taskRun.id, {
@@ -1075,6 +1100,7 @@ async function handleRequest(req, res) {
1075
1100
  sendJson(req, res, 401, { error: "Invalid, expired, or already consumed pairing token" });
1076
1101
  return;
1077
1102
  }
1103
+ trackManagedEvent("browser_paired", session.workspaceId);
1078
1104
  sendJson(req, res, 201, {
1079
1105
  browser_session_id: session.id,
1080
1106
  session_token: session.sessionToken,
@@ -1098,6 +1124,7 @@ async function handleRequest(req, res) {
1098
1124
  const label = typeof body.label === "string" ? body.label.slice(0, 200) : undefined;
1099
1125
  const externalUserId = typeof body.external_user_id === "string" ? body.external_user_id.slice(0, 200) : undefined;
1100
1126
  const token = await S.createPairingToken(apiKey.workspaceId, apiKey.id, { label, externalUserId });
1127
+ trackManagedEvent("pairing_link_generated", apiKey.workspaceId);
1101
1128
  sendJson(req, res, 201, {
1102
1129
  pairing_token: token._plainToken,
1103
1130
  expires_at: token.expiresAt,
@@ -1220,6 +1247,7 @@ async function handleRequest(req, res) {
1220
1247
  return;
1221
1248
  }
1222
1249
  const newKey = await S.createApiKey(apiKey.workspaceId, name);
1250
+ trackManagedEvent("api_key_created", apiKey.workspaceId);
1223
1251
  sendJson(req, res, 201, {
1224
1252
  id: newKey.id,
1225
1253
  key: newKey.key, // plaintext — shown once
@@ -1283,6 +1311,195 @@ async function handleRequest(req, res) {
1283
1311
  sendJson(req, res, 200, session);
1284
1312
  return;
1285
1313
  }
1314
+ // ── Automations ───────────────────────────────────────────────────
1315
+ // POST /v1/automations — create a new automation
1316
+ if (method === "POST" && url === "/v1/automations") {
1317
+ const body = await parseBody(req);
1318
+ const { browser_session_id, config } = body;
1319
+ if (!browser_session_id || !config) {
1320
+ sendJson(req, res, 400, { error: "browser_session_id and config are required" });
1321
+ return;
1322
+ }
1323
+ if (!config.keywords?.length || !config.product_name) {
1324
+ sendJson(req, res, 400, { error: "config must include keywords (array) and product_name" });
1325
+ return;
1326
+ }
1327
+ if (!config.schedule_cron) {
1328
+ config.schedule_cron = "0 9 * * 1,3,5"; // default: 3x/week at 9am
1329
+ }
1330
+ const { computeNextRun } = await import("./scheduler.js");
1331
+ const nextRunAt = computeNextRun(config.schedule_cron, config.timezone);
1332
+ const auto = await S.createAutomation({
1333
+ workspaceId: apiKey.workspaceId,
1334
+ browserSessionId: browser_session_id,
1335
+ config,
1336
+ nextRunAt: nextRunAt || undefined,
1337
+ });
1338
+ sendJson(req, res, 201, auto);
1339
+ return;
1340
+ }
1341
+ // GET /v1/automations — list automations for workspace
1342
+ if (method === "GET" && url === "/v1/automations") {
1343
+ const list = await S.listAutomations(apiKey.workspaceId);
1344
+ sendJson(req, res, 200, list);
1345
+ return;
1346
+ }
1347
+ // PATCH /v1/automations/:id — update config, pause/resume
1348
+ const autoMatch = url?.match(/^\/v1\/automations\/([^/]+)$/);
1349
+ if (autoMatch && method === "PATCH") {
1350
+ const autoId = autoMatch[1];
1351
+ const body = await parseBody(req);
1352
+ const fields = {};
1353
+ if (body.status !== undefined)
1354
+ fields.status = body.status;
1355
+ if (body.config !== undefined)
1356
+ fields.config = body.config;
1357
+ if (body.browser_session_id !== undefined)
1358
+ fields.browserSessionId = body.browser_session_id;
1359
+ if (body.config?.schedule_cron || body.status === "active") {
1360
+ const auto = await S.getAutomation(autoId);
1361
+ if (auto) {
1362
+ const cron = body.config?.schedule_cron || auto.config.schedule_cron;
1363
+ const tz = body.config?.timezone || auto.config.timezone;
1364
+ const { computeNextRun } = await import("./scheduler.js");
1365
+ const next = computeNextRun(cron, tz);
1366
+ if (next)
1367
+ fields.nextRunAt = next;
1368
+ }
1369
+ }
1370
+ if (body.status === "active") {
1371
+ fields.consecutiveFailures = 0;
1372
+ fields.errorMessage = null;
1373
+ }
1374
+ const updated = await S.updateAutomation(autoId, apiKey.workspaceId, fields);
1375
+ if (!updated) {
1376
+ sendJson(req, res, 404, { error: "Automation not found" });
1377
+ return;
1378
+ }
1379
+ sendJson(req, res, 200, updated);
1380
+ return;
1381
+ }
1382
+ // DELETE /v1/automations/:id
1383
+ if (autoMatch && method === "DELETE") {
1384
+ const deleted = await S.deleteAutomation(autoMatch[1], apiKey.workspaceId);
1385
+ if (!deleted) {
1386
+ sendJson(req, res, 404, { error: "Automation not found" });
1387
+ return;
1388
+ }
1389
+ sendJson(req, res, 200, { id: autoMatch[1], deleted: true });
1390
+ return;
1391
+ }
1392
+ // GET /v1/automations/drafts — list drafts
1393
+ if (method === "GET" && (url === "/v1/automations/drafts" || url?.startsWith("/v1/automations/drafts?"))) {
1394
+ const params = new URLSearchParams(url?.split("?")[1] || "");
1395
+ const drafts = await S.listDrafts(apiKey.workspaceId, {
1396
+ status: params.get("status") || undefined,
1397
+ automationId: params.get("automation_id") || undefined,
1398
+ limit: params.has("limit") ? parseInt(params.get("limit")) : undefined,
1399
+ });
1400
+ sendJson(req, res, 200, drafts);
1401
+ return;
1402
+ }
1403
+ // PATCH /v1/automations/drafts/:id — approve/edit/skip a draft
1404
+ const draftMatch = url?.match(/^\/v1\/automations\/drafts\/([^/]+)$/);
1405
+ if (draftMatch && method === "PATCH") {
1406
+ const body = await parseBody(req);
1407
+ const fields = {};
1408
+ if (body.status !== undefined)
1409
+ fields.status = body.status;
1410
+ if (body.edited_text !== undefined)
1411
+ fields.editedText = body.edited_text;
1412
+ const updated = await S.updateDraft(draftMatch[1], apiKey.workspaceId, fields);
1413
+ if (!updated) {
1414
+ sendJson(req, res, 404, { error: "Draft not found" });
1415
+ return;
1416
+ }
1417
+ sendJson(req, res, 200, updated);
1418
+ return;
1419
+ }
1420
+ // POST /v1/automations/drafts/:id/post — trigger post task for an approved draft
1421
+ const draftPostMatch = url?.match(/^\/v1\/automations\/drafts\/([^/]+)\/post$/);
1422
+ if (draftPostMatch && method === "POST") {
1423
+ const draft = await S.getDraft(draftPostMatch[1]);
1424
+ if (!draft || draft.workspaceId !== apiKey.workspaceId) {
1425
+ sendJson(req, res, 404, { error: "Draft not found" });
1426
+ return;
1427
+ }
1428
+ if (draft.status !== "approved" && draft.status !== "edited") {
1429
+ sendJson(req, res, 400, { error: `Draft must be approved or edited to post (current: ${draft.status})` });
1430
+ return;
1431
+ }
1432
+ const auto = await S.getAutomation(draft.automationId);
1433
+ if (!auto?.browserSessionId) {
1434
+ sendJson(req, res, 409, { error: "No browser session configured on automation" });
1435
+ return;
1436
+ }
1437
+ const connected = isSessionConnectedFn ? isSessionConnectedFn(auto.browserSessionId) : false;
1438
+ if (!connected) {
1439
+ sendJson(req, res, 409, { error: "Browser session is not connected" });
1440
+ return;
1441
+ }
1442
+ const replyText = draft.editedText || draft.replyText;
1443
+ const { buildPostPrompt } = await import("./scheduler.js");
1444
+ const postPrompt = buildPostPrompt(draft.tweetUrl, replyText);
1445
+ // Run post task in background
1446
+ runInternalTask({
1447
+ workspaceId: apiKey.workspaceId,
1448
+ browserSessionId: auto.browserSessionId,
1449
+ task: postPrompt,
1450
+ url: draft.tweetUrl,
1451
+ }).then(async (result) => {
1452
+ if (result.status === "complete") {
1453
+ await S.updateDraft(draft.id, apiKey.workspaceId, {
1454
+ status: "posted",
1455
+ postTaskId: result.taskId,
1456
+ postedAt: new Date(),
1457
+ });
1458
+ await S.logEngagement({
1459
+ workspaceId: apiKey.workspaceId,
1460
+ automationId: draft.automationId,
1461
+ draftId: draft.id,
1462
+ authorHandle: draft.tweetAuthorHandle || "unknown",
1463
+ replyType: draft.replyType,
1464
+ tweetUrl: draft.tweetUrl,
1465
+ tweetSummary: draft.tweetText?.slice(0, 200),
1466
+ replySummary: replyText.slice(0, 200),
1467
+ });
1468
+ }
1469
+ else {
1470
+ await S.updateDraft(draft.id, apiKey.workspaceId, {
1471
+ status: "failed",
1472
+ postTaskId: result.taskId,
1473
+ });
1474
+ }
1475
+ }).catch(() => { });
1476
+ sendJson(req, res, 202, { draft_id: draft.id, status: "posting", task_id: "pending" });
1477
+ return;
1478
+ }
1479
+ // POST /v1/automations/drafts/batch-approve — approve multiple drafts
1480
+ if (method === "POST" && url === "/v1/automations/drafts/batch-approve") {
1481
+ const body = await parseBody(req);
1482
+ const { draft_ids } = body;
1483
+ if (!Array.isArray(draft_ids) || draft_ids.length === 0) {
1484
+ sendJson(req, res, 400, { error: "draft_ids array is required" });
1485
+ return;
1486
+ }
1487
+ const results = [];
1488
+ for (const draftId of draft_ids) {
1489
+ const updated = await S.updateDraft(draftId, apiKey.workspaceId, { status: "approved" });
1490
+ results.push({ id: draftId, status: updated ? "approved" : "not_found" });
1491
+ }
1492
+ sendJson(req, res, 200, { results });
1493
+ return;
1494
+ }
1495
+ // GET /v1/automations/engagements — list engagement history
1496
+ if (method === "GET" && (url === "/v1/automations/engagements" || url?.startsWith("/v1/automations/engagements?"))) {
1497
+ const params = new URLSearchParams(url?.split("?")[1] || "");
1498
+ const limit = params.has("limit") ? parseInt(params.get("limit")) : 50;
1499
+ const engagements = await S.listEngagements(apiKey.workspaceId, limit);
1500
+ sendJson(req, res, 200, engagements);
1501
+ return;
1502
+ }
1286
1503
  sendJson(req, res, 404, { error: "Not found" });
1287
1504
  }
1288
1505
  catch (err) {
@@ -1290,6 +1507,61 @@ async function handleRequest(req, res) {
1290
1507
  sendJson(req, res, 500, { error: err.message, request_id: requestId });
1291
1508
  }
1292
1509
  }
1510
+ /**
1511
+ * Run a task internally (used by scheduler — no HTTP, no auth, no billing).
1512
+ * Returns a promise that resolves when the task completes.
1513
+ */
1514
+ export async function runInternalTask(params) {
1515
+ const { workspaceId, browserSessionId, task, url } = params;
1516
+ const taskRun = await S.createTaskRun({
1517
+ workspaceId,
1518
+ apiKeyId: "scheduler",
1519
+ task,
1520
+ url,
1521
+ browserSessionId,
1522
+ });
1523
+ const abort = new AbortController();
1524
+ taskAborts.set(taskRun.id, abort);
1525
+ taskWorkspaceMap.set(taskRun.id, { workspaceId, startedAt: Date.now() });
1526
+ const taskTimeout = setTimeout(() => {
1527
+ abort.abort();
1528
+ }, TASK_TIMEOUT_MS);
1529
+ let currentStep = 0;
1530
+ try {
1531
+ const result = await runAgentLoop({
1532
+ task,
1533
+ url,
1534
+ executeTool: async (toolName, toolInput) => {
1535
+ return executeToolViaRelay(toolName, toolInput, browserSessionId);
1536
+ },
1537
+ onStep: (step) => {
1538
+ currentStep = step.step;
1539
+ void S.updateTaskRun(taskRun.id, { steps: step.step });
1540
+ },
1541
+ maxSteps: 50,
1542
+ signal: abort.signal,
1543
+ });
1544
+ const status = result.status === "complete" ? "complete" : "error";
1545
+ await S.updateTaskRun(taskRun.id, {
1546
+ status: status,
1547
+ answer: result.answer || undefined,
1548
+ steps: result.usage.apiCalls,
1549
+ });
1550
+ return { taskId: taskRun.id, answer: result.answer, status };
1551
+ }
1552
+ catch (err) {
1553
+ try {
1554
+ await S.updateTaskRun(taskRun.id, { status: "error", answer: err.message });
1555
+ }
1556
+ catch { }
1557
+ return { taskId: taskRun.id, status: "error" };
1558
+ }
1559
+ finally {
1560
+ clearTimeout(taskTimeout);
1561
+ taskAborts.delete(taskRun.id);
1562
+ taskWorkspaceMap.delete(taskRun.id);
1563
+ }
1564
+ }
1293
1565
  export function startManagedAPI(port = 3456) {
1294
1566
  const host = process.env.NODE_ENV === "production" ? "127.0.0.1" : "0.0.0.0";
1295
1567
  const server = createServer(handleRequest);
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import Stripe from "stripe";
26
26
  import { log } from "./log.js";
27
+ import { trackManagedEvent } from "./telemetry.js";
27
28
  let stripe = null;
28
29
  let S = null;
29
30
  /** Set the backing store so billing can persist webhook results. */
@@ -182,6 +183,7 @@ export async function handleWebhook(rawBody, signature) {
182
183
  if (credits > 0) {
183
184
  const newBalance = await S.addCredits(workspaceId, credits);
184
185
  log.info("Credits purchased", { workspaceId }, { credits, newBalance });
186
+ trackManagedEvent("credits_purchased", workspaceId, { credits });
185
187
  }
186
188
  }
187
189
  catch (err) {
@@ -16,9 +16,12 @@
16
16
  import { WebSocketServer, WebSocket } from "ws";
17
17
  import { randomUUID } from "crypto";
18
18
  import { initVertex } from "../llm/vertex.js";
19
- import { startManagedAPI, initManagedAPI, handleRelayMessage, setStoreModule, onSessionDisconnected, shutdownManagedAPI, recoverStuckTasks } from "./api.js";
19
+ import { startManagedAPI, initManagedAPI, handleRelayMessage, setStoreModule, onSessionDisconnected, shutdownManagedAPI, recoverStuckTasks, runInternalTask } from "./api.js";
20
+ import { initScheduler, startScheduler, stopScheduler } from "./scheduler.js";
21
+ import { notifyDraftsReady } from "./notify.js";
20
22
  import { initBilling, setBillingStore } from "./billing.js";
21
23
  import { WebSocketClient } from "../ipc/websocket-client.js";
24
+ import { initManagedTelemetry, shutdownManagedTelemetry } from "./telemetry.js";
22
25
  // Dynamic store import — Postgres when DATABASE_URL is set, file-based otherwise
23
26
  const DATABASE_URL = process.env.DATABASE_URL;
24
27
  let store;
@@ -312,6 +315,7 @@ function setupRelayHandlers(wss) {
312
315
  }
313
316
  // --- Main ---
314
317
  async function main() {
318
+ initManagedTelemetry();
315
319
  // 1. Init Vertex AI (optional — managed task execution disabled without it)
316
320
  const saJson = process.env.VERTEX_SA_JSON;
317
321
  const saPath = process.env.VERTEX_SA_PATH;
@@ -370,6 +374,17 @@ async function main() {
370
374
  store.startHeartbeatFlush();
371
375
  // 7. Recover tasks stuck in "running" from a previous process
372
376
  await recoverStuckTasks();
377
+ // 8. Start scheduler for automated tasks
378
+ if (DATABASE_URL) {
379
+ const pgStore = await import("./store-pg.js");
380
+ initScheduler({
381
+ store: pgStore,
382
+ runTask: runInternalTask,
383
+ isSessionConnected: isSessionConnected,
384
+ notify: notifyDraftsReady,
385
+ });
386
+ startScheduler();
387
+ }
373
388
  console.error(`
374
389
  ╔════════════════════════════════════════════════╗
375
390
  ║ Hanzi Managed Backend (deployed) ║
@@ -392,7 +407,9 @@ main().catch((err) => {
392
407
  async function handleShutdown(signal) {
393
408
  console.error(`\n[Server] Received ${signal} — shutting down gracefully...`);
394
409
  try {
410
+ stopScheduler();
395
411
  await shutdownManagedAPI();
412
+ await shutdownManagedTelemetry();
396
413
  }
397
414
  catch (err) {
398
415
  console.error(`[Server] Shutdown error:`, err.message);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Email notifications via Resend.
3
+ * Free tier: 100 emails/day — more than enough for automation drafts.
4
+ *
5
+ * Set RESEND_API_KEY env var to enable. Without it, notifications are no-ops.
6
+ */
7
+ export declare function notifyDraftsReady(email: string, count: number): Promise<void>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Email notifications via Resend.
3
+ * Free tier: 100 emails/day — more than enough for automation drafts.
4
+ *
5
+ * Set RESEND_API_KEY env var to enable. Without it, notifications are no-ops.
6
+ */
7
+ const RESEND_API_KEY = process.env.RESEND_API_KEY;
8
+ const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://api.hanzilla.co/dashboard";
9
+ export async function notifyDraftsReady(email, count) {
10
+ if (!RESEND_API_KEY) {
11
+ console.error("[Notify] RESEND_API_KEY not set — skipping email notification");
12
+ return;
13
+ }
14
+ try {
15
+ const res = await fetch("https://api.resend.com/emails", {
16
+ method: "POST",
17
+ headers: {
18
+ "Authorization": `Bearer ${RESEND_API_KEY}`,
19
+ "Content-Type": "application/json",
20
+ },
21
+ body: JSON.stringify({
22
+ from: "Hanzi <notifications@hanzilla.co>",
23
+ to: email,
24
+ subject: `${count} X reply draft${count === 1 ? "" : "s"} ready for review`,
25
+ text: `Your X marketing scout found ${count} opportunity${count === 1 ? "" : "ies"}.\n\nReview and approve them:\n${DASHBOARD_URL}?tab=automations\n\nDrafts are waiting for your approval — nothing gets posted until you say so.`,
26
+ }),
27
+ });
28
+ if (!res.ok) {
29
+ const body = await res.text();
30
+ console.error(`[Notify] Resend error ${res.status}: ${body}`);
31
+ }
32
+ }
33
+ catch (err) {
34
+ console.error(`[Notify] Failed to send email:`, err.message);
35
+ }
36
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Scheduler for automated browser tasks
3
+ *
4
+ * Checks every 60 seconds for automations whose next_run_at has passed.
5
+ * Runs scout tasks via the existing agent loop infrastructure.
6
+ */
7
+ declare let runTaskFn: (params: {
8
+ workspaceId: string;
9
+ browserSessionId: string;
10
+ task: string;
11
+ url?: string;
12
+ }) => Promise<{
13
+ taskId: string;
14
+ answer?: string;
15
+ status: string;
16
+ }>;
17
+ export declare function initScheduler(deps: {
18
+ store: typeof import("./store-pg.js");
19
+ runTask: typeof runTaskFn;
20
+ isSessionConnected: (id: string) => boolean;
21
+ notify?: (email: string, count: number) => Promise<void>;
22
+ }): void;
23
+ export declare function startScheduler(): void;
24
+ export declare function stopScheduler(): void;
25
+ interface ParsedDraft {
26
+ tweetUrl: string;
27
+ tweetText?: string;
28
+ tweetAuthorHandle?: string;
29
+ tweetAuthorName?: string;
30
+ tweetAuthorBio?: string;
31
+ tweetAuthorFollowers?: number;
32
+ tweetEngagement?: Record<string, any>;
33
+ tweetAgeHours?: number;
34
+ replyText: string;
35
+ replyType?: "A" | "B" | "C";
36
+ replyReasoning?: string;
37
+ score?: number;
38
+ }
39
+ export declare function parseScoutAnswer(answer: string): ParsedDraft[] | null;
40
+ export declare function computeNextRun(cronExpr: string, timezone?: string): Date | null;
41
+ export declare function buildPostPrompt(tweetUrl: string, replyText: string): string;
42
+ export {};