diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts index 1b75cbc0..0848b82f 100644 --- a/tools/hwlab-cli/bin/hwlab-cli.ts +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -27,10 +27,10 @@ if (argv[0] === "tasktree") { process.exitCode = 1; } } else if (argv[0] === "hwpod" && argv[1] === "service") { - const { hwpodNativeServiceCommand, hwpodNativeServicesStatus } = await import("../../src/hwpod-native-service.ts"); + const { hwpodNativeServiceCommand, hwpodNativeServicesCommand } = await import("../../src/hwpod-native-service.ts"); try { - const result = argv[2] === "status" && argv[3] === undefined - ? await hwpodNativeServicesStatus({ cwd: process.cwd(), env: process.env }) + const result = ["start", "stop", "restart", "status", "logs"].includes(argv[2] ?? "") && argv[3] === undefined + ? await hwpodNativeServicesCommand({ action: argv[2], cwd: process.cwd(), env: process.env }) : await hwpodNativeServiceCommand({ service: argv[2], action: argv[3] ?? "status", cwd: process.cwd(), env: process.env }); console.log(JSON.stringify(result, null, 2)); process.exitCode = result?.ok === false ? 1 : 0; diff --git a/tools/src/hwpod-native-service.test.ts b/tools/src/hwpod-native-service.test.ts index f0d448ce..9e8b0e18 100644 --- a/tools/src/hwpod-native-service.test.ts +++ b/tools/src/hwpod-native-service.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { hwpodNativeServicesStatus } from "./hwpod-native-service.ts"; +import { hwpodNativeServicesCommand, hwpodNativeServicesStatus } from "./hwpod-native-service.ts"; test("HWPOD aggregate status reports API, worker, and Web together", async () => { const cwd = await mkdtemp(path.join(tmpdir(), "hwpod-native-status-")); @@ -23,3 +23,15 @@ test("HWPOD aggregate status reports API, worker, and Web together", async () => await rm(cwd, { recursive: true, force: true }); } }); + +test("aggregate stop owns all HWPOD native services", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "hwpod-native-stop-")); + try { + const result = await hwpodNativeServicesCommand({ action: "stop", cwd, env: { HWPOD_NATIVE_SERVICE_STATE_DIR: ".state/services" } }); + expect(result.ok).toBe(true); + expect(Object.keys(result.services).sort()).toEqual(["api", "web", "worker"]); + expect(result.summary.completed).toBe(3); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/tools/src/hwpod-native-service.ts b/tools/src/hwpod-native-service.ts index 3f6329d6..39102078 100644 --- a/tools/src/hwpod-native-service.ts +++ b/tools/src/hwpod-native-service.ts @@ -24,6 +24,52 @@ export async function hwpodNativeServicesStatus(input: { cwd: string; env: Recor }; } +export async function hwpodNativeServicesCommand(input: { action: string; cwd: string; env: Record }) { + if (input.action === "status") return hwpodNativeServicesStatus(input); + if (input.action === "logs") { + const results = await Promise.all(SERVICE_NAMES.map((service) => hwpodNativeServiceCommand({ service, action: "logs", ...input }))); + return aggregateResult("logs", results); + } + if (input.action === "stop") { + const results = []; + for (const service of [...SERVICE_NAMES].reverse()) results.push(await hwpodNativeServiceCommand({ service, action: "stop", ...input })); + return aggregateResult("stop", results); + } + if (input.action === "start" || input.action === "restart") { + const stopped = []; + if (input.action === "restart") { + for (const service of [...SERVICE_NAMES].reverse()) stopped.push(await hwpodNativeServiceCommand({ service, action: "stop", ...input })); + } + const started = []; + for (const service of SERVICE_NAMES) started.push(await hwpodNativeServiceCommand({ service, action: "start", ...input })); + const readiness = await waitForServicesReady(input); + return { ...aggregateResult(input.action, started), stopped, readiness, ok: readiness.ok, status: readiness.status }; + } + throw codedError("unsupported_service_action", `unsupported aggregate HWPOD service action: ${input.action}`); +} + +function aggregateResult(action: string, results: any[]) { + const ok = results.every((result) => result.ok !== false); + return { + ok, + operation: `hwpod.service.${action}`, + status: ok ? "completed" : "failed", + summary: { total: SERVICE_NAMES.length, completed: results.filter((result) => result.ok !== false).length, failed: results.filter((result) => result.ok === false).length }, + services: Object.fromEntries(results.map((result) => [result.service, result])), + valuesPrinted: false, + }; +} + +async function waitForServicesReady(input: { cwd: string; env: Record }) { + const deadline = Date.now() + 30_000; + let current = await hwpodNativeServicesStatus(input); + while (!current.ok && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + current = await hwpodNativeServicesStatus(input); + } + return current; +} + export async function hwpodNativeServiceCommand(input: { service: string; action: string; cwd: string; env: Record }) { const service = validService(input.service); const stateDir = path.resolve(input.cwd, requiredEnv(input.env, "HWPOD_NATIVE_SERVICE_STATE_DIR"));