Skip to content

Commit d2e6b37

Browse files
committed
feat: split local dev into explicit service controls
1 parent 57fda25 commit d2e6b37

File tree

14 files changed

+880
-217
lines changed

14 files changed

+880
-217
lines changed

AGENTS.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ All commands use pnpm. Target a single app with `pnpm --filter <package>`.
2424

2525
```bash
2626
pnpm install # Install
27-
pnpm dev # Local controller-first web stack (Controller + Web)
28-
pnpm dev:controller # Controller only
27+
pnpm dev start <service> # Start one local-dev service: desktop|openclaw|controller|web
28+
pnpm dev stop <service> # Stop one local-dev service
29+
pnpm dev restart <service> # Restart one local-dev service
30+
pnpm dev status <service> # Show status for one local-dev service
31+
pnpm dev logs <service> # Show active-session log tail (max 200 lines) for one local-dev service
32+
pnpm dev:controller # Legacy controller-only direct dev entrypoint
2933
pnpm start # Build and launch the desktop local runtime stack
3034
pnpm stop # Stop the desktop local runtime stack
3135
pnpm restart # Restart the desktop local runtime stack
@@ -69,6 +73,9 @@ This repo is desktop-first. Prefer the controller-first path and remove or ignor
6973
## Desktop local development
7074

7175
- Use `pnpm install` first, then `pnpm start` / `pnpm stop` / `pnpm restart` / `pnpm status` as the standard local desktop workflow.
76+
- For script-managed local development, use explicit per-service commands only: `pnpm dev start <desktop|openclaw|controller|web>`, `pnpm dev stop <service>`, `pnpm dev restart <service>`, `pnpm dev status <service>`, and `pnpm dev logs <service>`.
77+
- `pnpm dev` has no implicit aggregate default and intentionally does not support `all`; start each service deliberately in dependency order when you want the full local stack: `openclaw` -> `controller` -> `web` -> `desktop`.
78+
- `pnpm dev logs <service>` is session-scoped, prints a fixed header, and tails at most the last 200 lines from the active service session.
7279
- `pnpm start` is the canonical local desktop entrypoint and now applies safe startup optimizations by default: it reuses existing build artifacts when present and reuses the prepared OpenClaw sidecar cache when its inputs have not changed.
7380
- Temporary escape hatches exist for debugging or suspicious cache behavior: `NEXU_DESKTOP_FORCE_FULL_START=1` disables the optimized start path, `NEXU_DESKTOP_DISABLE_BUILD_REUSE=1` disables build artifact reuse only, and `NEXU_DESKTOP_DISABLE_OPENCLAW_SIDECAR_CACHE=1` disables the OpenClaw sidecar cache only.
7481
- `pnpm reset-state` is the reset button for the optimized path too: it stops the desktop runtime and clears repo-local runtime state plus cached sidecars under `.tmp/sidecars/`.
@@ -263,6 +270,8 @@ This note should track:
263270
- OpenClaw managed skills dir (expected default): `~/.openclaw/skills/`
264271
- Slack smoke probe setup: install Chrome Canary, set `PROBE_SLACK_URL`, run `pnpm probe:slack prepare`, then manually log into Slack in Canary before `pnpm probe:slack run`
265272
- `openclaw-runtime` is installed implicitly by `pnpm install`; local development should normally not use a global `openclaw` CLI
273+
- Local service startup order for the script-managed dev path: `pnpm dev start openclaw` -> `pnpm dev start controller` -> `pnpm dev start web` -> `pnpm dev start desktop`
274+
- Local service shutdown order for the script-managed dev path: `pnpm dev stop desktop` -> `pnpm dev stop web` -> `pnpm dev stop controller` -> `pnpm dev stop openclaw`
266275
- Prefer `./openclaw-wrapper` over global `openclaw` in local development; it executes `openclaw-runtime/node_modules/openclaw/openclaw.mjs`
267276
- When OpenClaw is started manually, set `RUNTIME_MANAGE_OPENCLAW_PROCESS=false` for `@nexu/controller` to avoid launching a second OpenClaw process
268277
- If behavior differs, verify effective `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` used by the running controller process.

TASK.md

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,34 @@
4141
- Updated `scripts/dev/src/index.ts` so `pnpm dev start|restart|stop|status|logs` now includes `openclaw`
4242
- Updated existing `scripts/dev` controller/web assembly to consume injected values from `scripts/dev/.env` instead of assuming only hard-coded defaults
4343

44+
### `scripts/dev` now also owns desktop local-dev attach
45+
46+
- Added `scripts/dev/src/services/desktop.ts`
47+
- Updated `scripts/dev/src/index.ts` so `pnpm dev start|restart|stop|status|logs` now includes `desktop`
48+
- `scripts/dev` now launches desktop through `apps/desktop/scripts/dev-cli.mjs` with:
49+
- `NEXU_DESKTOP_RUNTIME_MODE=external`
50+
- injected controller/web/openclaw URLs from `scripts/dev/.env`
51+
- shared `NEXU_HOME` from the `scripts/dev` contract
52+
- Added desktop-specific `scripts/dev` state/log plumbing:
53+
- `.tmp/dev/desktop.pid`
54+
- desktop log access wired to `.tmp/logs/desktop-dev.log`
55+
- `scripts/dev` command routing no longer has implicit aggregate defaults for `start | status | stop | restart`
56+
- these commands now require an explicit single-service target: `desktop | openclaw | controller | web`
57+
- aggregate target `all` is rejected
58+
- example: `pnpm dev start openclaw`, `pnpm dev status desktop`, `pnpm dev stop web`
59+
- `pnpm dev logs <service>` is now session-scoped and tail-oriented:
60+
- logs are resolved from the active service session only
61+
- output is capped to the last 200 lines by default
62+
- the CLI prints a fixed header with the tail policy, session line count, and actual log file path before log content
63+
- desktop logs are sliced from the current `launchId` inside the shared `desktop-dev.log` file instead of dumping the whole file
64+
- Root `AGENTS.md` and `scripts/dev/AGENTS.md` were updated to describe the explicit per-service local-dev workflow, the lack of an `all` target, and the new session-scoped logging contract
65+
- Cleaned obvious old script-managed dev wording in service errors so each service now points to the matching explicit stop command (`pnpm dev stop <service>`)
66+
- `apps/desktop/main/bootstrap.ts` now respects a pre-injected `NEXU_HOME` in local dev instead of always forcing the desktop-local fallback path
67+
- `apps/desktop/scripts/dev-cli.mjs` now treats external runtime attach as a first-class mode for local dev:
68+
- skips killing controller/web/openclaw listener ports when desktop is only attaching
69+
- derives runtime ports from injected env instead of fixed desktop defaults
70+
- skips controller/web/openclaw sidecar builds when external attach only needs desktop artifacts
71+
4472
### Small controller-chain robustness fix
4573

4674
- `apps/controller/src/runtime/openclaw-config-writer.ts` now derives a fallback state dir from `openclawConfigPath` when the full env shape is not present, which fixed the related config-writer regression in tests
@@ -52,23 +80,36 @@
5280
- `pnpm --filter @nexu/controller typecheck` passed
5381
- `pnpm --filter @nexu/controller build` passed
5482
- `pnpm --dir ./scripts/dev exec tsc --noEmit` passed
55-
- Root-entrypoint local-dev acceptance for the current three-service flow passed:
56-
- `pnpm dev status`
57-
- `pnpm dev start`
58-
- `pnpm dev status`
83+
- Root-entrypoint local-dev acceptance for explicit per-service local-dev flow passed:
84+
- `pnpm dev status all` rejects `all` as intended
85+
- `pnpm dev start openclaw`
5986
- `pnpm dev logs openclaw`
87+
- `pnpm dev start controller`
6088
- `pnpm dev logs controller`
61-
- `pnpm dev restart`
62-
- `pnpm dev stop`
63-
- `pnpm dev status`
89+
- `pnpm dev start web`
90+
- `pnpm dev logs web`
91+
- `pnpm dev start desktop`
92+
- `pnpm dev logs desktop`
93+
- `pnpm dev stop desktop`
94+
- `pnpm dev stop web`
95+
- `pnpm dev stop controller`
96+
- `pnpm dev stop openclaw`
97+
- Follow-up doc / cleanup validation passed:
98+
- `pnpm --dir ./scripts/dev exec tsc --noEmit`
99+
- grep audit across `AGENTS.md`, `scripts/dev/AGENTS.md`, and `scripts/dev/src/` for stale implicit-aggregate command wording
64100
- Verified controller now boots in `external` OpenClaw mode and successfully reaches `openclaw_ws_connected` through the `scripts/dev`-managed OpenClaw process
101+
- `pnpm lint` still fails, but only on pre-existing repo-wide Biome formatting drift unrelated to this branch
102+
- `pnpm test` still fails, but the observed failures are pre-existing desktop cross-platform/path test issues unrelated to this branch
65103

66104
## Important Current Behavior
67105

68106
- OpenClaw local dev is now expected to be orchestrated by `scripts/dev`, not by its own dedicated `.env`
69107
- `scripts/dev/.env` is intended to become the single dev-only source of truth for cross-service injected runtime values
70108
- Controller local dev is already consuming OpenClaw through that external contract when launched via `scripts/dev`
71-
- Desktop code is prepared for the same external-runtime shape, but desktop is not yet wired into `scripts/dev`
109+
- Desktop local dev is now started/stopped through `scripts/dev` in `external` mode and attaches to the `scripts/dev`-managed controller/web/openclaw stack
110+
- `pnpm dev start|status|stop|restart` now require an explicit single-service target; `all` is intentionally unsupported
111+
- `pnpm dev logs <service>` only works for the active session of that service and prints at most the last 200 lines, prefixed with a fixed metadata header
112+
- Desktop still writes to the shared `.tmp/logs/desktop-dev.log`, but `pnpm dev logs desktop` now slices output to the current `launchId` session before tailing
72113

73114
## Known Existing Issues
74115

@@ -77,10 +118,10 @@
77118
- `tests/nexu-config-store.test.ts`
78119
- `tests/openclaw-sync.test.ts`
79120
- `tests/openclaw-runtime-plugin-writer.test.ts` (Windows symlink permission issue)
80-
- Desktop is not yet started/stopped through `scripts/dev`; only `openclaw + controller + web` are wired today
121+
- `pnpm test` still has additional pre-existing desktop failures on Windows path expectations / filesystem assumptions (launchd, plist, state migration, skill path, runtime manifest, skill DB migration)
81122

82123
## Suggested Next Steps
83124

84-
1. Wire `desktop` into `scripts/dev` using the same `scripts/dev/.env` contract and `NEXU_DESKTOP_RUNTIME_MODE=external`
85-
2. Decide whether to expand `pnpm dev` into explicit single-service targeting like `pnpm dev start openclaw` / `pnpm dev stop controller`
125+
1. Decide whether any per-service dependency guardrails are needed when users start `controller` or `desktop` without their expected upstream services already running
126+
2. Decide whether `logs` should gain an opt-in `--full` / `--lines <n>` escape hatch later, or remain hard-capped at 200 lines
86127
3. Continue tightening the `scripts/dev/.env` contract so every external injection is documented, named consistently, and traced to a single owner

apps/desktop/main/bootstrap.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ function configureLocalDevPaths(): void {
5252
const userDataPath = resolve(electronRoot, "user-data");
5353
const sessionDataPath = resolve(electronRoot, "session-data");
5454
const logsPath = resolve(userDataPath, "logs");
55-
const nexuHomePath = getDesktopNexuHomeDir(userDataPath);
55+
const nexuHomePath = process.env.NEXU_HOME
56+
? resolve(process.env.NEXU_HOME)
57+
: getDesktopNexuHomeDir(userDataPath);
5658

5759
mkdirSync(userDataPath, { recursive: true });
5860
mkdirSync(sessionDataPath, { recursive: true });

apps/desktop/scripts/dev-cli.mjs

Lines changed: 101 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ const logFile = resolve(logDir, "desktop-dev.log");
1717
const timelineFile = resolve(logDir, "desktop-startup-timeline.log");
1818
const managerDir = resolve(runtimeRoot, "manager");
1919
const stateFile = resolve(managerDir, "state.json");
20-
const defaultPorts = [18789, 50800, 50810];
2120
const cliFlags = new Set(process.argv.slice(3));
2221
const sidecarRoot = resolve(rootDir, ".tmp", "sidecars");
2322

@@ -118,6 +117,42 @@ function shouldReuseBuildArtifacts() {
118117
return !isEnvFlagEnabled("NEXU_DESKTOP_DISABLE_BUILD_REUSE");
119118
}
120119

120+
function getRuntimeMode() {
121+
const explicitMode = process.env.NEXU_DESKTOP_RUNTIME_MODE?.trim();
122+
if (explicitMode === "external") {
123+
return "external";
124+
}
125+
126+
if (isEnvFlagEnabled("NEXU_DESKTOP_EXTERNAL_RUNTIME")) {
127+
return "external";
128+
}
129+
130+
return "internal";
131+
}
132+
133+
function readPort(value, fallback) {
134+
const parsed = Number.parseInt(value ?? "", 10);
135+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
136+
}
137+
138+
function getRuntimePorts() {
139+
const controllerPort = readPort(
140+
process.env.NEXU_CONTROLLER_PORT ?? process.env.NEXU_API_PORT,
141+
50800,
142+
);
143+
const webPort = readPort(process.env.NEXU_WEB_PORT, 50810);
144+
145+
let openclawPort = 18789;
146+
try {
147+
const url = new URL(
148+
process.env.NEXU_OPENCLAW_BASE_URL ?? "http://127.0.0.1:18789",
149+
);
150+
openclawPort = readPort(url.port, 18789);
151+
} catch {}
152+
153+
return [openclawPort, controllerPort, webPort];
154+
}
155+
121156
function createLauncherEnv() {
122157
return {
123158
...process.env,
@@ -287,19 +322,25 @@ async function removeState() {
287322
}
288323

289324
function hasReusableArtifacts() {
290-
const requiredPaths = [
291-
resolve(rootDir, "packages/shared/dist/index.js"),
292-
resolve(rootDir, "apps/controller/dist/index.js"),
293-
resolve(rootDir, "apps/web/dist/index.html"),
294-
resolve(appDir, "dist/index.html"),
295-
resolve(appDir, "dist-electron/main/bootstrap.js"),
296-
resolve(rootDir, ".tmp/sidecars/controller/dist/index.js"),
297-
resolve(
298-
rootDir,
299-
".tmp/sidecars/openclaw/node_modules/openclaw/openclaw.mjs",
300-
),
301-
resolve(rootDir, ".tmp/sidecars/web/index.js"),
302-
];
325+
const requiredPaths =
326+
getRuntimeMode() === "external"
327+
? [
328+
resolve(appDir, "dist/index.html"),
329+
resolve(appDir, "dist-electron/main/bootstrap.js"),
330+
]
331+
: [
332+
resolve(rootDir, "packages/shared/dist/index.js"),
333+
resolve(rootDir, "apps/controller/dist/index.js"),
334+
resolve(rootDir, "apps/web/dist/index.html"),
335+
resolve(appDir, "dist/index.html"),
336+
resolve(appDir, "dist-electron/main/bootstrap.js"),
337+
resolve(rootDir, ".tmp/sidecars/controller/dist/index.js"),
338+
resolve(
339+
rootDir,
340+
".tmp/sidecars/openclaw/node_modules/openclaw/openclaw.mjs",
341+
),
342+
resolve(rootDir, ".tmp/sidecars/web/index.js"),
343+
];
303344

304345
return requiredPaths.every((filePath) => existsSync(filePath));
305346
}
@@ -560,9 +601,11 @@ async function killResidualProcesses() {
560601
await killPid(state.electronPid);
561602
}
562603

563-
const portPids = listListeningPids(defaultPorts);
564-
for (const pid of portPids) {
565-
await killPid(pid);
604+
if (getRuntimeMode() !== "external") {
605+
const portPids = listListeningPids(getRuntimePorts());
606+
for (const pid of portPids) {
607+
await killPid(pid);
608+
}
566609
}
567610

568611
await removeState();
@@ -572,43 +615,48 @@ async function killResidualProcesses() {
572615
async function buildRuntime() {
573616
const launcherEnv = createLauncherEnv();
574617
const webBuildEnv = createWebBuildEnv();
618+
const runtimeMode = getRuntimeMode();
575619

576620
await runTimedPhase("build_runtime", async () => {
577-
await log("building runtime artifacts");
578-
await logTimeline("build_runtime shared build start");
579-
await runLogged(
580-
pnpmCommand,
581-
["--dir", rootDir, "--filter", "@nexu/shared", "build"],
582-
{ env: launcherEnv },
583-
);
584-
await logTimeline("build_runtime controller build start");
585-
await runLogged(
586-
pnpmCommand,
587-
["--dir", rootDir, "--filter", "@nexu/controller", "build"],
588-
{ env: launcherEnv },
589-
);
590-
await logTimeline("build_runtime web build start");
591-
await runLogged(
592-
pnpmCommand,
593-
["--dir", rootDir, "--filter", "@nexu/web", "build"],
594-
{ env: webBuildEnv },
595-
);
596-
await logTimeline("build_runtime controller sidecar start");
597-
await runLogged(
598-
pnpmCommand,
599-
["--dir", appDir, "prepare:controller-sidecar"],
600-
{ env: launcherEnv },
601-
);
602-
await logTimeline("build_runtime openclaw sidecar start");
603-
await runLogged(
604-
pnpmCommand,
605-
["--dir", appDir, "prepare:openclaw-sidecar"],
606-
{ env: launcherEnv },
607-
);
608-
await logTimeline("build_runtime web sidecar start");
609-
await runLogged(pnpmCommand, ["--dir", appDir, "prepare:web-sidecar"], {
610-
env: launcherEnv,
611-
});
621+
await log(`building runtime artifacts mode=${runtimeMode}`);
622+
623+
if (runtimeMode !== "external") {
624+
await logTimeline("build_runtime shared build start");
625+
await runLogged(
626+
pnpmCommand,
627+
["--dir", rootDir, "--filter", "@nexu/shared", "build"],
628+
{ env: launcherEnv },
629+
);
630+
await logTimeline("build_runtime controller build start");
631+
await runLogged(
632+
pnpmCommand,
633+
["--dir", rootDir, "--filter", "@nexu/controller", "build"],
634+
{ env: launcherEnv },
635+
);
636+
await logTimeline("build_runtime web build start");
637+
await runLogged(
638+
pnpmCommand,
639+
["--dir", rootDir, "--filter", "@nexu/web", "build"],
640+
{ env: webBuildEnv },
641+
);
642+
await logTimeline("build_runtime controller sidecar start");
643+
await runLogged(
644+
pnpmCommand,
645+
["--dir", appDir, "prepare:controller-sidecar"],
646+
{ env: launcherEnv },
647+
);
648+
await logTimeline("build_runtime openclaw sidecar start");
649+
await runLogged(
650+
pnpmCommand,
651+
["--dir", appDir, "prepare:openclaw-sidecar"],
652+
{ env: launcherEnv },
653+
);
654+
await logTimeline("build_runtime web sidecar start");
655+
await runLogged(pnpmCommand, ["--dir", appDir, "prepare:web-sidecar"], {
656+
env: launcherEnv,
657+
});
658+
}
659+
612660
await logTimeline("build_runtime desktop build start");
613661
await runLogged(pnpmCommand, ["--dir", appDir, "build"], {
614662
env: launcherEnv,
@@ -749,7 +797,7 @@ async function status() {
749797
console.log(`[${timestamp()}] desktop process is not running`);
750798
}
751799

752-
const portPids = listListeningPids(defaultPorts);
800+
const portPids = listListeningPids(getRuntimePorts());
753801
if (portPids.length > 0) {
754802
console.log(`listening pids: ${portPids.join(", ")}`);
755803
}

0 commit comments

Comments
 (0)