Skip to content

Commit 57fda25

Browse files
committed
feat: externalize dev runtime ownership
1 parent 01bd29a commit 57fda25

30 files changed

+1130
-45
lines changed

TASK.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Handoff Notes
2+
3+
## Branch
4+
5+
- Current branch: `feat/local-dev-workflow-optimization`
6+
- Branch pushed to: `origin/feat/local-dev-workflow-optimization`
7+
- Latest commit from before this session: `01bd29a` `refactor: clarify scripts dev module boundaries`
8+
- Workspace status at handoff: code changes for desktop/controller/scripts-dev integration plus this `TASK.md` update
9+
10+
## What Changed In This Session
11+
12+
### Desktop runtime ownership was split into `external | internal`
13+
14+
- `apps/desktop/shared/runtime-config.ts` now exposes `runtimeMode: "external" | "internal"`
15+
- `apps/desktop/main/platforms/shared/runtime-common.ts` now has an external runtime adapter path
16+
- `apps/desktop/main/platforms/index.ts` selects an external adapter when desktop is launched in external mode
17+
- `apps/desktop/main/runtime/manifests.ts` marks `web`, `controller`, and `openclaw` runtime units as `external` when desktop is attaching instead of owning processes
18+
- `apps/desktop/main/runtime/daemon-supervisor.ts` now probes external runtime units by port and reports external availability state instead of trying to manage them
19+
- `apps/desktop/main/index.ts` logs the effective desktop runtime mode and the external runtime targets during cold start
20+
21+
### Controller OpenClaw ownership was split into `external | internal`
22+
23+
- `apps/controller/src/app/env.ts` now accepts:
24+
- `NEXU_CONTROLLER_OPENCLAW_MODE=external|internal`
25+
- `OPENCLAW_BASE_URL`
26+
- `OPENCLAW_LOG_DIR`
27+
- Legacy `RUNTIME_MANAGE_OPENCLAW_PROCESS` is now treated as a compatibility input; the effective owner is derived from the explicit mode when present
28+
- `apps/controller/src/runtime/gateway-client.ts`, `apps/controller/src/runtime/runtime-health.ts`, and `apps/controller/src/runtime/openclaw-ws-client.ts` now connect through `OPENCLAW_BASE_URL` instead of hard-coded `127.0.0.1:${port}`
29+
- `apps/controller/src/app/bootstrap.ts` now logs the runtime contract and only starts OpenClaw when controller is in `internal` mode
30+
- `apps/controller/src/services/model-provider-service.ts` and `apps/controller/src/services/desktop-local-service.ts` now skip runtime restarts when controller is attached to an external OpenClaw instance
31+
- `apps/controller/src/runtime/openclaw-process.ts` now exposes `managesProcess()` so ownership checks are explicit at call sites
32+
33+
### `scripts/dev` now owns OpenClaw local-dev startup
34+
35+
- Added `scripts/dev/src/shared/dev-runtime-config.ts`
36+
- reads `scripts/dev/.env` when present
37+
- defines the cross-service local-dev contract for ports, URLs, state dirs, config path, log dir, and gateway token
38+
- Added `scripts/dev/.env.example` as the source-of-truth example for dev-only external injection
39+
- Added `scripts/dev/src/services/openclaw.ts`
40+
- Added `scripts/dev/src/supervisors/openclaw.ts`
41+
- Updated `scripts/dev/src/index.ts` so `pnpm dev start|restart|stop|status|logs` now includes `openclaw`
42+
- Updated existing `scripts/dev` controller/web assembly to consume injected values from `scripts/dev/.env` instead of assuming only hard-coded defaults
43+
44+
### Small controller-chain robustness fix
45+
46+
- `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
47+
48+
## Validation Already Done
49+
50+
- `pnpm --filter @nexu/desktop typecheck` passed
51+
- `pnpm --filter @nexu/desktop build` passed earlier in the session after the desktop external-runtime split
52+
- `pnpm --filter @nexu/controller typecheck` passed
53+
- `pnpm --filter @nexu/controller build` passed
54+
- `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`
59+
- `pnpm dev logs openclaw`
60+
- `pnpm dev logs controller`
61+
- `pnpm dev restart`
62+
- `pnpm dev stop`
63+
- `pnpm dev status`
64+
- Verified controller now boots in `external` OpenClaw mode and successfully reaches `openclaw_ws_connected` through the `scripts/dev`-managed OpenClaw process
65+
66+
## Important Current Behavior
67+
68+
- OpenClaw local dev is now expected to be orchestrated by `scripts/dev`, not by its own dedicated `.env`
69+
- `scripts/dev/.env` is intended to become the single dev-only source of truth for cross-service injected runtime values
70+
- 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`
72+
73+
## Known Existing Issues
74+
75+
- `pnpm lint` still fails due to pre-existing repo-wide Biome formatting issues unrelated to this branch
76+
- `pnpm --filter @nexu/controller test` still has pre-existing failures not introduced by this session:
77+
- `tests/nexu-config-store.test.ts`
78+
- `tests/openclaw-sync.test.ts`
79+
- `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
81+
82+
## Suggested Next Steps
83+
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`
86+
3. Continue tightening the `scripts/dev/.env` contract so every external injection is documented, named consistently, and traced to a single owner

