Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export type AdeRuntimeSyncOptions = {
foreignChatProvider?: Parameters<typeof createSyncService>[0]["foreignChatProvider"];
personalChatScope?: Parameters<typeof createSyncService>[0]["personalChatScope"];
remoteCommandExecutor?: Parameters<typeof createSyncService>[0]["remoteCommandExecutor"];
projectScopes?: Parameters<typeof createSyncService>[0]["projectScopes"];
getAccountDirectoryHealth?: Parameters<typeof createSyncService>[0]["getAccountDirectoryHealth"];
requestAccountMachinePublish?: () => void | Promise<void>;
/**
Expand Down Expand Up @@ -2802,6 +2803,7 @@ export async function createAdeRuntime(args: {
foreignChatProvider: syncRuntimeOptions.foreignChatProvider,
personalChatScope: syncRuntimeOptions.personalChatScope,
remoteCommandExecutor: syncRuntimeOptions.remoteCommandExecutor,
projectScopes: syncRuntimeOptions.projectScopes,
getModelPickerStore: () => getSharedModelPickerStore(db),
cloudRelayStore,
syncTunnelClientService,
Expand Down
124 changes: 105 additions & 19 deletions apps/ade-cli/src/serviceManager/installWindows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,29 @@ afterEach(() => {
}
});

/**
* Kills a supervisor started detached through Start-Process. Its pid record is
* the only handle on it, and one read races the supervisor's first
* Write-PidRecord: when the read lost, the supervisor was never killed and
* kept restarting its child for the rest of the run, holding the temp tree
* open (EBUSY on unlink) and starving every later suite. So wait for the
* record before giving up on the kill.
*/
async function killDetachedSupervisor(pidPath: string): Promise<void> {
const killDeadline = Date.now() + 15_000;
let record = readWindowsServicePidRecord({ pidPath });
while (!record?.supervisorPid && Date.now() < killDeadline) {
await new Promise((resolve) => setTimeout(resolve, 20));
record = readWindowsServicePidRecord({ pidPath });
}
if (record?.supervisorPid) {
spawnChildSync("taskkill.exe", ["/PID", String(record.supervisorPid), "/T", "/F"], {
encoding: "utf8",
windowsHide: true,
});
}
}

function makeTempHome(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
Expand Down Expand Up @@ -323,30 +346,93 @@ describe("Windows background service helpers", () => {
args: ["quoted \"value\"", "O'Brien", "100% & $HOME", "naïve-東京-🚀"],
});
} finally {
// The supervisor is detached via Start-Process, so this pid record is
// the only handle on it. Reading it once races the supervisor's first
// Write-PidRecord: when the read lost, the supervisor was never killed
// and kept restarting its child for the rest of the run, holding the
// temp tree open (EBUSY on unlink) and starving every later suite.
// Wait for the record before giving up on the kill.
const pidPath = `${launcherPath}.pid.json`;
const killDeadline = Date.now() + 15_000;
let record = readWindowsServicePidRecord({ pidPath });
while (!record?.supervisorPid && Date.now() < killDeadline) {
await new Promise((resolve) => setTimeout(resolve, 20));
record = readWindowsServicePidRecord({ pidPath });
}
if (record?.supervisorPid) {
spawnChildSync("taskkill.exe", ["/PID", String(record.supervisorPid), "/T", "/F"], {
encoding: "utf8",
windowsHide: true,
});
}
await killDetachedSupervisor(`${launcherPath}.pid.json`);
}
},
90_000,
);

