feat: polish mindspace decoupling flows
This commit is contained in:
@@ -41,9 +41,14 @@ Streaming runtime operations:
|
||||
node scripts/runtime-worker-metrics.mjs status
|
||||
node scripts/runtime-worker-metrics.mjs sample
|
||||
bash scripts/install-runtime-metrics-agent.sh
|
||||
node scripts/runtime-worker-heartbeat.mjs once
|
||||
node scripts/runtime-worker-heartbeat.mjs serve
|
||||
bash scripts/install-runtime-heartbeat-agent.sh
|
||||
node scripts/runtime-slo-report.mjs
|
||||
node scripts/runtime-slo-report.mjs --write-report
|
||||
node scripts/runtime-slo-report.mjs --write-report --prune --retention-days 30
|
||||
bash scripts/install-runtime-slo-report-agent.sh
|
||||
bash scripts/install-runtime-slo-soak-agent.sh
|
||||
node scripts/runtime-worker-drain.mjs drain goosed-3
|
||||
node scripts/runtime-worker-drain.mjs undrain goosed-3
|
||||
node scripts/check-tool-runtime.mjs
|
||||
@@ -51,3 +56,8 @@ Streaming runtime operations:
|
||||
curl -sk https://mm.tkmind.cn/api/runtime/status # includes toolRuntime.queue
|
||||
node scripts/agent-run-worker.mjs --status
|
||||
node scripts/agent-run-worker.mjs --once
|
||||
bash scripts/install-agent-run-worker-agent.sh # installs disabled by default
|
||||
node scripts/check-agent-run-worker.mjs # read-only LaunchAgent/queue check
|
||||
node scripts/agent-run-guard.mjs # dry-run auto-pause guard check
|
||||
node scripts/agent-run-guard.mjs --apply # stop worker and disable code-run gate when thresholds trip
|
||||
bash scripts/install-agent-run-guard-agent.sh # installs guard LaunchAgent
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_REFERENCE = /^(?:https?:|mailto:|javascript:|data:|tel:|#|\/\/)/i;
|
||||
const DOWNLOAD_EXTENSIONS = /\.(?:docx|doc|pdf|xlsx|xls|pptx|ppt|zip|rar|7z|csv)$/i;
|
||||
|
||||
export function isFilesystemPublicReference(raw) {
|
||||
const value = String(raw ?? '').trim();
|
||||
if (!value) return false;
|
||||
if (SKIP_REFERENCE.test(value)) return false;
|
||||
// /api/... and /MindSpace/... are server routes, not on-disk siblings of public HTML.
|
||||
if (value.startsWith('/')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated use isFilesystemPublicReference */
|
||||
export function isRelativePublicReference(raw) {
|
||||
return isFilesystemPublicReference(raw);
|
||||
}
|
||||
|
||||
export function isDownloadLikeReference(raw, source = '') {
|
||||
if (!isFilesystemPublicReference(raw)) return false;
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (DOWNLOAD_EXTENSIONS.test(ref)) return true;
|
||||
return /\bdownload\b/i.test(String(source ?? ''));
|
||||
}
|
||||
|
||||
export function normalizeReferencePath(raw) {
|
||||
const trimmed = String(raw ?? '')
|
||||
.trim()
|
||||
.split('#')[0]
|
||||
.split('?')[0]
|
||||
.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
return decodeURIComponent(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReferencePath(htmlDir, raw) {
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (!ref) return null;
|
||||
return path.resolve(htmlDir, ref);
|
||||
}
|
||||
|
||||
function parseMindspaceCoverPaths(contentAttr) {
|
||||
const paths = [];
|
||||
if (!contentAttr) return paths;
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(contentAttr);
|
||||
} catch {
|
||||
for (const key of ['cover', 'image']) {
|
||||
const match = contentAttr.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`));
|
||||
if (match?.[1]) paths.push(match[1]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
for (const key of ['cover', 'image']) {
|
||||
if (parsed?.[key]) paths.push(String(parsed[key]));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function collectPublicHtmlReferences(html, htmlDir, { downloadsOnly = false } = {}) {
|
||||
const refs = [];
|
||||
const seen = new Set();
|
||||
|
||||
const add = (raw, source) => {
|
||||
if (downloadsOnly && !isDownloadLikeReference(raw, source)) return;
|
||||
if (!downloadsOnly && !isFilesystemPublicReference(raw)) return;
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (!ref) return;
|
||||
const key = `${source}\0${ref}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
refs.push({
|
||||
ref,
|
||||
source,
|
||||
resolvedPath: resolveReferencePath(htmlDir, ref),
|
||||
});
|
||||
};
|
||||
|
||||
for (const match of String(html ?? '').matchAll(/\b(?:href|src)\s*=\s*["']([^"']+)["']/gi)) {
|
||||
add(match[1], match[0]);
|
||||
}
|
||||
|
||||
if (!downloadsOnly) {
|
||||
for (const match of String(html ?? '').matchAll(
|
||||
/<meta\b[^>]*\bname\s*=\s*["']mindspace-cover["'][^>]*>/gi,
|
||||
)) {
|
||||
const tag = match[0];
|
||||
const contentMatch =
|
||||
tag.match(/\bcontent\s*=\s*"([^"]*)"/i) ?? tag.match(/\bcontent\s*=\s*'([^']*)'/i);
|
||||
for (const coverPath of parseMindspaceCoverPaths(contentMatch?.[1])) {
|
||||
add(coverPath, 'mindspace-cover');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function findMissingPublicHtmlReferences(htmlPath, html = null, options = {}) {
|
||||
const resolvedHtmlPath = path.resolve(htmlPath);
|
||||
const content = html ?? fs.readFileSync(resolvedHtmlPath, 'utf8');
|
||||
const htmlDir = path.dirname(resolvedHtmlPath);
|
||||
const missing = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const item of collectPublicHtmlReferences(content, htmlDir, options)) {
|
||||
const key = item.ref;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (!item.resolvedPath || !fs.existsSync(item.resolvedPath)) {
|
||||
missing.push({ htmlPath: resolvedHtmlPath, ...item });
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function listPublicHtmlFiles(publishRoot, { userId = null } = {}) {
|
||||
const root = path.resolve(publishRoot);
|
||||
if (!fs.existsSync(root)) return [];
|
||||
|
||||
const userDirs = userId
|
||||
? [path.join(root, userId)]
|
||||
: fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(root, d.name));
|
||||
|
||||
const files = [];
|
||||
for (const userDir of userDirs) {
|
||||
const publicDir = path.join(userDir, 'public');
|
||||
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue;
|
||||
for (const name of fs.readdirSync(publicDir)) {
|
||||
if (!name.toLowerCase().endsWith('.html')) continue;
|
||||
files.push(path.join(publicDir, name));
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export function scanPublicHtmlLinks(publishRoot, options = {}) {
|
||||
const issues = [];
|
||||
for (const htmlPath of listPublicHtmlFiles(publishRoot, options)) {
|
||||
issues.push(...findMissingPublicHtmlReferences(htmlPath, null, options));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
|
||||
|
||||
// mindspace-sandbox-mcp.mjs
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import path2 from "node:path";
|
||||
import fs2 from "node:fs";
|
||||
import readline from "node:readline";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import mysql from "mysql2/promise";
|
||||
@@ -1132,15 +1132,102 @@ function createScheduleService(pool, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// mindspace-long-image.mjs
|
||||
import fs from "node:fs";
|
||||
import fsPromises from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
var DEFAULT_VIEWPORT_WIDTH = 1280;
|
||||
var DEFAULT_VIEWPORT_HEIGHT = 720;
|
||||
var MAX_LONG_IMAGE_HEIGHT = 2e4;
|
||||
function longImagePathForHtml(htmlPath, outputPath = null) {
|
||||
return outputPath || String(htmlPath).replace(/\.html$/i, ".long.png");
|
||||
}
|
||||
function clampDimension(value, fallback, max) {
|
||||
const number = Math.ceil(Number(value) || fallback);
|
||||
return Math.max(320, Math.min(number, max));
|
||||
}
|
||||
async function launchChromium(chromium) {
|
||||
const base = {
|
||||
headless: true,
|
||||
args: ["--disable-dev-shm-usage", "--hide-scrollbars"]
|
||||
};
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
|
||||
return chromium.launch({
|
||||
...base,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
});
|
||||
}
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_CHANNEL) {
|
||||
return chromium.launch({ ...base, channel: process.env.PLAYWRIGHT_CHROMIUM_CHANNEL });
|
||||
}
|
||||
try {
|
||||
return await chromium.launch(base);
|
||||
} catch (error) {
|
||||
if (process.platform === "darwin") {
|
||||
return chromium.launch({ ...base, channel: "chrome" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function renderLongImage({
|
||||
htmlPath = null,
|
||||
url = null,
|
||||
outputPath = null,
|
||||
viewportWidth = DEFAULT_VIEWPORT_WIDTH,
|
||||
viewportHeight = DEFAULT_VIEWPORT_HEIGHT
|
||||
} = {}) {
|
||||
if (!htmlPath && !url) throw new Error("\u7F3A\u5C11 htmlPath \u6216 url");
|
||||
const targetUrl = url || pathToFileURL(path.resolve(htmlPath)).toString();
|
||||
const destination = outputPath ? path.resolve(outputPath) : longImagePathForHtml(path.resolve(htmlPath));
|
||||
const { chromium } = await import("playwright");
|
||||
let browser = null;
|
||||
try {
|
||||
browser = await launchChromium(chromium);
|
||||
const page = await browser.newPage({
|
||||
viewport: {
|
||||
width: clampDimension(viewportWidth, DEFAULT_VIEWPORT_WIDTH, 2400),
|
||||
height: clampDimension(viewportHeight, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT)
|
||||
},
|
||||
deviceScaleFactor: 2
|
||||
});
|
||||
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
|
||||
await page.evaluate(() => document.fonts?.ready).catch(() => null);
|
||||
const size = await page.evaluate(() => ({
|
||||
width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 320),
|
||||
height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 320)
|
||||
}));
|
||||
await page.setViewportSize({
|
||||
width: clampDimension(size.width, DEFAULT_VIEWPORT_WIDTH, 2400),
|
||||
height: Math.min(clampDimension(size.height, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT), 2400)
|
||||
});
|
||||
await fsPromises.mkdir(path.dirname(destination), { recursive: true });
|
||||
await page.screenshot({
|
||||
path: destination,
|
||||
fullPage: true,
|
||||
type: "png",
|
||||
animations: "disabled",
|
||||
caret: "hide"
|
||||
});
|
||||
return {
|
||||
outputPath: destination,
|
||||
bytes: fs.statSync(destination).size
|
||||
};
|
||||
} finally {
|
||||
await browser?.close().catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// mindspace-sandbox-mcp.mjs
|
||||
var SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
|
||||
if (!SANDBOX_ROOT) {
|
||||
process.stderr.write("[mindspace-sandbox-mcp] SANDBOX_ROOT is not set \u2014 refusing to start\n");
|
||||
process.exit(1);
|
||||
}
|
||||
var SANDBOX = path.resolve(SANDBOX_ROOT);
|
||||
var PRIVATE_DATA_DIR = path.join(SANDBOX, ".mindspace");
|
||||
var PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, "private-data.sqlite");
|
||||
var SANDBOX = path2.resolve(SANDBOX_ROOT);
|
||||
var PRIVATE_DATA_DIR = path2.join(SANDBOX, ".mindspace");
|
||||
var PRIVATE_DATA_DB = path2.join(PRIVATE_DATA_DIR, "private-data.sqlite");
|
||||
var SQLITE_BIN = process.env.SQLITE_BIN?.trim() || "sqlite3";
|
||||
var PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024);
|
||||
var PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5e3);
|
||||
@@ -1150,11 +1237,11 @@ var allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim();
|
||||
var ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(",").map((s) => s.trim())) : null;
|
||||
function resolveSandboxed(p) {
|
||||
if (!p || typeof p !== "string") throw new Error("\u8DEF\u5F84\u53C2\u6570\u65E0\u6548");
|
||||
const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(SANDBOX, p);
|
||||
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
|
||||
const resolved = path2.isAbsolute(p) ? path2.resolve(p) : path2.resolve(SANDBOX, p);
|
||||
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path2.sep)) {
|
||||
throw Object.assign(new Error(`\u8DEF\u5F84\u8D8A\u754C\uFF1A${p} \u4E0D\u5728\u5F53\u524D\u5DE5\u4F5C\u533A\u5185`), { code: "EACCES" });
|
||||
}
|
||||
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) {
|
||||
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path2.sep)) {
|
||||
throw Object.assign(new Error("\u7981\u6B62\u76F4\u63A5\u8BBF\u95EE\u79C1\u6709\u6570\u636E\u76EE\u5F55\uFF0C\u8BF7\u4F7F\u7528 private_data_* \u5DE5\u5177"), { code: "EACCES" });
|
||||
}
|
||||
return resolved;
|
||||
@@ -1220,6 +1307,18 @@ var ALL_TOOLS = [
|
||||
required: ["path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "generate_long_image",
|
||||
description: "\u7528 Playwright \u5C06\u5DE5\u4F5C\u533A\u5185\u7684 HTML \u9875\u9762\u6E32\u67D3\u4E3A\u6574\u9875 PNG \u957F\u56FE\u3002\u8F93\u51FA\u6587\u4EF6\u901A\u5E38\u4E3A public/<\u9875\u9762\u540D>.long.png\uFF0C\u5FC5\u987B\u5728\u5DE5\u4F5C\u533A\u5185\u3002",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
html_path: { type: "string", description: "HTML \u6587\u4EF6\u8DEF\u5F84\uFF0C\u5982 public/report.html" },
|
||||
output_path: { type: "string", description: "\u8F93\u51FA PNG \u8DEF\u5F84\uFF0C\u5982 public/report.long.png\uFF1B\u53EF\u9009" }
|
||||
},
|
||||
required: ["html_path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "private_data_info",
|
||||
description: "\u67E5\u770B\u5F53\u524D\u7528\u6237\u201C\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u201D\u7684\u72B6\u6001\u3002\u6BCF\u4E2A\u7528\u6237\u53EA\u6709\u4E00\u4E2A\u79C1\u6709 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u4FDD\u5B58\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u521B\u5EFA\u989D\u5916 SQLite \u6587\u4EF6\u3002",
|
||||
@@ -1348,17 +1447,17 @@ if (isScheduleConfigured()) {
|
||||
}
|
||||
var TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;
|
||||
function ensurePrivateDataDb() {
|
||||
fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
|
||||
if (!fs.existsSync(PRIVATE_DATA_DB)) {
|
||||
fs2.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
|
||||
if (!fs2.existsSync(PRIVATE_DATA_DB)) {
|
||||
runSqlite(["-batch", PRIVATE_DATA_DB, "PRAGMA journal_mode=WAL; PRAGMA user_version = 1;"]);
|
||||
}
|
||||
return PRIVATE_DATA_DB;
|
||||
}
|
||||
function privateDataSize() {
|
||||
let total = 0;
|
||||
for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) {
|
||||
for (const file of fs2.existsSync(PRIVATE_DATA_DIR) ? fs2.readdirSync(PRIVATE_DATA_DIR) : []) {
|
||||
if (file === "private-data.sqlite" || file.startsWith("private-data.sqlite-")) {
|
||||
total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size;
|
||||
total += fs2.statSync(path2.join(PRIVATE_DATA_DIR, file)).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
@@ -1505,38 +1604,57 @@ async function callTool(name, args) {
|
||||
switch (name) {
|
||||
case "read_file": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
const content = fs.readFileSync(abs, "utf8");
|
||||
const content = fs2.readFileSync(abs, "utf8");
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
case "write_file": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, args.content ?? "", "utf8");
|
||||
fs2.mkdirSync(path2.dirname(abs), { recursive: true });
|
||||
fs2.writeFileSync(abs, args.content ?? "", "utf8");
|
||||
return [{ type: "text", text: `\u5DF2\u5199\u5165 ${args.path}\uFF08${(args.content ?? "").length} \u5B57\u8282\uFF09` }];
|
||||
}
|
||||
case "edit_file": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
const original = fs.readFileSync(abs, "utf8");
|
||||
const original = fs2.readFileSync(abs, "utf8");
|
||||
const oldStr = args.old_str ?? "";
|
||||
if (oldStr && !original.includes(oldStr)) {
|
||||
throw new Error("edit_file: old_str \u5728\u6587\u4EF6\u4E2D\u4E0D\u5B58\u5728\uFF0C\u66FF\u6362\u5931\u8D25");
|
||||
}
|
||||
const updated = oldStr ? original.replace(oldStr, args.new_str ?? "") : args.new_str ?? "";
|
||||
fs.writeFileSync(abs, updated, "utf8");
|
||||
fs2.writeFileSync(abs, updated, "utf8");
|
||||
return [{ type: "text", text: `\u5DF2\u7F16\u8F91 ${args.path}` }];
|
||||
}
|
||||
case "list_dir": {
|
||||
const target = args?.path ?? ".";
|
||||
const abs = resolveSandboxed(target);
|
||||
const entries = fs.readdirSync(abs, { withFileTypes: true });
|
||||
const entries = fs2.readdirSync(abs, { withFileTypes: true });
|
||||
const lines = entries.map((e) => `${e.isDirectory() ? "[\u76EE\u5F55]" : "[\u6587\u4EF6]"} ${e.name}`);
|
||||
return [{ type: "text", text: lines.join("\n") || "\uFF08\u7A7A\u76EE\u5F55\uFF09" }];
|
||||
}
|
||||
case "create_dir": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
fs.mkdirSync(abs, { recursive: true });
|
||||
fs2.mkdirSync(abs, { recursive: true });
|
||||
return [{ type: "text", text: `\u5DF2\u521B\u5EFA\u76EE\u5F55 ${args.path}` }];
|
||||
}
|
||||
case "generate_long_image": {
|
||||
const htmlPath = String(args.html_path ?? args.path ?? "").trim();
|
||||
if (!htmlPath.toLowerCase().endsWith(".html")) {
|
||||
throw new Error("generate_long_image: html_path \u5FC5\u987B\u662F .html \u6587\u4EF6");
|
||||
}
|
||||
const htmlAbs = resolveSandboxed(htmlPath);
|
||||
const outputPath = String(args.output_path ?? "").trim() || htmlPath.replace(/\.html$/i, ".long.png");
|
||||
if (!outputPath.toLowerCase().endsWith(".png")) {
|
||||
throw new Error("generate_long_image: output_path \u5FC5\u987B\u662F .png \u6587\u4EF6");
|
||||
}
|
||||
const outputAbs = resolveSandboxed(outputPath);
|
||||
const result = await renderLongImage({ htmlPath: htmlAbs, outputPath: outputAbs });
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: `\u5DF2\u751F\u6210\u957F\u56FE ${outputPath}\uFF08${result.bytes} \u5B57\u8282\uFF09`
|
||||
}
|
||||
];
|
||||
}
|
||||
case "private_data_info": {
|
||||
ensurePrivateDataDb();
|
||||
const size = privateDataSize();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"playwright": "^1.61.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^4.7.1",
|
||||
"sharp": "^0.35.2",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>微信分享预览 · 连云港三天两夜攻略 🌊</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; padding: 24px; background: #ececec; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; color: #111; }
|
||||
.wrap { max-width: 420px; margin: 0 auto; }
|
||||
h1 { margin: 0 0 8px; font-size: 18px; }
|
||||
.hint, .note { margin: 0 0 16px; color: #666; font-size: 13px; line-height: 1.6; }
|
||||
.card { display: grid; grid-template-columns: 1fr 72px; gap: 12px; padding: 12px; border-radius: 8px; background: #fff; box-shadow: 0 1px 0 rgba(0,0,0,.06); }
|
||||
.card-main { min-width: 0; }
|
||||
.card-title { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-desc { margin: 6px 0 0; color: #888; font-size: 13px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; color: #999; font-size: 12px; }
|
||||
.card-foot img { width: 16px; height: 16px; border-radius: 999px; object-fit: cover; }
|
||||
.card-thumb { width: 72px; height: 72px; border-radius: 4px; object-fit: cover; background: #f3f3f3; }
|
||||
.card-thumb-empty { display: grid; place-items: center; color: #bbb; font-size: 12px; }
|
||||
.meta { margin-top: 18px; padding: 14px; border-radius: 10px; background: rgba(255,255,255,.72); font-size: 12px; line-height: 1.7; word-break: break-all; }
|
||||
code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>微信链接卡片预览</h1>
|
||||
<p class="hint">本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。源链接:<code>https://m.tkmind.cn/MindSpace/john/public/lianyungang-guide.html</code></p>
|
||||
<p class="note">修复后:标题 + 描述 + 缩略图 + 底部「TKMind 智趣」小图标应同时出现。</p>
|
||||
<article class="card" aria-label="微信分享卡片预览">
|
||||
<div class="card-main">
|
||||
<h2 class="card-title">连云港三天两夜攻略 🌊</h2>
|
||||
<p class="card-desc">三天两夜·沙滩酒店·必吃美食</p>
|
||||
<div class="card-foot">
|
||||
<img src="http://127.0.0.1:8081/brand/tkmind-icon.png" alt="" onerror="this.style.display='none'">
|
||||
<span>TKMind 智趣</span>
|
||||
</div>
|
||||
</div>
|
||||
<img class="card-thumb" src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=200&q=80" alt="">
|
||||
</article>
|
||||
<div class="meta">
|
||||
<div><strong>og:title</strong> 连云港三天两夜攻略 🌊</div>
|
||||
<div><strong>og:description</strong> 三天两夜·沙滩酒店·必吃美食</div>
|
||||
<div><strong>og:site_name</strong> TKMind 智趣</div>
|
||||
<div><strong>og:image</strong> https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=200&q=80</div>
|
||||
<div><strong>icon</strong> /brand/tkmind-icon.png</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>微信分享预览工具</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; padding: 24px; background: #f3f3f3; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; color: #111; }
|
||||
.panel { max-width: 560px; margin: 0 auto; padding: 20px; border-radius: 16px; background: #fff; box-shadow: 0 8px 30px rgba(0,0,0,.06); }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
p { margin: 0 0 16px; color: #666; line-height: 1.6; font-size: 14px; }
|
||||
label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 600; }
|
||||
input { width: 100%; padding: 12px 14px; border: 1px solid #ddd; border-radius: 10px; font: inherit; }
|
||||
button { margin-top: 14px; width: 100%; padding: 12px 14px; border: 0; border-radius: 10px; background: #2f6f57; color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<h1>微信分享预览</h1>
|
||||
<p>输入已发布页面路径,本地模拟微信「粘贴链接后」看到的卡片。开发环境走 Portal API:<code>/dev/wechat-share-preview</code></p>
|
||||
<form id="preview-form">
|
||||
<label for="url">页面 URL 或路径</label>
|
||||
<input id="url" name="url" placeholder="/MindSpace/john/public/demo.html" required>
|
||||
<button type="submit">生成预览</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('preview-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const value = document.getElementById('url').value.trim();
|
||||
if (!value) return;
|
||||
const target = value.startsWith('http')
|
||||
? `/dev/wechat-share-preview?url=${encodeURIComponent(value)}`
|
||||
: `/dev/wechat-share-preview?url=${encodeURIComponent(value.startsWith('/') ? value : `/${value}`)}`;
|
||||
window.location.href = target;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS h5_users (
|
||||
status ENUM('active', 'suspended', 'disabled') NOT NULL DEFAULT 'active',
|
||||
plan_type VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||
workspace_root VARCHAR(512) NOT NULL,
|
||||
low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0,
|
||||
low_balance_gift_granted_at BIGINT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_users_slug (slug),
|
||||
@@ -121,11 +123,14 @@ CREATE TABLE IF NOT EXISTS h5_upload_sessions (
|
||||
temporary_storage_key VARCHAR(512) NOT NULL,
|
||||
checksum CHAR(64) NULL,
|
||||
completed_asset_id CHAR(36) NULL,
|
||||
source_session_id VARCHAR(128) NULL,
|
||||
source_message_id VARCHAR(128) NULL,
|
||||
status ENUM('reserved', 'uploaded', 'completed', 'cancelled', 'expired', 'failed') NOT NULL DEFAULT 'reserved',
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
completed_at BIGINT NULL,
|
||||
KEY idx_h5_upload_user_status (user_id, status, expires_at),
|
||||
KEY idx_h5_upload_source_session (user_id, source_session_id),
|
||||
CONSTRAINT fk_h5_upload_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_upload_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_upload_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE
|
||||
@@ -348,6 +353,7 @@ CREATE TABLE IF NOT EXISTS h5_user_path_grants (
|
||||
CREATE TABLE IF NOT EXISTS h5_user_sessions (
|
||||
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
origin ENUM('h5', 'wechat') NOT NULL DEFAULT 'h5',
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_h5_user_sessions_user (user_id),
|
||||
CONSTRAINT fk_h5_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
|
||||
@@ -383,6 +389,52 @@ CREATE TABLE IF NOT EXISTS h5_agent_run_events (
|
||||
CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_packages (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
session_id VARCHAR(128) NOT NULL,
|
||||
title VARCHAR(255) NULL,
|
||||
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
|
||||
storage_prefix VARCHAR(512) NULL,
|
||||
manifest_asset_id CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_conversation_package_session (user_id, session_id),
|
||||
KEY idx_h5_conversation_package_user_updated (user_id, updated_at),
|
||||
CONSTRAINT fk_h5_conversation_package_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_package_manifest FOREIGN KEY (manifest_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_artifacts (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
package_id VARCHAR(64) NOT NULL,
|
||||
asset_id CHAR(36) NULL,
|
||||
page_id CHAR(36) NULL,
|
||||
publication_id CHAR(36) NULL,
|
||||
agent_run_id CHAR(36) NULL,
|
||||
message_id VARCHAR(128) NULL,
|
||||
role VARCHAR(32) NULL,
|
||||
artifact_kind VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(255) NULL,
|
||||
mime_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT NULL,
|
||||
storage_key VARCHAR(512) NULL,
|
||||
canonical_url VARCHAR(512) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_h5_conversation_artifact_package (package_id, sort_order, created_at),
|
||||
KEY idx_h5_conversation_artifact_asset (asset_id),
|
||||
KEY idx_h5_conversation_artifact_page (page_id),
|
||||
KEY idx_h5_conversation_artifact_publication (publication_id),
|
||||
KEY idx_h5_conversation_artifact_run (agent_run_id),
|
||||
KEY idx_h5_conversation_artifact_message (message_id),
|
||||
CONSTRAINT fk_h5_conversation_artifact_package FOREIGN KEY (package_id) REFERENCES h5_conversation_packages(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_artifact_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_publication FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_run FOREIGN KEY (agent_run_id) REFERENCES h5_agent_runs(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_session_billing_state (
|
||||
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_WORKER_LABEL = 'cn.tkmind.memind-agent-run-worker';
|
||||
const DEFAULT_PORTAL_LABEL = 'cn.tkmind.memind-portal';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
let value = trimmed.slice(idx + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function truthy(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
apply: argv.includes('--apply'),
|
||||
dryRun: argv.includes('--dry-run') || !argv.includes('--apply'),
|
||||
help: argv.includes('--help') || argv.includes('-h'),
|
||||
};
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/agent-run-guard.mjs [--dry-run]',
|
||||
' node scripts/agent-run-guard.mjs --apply',
|
||||
'',
|
||||
'Dry-run is the default. --apply can stop the external worker and disable code-run gate in .env.',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function parseMysqlConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
if (url.protocol !== 'mysql:') {
|
||||
throw new Error(`Unsupported DATABASE_URL scheme for agent run guard: ${url.protocol}`);
|
||||
}
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\/+/, ''),
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
return {
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT || 3306),
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
|
||||
async function runCommand(command, args) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, { maxBuffer: 1024 * 1024 });
|
||||
return { ok: true, stdout, stderr };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: err?.code ?? null,
|
||||
stdout: err?.stdout ?? '',
|
||||
stderr: err?.stderr ?? '',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function replaceOrAppendEnv(raw, updates) {
|
||||
const pending = new Map(Object.entries(updates));
|
||||
const lines = raw.split('\n');
|
||||
const next = lines.map((line) => {
|
||||
const match = line.match(/^(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*=).*/);
|
||||
if (!match) return line;
|
||||
const key = match[2];
|
||||
if (!pending.has(key)) return line;
|
||||
const value = pending.get(key);
|
||||
pending.delete(key);
|
||||
return `${key}=${value}`;
|
||||
});
|
||||
if (pending.size > 0) {
|
||||
if (next.length > 0 && next[next.length - 1] !== '') next.push('');
|
||||
next.push('## agent-run guard auto-disable');
|
||||
for (const [key, value] of pending) next.push(`${key}=${value}`);
|
||||
}
|
||||
return next.join('\n').replace(/\n*$/, '\n');
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
async function readQueueHealth(now) {
|
||||
const conn = await mysql.createConnection(parseMysqlConfig());
|
||||
try {
|
||||
const [statusRows] = await conn.query(
|
||||
`SELECT status, COUNT(*) AS count
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'running', 'retryable')
|
||||
GROUP BY status`,
|
||||
);
|
||||
const statusCounts = {};
|
||||
for (const row of statusRows) statusCounts[row.status] = Number(row.count ?? 0);
|
||||
|
||||
const [oldestPendingRows] = await conn.query(
|
||||
`SELECT MIN(updated_at) AS oldest_updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'retryable')`,
|
||||
);
|
||||
const oldestPendingUpdatedAt = oldestPendingRows[0]?.oldest_updated_at == null
|
||||
? null
|
||||
: Number(oldestPendingRows[0].oldest_updated_at);
|
||||
|
||||
const [oldestRunningRows] = await conn.query(
|
||||
`SELECT
|
||||
r.id,
|
||||
r.request_id,
|
||||
r.started_at,
|
||||
r.updated_at,
|
||||
h.latest_heartbeat_at
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running'
|
||||
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
const oldestRunningStartedAt = oldestRunningRows[0]?.started_at == null
|
||||
? null
|
||||
: Number(oldestRunningRows[0].started_at);
|
||||
const oldestRunningHeartbeatAt = oldestRunningRows[0]?.latest_heartbeat_at == null
|
||||
? null
|
||||
: Number(oldestRunningRows[0].latest_heartbeat_at);
|
||||
const oldestRunningHeartbeatAgeMs = oldestRunningRows[0]
|
||||
? Math.max(0, now - Number(oldestRunningRows[0].latest_heartbeat_at ?? oldestRunningRows[0].started_at ?? now))
|
||||
: 0;
|
||||
const [runningWithoutHeartbeatRows] = await conn.query(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running' AND h.latest_heartbeat_at IS NULL`,
|
||||
);
|
||||
|
||||
const failedWindowMs = positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_FAILED_WINDOW_MS, 10 * 60 * 1000);
|
||||
const since = now - failedWindowMs;
|
||||
const [failedRows] = await conn.query(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM h5_agent_runs
|
||||
WHERE status = 'failed' AND updated_at >= ?`,
|
||||
[since],
|
||||
);
|
||||
const [latestFailedRows] = await conn.query(
|
||||
`SELECT id, request_id, error_message, updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status = 'failed'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCounts,
|
||||
queuedOrRetryable: Number(statusCounts.queued ?? 0) + Number(statusCounts.retryable ?? 0),
|
||||
running: Number(statusCounts.running ?? 0),
|
||||
oldestPendingUpdatedAt,
|
||||
oldestPendingAgeMs: oldestPendingUpdatedAt == null ? 0 : Math.max(0, now - oldestPendingUpdatedAt),
|
||||
oldestRunningStartedAt,
|
||||
oldestRunningAgeMs: oldestRunningStartedAt == null ? 0 : Math.max(0, now - oldestRunningStartedAt),
|
||||
oldestRunningHeartbeatAt,
|
||||
oldestRunningHeartbeatAgeMs,
|
||||
runningWithoutHeartbeatCount: Number(runningWithoutHeartbeatRows[0]?.count ?? 0),
|
||||
latestRunningRun: oldestRunningRows[0] ? {
|
||||
id: oldestRunningRows[0].id,
|
||||
requestId: oldestRunningRows[0].request_id,
|
||||
startedAt: oldestRunningStartedAt,
|
||||
updatedAt: oldestRunningRows[0].updated_at == null ? null : Number(oldestRunningRows[0].updated_at),
|
||||
heartbeatAt: oldestRunningHeartbeatAt,
|
||||
heartbeatAgeMs: oldestRunningHeartbeatAgeMs,
|
||||
} : null,
|
||||
failedWindowMs,
|
||||
failedRecentCount: Number(failedRows[0]?.count ?? 0),
|
||||
latestFailedRun: latestFailedRows[0] ? {
|
||||
id: latestFailedRows[0].id,
|
||||
requestId: latestFailedRows[0].request_id,
|
||||
error: latestFailedRows[0].error_message,
|
||||
updatedAt: Number(latestFailedRows[0].updated_at ?? 0),
|
||||
} : null,
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateHealth(queue) {
|
||||
const thresholds = {
|
||||
maxRecentFailures: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_RECENT_FAILURES, 3),
|
||||
maxPendingAgeMs: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_PENDING_AGE_MS, 5 * 60 * 1000),
|
||||
maxPendingCount: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_PENDING_COUNT, 10),
|
||||
maxRunningAgeMs: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_RUNNING_AGE_MS, 15 * 60 * 1000),
|
||||
};
|
||||
const reasons = [];
|
||||
if (queue.failedRecentCount >= thresholds.maxRecentFailures) {
|
||||
reasons.push(`recent_failed_count ${queue.failedRecentCount} >= ${thresholds.maxRecentFailures}`);
|
||||
}
|
||||
if (queue.oldestPendingAgeMs >= thresholds.maxPendingAgeMs) {
|
||||
reasons.push(`oldest_pending_age_ms ${queue.oldestPendingAgeMs} >= ${thresholds.maxPendingAgeMs}`);
|
||||
}
|
||||
if (queue.queuedOrRetryable >= thresholds.maxPendingCount) {
|
||||
reasons.push(`pending_count ${queue.queuedOrRetryable} >= ${thresholds.maxPendingCount}`);
|
||||
}
|
||||
if (queue.oldestRunningHeartbeatAgeMs >= thresholds.maxRunningAgeMs) {
|
||||
reasons.push(`oldest_running_heartbeat_age_ms ${queue.oldestRunningHeartbeatAgeMs} >= ${thresholds.maxRunningAgeMs}`);
|
||||
}
|
||||
return { thresholds, reasons, shouldPause: reasons.length > 0 };
|
||||
}
|
||||
|
||||
async function disableCodeRuns({ root, envFile, workerLabel, portalLabel, gui, reasons, dryRun }) {
|
||||
const actions = [];
|
||||
const updates = {
|
||||
MEMIND_AGENT_CODE_RUNS_ENABLED: '0',
|
||||
MEMIND_AGENT_RUN_AUTODISPATCH: '1',
|
||||
};
|
||||
if (dryRun) {
|
||||
return {
|
||||
applied: false,
|
||||
actions: [
|
||||
`would backup ${envFile}`,
|
||||
`would set ${Object.entries(updates).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
||||
`would stop and disable ${workerLabel}`,
|
||||
`would kickstart ${portalLabel}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const backupRoot = process.env.MEMIND_AGENT_RUN_GUARD_BACKUP_DIR
|
||||
|| path.join(path.dirname(root), 'memind_backups', `${timestamp()}-agent-run-guard-pause`);
|
||||
fs.mkdirSync(backupRoot, { recursive: true });
|
||||
const envBackup = path.join(backupRoot, '.env.before');
|
||||
fs.copyFileSync(envFile, envBackup);
|
||||
actions.push(`backed_up_env:${envBackup}`);
|
||||
|
||||
const raw = fs.readFileSync(envFile, 'utf8');
|
||||
const updated = replaceOrAppendEnv(raw, updates);
|
||||
const marker = [
|
||||
'',
|
||||
`# agent-run guard pause at ${new Date().toISOString()}`,
|
||||
`# reasons: ${reasons.join('; ')}`,
|
||||
].join('\n');
|
||||
fs.writeFileSync(envFile, `${updated.replace(/\n*$/, '\n')}${marker}\n`, 'utf8');
|
||||
actions.push(`updated_env:${envFile}`);
|
||||
|
||||
await runCommand('launchctl', ['bootout', `${gui}/${workerLabel}`]);
|
||||
await runCommand('launchctl', ['disable', `${gui}/${workerLabel}`]);
|
||||
actions.push(`disabled_worker:${workerLabel}`);
|
||||
|
||||
const kick = await runCommand('launchctl', ['kickstart', '-k', `${gui}/${portalLabel}`]);
|
||||
actions.push(kick.ok ? `kickstarted_portal:${portalLabel}` : `portal_kickstart_failed:${kick.message}`);
|
||||
return { applied: true, backupRoot, actions };
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const root = path.join(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
const envFile = process.env.MEMIND_ENV_FILE || path.join(root, '.env');
|
||||
loadEnvFile(envFile);
|
||||
|
||||
const enabled = process.env.MEMIND_AGENT_RUN_GUARD_ENABLED == null
|
||||
? true
|
||||
: truthy(process.env.MEMIND_AGENT_RUN_GUARD_ENABLED);
|
||||
const workerLabel = process.env.MEMIND_AGENT_RUN_WORKER_LABEL || DEFAULT_WORKER_LABEL;
|
||||
const portalLabel = process.env.MEMIND_PORTAL_LABEL || DEFAULT_PORTAL_LABEL;
|
||||
const gui = `gui/${process.getuid()}`;
|
||||
const now = Date.now();
|
||||
const queue = await readQueueHealth(now).catch((err) => ({
|
||||
error: err instanceof Error ? (err.message || err.code || err.name) : String(err),
|
||||
}));
|
||||
const evaluation = queue.error
|
||||
? { thresholds: {}, reasons: [`queue_health_error ${queue.error}`], shouldPause: false }
|
||||
: evaluateHealth(queue);
|
||||
let pause = { applied: false, actions: [] };
|
||||
|
||||
if (enabled && evaluation.shouldPause) {
|
||||
pause = await disableCodeRuns({
|
||||
root,
|
||||
envFile,
|
||||
workerLabel,
|
||||
portalLabel,
|
||||
gui,
|
||||
reasons: evaluation.reasons,
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: enabled ? !evaluation.shouldPause || pause.applied || args.dryRun : true,
|
||||
checkedAt: new Date(now).toISOString(),
|
||||
enabled,
|
||||
mode: args.apply ? 'apply' : 'dry-run',
|
||||
queue,
|
||||
evaluation,
|
||||
pause,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,8 @@ const buildFlags = {
|
||||
serverCodeRunsEnabledTruthy: truthy(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
|
||||
enabledEnv: process.env.VITE_AGENT_CODE_RUNS_ENABLED ?? null,
|
||||
autodetectEnv: process.env.VITE_AGENT_CODE_RUNS_AUTODETECT ?? null,
|
||||
enabledUserIdsEnv: process.env.VITE_AGENT_CODE_RUNS_USER_IDS ?? null,
|
||||
serverCodeRunUserIdsEnv: process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS ?? null,
|
||||
enabledTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_ENABLED),
|
||||
autodetectTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT),
|
||||
note: 'These env values describe the current shell, not necessarily the already-built dist.',
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_LABEL = 'cn.tkmind.memind-agent-run-worker';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function truthy(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseMysqlConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
if (url.protocol !== 'mysql:') {
|
||||
throw new Error(`Unsupported DATABASE_URL scheme for agent run worker check: ${url.protocol}`);
|
||||
}
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\/+/, ''),
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
return {
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT || 3306),
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
|
||||
async function runCommand(command, args) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return { ok: true, stdout, stderr };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: err?.code ?? null,
|
||||
stdout: err?.stdout ?? '',
|
||||
stderr: err?.stderr ?? '',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseLaunchctlPrint(text) {
|
||||
const state = text.match(/\bstate = ([^\n]+)/)?.[1]?.trim() ?? null;
|
||||
const pid = text.match(/\bpid = ([0-9]+)/)?.[1] ?? null;
|
||||
const program = text.match(/\bprogram = ([^\n]+)/)?.[1]?.trim() ?? null;
|
||||
const pathValue = text.match(/\bpath = ([^\n]+)/)?.[1]?.trim() ?? null;
|
||||
return {
|
||||
loaded: Boolean(state || pid || program || pathValue),
|
||||
state,
|
||||
pid: pid == null ? null : Number(pid),
|
||||
program,
|
||||
path: pathValue,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDisabled(text, label) {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = text.match(new RegExp(`"${escaped}"\\s*=>\\s*(disabled|enabled)`));
|
||||
return match?.[1] === 'disabled';
|
||||
}
|
||||
|
||||
async function readQueueSummary() {
|
||||
const conn = await mysql.createConnection(parseMysqlConfig());
|
||||
try {
|
||||
const [statusRows] = await conn.query(
|
||||
`SELECT status, COUNT(*) AS count
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'running', 'retryable')
|
||||
GROUP BY status`,
|
||||
);
|
||||
const statusCounts = {};
|
||||
for (const row of statusRows) statusCounts[row.status] = Number(row.count ?? 0);
|
||||
|
||||
const [lagRows] = await conn.query(
|
||||
`SELECT MIN(updated_at) AS oldest_updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'retryable')`,
|
||||
);
|
||||
const oldestUpdatedAt = lagRows[0]?.oldest_updated_at == null
|
||||
? null
|
||||
: Number(lagRows[0].oldest_updated_at);
|
||||
|
||||
const [runningRows] = await conn.query(
|
||||
`SELECT
|
||||
r.id,
|
||||
r.request_id,
|
||||
r.started_at,
|
||||
r.updated_at,
|
||||
h.latest_heartbeat_at
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running'
|
||||
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
const oldestRunningStartedAt = runningRows[0]?.started_at == null
|
||||
? null
|
||||
: Number(runningRows[0].started_at);
|
||||
const oldestRunningHeartbeatAt = runningRows[0]?.latest_heartbeat_at == null
|
||||
? null
|
||||
: Number(runningRows[0].latest_heartbeat_at);
|
||||
const oldestRunningHeartbeatAgeMs = runningRows[0]
|
||||
? Math.max(0, Date.now() - Number(runningRows[0].latest_heartbeat_at ?? runningRows[0].started_at ?? Date.now()))
|
||||
: 0;
|
||||
const [runningWithoutHeartbeatRows] = await conn.query(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running' AND h.latest_heartbeat_at IS NULL`,
|
||||
);
|
||||
|
||||
const [failedRows] = await conn.query(
|
||||
`SELECT id, request_id, error_message, updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status = 'failed'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCounts,
|
||||
oldestPendingUpdatedAt: oldestUpdatedAt,
|
||||
oldestPendingAgeMs: oldestUpdatedAt == null ? 0 : Math.max(0, Date.now() - oldestUpdatedAt),
|
||||
oldestRunningStartedAt,
|
||||
oldestRunningAgeMs: oldestRunningStartedAt == null ? 0 : Math.max(0, Date.now() - oldestRunningStartedAt),
|
||||
oldestRunningHeartbeatAt,
|
||||
oldestRunningHeartbeatAgeMs,
|
||||
runningWithoutHeartbeatCount: Number(runningWithoutHeartbeatRows[0]?.count ?? 0),
|
||||
latestRunningRun: runningRows[0] ? {
|
||||
id: runningRows[0].id,
|
||||
requestId: runningRows[0].request_id,
|
||||
startedAt: oldestRunningStartedAt,
|
||||
updatedAt: runningRows[0].updated_at == null ? null : Number(runningRows[0].updated_at),
|
||||
heartbeatAt: oldestRunningHeartbeatAt,
|
||||
heartbeatAgeMs: oldestRunningHeartbeatAgeMs,
|
||||
} : null,
|
||||
latestFailedRun: failedRows[0] ? {
|
||||
id: failedRows[0].id,
|
||||
requestId: failedRows[0].request_id,
|
||||
error: failedRows[0].error_message,
|
||||
updatedAt: Number(failedRows[0].updated_at ?? 0),
|
||||
} : null,
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
const root = path.join(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(root, '.env'));
|
||||
|
||||
const label = process.env.MEMIND_AGENT_RUN_WORKER_LABEL || DEFAULT_LABEL;
|
||||
const gui = `gui/${process.getuid()}`;
|
||||
const plist = path.join(process.env.HOME || '', 'Library', 'LaunchAgents', `${label}.plist`);
|
||||
const expectRunning = truthy(process.env.MEMIND_AGENT_RUN_WORKER_EXPECT_RUNNING);
|
||||
|
||||
const printResult = await runCommand('launchctl', ['print', `${gui}/${label}`]);
|
||||
const disabledResult = await runCommand('launchctl', ['print-disabled', gui]);
|
||||
const pgrepResult = await runCommand('pgrep', ['-fl', 'agent-run-worker.mjs']);
|
||||
const launchd = parseLaunchctlPrint(`${printResult.stdout}\n${printResult.stderr}`);
|
||||
const disabled = disabledResult.ok ? parseDisabled(disabledResult.stdout, label) : null;
|
||||
const pgrepLines = pgrepResult.ok
|
||||
? pgrepResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
|
||||
: [];
|
||||
const queue = await readQueueSummary().catch((err) => ({
|
||||
error: err instanceof Error ? (err.message || err.code || err.name) : String(err),
|
||||
}));
|
||||
|
||||
const installed = fs.existsSync(plist);
|
||||
const running = launchd.state === 'running' || pgrepLines.length > 0;
|
||||
const ok = Boolean(
|
||||
installed &&
|
||||
!queue.error &&
|
||||
(expectRunning ? running : disabled === true && !running),
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok,
|
||||
checkedAt: new Date().toISOString(),
|
||||
expected: expectRunning ? 'running' : 'disabled',
|
||||
label,
|
||||
plist,
|
||||
installed,
|
||||
disabled,
|
||||
running,
|
||||
launchd,
|
||||
processes: pgrepLines,
|
||||
queue,
|
||||
}, null, 2));
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scan MindSpace public/*.html for broken relative links.
|
||||
*
|
||||
* Default (--downloads-only): attachment / download links only (release gate).
|
||||
* --all-links: also check img/src and mindspace-cover assets.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-mindspace-public-links.mjs
|
||||
* node scripts/check-mindspace-public-links.mjs --user <uuid>
|
||||
* node scripts/check-mindspace-public-links.mjs --root /path/to/MindSpace
|
||||
* node scripts/check-mindspace-public-links.mjs --all-links
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanPublicHtmlLinks } from '../mindspace-public-links.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_PUBLISH_ROOT = 'MindSpace';
|
||||
|
||||
function parseArgs(argv) {
|
||||
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
|
||||
let userId = null;
|
||||
let allLinks = false;
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--root' && argv[i + 1]) {
|
||||
root = path.resolve(argv[i + 1]);
|
||||
i += 1;
|
||||
} else if (argv[i] === '--user' && argv[i + 1]) {
|
||||
userId = argv[i + 1];
|
||||
i += 1;
|
||||
} else if (argv[i] === '--all-links') {
|
||||
allLinks = true;
|
||||
} else if (argv[i] === '--downloads-only') {
|
||||
allLinks = false;
|
||||
} else if (argv[i] === '--help' || argv[i] === '-h') {
|
||||
console.log(
|
||||
`Usage: node scripts/check-mindspace-public-links.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--downloads-only|--all-links]`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return { root, userId, downloadsOnly: !allLinks };
|
||||
}
|
||||
|
||||
const { root, userId, downloadsOnly } = parseArgs(process.argv);
|
||||
const issues = scanPublicHtmlLinks(root, { userId, downloadsOnly });
|
||||
const modeLabel = downloadsOnly ? 'download/attachment links' : 'all relative links';
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log(`OK: no missing ${modeLabel} under ${root}${userId ? ` (user ${userId})` : ''}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Found ${issues.length} missing ${modeLabel} under ${root}:`);
|
||||
for (const issue of issues) {
|
||||
const relHtml = path.relative(root, issue.htmlPath);
|
||||
console.error(`- ${relHtml}: ${issue.ref} (${issue.source})`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -97,6 +97,19 @@ try {
|
||||
chatInjectsCodeTools: false,
|
||||
aiderTimeoutMs: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 600_000),
|
||||
openhandsTimeoutMs: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 900_000),
|
||||
toolGateway: {
|
||||
enabled: ['1', 'true', 'yes', 'on'].includes(
|
||||
String(process.env.MEMIND_TOOL_GATEWAY_ENABLED ?? '').trim().toLowerCase(),
|
||||
),
|
||||
dryRun: ['1', 'true', 'yes', 'on'].includes(
|
||||
String(process.env.MEMIND_TOOL_GATEWAY_DRY_RUN ?? '').trim().toLowerCase(),
|
||||
),
|
||||
protocol: 'agent-run-v1',
|
||||
defaultExecutor: process.env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR ?? 'aider',
|
||||
openhandsTaskTypes:
|
||||
process.env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ??
|
||||
'repo_refactor,multi_file,complex_repo',
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
process.exit(ok ? 0 : 1);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_AGENT_RUN_GUARD_SCRIPT:-$ROOT/scripts/agent-run-guard.mjs}"
|
||||
LABEL="${MEMIND_AGENT_RUN_GUARD_LABEL:-cn.tkmind.memind-agent-run-guard}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="${MEMIND_AGENT_RUN_GUARD_LOG:-$HOME/Library/Logs/memind-agent-run-guard.log}"
|
||||
GUI="gui/$(id -u)"
|
||||
INTERVAL="${MEMIND_AGENT_RUN_GUARD_INTERVAL_SECONDS:-60}"
|
||||
START="${MEMIND_AGENT_RUN_GUARD_START:-1}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$(dirname "$LOG")"
|
||||
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "agent run guard script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--apply</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>StartInterval</key>
|
||||
<integer>$INTERVAL</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_ENABLED</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_ENABLED:-1}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_FAILED_WINDOW_MS</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_FAILED_WINDOW_MS:-600000}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_RECENT_FAILURES</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_RECENT_FAILURES:-3}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_PENDING_AGE_MS</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_PENDING_AGE_MS:-300000}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_PENDING_COUNT</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_PENDING_COUNT:-10}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_RUNNING_AGE_MS</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_RUNNING_AGE_MS:-900000}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
|
||||
if [[ "$START" == "1" || "$START" == "true" || "$START" == "yes" ]]; then
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
state="started"
|
||||
else
|
||||
launchctl disable "$GUI/$LABEL" 2>/dev/null || true
|
||||
state="installed-disabled"
|
||||
fi
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "state: $state"
|
||||
echo "script: $SCRIPT"
|
||||
echo "interval_seconds: $INTERVAL"
|
||||
echo "log: $LOG"
|
||||
echo "manual stop: launchctl bootout $GUI/$LABEL"
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_AGENT_RUN_WORKER_SCRIPT:-$ROOT/scripts/agent-run-worker.mjs}"
|
||||
LABEL="${MEMIND_AGENT_RUN_WORKER_LABEL:-cn.tkmind.memind-agent-run-worker}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="${MEMIND_AGENT_RUN_WORKER_LOG:-$HOME/Library/Logs/memind-agent-run-worker.log}"
|
||||
GUI="gui/$(id -u)"
|
||||
POLL_MS="${MEMIND_AGENT_RUN_WORKER_POLL_MS:-3000}"
|
||||
BATCH_SIZE="${MEMIND_AGENT_RUN_WORKER_BATCH_SIZE:-1}"
|
||||
CONCURRENCY="${MEMIND_AGENT_RUN_QUEUE_CONCURRENCY:-1}"
|
||||
RUN_TIMEOUT_MS="${MEMIND_AGENT_RUN_TIMEOUT_MS:-900000}"
|
||||
TOOL_GATEWAY_ENABLED="${MEMIND_TOOL_GATEWAY_ENABLED:-0}"
|
||||
TOOL_GATEWAY_DRY_RUN="${MEMIND_TOOL_GATEWAY_DRY_RUN:-0}"
|
||||
WORKDIR_OVERRIDE="${MEMIND_AGENT_RUN_WORKDIR_OVERRIDE:-}"
|
||||
WORKDIR_OVERRIDE_USER_ID="${MEMIND_AGENT_RUN_WORKDIR_USER_ID:-}"
|
||||
START="${MEMIND_AGENT_RUN_WORKER_START:-0}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$(dirname "$LOG")"
|
||||
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "agent run worker script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--poll-ms</string>
|
||||
<string>$POLL_MS</string>
|
||||
<string>--limit</string>
|
||||
<string>$BATCH_SIZE</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>MEMIND_AGENT_RUN_AUTODISPATCH</key>
|
||||
<string>0</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKER_POLL_MS</key>
|
||||
<string>$POLL_MS</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKER_BATCH_SIZE</key>
|
||||
<string>$BATCH_SIZE</string>
|
||||
<key>MEMIND_AGENT_RUN_QUEUE_CONCURRENCY</key>
|
||||
<string>$CONCURRENCY</string>
|
||||
<key>MEMIND_AGENT_RUN_TIMEOUT_MS</key>
|
||||
<string>$RUN_TIMEOUT_MS</string>
|
||||
<key>MEMIND_TOOL_GATEWAY_ENABLED</key>
|
||||
<string>$TOOL_GATEWAY_ENABLED</string>
|
||||
<key>MEMIND_TOOL_GATEWAY_DRY_RUN</key>
|
||||
<string>$TOOL_GATEWAY_DRY_RUN</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKDIR_OVERRIDE</key>
|
||||
<string>$WORKDIR_OVERRIDE</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKDIR_USER_ID</key>
|
||||
<string>$WORKDIR_OVERRIDE_USER_ID</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
|
||||
if [[ "$START" == "1" || "$START" == "true" || "$START" == "yes" ]]; then
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
state="started"
|
||||
else
|
||||
launchctl disable "$GUI/$LABEL" 2>/dev/null || true
|
||||
state="installed-disabled"
|
||||
fi
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "state: $state"
|
||||
echo "script: $SCRIPT"
|
||||
echo "poll_ms: $POLL_MS"
|
||||
echo "batch_size: $BATCH_SIZE"
|
||||
echo "concurrency: $CONCURRENCY"
|
||||
echo "tool_gateway_enabled: $TOOL_GATEWAY_ENABLED"
|
||||
echo "tool_gateway_dry_run: $TOOL_GATEWAY_DRY_RUN"
|
||||
echo "workdir_override: $WORKDIR_OVERRIDE"
|
||||
echo "workdir_override_user_id: $WORKDIR_OVERRIDE_USER_ID"
|
||||
echo "log: $LOG"
|
||||
echo "manual start: launchctl enable $GUI/$LABEL && launchctl kickstart -k $GUI/$LABEL"
|
||||
echo "manual stop: launchctl bootout $GUI/$LABEL"
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_RUNTIME_HEARTBEAT_SCRIPT:-$ROOT/scripts/runtime-worker-heartbeat.mjs}"
|
||||
LABEL="${MEMIND_RUNTIME_HEARTBEAT_LABEL:-cn.tkmind.memind-runtime-heartbeat}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="$HOME/Library/Logs/memind-runtime-heartbeat.log"
|
||||
GUI="gui/$(id -u)"
|
||||
INTERVAL_MS="${MEMIND_RUNTIME_HEARTBEAT_INTERVAL_MS:-15000}"
|
||||
TIMEOUT_MS="${MEMIND_RUNTIME_HEARTBEAT_TIMEOUT_MS:-5000}"
|
||||
TTL_MS="${MEMIND_RUNTIME_HEARTBEAT_TTL_MS:-45000}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
|
||||
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "runtime heartbeat script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>serve</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>MEMIND_RUNTIME_HEARTBEAT_INTERVAL_MS</key>
|
||||
<string>$INTERVAL_MS</string>
|
||||
<key>MEMIND_RUNTIME_HEARTBEAT_TIMEOUT_MS</key>
|
||||
<string>$TIMEOUT_MS</string>
|
||||
<key>MEMIND_RUNTIME_HEARTBEAT_TTL_MS</key>
|
||||
<string>$TTL_MS</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "script: $SCRIPT"
|
||||
echo "interval_ms: $INTERVAL_MS"
|
||||
echo "timeout_ms: $TIMEOUT_MS"
|
||||
echo "ttl_ms: $TTL_MS"
|
||||
echo "log: $LOG"
|
||||
@@ -11,6 +11,7 @@ GUI="gui/$(id -u)"
|
||||
HOUR="${MEMIND_RUNTIME_SLO_HOUR:-23}"
|
||||
MINUTE="${MEMIND_RUNTIME_SLO_MINUTE:-55}"
|
||||
REPORT_DIR="${MEMIND_RUNTIME_REPORT_DIR:-$ROOT/reports/runtime-slo}"
|
||||
RETENTION_DAYS="${MEMIND_RUNTIME_SLO_RETENTION_DAYS:-30}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" "$REPORT_DIR"
|
||||
|
||||
@@ -34,6 +35,9 @@ cat > "$PLIST" <<EOF
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--write-report</string>
|
||||
<string>--prune</string>
|
||||
<string>--retention-days</string>
|
||||
<string>$RETENTION_DAYS</string>
|
||||
<string>--report-dir</string>
|
||||
<string>$REPORT_DIR</string>
|
||||
</array>
|
||||
@@ -69,4 +73,5 @@ echo "installed $PLIST"
|
||||
echo "script: $SCRIPT"
|
||||
echo "schedule: daily ${HOUR}:${MINUTE}"
|
||||
echo "report_dir: $REPORT_DIR"
|
||||
echo "retention_days: $RETENTION_DAYS"
|
||||
echo "log: $LOG"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_RUNTIME_SLO_SCRIPT:-$ROOT/scripts/runtime-slo-report.mjs}"
|
||||
LABEL="${MEMIND_RUNTIME_SLO_SOAK_LABEL:-cn.tkmind.memind-runtime-slo-soak}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="$HOME/Library/Logs/memind-runtime-slo-soak.log"
|
||||
GUI="gui/$(id -u)"
|
||||
INTERVAL_SECONDS="${MEMIND_RUNTIME_SLO_SOAK_INTERVAL_SECONDS:-3600}"
|
||||
REPORT_DIR="${MEMIND_RUNTIME_SLO_SOAK_REPORT_DIR:-$ROOT/reports/runtime-slo-soak}"
|
||||
RETENTION_DAYS="${MEMIND_RUNTIME_SLO_SOAK_RETENTION_DAYS:-7}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" "$REPORT_DIR"
|
||||
|
||||
if [[ ! "$INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || [[ "$INTERVAL_SECONDS" -lt 60 ]]; then
|
||||
echo "invalid MEMIND_RUNTIME_SLO_SOAK_INTERVAL_SECONDS: $INTERVAL_SECONDS" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || [[ "$RETENTION_DAYS" -lt 1 ]]; then
|
||||
echo "invalid MEMIND_RUNTIME_SLO_SOAK_RETENTION_DAYS: $RETENTION_DAYS" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "runtime SLO script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--write-report</string>
|
||||
<string>--prune</string>
|
||||
<string>--retention-days</string>
|
||||
<string>$RETENTION_DAYS</string>
|
||||
<string>--report-dir</string>
|
||||
<string>$REPORT_DIR</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>StartInterval</key>
|
||||
<integer>$INTERVAL_SECONDS</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "script: $SCRIPT"
|
||||
echo "schedule: every ${INTERVAL_SECONDS}s"
|
||||
echo "report_dir: $REPORT_DIR"
|
||||
echo "retention_days: $RETENTION_DAYS"
|
||||
echo "log: $LOG"
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator < 0) continue;
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const value = trimmed.slice(separator + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadH5Environment(scriptDirectory) {
|
||||
loadEnvFile(path.join(scriptDirectory, '../../../.env.local'));
|
||||
loadEnvFile(path.join(scriptDirectory, '../.env'));
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# 反向 SSH 隧道:105 通过 127.0.0.1:19081 访问本机 Portal (:8081)
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${MEMIND_PORTAL_TUNNEL_HOST:-ssh105-public}"
|
||||
LOCAL_PORT="${MEMIND_PORTAL_TUNNEL_LOCAL_PORT:-8081}"
|
||||
REMOTE_PORT="${MEMIND_PORTAL_TUNNEL_REMOTE_PORT:-19081}"
|
||||
|
||||
exec ssh -N \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-R "127.0.0.1:${REMOTE_PORT}:127.0.0.1:${LOCAL_PORT}" \
|
||||
"${HOST}"
|
||||
@@ -90,27 +90,85 @@ function uniquePaths(paths) {
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
writeReport: false,
|
||||
prune: false,
|
||||
retentionDays: Number(process.env.MEMIND_RUNTIME_SLO_RETENTION_DAYS || 30),
|
||||
reportDir: process.env.MEMIND_RUNTIME_REPORT_DIR || '',
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const item = argv[i];
|
||||
if (item === '--write-report') args.writeReport = true;
|
||||
else if (item === '--prune') args.prune = true;
|
||||
else if (item === '--retention-days') args.retentionDays = Number(argv[++i] ?? args.retentionDays);
|
||||
else if (item === '--report-dir') args.reportDir = String(argv[++i] ?? args.reportDir);
|
||||
else if (item === '--help' || item === '-h') args.help = true;
|
||||
}
|
||||
if (!Number.isFinite(args.retentionDays) || args.retentionDays < 1) {
|
||||
throw new Error(`Invalid --retention-days: ${args.retentionDays}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/runtime-slo-report.mjs [--write-report] [--report-dir <dir>]',
|
||||
' node scripts/runtime-slo-report.mjs [--write-report] [--prune] [--retention-days <days>] [--report-dir <dir>]',
|
||||
'',
|
||||
'Default behavior is read-only and writes nothing.',
|
||||
'--write-report writes JSON and Markdown snapshots to an operations report directory.',
|
||||
'--prune deletes old .json/.md snapshots in the report directory only.',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function pruneReportDir(reportDir, retentionDays) {
|
||||
const resolvedDir = path.resolve(reportDir);
|
||||
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||
const result = {
|
||||
enabled: true,
|
||||
reportDir: resolvedDir,
|
||||
retentionDays,
|
||||
deleted: [],
|
||||
kept: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
result.errors.push(err instanceof Error ? err.message : String(err));
|
||||
return result;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.json') && !entry.name.endsWith('.md')) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const full = path.join(resolvedDir, entry.name);
|
||||
let stat = null;
|
||||
try {
|
||||
stat = fs.statSync(full);
|
||||
} catch (err) {
|
||||
result.errors.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
continue;
|
||||
}
|
||||
if (stat.mtimeMs >= cutoffMs) {
|
||||
result.kept += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(full);
|
||||
result.deleted.push(entry.name);
|
||||
} catch (err) {
|
||||
result.errors.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function tableCountQueries() {
|
||||
return [
|
||||
['h5_users', 'users'],
|
||||
@@ -166,13 +224,28 @@ async function readRedisSummary(namespace, redisUrl) {
|
||||
|
||||
function summarizeRuntime(runtimeJson) {
|
||||
const workers = runtimeJson?.router?.workers ?? [];
|
||||
const toolQueue = runtimeJson?.toolRuntime?.queue ?? null;
|
||||
const staleMetricMs = 2 * 60 * 1000;
|
||||
const staleHeartbeatMs = Number(process.env.MEMIND_RUNTIME_HEARTBEAT_STALE_MS || 60 * 1000);
|
||||
const now = Date.now();
|
||||
return {
|
||||
routerEnabled: Boolean(runtimeJson?.router?.enabled),
|
||||
publicBaseUrl: runtimeJson?.publicBaseUrl ?? null,
|
||||
toolRuntime: runtimeJson?.toolRuntime ?? null,
|
||||
toolQueue: runtimeJson?.toolRuntime?.queue ?? null,
|
||||
toolQueue,
|
||||
toolQueueSlo: toolQueue
|
||||
? {
|
||||
heartbeatMs: Number(toolQueue.heartbeatMs ?? 0),
|
||||
maxConcurrentRuns: Number(toolQueue.maxConcurrentRuns ?? 0),
|
||||
statusCounts: toolQueue.statusCounts ?? {},
|
||||
oldestRunningStartedAt: toolQueue.oldestRunningStartedAt ?? null,
|
||||
oldestRunningAgeMs: Number(toolQueue.oldestRunningAgeMs ?? 0),
|
||||
oldestRunningHeartbeatAt: toolQueue.oldestRunningHeartbeatAt ?? null,
|
||||
oldestRunningHeartbeatAgeMs: Number(toolQueue.oldestRunningHeartbeatAgeMs ?? 0),
|
||||
runningWithoutHeartbeatCount: Number(toolQueue.runningWithoutHeartbeatCount ?? 0),
|
||||
latestRunningRun: toolQueue.latestRunningRun ?? null,
|
||||
}
|
||||
: null,
|
||||
workers: workers.map((worker) => ({
|
||||
id: worker.id,
|
||||
healthy: runtimeJson?.targets?.find((target) => target.target === worker.target)?.healthy ?? null,
|
||||
@@ -191,6 +264,14 @@ function summarizeRuntime(runtimeJson) {
|
||||
fdPressure: worker.fdPressure,
|
||||
fdCount: worker.fdCount,
|
||||
containerHealth: worker.containerHealth,
|
||||
heartbeat: worker.heartbeat,
|
||||
heartbeatAgeMs: worker.heartbeat ? now - worker.heartbeat : null,
|
||||
heartbeatFresh: worker.heartbeat ? now - worker.heartbeat <= staleHeartbeatMs : false,
|
||||
heartbeatSource: worker.heartbeatSource ?? null,
|
||||
heartbeatOk: worker.heartbeatOk ?? null,
|
||||
heartbeatStatusCode: worker.heartbeatStatusCode ?? null,
|
||||
heartbeatLatencyMs: worker.heartbeatLatencyMs ?? null,
|
||||
heartbeatError: worker.heartbeatError ?? null,
|
||||
metricsAgeMs: worker.metricsSampledAt ? now - worker.metricsSampledAt : null,
|
||||
metricsFresh: worker.metricsSampledAt ? now - worker.metricsSampledAt <= staleMetricMs : false,
|
||||
score: worker.score,
|
||||
@@ -246,6 +327,8 @@ if (!runtime.ok) failures.push('runtime_status_unavailable');
|
||||
if (runtimeSummary && !runtimeSummary.routerEnabled) failures.push('router_disabled');
|
||||
for (const worker of runtimeSummary?.workers ?? []) {
|
||||
if (!worker.healthy) failures.push(`${worker.id}_target_unhealthy`);
|
||||
if (!worker.heartbeatFresh) failures.push(`${worker.id}_heartbeat_stale`);
|
||||
if (worker.heartbeatOk === false) failures.push(`${worker.id}_heartbeat_unhealthy`);
|
||||
if (worker.containerHealth && worker.containerHealth !== 'healthy') failures.push(`${worker.id}_container_${worker.containerHealth}`);
|
||||
if (!worker.metricsFresh) failures.push(`${worker.id}_metrics_stale`);
|
||||
if (worker.streamErrorCount > 0) failures.push(`${worker.id}_stream_errors_${worker.streamErrorCount}`);
|
||||
@@ -256,6 +339,32 @@ if (runtimeSummary?.toolQueue?.error) failures.push('tool_queue_status_error');
|
||||
if (runtimeSummary?.toolQueue?.inFlight > runtimeSummary?.toolQueue?.maxConcurrentRuns) {
|
||||
failures.push('tool_queue_concurrency_exceeded');
|
||||
}
|
||||
const agentRunHeartbeatStaleMs = Number(
|
||||
process.env.MEMIND_AGENT_RUN_HEARTBEAT_STALE_MS ||
|
||||
Math.max(90 * 1000, Number(runtimeSummary?.toolQueueSlo?.heartbeatMs ?? 0) * 3),
|
||||
);
|
||||
if (
|
||||
runtimeSummary?.toolQueueSlo?.oldestRunningHeartbeatAgeMs > agentRunHeartbeatStaleMs
|
||||
) {
|
||||
failures.push(`agent_run_heartbeat_stale_${runtimeSummary.toolQueueSlo.oldestRunningHeartbeatAgeMs}`);
|
||||
}
|
||||
if (
|
||||
Number(runtimeSummary?.toolQueueSlo?.runningWithoutHeartbeatCount ?? 0) > 0 &&
|
||||
Number(runtimeSummary?.toolQueueSlo?.oldestRunningAgeMs ?? 0) > agentRunHeartbeatStaleMs
|
||||
) {
|
||||
failures.push(`agent_run_missing_heartbeat_${runtimeSummary.toolQueueSlo.runningWithoutHeartbeatCount}`);
|
||||
}
|
||||
|
||||
const reportDir = path.resolve(args.reportDir || path.join(appRoot, 'reports', 'runtime-slo'));
|
||||
if (args.writeReport || args.prune) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
}
|
||||
const reportPrune = args.prune
|
||||
? pruneReportDir(reportDir, args.retentionDays)
|
||||
: { enabled: false, reportDir, retentionDays: args.retentionDays };
|
||||
for (const error of reportPrune.errors ?? []) {
|
||||
failures.push(`report_prune_error:${error}`);
|
||||
}
|
||||
|
||||
const report = {
|
||||
ok: failures.length === 0,
|
||||
@@ -274,7 +383,9 @@ const report = {
|
||||
mindSpace: false,
|
||||
redis: false,
|
||||
report: Boolean(args.writeReport),
|
||||
reportPrune: Boolean(args.prune),
|
||||
},
|
||||
reportPrune,
|
||||
};
|
||||
|
||||
function markdownReport(payload) {
|
||||
@@ -290,8 +401,8 @@ function markdownReport(payload) {
|
||||
'',
|
||||
'## Workers',
|
||||
'',
|
||||
'| worker | healthy | active | errors | first-token 5m p50/p95 | first-token 1h p50/p95 | metricsFresh | score |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
'| worker | healthy | active | errors | first-token 5m p50/p95 | first-token 1h p50/p95 | heartbeat | metricsFresh | score |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
for (const worker of workers) {
|
||||
lines.push([
|
||||
@@ -301,6 +412,7 @@ function markdownReport(payload) {
|
||||
worker.streamErrorCount,
|
||||
`${worker.firstToken5m?.p50Ms ?? 0}/${worker.firstToken5m?.p95Ms ?? 0}`,
|
||||
`${worker.firstToken1h?.p50Ms ?? 0}/${worker.firstToken1h?.p95Ms ?? 0}`,
|
||||
`${worker.heartbeatSource ?? 'none'}:${worker.heartbeatFresh}`,
|
||||
worker.metricsFresh,
|
||||
worker.score,
|
||||
].join(' | ').replace(/^/, '| ').replace(/$/, ' |'));
|
||||
@@ -309,6 +421,10 @@ function markdownReport(payload) {
|
||||
'',
|
||||
'## Tool Queue',
|
||||
'',
|
||||
`- heartbeatMs: ${payload.runtime?.toolQueueSlo?.heartbeatMs ?? 0}`,
|
||||
`- oldestRunningHeartbeatAgeMs: ${payload.runtime?.toolQueueSlo?.oldestRunningHeartbeatAgeMs ?? 0}`,
|
||||
`- runningWithoutHeartbeatCount: ${payload.runtime?.toolQueueSlo?.runningWithoutHeartbeatCount ?? 0}`,
|
||||
'',
|
||||
'```json',
|
||||
JSON.stringify(payload.runtime?.toolQueue ?? null, null, 2),
|
||||
'```',
|
||||
@@ -319,13 +435,17 @@ function markdownReport(payload) {
|
||||
JSON.stringify(payload.writes, null, 2),
|
||||
'```',
|
||||
'',
|
||||
'## Report Prune',
|
||||
'',
|
||||
'```json',
|
||||
JSON.stringify(payload.reportPrune ?? null, null, 2),
|
||||
'```',
|
||||
'',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (args.writeReport) {
|
||||
const reportDir = path.resolve(args.reportDir || path.join(appRoot, 'reports', 'runtime-slo'));
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
report.reportFiles = {
|
||||
json: path.join(reportDir, `${stamp}.json`),
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function targetWorkers() {
|
||||
const targets = (process.env.TKMIND_API_TARGETS || process.env.TKMIND_API_TARGET || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return targets.map((target, index) => ({
|
||||
id: `goosed-${index + 1}`,
|
||||
target,
|
||||
}));
|
||||
}
|
||||
|
||||
function workerKey(namespace, id, field) {
|
||||
return [namespace, 'worker', id, field].join(':');
|
||||
}
|
||||
|
||||
async function probeTarget(target, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
try {
|
||||
const url = new URL('/status', target.endsWith('/') ? target : `${target}/`).toString();
|
||||
const res = await undiciFetch(url, {
|
||||
signal: controller.signal,
|
||||
dispatcher: url.startsWith('https://127.0.0.1') ? dispatcher : undefined,
|
||||
headers: process.env.TKMIND_SERVER__SECRET_KEY
|
||||
? { 'X-Secret-Key': process.env.TKMIND_SERVER__SECRET_KEY }
|
||||
: undefined,
|
||||
});
|
||||
const body = (await res.text()).trim().slice(0, 80);
|
||||
return {
|
||||
ok: res.ok && body === 'ok',
|
||||
statusCode: res.status,
|
||||
body,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 0,
|
||||
body: '',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
dispatcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeHeartbeats(client, namespace, workers, options) {
|
||||
const now = Date.now();
|
||||
const results = [];
|
||||
for (const worker of workers) {
|
||||
const probe = await probeTarget(worker.target, options.timeoutMs);
|
||||
const ttlSeconds = Math.max(5, Math.ceil(options.ttlMs / 1000));
|
||||
const multi = client
|
||||
.multi()
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat'), String(now), { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_at'), String(now), { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_source'), 'sidecar', { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_target'), worker.target, { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_ok'), probe.ok ? '1' : '0', { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_status_code'), String(probe.statusCode), { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_latency_ms'), String(probe.latencyMs), { EX: ttlSeconds });
|
||||
if (probe.error) {
|
||||
multi.set(workerKey(namespace, worker.id, 'heartbeat_error'), probe.error, { EX: ttlSeconds });
|
||||
} else {
|
||||
multi.del(workerKey(namespace, worker.id, 'heartbeat_error'));
|
||||
}
|
||||
await multi.exec();
|
||||
results.push({ ...worker, ...probe, heartbeatAt: now });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'));
|
||||
|
||||
const redisUrl = process.env.MEMIND_RUNTIME_REDIS_URL || 'redis://127.0.0.1:6379/0';
|
||||
const namespace = process.env.MEMIND_RUNTIME_REDIS_NAMESPACE || 'memind:runtime';
|
||||
const action = process.argv[2] || 'once';
|
||||
const intervalMs = positiveInteger(process.env.MEMIND_RUNTIME_HEARTBEAT_INTERVAL_MS, 15_000);
|
||||
const timeoutMs = positiveInteger(process.env.MEMIND_RUNTIME_HEARTBEAT_TIMEOUT_MS, 5_000);
|
||||
const ttlMs = positiveInteger(process.env.MEMIND_RUNTIME_HEARTBEAT_TTL_MS, Math.max(45_000, intervalMs * 3));
|
||||
|
||||
if (!['once', 'serve'].includes(action)) {
|
||||
console.error('Usage: node scripts/runtime-worker-heartbeat.mjs <once|serve>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const workers = targetWorkers();
|
||||
if (workers.length === 0) {
|
||||
console.error('No TKMIND_API_TARGETS configured');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const client = createClient({ url: redisUrl });
|
||||
client.on('error', (err) => {
|
||||
console.error(`Redis error: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
let stopping = false;
|
||||
const stop = async () => {
|
||||
stopping = true;
|
||||
await client.quit().catch(() => {});
|
||||
};
|
||||
process.on('SIGTERM', () => void stop().finally(() => process.exit(0)));
|
||||
process.on('SIGINT', () => void stop().finally(() => process.exit(0)));
|
||||
|
||||
async function tick() {
|
||||
const result = await writeHeartbeats(client, namespace, workers, { timeoutMs, ttlMs });
|
||||
const payload = {
|
||||
ok: result.every((worker) => worker.ok),
|
||||
action,
|
||||
namespace,
|
||||
intervalMs: action === 'serve' ? intervalMs : undefined,
|
||||
timeoutMs,
|
||||
ttlMs,
|
||||
workers: result,
|
||||
};
|
||||
console.log(JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (action === 'once') {
|
||||
const payload = await tick();
|
||||
await client.quit();
|
||||
process.exit(payload.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
await tick();
|
||||
while (!stopping) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
if (!stopping) await tick().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 微信服务号自定义菜单(menu/create)。
|
||||
*
|
||||
* 变更规范:
|
||||
* - 只在本仓库修改 MENU 常量,禁止 SSH 到 105 直接改线上脚本。
|
||||
* - 流程:本地改码 → Git commit → 正式发布 → 再执行本脚本同步到微信 API。
|
||||
* - 详见 docs/105-server-operations.md
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
loadH5Environment(__dirname);
|
||||
|
||||
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
||||
const DEFAULT_MENU_CREATE_URL = 'https://api.weixin.qq.com/cgi-bin/menu/create';
|
||||
const DEFAULT_MENU_GET_URL = 'https://api.weixin.qq.com/cgi-bin/menu/get';
|
||||
|
||||
const MENU = {
|
||||
button: [
|
||||
{
|
||||
type: 'view',
|
||||
name: 'TKMind',
|
||||
url: 'https://mm.tkmind.cn',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
name: 'M空间',
|
||||
url: 'https://mm.tkmind.cn/space',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
name: 'M发现',
|
||||
url: 'https://plaza.tkmind.cn',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function endpointFromTokenUrl(pathname, fallback) {
|
||||
const tokenUrl = process.env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL;
|
||||
try {
|
||||
const url = new URL(tokenUrl);
|
||||
url.pathname = pathname;
|
||||
url.search = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(response) {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
const appid =
|
||||
process.env.H5_WECHAT_MP_APP_ID?.trim() ?? process.env.H5_WECHAT_APP_ID?.trim() ?? '';
|
||||
const secret =
|
||||
process.env.H5_WECHAT_MP_APP_SECRET?.trim() ?? process.env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
||||
if (!appid || !secret) {
|
||||
throw new Error(
|
||||
'缺少 H5_WECHAT_MP_APP_ID / H5_WECHAT_MP_APP_SECRET(或 H5_WECHAT_APP_ID / H5_WECHAT_APP_SECRET)',
|
||||
);
|
||||
}
|
||||
|
||||
const tokenUrl = process.env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL;
|
||||
const payload = await readJson(
|
||||
await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credential',
|
||||
appid,
|
||||
secret,
|
||||
force_refresh: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (!payload?.access_token) {
|
||||
throw new Error(payload?.errmsg || `获取 access_token 失败 (${payload?.errcode ?? 'unknown'})`);
|
||||
}
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
async function createMenu(accessToken) {
|
||||
const baseUrl =
|
||||
process.env.H5_WECHAT_MP_MENU_CREATE_URL?.trim() ||
|
||||
endpointFromTokenUrl('/cgi-bin/menu/create', DEFAULT_MENU_CREATE_URL);
|
||||
const url = `${baseUrl}?access_token=${encodeURIComponent(accessToken)}`;
|
||||
const payload = await readJson(
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(MENU),
|
||||
}),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||||
throw new Error(payload?.errmsg || `创建菜单失败 (${payload?.errcode})`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function getMenu(accessToken) {
|
||||
const baseUrl =
|
||||
process.env.H5_WECHAT_MP_MENU_GET_URL?.trim() ||
|
||||
endpointFromTokenUrl('/cgi-bin/menu/get', DEFAULT_MENU_GET_URL);
|
||||
const url = `${baseUrl}?access_token=${encodeURIComponent(accessToken)}`;
|
||||
return readJson(await fetch(url, { method: 'GET' }));
|
||||
}
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
if (dryRun) {
|
||||
console.log(JSON.stringify(MENU, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
const created = await createMenu(accessToken);
|
||||
const current = await getMenu(accessToken).catch((err) => ({ warning: err.message }));
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
created,
|
||||
menu: MENU,
|
||||
current,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
+4613
-1840
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .
|
||||
## 何时使用
|
||||
|
||||
- 用户要 Word / docx / .doc 文档(输出 `.docx`)
|
||||
- 需要保存到 `oa/`、`private/` 等分区
|
||||
- 需要保存到 `oa/`、`private/`、`public/` 等分区
|
||||
|
||||
## 推荐命令
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ def table_block(headers: list[str], rows: list[list[str]]) -> str:
|
||||
"</w:tblBorders>"
|
||||
"</w:tblPr>"
|
||||
)
|
||||
# Table must be a direct child of w:body — never wrap w:tbl inside w:p.
|
||||
parts = ["<w:tbl>", tbl_pr, f"<w:tblGrid>{grid_cols}</w:tblGrid>"]
|
||||
if headers:
|
||||
parts.append(table_row(headers))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: long-image-download
|
||||
description: 用 Playwright 将 MindSpace HTML 页面生成同名 PNG 长图,并返回可打开图片链接与附件下载链接
|
||||
---
|
||||
|
||||
# 长图下载(Playwright)
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求“长图下载”“保存长图”“页面图片格式”“整页 PNG”
|
||||
- 已经或即将用 `static-page-publish` 生成 `public/*.html`
|
||||
|
||||
## 必须流程
|
||||
|
||||
1. 先确保 HTML 已经用 `write_file` / `edit_file` 写入 `public/<页面名>.html`
|
||||
2. 调用 sandbox-fs 工具 `generate_long_image`:
|
||||
|
||||
```json
|
||||
{
|
||||
"html_path": "public/report.html",
|
||||
"output_path": "public/report.long.png"
|
||||
}
|
||||
```
|
||||
|
||||
3. 用 `list_dir public` 确认 `<页面名>.long.png` 已存在
|
||||
4. 回复用户时同时给:
|
||||
- 页面链接:`[页面标题](.../public/report.html)`
|
||||
- 长图预览链接:`[长图预览](.../public/report.long.png)`
|
||||
- 长图下载链接:`[下载长图](.../public/report.html?download=long-image)`
|
||||
|
||||
## 规则
|
||||
|
||||
- `generate_long_image` 使用平台 Playwright,不要用 shell、自写截图脚本、`html2canvas` 或 `.thumbnail.svg` 冒充长图
|
||||
- `.thumbnail.svg` 只是信息流封面,不是整页长图
|
||||
- 长图文件名推荐与 HTML 同名:`report.html` -> `report.long.png`
|
||||
- 没有确认 `.long.png` 已生成时,不要声称“长图已生成”
|
||||
@@ -14,13 +14,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
## 规则摘要
|
||||
|
||||
0. 可以用 `apps__create_app` 设计/预览页面,但那一步只是在 Apps 窗口内生成交互式 App,**还没有公网链接**;只要用户要「可访问的链接」「分享出去」,最后必须把内容 `write_file` 落到 `public/*.html`,按下方「回复格式」给出真实链接,不要停在 App 阶段就回复链接
|
||||
1. 只在**当前用户工作区**(会话 `working_dir`)内读写与搜索,从 `.` 开始
|
||||
2. 查找 CSV/文档时只用相对路径(如 `oa/report.csv`),**禁止**去上级目录、MindSpace 根目录、其它用户目录或主机路径搜索
|
||||
3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替
|
||||
4. **禁止**用 `shell` / `cat` / `heredoc` / `echo` / `cp` 写入 `public/*.html`;HTML 必须用 `write_file` / `edit_file`(shell 在容器内执行,公网链接会 404)
|
||||
5. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件
|
||||
6. 静态文件保存即可访问,**无需重启**
|
||||
7. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
|
||||
7. 默认只生成 HTML;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件
|
||||
8. 只有用户明确要求 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
|
||||
9. 只有用户明确要求**长图下载**时:必须先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成同目录 `public/<页面名>.long.png`,再返回长图预览与下载链接;禁止把 `.thumbnail.svg` 当成长图
|
||||
|
||||
详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。
|
||||
|
||||
@@ -31,6 +34,17 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
3. 在 `<head>` 写入 **mindspace-cover**(必须与页面主题一致,见下文)
|
||||
4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`(Agent 交互阶段即生效)
|
||||
5. 按「回复格式」返回**可点击**公网链接
|
||||
6. 若用户明确要求 Word/docx 下载,必须用 `generate_docx`(sandbox-fs 工具)生成 `public/<同名>.docx`,再确认链接目标已落盘
|
||||
7. 若用户明确要求长图下载,必须用 `long-image-download` 生成 `public/<同名>.long.png`,并确认文件存在
|
||||
|
||||
## 按需伴生下载文件
|
||||
|
||||
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成
|
||||
- `<a href="report.docx" download>` 等相对下载链接,目标文件必须已在 HTML 同目录或子目录
|
||||
- 推荐 `public/report.html` + `public/report.docx`;**禁止** HTML 链接名与磁盘文件名不一致
|
||||
- 生成 Word 时必须调用 sandbox-fs 的 `generate_docx`;**禁止**用 `computercontroller` / shell 生成生产下载文件作为交付依据
|
||||
- 从 `oa/` 引用文档时,先 **复制** 到 `public/` 再写链接
|
||||
- 交付前 `list_dir public/` 自检;可跑 `npm run check:mindspace-public-links`
|
||||
|
||||
## 回复格式(必须)
|
||||
|
||||
@@ -44,9 +58,11 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
- **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」
|
||||
- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致)
|
||||
- 域名严格按本节模板拼接(`https://goo.tkmind.cn/MindSpace/<用户ID>/public/...`);拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名
|
||||
- 标题用页面真实主题名
|
||||
- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`)
|
||||
- 说明:保存即生效,无需重启
|
||||
- 若生成了长图,同时给 `[长图预览](.../public/malaysia-travel-guide.long.png)` 和 `[下载长图](.../public/malaysia-travel-guide.html?download=long-image)`
|
||||
|
||||
## 信息流预览图(必须)
|
||||
|
||||
@@ -80,16 +96,17 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
## 平台页脚标记(必须)
|
||||
|
||||
页脚平台联系行**必须**使用 `data-mindspace-page-tag="platform-brand"`,且邮箱/域名只用 **tkmind.cn**(如 `contact@tkmind.cn`),**禁止** `tkmind.ai`:
|
||||
页脚平台品牌行**必须**使用 `data-mindspace-page-tag="platform-brand"`,显示为 **TKMind · 智趣**,**禁止**使用邮箱或 `tkmind.ai`:
|
||||
|
||||
```html
|
||||
<p data-mindspace-page-tag="platform-brand">📧 contact@tkmind.cn</p>
|
||||
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
|
||||
```
|
||||
|
||||
带 `data-mindspace-page-tag` 的区域为平台固定信息:用户在编辑模式中不可见、不可改;预览与发布后正常显示。
|
||||
|
||||
## 附带文件下载(Word / PDF)
|
||||
## 按需附带文件下载(Word / PDF)
|
||||
|
||||
- 只有用户明确要求 Word / PDF 下载时才生成二进制文件
|
||||
- 二进制文件用 `docx-generate` 脚本或平台允许的方式**单独生成**,保存到 `public/`(或 `oa/` 再复制到 `public/`)
|
||||
- 下载按钮示例:`<a href="report.docx" download>下载文档</a>`(与 HTML 同目录时用文件名即可)
|
||||
- **禁止** `<a href="data:application/vnd...;base64,...">` 内嵌 docx/pdf
|
||||
|
||||
@@ -26,3 +26,9 @@ description: 网页抓取与搜索技能:访问网页、查阅文档、搜索
|
||||
2. `extract_text: true`(默认)获取可读文本,`false` 获取原始 HTML
|
||||
3. 不要访问不明来源的链接,向用户确认后再访问
|
||||
4. 官方文档优先于第三方博客
|
||||
|
||||
## 国内网络环境(建议)
|
||||
|
||||
- 生产环境访问不了 `google.com`,优先用本技能的 `web_search`(DuckDuckGo)/`fetch_url`,避免直接拿 `computercontroller__web_scrape` 抓 Google 页面来回重试
|
||||
- `web_search` 连续几次没有可用结果时,改用 `fetch_url` 直接访问 `https://cn.bing.com/search?q=...` 或 `https://www.so.com/s?q=...` 这类国内可达的搜索入口
|
||||
- 百度/知乎/大众点评等站点有反爬拦截,遇到跳转或空结果就换个搜索源,不必在同一个来源上反复硬抓
|
||||
|
||||
Reference in New Issue
Block a user