Skip to content

Commit 0b4a69e

Browse files
authored
Merge pull request #84 from odefun/feat/process-daemon-4385
Add Ode daemon supervisor
2 parents dd4ef8c + d8f93b5 commit 0b4a69e

File tree

6 files changed

+665
-15
lines changed

6 files changed

+665
-15
lines changed

packages/core/cli.ts

Lines changed: 204 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,49 @@
11
#!/usr/bin/env bun
22

3+
import { spawn } from "child_process";
4+
import { closeSync, openSync, readSync, statSync } from "fs";
35
import packageJson from "../../package.json" with { type: "json" };
4-
import { isInstalledBinary, performUpgrade } from "./upgrade";
5-
import { runOnboarding } from "./onboarding";
6+
import { getWebHost, getWebPort } from "@/config";
7+
import { runDaemon } from "@/core/daemon/manager";
8+
import { getDaemonLogPath } from "@/core/daemon/paths";
9+
import { isProcessAlive, readDaemonState, type DaemonState } from "@/core/daemon/state";
10+
import { runOnboarding } from "@/core/onboarding";
11+
import { isInstalledBinary, performUpgrade } from "@/core/upgrade";
612

7-
const args = process.argv.slice(2);
13+
const rawArgs = process.argv.slice(2);
814
const CURRENT_VERSION = packageJson.version ?? "0.0.0";
15+
const CLI_ENTRY = new URL(import.meta.url).pathname;
16+
const BUN_EXECUTABLE: string = process.argv[0] ?? process.execPath;
17+
const READY_WAIT_MS = 2 * 60 * 1000;
18+
const READY_POLL_MS = 500;
19+
const LOG_TAIL_BYTES = 200_000;
20+
const LOG_TAIL_LINES = 40;
21+
22+
const foregroundRequested = rawArgs.includes("--foreground");
23+
const args = foregroundRequested
24+
? rawArgs.filter((arg) => arg !== "--foreground")
25+
: rawArgs;
26+
const command = args[0];
927

1028
function printHelp(): void {
11-
// Keep this minimal; runtime.ts runs local mode by default.
1229
console.log(
1330
[
1431
"ode - OpenCode Slack bot",
1532
"",
1633
"Usage:",
17-
" ode [--local]",
34+
" ode [--foreground]",
35+
" ode status",
36+
" ode restart",
1837
" ode onboarding",
1938
" ode upgrade",
2039
" ode --version",
2140
"",
2241
"Examples:",
23-
" ode --local",
24-
" ode onboarding",
25-
" ode upgrade",
26-
].join("\n")
42+
" ode",
43+
" ode status",
44+
" ode restart",
45+
" ode --foreground",
46+
].join("\n"),
2747
);
2848
}
2949

@@ -42,24 +62,195 @@ async function upgrade(): Promise<void> {
4262
console.log("ode upgraded.");
4363
}
4464