apps/controller/src/app/bootstrap.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1+
import { logger } from "../lib/logger.js";
12
import type { ControllerContainer } from "./container.js";
23

34
export async function bootstrapController(
45
container: ControllerContainer,
56
): Promise<() => void> {
7+
logger.info(
8+
{
9+
openclawOwnershipMode: container.env.openclawOwnershipMode,
10+
openclawBaseUrl: container.env.openclawBaseUrl,
11+
openclawConfigPath: container.env.openclawConfigPath,
12+
openclawStateDir: container.env.openclawStateDir,
13+
openclawLogDir: container.env.openclawLogDir,
14+
},
15+
"controller_bootstrap_runtime_contract",
16+
);
17+
618
// Run independent prep tasks in parallel to shave off startup time.
719
// All three are independent: process cleanup, plugin files, cloud model fetch.
820
await Promise.all([
@@ -28,8 +40,12 @@ export async function bootstrapController(
2840
// restarts from async setup (cloud connect, model selection, bot creation).
2941
container.openclawSyncService.beginSettling();
3042

31-
container.openclawProcess.enableAutoRestart();
32-
container.openclawProcess.start();
43+
if (container.openclawProcess.managesProcess()) {
44+
container.openclawProcess.enableAutoRestart();
45+
container.openclawProcess.start();
46+
} else {
47+
logger.info({}, "controller_bootstrap_attaching_external_openclaw");
48+
}
3349
container.channelFallbackService.start();
3450

3551
// Start WS client — connects to OpenClaw gateway

apps/controller/src/app/container.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,10 @@ export interface ControllerContainer {
6969
export async function createContainer(): Promise<ControllerContainer> {
7070
const configStore = new NexuConfigStore(env);
7171
await configStore.reconcileConfiguredDesktopCloudState();
72-
if (env.manageOpenclawProcess) {
73-
await configStore.syncManagedRuntimeGateway({
74-
port: env.openclawGatewayPort,
75-
authMode: env.openclawGatewayToken ? "token" : "none",
76-
});
77-
}
72+
await configStore.syncManagedRuntimeGateway({
73+
port: env.openclawGatewayPort,
74+
authMode: env.openclawGatewayToken ? "token" : "none",
75+
});
7876
const artifactsStore = new ArtifactsStore(env);
7977
const compiledStore = new CompiledOpenClawStore(env);
8078
const configWriter = new OpenClawConfigWriter(env);

apps/controller/src/app/env.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,52 @@ const booleanSchema = z
3636
.enum(["true", "false", "1", "0"])
3737
.transform((value) => value === "true" || value === "1");
3838

39+
const openclawOwnershipModeSchema = z.enum(["external", "internal"]);
40+
41+
function parseUrlPort(value: string): number | null {
42+
try {
43+
const url = new URL(value);
44+
if (url.port.length > 0) {
45+
return Number.parseInt(url.port, 10);
46+
}
47+
48+
if (url.protocol === "https:") {
49+
return 443;
50+
}
51+
52+
if (url.protocol === "http:") {
53+
return 80;
54+
}
55+
56+
return null;
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
function readOpenclawOwnershipMode(input: {
63+
explicitMode?: "external" | "internal";
64+
legacyManageProcess: boolean;
65+
}): "external" | "internal" {
66+
if (input.explicitMode) {
67+
return input.explicitMode;
68+
}
69+
70+
return input.legacyManageProcess ? "internal" : "external";
71+
}
72+
3973
const envSchema = z.object({
4074
NODE_ENV: z
4175
.enum(["development", "test", "production"])
4276
.default("development"),
4377
PORT: z.coerce.number().int().positive().default(3010),
4478
HOST: z.string().default("127.0.0.1"),
4579
NEXU_HOME: z.string().default("~/.nexu"),
80+
NEXU_CONTROLLER_OPENCLAW_MODE: openclawOwnershipModeSchema.optional(),
81+
OPENCLAW_BASE_URL: z.string().url().optional(),
4682
OPENCLAW_STATE_DIR: z.string().optional(),
4783
OPENCLAW_CONFIG_PATH: z.string().optional(),
84+
OPENCLAW_LOG_DIR: z.string().optional(),
4885
OPENCLAW_SKILLS_DIR: z.string().optional(),
4986
SKILLHUB_STATIC_SKILLS_DIR: z.string().optional(),
5087
PLATFORM_TEMPLATES_DIR: z.string().optional(),
@@ -64,6 +101,15 @@ const envSchema = z.object({
64101
});
65102

66103
const parsed = envSchema.parse(process.env);
104+
const openclawOwnershipMode = readOpenclawOwnershipMode({
105+
explicitMode: parsed.NEXU_CONTROLLER_OPENCLAW_MODE,
106+
legacyManageProcess: parsed.RUNTIME_MANAGE_OPENCLAW_PROCESS,
107+
});
108+
const openclawBaseUrl =
109+
parsed.OPENCLAW_BASE_URL ??
110+
`http://127.0.0.1:${String(parsed.OPENCLAW_GATEWAY_PORT)}`;
111+
const openclawGatewayPort =
112+
parseUrlPort(openclawBaseUrl) ?? parsed.OPENCLAW_GATEWAY_PORT;
67113

68114
const nexuHomeDir = expandHomeDir(parsed.NEXU_HOME);
69115
const openclawStateDir = expandHomeDir(
@@ -119,12 +165,17 @@ export const env = {
119165
openclawStateDir,
120166
"workspace-templates",
121167
),
168+
openclawOwnershipMode,
169+
openclawBaseUrl,
122170
openclawBin: parsed.OPENCLAW_BIN,
171+
openclawLogDir: expandHomeDir(
172+
parsed.OPENCLAW_LOG_DIR ?? path.join(nexuHomeDir, "logs", "openclaw"),
173+
),
123174
litellmBaseUrl: parsed.LITELLM_BASE_URL ?? null,
124175
litellmApiKey: parsed.LITELLM_API_KEY ?? null,
125-
openclawGatewayPort: parsed.OPENCLAW_GATEWAY_PORT,
176+
openclawGatewayPort,
126177
openclawGatewayToken: parsed.OPENCLAW_GATEWAY_TOKEN,
127-
manageOpenclawProcess: parsed.RUNTIME_MANAGE_OPENCLAW_PROCESS,
178+
manageOpenclawProcess: openclawOwnershipMode === "internal",
128179
gatewayProbeEnabled: parsed.RUNTIME_GATEWAY_PROBE_ENABLED,
129180
runtimeSyncIntervalMs: parsed.RUNTIME_SYNC_INTERVAL_MS,
130181
runtimeHealthIntervalMs: parsed.RUNTIME_HEALTH_INTERVAL_MS,

apps/controller/src/runtime/gateway-client.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@ export class GatewayClient {
44
constructor(private readonly env: ControllerEnv) {}
55

66
async fetchJson<T>(pathname: string): Promise<T> {
7-
const url = new URL(
8-
pathname,
9-
`http://127.0.0.1:${this.env.openclawGatewayPort}`,
10-
);
7+
const url = new URL(pathname, this.env.openclawBaseUrl);
118
const response = await fetch(url, {
129
headers: this.env.openclawGatewayToken
1310
? { Authorization: `Bearer ${this.env.openclawGatewayToken}` }

apps/controller/src/runtime/openclaw-config-writer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ async function syncWeixinAccountIndex(
5151
);
5252
}
5353

54+
function resolveOpenclawStateDir(env: ControllerEnv): string {
55+
return env.openclawStateDir ?? path.dirname(env.openclawConfigPath);
56+
}
57+
5458
export class OpenClawConfigWriter {
5559
/** Last successfully written content — used to skip redundant writes. */
5660
private lastWrittenContent: string | null = null;
@@ -100,7 +104,7 @@ export class OpenClawConfigWriter {
100104
this.lastWrittenContent = content;
101105

102106
// Sync weixin account index for openclaw-weixin plugin compatibility
103-
await syncWeixinAccountIndex(this.env.openclawStateDir, config);
107+
await syncWeixinAccountIndex(resolveOpenclawStateDir(this.env), config);
104108

105109
const configStat = await stat(this.env.openclawConfigPath);
106110
logger.info(

apps/controller/src/runtime/openclaw-process.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export class OpenClawProcessManager {
3636

3737
constructor(private readonly env: ControllerEnv) {}
3838

39+
managesProcess(): boolean {
40+
return this.env.manageOpenclawProcess;
41+
}
42+
3943
async prepare(): Promise<void> {
4044
if (!this.env.manageOpenclawProcess) {
4145
return;

apps/controller/src/runtime/openclaw-ws-client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ function buildDeviceAuthPayloadV3(params: {
141141
].join("|");
142142
}
143143

144+
function toGatewayWsUrl(baseUrl: string): string {
145+
const url = new URL(baseUrl);
146+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
147+
return url.toString().replace(/\/$/, "");
148+
}
149+
144150
// ---------------------------------------------------------------------------
145151
// Protocol types (subset of openclaw/src/gateway/protocol)
146152
// ---------------------------------------------------------------------------
@@ -204,7 +210,7 @@ export class OpenClawWsClient {
204210
private readonly deviceIdentity: DeviceIdentity;
205211

206212
constructor(env: ControllerEnv) {
207-
this.url = `ws://127.0.0.1:${env.openclawGatewayPort}`;
213+
this.url = toGatewayWsUrl(env.openclawBaseUrl);
208214
this.token = env.openclawGatewayToken ?? "";
209215
this.deviceIdentity = loadOrCreateDeviceIdentity(
210216
path.join(env.openclawStateDir, "identity", "device.json"),

apps/controller/src/runtime/runtime-health.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class RuntimeHealth {
1010

1111
try {
1212
const response = await fetch(
13-
`http://127.0.0.1:${this.env.openclawGatewayPort}/health`,
13+
new URL("/health", this.env.openclawBaseUrl),
1414
);
1515
return {
1616
ok: response.ok,

apps/controller/src/services/desktop-local-service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { logger } from "../lib/logger.js";
12
import type { OpenClawProcessManager } from "../runtime/openclaw-process.js";
23
import type { NexuConfigStore } from "../store/nexu-config-store.js";
34
import type { ModelProviderService } from "./model-provider-service.js";
@@ -87,6 +88,14 @@ export class DesktopLocalService {
8788
}
8889

8990
async restartRuntime(): Promise<void> {
91+
if (!this.openclawProcess.managesProcess()) {
92+
logger.info(
93+
{},
94+
"desktop_local_runtime_restart_skipped_external_openclaw",
95+
);
96+
return;
97+
}
98+
9099
await this.openclawProcess.stop();
91100
this.openclawProcess.enableAutoRestart();
92101
this.openclawProcess.start();

0 commit comments

Comments
 (0)