Skip to content

Commit 4f38c5e

Browse files
arul28claude
andauthored
Automations fixes and lane-wide UX/runtime polish (#321)
Ship the automations rework alongside related ADE CLI TUI, desktop chat, PTY, IPC, runtime, and docs updates accumulated on this lane. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 049a79f commit 4f38c5e

106 files changed

Lines changed: 6101 additions & 2196 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ade-cli/src/cli.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env node
22
import { Buffer } from "node:buffer";
33
import { spawn, spawnSync } from "node:child_process";
4+
import { createHash } from "node:crypto";
45
import fs from "node:fs";
56
import net from "node:net";
67
import path from "node:path";
@@ -7839,15 +7840,17 @@ function automationsExampleText(): string {
78397840
laneMode: "create",
78407841
laneNamePreset: "issue-num-title",
78417842
session: {
7842-
prompt: "Investigate and propose a fix for {{trigger.issue.title}}.",
7843+
title: "Issue fix",
7844+
codexFastMode: true,
78437845
},
78447846
},
7845-
actions: [
7846-
{
7847-
type: "agent-session",
7848-
modelId: "claude-opus-4-7",
7847+
prompt: "Investigate and propose a fix for {{trigger.issue.title}}.",
7848+
modelConfig: {
7849+
orchestratorModel: {
7850+
modelId: "openai/gpt-5.5",
7851+
thinkingLevel: "xhigh",
78497852
},
7850-
],
7853+
},
78517854
},
78527855
null,
78537856
2,
@@ -10256,6 +10259,14 @@ function shouldReplaceMachineRuntimeVersion(runtimeVersion: string | null): bool
1025610259
return runtimeVersion == null || runtimeVersion !== VERSION;
1025710260
}
1025810261

