7ddfd51139
Create legion-config.jsonc beside local state and route its DNS TTL, recursive resolver, and ACME directory settings through runtime services.
891 lines
26 KiB
TypeScript
891 lines
26 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { execFile } from "node:child_process"
|
|
import { randomUUID } from "node:crypto"
|
|
import { mkdtemp, rm, stat } from "node:fs/promises"
|
|
import { createConnection, type Socket } from "node:net"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
import test from "node:test"
|
|
|
|
import {
|
|
createDaemonLegionApiClient,
|
|
createDaemonLegionCliClient,
|
|
getReachableDaemonMetadata,
|
|
getDaemonStatus,
|
|
runDaemonApplyCommand,
|
|
readDaemonMetadata,
|
|
renderDaemonTokenExport,
|
|
startLegionDaemonServer,
|
|
stopDaemon
|
|
} from "../../src/engine/daemon"
|
|
import { LegionEngineError } from "../../src/engine/errors"
|
|
import { createDaemonLineParser, encodeDaemonMessage } from "../../src/engine/daemon-protocol"
|
|
import type { LegionEnginePaths } from "../../src/engine/paths"
|
|
|
|
test("daemon server runs structured CLI calls over local transport", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const calls: string[] = []
|
|
const server = await startLegionDaemonServer(paths, {
|
|
cli: {
|
|
async materialize() {
|
|
calls.push("materialize")
|
|
return { activity: null }
|
|
}
|
|
} as never
|
|
})
|
|
|
|
try {
|
|
const metadata = await readDaemonMetadata(paths.daemonMetadataPath)
|
|
assert.ok(metadata)
|
|
assert.equal("token" in metadata, false)
|
|
if (process.platform === "win32") {
|
|
assert.ok(server.endpoint.startsWith("\\\\.\\pipe\\legion-kk-"))
|
|
} else {
|
|
assert.equal((await stat(root)).mode & 0o777, 0o700)
|
|
assert.equal((await stat(paths.daemonMetadataPath)).mode & 0o777, 0o600)
|
|
assert.ok(server.endpoint.startsWith(join(tmpdir(), "legion-kk-")))
|
|
assert.ok(server.endpoint.length < 100)
|
|
assert.equal((await stat(server.endpoint)).mode & 0o777, 0o600)
|
|
}
|
|
|
|
const client = await createDaemonLegionCliClient(paths, { token: server.token })
|
|
assert.ok(client)
|
|
assert.deepEqual(await client.materialize(), { activity: null })
|
|
assert.deepEqual(calls, ["materialize"])
|
|
|
|
let stdout = ""
|
|
const statusCode = await getDaemonStatus(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: () => true }
|
|
},
|
|
{ token: server.token }
|
|
)
|
|
assert.equal(statusCode, 0)
|
|
assert.match(stdout, /operation: .* completed cli\.materialize/)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon apply command starts materialization asynchronously by default", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
let releaseMaterialize!: () => void
|
|
const materializeReleased = new Promise<void>((resolve) => {
|
|
releaseMaterialize = resolve
|
|
})
|
|
const server = await startLegionDaemonServer(paths, {
|
|
cli: {
|
|
async materialize() {
|
|
await materializeReleased
|
|
return { activity: null }
|
|
}
|
|
} as never
|
|
})
|
|
|
|
try {
|
|
let stdout = ""
|
|
let stderr = ""
|
|
const exitCode = await runDaemonApplyCommand(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{ token: server.token, json: true }
|
|
)
|
|
|
|
assert.equal(exitCode, 0)
|
|
assert.equal(stderr, "")
|
|
const output = JSON.parse(stdout) as { status: string; operationId: string }
|
|
assert.equal(output.status, "running")
|
|
assert.match(output.operationId, /.+/)
|
|
|
|
let statusOut = ""
|
|
assert.equal(
|
|
await getDaemonStatus(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((statusOut += chunk), true) },
|
|
stderr: { write: () => true }
|
|
},
|
|
{ token: server.token }
|
|
),
|
|
0
|
|
)
|
|
assert.match(
|
|
statusOut,
|
|
new RegExp(`operation: ${output.operationId} running cli\\.materialize`)
|
|
)
|
|
|
|
releaseMaterialize()
|
|
} finally {
|
|
releaseMaterialize()
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon apply command waits for materialization when requested", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
let completed = false
|
|
const server = await startLegionDaemonServer(paths, {
|
|
cli: {
|
|
async materialize() {
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
completed = true
|
|
return { activity: null }
|
|
}
|
|
} as never
|
|
})
|
|
|
|
try {
|
|
let stdout = ""
|
|
let stderr = ""
|
|
const exitCode = await runDaemonApplyCommand(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{ token: server.token, json: true, wait: true }
|
|
)
|
|
|
|
assert.equal(exitCode, 0)
|
|
assert.equal(stderr, "")
|
|
assert.equal(completed, true)
|
|
const output = JSON.parse(stdout) as { status: string; operationId: string }
|
|
assert.equal(output.status, "materialized")
|
|
assert.match(output.operationId, /.+/)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon apply wait tolerates transient unreachable status polls", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
let releaseMaterialize!: () => void
|
|
const materializeReleased = new Promise<void>((resolve) => {
|
|
releaseMaterialize = resolve
|
|
})
|
|
const server = await startLegionDaemonServer(paths, {
|
|
cli: {
|
|
async materialize() {
|
|
await materializeReleased
|
|
return { activity: null }
|
|
}
|
|
} as never
|
|
})
|
|
const previousGrace = process.env["LEGION_DAEMON_OPERATION_UNREACHABLE_GRACE_MS"]
|
|
process.env["LEGION_DAEMON_OPERATION_UNREACHABLE_GRACE_MS"] = "25"
|
|
|
|
try {
|
|
let stderr = ""
|
|
const startedAt = Date.now()
|
|
const apply = runDaemonApplyCommand(
|
|
paths,
|
|
{
|
|
stdout: { write: () => true },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{ token: server.token, wait: true }
|
|
)
|
|
|
|
await waitForCondition(async () => {
|
|
let stdout = ""
|
|
const statusCode = await getDaemonStatus(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: () => true }
|
|
},
|
|
{ token: server.token }
|
|
)
|
|
return statusCode === 0 && /operation: .* running cli\.materialize/.test(stdout)
|
|
})
|
|
await server.close()
|
|
|
|
await assert.rejects(apply, /Legion daemon is unreachable\./)
|
|
assert.equal(stderr, "")
|
|
assert.ok(Date.now() - startedAt >= 25)
|
|
} finally {
|
|
if (previousGrace === undefined) {
|
|
delete process.env["LEGION_DAEMON_OPERATION_UNREACHABLE_GRACE_MS"]
|
|
} else {
|
|
process.env["LEGION_DAEMON_OPERATION_UNREACHABLE_GRACE_MS"] = previousGrace
|
|
}
|
|
releaseMaterialize()
|
|
await server.close().catch(() => undefined)
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon apply trace forwards node streams", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
let publishActivity: (payload: unknown) => void = () => undefined
|
|
const server = await startLegionDaemonServer(paths, {
|
|
cli: {
|
|
async materialize() {
|
|
publishActivity({
|
|
nodeStreams: {
|
|
"node-a": [
|
|
{
|
|
sequence: 1,
|
|
occurredAt: "2026-06-08T00:00:00.000Z",
|
|
nodeId: "node-a",
|
|
stream: "stdout",
|
|
text: "install stdout\n"
|
|
},
|
|
{
|
|
sequence: 2,
|
|
occurredAt: "2026-06-08T00:00:01.000Z",
|
|
nodeId: "node-a",
|
|
stream: "stderr",
|
|
text: "install stderr\n"
|
|
}
|
|
]
|
|
}
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
return { activity: null }
|
|
}
|
|
} as never
|
|
})
|
|
publishActivity = (payload) => server.publishEvent("activity:update", payload)
|
|
|
|
try {
|
|
let stdout = ""
|
|
let stderr = ""
|
|
const exitCode = await runDaemonApplyCommand(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{ token: server.token, trace: true }
|
|
)
|
|
|
|
assert.equal(exitCode, 0)
|
|
assert.match(stdout, /install stdout/)
|
|
assert.match(stdout, /materialized:/)
|
|
assert.match(stderr, /install stderr/)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon server keeps structured CLI calls open until completion", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, {
|
|
cli: {
|
|
async materialize() {
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
return { activity: null }
|
|
}
|
|
} as never
|
|
})
|
|
|
|
try {
|
|
const client = await createDaemonLegionCliClient(paths, { token: server.token })
|
|
assert.ok(client)
|
|
assert.deepEqual(await client.materialize(), { activity: null })
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon server runs structured engine calls and publishes events", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, {
|
|
engine: {
|
|
async getBootstrapState() {
|
|
return {
|
|
snapshot: null,
|
|
activity: null,
|
|
auth: { locked: true, availableMethods: ["password"] },
|
|
daemon: null
|
|
}
|
|
}
|
|
} as never
|
|
})
|
|
const events: Array<{ channel: string; payload: unknown }> = []
|
|
|
|
try {
|
|
const client = await createDaemonLegionApiClient(paths, {
|
|
token: server.token,
|
|
onEvent: (channel, payload) => events.push({ channel, payload })
|
|
})
|
|
assert.ok(client)
|
|
assert.deepEqual(await client.getBootstrapState(), {
|
|
snapshot: null,
|
|
activity: null,
|
|
auth: { locked: true, availableMethods: ["password"] },
|
|
daemon: null
|
|
})
|
|
|
|
server.publishEvent("activity:update", { title: "Activity" })
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
assert.deepEqual(events, [{ channel: "activity:update", payload: { title: "Activity" } }])
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon client preserves structured app errors", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, {
|
|
engine: {
|
|
async performServerAction() {
|
|
throw new LegionEngineError(
|
|
"OPERATION_BUSY",
|
|
"Wait for the current operation to finish.",
|
|
"validation",
|
|
true,
|
|
{
|
|
workflowState: "apply",
|
|
requestedState: "performServerAction"
|
|
}
|
|
)
|
|
}
|
|
} as never
|
|
})
|
|
|
|
try {
|
|
const client = await createDaemonLegionApiClient(paths, { token: server.token })
|
|
assert.ok(client)
|
|
|
|
await assert.rejects(
|
|
() => client.performServerAction({ serverId: "server-a", action: "reboot" }),
|
|
(error) => {
|
|
assert.equal((error as { code?: string }).code, "OPERATION_BUSY")
|
|
assert.equal((error as { category?: string }).category, "validation")
|
|
assert.equal((error as { retryable?: boolean }).retryable, true)
|
|
assert.deepEqual((error as { details?: unknown }).details, {
|
|
workflowState: "apply",
|
|
requestedState: "performServerAction"
|
|
})
|
|
assert.equal((error as Error).message, "Wait for the current operation to finish.")
|
|
return true
|
|
}
|
|
)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon event subscription reconnects after daemon restart", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
let server = await startLegionDaemonServer(paths, {
|
|
engine: {
|
|
async getBootstrapState() {
|
|
return {
|
|
snapshot: null,
|
|
activity: null,
|
|
auth: { locked: true, availableMethods: ["password"] },
|
|
daemon: null
|
|
}
|
|
}
|
|
} as never
|
|
})
|
|
const token = server.token
|
|
const events: Array<{ channel: string; payload: unknown }> = []
|
|
|
|
try {
|
|
const client = await createDaemonLegionApiClient(paths, {
|
|
token,
|
|
onEvent: (channel, payload) => events.push({ channel, payload })
|
|
})
|
|
assert.ok(client)
|
|
|
|
await server.close()
|
|
server = await startLegionDaemonServer(
|
|
paths,
|
|
{
|
|
engine: {
|
|
async getBootstrapState() {
|
|
return {
|
|
snapshot: null,
|
|
activity: null,
|
|
auth: { locked: true, availableMethods: ["password"] },
|
|
daemon: null
|
|
}
|
|
}
|
|
} as never
|
|
},
|
|
{ token }
|
|
)
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 750))
|
|
server.publishEvent("activity:update", { title: "After restart" })
|
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
|
|
assert.deepEqual(events, [{ channel: "activity:update", payload: { title: "After restart" } }])
|
|
} finally {
|
|
await server.close().catch(() => undefined)
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon buffers terminal events while a subscriber is backpressured", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, {
|
|
engine: {
|
|
async getBootstrapState() {
|
|
return {
|
|
snapshot: null,
|
|
activity: null,
|
|
auth: { locked: true, availableMethods: ["password"] },
|
|
daemon: null
|
|
}
|
|
}
|
|
} as never
|
|
})
|
|
let socket: Socket | null = null
|
|
const terminalEvents: unknown[] = []
|
|
|
|
try {
|
|
const subscription = await openRawDaemonSubscription(server.endpoint, server.token, (event) => {
|
|
if (event.channel === "terminal:data") {
|
|
terminalEvents.push(event.payload)
|
|
}
|
|
})
|
|
socket = subscription.socket
|
|
socket.pause()
|
|
|
|
const payloads = [0, 1, 2].map((sequence) => ({
|
|
sessionId: "session-a",
|
|
chunk: `${sequence}:${"x".repeat(512 * 1024)}`,
|
|
sequence
|
|
}))
|
|
|
|
for (const payload of payloads) {
|
|
server.publishEvent("terminal:data", payload)
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
socket.resume()
|
|
|
|
await waitForCondition(async () => terminalEvents.length === payloads.length)
|
|
assert.deepEqual(
|
|
terminalEvents.map((event) => (event as { sequence: number }).sequence),
|
|
[0, 1, 2]
|
|
)
|
|
} finally {
|
|
socket?.destroy()
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon token export renders shell syntax for each platform", () => {
|
|
assert.equal(
|
|
renderDaemonTokenExport("abc'123", "darwin"),
|
|
"export LEGION_DAEMON_TOKEN='abc'\\''123'"
|
|
)
|
|
assert.equal(renderDaemonTokenExport("abc'123", "win32"), "$env:LEGION_DAEMON_TOKEN = 'abc''123'")
|
|
})
|
|
|
|
test("reachable daemon metadata detects an already running daemon without the token", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, { cli: {} as never })
|
|
|
|
try {
|
|
const metadata = await getReachableDaemonMetadata(paths)
|
|
assert.ok(metadata)
|
|
assert.equal(metadata.endpoint, server.endpoint)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon start refuses to spawn a second daemon for a running state directory", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, { cli: {} as never })
|
|
|
|
try {
|
|
const result = await execLegionCli(["daemon", "start"], {
|
|
LEGION_STATE_DIR: root,
|
|
LEGION_CACHE_DIR: root,
|
|
LEGION_APP_ROOT: process.cwd()
|
|
})
|
|
|
|
assert.equal(result.code, 1)
|
|
assert.equal(result.stdout, "")
|
|
assert.match(result.stderr, /daemon: already running/)
|
|
assert.match(result.stderr, /Refusing to start a second daemon/)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test(
|
|
"daemon start launches a reachable detached daemon on Windows",
|
|
{ skip: process.platform !== "win32" },
|
|
async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const token = `detached-${randomUUID()}`
|
|
const env = {
|
|
LEGION_STATE_DIR: root,
|
|
LEGION_CACHE_DIR: root,
|
|
LEGION_APP_ROOT: process.cwd()
|
|
}
|
|
|
|
try {
|
|
const start = await execLegionCli(["--daemon-token", token, "daemon", "start"], env)
|
|
|
|
assert.equal(start.code, 0)
|
|
assert.match(start.stdout, /daemon: running/)
|
|
assert.match(start.stdout, /pid: \d+/)
|
|
assert.match(start.stdout, /log: /)
|
|
assert.equal(start.stderr, "")
|
|
|
|
const status = await execLegionCli(["--daemon-token", token, "daemon", "status"], env)
|
|
assert.equal(status.code, 0)
|
|
assert.match(status.stdout, /daemon: running/)
|
|
assert.equal(status.stderr, "")
|
|
} finally {
|
|
await execLegionCli(["--daemon-token", token, "daemon", "stop", "--force"], env).catch(
|
|
() => undefined
|
|
)
|
|
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
|
}
|
|
}
|
|
)
|
|
|
|
test("daemon-required CLI commands report a reachable daemon without an exported token", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, { cli: {} as never })
|
|
|
|
try {
|
|
const result = await execLegionCli(["node", "start", "node-a"], {
|
|
LEGION_STATE_DIR: root,
|
|
LEGION_CACHE_DIR: root,
|
|
LEGION_APP_ROOT: process.cwd(),
|
|
LEGION_DAEMON_TOKEN: ""
|
|
})
|
|
|
|
assert.equal(result.code, 1)
|
|
assert.equal(result.stdout, "")
|
|
assert.match(result.stderr, /LEGION_DAEMON_TOKEN is not set/)
|
|
assert.match(result.stderr, /close the GUI\/current daemon/)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon client requires an explicit token when metadata exists", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, { cli: {} as never })
|
|
|
|
try {
|
|
await assert.rejects(() => createDaemonLegionCliClient(paths), /LEGION_DAEMON_TOKEN/)
|
|
} finally {
|
|
await server.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon status and stop commands report the running server", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, { cli: {} as never })
|
|
|
|
let stdout = ""
|
|
let stderr = ""
|
|
|
|
try {
|
|
const statusCode = await getDaemonStatus(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{
|
|
token: server.token
|
|
}
|
|
)
|
|
|
|
assert.equal(statusCode, 0)
|
|
assert.match(stdout, /daemon: running/)
|
|
assert.equal(stderr, "")
|
|
|
|
stdout = ""
|
|
const stopCode = await stopDaemon(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{
|
|
token: server.token
|
|
}
|
|
)
|
|
|
|
assert.equal(stopCode, 0)
|
|
assert.match(stdout, /daemon: stopping/)
|
|
} finally {
|
|
await server.close().catch(() => undefined)
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon stop requires force when engine operations are active", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const server = await startLegionDaemonServer(paths, {
|
|
engine: {
|
|
getActiveOperations() {
|
|
return [
|
|
{
|
|
id: "stream-session-a",
|
|
kind: "sender-stream",
|
|
label: "Sender stream stream-session-a",
|
|
detail: "running",
|
|
startedAt: "2026-06-17T10:00:00.000Z"
|
|
}
|
|
]
|
|
}
|
|
} as never
|
|
})
|
|
|
|
let stdout = ""
|
|
let stderr = ""
|
|
|
|
try {
|
|
const blockedCode = await stopDaemon(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{
|
|
token: server.token
|
|
}
|
|
)
|
|
|
|
assert.equal(blockedCode, 1)
|
|
assert.equal(stdout, "")
|
|
assert.match(stderr, /The Legion daemon has active operations:/)
|
|
assert.match(stderr, /Sender stream stream-session-a \(running\)/)
|
|
assert.match(stderr, /daemon stop --force/)
|
|
|
|
stdout = ""
|
|
stderr = ""
|
|
const forcedCode = await stopDaemon(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{
|
|
token: server.token,
|
|
force: true
|
|
}
|
|
)
|
|
|
|
assert.equal(forcedCode, 0)
|
|
assert.match(stdout, /daemon: stopping/)
|
|
assert.equal(stderr, "")
|
|
} finally {
|
|
await server.close().catch(() => undefined)
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("daemon stop requires force for a GUI-owned daemon", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "legion-daemon-test-"))
|
|
const paths = createPaths(root)
|
|
const events: { channel: string; payload: unknown }[] = []
|
|
const server = await startLegionDaemonServer(
|
|
paths,
|
|
{
|
|
cli: {} as never
|
|
},
|
|
{
|
|
owner: "gui"
|
|
}
|
|
)
|
|
const subscription = await openRawDaemonSubscription(server.endpoint, server.token, (event) => {
|
|
events.push(event)
|
|
})
|
|
|
|
let stdout = ""
|
|
let stderr = ""
|
|
|
|
try {
|
|
const blockedCode = await stopDaemon(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{
|
|
token: server.token
|
|
}
|
|
)
|
|
|
|
assert.equal(blockedCode, 1)
|
|
assert.equal(stdout, "")
|
|
assert.match(stderr, /belongs to a running GUI session/)
|
|
assert.match(stderr, /daemon stop --force/)
|
|
assert.equal((await getReachableDaemonMetadata(paths))?.pid, process.pid)
|
|
|
|
stdout = ""
|
|
stderr = ""
|
|
const forcedCode = await stopDaemon(
|
|
paths,
|
|
{
|
|
stdout: { write: (chunk: string) => ((stdout += chunk), true) },
|
|
stderr: { write: (chunk: string) => ((stderr += chunk), true) }
|
|
},
|
|
{
|
|
token: server.token,
|
|
force: true
|
|
}
|
|
)
|
|
|
|
assert.equal(forcedCode, 0)
|
|
assert.match(stdout, /daemon: stopping/)
|
|
assert.equal(stderr, "")
|
|
await waitForCondition(async () =>
|
|
events.some(
|
|
(event) =>
|
|
event.channel === "daemon:shutdown" &&
|
|
JSON.stringify(event.payload) ===
|
|
JSON.stringify({ owner: "gui", forced: true, reason: "requested" })
|
|
)
|
|
)
|
|
} finally {
|
|
subscription.socket.destroy()
|
|
await server.close().catch(() => undefined)
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
async function waitForCondition(predicate: () => Promise<boolean>): Promise<void> {
|
|
const deadline = Date.now() + 1_000
|
|
while (Date.now() < deadline) {
|
|
if (await predicate()) {
|
|
return
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
}
|
|
assert.equal(await predicate(), true)
|
|
}
|
|
|
|
async function openRawDaemonSubscription(
|
|
endpoint: string,
|
|
token: string,
|
|
onEvent: (event: { channel: string; payload: unknown }) => void
|
|
): Promise<{ socket: Socket }> {
|
|
const socket = createConnection(endpoint)
|
|
let resolveSubscribed!: () => void
|
|
let rejectSubscribed!: (error: unknown) => void
|
|
const subscribed = new Promise<void>((resolve, reject) => {
|
|
resolveSubscribed = resolve
|
|
rejectSubscribed = reject
|
|
})
|
|
const parser = createDaemonLineParser((raw) => {
|
|
const response = raw as {
|
|
type: string
|
|
channel?: string
|
|
payload?: unknown
|
|
message?: string
|
|
}
|
|
|
|
if (response.type === "subscribed") {
|
|
resolveSubscribed()
|
|
return
|
|
}
|
|
|
|
if (response.type === "event" && response.channel) {
|
|
onEvent({ channel: response.channel, payload: response.payload })
|
|
return
|
|
}
|
|
|
|
if (response.type === "error") {
|
|
rejectSubscribed(new Error(response.message ?? "Daemon subscription failed."))
|
|
}
|
|
})
|
|
|
|
socket.on("connect", () => {
|
|
socket.write(
|
|
encodeDaemonMessage({
|
|
type: "subscribe",
|
|
id: randomUUID(),
|
|
token
|
|
})
|
|
)
|
|
})
|
|
socket.on("data", (chunk) => parser.push(chunk))
|
|
socket.on("error", rejectSubscribed)
|
|
|
|
await subscribed
|
|
return { socket }
|
|
}
|
|
|
|
function createPaths(root: string): LegionEnginePaths {
|
|
return {
|
|
stateFilePath: join(root, "legion-state.json"),
|
|
configFilePath: join(root, "legion-config.jsonc"),
|
|
providerCatalogCachePath: join(root, "provider-catalogs.json"),
|
|
embeddedCatalogPath: join(root, "embedded-provider-catalogs.json"),
|
|
logDirPath: join(root, "logs"),
|
|
daemonMetadataPath: join(root, "daemon.json")
|
|
}
|
|
}
|
|
|
|
async function execLegionCli(
|
|
args: string[],
|
|
env: Record<string, string>
|
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
return await new Promise((resolve, reject) => {
|
|
execFile(
|
|
process.execPath,
|
|
["--import", "tsx", join(process.cwd(), "src", "engine", "cli-main.ts"), ...args],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
...env
|
|
}
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (error && typeof (error as { code?: unknown }).code !== "number") {
|
|
reject(error)
|
|
return
|
|
}
|
|
|
|
resolve({
|
|
code: error ? (error as { code: number }).code : 0,
|
|
stdout,
|
|
stderr
|
|
})
|
|
}
|
|
)
|
|
})
|
|
}
|