From 092f66f6011f658886832cbc25bfc6d9473e0260 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 15:46:46 +0200 Subject: [PATCH] feat: add TaskTree L1 native shell --- web/hwlab-cloud-web/package.json | 4 + .../scripts/tasktree-native-service.ts | 88 ++++++++++++++++++ .../scripts/tasktree-native-supervisor.ts | 58 ++++++++++++ .../scripts/tasktree-native-vite.config.ts | 91 +++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 web/hwlab-cloud-web/scripts/tasktree-native-service.ts create mode 100644 web/hwlab-cloud-web/scripts/tasktree-native-supervisor.ts create mode 100644 web/hwlab-cloud-web/scripts/tasktree-native-vite.config.ts diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index b7a86872..719d8da4 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -6,6 +6,10 @@ "scripts": { "deps": "node ../../scripts/worktree-deps.mjs --web", "dev": "bun run deps --quiet && vite", + "tasktree:native:start": "bun run scripts/tasktree-native-service.ts start", + "tasktree:native:stop": "bun run scripts/tasktree-native-service.ts stop", + "tasktree:native:status": "bun run scripts/tasktree-native-service.ts status", + "tasktree:native:logs": "bun run scripts/tasktree-native-service.ts logs", "check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts", "check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts", "check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict", diff --git a/web/hwlab-cloud-web/scripts/tasktree-native-service.ts b/web/hwlab-cloud-web/scripts/tasktree-native-service.ts new file mode 100644 index 00000000..45dada89 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/tasktree-native-service.ts @@ -0,0 +1,88 @@ +import { mkdirSync, openSync, readFileSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; + +const action = process.argv[2]; +const root = process.cwd(); +const stateDir = resolve(root, ".state/tasktree-native"); +const pidPath = resolve(stateDir, "supervisor.pid"); +const logPath = resolve(stateDir, "supervisor.log"); +const servicesLogPath = resolve(stateDir, "services.log"); + +function readPid(): number | null { + try { + const pid = Number(readFileSync(pidPath, "utf8").trim()); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isRunning(pid: number | null): boolean { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (!isRunning(pid)) return true; + await Bun.sleep(25); + } + return !isRunning(pid); +} + +mkdirSync(stateDir, { recursive: true }); +const pid = readPid(); + +if (action === "start") { + if (isRunning(pid)) { + console.log(JSON.stringify({ ok: true, action, status: "already-running", pid, logPath })); + process.exit(0); + } + rmSync(pidPath, { force: true }); + const logFd = openSync(logPath, "a"); + const child = Bun.spawn([process.execPath, "scripts/tasktree-native-supervisor.ts"], { + cwd: root, + env: process.env, + stdin: "ignore", + stdout: logFd, + stderr: logFd, + detached: true + }); + child.unref(); + await Bun.write(pidPath, `${child.pid}\n`); + console.log(JSON.stringify({ ok: true, action, status: "started", pid: child.pid, logPath })); +} else if (action === "stop") { + if (!isRunning(pid)) { + rmSync(pidPath, { force: true }); + console.log(JSON.stringify({ ok: true, action, status: "stopped" })); + } else { + process.kill(pid!, "SIGTERM"); + if (!await waitForExit(pid!)) { + console.error(JSON.stringify({ ok: false, action, status: "stop-timeout", pid })); + process.exit(1); + } + rmSync(pidPath, { force: true }); + console.log(JSON.stringify({ ok: true, action, status: "stopped", pid })); + } +} else if (action === "status") { + const running = isRunning(pid); + console.log(JSON.stringify({ ok: running, action, status: running ? "running" : "stopped", pid: running ? pid : null, logPath, servicesLogPath })); + process.exit(running ? 0 : 1); +} else if (action === "logs") { + for (const path of [logPath, servicesLogPath]) { + try { + console.log(readFileSync(path, "utf8").trimEnd().split("\n").slice(-80).join("\n")); + } catch { + // A log is optional until its process has started. + } + } +} else { + console.error("usage: bun run scripts/tasktree-native-service.ts "); + process.exit(2); +} diff --git a/web/hwlab-cloud-web/scripts/tasktree-native-supervisor.ts b/web/hwlab-cloud-web/scripts/tasktree-native-supervisor.ts new file mode 100644 index 00000000..22393237 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/tasktree-native-supervisor.ts @@ -0,0 +1,58 @@ +import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const webRoot = process.cwd(); +const repoRoot = resolve(webRoot, "../.."); +const stateDir = resolve(webRoot, ".state/tasktree-native"); +const databaseEnvFile = process.env.TASKTREE_NATIVE_DATABASE_ENV_FILE; +const temporalAddress = process.env.TASKTREE_TEMPORAL_ADDRESS; + +if (!databaseEnvFile) throw new Error("TASKTREE_NATIVE_DATABASE_ENV_FILE is required"); +if (!temporalAddress) throw new Error("TASKTREE_TEMPORAL_ADDRESS is required"); + +function readEnvValue(path: string, key: string): string { + const line = readFileSync(path, "utf8") + .split(/\r?\n/u) + .find((candidate) => candidate.startsWith(`${key}=`)); + if (!line) throw new Error(`${key} is missing from the configured env source`); + const raw = line.slice(key.length + 1).trim(); + if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) return raw.slice(1, -1); + return raw; +} + +mkdirSync(stateDir, { recursive: true }); +const logFd = openSync(resolve(stateDir, "services.log"), "a"); +const databaseUrl = readEnvValue(databaseEnvFile, "DATABASE_URL"); +const commonEnv = { + ...process.env, + TASKTREE_DATABASE_URL: databaseUrl, + TASKTREE_TEMPORAL_ADDRESS: temporalAddress, + TASKTREE_TEMPORAL_NAMESPACE: process.env.TASKTREE_TEMPORAL_NAMESPACE ?? "unidesk", + TASKTREE_TEMPORAL_TASK_QUEUE: process.env.TASKTREE_TEMPORAL_TASK_QUEUE ?? "hwlab-v03-tasktree" +}; + +const children = [ + Bun.spawn([process.execPath, "cmd/hwlab-tasktree-worker/main.ts"], { cwd: repoRoot, env: commonEnv, stdin: "ignore", stdout: logFd, stderr: logFd }), + Bun.spawn([process.execPath, "cmd/hwlab-tasktree-api/main.ts"], { cwd: repoRoot, env: { ...commonEnv, TASKTREE_API_HOST: "127.0.0.1", TASKTREE_API_PORT: "6673" }, stdin: "ignore", stdout: logFd, stderr: logFd }), + Bun.spawn([process.execPath, "x", "vite", "--config", "scripts/tasktree-native-vite.config.ts"], { cwd: webRoot, env: { ...process.env, HWLAB_TASKTREE_NATIVE_API_URL: "http://127.0.0.1:6673" }, stdin: "ignore", stdout: logFd, stderr: logFd }) +]; + +process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-tasktree-native", status: "started", pids: children.map((child) => child.pid), valuesPrinted: false })}\n`); + +let stopping = false; +async function stop(): Promise { + if (stopping) return; + stopping = true; + for (const child of children) child.kill("SIGTERM"); + await Promise.allSettled(children.map((child) => child.exited)); + closeSync(logFd); + process.exit(0); +} + +process.once("SIGINT", stop); +process.once("SIGTERM", stop); +const exited = await Promise.race(children.map(async (child, index) => ({ index, code: await child.exited }))); +if (!stopping) { + process.stderr.write(`${JSON.stringify({ serviceId: "hwlab-tasktree-native", status: "child-exited", ...exited })}\n`); + await stop(); +} diff --git a/web/hwlab-cloud-web/scripts/tasktree-native-vite.config.ts b/web/hwlab-cloud-web/scripts/tasktree-native-vite.config.ts new file mode 100644 index 00000000..ab0227d7 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/tasktree-native-vite.config.ts @@ -0,0 +1,91 @@ +import { fileURLToPath, URL } from "node:url"; + +import vue from "@vitejs/plugin-vue"; +import { defineConfig, type Plugin } from "vite"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const tasktreeApi = process.env.HWLAB_TASKTREE_NATIVE_API_URL ?? "http://127.0.0.1:6673"; +const webHost = process.env.TASKTREE_NATIVE_WEB_HOST ?? "127.0.0.1"; +const webPort = Number.parseInt(process.env.TASKTREE_NATIVE_WEB_PORT ?? "4173", 10); + +const authPayload = { + authenticated: true, + mode: "server", + authMethod: "native-web", + identityAuthority: "hwlab-local", + sessionKind: "browser", + actor: { id: "usr_tasktree_native", username: "tasktree-native", role: "admin", status: "active" }, + capabilities: { web: "available", admin: "available" }, + access: { nav: { profileId: "tasktree-native", allowedIds: ["project.tasktree"], valuesRedacted: true } }, + expiresAt: null, + valuesRedacted: true +}; + +function nativeAuth(): Plugin { + return { + name: "hwlab-tasktree-native-auth", + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url === "/auth/logout") { + response.writeHead(204, { "cache-control": "no-store" }); + response.end(); + return; + } + if (request.url !== "/auth/session" && request.url !== "/auth/bootstrap" && request.url !== "/auth/login") return next(); + response.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + ...(request.url === "/auth/login" ? { "set-cookie": "hwlab_session=tasktree-native; Path=/; HttpOnly; SameSite=Lax" } : {}) + }); + response.end(JSON.stringify(authPayload)); + }); + } + }; +} + +function nativeRuntimeConfig(): Plugin { + return { + name: "hwlab-tasktree-native-runtime-config", + transformIndexHtml() { + return [{ + tag: "script", + injectTo: "head-prepend", + children: `window.HWLAB_CLOUD_WEB_CONFIG = { + displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" }, + workbench: { + realtimeFeatures: { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }, + debugCapabilities: { isolatedKafka: false, rawHwlabEventWindow: { enabled: false, maxEntries: 1, maxRetainedBytes: 1 } }, + traceTimeline: { autoExpandRunning: false, autoCollapseTerminal: false } + } + };` + }]; + } + }; +} + +export default defineConfig({ + root, + plugins: [nativeRuntimeConfig(), nativeAuth(), vue()], + resolve: { + alias: { + "@": fileURLToPath(new URL("../src", import.meta.url)) + } + }, + server: { + host: webHost, + port: webPort, + strictPort: true, + hmr: { clientPort: webPort }, + watch: { + usePolling: true, + interval: 300, + ignored: ["**/node_modules/**", "**/.git/**", "**/.state/**"] + }, + proxy: { + "/v1/tasktree": { + target: tasktreeApi, + changeOrigin: false + } + } + } +});