10262+
function computeRuntimeBuildHash(filePath: string): string | null {
10263+
try {
10264+
return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
10265+
} catch {
10266+
return null;
10267+
}
10268+
}
10269+
1025910270
async function initializeMachineRuntimeDaemon(
1026010271
client: SocketJsonRpcClient,
1026110272
options: GlobalOptions,
@@ -10291,26 +10302,35 @@ async function spawnMachineRuntimeDaemon(
1029110302
const { resolveAdeServeCommand } = await import("./serviceManager/common");
1029210303
const serviceCommand = resolveAdeServeCommand();
1029310304
const args = [...serviceCommand.args];
10305+
let runtimeBuildHash: string | null = null;
1029410306
if (
1029510307
serviceCommand.command === process.execPath &&
1029610308
args.length === 1 &&
1029710309
args[0] === "serve" &&
1029810310
fs.existsSync(CLI_DIST_PATH)
1029910311
) {
1030010312
args.splice(0, 1, CLI_DIST_PATH, "serve");
10313+
runtimeBuildHash = computeRuntimeBuildHash(CLI_DIST_PATH);
10314+
} else if (serviceCommand.command === process.execPath && args[0]) {
10315+
runtimeBuildHash = computeRuntimeBuildHash(path.resolve(args[0]));
1030110316
}
1030210317
args.push("--socket", socketPath);
1030310318

10319+
const env: NodeJS.ProcessEnv = {
10320+
...process.env,
10321+
...(serviceCommand.env ?? {}),
10322+
ADE_DEFAULT_ROLE: options.role,
10323+
ADE_RPC_SOCKET_PATH: socketPath,
10324+
ADE_RUNTIME_SOCKET_PATH: socketPath,
10325+
};
10326+
if (runtimeBuildHash) {
10327+
env.ADE_RUNTIME_BUILD_HASH = runtimeBuildHash;
10328+
}
10329+
1030410330
const child = spawn(serviceCommand.command, args, {
1030510331
detached: true,
1030610332
stdio: "ignore",
10307-
env: {
10308-
...process.env,
10309-
...(serviceCommand.env ?? {}),
10310-
ADE_DEFAULT_ROLE: options.role,
10311-
ADE_RPC_SOCKET_PATH: socketPath,
10312-
ADE_RUNTIME_SOCKET_PATH: socketPath,
10313-
},
10333+
env,
1031410334
});
1031510335
child.once("error", () => {});
1031610336
child.unref();

apps/ade-cli/src/services/projects/projectScope.test.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,24 @@ describe("ProjectScopeRegistry", () => {
140140
await scopeRegistry.disposeAll();
141141
});
142142

143-
it("can switch the daemon sync host to a requested project", async () => {
143+
it("switches the daemon sync host without disposing active project scopes", async () => {
144144
const { registry, first, second } = createRegistry();
145145
const firstDispose = vi.fn();
146146
const secondDispose = vi.fn();
147147
const onDisposeProject = vi.fn();
148+
const firstSyncService = {
149+
initialize: vi.fn(async () => undefined),
150+
setHostDiscoveryEnabled: vi.fn(),
151+
setHostStartupEnabled: vi.fn(async () => undefined),
152+
};
153+
const secondSyncService = {
154+
initialize: vi.fn(async () => undefined),
155+
setHostDiscoveryEnabled: vi.fn(),
156+
setHostStartupEnabled: vi.fn(async () => undefined),
157+
};
148158
createAdeRuntimeMock
149-
.mockResolvedValueOnce({ dispose: firstDispose })
150-
.mockResolvedValueOnce({ dispose: secondDispose });
159+
.mockResolvedValueOnce({ dispose: firstDispose, syncService: firstSyncService })
160+
.mockResolvedValueOnce({ dispose: secondDispose, syncService: secondSyncService });
151161
const scopeRegistry = new ProjectScopeRegistry(registry, {
152162
onDisposeProject,
153163
syncRuntime: {
@@ -162,8 +172,14 @@ describe("ProjectScopeRegistry", () => {
162172
await scopeRegistry.ensureSyncHost(first.projectId);
163173
await scopeRegistry.ensureSyncHost(second.projectId);
164174

165-
expect(firstDispose).toHaveBeenCalledTimes(1);
166-
expect(onDisposeProject).toHaveBeenCalledWith(first.projectId);
175+
expect(firstDispose).not.toHaveBeenCalled();
176+
expect(secondDispose).not.toHaveBeenCalled();
177+
expect(onDisposeProject).not.toHaveBeenCalled();
178+
expect(firstSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(false);
179+
expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false);
180+
expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true);
181+
expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true);
182+
expect(secondSyncService.initialize).toHaveBeenCalled();
167183
expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2);
168184
expect(createAdeRuntimeMock.mock.calls[1]?.[0]).toMatchObject({
169185
projectRoot: second.rootPath,
@@ -176,9 +192,58 @@ describe("ProjectScopeRegistry", () => {
176192
});
177193

178194
await scopeRegistry.disposeAll();
195+
expect(firstDispose).toHaveBeenCalledTimes(1);
179196
expect(secondDispose).toHaveBeenCalledTimes(1);
180197
});
181198

199+
it("promotes an existing warm project when selecting the default sync host", async () => {
200+
const { registry, first, second } = createRegistry();
201+
const firstSyncService = {
202+
initialize: vi.fn(async () => undefined),
203+
setHostDiscoveryEnabled: vi.fn(),
204+
setHostStartupEnabled: vi.fn(async () => undefined),
205+
};
206+
const secondSyncService = {
207+
initialize: vi.fn(async () => undefined),
208+
setHostDiscoveryEnabled: vi.fn(),
209+
setHostStartupEnabled: vi.fn(async () => undefined),
210+
};
211+
createAdeRuntimeMock
212+
.mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService })
213+
.mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService });
214+
const scopeRegistry = new ProjectScopeRegistry(registry, {
215+
syncRuntime: {
216+
enabled: true,
217+
hostStartupEnabled: true,
218+
hostDiscoveryEnabled: true,
219+
forceHostRole: true,
220+
runtimeKind: "daemon",
221+
},
222+
});
223+
224+
await scopeRegistry.ensureSyncHost(first.projectId);
225+
await scopeRegistry.get(second.projectId);
226+
const file = JSON.parse(fs.readFileSync(registry.path, "utf8")) as {
227+
projects: Array<{ projectId: string; lastOpenedAt: number; addedAt: number }>;
228+
};
229+
file.projects = file.projects.map((project) => ({
230+
...project,
231+
lastOpenedAt: project.projectId === second.projectId ? 2_000 : 1_000,
232+
addedAt: project.projectId === second.projectId ? 2_000 : 1_000,
233+
}));
234+
fs.writeFileSync(registry.path, JSON.stringify(file, null, 2));
235+
await scopeRegistry.dispose(first.projectId);
236+
const promoted = await scopeRegistry.ensureSyncHost();
237+
238+
expect(promoted?.registryProjectId).toBe(second.projectId);
239+
expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2);
240+
expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true);
241+
expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true);
242+
expect(secondSyncService.initialize).toHaveBeenCalled();
243+
244+
await scopeRegistry.disposeAll();
245+
});
246+
182247
it("passes runtime capability options into project runtimes", async () => {
183248
const { registry, first } = createRegistry();
184249
const scopeRegistry = new ProjectScopeRegistry(registry, {

apps/ade-cli/src/services/projects/projectScope.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,21 @@ export class ProjectScopeRegistry {
112112
async ensureSyncHost(projectId?: ProjectId): Promise<ProjectScope | null> {
113113
if (!this.options.syncRuntime?.enabled) return null;
114114
if (projectId) {
115-
if (this.scopes.has(projectId) && this.syncHostProjectId !== projectId) {
116-
await this.dispose(projectId);
117-
}
118115
const existingHostId = this.syncHostProjectId;
119116
if (existingHostId && existingHostId !== projectId) {
120-
await this.dispose(existingHostId);
117+
await this.configureCachedSyncHost(existingHostId, false);
121118
}
122119
this.syncHostProjectId = projectId;
123-
return await this.get(projectId);
120+
try {
121+
const scope = await this.get(projectId);
122+
await this.configureSyncHost(scope, true);
123+
return scope;
124+
} catch (error) {
125+
if (this.syncHostProjectId === projectId) {
126+
this.syncHostProjectId = null;
127+
}
128+
throw error;
129+
}
124130
}
125131

126132
const existingHostId = this.syncHostProjectId;
@@ -139,7 +145,28 @@ export class ProjectScopeRegistry {
139145
const openedDelta = right.lastOpenedAt - left.lastOpenedAt;
140146
return openedDelta !== 0 ? openedDelta : right.addedAt - left.addedAt;
141147
})[0];
142-
return record ? this.get(record.projectId) : null;
148+
return record ? this.ensureSyncHost(record.projectId) : null;
149+
}
150+
151+
private async configureCachedSyncHost(
152+
projectId: ProjectId,
153+
enabled: boolean,
154+
): Promise<void> {
155+
const cached = this.scopes.get(projectId);
156+
if (!cached) return;
157+
const scope = await cached.catch(() => null);
158+
if (scope) await this.configureSyncHost(scope, enabled);
159+
}
160+
161+
private async configureSyncHost(
162+
scope: ProjectScope,
163+
enabled: boolean,
164+
): Promise<void> {
165+
const syncService = scope.runtime.syncService;
166+
if (!syncService) return;
167+
syncService.setHostDiscoveryEnabled?.(enabled);
168+
await syncService.setHostStartupEnabled?.(enabled);
169+
if (enabled) await syncService.initialize();
143170
}
144171

145172
private buildSyncRuntimeOptions(projectId: ProjectId): AdeRuntimeSyncOptions | null {

0 commit comments

Comments
 (0)