65+
function getLocalSettingsUrl(): string {
66+
const host = getWebHost();
67+
const port = getWebPort();
68+
return `http://${host}:${port}/local-setting`;
69+
}
70+
71+
function fallbackReadyMessage(): string {
72+
return `Ode is ready! Waiting for messages, setting UI is accessible at ${getLocalSettingsUrl()}`;
73+
}
74+
75+
function delay(ms: number): Promise<void> {
76+
return new Promise((resolve) => {
77+
setTimeout(resolve, ms);
78+
});
79+
}
80+
81+
function daemonState(): DaemonState {
82+
return readDaemonState();
83+
}
84+
85+
function managerRunning(state: DaemonState = daemonState()): boolean {
86+
return isProcessAlive(state.managerPid);
87+
}
88+
89+
function runtimeRunning(state: DaemonState = daemonState()): boolean {
90+
return isProcessAlive(state.runtimePid);
91+
}
92+
93+
function ensureDaemonRunning(): void {
94+
const state = daemonState();
95+
if (managerRunning(state)) return;
96+
const child = spawn(BUN_EXECUTABLE, [CLI_ENTRY, "daemon"], {
97+
detached: true,
98+
stdio: "ignore",
99+
});
100+
child.unref();
101+
}
102+
103+
async function waitForReadyMessage(timeoutMs: number): Promise<string | null> {
104+
const startedAt = Date.now();
105+
while (Date.now() - startedAt < timeoutMs) {
106+
const state = daemonState();
107+
if (state.status === "ready" && typeof state.readyMessage === "string" && state.readyMessage.length > 0 && managerRunning(state)) {
108+
return state.readyMessage;
109+
}
110+
if (!managerRunning(state)) {
111+
ensureDaemonRunning();
112+
}
113+
await delay(READY_POLL_MS);
114+
}
115+
return null;
116+
}
117+
118+
async function startBackground(): Promise<void> {
119+
const state = daemonState();
120+
if (state.status === "ready" && state.readyMessage && managerRunning(state)) {
121+
console.log(state.readyMessage);
122+
return;
123+
}
124+
ensureDaemonRunning();
125+
const readyMessage = await waitForReadyMessage(READY_WAIT_MS);
126+
if (readyMessage) {
127+
console.log(readyMessage);
128+
return;
129+
}
130+
console.log(`Ode daemon is still starting. Follow logs at ${getDaemonLogPath()}`);
131+
}
132+
133+
function tailLogs(maxLines: number): string[] {
134+
const logPath = getDaemonLogPath();
135+
try {
136+
const stats = statSync(logPath);
137+
if (stats.size === 0) return [];
138+
const bytes = Math.min(LOG_TAIL_BYTES, stats.size);
139+
const buffer = Buffer.alloc(Number(bytes));
140+
const fd = openSync(logPath, "r");
141+
try {
142+
readSync(fd, buffer, 0, Number(bytes), stats.size - bytes);
143+
} finally {
144+
closeSync(fd);
145+
}
146+
const content = buffer.toString("utf-8");
147+
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
148+
return lines.slice(-maxLines);
149+
} catch {
150+
return [];
151+
}
152+
}
153+
154+
function formatTimestamp(value: number | null): string {
155+
if (!value) return "n/a";
156+
return new Date(value).toLocaleString();
157+
}
158+
159+
async function showStatus(): Promise<void> {
160+
const state = daemonState();
161+
const daemonStatus = managerRunning(state) ? `running (pid ${state.managerPid})` : "stopped";
162+
const runtimeStatus = runtimeRunning(state) ? `running (pid ${state.runtimePid})` : "stopped";
163+
console.log(`Daemon: ${daemonStatus}`);
164+
console.log(`Runtime: ${runtimeStatus}`);
165+
console.log(`Last start: ${formatTimestamp(state.lastStartAt)}`);
166+
console.log(`Last ready: ${formatTimestamp(state.lastReadyAt)}`);
167+
if (state.pendingUpgradeRestart) {
168+
console.log(
169+
`Pending upgrade restart since ${formatTimestamp(state.pendingUpgradeRestart.scheduledAt)} (${state.pendingUpgradeRestart.reason})`,
170+
);
171+
}
172+
const logs = tailLogs(LOG_TAIL_LINES);
173+
if (logs.length === 0) {
174+
console.log(`No logs yet. Log file: ${getDaemonLogPath()}`);
175+
return;
176+
}
177+
console.log(`Recent logs (${getDaemonLogPath()}):`);
178+
console.log(logs.join("\n"));
179+
}
180+
181+
async function restartDaemonCommand(): Promise<void> {
182+
const state = daemonState();
183+
if (!managerRunning(state)) {
184+
console.log("Daemon not running. Starting a new daemon instance...");
185+
ensureDaemonRunning();
186+
const ready = await waitForReadyMessage(READY_WAIT_MS);
187+
console.log(ready ?? `Restart requested. Follow logs at ${getDaemonLogPath()}`);
188+
return;
189+
}
190+
191+
if (runtimeRunning(state) && state.runtimePid) {
192+
try {
193+
process.kill(state.runtimePid, "SIGTERM");
194+
console.log(`Sent shutdown signal to runtime (pid ${state.runtimePid}).`);
195+
} catch (error) {
196+
console.warn(`Failed to signal runtime (pid ${state.runtimePid}): ${String(error)}`);
197+
}
198+
} else {
199+
console.log("Runtime is not currently running; waiting for daemon to restart.");
200+
}
201+
202+
const ready = await waitForReadyMessage(READY_WAIT_MS);
203+
console.log(ready ?? `Restart requested. Follow logs at ${getDaemonLogPath()}`);
204+
}
205+
45206
if (args.includes("--help") || args.includes("-h")) {
46207
printHelp();
47208
process.exit(0);
48209
}
49210

50-
if (args.includes("--version") || args[0] === "version") {
211+
if (command === "__runtime") {
212+
await import("./index");
213+
process.exit(0);
214+
}
215+
216+
if (command === "daemon") {
217+
await runDaemon();
218+
process.exit(0);
219+
}
220+
221+
if (args.includes("--version") || command === "version") {
51222
console.log(CURRENT_VERSION);
52223
process.exit(0);
53224
}
54225

55-
if (args[0] === "upgrade") {
226+
if (command === "upgrade") {
56227
await upgrade();
57228
process.exit(0);
58229
}
59230

60-
if (args[0] === "onboarding") {
231+
if (command === "onboarding") {
61232
await runOnboarding({ force: true });
62233
process.exit(0);
63234
}
64235

65-
await import("./index");
236+
if (command === "status") {
237+
await showStatus();
238+
process.exit(0);
239+
}
240+
241+
if (command === "restart") {
242+
await restartDaemonCommand();
243+
process.exit(0);
244+
}
245+
246+
if (command === "start") {
247+
await startBackground();
248+
process.exit(0);
249+
}
250+
251+
if (foregroundRequested) {
252+
await import("./index");
253+
process.exit(0);
254+
}
255+
256+
await startBackground();

packages/core/daemon/control.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { patchDaemonState } from "./state";
2+
import type { DaemonState } from "./state";
3+
4+
function safePatch(updater: () => Partial<DaemonState>): void {
5+
try {
6+
patchDaemonState(updater());
7+
} catch {
8+
// Best effort only; ignore persistence errors.
9+
}
10+
}
11+
12+
export function markRuntimeReady(message: string): void {
13+
safePatch(() => ({
14+
readyMessage: message,
15+
status: "ready",
16+
lastReadyAt: Date.now(),
17+
}));
18+
}
19+
20+
export function clearRuntimeReadyState(): void {
21+
safePatch(() => ({
22+
readyMessage: null,
23+
lastReadyAt: null,
24+
status: "starting",
25+
}));
26+
}
27+
28+
export function scheduleUpgradeRestart(reason: string): void {
29+
safePatch(() => ({
30+
pendingUpgradeRestart: {
31+
reason,
32+
scheduledAt: Date.now(),
33+
},
34+
}));
35+
}
36+
37+
export function clearPendingUpgradeRestart(): void {
38+
safePatch(() => ({ pendingUpgradeRestart: null }));
39+
}

0 commit comments

Comments
 (0)