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.
- package/dist/cli/setup.js +10 -1
- package/dist/dashboard/assets/index-wVMUNuBA.js +61 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/index.js +40 -0
- package/dist/managed/api.d.ts +14 -0
- package/dist/managed/api.js +272 -0
- package/dist/managed/billing.js +2 -0
- package/dist/managed/deploy.js +18 -1
- package/dist/managed/notify.d.ts +7 -0
- package/dist/managed/notify.js +36 -0
- package/dist/managed/scheduler.d.ts +42 -0
- package/dist/managed/scheduler.js +282 -0
- package/dist/managed/store-pg.d.ts +116 -0
- package/dist/managed/store-pg.js +215 -0
- package/dist/managed/store.d.ts +13 -0
- package/dist/managed/store.js +15 -0
- package/dist/managed/telemetry.d.ts +8 -0
- package/dist/managed/telemetry.js +61 -0
- package/dist/telemetry.d.ts +14 -0
- package/dist/telemetry.js +141 -0
- package/package.json +4 -1
- package/dist/dashboard/assets/index-B6M8kZZo.js +0 -46
|
@@ -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-
|
|
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
|
});
|
package/dist/managed/api.d.ts
CHANGED
|
@@ -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.
|
package/dist/managed/api.js
CHANGED
|
@@ -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);
|
package/dist/managed/billing.js
CHANGED
|
@@ -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) {
|
package/dist/managed/deploy.js
CHANGED
|
@@ -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 {};
|