// The drain is C# compiled by Add-Type at supervisor start, so only a real
// Windows PowerShell run can prove it compiles, never blocks the brain on a
// full pipe, splits a line with no newline, keeps stderr, and rotates.
(process.platform === "win32" ? it : it.skip)(
"drains the brain's stdout and stderr into a bounded, rotating output log",
async () => {
const home = makeTempHome("ade windows brain output-");
const launcherPath = path.join(home, "brain-service.ps1");
const outputLogPath = path.join(home, "brain-output.log");
const donePath = path.join(home, "child-done");
// ~220 KB of lines first (past the 64 KiB rotation floor, so the log
// rotates; only one previous file is kept, so the lines checked below
// come last), then 20,000 chars with no newline, then stderr. The child
// then stays alive (a finished child would be restarted and write
// everything again).
const childScript = [
"const fs = require('node:fs');",
"for (let i = 0; i < 2000; i += 1) process.stdout.write(`line-${i} ${'x'.repeat(72)}\\n`);",
"process.stdout.write('L'.repeat(20000));",
"process.stdout.write('\\n');",
"process.stderr.write('stderr-marker\\n');",
"process.stdout.write('last-line\\n', () => fs.writeFileSync(process.env.ADE_TEST_DONE, 'ok'));",
"setInterval(() => {}, 1000);",
].join(" ");
fs.writeFileSync(
launcherPath,
`\uFEFF${renderWindowsServiceLauncher({
command: process.execPath,
args: ["-e", childScript],
env: { ADE_TEST_DONE: donePath },
}, {
pidPath: `${launcherPath}.pid.json`,
initialRestartDelayMs: 100,
brainOutputLogPath: outputLogPath,
brainOutputMaxBytes: 64 * 1024,
})}`,
"utf8",
);

const bootstrap = spawnChildSync(
windowsPowerShellCommand(),
buildWindowsStartLauncherArgs(launcherPath),
{ encoding: "utf8", windowsHide: true },
);
try {
expect(bootstrap.status).toBe(0);
const readLog = (): string =>
[`${outputLogPath}.1`, outputLogPath]
.filter((file) => fs.existsSync(file))
.map((file) => fs.readFileSync(file, "utf8"))
.join("");
// The child's done file only says its writes returned; the drain
// threads (one per stream) append after that, so wait for both.
const drained = (log: string): boolean =>
log.includes(" out last-line") && log.includes(" err stderr-marker");
const deadline = Date.now() + 60_000;
while (!drained(readLog()) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
expect(fs.existsSync(donePath)).toBe(true);
const lines = readLog().split("\r\n").filter(Boolean);
expect(lines.some((line) => line.endsWith(" err stderr-marker"))).toBe(true);
expect(lines.some((line) => line.endsWith(" out last-line"))).toBe(true);
// The no-newline line comes out in bounded pieces (a pending buffer
// under 8,192 chars plus one 8,192-char read), and none of it is lost.
const longPieces = lines
.map((line) => /^\S+ out (L+)$/.exec(line)?.[1] ?? null)
.filter((piece): piece is string => piece !== null);
expect(longPieces.length).toBeGreaterThan(1);
expect(longPieces.every((piece) => piece.length < 2 * 8192)).toBe(true);
expect(longPieces.join("").length).toBe(20000);
// Rotation: one previous file, and the live file stays near the cap.
expect(fs.existsSync(`${outputLogPath}.1`)).toBe(true);
expect(fs.statSync(outputLogPath).size).toBeLessThan(64 * 1024 + 16 * 1024);
} finally {
await killDetachedSupervisor(`${launcherPath}.pid.json`);
}
},
120_000,
);

it("registers and starts the per-user background service without Task Scheduler", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const spawnSync = spawnSequence(calls, [
Expand Down
15 changes: 14 additions & 1 deletion apps/ade-cli/src/services/projects/projectScope.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AdeRuntime, AdeRuntimeSyncOptions } from "../../bootstrap";
import type { SyncCommandPayload } from "../../../../desktop/src/shared/types";
import type { SyncCommandPayload, SyncFileRequest } from "../../../../desktop/src/shared/types";
import type { SyncRemoteCommandExecutionContext } from "../sync/syncRemoteCommandService";
import type { ProjectId, ProjectRecord, ProjectRegistry } from "./projectRegistry";

Expand Down Expand Up @@ -101,6 +101,18 @@ export class ProjectScopeRegistry {
releaseStreamConnection: (connectionId: string): void => {
this.releaseStreamConnection(connectionId);
},
executeFileRequest: async (
projectId: string,
payload: SyncFileRequest,
context: { isMobile: boolean },
): Promise<unknown> => {
const scope = await this.get(projectId);
const syncService = scope.runtime.syncService;
if (!syncService) {
throw new Error(`Phone sync is not available for project ${projectId}.`);
}
return await syncService.executeRemoteFileRequest(payload, context);
},
};

constructor(
Expand Down Expand Up @@ -550,6 +562,7 @@ export class ProjectScopeRegistry {
hostStartupEnabled: isHost ? base.hostStartupEnabled ?? true : false,
hostDiscoveryEnabled: isHost ? base.hostDiscoveryEnabled ?? true : false,
remoteCommandExecutor: base.remoteCommandExecutor ?? this.remoteCommandExecutor,
projectScopes: this,
};
}

Expand Down
8 changes: 8 additions & 0 deletions apps/ade-cli/src/services/sync/deviceRegistryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,19 @@ function isTailscaleAddress(ipAddress: string): boolean {
return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
}

// Host-only virtual adapters (the WSL and Default Switch Hyper-V switches,
// Docker bridges, VM host-only networks). Their addresses exist only inside
// this computer, so a phone that dials them just burns a connect attempt.
// Other `vEthernet (...)` adapters stay: with a Hyper-V External switch, or WSL
// in bridged mode, Windows moves the real LAN address onto one of them.
const VIRTUAL_ADAPTER_PATTERN = /^vEthernet \((WSL|Default Switch)|docker|^br-|virbr|vmnet|vboxnet/i;

function readLocalNetworkMetadata(): LocalNetworkMetadata {
const interfaces = os.networkInterfaces();
const lan: string[] = [];
const tailscale: string[] = [];
for (const [interfaceName, entries] of Object.entries(interfaces)) {
if (VIRTUAL_ADAPTER_PATTERN.test(interfaceName)) continue;
const isLikelyTailscaleInterface = /tailscale|utun|tun/i.test(interfaceName);
for (const entry of entries ?? []) {
if (!entry || entry.internal || entry.family !== "IPv4") continue;
Expand Down
166 changes: 166 additions & 0 deletions apps/ade-cli/src/services/sync/syncFileRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import path from "node:path";
import type { createFileService } from "../../../../desktop/src/main/services/files/fileService";
import { toOptionalString } from "../../../../desktop/src/main/services/shared/utils";
import type {
FileContent,
FilesWorkspace,
SyncFileBlob,
SyncFileRequest,
} from "../../../../desktop/src/shared/types";

// One project's `file_request` handling, with no sync-host state: the host
// runs it for its own project, and the brain runs it for a request that names
// another open project on the machine (a phone reading a lane of that
// checkout). Artifact reads stay with the host, which owns the artifacts.

export function syncFileRequestWorkspaceId(payload: SyncFileRequest): string | null {
switch (payload.action) {
case "listTree":
case "listTreeChildren":
case "refreshGitDecorations":
case "readFile":
case "readFileRange":
case "gitBlame":
case "writeText":
case "createFile":
case "createDirectory":
case "rename":
case "deletePath":
case "watchChanges":
case "stopWatching":
case "quickOpen":
case "searchText":
return toOptionalString(payload.args.workspaceId);
case "listWorkspaces":
case "readArtifact":
case "readArtifactRange":
return null;
default:
return null;
}
}

export function visibleFileWorkspacesForPeer(workspaces: FilesWorkspace[], opts: { isMobile: boolean }): FilesWorkspace[] {
return opts.isMobile ? workspaces.filter((workspace) => workspace.kind !== "external") : workspaces;
}

export function assertFileRequestWorkspaceVisibleToPeer(args: {
isMobile: boolean;
workspace: FilesWorkspace | null;
}): void {
if (args.isMobile && args.workspace?.kind === "external") {
throw new Error("External local files are not available on mobile.");
}
}

/**
* Runs one `file_request` against a project's file service: every action but
* the artifact reads, which belong to the sync host's own project. The host
* uses it for its project; the brain uses it for a request that names another
* open project (a phone reading a lane of this machine's other checkout).
*/
export async function runSyncFileServiceRequest(
fileService: ReturnType<typeof createFileService>,
payload: SyncFileRequest,
opts: { isMobile: boolean },
): Promise<unknown> {
if (opts.isMobile) {
const workspaceId = syncFileRequestWorkspaceId(payload);
assertFileRequestWorkspaceVisibleToPeer({
isMobile: true,
workspace: workspaceId
? fileService.listWorkspaces({ includeArchived: true }).find((entry) => entry.id === workspaceId) ?? null
: null,
});
}
switch (payload.action) {
case "listWorkspaces":
return visibleFileWorkspacesForPeer(fileService.listWorkspaces(payload.args ?? {}), { isMobile: opts.isMobile });
case "listTree":
return await fileService.listTree(payload.args);
case "listTreeChildren":
return await fileService.listTreeChildren(payload.args);
case "refreshGitDecorations":
return await fileService.refreshGitDecorations(payload.args);
case "readFile":
return fileContentToBlob(payload.args.path, await fileService.readFile(payload.args));
case "readFileRange":
return await fileService.readFileRange(payload.args);
case "gitBlame":
return await fileService.blame(payload.args);
case "writeText":
fileService.writeWorkspaceText(payload.args);
return { ok: true };
case "createFile":
fileService.createFile(payload.args);
return { ok: true };
case "createDirectory":
fileService.createDirectory(payload.args);
return { ok: true };
case "rename":
fileService.rename(payload.args);
return { ok: true };
case "deletePath":
fileService.deletePath(payload.args);
return { ok: true };
case "quickOpen":
return await fileService.quickOpen(payload.args);
case "searchText":
return await fileService.searchText(payload.args);
default:
throw new Error(`Unsupported file action: ${(payload as { action?: string }).action ?? "unknown"}`);
}
}

export function inferMimeType(filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
case ".mp4":
return "video/mp4";
case ".mov":
return "video/quicktime";
case ".zip":
return "application/zip";
case ".json":
return "application/json";
case ".md":
return "text/markdown";
case ".txt":
case ".log":
return "text/plain";
case ".yaml":
case ".yml":
return "application/yaml";
default:
return null;
}
}

export function fileContentToBlob(filePath: string, content: FileContent): SyncFileBlob {
return {
path: filePath,
size: content.size,
mimeType: content.mimeType ?? inferMimeType(filePath),
encoding: content.encoding,
isBinary: content.isBinary,
content: content.content,
languageId: content.languageId,
...(content.previewKind ? { previewKind: content.previewKind } : {}),
...(content.dataUrl ? { dataUrl: content.dataUrl } : {}),
...(typeof content.contentOmitted === "boolean" ? { contentOmitted: content.contentOmitted } : {}),
...(content.omittedReason ? { omittedReason: content.omittedReason } : {}),
// Forwarded so a mobile client can say WHY only part of a large file
// arrived. Without these the phone shows a prefix with no explanation.
...(typeof content.isPartial === "boolean" ? { isPartial: content.isPartial } : {}),
...(typeof content.totalSize === "number" ? { totalSize: content.totalSize } : {}),
};
}
Loading
Loading