#!/usr/bin/env bun import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { runTaskTreeCli } from "../tools/src/tasktree-cli.ts"; const repoRoot = resolve(import.meta.dir, ".."); const databaseEnvFile = requiredEnv("TASKTREE_NATIVE_DATABASE_ENV_FILE"); const databaseUrl = readEnvValue(databaseEnvFile, "DATABASE_URL"); const temporalAddress = requiredEnv("TASKTREE_TEMPORAL_ADDRESS"); const stateDir = mkdtempSync(join(tmpdir(), "tasktree-backup-l1-")); const apiPort = positiveInteger(process.env.TASKTREE_API_PORT, 6673); const workerPort = positiveInteger(process.env.TASKTREE_WORKER_HEALTH_PORT, 6674); const apiUrl = `http://127.0.0.1:${apiPort}`; 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", TASKTREE_API_HOST: "127.0.0.1", TASKTREE_API_PORT: String(apiPort), TASKTREE_WORKER_HEALTH_HOST: "127.0.0.1", TASKTREE_WORKER_HEALTH_PORT: String(workerPort), TASKTREE_BACKUP_ENABLED: "true", TASKTREE_BACKUP_WORKTREE: join(stateDir, "repo"), }; const cliEnv = { ...commonEnv, HWLAB_TASKTREE_API_URL: apiUrl }; const children = []; let smokeGroupId = ""; let childrenStopped = false; let readiness = {}; try { event("children-starting"); children.push(spawn("cmd/hwlab-tasktree-worker/main.ts")); children.push(spawn("cmd/hwlab-tasktree-api/main.ts")); await waitUntil(async () => { const [api, worker] = await Promise.all([ health(`${apiUrl}/health/ready`), health(`http://127.0.0.1:${workerPort}/health/ready`), ]); readiness = { api, worker }; return api.ok && worker.ok; }, 30_000, () => `TaskTree native API and worker did not become ready: ${JSON.stringify(readiness)}`); event("children-ready"); const initialStatus = await waitForBackupSuccess(); event("initial-backup-ready"); const initialSha = initialStatus.state.contentSha256; const initialCommit = initialStatus.state.gitCommit; const verify = successful(await runTaskTreeCli(["backup", "verify", "--overapi"], cliEnv)); if (verify.verified !== true) throw new Error("TaskTree --overapi backup verify did not match PostgreSQL"); const restore = successful(await runTaskTreeCli(["backup", "restore", "--overapi"], cliEnv)); if (restore.dryRun !== true || restore.restored !== false) throw new Error("TaskTree restore did not default to dry-run"); event("overapi-verified"); const group = successful(await runTaskTreeCli( ["group", "create", "--name", `Backup L1 smoke ${Date.now()}`], cliEnv, )); smokeGroupId = group.id; event("smoke-group-created"); const changedStatus = await waitUntil(async () => { const status = successful(await runTaskTreeCli(["backup", "status", "--overapi"], cliEnv)); return status.state.status === "succeeded" && status.state.contentSha256 !== initialSha ? status : null; }, 30_000, "automatic backup did not capture the smoke mutation"); const changedSha = changedStatus.state.contentSha256; event("smoke-mutation-backed-up"); successful(await runTaskTreeCli(["group", "delete", "--group", smokeGroupId], cliEnv)); smokeGroupId = ""; const restoredStatus = await waitUntil(async () => { const status = successful(await runTaskTreeCli(["backup", "status", "--overapi"], cliEnv)); return status.state.status === "succeeded" && status.state.contentSha256 === initialSha ? status : null; }, 30_000, "automatic backup did not capture smoke cleanup"); event("smoke-cleanup-backed-up"); console.log(JSON.stringify({ ok: true, action: "tasktree-backup-native-l1-smoke", mutation: true, l1: { apiReady: true, workerReady: true, temporalConnected: true, automaticBackup: true, overapiVerify: true, restoreDryRun: true, }, initialSha, initialCommit, changedSha, restoredSha: restoredStatus.state.contentSha256, valuesPrinted: false, }, null, 2)); } catch (error) { await stopChildren(); const diagnostics = []; for (const child of children) { diagnostics.push({ pid: child.pid, exitCode: await child.exited, }); } process.stderr.write(`${JSON.stringify({ ok: false, action: "tasktree-backup-native-l1-smoke", error: error instanceof Error ? error.message : String(error), readiness, diagnostics, valuesPrinted: false, }, null, 2)}\n`); throw error; } finally { if (smokeGroupId) { await runTaskTreeCli(["group", "delete", "--group", smokeGroupId], cliEnv).catch(() => undefined); } await stopChildren(); rmSync(stateDir, { recursive: true, force: true }); } function spawn(entrypoint) { const env = entrypoint.includes("api") ? { ...commonEnv, TASKTREE_BACKUP_WORKTREE: `${commonEnv.TASKTREE_BACKUP_WORKTREE}-api` } : commonEnv; return Bun.spawn([process.execPath, entrypoint], { cwd: repoRoot, env, stdin: "ignore", stdout: "inherit", stderr: "inherit", }); } async function stopChildren() { if (childrenStopped) return; childrenStopped = true; for (const child of children) child.kill("SIGTERM"); await Promise.allSettled(children.map((child) => child.exited)); } async function waitForBackupSuccess() { return await waitUntil(async () => { const status = successful(await runTaskTreeCli(["backup", "status", "--overapi"], cliEnv)); return status.state.status === "succeeded" && status.state.contentSha256 ? status : null; }, 40_000, "automatic TaskTree backup did not succeed"); } async function waitUntil(check, timeoutMs, message) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const result = await check(); if (result) return result; await Bun.sleep(500); } throw new Error(typeof message === "function" ? message() : message); } async function health(url) { try { const response = await fetch(url); const body = await response.json().catch(() => null); return { ok: response.ok && body?.ok === true, status: response.status, error: null }; } catch (error) { return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) }; } } function successful(result) { if (result?.ok !== true) { const error = new Error(result?.error?.message ?? "TaskTree command failed"); error.code = result?.error?.code ?? "tasktree_error"; throw error; } return result.data; } function readEnvValue(path, key) { 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; } function requiredEnv(name) { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); return value; } function positiveInteger(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function event(stage) { process.stderr.write(`${JSON.stringify({ action: "tasktree-backup-native-l1-smoke", stage })}\n`); }