feat: add TaskTree L1 native shell
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 <start|stop|status|logs>");
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user