Skip to content

Commit 572ab26

Browse files
authored
Merge pull request #23 from hack-dance/symphony/HACK-434-program-runtime-sessions-and-remote-beta-hardeni
HACK-434: Harden runtime, sessions, and remote beta flows
2 parents be27890 + cf8236c commit 572ab26

8 files changed

Lines changed: 217 additions & 53 deletions

File tree

src/commands/daemon.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@ import { type DaemonPaths, resolveDaemonPaths } from "../daemon/paths.ts";
2020
import { removeFileIfExists, waitForProcessExit } from "../daemon/process.ts";
2121
import { runDaemon } from "../daemon/server.ts";
2222
import {
23+
buildDaemonRepairMessage,
2324
buildDaemonStatusReport,
2425
type DaemonStatusReport,
2526
readDaemonStatus,
2627
} from "../daemon/status.ts";
2728
import { updateGlobalConfig } from "../lib/config.ts";
2829
import { pathExists, readTextFile } from "../lib/fs.ts";
2930
import { resolveHackInvocation } from "../lib/hack-cli.ts";
31+
import {
32+
buildDockerStatusProbe,
33+
detectDockerBackend,
34+
} from "../lib/runtime-guidance.ts";
3035
import { logger } from "../ui/logger.ts";
3136

3237
const optForeground = defineOption({
@@ -309,6 +314,8 @@ async function handleDaemonStatus({
309314
apiCompatible: api.compatible,
310315
});
311316
const launchdStatus = await resolveLaunchdStatus({ paths });
317+
const dockerBackend = await detectDockerBackend();
318+
const dockerStatus = await buildDockerStatusProbe();
312319

313320
if (args.options.json) {
314321
outputDaemonStatusJson({
@@ -322,6 +329,8 @@ async function handleDaemonStatus({
322329
return reportDaemonStatus({
323330
report,
324331
launchdStatus,
332+
dockerBackendName: dockerBackend?.name ?? null,
333+
dockerReachable: dockerStatus.reachable,
325334
});
326335
}
327336

@@ -386,6 +395,8 @@ function outputDaemonStatusJson(opts: {
386395
function reportDaemonStatus(opts: {
387396
readonly report: DaemonStatusReport;
388397
readonly launchdStatus: LaunchdServiceStatus | null;
398+
readonly dockerBackendName: string | null;
399+
readonly dockerReachable: boolean;
389400
}): number {
390401
const { report, launchdStatus } = opts;
391402
if (report.status === "running") {
@@ -412,9 +423,12 @@ function reportDaemonStatus(opts: {
412423
}
413424

414425
logger.warn({
415-
message: report.stale
416-
? `hackd stopped (stale state detected; run \`${report.nextStep}\`)`
417-
: "hackd is not running",
426+
message: buildDaemonRepairMessage({
427+
report,
428+
launchdStatus,
429+
dockerBackendName: opts.dockerBackendName,
430+
dockerReachable: opts.dockerReachable,
431+
}),
418432
});
419433
logLaunchdStatus({ launchdStatus, running: false });
420434
return 1;

src/commands/global.ts

Lines changed: 10 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ import {
6666
reconcileRemoteCaddyRoutesStack,
6767
stopRemoteCaddyRoutesStack,
6868
} from "../lib/remote-caddy-routes.ts";
69+
import {
70+
detectDockerBackend,
71+
formatDockerConnectionGuidance,
72+
} from "../lib/runtime-guidance.ts";
6973
import { exec, execOrThrow, findExecutableInPath, run } from "../lib/shell.ts";
7074
import { resolveSessionsMuxMode } from "../mux/mux-config.ts";
7175
import {
@@ -299,7 +303,12 @@ async function ensureDockerRunning(): Promise<void> {
299303
const backend = await detectDockerBackend();
300304
if (!backend) {
301305
throw new Error(
302-
"Docker does not seem to be running and no Docker backend was detected.\nInstall Docker Desktop or OrbStack, then retry."
306+
formatDockerConnectionGuidance({
307+
backend,
308+
failureText:
309+
"Docker does not seem to be running and no Docker backend was detected.",
310+
retryCommand: "hack global install",
311+
})
303312
);
304313
}
305314

@@ -326,50 +335,6 @@ async function ensureDockerRunning(): Promise<void> {
326335

327336
logger.success({ message: `${backend.name} is running` });
328337
}
329-
330-
type DockerBackend = {
331-
readonly name: string;
332-
readonly startCommand: readonly string[];
333-
};
334-
335-
/**
336-
* Detects the installed Docker backend on macOS (OrbStack, Docker Desktop)
337-
* or checks for the docker socket on Linux.
338-
*/
339-
async function detectDockerBackend(): Promise<DockerBackend | null> {
340-
if (isMac()) {
341-
if (await pathExists("/Applications/OrbStack.app")) {
342-
const hasOrbctl = await findExecutableInPath("orbctl");
343-
return {
344-
name: "OrbStack",
345-
startCommand: hasOrbctl
346-
? ["orbctl", "start"]
347-
: ["open", "-a", "OrbStack"],
348-
};
349-
}
350-
if (await pathExists("/Applications/Docker.app")) {
351-
return {
352-
name: "Docker Desktop",
353-
startCommand: ["open", "-a", "Docker"],
354-
};
355-
}
356-
return null;
357-
}
358-
359-
const hasDocker = await findExecutableInPath("docker");
360-
if (hasDocker) {
361-
const hasSystemctl = await findExecutableInPath("systemctl");
362-
if (hasSystemctl) {
363-
return {
364-
name: "Docker (systemd)",
365-
startCommand: ["sudo", "systemctl", "start", "docker"],
366-
};
367-
}
368-
}
369-
370-
return null;
371-
}
372-
373338
async function waitForDocker(opts: {
374339
readonly timeoutMs: number;
375340
readonly intervalMs: number;

src/daemon/status.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export interface DaemonStatusReport {
3434
readonly nextStep: string | null;
3535
}
3636

37+
type DaemonRepairLaunchdStatus = {
38+
readonly installed: boolean;
39+
readonly loaded: boolean;
40+
readonly running: boolean;
41+
readonly pid: number | null;
42+
readonly exitStatus: number | null;
43+
};
44+
3745
export async function readDaemonStatus({
3846
paths,
3947
}: {
@@ -166,3 +174,69 @@ export function buildDaemonStatusReport(opts: {
166174
nextStep: "hack daemon start",
167175
};
168176
}
177+
178+
export function buildDaemonRepairMessage(opts: {
179+
readonly report: DaemonStatusReport;
180+
readonly launchdStatus: DaemonRepairLaunchdStatus | null;
181+
readonly dockerBackendName: string | null;
182+
readonly dockerReachable: boolean;
183+
}): string {
184+
if (opts.report.status === "running") {
185+
return `hackd running (pid ${opts.report.pid ?? "unknown"})`;
186+
}
187+
188+
if (opts.report.status === "starting") {
189+
return `hackd starting (pid ${
190+
opts.report.pid ?? "unknown"
191+
}): API not responding yet | If this persists, check: hack daemon logs --tail 200`;
192+
}
193+
194+
if (opts.report.stale) {
195+
return "hackd stopped with stale local state | Run: hack daemon clear | Then run: hack daemon start";
196+
}
197+
198+
const crashExitStatus = opts.launchdStatus?.loaded
199+
? opts.launchdStatus.exitStatus
200+
: null;
201+
const startCommand =
202+
opts.launchdStatus?.loaded || crashExitStatus !== null
203+
? "hack daemon restart"
204+
: "hack daemon start";
205+
const segments = ["hackd is not running"];
206+
207+
if (crashExitStatus !== null && crashExitStatus !== 0) {
208+
segments.push(`launchd last exit status ${crashExitStatus}`);
209+
}
210+
211+
if (!opts.dockerReachable) {
212+
segments.push(
213+
buildDockerStartHint({ backendName: opts.dockerBackendName })
214+
);
215+
}
216+
217+
segments.push(`Run: ${startCommand}`);
218+
219+
if (crashExitStatus !== null && crashExitStatus !== 0) {
220+
segments.push("Then check: hack daemon logs --tail 200");
221+
}
222+
223+
return segments.join(" | ");
224+
}
225+
226+
function buildDockerStartHint(opts: {
227+
readonly backendName: string | null;
228+
}): string {
229+
if (opts.backendName === "Docker Desktop") {
230+
return "Start Docker Desktop";
231+
}
232+
233+
if (opts.backendName === "OrbStack") {
234+
return "Start OrbStack";
235+
}
236+
237+
if (opts.backendName === "Docker (systemd)") {
238+
return "Start the Docker system service";
239+
}
240+
241+
return "Start Docker";
242+
}

src/lib/config-paths.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ export function resolveGlobalConfigPath(): string {
1111
if (override.length > 0) {
1212
return override;
1313
}
14-
return resolve(homedir(), GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME);
14+
const home = (process.env.HOME ?? homedir()).trim();
15+
return resolve(home, GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME);
1516
}

src/mcp/agent-docs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ export function renderAgentDocsSnippet(): string {
234234
"- Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks.",
235235
"- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.",
236236
"",
237-
"Workspaces (mux-managed):",
237+
"Workspaces (mux-managed, tmux-first by default):",
238238
"- Picker: `hack session` for persistent project workspaces.",
239239
"- Reuse/create: `hack session start <project>`",
240240
"- Force isolated agent workspace: `hack session start <project> --new --name agent-1` (`<project>--agent-1`).",

tests/config-paths.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { afterEach, expect, test } from "bun:test";
2-
import { homedir } from "node:os";
32
import { resolve } from "node:path";
43

54
import { resolveGlobalConfigPath } from "@lib/config-paths.ts";
@@ -27,7 +26,7 @@ test("resolveGlobalConfigPath prefers HOME when no explicit override is set", ()
2726
process.env.HOME = "/tmp/hack-home";
2827

2928
expect(resolveGlobalConfigPath()).toBe(
30-
resolve(homedir(), GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME)
29+
resolve("/tmp/hack-home", GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME)
3130
);
3231
});
3332

tests/daemon-status.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { expect, test } from "bun:test";
22

3-
import { buildDaemonStatusReport } from "../src/daemon/status.ts";
3+
import {
4+
buildDaemonRepairMessage,
5+
buildDaemonStatusReport,
6+
} from "../src/daemon/status.ts";
47

58
test("buildDaemonStatusReport marks running when API is reachable", () => {
69
const report = buildDaemonStatusReport({
@@ -94,3 +97,71 @@ test("buildDaemonStatusReport marks incompatible daemon with guided restart", ()
9497
expect(report.nextStep).toBe("hack daemon restart");
9598
expect(report.stale).toBe(false);
9699
});
100+
101+
test("buildDaemonRepairMessage points stale state to daemon clear", () => {
102+
const report = buildDaemonStatusReport({
103+
pid: 123,
104+
processRunning: false,
105+
socketExists: true,
106+
logExists: true,
107+
apiReachable: false,
108+
apiCompatible: false,
109+
});
110+
111+
const message = buildDaemonRepairMessage({
112+
report,
113+
launchdStatus: null,
114+
dockerBackendName: null,
115+
dockerReachable: true,
116+
});
117+
118+
expect(message).toContain("hack daemon clear");
119+
});
120+
121+
test("buildDaemonRepairMessage calls out launchd crashes and restart guidance", () => {
122+
const report = buildDaemonStatusReport({
123+
pid: null,
124+
processRunning: false,
125+
socketExists: false,
126+
logExists: true,
127+
apiReachable: false,
128+
apiCompatible: false,
129+
});
130+
131+
const message = buildDaemonRepairMessage({
132+
report,
133+
launchdStatus: {
134+
installed: true,
135+
loaded: true,
136+
running: false,
137+
pid: null,
138+
exitStatus: 78,
139+
},
140+
dockerBackendName: null,
141+
dockerReachable: true,
142+
});
143+
144+
expect(message).toContain("last exit status 78");
145+
expect(message).toContain("hack daemon restart");
146+
});
147+
148+
test("buildDaemonRepairMessage tells Docker Desktop users to start Docker first", () => {
149+
const report = buildDaemonStatusReport({
150+
pid: null,
151+
processRunning: false,
152+
socketExists: false,
153+
logExists: false,
154+
apiReachable: false,
155+
apiCompatible: false,
156+
});
157+
158+
const message = buildDaemonRepairMessage({
159+
report,
160+
launchdStatus: null,
161+
dockerBackendName: "Docker Desktop",
162+
dockerReachable: false,
163+
});
164+
165+
expect(message).toContain("Start Docker Desktop");
166+
expect(message).toContain("hack daemon start");
167+
});

tests/project-views.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,46 @@ test("buildProjectViews includes matching project sessions from tmux", async ()
422422
expect(serializedSessions?.[0]?.source).toBe("hack");
423423
});
424424

425+
test("buildProjectViews treats double-dash and legacy colon hack sessions as project sessions", async () => {
426+
const alpha = await createProject({ name: "alpha", services: ["api"] });
427+
428+
const views = await buildProjectViews({
429+
registryProjects: [alpha],
430+
runtime: [],
431+
runtimeOk: true,
432+
filter: null,
433+
includeUnregistered: false,
434+
muxSessions: [
435+
{
436+
name: "alpha--agent-1",
437+
backend: "tmux",
438+
attached: false,
439+
path: null,
440+
windows: 1,
441+
createdAt: 1_735_000_100,
442+
},
443+
{
444+
name: "alpha:agent-legacy",
445+
backend: "tmux",
446+
attached: false,
447+
path: null,
448+
windows: 1,
449+
createdAt: 1_735_000_101,
450+
},
451+
],
452+
});
453+
454+
const alphaView = views.find((view) => view.name === "alpha");
455+
expect(alphaView?.sessions.map((session) => session.name)).toEqual([
456+
"alpha--agent-1",
457+
"alpha:agent-legacy",
458+
]);
459+
expect(alphaView?.sessions.map((session) => session.source)).toEqual([
460+
"hack",
461+
"hack",
462+
]);
463+
});
464+
425465
test("buildProjectViews matches tmux sessions when path is a symlink to repo root", async () => {
426466
const alpha = await createProject({ name: "alpha", services: ["api"] });
427467
if (!tempDir) {

0 commit comments

Comments
 (0)