Skip to content

Commit 4204572

Browse files
authored
Merge pull request #2800 from generalaction/wire-abstraction
feat: wip workspace server worskapce setup
2 parents f3faeb8 + 9a373a3 commit 4204572

217 files changed

Lines changed: 11024 additions & 5758 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/emdash-desktop/electron.vite.config.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@ import tailwindcss from '@tailwindcss/vite';
33
import react from '@vitejs/plugin-react';
44
import { defineConfig } from 'electron-vite';
55

6+
const workspaceAliases = {
7+
'@emdash/core/acp/client': resolve('../../packages/core/src/acp/client.ts'),
8+
'@emdash/core/acp': resolve('../../packages/core/src/acp/index.ts'),
9+
'@emdash/core/agents/plugins/helpers': resolve(
10+
'../../packages/core/src/agents/plugins/helpers/index.ts'
11+
),
12+
'@emdash/core/agents/plugins': resolve('../../packages/core/src/agents/plugins/index.ts'),
13+
'@emdash/plugins/agents/types': resolve('../../packages/plugins/src/agents/types.ts'),
14+
'@emdash/plugins/agents': resolve('../../packages/plugins/src/agents/registry.ts'),
15+
'@emdash/wire/api': resolve('../../packages/wire/src/api/index.ts'),
16+
'@emdash/wire/process': resolve('../../packages/wire/src/process/index.ts'),
17+
'@emdash/wire/util/mobx': resolve('../../packages/wire/src/util/mobx/index.ts'),
18+
'@emdash/wire/util/process-runtime': resolve(
19+
'../../packages/wire/src/util/process-runtime/index.ts'
20+
),
21+
'@emdash/wire': resolve('../../packages/wire/src/index.ts'),
22+
};
23+
624
export default defineConfig({
725
main: {
826
root: 'src/main',
@@ -21,6 +39,7 @@ export default defineConfig({
2139
'@main': resolve('src/main'),
2240
'@shared': resolve('src/shared'),
2341
'@root': resolve('.'),
42+
...workspaceAliases,
2443
},
2544
},
2645
},
@@ -30,6 +49,7 @@ export default defineConfig({
3049
alias: {
3150
'@shared': resolve('src/shared'),
3251
'@root': resolve('.'),
52+
...workspaceAliases,
3353
},
3454
},
3555
},
@@ -42,6 +62,7 @@ export default defineConfig({
4262
'@renderer': resolve('src/renderer'),
4363
'@shared': resolve('src/shared'),
4464
'@root': resolve('.'),
65+
...workspaceAliases,
4566
// cli-agent-plugins metadata/icons chunks transitively reference node:buffer
4667
// (through hook-config helpers bundled in the same tsdown chunk), even though
4768
// those helpers never run in the renderer. Alias to the browser-safe polyfill.

apps/emdash-desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@emdash/plugins": "workspace:*",
5656
"@emdash/shared": "workspace:*",
5757
"@emdash/ui": "workspace:*",
58+
"@emdash/wire": "workspace:*",
5859
"@fontsource-variable/inter": "^5.2.8",
5960
"@fontsource-variable/jetbrains-mono": "5.2.8",
6061
"@gitbeaker/rest": "^43.8.0",

apps/emdash-desktop/src/main/core/acp/agent-status-bridge.ts

Lines changed: 31 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,32 @@
1-
import { acpLiveTopics, sessionSummarySchema, type SessionSummary } from '@emdash/core/acp';
2-
import { LiveModelClient, type LiveSnapshot, type LiveUpdate } from '@emdash/core/live';
1+
import { sessionSummarySchema, type SessionSummary } from '@emdash/core/acp';
32
import type { Unsubscribe } from '@emdash/shared';
3+
import { ReplicaState } from '@emdash/wire';
44
import { z } from 'zod';
55
import { agentHookService } from '@main/core/agent-hooks/agent-hook-service';
66
import { isAppFocused } from '@main/core/agent-hooks/notification';
77
import { log } from '@main/lib/logger';
88
import { deriveAcpAgentStatusActions, type AcpAgentStatusAction } from './agent-status-transition';
9-
import { acpWire } from './controller';
10-
import { acpRuntimeProcessHost } from './runtime-process/host';
9+
import { getAcpRuntimeHandle } from './controller';
1110

1211
type SessionSummaryList = Record<string, SessionSummary>;
1312

1413
const sessionSummaryListSchema = z.record(z.string(), sessionSummarySchema);
1514

1615
class AcpAgentStatusBridge {
1716
private readonly summaries = new Map<string, SessionSummary>();
18-
private startedUnsubscribe: Unsubscribe | null = null;
19-
private wireUnsubscribe: Unsubscribe | null = null;
17+
private processExitUnsubscribe: Unsubscribe | null = null;
18+
private replica: ReplicaState<SessionSummaryList> | null = null;
2019
private attaching = false;
2120

2221
initialize(): void {
23-
this.startedUnsubscribe = acpRuntimeProcessHost.onStarted(() => {
24-
void this.attach().catch((error) => {
25-
log.warn('ACP agent status bridge failed to attach', { error: String(error) });
26-
});
22+
void this.attach().catch((error) => {
23+
log.warn('ACP agent status bridge failed to attach', { error: String(error) });
2724
});
2825
}
2926

3027
dispose(): void {
31-
this.startedUnsubscribe?.();
32-
this.startedUnsubscribe = null;
28+
this.processExitUnsubscribe?.();
29+
this.processExitUnsubscribe = null;
3330
this.detach();
3431
}
3532

@@ -38,58 +35,37 @@ class AcpAgentStatusBridge {
3835
this.attaching = true;
3936
try {
4037
this.detach();
41-
42-
const topic = acpLiveTopics.sessionStateList.topic(undefined);
43-
const client = new LiveModelClient<SessionSummaryList>(
44-
sessionSummaryListSchema,
45-
() => acpWire.live.snapshot(topic) as Promise<LiveSnapshot<SessionSummaryList>>,
46-
(summaries) => void this.applySummaries(summaries)
47-
);
48-
const buffer: LiveUpdate[] = [];
49-
let seeded = false;
50-
let detachLive: Unsubscribe | null = null;
51-
let detachWire: Unsubscribe | null = null;
52-
53-
try {
54-
detachLive = await acpWire.live.attach(topic, (update) => {
55-
if (seeded) {
56-
client.applyUpdate(update);
57-
} else {
58-
buffer.push(update);
59-
}
60-
});
61-
detachWire = acpWire.onDisconnect(() => {
62-
void this.resetAll().catch((error) => {
63-
log.warn('ACP agent status bridge failed to reset statuses on disconnect', {
64-
error: String(error),
65-
});
38+
const handle = await getAcpRuntimeHandle();
39+
this.processExitUnsubscribe = handle.process.onExit((exit) => {
40+
if (exit.willRestart) return;
41+
void this.resetAll().catch((error) => {
42+
log.warn('ACP agent status bridge failed to reset statuses on disconnect', {
43+
error: String(error),
6644
});
67-
this.detach();
6845
});
69-
70-
client.seed((await acpWire.live.snapshot(topic)) as LiveSnapshot<SessionSummaryList>);
71-
seeded = true;
72-
for (const update of buffer) {
73-
client.applyUpdate(update);
46+
this.detach();
47+
});
48+
const replica = new ReplicaState<SessionSummaryList>(
49+
handle.client.sessions.state(undefined, 'list'),
50+
{
51+
schema: sessionSummaryListSchema,
52+
onChange: (summaries) => void this.applySummaries(summaries),
7453
}
75-
76-
this.wireUnsubscribe = () => {
77-
detachLive?.();
78-
detachWire?.();
79-
};
80-
} catch (error) {
81-
detachLive?.();
82-
detachWire?.();
83-
throw error;
84-
}
54+
);
55+
await replica.ready;
56+
this.replica = replica;
57+
this.applySummaries(replica.current());
8558
} finally {
8659
this.attaching = false;
8760
}
8861
}
8962

9063
private detach(): void {
91-
this.wireUnsubscribe?.();
92-
this.wireUnsubscribe = null;
64+
this.processExitUnsubscribe?.();
65+
this.processExitUnsubscribe = null;
66+
const replica = this.replica;
67+
this.replica = null;
68+
if (replica) void replica.dispose();
9369
}
9470

9571
private applySummaries(nextSummaries: SessionSummaryList): void {
Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import type { AcpProcedures } from '@emdash/core/acp';
2-
import { portWire } from '@emdash/core/wire';
3-
import { typedProcedures } from '@emdash/core/wire';
4-
import { exposeWire } from '@main/lib/wire/expose-wire';
5-
import { acpRuntimeProcessHost } from './runtime-process/host';
6-
7-
export const acpWire = portWire(acpRuntimeProcessHost.transport());
8-
9-
export const acpRuntimeProcedures = typedProcedures<AcpProcedures>(acpWire.procedures);
10-
export const acpController = exposeWire('acp', acpWire);
1+
export {
2+
disposeAcpRuntimeProcess,
3+
getAcpRuntimeClient,
4+
getAcpRuntimeHandle,
5+
initializeAcpRuntimeProcess,
6+
type AcpRuntimeClient,
7+
} from './runtime-process/host';

apps/emdash-desktop/src/main/core/acp/runtime-process/child-process-host.ts

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,14 @@ import { spawn } from 'node:child_process';
22
import { EventEmitter } from 'node:events';
33
import { mkdir, readFile, writeFile } from 'node:fs/promises';
44
import type {
5+
acpHostContract,
56
AcpFs,
67
AcpProcessHandle,
78
AcpProcessHost,
89
AcpTerminalExit,
910
AcpTerminalProcess,
1011
} from '@emdash/core/acp';
11-
import type { UtilityParentPort } from './protocol';
12-
13-
type PendingResolve = {
14-
resolve(value: { cli: string; agentEnv: Record<string, string> }): void;
15-
reject(error: Error): void;
16-
};
12+
import type { ContractClient } from '@emdash/wire/api';
1713

1814
class ChildProcessHandle implements AcpProcessHandle {
1915
constructor(private readonly child: ReturnType<typeof spawn>) {}
@@ -95,30 +91,13 @@ const fsPort: AcpFs = {
9591

9692
export class ChildAcpProcessHost implements AcpProcessHost {
9793
readonly fs = fsPort;
98-
private readonly pending = new Map<string, PendingResolve>();
99-
100-
constructor(private readonly parentPort: UtilityParentPort) {}
101-
102-
handleMessage(message: unknown): void {
103-
if (!isResolveResult(message)) return;
104-
const pending = this.pending.get(message.requestId);
105-
if (!pending) return;
106-
this.pending.delete(message.requestId);
107-
if (message.ok) {
108-
pending.resolve(message.value);
109-
} else {
110-
pending.reject(new Error(message.error));
111-
}
112-
}
94+
95+
constructor(private readonly host: ContractClient<typeof acpHostContract>) {}
11396

11497
resolveSpawnContext(
11598
providerId: string
11699
): Promise<{ cli: string; agentEnv: Record<string, string> }> {
117-
const requestId = crypto.randomUUID();
118-
return new Promise((resolve, reject) => {
119-
this.pending.set(requestId, { resolve, reject });
120-
this.parentPort.postMessage({ type: 'resolve-spawn-context', requestId, providerId });
121-
});
100+
return this.host.resolveSpawnContext({ providerId });
122101
}
123102

124103
async spawn(spec: {
@@ -155,18 +134,3 @@ export class ChildAcpProcessHost implements AcpProcessHost {
155134
return new ChildTerminalProcess(child);
156135
}
157136
}
158-
159-
function isResolveResult(value: unknown): value is {
160-
type: 'resolve-spawn-context-result';
161-
requestId: string;
162-
ok: boolean;
163-
value: { cli: string; agentEnv: Record<string, string> };
164-
error: string;
165-
} {
166-
return (
167-
typeof value === 'object' &&
168-
value !== null &&
169-
(value as { type?: unknown }).type === 'resolve-spawn-context-result' &&
170-
typeof (value as { requestId?: unknown }).requestId === 'string'
171-
);
172-
}

0 commit comments

Comments
 (0)