authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-22 01:00:48-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log7a5ea7a5d8d2a779cafb6413306eed554977825c
tree598e736705c387165b9252dc03cd8d4e7342a042
parentc71e71b5acacb74e201b3f4692ef08ed1dce3a28

more effect: ProjectRegistry and shared runtime


27 files changed, 1204 insertions(+), 1135 deletions(-)

apps/server/src/index.ts-3
...@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";...@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
66
7import { fixPath } from "./fixPath";7import { fixPath } from "./fixPath";
8import { createLogger } from "./logger";8import { createLogger } from "./logger";
9import { ProjectRegistry } from "./projectRegistry";
10import { createServer } from "./wsServer";9import { createServer } from "./wsServer";
1110
12fixPath();11fixPath();
...@@ -102,7 +101,6 @@ async function main() {...@@ -102,7 +101,6 @@ async function main() {
102 const port =101 const port =
103 requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT));102 requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT));
104 const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR);103 const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR);
105 const projectRegistry = new ProjectRegistry(stateDir);
106 const devUrl = process.env.VITE_DEV_SERVER_URL;104 const devUrl = process.env.VITE_DEV_SERVER_URL;
107 const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop";105 const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop";
108 const authToken = process.env.T3CODE_AUTH_TOKEN;106 const authToken = process.env.T3CODE_AUTH_TOKEN;
...@@ -121,7 +119,6 @@ async function main() {...@@ -121,7 +119,6 @@ async function main() {
121 stateDir,119 stateDir,
122 staticDir,120 staticDir,
123 devUrl,121 devUrl,
124 projectRegistry,
125 authToken,122 authToken,
126 });123 });
127 await server.start();124 await server.start();
apps/server/src/orchestration/engine.test.ts+53-53
...@@ -8,7 +8,13 @@ import { afterEach, describe, expect, it } from "vitest";...@@ -8,7 +8,13 @@ import { afterEach, describe, expect, it } from "vitest";
88
9import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents";9import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents";
10import { OrchestrationEngine } from "./engine";10import { OrchestrationEngine } from "./engine";
11import { createOrchestrationSystem } from "./runtime";11
12import { Layer, ManagedRuntime } from "effect";
13
14import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite";
15
16import { OrchestrationLive } from "./layers";
17import { OrchestrationEngineService } from "./services";
1218
13const tempDirs: string[] = [];19const tempDirs: string[] = [];
1420
...@@ -24,6 +30,19 @@ afterEach(() => {...@@ -24,6 +30,19 @@ afterEach(() => {
24 }30 }
25});31});
2632
33export async function createOrchestrationSystem(stateDir: string) {
34 const dbPath = path.join(stateDir, "orchestration.sqlite");
35 const orchestrationLayer = OrchestrationLive.pipe(
36 Layer.provide(makeSqlitePersistenceLive(dbPath)),
37 );
38 const runtime = ManagedRuntime.make(orchestrationLayer);
39 const engine = await runtime.runPromise(OrchestrationEngineService);
40 return {
41 engine,
42 dispose: () => runtime.dispose(),
43 };
44}
45
27describe("OrchestrationEngine", () => {46describe("OrchestrationEngine", () => {
28 it("replays to the same deterministic snapshot", async () => {47 it("replays to the same deterministic snapshot", async () => {
29 const stateDir = makeTempDir("t3code-orchestration-");48 const stateDir = makeTempDir("t3code-orchestration-");
...@@ -33,18 +52,9 @@ describe("OrchestrationEngine", () => {...@@ -33,18 +52,9 @@ describe("OrchestrationEngine", () => {
3352
34 const firstSystem = await createOrchestrationSystem(stateDir);53 const firstSystem = await createOrchestrationSystem(stateDir);
35 const engineA = firstSystem.engine;54 const engineA = firstSystem.engine;
36 await engineA.dispatch({
37 type: "project.create",
38 commandId: "cmd-1",
39 projectId,
40 name: "demo",
41 cwd: "/tmp/demo",
42 model: "gpt-5-codex",
43 createdAt,
44 });
45 await engineA.dispatch({55 await engineA.dispatch({
46 type: "thread.create",56 type: "thread.create",
47 commandId: "cmd-2",57 commandId: "cmd-1",
48 threadId,58 threadId,
49 projectId,59 projectId,
50 title: "Thread",60 title: "Thread",
...@@ -82,12 +92,14 @@ describe("OrchestrationEngine", () => {...@@ -82,12 +92,14 @@ describe("OrchestrationEngine", () => {
82 updates.push(snapshot.sequence);92 updates.push(snapshot.sequence);
83 });93 });
84 await engine.dispatch({94 await engine.dispatch({
85 type: "project.create",95 type: "thread.create",
86 commandId: "cmd-project",96 commandId: "cmd-thread",
97 threadId: "thread-2",
87 projectId: "project-2",98 projectId: "project-2",
88 name: "fanout",99 title: "fanout",
89 cwd: "/tmp/fanout",
90 model: "gpt-5-codex",100 model: "gpt-5-codex",
101 branch: null,
102 worktreePath: null,
91 createdAt: new Date().toISOString(),103 createdAt: new Date().toISOString(),
92 });104 });
93 unsubscribe();105 unsubscribe();
...@@ -100,24 +112,26 @@ describe("OrchestrationEngine", () => {...@@ -100,24 +112,26 @@ describe("OrchestrationEngine", () => {
100 const system = await createOrchestrationSystem(stateDir);112 const system = await createOrchestrationSystem(stateDir);
101 const engine = system.engine;113 const engine = system.engine;
102 await engine.dispatch({114 await engine.dispatch({
103 type: "project.create",115 type: "thread.create",
104 commandId: "cmd-a",116 commandId: "cmd-a",
117 threadId: "thread-replay",
105 projectId: "project-replay",118 projectId: "project-replay",
106 name: "replay",119 title: "replay",
107 cwd: "/tmp/replay",
108 model: "gpt-5-codex",120 model: "gpt-5-codex",
121 branch: null,
122 worktreePath: null,
109 createdAt: new Date().toISOString(),123 createdAt: new Date().toISOString(),
110 });124 });
111 await engine.dispatch({125 await engine.dispatch({
112 type: "project.delete",126 type: "thread.delete",
113 commandId: "cmd-b",127 commandId: "cmd-b",
114 projectId: "project-replay",128 threadId: "thread-replay",
115 createdAt: new Date().toISOString(),129 createdAt: new Date().toISOString(),
116 });130 });
117 const events = await engine.replayEvents(0);131 const events = await engine.replayEvents(0);
118 expect(events.length).toBe(2);132 expect(events.length).toBe(2);
119 expect(events[0]?.type).toBe("project.created");133 expect(events[0]?.type).toBe("thread.created");
120 expect(events[1]?.type).toBe("project.deleted");134 expect(events[1]?.type).toBe("thread.deleted");
121 await system.dispose();135 await system.dispose();
122 });136 });
123137
...@@ -126,15 +140,6 @@ describe("OrchestrationEngine", () => {...@@ -126,15 +140,6 @@ describe("OrchestrationEngine", () => {
126 const firstSystem = await createOrchestrationSystem(stateDir);140 const firstSystem = await createOrchestrationSystem(stateDir);
127 const engine = firstSystem.engine;141 const engine = firstSystem.engine;
128 const completedAt = new Date().toISOString();142 const completedAt = new Date().toISOString();
129 await engine.dispatch({
130 type: "project.create",
131 commandId: "cmd-project-turn-diff",
132 projectId: "project-turn-diff",
133 name: "turn-diff",
134 cwd: "/tmp/turn-diff",
135 model: "gpt-5-codex",
136 createdAt: completedAt,
137 });
138 await engine.dispatch({143 await engine.dispatch({
139 type: "thread.create",144 type: "thread.create",
140 commandId: "cmd-thread-turn-diff",145 commandId: "cmd-thread-turn-diff",
...@@ -158,7 +163,9 @@ describe("OrchestrationEngine", () => {...@@ -158,7 +163,9 @@ describe("OrchestrationEngine", () => {
158 createdAt: completedAt,163 createdAt: completedAt,
159 });164 });
160165
161 const firstThread = engine.getSnapshot().threads.find((thread) => thread.id === "thread-turn-diff");166 const firstThread = engine
167 .getSnapshot()
168 .threads.find((thread) => thread.id === "thread-turn-diff");
162 expect(firstThread?.turnDiffSummaries).toEqual([169 expect(firstThread?.turnDiffSummaries).toEqual([
163 {170 {
164 turnId: "turn-1",171 turnId: "turn-1",
...@@ -192,15 +199,6 @@ describe("OrchestrationEngine", () => {...@@ -192,15 +199,6 @@ describe("OrchestrationEngine", () => {
192 const firstSystem = await createOrchestrationSystem(stateDir);199 const firstSystem = await createOrchestrationSystem(stateDir);
193 const engine = firstSystem.engine;200 const engine = firstSystem.engine;
194 const createdAt = new Date().toISOString();201 const createdAt = new Date().toISOString();
195 await engine.dispatch({
196 type: "project.create",
197 commandId: "cmd-project-revert",
198 projectId: "project-revert",
199 name: "revert",
200 cwd: "/tmp/revert",
201 model: "gpt-5-codex",
202 createdAt,
203 });
204 await engine.dispatch({202 await engine.dispatch({
205 type: "thread.create",203 type: "thread.create",
206 commandId: "cmd-thread-revert",204 commandId: "cmd-thread-revert",
...@@ -334,9 +332,7 @@ describe("OrchestrationEngine", () => {...@@ -334,9 +332,7 @@ describe("OrchestrationEngine", () => {
334 append(event) {332 append(event) {
335 if (shouldFailFirstAppend) {333 if (shouldFailFirstAppend) {
336 shouldFailFirstAppend = false;334 shouldFailFirstAppend = false;
337 return Effect.sync(() => {335 return Effect.die(new Error("append failed"));
338 throw new Error("append failed");
339 });
340 }336 }
341 const savedEvent = {337 const savedEvent = {
342 ...event,338 ...event,
...@@ -360,28 +356,32 @@ describe("OrchestrationEngine", () => {...@@ -360,28 +356,32 @@ describe("OrchestrationEngine", () => {
360356
361 await expect(357 await expect(
362 engine.dispatch({358 engine.dispatch({
363 type: "project.create",359 type: "thread.create",
364 commandId: "cmd-flaky-1",360 commandId: "cmd-flaky-1",
365 projectId: "project-flaky-fail",361 threadId: "thread-flaky-fail",
366 name: "flaky-fail",362 projectId: "project-flaky",
367 cwd: "/tmp/flaky-fail",363 title: "flaky-fail",
368 model: "gpt-5-codex",364 model: "gpt-5-codex",
365 branch: null,
366 worktreePath: null,
369 createdAt,367 createdAt,
370 }),368 }),
371 ).rejects.toThrow("append failed");369 ).rejects.toThrow("append failed");
372370
373 const result = await engine.dispatch({371 const result = await engine.dispatch({
374 type: "project.create",372 type: "thread.create",
375 commandId: "cmd-flaky-2",373 commandId: "cmd-flaky-2",
376 projectId: "project-flaky-ok",374 threadId: "thread-flaky-ok",
377 name: "flaky-ok",375 projectId: "project-flaky",
378 cwd: "/tmp/flaky-ok",376 title: "flaky-ok",
379 model: "gpt-5-codex",377 model: "gpt-5-codex",
378 branch: null,
379 worktreePath: null,
380 createdAt,380 createdAt,
381 });381 });
382382
383 expect(result.sequence).toBe(1);383 expect(result.sequence).toBe(1);
384 expect(engine.getSnapshot().projects.map((project) => project.id)).toEqual(["project-flaky-ok"]);384 expect(engine.getSnapshot().sequence).toBe(1);
385385
386 await engine.stop();386 await engine.stop();
387 });387 });
apps/server/src/orchestration/engine.ts+11-48
...@@ -34,36 +34,6 @@ function asError(error: unknown, fallbackMessage: string): Error {...@@ -34,36 +34,6 @@ function asError(error: unknown, fallbackMessage: string): Error {
34function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> {34function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> {
35 const eventId = crypto.randomUUID();35 const eventId = crypto.randomUUID();
36 switch (command.type) {36 switch (command.type) {
37 case "project.create":
38 return {
39 eventId,
40 type: "project.created",
41 aggregateType: "project",
42 aggregateId: command.projectId,
43 occurredAt: command.createdAt,
44 commandId: command.commandId,
45 payload: {
46 id: command.projectId,
47 name: command.name,
48 cwd: command.cwd,
49 model: command.model,
50 createdAt: command.createdAt,
51 updatedAt: command.createdAt,
52 },
53 };
54 case "project.delete":
55 return {
56 eventId,
57 type: "project.deleted",
58 aggregateType: "project",
59 aggregateId: command.projectId,
60 occurredAt: command.createdAt,
61 commandId: command.commandId,
62 payload: {
63 id: command.projectId,
64 deletedAt: command.createdAt,
65 },
66 };
67 case "thread.create":37 case "thread.create":
68 return {38 return {
69 eventId,39 eventId,
...@@ -226,10 +196,7 @@ export class OrchestrationEngine {...@@ -226,10 +196,7 @@ export class OrchestrationEngine {
226 return Effect.gen(this, function* () {196 return Effect.gen(this, function* () {
227 const eventBase = mapCommandToEvent(envelope.command);197 const eventBase = mapCommandToEvent(envelope.command);
228 const savedEvent = yield* this.eventStore.append(eventBase);198 const savedEvent = yield* this.eventStore.append(eventBase);
229199 this.readModel = yield* reduceEvent(this.readModel, savedEvent);
230 yield* Effect.sync(() => {
231 this.readModel = reduceEvent(this.readModel, savedEvent);
232 });
233200
234 const snapshot = this.readModel;201 const snapshot = this.readModel;
235 yield* Effect.all([202 yield* Effect.all([
...@@ -237,14 +204,12 @@ export class OrchestrationEngine {...@@ -237,14 +204,12 @@ export class OrchestrationEngine {
237 PubSub.publish(this.readModelPubSub, snapshot),204 PubSub.publish(this.readModelPubSub, snapshot),
238 ]);205 ]);
239206
240 yield* Effect.sync(() => {207 for (const listener of this.domainEventListeners) {
241 for (const listener of this.domainEventListeners) {208 listener(savedEvent);
242 listener(savedEvent);209 }
243 }210 for (const listener of this.readModelListeners) {
244 for (const listener of this.readModelListeners) {211 listener(snapshot);
245 listener(snapshot);212 }
246 }
247 });
248213
249 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });214 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });
250 }).pipe(215 }).pipe(
...@@ -257,14 +222,12 @@ export class OrchestrationEngine {...@@ -257,14 +222,12 @@ export class OrchestrationEngine {
257 );222 );
258 }223 }
259224
260 private bootstrapReadModel(): Effect.Effect<void> {225 private bootstrapReadModel(): Effect.Effect<void, unknown> {
261 return Effect.gen(this, function* () {226 return Effect.gen(this, function* () {
262 const existingEvents = yield* this.eventStore.readAll();227 const existingEvents = yield* this.eventStore.readAll();
263 yield* Effect.sync(() => {228 for (const event of existingEvents) {
264 for (const event of existingEvents) {229 this.readModel = yield* reduceEvent(this.readModel, event);
265 this.readModel = reduceEvent(this.readModel, event);230 }
266 }
267 });
268 });231 });
269 }232 }
270233
apps/server/src/orchestration/layers.test.ts deleted-47
...@@ -1,47 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { createOrchestrationSystem } from "./runtime";
8
9const tempDirs: string[] = [];
10
11function makeTempDir(prefix: string): string {
12 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13 tempDirs.push(dir);
14 return dir;
15}
16
17afterEach(() => {
18 for (const dir of tempDirs.splice(0, tempDirs.length)) {
19 fs.rmSync(dir, { recursive: true, force: true });
20 }
21});
22
23describe("orchestration layers", () => {
24 it("creates an engine through Context.Tag wiring", async () => {
25 const stateDir = makeTempDir("t3code-orchestration-layer-");
26 const createdAt = new Date().toISOString();
27 const firstSystem = await createOrchestrationSystem(stateDir);
28 const { engine } = firstSystem;
29
30 await engine.dispatch({
31 type: "project.create",
32 commandId: "cmd-layer-1",
33 projectId: "project-layer-1",
34 name: "layer",
35 cwd: "/tmp/layer",
36 model: "gpt-5-codex",
37 createdAt,
38 });
39 const firstSnapshot = engine.getSnapshot();
40 await firstSystem.dispose();
41
42 const secondSystem = await createOrchestrationSystem(stateDir);
43 const restarted = secondSystem.engine;
44 expect(restarted.getSnapshot()).toEqual(firstSnapshot);
45 await secondSystem.dispose();
46 });
47});
apps/server/src/orchestration/layers.ts+2-11
...@@ -1,18 +1,9 @@...@@ -1,18 +1,9 @@
1import path from "node:path";
2
3import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
42
5import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents";3import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents";
6import { makeSqliteOrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents";4import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents";
7import { OrchestrationEngine } from "./engine";5import { OrchestrationEngine } from "./engine";
8import { OrchestrationConfig, OrchestrationEngineService } from "./services";6import { OrchestrationEngineService } from "./services";
9
10export const OrchestrationEventRepositoryLive = Layer.unwrapEffect(
11 Effect.map(OrchestrationConfig, ({ stateDir }) => {
12 const dbPath = path.join(stateDir, "orchestration.sqlite");
13 return makeSqliteOrchestrationEventRepositoryLive(dbPath);
14 }),
15);
167
17export const OrchestrationEngineLive = Layer.scoped(8export const OrchestrationEngineLive = Layer.scoped(
18 OrchestrationEngineService,9 OrchestrationEngineService,
apps/server/src/orchestration/reducer.test.ts created+97
...@@ -0,0 +1,97 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";
2import { Effect } from "effect";
3import { describe, expect, it } from "vitest";
4
5import { createEmptyReadModel, reduceEvent } from "./reducer";
6
7describe("orchestration reducer", () => {
8 it("applies thread.created events using runtime schema decoding", async () => {
9 const now = new Date().toISOString();
10 const model = createEmptyReadModel(now);
11 const event: OrchestrationEvent = {
12 sequence: 1,
13 eventId: "event-thread-created",
14 type: "thread.created",
15 aggregateType: "thread",
16 aggregateId: "thread-1",
17 occurredAt: now,
18 commandId: "cmd-1",
19 payload: {
20 id: "thread-1",
21 projectId: "project-1",
22 title: "demo",
23 model: "gpt-5-codex",
24 branch: null,
25 worktreePath: null,
26 createdAt: now,
27 updatedAt: now,
28 },
29 };
30
31 const next = await Effect.runPromise(reduceEvent(model, event));
32 expect(next.sequence).toBe(1);
33 expect(next.threads).toEqual([
34 {
35 id: "thread-1",
36 projectId: "project-1",
37 title: "demo",
38 model: "gpt-5-codex",
39 branch: null,
40 worktreePath: null,
41 createdAt: now,
42 updatedAt: now,
43 latestTurnId: null,
44 latestTurnStartedAt: null,
45 latestTurnCompletedAt: null,
46 latestTurnDurationMs: null,
47 messages: [],
48 session: null,
49 turnDiffSummaries: [],
50 error: null,
51 },
52 ]);
53 });
54
55 it("fails when event payload cannot be decoded by runtime schema", async () => {
56 const now = new Date().toISOString();
57 const model = createEmptyReadModel(now);
58 const event: OrchestrationEvent = {
59 sequence: 1,
60 eventId: "event-message-invalid",
61 type: "message.sent",
62 aggregateType: "thread",
63 aggregateId: "thread-1",
64 occurredAt: now,
65 commandId: "cmd-1",
66 payload: {
67 id: "message-1",
68 threadId: "thread-1",
69 text: "hello",
70 createdAt: now,
71 streaming: false,
72 },
73 };
74
75 await expect(Effect.runPromise(reduceEvent(model, event))).rejects.toBeDefined();
76 });
77
78 it("keeps reducer forward-compatible for unknown event types", async () => {
79 const now = new Date().toISOString();
80 const model = createEmptyReadModel(now);
81 const event: OrchestrationEvent = {
82 sequence: 7,
83 eventId: "event-unknown",
84 type: "custom.event.ignored",
85 aggregateType: "unknown",
86 aggregateId: "aggregate-1",
87 occurredAt: "2026-01-01T00:00:00.000Z",
88 commandId: null,
89 payload: { test: true },
90 };
91
92 const next = await Effect.runPromise(reduceEvent(model, event));
93 expect(next.sequence).toBe(7);
94 expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z");
95 expect(next.threads).toEqual([]);
96 });
97});
apps/server/src/orchestration/reducer.ts+265-261
...@@ -2,11 +2,20 @@ import type {...@@ -2,11 +2,20 @@ import type {
2 OrchestrationEvent,2 OrchestrationEvent,
3 OrchestrationGitReadModel,3 OrchestrationGitReadModel,
4 OrchestrationMessage,4 OrchestrationMessage,
5 OrchestrationProject,
6 OrchestrationReadModel,5 OrchestrationReadModel,
7 OrchestrationSession,6 OrchestrationSession,
8 OrchestrationThread,7 OrchestrationThread,
9} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import {
10 OrchestrationGitReadModelSchema,
11 OrchestrationMessageSchema,
12 OrchestrationReadModelSchema,
13 OrchestrationSessionSchema,
14 OrchestrationThreadSchema,
15 OrchestrationTurnDiffFileSchema,
16 OrchestrationTurnDiffSummarySchema,
17} from "@t3tools/contracts";
18import { Effect, Schema } from "effect";
1019
11type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;20type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
1221
...@@ -18,38 +27,84 @@ function updateThread(...@@ -18,38 +27,84 @@ function updateThread(
18 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));27 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
19}28}
2029
21function asObject(value: unknown): Record<string, unknown> | null {30const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModelSchema);
22 if (!value || typeof value !== "object") return null;31const decodeMessage = Schema.decodeUnknown(OrchestrationMessageSchema);
23 return value as Record<string, unknown>;32const decodeSession = Schema.decodeUnknown(OrchestrationSessionSchema);
24}33const decodeThread = Schema.decodeUnknown(OrchestrationThreadSchema);
34const decodeGitReadModel = Schema.decodeUnknown(OrchestrationGitReadModelSchema);
35const decodeTurnDiffSummary = Schema.decodeUnknown(OrchestrationTurnDiffSummarySchema);
2536
26function asString(value: unknown): string | null {37const ThreadCreatedPayloadSchema = Schema.Struct({
27 return typeof value === "string" ? value : null;38 id: Schema.String,
28}39 projectId: Schema.String,
40 title: Schema.String,
41 model: Schema.String,
42 branch: Schema.NullOr(Schema.String),
43 worktreePath: Schema.NullOr(Schema.String),
44 createdAt: Schema.String,
45 updatedAt: Schema.String,
46});
47const decodeThreadCreatedPayload = Schema.decodeUnknown(ThreadCreatedPayloadSchema);
2948
30function asNumber(value: unknown): number | null {49const ThreadMetaUpdatedPayloadSchema = Schema.Struct({
31 return typeof value === "number" && Number.isFinite(value) ? value : null;50 threadId: Schema.String,
32}51 title: Schema.optional(Schema.String),
52 model: Schema.optional(Schema.String),
53 branch: Schema.optional(Schema.NullOr(Schema.String)),
54 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
55 updatedAt: Schema.String,
56});
57const decodeThreadMetaUpdatedPayload = Schema.decodeUnknown(ThreadMetaUpdatedPayloadSchema);
3358
34function asArray(value: unknown): unknown[] {59const MessageSentPayloadSchema = Schema.Struct({
35 return Array.isArray(value) ? value : [];60 id: Schema.String,
36}61 role: Schema.Literal("user", "assistant"),
62 text: Schema.String,
63 threadId: Schema.String,
64 createdAt: Schema.String,
65 streaming: Schema.Boolean,
66});
67const decodeMessageSentPayload = Schema.decodeUnknown(MessageSentPayloadSchema);
68
69const ThreadSessionSetPayloadSchema = Schema.Struct({
70 threadId: Schema.String,
71 session: OrchestrationSessionSchema,
72});
73const decodeThreadSessionSetPayload = Schema.decodeUnknown(ThreadSessionSetPayloadSchema);
74
75const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({
76 threadId: Schema.String,
77 turnId: Schema.String,
78 completedAt: Schema.String,
79 status: Schema.optional(Schema.String),
80 files: Schema.Array(OrchestrationTurnDiffFileSchema),
81 assistantMessageId: Schema.optional(Schema.String),
82 checkpointTurnCount: Schema.optional(Schema.Number),
83});
84const decodeThreadTurnDiffCompletedPayload = Schema.decodeUnknown(
85 ThreadTurnDiffCompletedPayloadSchema,
86);
87
88const ThreadRevertedPayloadSchema = Schema.Struct({
89 threadId: Schema.String,
90 turnCount: Schema.Number,
91 messageCount: Schema.Number,
92});
93const decodeThreadRevertedPayload = Schema.decodeUnknown(ThreadRevertedPayloadSchema);
3794
38export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {95export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
39 return {96 return decodeReadModelSync({
40 sequence: 0,97 sequence: 0,
41 projects: [],
42 threads: [],98 threads: [],
43 gitByProjectId: {},99 gitByProjectId: {},
44 updatedAt: nowIso,100 updatedAt: nowIso,
45 };101 });
46}102}
47103
48export function reduceEvent(104export function reduceEvent(
49 model: OrchestrationReadModel,105 model: OrchestrationReadModel,
50 event: OrchestrationEvent,106 event: OrchestrationEvent,
51): OrchestrationReadModel {107): Effect.Effect<OrchestrationReadModel, unknown> {
52 const payload = asObject(event.payload);
53 const nextBase: OrchestrationReadModel = {108 const nextBase: OrchestrationReadModel = {
54 ...model,109 ...model,
55 sequence: event.sequence,110 sequence: event.sequence,
...@@ -57,266 +112,215 @@ export function reduceEvent(...@@ -57,266 +112,215 @@ export function reduceEvent(
57 };112 };
58113
59 switch (event.type) {114 switch (event.type) {
60 case "project.created": {115 case "thread.created":
61 if (!payload) return nextBase;116 return Effect.gen(function* () {
62 const project: OrchestrationProject = {117 const payload = yield* decodeThreadCreatedPayload(event.payload);
63 id: asString(payload.id) ?? event.aggregateId,118 const thread: OrchestrationThread = yield* decodeThread({
64 name: asString(payload.name) ?? "project",119 id: payload.id,
65 cwd: asString(payload.cwd) ?? "",120 projectId: payload.projectId,
66 model: asString(payload.model) ?? "gpt-5-codex",121 title: payload.title,
67 createdAt: asString(payload.createdAt) ?? event.occurredAt,122 model: payload.model,
68 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,123 branch: payload.branch,
69 };124 worktreePath: payload.worktreePath,
70 const existing = nextBase.projects.find((entry) => entry.id === project.id);125 createdAt: payload.createdAt,
71 return {126 updatedAt: payload.updatedAt,
72 ...nextBase,127 latestTurnId: null,
73 projects: existing128 latestTurnStartedAt: null,
74 ? nextBase.projects.map((entry) => (entry.id === project.id ? project : entry))129 latestTurnCompletedAt: null,
75 : [...nextBase.projects, project],130 latestTurnDurationMs: null,
76 };131 messages: [],
77 }132 session: null,
78 case "project.deleted":133 turnDiffSummaries: [],
79 return {134 error: null,
80 ...nextBase,135 });
81 projects: nextBase.projects.filter((project) => project.id !== event.aggregateId),136 const existing = nextBase.threads.find((entry) => entry.id === thread.id);
82 threads: nextBase.threads.filter((thread) => thread.projectId !== event.aggregateId),137 return {
83 };138 ...nextBase,
84 case "thread.created": {139 threads: existing
85 if (!payload) return nextBase;140 ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry))
86 const thread: OrchestrationThread = {141 : [...nextBase.threads, thread],
87 id: asString(payload.id) ?? event.aggregateId,142 };
88 projectId: asString(payload.projectId) ?? "",143 });
89 title: asString(payload.title) ?? "New thread",
90 model: asString(payload.model) ?? "gpt-5-codex",
91 branch: asString(payload.branch),
92 worktreePath: asString(payload.worktreePath),
93 createdAt: asString(payload.createdAt) ?? event.occurredAt,
94 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
95 latestTurnId: null,
96 latestTurnStartedAt: null,
97 latestTurnCompletedAt: null,
98 latestTurnDurationMs: null,
99 messages: [],
100 session: null,
101 turnDiffSummaries: [],
102 error: null,
103 };
104 const existing = nextBase.threads.find((entry) => entry.id === thread.id);
105 return {
106 ...nextBase,
107 threads: existing
108 ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry))
109 : [...nextBase.threads, thread],
110 };
111 }
112 case "thread.deleted":144 case "thread.deleted":
113 return {145 return Effect.succeed({
114 ...nextBase,146 ...nextBase,
115 threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId),147 threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId),
116 };148 });
117 case "thread.meta-updated": {149 case "thread.meta-updated":
118 if (!payload) return nextBase;150 return decodeThreadMetaUpdatedPayload(event.payload).pipe(
119 const threadId = asString(payload.threadId) ?? event.aggregateId;151 Effect.map((payload) => {
120 const thread = nextBase.threads.find((entry) => entry.id === threadId);152 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
121 if (!thread) return nextBase;153 if (!thread) {
122 return {154 return nextBase;
123 ...nextBase,155 }
124 threads: updateThread(nextBase.threads, threadId, {156 return {
125 ...(asString(payload.title) !== null ? { title: asString(payload.title) ?? thread.title } : {}),157 ...nextBase,
126 ...(asString(payload.model) !== null ? { model: asString(payload.model) ?? thread.model } : {}),158 threads: updateThread(nextBase.threads, payload.threadId, {
127 ...(payload.branch !== undefined ? { branch: asString(payload.branch) } : {}),159 ...(payload.title !== undefined ? { title: payload.title } : {}),
128 ...(payload.worktreePath !== undefined ? { worktreePath: asString(payload.worktreePath) } : {}),160 ...(payload.model !== undefined ? { model: payload.model } : {}),
129 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,161 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
162 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
163 updatedAt: payload.updatedAt,
164 }),
165 };
130 }),166 }),
131 };167 );
132 }168 case "message.sent":
133 case "message.sent": {169 return Effect.gen(function* () {
134 if (!payload) return nextBase;170 const payload = yield* decodeMessageSentPayload(event.payload);
135 const threadId = asString(payload.threadId) ?? event.aggregateId;171 const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId);
136 const message: OrchestrationMessage = {172 if (!targetThread) {
137 id: asString(payload.id) ?? crypto.randomUUID(),173 return nextBase;
138 role: (asString(payload.role) as OrchestrationMessage["role"] | null) ?? "user",174 }
139 text: asString(payload.text) ?? "",175
140 createdAt: asString(payload.createdAt) ?? event.occurredAt,176 const message: OrchestrationMessage = yield* decodeMessage({
141 streaming: payload.streaming === true,177 id: payload.id,
142 };178 role: payload.role,
143 const targetThread = nextBase.threads.find((thread) => thread.id === threadId);179 text: payload.text,
144 if (!targetThread) return nextBase;180 createdAt: payload.createdAt,
145 const existingMessage = targetThread.messages.find((entry) => entry.id === message.id);181 streaming: payload.streaming,
146 const nextMessages = existingMessage182 });
147 ? targetThread.messages.map((entry) =>183 const existingMessage = targetThread.messages.find((entry) => entry.id === message.id);
148 entry.id === message.id184 const nextMessages = existingMessage
149 ? {185 ? targetThread.messages.map((entry) =>
150 ...entry,186 entry.id === message.id
151 text:187 ? {
152 message.streaming188 ...entry,
189 text: message.streaming
153 ? `${entry.text}${message.text}`190 ? `${entry.text}${message.text}`
154 : message.text.length > 0191 : message.text.length > 0
155 ? message.text192 ? message.text
156 : entry.text,193 : entry.text,
157 streaming: message.streaming,194 streaming: message.streaming,
158 createdAt: message.createdAt,195 createdAt: message.createdAt,
159 }196 }
160 : entry,197 : entry,
161 )198 )
162 : [...targetThread.messages, message];199 : [...targetThread.messages, message];
163 return {
164 ...nextBase,
165 threads: updateThread(nextBase.threads, threadId, {
166 messages: nextMessages,
167 updatedAt: event.occurredAt,
168 }),
169 };
170 }
171 case "thread.session-set": {
172 if (!payload) return nextBase;
173 const threadId = asString(payload.threadId) ?? event.aggregateId;
174 const thread = nextBase.threads.find((entry) => entry.id === threadId);
175 if (!thread) return nextBase;
176 const sessionPayload = asObject(payload.session);
177 if (!sessionPayload) return nextBase;
178 const session: OrchestrationSession = {
179 sessionId: asString(sessionPayload.sessionId) ?? crypto.randomUUID(),
180 status:
181 (asString(sessionPayload.status) as OrchestrationSession["status"] | null) ?? "connecting",
182 provider:
183 (asString(sessionPayload.provider) as OrchestrationSession["provider"] | null) ?? "codex",
184 threadId,
185 activeTurnId: asString(sessionPayload.activeTurnId),
186 createdAt: asString(sessionPayload.createdAt) ?? event.occurredAt,
187 updatedAt: asString(sessionPayload.updatedAt) ?? event.occurredAt,
188 lastError: asString(sessionPayload.lastError),
189 };
190 return {
191 ...nextBase,
192 threads: updateThread(nextBase.threads, threadId, {
193 session,
194 updatedAt: event.occurredAt,
195 error: session.lastError,
196 }),
197 };
198 }
199 case "thread.turn-diff-completed": {
200 if (!payload) return nextBase;
201 const threadId = asString(payload.threadId) ?? event.aggregateId;
202 const thread = nextBase.threads.find((entry) => entry.id === threadId);
203 const turnId = asString(payload.turnId);
204 if (!thread || !turnId) return nextBase;
205200
206 const files = asArray(payload.files).flatMap((filePayload) => {201 return {
207 const file = asObject(filePayload);202 ...nextBase,
208 const filePath = asString(file?.path);203 threads: updateThread(nextBase.threads, payload.threadId, {
209 if (!filePath) {204 messages: nextMessages,
210 return [];205 updatedAt: event.occurredAt,
206 }),
207 };
208 });
209 case "thread.session-set":
210 return Effect.gen(function* () {
211 const payload = yield* decodeThreadSessionSetPayload(event.payload);
212 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
213 if (!thread) {
214 return nextBase;
211 }215 }
212216
213 const nextFile: {217 const session: OrchestrationSession = yield* decodeSession({
214 path: string;218 ...payload.session,
215 kind?: string;219 threadId: payload.threadId,
216 additions?: number;220 });
217 deletions?: number;221
218 } = { path: filePath };222 return {
219 const kind = asString(file?.kind);223 ...nextBase,
220 if (kind !== null) {224 threads: updateThread(nextBase.threads, payload.threadId, {
221 nextFile.kind = kind;225 session,
222 }226 updatedAt: event.occurredAt,
223 const additions = asNumber(file?.additions);227 error: session.lastError,
224 if (additions !== null) {228 }),
225 nextFile.additions = additions;229 };
226 }
227 const deletions = asNumber(file?.deletions);
228 if (deletions !== null) {
229 nextFile.deletions = deletions;
230 }
231 return [nextFile];
232 });230 });
231 case "thread.turn-diff-completed":
232 return Effect.gen(function* () {
233 const payload = yield* decodeThreadTurnDiffCompletedPayload(event.payload);
234 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
235 if (!thread) {
236 return nextBase;
237 }
233238
234 const nextSummary = {239 const nextSummary = yield* decodeTurnDiffSummary({
235 turnId,240 turnId: payload.turnId,
236 completedAt: asString(payload.completedAt) ?? event.occurredAt,241 completedAt: payload.completedAt,
237 ...(asString(payload.status) !== null ? { status: asString(payload.status) ?? undefined } : {}),242 ...(payload.status !== undefined ? { status: payload.status } : {}),
238 files,243 files: payload.files,
239 ...(asString(payload.assistantMessageId) !== null244 ...(payload.assistantMessageId !== undefined
240 ? { assistantMessageId: asString(payload.assistantMessageId) ?? undefined }245 ? { assistantMessageId: payload.assistantMessageId }
241 : {}),246 : {}),
242 ...(asNumber(payload.checkpointTurnCount) !== null247 ...(payload.checkpointTurnCount !== undefined
243 ? { checkpointTurnCount: asNumber(payload.checkpointTurnCount) ?? undefined }248 ? { checkpointTurnCount: payload.checkpointTurnCount }
244 : {}),249 : {}),
245 };250 });
246 const turnDiffSummaries = [...thread.turnDiffSummaries.filter((summary) => summary.turnId !== turnId), nextSummary]251 const turnDiffSummaries = [
247 .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));252 ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId),
253 nextSummary,
254 ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
248255
249 return {256 return {
250 ...nextBase,257 ...nextBase,
251 threads: updateThread(nextBase.threads, threadId, {258 threads: updateThread(nextBase.threads, payload.threadId, {
252 latestTurnId: turnId,259 latestTurnId: payload.turnId,
253 latestTurnCompletedAt: nextSummary.completedAt,260 latestTurnCompletedAt: nextSummary.completedAt,
254 turnDiffSummaries,261 turnDiffSummaries,
255 updatedAt: event.occurredAt,262 updatedAt: event.occurredAt,
256 }),263 }),
257 };264 };
258 }265 });
259 case "thread.reverted": {266 case "thread.reverted":
260 if (!payload) return nextBase;267 return decodeThreadRevertedPayload(event.payload).pipe(
261 const threadId = asString(payload.threadId) ?? event.aggregateId;268 Effect.map((payload) => {
262 const thread = nextBase.threads.find((entry) => entry.id === threadId);269 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
263 if (!thread) return nextBase;270 if (!thread) {
271 return nextBase;
272 }
264273
265 const targetTurnCount = Math.max(0, Math.floor(asNumber(payload.turnCount) ?? 0));274 const targetTurnCount = Math.max(0, Math.floor(payload.turnCount));
266 const targetMessageCount = Math.max(0, Math.floor(asNumber(payload.messageCount) ?? 0));275 const targetMessageCount = Math.max(0, Math.floor(payload.messageCount));
267 const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) =>276 const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) =>
268 left.completedAt.localeCompare(right.completedAt),277 left.completedAt.localeCompare(right.completedAt),
269 );278 );
270 const inferredTurnCountByTurnId = new Map<string, number>();279 const inferredTurnCountByTurnId = new Map<string, number>();
271 for (let index = 0; index < sortedSummaries.length; index += 1) {280 for (let index = 0; index < sortedSummaries.length; index += 1) {
272 const summary = sortedSummaries[index];281 const summary = sortedSummaries[index];
273 if (!summary) continue;282 if (!summary) continue;
274 inferredTurnCountByTurnId.set(summary.turnId, index + 1);283 inferredTurnCountByTurnId.set(summary.turnId, index + 1);
275 }284 }
276285
277 const turnDiffSummaries = thread.turnDiffSummaries286 const turnDiffSummaries = thread.turnDiffSummaries
278 .filter((summary) => {287 .filter((summary) => {
279 const checkpointTurnCount =288 const checkpointTurnCount =
280 summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0;289 summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0;
281 return checkpointTurnCount <= targetTurnCount;290 return checkpointTurnCount <= targetTurnCount;
282 })291 })
283 .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));292 .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
284 const latestSummary =293 const latestSummary =
285 turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null;294 turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null;
286295
287 return {296 return {
288 ...nextBase,297 ...nextBase,
289 threads: updateThread(nextBase.threads, threadId, {298 threads: updateThread(nextBase.threads, payload.threadId, {
290 messages: thread.messages.slice(0, Math.min(targetMessageCount, thread.messages.length)),299 messages: thread.messages.slice(
291 turnDiffSummaries,300 0,
292 latestTurnId: latestSummary?.turnId ?? null,301 Math.min(targetMessageCount, thread.messages.length),
293 latestTurnStartedAt: null,302 ),
294 latestTurnCompletedAt: latestSummary?.completedAt ?? null,303 turnDiffSummaries,
295 latestTurnDurationMs: null,304 latestTurnId: latestSummary?.turnId ?? null,
296 updatedAt: event.occurredAt,305 latestTurnStartedAt: null,
306 latestTurnCompletedAt: latestSummary?.completedAt ?? null,
307 latestTurnDurationMs: null,
308 updatedAt: event.occurredAt,
309 }),
310 };
297 }),311 }),
298 };312 );
299 }313 case "git.read-model-upsert":
300 case "git.read-model-upsert": {314 return decodeGitReadModel(event.payload).pipe(
301 if (!payload) return nextBase;315 Effect.map((gitReadModel: OrchestrationGitReadModel) => ({
302 const projectId = asString(payload.projectId) ?? event.aggregateId;316 ...nextBase,
303 const gitReadModel: OrchestrationGitReadModel = {317 gitByProjectId: {
304 projectId,318 ...nextBase.gitByProjectId,
305 branch: asString(payload.branch),319 [gitReadModel.projectId]: gitReadModel,
306 hasWorkingTreeChanges: payload.hasWorkingTreeChanges === true,320 },
307 aheadCount: asNumber(payload.aheadCount) ?? 0,321 })),
308 behindCount: asNumber(payload.behindCount) ?? 0,322 );
309 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
310 };
311 return {
312 ...nextBase,
313 gitByProjectId: {
314 ...nextBase.gitByProjectId,
315 [projectId]: gitReadModel,
316 },
317 };
318 }
319 default:323 default:
320 return nextBase;324 return Effect.succeed(nextBase);
321 }325 }
322}326}
apps/server/src/orchestration/runtime.ts deleted-22
...@@ -1,22 +0,0 @@
1import { Layer, ManagedRuntime } from "effect";
2
3import type { OrchestrationEngine } from "./engine";
4import { OrchestrationLive } from "./layers";
5import { OrchestrationConfig, OrchestrationEngineService } from "./services";
6
7export interface OrchestrationSystem {
8 readonly engine: OrchestrationEngine;
9 readonly dispose: () => Promise<void>;
10}
11
12export async function createOrchestrationSystem(stateDir: string): Promise<OrchestrationSystem> {
13 const orchestrationLayer = OrchestrationLive.pipe(
14 Layer.provide(Layer.succeed(OrchestrationConfig, { stateDir })),
15 );
16 const runtime = ManagedRuntime.make(orchestrationLayer);
17 const engine = await runtime.runPromise(OrchestrationEngineService);
18 return {
19 engine,
20 dispose: () => runtime.dispose(),
21 };
22}
apps/server/src/orchestration/services.ts-9
...@@ -2,15 +2,6 @@ import { Context } from "effect";...@@ -2,15 +2,6 @@ import { Context } from "effect";
22
3import { OrchestrationEngine } from "./engine";3import { OrchestrationEngine } from "./engine";
44
5export interface OrchestrationConfigShape {
6 readonly stateDir: string;
7}
8
9export class OrchestrationConfig extends Context.Tag("orchestration/Config")<
10 OrchestrationConfig,
11 OrchestrationConfigShape
12>() {}
13
14export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")<5export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")<
15 OrchestrationEngineService,6 OrchestrationEngineService,
16 OrchestrationEngine7 OrchestrationEngine
apps/server/src/orchestration/uiContractInventory.ts+4-6
...@@ -7,11 +7,6 @@ export interface UiEntityContract {...@@ -7,11 +7,6 @@ export interface UiEntityContract {
7// Explicit UI inventory used to keep server read models aligned7// Explicit UI inventory used to keep server read models aligned
8// with what the current frontend renders.8// with what the current frontend renders.
9export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [9export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [
10 {
11 entity: "Project",
12 requiredFields: ["id", "name", "cwd", "model"],
13 consumedBy: ["apps/web/src/components/Sidebar.tsx"],
14 },
15 {10 {
16 entity: "Thread",11 entity: "Thread",
17 requiredFields: [12 requiredFields: [
...@@ -61,6 +56,9 @@ export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [...@@ -61,6 +56,9 @@ export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [
61 {56 {
62 entity: "GitReadModel",57 entity: "GitReadModel",
63 requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"],58 requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"],
64 consumedBy: ["apps/web/src/components/GitActionsControl.tsx", "apps/web/src/components/BranchToolbar.tsx"],59 consumedBy: [
60 "apps/web/src/components/GitActionsControl.tsx",
61 "apps/web/src/components/BranchToolbar.tsx",
62 ],
65 },63 },
66];64];
apps/server/src/persistence/Layers/OrchestrationEvents.test.ts+5-5
...@@ -35,12 +35,12 @@ describe("OrchestrationEventRepository", () => {...@@ -35,12 +35,12 @@ describe("OrchestrationEventRepository", () => {
35 const saved = await Runtime.runPromise(runtime)(35 const saved = await Runtime.runPromise(runtime)(
36 first.append({36 first.append({
37 eventId: "event-1",37 eventId: "event-1",
38 type: "project.created",38 type: "thread.created",
39 aggregateType: "project",39 aggregateType: "thread",
40 aggregateId: "project-1",40 aggregateId: "thread-1",
41 occurredAt: createdAt,41 occurredAt: createdAt,
42 commandId: "cmd-1",42 commandId: "cmd-1",
43 payload: { id: "project-1", name: "demo" },43 payload: { id: "thread-1", projectId: "project-1", title: "demo" },
44 }),44 }),
45 );45 );
46 expect(saved.sequence).toBe(1);46 expect(saved.sequence).toBe(1);
...@@ -92,7 +92,7 @@ describe("OrchestrationEventRepository", () => {...@@ -92,7 +92,7 @@ describe("OrchestrationEventRepository", () => {
92 `,92 `,
93 )93 )
94 .all() as Array<Record<string, unknown>>;94 .all() as Array<Record<string, unknown>>;
95 expect(migrationRows).toHaveLength(1);95 expect(migrationRows.length).toBeGreaterThanOrEqual(1);
96 expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0);96 expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0);
97 db.close();97 db.close();
98 });98 });
apps/server/src/persistence/Layers/OrchestrationEvents.ts+4-33
...@@ -1,15 +1,10 @@...@@ -1,15 +1,10 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import type { OrchestrationEvent } from "@t3tools/contracts";1import type { OrchestrationEvent } from "@t3tools/contracts";
5import { OrchestrationEventSchema } from "@t3tools/contracts";2import { OrchestrationEventSchema } from "@t3tools/contracts";
6import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient";
7import * as SqlClient from "@effect/sql/SqlClient";3import * as SqlClient from "@effect/sql/SqlClient";
8import * as SqlSchema from "@effect/sql/SqlSchema";4import * as SqlSchema from "@effect/sql/SqlSchema";
9import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient";5import { Effect, Layer, Schema } from "effect";
10import { Context, Effect, Layer, Schema } from "effect";
116
12import { runMigrations } from "../Migrations";7import { makeSqlitePersistenceLive } from "./Sqlite";
13import {8import {
14 OrchestrationEventRepository,9 OrchestrationEventRepository,
15 type OrchestrationEventRepositoryShape,10 type OrchestrationEventRepositoryShape,
...@@ -45,11 +40,6 @@ const ReadFromSequenceRequestSchema = Schema.Struct({...@@ -45,11 +40,6 @@ const ReadFromSequenceRequestSchema = Schema.Struct({
45 limit: Schema.Number,40 limit: Schema.Number,
46});41});
4742
48class OrchestrationSql extends Context.Tag("persistence/OrchestrationSql")<
49 OrchestrationSql,
50 SqlClientService
51>() {}
52
53function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent {43function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent {
54 return decodeEvent({44 return decodeEvent({
55 sequence: row.sequence,45 sequence: row.sequence,
...@@ -64,7 +54,7 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent {...@@ -64,7 +54,7 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent {
64}54}
6555
66const makeRepository = Effect.gen(function* () {56const makeRepository = Effect.gen(function* () {
67 const sql = yield* OrchestrationSql;57 const sql = yield* SqlClient.SqlClient;
6858
69 const appendEventRow = SqlSchema.single({59 const appendEventRow = SqlSchema.single({
70 Request: AppendEventRequestSchema,60 Request: AppendEventRequestSchema,
...@@ -155,24 +145,5 @@ export const OrchestrationEventRepositoryLive = Layer.effect(...@@ -155,24 +145,5 @@ export const OrchestrationEventRepositoryLive = Layer.effect(
155);145);
156146
157export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) {147export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) {
158 const SqliteClientLive = Effect.gen(function* () {148 return OrchestrationEventRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath)));
159 yield* Effect.try({
160 try: () => fs.mkdirSync(path.dirname(dbPath), { recursive: true }),
161 catch: (error) => Effect.die(error),
162 })
163 return SqliteClient.layer({ filename: dbPath });
164 }).pipe(Layer.unwrapEffect)
165
166 const OrchestrationSqlLive = Layer.scoped(
167 OrchestrationSql,
168 Effect.gen(function* () {
169 const sql = yield* SqlClient.SqlClient;
170 yield* sql`PRAGMA journal_mode = WAL;`;
171 yield* sql`PRAGMA foreign_keys = ON;`;
172 yield* runMigrations;
173 return sql;
174 }),
175 ).pipe(Layer.provide(SqliteClientLive));
176
177 return OrchestrationEventRepositoryLive.pipe(Layer.provide(OrchestrationSqlLive));
178}149}
apps/server/src/persistence/Layers/Projects.test.ts created+83
...@@ -0,0 +1,83 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { Layer, ManagedRuntime } from "effect";
6import { afterEach, describe, expect, it } from "vitest";
7
8import { ProjectRepository } from "../Services/Projects";
9import { ProjectRepositoryLive } from "./Projects";
10import { makeSqlitePersistenceLive } from "./Sqlite";
11
12function makeProjectRepositoryTest(dbPath: string) {
13 return ProjectRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath)));
14}
15
16const tempDirs: string[] = [];
17
18function makeTempDir(prefix: string): string {
19 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
20 tempDirs.push(dir);
21 return dir;
22}
23
24afterEach(() => {
25 for (const dir of tempDirs.splice(0, tempDirs.length)) {
26 fs.rmSync(dir, { recursive: true, force: true });
27 }
28});
29
30describe("ProjectRepository", () => {
31 it("persists projects and deduplicates adds by cwd", async () => {
32 const stateDir = makeTempDir("t3code-project-repo-state-");
33 const projectDir = makeTempDir("t3code-project-repo-project-");
34 const dbPath = path.join(stateDir, "orchestration.sqlite");
35
36 const firstRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath));
37 const first = await firstRuntime.runPromise(ProjectRepository);
38
39 const created = await firstRuntime.runPromise(first.add({ cwd: projectDir }));
40 expect(created.created).toBe(true);
41
42 const duplicate = await firstRuntime.runPromise(first.add({ cwd: projectDir }));
43 expect(duplicate.created).toBe(false);
44 expect(duplicate.project.id).toBe(created.project.id);
45
46 await firstRuntime.dispose();
47
48 const secondRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath));
49 const second = await secondRuntime.runPromise(ProjectRepository);
50 const listed = await secondRuntime.runPromise(second.list());
51
52 expect(listed).toHaveLength(1);
53 expect(listed[0]?.cwd).toBe(projectDir);
54
55 await secondRuntime.dispose();
56 });
57
58 it("prunes missing project paths", async () => {
59 const stateDir = makeTempDir("t3code-project-repo-prune-state-");
60 const existing = makeTempDir("t3code-project-repo-prune-existing-");
61 const missing = makeTempDir("t3code-project-repo-prune-missing-");
62 const dbPath = path.join(stateDir, "orchestration.sqlite");
63
64 const runtime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath));
65 const repository = await runtime.runPromise(ProjectRepository);
66
67 const existingProject = await runtime.runPromise(repository.add({ cwd: existing }));
68 const missingProject = await runtime.runPromise(repository.add({ cwd: missing }));
69
70 expect(existingProject.created).toBe(true);
71 expect(missingProject.created).toBe(true);
72
73 fs.rmSync(missing, { recursive: true, force: true });
74
75 await runtime.runPromise(repository.pruneMissing());
76 const listed = await runtime.runPromise(repository.list());
77
78 expect(listed).toHaveLength(1);
79 expect(listed[0]?.cwd).toBe(existing);
80
81 await runtime.dispose();
82 });
83});
apps/server/src/persistence/Layers/Projects.ts created+339
...@@ -0,0 +1,339 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs";
3import path from "node:path";
4
5import type {
6 ProjectAddInput,
7 ProjectAddResult,
8 ProjectListResult,
9 ProjectRecord,
10 ProjectScript,
11 ProjectUpdateScriptsResult,
12} from "@t3tools/contracts";
13import {
14 normalizeProjectScripts,
15 projectAddInputSchema,
16 projectRecordSchema,
17 projectRemoveInputSchema,
18 projectScriptsSchema,
19 projectUpdateScriptsInputSchema,
20} from "@t3tools/contracts";
21import * as SqlClient from "@effect/sql/SqlClient";
22import * as SqlSchema from "@effect/sql/SqlSchema";
23import { Effect, Layer, Option, Schema } from "effect";
24
25import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects";
26
27const ProjectRowSchema = Schema.Struct({
28 id: Schema.String,
29 cwd: Schema.String,
30 name: Schema.String,
31 scriptsJson: Schema.String,
32 createdAt: Schema.String,
33 updatedAt: Schema.String,
34});
35
36type ProjectRow = Schema.Schema.Type<typeof ProjectRowSchema>;
37
38const FindByIdRequestSchema = Schema.Struct({
39 id: Schema.String,
40});
41
42const FindByCwdRequestSchema = Schema.Struct({
43 cwd: Schema.String,
44});
45
46const InsertProjectRequestSchema = Schema.Struct({
47 id: Schema.String,
48 cwd: Schema.String,
49 name: Schema.String,
50 scriptsJson: Schema.String,
51 createdAt: Schema.String,
52 updatedAt: Schema.String,
53});
54
55const UpdateScriptsRequestSchema = Schema.Struct({
56 id: Schema.String,
57 scriptsJson: Schema.String,
58 updatedAt: Schema.String,
59});
60
61const DeleteProjectRequestSchema = Schema.Struct({
62 id: Schema.String,
63});
64
65function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] {
66 const cloned: ProjectScript[] = [];
67 for (const script of scripts) {
68 cloned.push({
69 id: script.id,
70 name: script.name,
71 command: script.command,
72 icon: script.icon,
73 runOnWorktreeCreate: script.runOnWorktreeCreate,
74 });
75 }
76 return cloned;
77}
78
79function normalizeCwd(rawCwd: string): string {
80 const resolved = path.resolve(rawCwd.trim());
81 const normalized = path.normalize(resolved);
82 if (process.platform === "win32") {
83 return normalized.toLowerCase();
84 }
85 return normalized;
86}
87
88function isDirectory(cwd: string): boolean {
89 try {
90 return fs.statSync(cwd).isDirectory();
91 } catch {
92 return false;
93 }
94}
95
96function inferProjectName(cwd: string): string {
97 const name = path.basename(cwd);
98 return name.length > 0 ? name : "project";
99}
100
101function parseScriptsJson(raw: string): ProjectScript[] {
102 try {
103 const parsed = JSON.parse(raw) as unknown;
104 const scripts = projectScriptsSchema.parse(parsed);
105 return normalizeProjectScripts(scripts);
106 } catch {
107 return [];
108 }
109}
110
111function rowToProjectRecord(row: ProjectRow): ProjectRecord {
112 return projectRecordSchema.parse({
113 id: row.id,
114 cwd: row.cwd,
115 name: row.name,
116 scripts: parseScriptsJson(row.scriptsJson),
117 createdAt: row.createdAt,
118 updatedAt: row.updatedAt,
119 });
120}
121
122const makeRepository = Effect.gen(function* () {
123 const sql = yield* SqlClient.SqlClient;
124
125 const listProjectRows = SqlSchema.findAll({
126 Request: Schema.Void,
127 Result: ProjectRowSchema,
128 execute: () =>
129 sql`
130 SELECT
131 id,
132 cwd,
133 name,
134 scripts_json AS "scriptsJson",
135 created_at AS "createdAt",
136 updated_at AS "updatedAt"
137 FROM projects
138 ORDER BY created_at DESC
139 `,
140 });
141
142 const findProjectById = SqlSchema.findOne({
143 Request: FindByIdRequestSchema,
144 Result: ProjectRowSchema,
145 execute: ({ id }) =>
146 sql`
147 SELECT
148 id,
149 cwd,
150 name,
151 scripts_json AS "scriptsJson",
152 created_at AS "createdAt",
153 updated_at AS "updatedAt"
154 FROM projects
155 WHERE id = ${id}
156 `,
157 });
158
159 const findProjectByCwd = SqlSchema.findOne({
160 Request: FindByCwdRequestSchema,
161 Result: ProjectRowSchema,
162 execute: ({ cwd }) =>
163 sql`
164 SELECT
165 id,
166 cwd,
167 name,
168 scripts_json AS "scriptsJson",
169 created_at AS "createdAt",
170 updated_at AS "updatedAt"
171 FROM projects
172 WHERE cwd = ${cwd}
173 `,
174 });
175
176 const insertProject = SqlSchema.single({
177 Request: InsertProjectRequestSchema,
178 Result: ProjectRowSchema,
179 execute: (request) =>
180 sql`
181 INSERT INTO projects (
182 id,
183 cwd,
184 name,
185 scripts_json,
186 created_at,
187 updated_at
188 )
189 VALUES (
190 ${request.id},
191 ${request.cwd},
192 ${request.name},
193 ${request.scriptsJson},
194 ${request.createdAt},
195 ${request.updatedAt}
196 )
197 RETURNING
198 id,
199 cwd,
200 name,
201 scripts_json AS "scriptsJson",
202 created_at AS "createdAt",
203 updated_at AS "updatedAt"
204 `,
205 });
206
207 const updateProjectScripts = SqlSchema.single({
208 Request: UpdateScriptsRequestSchema,
209 Result: ProjectRowSchema,
210 execute: (request) =>
211 sql`
212 UPDATE projects
213 SET
214 scripts_json = ${request.scriptsJson},
215 updated_at = ${request.updatedAt}
216 WHERE id = ${request.id}
217 RETURNING
218 id,
219 cwd,
220 name,
221 scripts_json AS "scriptsJson",
222 created_at AS "createdAt",
223 updated_at AS "updatedAt"
224 `,
225 });
226
227 const deleteProject = SqlSchema.void({
228 Request: DeleteProjectRequestSchema,
229 execute: ({ id }) => sql`DELETE FROM projects WHERE id = ${id}`,
230 });
231
232 const list: ProjectRepositoryShape["list"] = () =>
233 listProjectRows(undefined).pipe(
234 Effect.map((rows): ProjectListResult => rows.map(rowToProjectRecord)),
235 Effect.map((projects) =>
236 projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })),
237 ),
238 Effect.orDie,
239 );
240
241 const add: ProjectRepositoryShape["add"] = (rawInput) =>
242 Effect.gen(function* () {
243 const input = projectAddInputSchema.parse(rawInput as ProjectAddInput);
244 const normalizedCwd = normalizeCwd(input.cwd);
245 if (!isDirectory(normalizedCwd)) {
246 return yield* Effect.fail(new Error(`Project path does not exist: ${normalizedCwd}`));
247 }
248
249 const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(Effect.orDie);
250 if (Option.isSome(existing)) {
251 const project = rowToProjectRecord(existing.value);
252 const result: ProjectAddResult = {
253 project: {
254 ...project,
255 scripts: cloneScripts(project.scripts),
256 },
257 created: false,
258 };
259 return result;
260 }
261
262 const now = new Date().toISOString();
263 const row = yield* insertProject({
264 id: randomUUID(),
265 cwd: normalizedCwd,
266 name: inferProjectName(normalizedCwd),
267 scriptsJson: "[]",
268 createdAt: now,
269 updatedAt: now,
270 }).pipe(Effect.orDie);
271
272 const created = rowToProjectRecord(row);
273 const result: ProjectAddResult = {
274 project: {
275 ...created,
276 scripts: cloneScripts(created.scripts),
277 },
278 created: true,
279 };
280 return result;
281 });
282
283 const remove: ProjectRepositoryShape["remove"] = (rawInput) =>
284 Effect.gen(function* () {
285 const input = projectRemoveInputSchema.parse(rawInput);
286 yield* deleteProject({ id: input.id }).pipe(Effect.orDie);
287 });
288
289 const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) =>
290 Effect.gen(function* () {
291 const input = projectUpdateScriptsInputSchema.parse(rawInput);
292 const existing = yield* findProjectById({ id: input.id }).pipe(Effect.orDie);
293 if (Option.isNone(existing)) {
294 return yield* Effect.fail(new Error(`Project not found: ${input.id}`));
295 }
296
297 const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts));
298 const nextUpdatedAt = new Date().toISOString();
299 const updatedRow = yield* updateProjectScripts({
300 id: input.id,
301 scriptsJson: JSON.stringify(nextScripts),
302 updatedAt: nextUpdatedAt,
303 }).pipe(Effect.orDie);
304
305 const updated = rowToProjectRecord(updatedRow);
306 const result: ProjectUpdateScriptsResult = {
307 project: {
308 ...updated,
309 scripts: cloneScripts(updated.scripts),
310 },
311 };
312 return result;
313 });
314
315 const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () =>
316 listProjectRows(undefined).pipe(
317 Effect.flatMap((rows) =>
318 Effect.forEach(rows, (row) => {
319 const normalizedCwd = normalizeCwd(row.cwd);
320 if (isDirectory(normalizedCwd)) {
321 return Effect.void;
322 }
323 return deleteProject({ id: row.id }).pipe(Effect.orDie);
324 }),
325 ),
326 Effect.asVoid,
327 Effect.orDie,
328 );
329
330 return {
331 list,
332 add,
333 remove,
334 updateScripts,
335 pruneMissing,
336 } satisfies ProjectRepositoryShape;
337});
338
339export const ProjectRepositoryLive = Layer.effect(ProjectRepository, makeRepository);
apps/server/src/persistence/Layers/Sqlite.ts created+41
...@@ -0,0 +1,41 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import * as SqlClient from "@effect/sql/SqlClient";
5import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient";
6import { Context, Effect, Layer } from "effect";
7
8import { runMigrations } from "../Migrations";
9
10export interface PersistenceConfigShape {
11 readonly dbPath: string;
12}
13
14export class PersistenceConfig extends Context.Tag("persistence/Config")<
15 PersistenceConfig,
16 PersistenceConfigShape
17>() {}
18
19export function makeSqlitePersistenceLive(dbPath: string) {
20 const SqliteClientLive = Layer.unwrapEffect(
21 Effect.sync(() => {
22 fs.mkdirSync(path.dirname(dbPath), { recursive: true });
23 return SqliteClient.layer({ filename: dbPath });
24 }),
25 );
26
27 return Layer.scoped(
28 SqlClient.SqlClient,
29 Effect.gen(function* () {
30 const sql = yield* SqliteClient.SqliteClient;
31 yield* sql`PRAGMA journal_mode = WAL;`;
32 yield* sql`PRAGMA foreign_keys = ON;`;
33 yield* runMigrations;
34 return sql;
35 }),
36 ).pipe(Layer.provide(SqliteClientLive));
37}
38
39export const SqlitePersistenceLive = Layer.unwrapEffect(
40 Effect.map(PersistenceConfig, ({ dbPath }) => makeSqlitePersistenceLive(dbPath)),
41);
apps/server/src/persistence/Migrations.ts+3-2
...@@ -13,7 +13,7 @@ import * as Layer from "effect/Layer";...@@ -13,7 +13,7 @@ import * as Layer from "effect/Layer";
1313
14// Import all migrations statically14// Import all migrations statically
15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
1616import Migration0002 from "./Migrations/002_Projects.ts";
1717
18/**18/**
19 * Migration loader with all migrations defined inline.19 * Migration loader with all migrations defined inline.
...@@ -27,6 +27,7 @@ import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";...@@ -27,6 +27,7 @@ import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
27 */27 */
28const loader = Migrator.fromRecord({28const loader = Migrator.fromRecord({
29 "1_OrchestrationEvents": Migration0001,29 "1_OrchestrationEvents": Migration0001,
30 "2_Projects": Migration0002,
30});31});
3132
32/**33/**
...@@ -64,4 +65,4 @@ export const runMigrations = run({ loader });...@@ -64,4 +65,4 @@ export const runMigrations = run({ loader });
64 * )65 * )
65 * ```66 * ```
66 */67 */
67export const MigrationsLive = Layer.effectDiscard(runMigrations);
\ No newline at end of file
68export const MigrationsLive = Layer.effectDiscard(runMigrations);
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts+1-1
...@@ -21,4 +21,4 @@ export default Effect.gen(function* () {...@@ -21,4 +21,4 @@ export default Effect.gen(function* () {
21 CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate21 CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate
22 ON orchestration_events(aggregate_type, aggregate_id, sequence)22 ON orchestration_events(aggregate_type, aggregate_id, sequence)
23 `;23 `;
24});
\ No newline at end of file
24});
apps/server/src/persistence/Migrations/002_Projects.ts created+22
...@@ -0,0 +1,22 @@
1import { SqlClient } from "@effect/sql";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS projects (
9 id TEXT PRIMARY KEY,
10 cwd TEXT NOT NULL UNIQUE,
11 name TEXT NOT NULL,
12 scripts_json TEXT NOT NULL,
13 created_at TEXT NOT NULL,
14 updated_at TEXT NOT NULL
15 )
16 `;
17
18 yield* sql`
19 CREATE INDEX IF NOT EXISTS idx_projects_created_at
20 ON projects(created_at DESC)
21 `;
22});
apps/server/src/persistence/Services/Projects.ts created+25
...@@ -0,0 +1,25 @@
1import type {
2 ProjectAddInput,
3 ProjectAddResult,
4 ProjectListResult,
5 ProjectRemoveInput,
6 ProjectUpdateScriptsInput,
7 ProjectUpdateScriptsResult,
8} from "@t3tools/contracts";
9import { Context } from "effect";
10import type { Effect } from "effect";
11
12export interface ProjectRepositoryShape {
13 readonly list: () => Effect.Effect<ProjectListResult>;
14 readonly add: (input: ProjectAddInput) => Effect.Effect<ProjectAddResult, Error>;
15 readonly remove: (input: ProjectRemoveInput) => Effect.Effect<void, Error>;
16 readonly updateScripts: (
17 input: ProjectUpdateScriptsInput,
18 ) => Effect.Effect<ProjectUpdateScriptsResult, Error>;
19 readonly pruneMissing: () => Effect.Effect<void>;
20}
21
22export class ProjectRepository extends Context.Tag("persistence/ProjectRepository")<
23 ProjectRepository,
24 ProjectRepositoryShape
25>() {}
apps/server/src/projectRegistry.test.ts deleted-113
...@@ -1,113 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { ProjectRegistry } from "./projectRegistry";
8
9const tempDirs: string[] = [];
10
11function makeTempDir(prefix: string): string {
12 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13 tempDirs.push(dir);
14 return dir;
15}
16
17afterEach(() => {
18 for (const dir of tempDirs.splice(0, tempDirs.length)) {
19 fs.rmSync(dir, { recursive: true, force: true });
20 }
21});
22
23describe("ProjectRegistry scripts", () => {
24 it("stores and reloads project scripts", () => {
25 const stateDir = makeTempDir("t3code-project-registry-state-");
26 const projectDir = makeTempDir("t3code-project-registry-project-");
27 const registry = new ProjectRegistry(stateDir);
28
29 const created = registry.add({ cwd: projectDir }).project;
30 const updated = registry.updateScripts({
31 id: created.id,
32 scripts: [
33 {
34 id: "run",
35 name: "Run",
36 command: "bun run dev",
37 icon: "play",
38 runOnWorktreeCreate: false,
39 },
40 {
41 id: "setup",
42 name: "Setup",
43 command: "bun install",
44 icon: "configure",
45 runOnWorktreeCreate: true,
46 },
47 ],
48 });
49
50 expect(updated.project.scripts).toHaveLength(2);
51 expect(updated.project.scripts[1]?.id).toBe("setup");
52 expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true);
53
54 const reloaded = new ProjectRegistry(stateDir);
55 const listed = reloaded.list();
56 expect(listed).toHaveLength(1);
57 expect(listed[0]?.scripts).toHaveLength(2);
58 expect(listed[0]?.scripts[0]?.id).toBe("run");
59 expect(listed[0]?.scripts[1]?.id).toBe("setup");
60 });
61
62 it("deduplicates script ids and allows only one setup script", () => {
63 const stateDir = makeTempDir("t3code-project-registry-state-");
64 const projectDir = makeTempDir("t3code-project-registry-project-");
65 const registry = new ProjectRegistry(stateDir);
66 const created = registry.add({ cwd: projectDir }).project;
67
68 const updated = registry.updateScripts({
69 id: created.id,
70 scripts: [
71 {
72 id: "setup",
73 name: "Setup",
74 command: "echo setup",
75 icon: "configure",
76 runOnWorktreeCreate: true,
77 },
78 {
79 id: "setup",
80 name: "Setup Duplicate",
81 command: "echo duplicate",
82 icon: "configure",
83 runOnWorktreeCreate: true,
84 },
85 {
86 id: "another",
87 name: "Another Setup",
88 command: "echo another",
89 icon: "play",
90 runOnWorktreeCreate: true,
91 },
92 ],
93 });
94
95 expect(updated.project.scripts).toHaveLength(2);
96 expect(updated.project.scripts[0]?.id).toBe("setup");
97 expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true);
98 expect(updated.project.scripts[1]?.id).toBe("another");
99 expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false);
100 });
101
102 it("throws when updating scripts for an unknown project id", () => {
103 const stateDir = makeTempDir("t3code-project-registry-state-");
104 const registry = new ProjectRegistry(stateDir);
105
106 expect(() =>
107 registry.updateScripts({
108 id: "missing-project",
109 scripts: [],
110 }),
111 ).toThrowError("Project not found");
112 });
113});
apps/server/src/projectRegistry.ts deleted-235
...@@ -1,235 +0,0 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
6 type ProjectAddInput,
7 type ProjectAddResult,
8 type ProjectListResult,
9 type ProjectRecord,
10 type ProjectRemoveInput,
11 type ProjectScript,
12 type ProjectUpdateScriptsInput,
13 type ProjectUpdateScriptsResult,
14 normalizeProjectScripts,
15 projectAddInputSchema,
16 projectRecordSchema,
17 projectRemoveInputSchema,
18 projectScriptsSchema,
19 projectUpdateScriptsInputSchema,
20} from "@t3tools/contracts";
21
22function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] {
23 const cloned: ProjectScript[] = [];
24 for (const script of scripts) {
25 cloned.push({
26 id: script.id,
27 name: script.name,
28 command: script.command,
29 icon: script.icon,
30 runOnWorktreeCreate: script.runOnWorktreeCreate,
31 });
32 }
33 return cloned;
34}
35
36function normalizeCwd(rawCwd: string): string {
37 const resolved = path.resolve(rawCwd.trim());
38 const normalized = path.normalize(resolved);
39 if (process.platform === "win32") {
40 return normalized.toLowerCase();
41 }
42 return normalized;
43}
44
45function isDirectory(cwd: string): boolean {
46 try {
47 return fs.statSync(cwd).isDirectory();
48 } catch {
49 return false;
50 }
51}
52
53function inferProjectName(cwd: string): string {
54 const name = path.basename(cwd);
55 return name.length > 0 ? name : "project";
56}
57
58export class ProjectRegistry {
59 private readonly stateDir: string;
60 private readonly filePath: string;
61 private projects: ProjectRecord[];
62
63 constructor(stateDir: string) {
64 this.stateDir = stateDir;
65 this.filePath = path.join(this.stateDir, "projects.json");
66 fs.mkdirSync(this.stateDir, { recursive: true });
67 this.projects = this.loadFromDisk();
68 }
69
70 list(): ProjectListResult {
71 return this.projects.map((project) => ({
72 ...project,
73 scripts: cloneScripts(project.scripts),
74 }));
75 }
76
77 add(raw: ProjectAddInput): ProjectAddResult {
78 const input = projectAddInputSchema.parse(raw);
79 const normalizedCwd = normalizeCwd(input.cwd);
80 if (!isDirectory(normalizedCwd)) {
81 throw new Error(`Project path does not exist: ${normalizedCwd}`);
82 }
83
84 const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd);
85 if (existing) {
86 return {
87 project: {
88 ...existing,
89 scripts: cloneScripts(existing.scripts),
90 },
91 created: false,
92 };
93 }
94
95 const now = new Date().toISOString();
96 const project: ProjectRecord = {
97 id: randomUUID(),
98 cwd: normalizedCwd,
99 name: inferProjectName(normalizedCwd),
100 scripts: [],
101 createdAt: now,
102 updatedAt: now,
103 };
104
105 this.projects = [project, ...this.projects];
106 this.persist();
107 return {
108 project: {
109 ...project,
110 scripts: cloneScripts(project.scripts),
111 },
112 created: true,
113 };
114 }
115
116 remove(raw: ProjectRemoveInput): void {
117 const input = projectRemoveInputSchema.parse(raw);
118 const next = this.projects.filter((project) => project.id !== input.id);
119 if (next.length === this.projects.length) {
120 return;
121 }
122
123 this.projects = next;
124 this.persist();
125 }
126
127 updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult {
128 const input = projectUpdateScriptsInputSchema.parse(raw);
129 const index = this.projects.findIndex((project) => project.id === input.id);
130 if (index < 0) {
131 throw new Error(`Project not found: ${input.id}`);
132 }
133
134 const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts));
135 const nextUpdatedAt = new Date().toISOString();
136 const nextProject: ProjectRecord = {
137 ...this.projects[index]!,
138 scripts: nextScripts,
139 updatedAt: nextUpdatedAt,
140 };
141
142 const nextProjects = [...this.projects];
143 nextProjects[index] = nextProject;
144 this.projects = nextProjects;
145 this.persist();
146
147 return {
148 project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) },
149 };
150 }
151
152 private loadFromDisk(): ProjectRecord[] {
153 const loaded = this.readPersistedProjects();
154 const deduped = new Map<string, ProjectRecord>();
155 const normalizedProjects: ProjectRecord[] = [];
156
157 for (const project of loaded) {
158 const normalizedCwd = normalizeCwd(project.cwd);
159 if (!isDirectory(normalizedCwd)) {
160 continue;
161 }
162 if (deduped.has(normalizedCwd)) {
163 continue;
164 }
165
166 const normalizedProject: ProjectRecord = {
167 ...project,
168 cwd: normalizedCwd,
169 name:
170 project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd),
171 scripts: normalizeProjectScripts(project.scripts),
172 };
173 deduped.set(normalizedCwd, normalizedProject);
174 normalizedProjects.push(normalizedProject);
175 }
176
177 const changed =
178 normalizedProjects.length !== loaded.length ||
179 normalizedProjects.some((project, index) => {
180 const source = loaded[index];
181 if (!source) return true;
182 return (
183 source.id !== project.id ||
184 source.cwd !== project.cwd ||
185 source.name !== project.name ||
186 JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) ||
187 source.createdAt !== project.createdAt ||
188 source.updatedAt !== project.updatedAt
189 );
190 });
191
192 if (changed) {
193 this.projects = normalizedProjects;
194 this.persist();
195 }
196
197 return normalizedProjects;
198 }
199
200 private readPersistedProjects(): ProjectRecord[] {
201 try {
202 const raw = fs.readFileSync(this.filePath, "utf8");
203 const parsed = JSON.parse(raw) as { projects?: unknown };
204 if (!parsed || !Array.isArray(parsed.projects)) {
205 return [];
206 }
207
208 const projects: ProjectRecord[] = [];
209 for (const candidate of parsed.projects) {
210 const row = projectRecordSchema.safeParse(candidate);
211 if (!row.success) {
212 continue;
213 }
214 projects.push(row.data);
215 }
216 return projects;
217 } catch {
218 return [];
219 }
220 }
221
222 private persist(): void {
223 const payload = JSON.stringify(
224 {
225 version: 1,
226 projects: this.projects,
227 },
228 null,
229 2,
230 );
231 const tempFile = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
232 fs.writeFileSync(tempFile, payload);
233 fs.renameSync(tempFile, this.filePath);
234 }
235}
apps/server/src/wsServer.test.ts+21-32
...@@ -3,6 +3,7 @@ import os from "node:os";...@@ -3,6 +3,7 @@ import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { EventEmitter } from "node:events";4import { EventEmitter } from "node:events";
55
6import { Layer, ManagedRuntime } from "effect";
6import { describe, expect, it, afterEach, vi } from "vitest";7import { describe, expect, it, afterEach, vi } from "vitest";
7import { createServer } from "./wsServer";8import { createServer } from "./wsServer";
8import WebSocket from "ws";9import WebSocket from "ws";
...@@ -16,8 +17,8 @@ import {...@@ -16,8 +17,8 @@ import {
16 type WsPush,17 type WsPush,
17 type WsResponse,18 type WsResponse,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
19import { ProjectRegistry } from "./projectRegistry";
20import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";20import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
21import { ProjectRepository } from "./persistence/Services/Projects";
21import type {22import type {
22 TerminalClearInput,23 TerminalClearInput,
23 TerminalCloseInput,24 TerminalCloseInput,
...@@ -28,6 +29,8 @@ import type {...@@ -28,6 +29,8 @@ import type {
28 TerminalWriteInput,29 TerminalWriteInput,
29} from "@t3tools/contracts";30} from "@t3tools/contracts";
30import type { TerminalManager } from "./terminalManager";31import type { TerminalManager } from "./terminalManager";
32import { ProjectRepositoryLive } from "./persistence/Layers/Projects";
33import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
3134
32interface PendingMessages {35interface PendingMessages {
33 queue: unknown[];36 queue: unknown[];
...@@ -249,9 +252,9 @@ describe("WebSocket Server", () => {...@@ -249,9 +252,9 @@ describe("WebSocket Server", () => {
249 return createServer({252 return createServer({
250 port: 0,253 port: 0,
251 cwd: options.cwd ?? "/test/project",254 cwd: options.cwd ?? "/test/project",
255 stateDir,
252 ...(options.devUrl ? { devUrl: options.devUrl } : {}),256 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
253 ...(options.authToken ? { authToken: options.authToken } : {}),257 ...(options.authToken ? { authToken: options.authToken } : {}),
254 projectRegistry: new ProjectRegistry(stateDir),
255 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),258 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
256 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),259 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
257 });260 });
...@@ -888,7 +891,11 @@ describe("WebSocket Server", () => {...@@ -888,7 +891,11 @@ describe("WebSocket Server", () => {
888 it("supports projects.searchEntries", async () => {891 it("supports projects.searchEntries", async () => {
889 const workspace = makeTempDir("t3code-ws-workspace-entries-");892 const workspace = makeTempDir("t3code-ws-workspace-entries-");
890 fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });893 fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });
891 fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8");894 fs.writeFileSync(
895 path.join(workspace, "src", "components", "Composer.tsx"),
896 "export {};",
897 "utf8",
898 );
892 fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");899 fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
893 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });900 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
894 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");901 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
...@@ -1025,36 +1032,18 @@ describe("WebSocket Server", () => {...@@ -1025,36 +1032,18 @@ describe("WebSocket Server", () => {
1025 it("prunes missing projects on startup", async () => {1032 it("prunes missing projects on startup", async () => {
1026 const stateDir = makeTempDir("t3code-ws-prune-state-");1033 const stateDir = makeTempDir("t3code-ws-prune-state-");
1027 const existing = makeTempDir("t3code-ws-existing-project-");1034 const existing = makeTempDir("t3code-ws-existing-project-");
1028 const missing = path.join(stateDir, "definitely-missing");1035 const missing = makeTempDir("t3code-ws-missing-project-");
1029 const now = new Date().toISOString();
1030 const projectsFile = path.join(stateDir, "projects.json");
10311036
1032 fs.writeFileSync(1037 const projectRuntime = ManagedRuntime.make(
1033 projectsFile,1038 ProjectRepositoryLive.pipe(
1034 JSON.stringify(1039 Layer.provide(makeSqlitePersistenceLive(path.join(stateDir, "orchestration.sqlite"))),
1035 {
1036 version: 1,
1037 projects: [
1038 {
1039 id: "project-existing",
1040 cwd: existing,
1041 name: "existing",
1042 createdAt: now,
1043 updatedAt: now,
1044 },
1045 {
1046 id: "project-missing",
1047 cwd: missing,
1048 name: "missing",
1049 createdAt: now,
1050 updatedAt: now,
1051 },
1052 ],
1053 },
1054 null,
1055 2,
1056 ),1040 ),
1057 );1041 );
1042 const projectRepository = await projectRuntime.runPromise(ProjectRepository);
1043 await projectRuntime.runPromise(projectRepository.add({ cwd: existing }));
1044 await projectRuntime.runPromise(projectRepository.add({ cwd: missing }));
1045 await projectRuntime.dispose();
1046 fs.rmSync(missing, { recursive: true, force: true });
10581047
1059 server = createTestServer({ stateDir, cwd: "/test" });1048 server = createTestServer({ stateDir, cwd: "/test" });
1060 await server.start();1049 await server.start();
...@@ -1067,9 +1056,9 @@ describe("WebSocket Server", () => {...@@ -1067,9 +1056,9 @@ describe("WebSocket Server", () => {
10671056
1068 const response = await sendRequest(ws, WS_METHODS.projectsList);1057 const response = await sendRequest(ws, WS_METHODS.projectsList);
1069 expect(response.error).toBeUndefined();1058 expect(response.error).toBeUndefined();
1070 const listed = response.result as Array<{ id: string }>;1059 const listed = response.result as Array<{ cwd: string }>;
1071 expect(listed).toHaveLength(1);1060 expect(listed).toHaveLength(1);
1072 expect(listed[0]?.id).toBe("project-existing");1061 expect(listed[0]?.cwd).toBe(existing);
1073 });1062 });
10741063
1075 it("rejects websocket connections without a valid auth token", async () => {1064 it("rejects websocket connections without a valid auth token", async () => {
apps/server/src/wsServer.ts+150-137
...@@ -18,10 +18,10 @@ import {...@@ -18,10 +18,10 @@ import {
18 type WsResponse,18 type WsResponse,
19 wsRequestSchema,19 wsRequestSchema,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { Layer, ManagedRuntime } from "effect";
21import { WebSocketServer, type WebSocket } from "ws";22import { WebSocketServer, type WebSocket } from "ws";
2223
23import { createLogger } from "./logger";24import { createLogger } from "./logger";
24import { ProjectRegistry } from "./projectRegistry";
25import { ProviderManager } from "./providerManager";25import { ProviderManager } from "./providerManager";
26import { GitManager } from "./gitManager";26import { GitManager } from "./gitManager";
27import {27import {
...@@ -36,7 +36,13 @@ import {...@@ -36,7 +36,13 @@ import {
36import { TerminalManager } from "./terminalManager";36import { TerminalManager } from "./terminalManager";
37import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";37import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
38import { searchWorkspaceEntries } from "./workspaceEntries";38import { searchWorkspaceEntries } from "./workspaceEntries";
39import { createOrchestrationSystem, type OrchestrationSystem } from "./orchestration/runtime";39import type { OrchestrationEngine } from "./orchestration/engine";
40import { OrchestrationLive } from "./orchestration/layers";
41import { OrchestrationEngineService } from "./orchestration/services";
42import { ProjectRepositoryLive } from "./persistence/Layers/Projects";
43import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
44import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects";
45import assert from "node:assert";
4046
41const MIME_TYPES: Record<string, string> = {47const MIME_TYPES: Record<string, string> = {
42 ".html": "text/html; charset=utf-8",48 ".html": "text/html; charset=utf-8",
...@@ -61,12 +67,31 @@ export interface ServerOptions {...@@ -61,12 +67,31 @@ export interface ServerOptions {
61 staticDir?: string | undefined;67 staticDir?: string | undefined;
62 devUrl?: string | undefined;68 devUrl?: string | undefined;
63 logWebSocketEvents?: boolean | undefined;69 logWebSocketEvents?: boolean | undefined;
64 projectRegistry?: ProjectRegistry | undefined;
65 gitManager?: GitManager | undefined;70 gitManager?: GitManager | undefined;
66 terminalManager?: TerminalManager | undefined;71 terminalManager?: TerminalManager | undefined;
67 authToken?: string | undefined;72 authToken?: string | undefined;
68}73}
6974
75const isServerNotRunningError = (error: unknown): boolean => {
76 if (!(error instanceof Error)) return false;
77 const maybeCode = (error as NodeJS.ErrnoException).code;
78 return (
79 maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running")
80 );
81};
82
83function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {
84 socket.write(
85 `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` +
86 "Connection: close\r\n" +
87 "Content-Type: text/plain\r\n" +
88 `Content-Length: ${Buffer.byteLength(message)}\r\n` +
89 "\r\n" +
90 message,
91 );
92 socket.destroy();
93}
94
70function parseBooleanEnv(value: string | undefined): boolean | undefined {95function parseBooleanEnv(value: string | undefined): boolean | undefined {
71 if (value === undefined) return undefined;96 if (value === undefined) return undefined;
72 const normalized = value.trim().toLowerCase();97 const normalized = value.trim().toLowerCase();
...@@ -99,18 +124,19 @@ export function createServer(options: ServerOptions) {...@@ -99,18 +124,19 @@ export function createServer(options: ServerOptions) {
99 staticDir,124 staticDir,
100 devUrl,125 devUrl,
101 logWebSocketEvents: explicitLogWsEvents,126 logWebSocketEvents: explicitLogWsEvents,
102 projectRegistry: providedRegistry,127 gitManager = new GitManager(),
103 gitManager: providedGitManager,128 terminalManager = new TerminalManager(),
104 terminalManager: providedTerminalManager,
105 authToken,129 authToken,
106 } = options;130 } = options;
107 const providerManager = new ProviderManager();131 const providerManager = new ProviderManager();
108 const terminalManager = providedTerminalManager ?? new TerminalManager();132
109 const projectRegistry =
110 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
111 const gitManager = providedGitManager ?? new GitManager();
112 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");133 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");
113 let orchestrationSystem: OrchestrationSystem | null = null;134 let effectRuntime: ManagedRuntime.ManagedRuntime<
135 ProjectRepository | OrchestrationEngineService,
136 unknown
137 > | null = null;
138 let projectRepository: ProjectRepositoryShape | null = null;
139 let orchestrationEngine: OrchestrationEngine | null = null;
114 const clients = new Set<WebSocket>();140 const clients = new Set<WebSocket>();
115 const logger = createLogger("ws");141 const logger = createLogger("ws");
116 const logWebSocketEvents =142 const logWebSocketEvents =
...@@ -128,15 +154,23 @@ export function createServer(options: ServerOptions) {...@@ -128,15 +154,23 @@ export function createServer(options: ServerOptions) {
128 });154 });
129 }155 }
130156
131 function requireOrchestrationEngine() {157 const getOrchestrationEngine = () => {
132 if (!orchestrationSystem) {158 assert(orchestrationEngine, "Orchestration engine is not started");
133 throw new Error("Orchestration engine is not started");159 return orchestrationEngine;
134 }160 };
135 return orchestrationSystem.engine;161
136 }162 const getProjectRepository = () => {
163 assert(projectRepository, "Project repository is not started");
164 return projectRepository;
165 };
166
167 const getEffectRuntime = () => {
168 assert(effectRuntime, "Effect runtime is not started");
169 return effectRuntime;
170 };
137171
138 function attachOrchestrationSubscriptions() {172 function attachOrchestrationSubscriptions() {
139 const orchestrationEngine = requireOrchestrationEngine();173 const orchestrationEngine = getOrchestrationEngine();
140174
141 unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => {175 unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => {
142 const push: WsPush = {176 const push: WsPush = {
...@@ -179,17 +213,16 @@ export function createServer(options: ServerOptions) {...@@ -179,17 +213,16 @@ export function createServer(options: ServerOptions) {
179 // Forward provider events to all connected WebSocket clients213 // Forward provider events to all connected WebSocket clients
180 providerManager.on("event", (event) => {214 providerManager.on("event", (event) => {
181 void (async () => {215 void (async () => {
182 const liveOrchestrationSystem = orchestrationSystem;216 const liveOrchestrationEngine = orchestrationEngine;
183 if (!liveOrchestrationSystem) {217 if (!liveOrchestrationEngine) {
184 return;218 return;
185 }219 }
186 const orchestrationEngine = liveOrchestrationSystem.engine;220 const snapshot = liveOrchestrationEngine.getSnapshot();
187 const snapshot = orchestrationEngine.getSnapshot();
188 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId);221 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId);
189 if (!thread) return;222 if (!thread) return;
190 const now = event.createdAt;223 const now = event.createdAt;
191 if (event.method === "turn/started" || event.method === "turn/completed") {224 if (event.method === "turn/started" || event.method === "turn/completed") {
192 await orchestrationEngine.dispatch({225 await liveOrchestrationEngine.dispatch({
193 type: "thread.session",226 type: "thread.session",
194 commandId: crypto.randomUUID(),227 commandId: crypto.randomUUID(),
195 threadId: thread.id,228 threadId: thread.id,
...@@ -208,7 +241,7 @@ export function createServer(options: ServerOptions) {...@@ -208,7 +241,7 @@ export function createServer(options: ServerOptions) {
208 }241 }
209 if (event.textDelta && event.textDelta.length > 0) {242 if (event.textDelta && event.textDelta.length > 0) {
210 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;243 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;
211 await orchestrationEngine.dispatch({244 await liveOrchestrationEngine.dispatch({
212 type: "message.send",245 type: "message.send",
213 commandId: crypto.randomUUID(),246 commandId: crypto.randomUUID(),
214 threadId: thread.id,247 threadId: thread.id,
...@@ -221,7 +254,7 @@ export function createServer(options: ServerOptions) {...@@ -221,7 +254,7 @@ export function createServer(options: ServerOptions) {
221 }254 }
222 if (event.method === "turn/completed") {255 if (event.method === "turn/completed") {
223 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;256 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;
224 await orchestrationEngine.dispatch({257 await liveOrchestrationEngine.dispatch({
225 type: "message.send",258 type: "message.send",
226 commandId: crypto.randomUUID(),259 commandId: crypto.randomUUID(),
227 threadId: thread.id,260 threadId: thread.id,
...@@ -236,13 +269,15 @@ export function createServer(options: ServerOptions) {...@@ -236,13 +269,15 @@ export function createServer(options: ServerOptions) {
236 const payload = asObject(event.payload);269 const payload = asObject(event.payload);
237 const turnId = event.turnId ?? asString(payload?.turnId);270 const turnId = event.turnId ?? asString(payload?.turnId);
238 if (turnId) {271 if (turnId) {
239 await orchestrationEngine.dispatch({272 await liveOrchestrationEngine.dispatch({
240 type: "thread.turnDiff.complete",273 type: "thread.turnDiff.complete",
241 commandId: crypto.randomUUID(),274 commandId: crypto.randomUUID(),
242 threadId: thread.id,275 threadId: thread.id,
243 turnId,276 turnId,
244 completedAt: now,277 completedAt: now,
245 ...(asString(payload?.status) !== null ? { status: asString(payload?.status) ?? undefined } : {}),278 ...(asString(payload?.status) !== null
279 ? { status: asString(payload?.status) ?? undefined }
280 : {}),
246 files: [],281 files: [],
247 assistantMessageId: `assistant:${turnId}`,282 assistantMessageId: `assistant:${turnId}`,
248 ...(asNumber(payload?.turnCount) !== null283 ...(asNumber(payload?.turnCount) !== null
...@@ -253,7 +288,7 @@ export function createServer(options: ServerOptions) {...@@ -253,7 +288,7 @@ export function createServer(options: ServerOptions) {
253 }288 }
254 }289 }
255 if (event.kind === "error" && event.message) {290 if (event.kind === "error" && event.message) {
256 await orchestrationEngine.dispatch({291 await liveOrchestrationEngine.dispatch({
257 type: "thread.session",292 type: "thread.session",
258 commandId: crypto.randomUUID(),293 commandId: crypto.randomUUID(),
259 threadId: thread.id,294 threadId: thread.id,
...@@ -350,18 +385,6 @@ export function createServer(options: ServerOptions) {...@@ -350,18 +385,6 @@ export function createServer(options: ServerOptions) {
350 // WebSocket server — upgrades from the HTTP server385 // WebSocket server — upgrades from the HTTP server
351 const wss = new WebSocketServer({ noServer: true });386 const wss = new WebSocketServer({ noServer: true });
352387
353 function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {
354 socket.write(
355 `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` +
356 "Connection: close\r\n" +
357 "Content-Type: text/plain\r\n" +
358 `Content-Length: ${Buffer.byteLength(message)}\r\n` +
359 "\r\n" +
360 message,
361 );
362 socket.destroy();
363 }
364
365 httpServer.on("upgrade", (request, socket, head) => {388 httpServer.on("upgrade", (request, socket, head) => {
366 if (authToken) {389 if (authToken) {
367 let providedToken: string | null = null;390 let providedToken: string | null = null;
...@@ -386,7 +409,7 @@ export function createServer(options: ServerOptions) {...@@ -386,7 +409,7 @@ export function createServer(options: ServerOptions) {
386409
387 wss.on("connection", (ws) => {410 wss.on("connection", (ws) => {
388 clients.add(ws);411 clients.add(ws);
389 const orchestrationEngine = requireOrchestrationEngine();412 const orchestrationEngine = getOrchestrationEngine();
390413
391 // Send welcome message with project info414 // Send welcome message with project info
392 const segments = cwd.split(/[/\\]/).filter(Boolean);415 const segments = cwd.split(/[/\\]/).filter(Boolean);
...@@ -453,7 +476,9 @@ export function createServer(options: ServerOptions) {...@@ -453,7 +476,9 @@ export function createServer(options: ServerOptions) {
453 }476 }
454477
455 async function routeRequest(request: WsRequest): Promise<unknown> {478 async function routeRequest(request: WsRequest): Promise<unknown> {
456 const orchestrationEngine = requireOrchestrationEngine();479 const orchestrationEngine = getOrchestrationEngine();
480 const projectRepository = getProjectRepository();
481 const effectRuntime = getEffectRuntime();
457482
458 switch (request.method) {483 switch (request.method) {
459 case WS_METHODS.providersStartSession:484 case WS_METHODS.providersStartSession:
...@@ -479,79 +504,56 @@ export function createServer(options: ServerOptions) {...@@ -479,79 +504,56 @@ export function createServer(options: ServerOptions) {
479 case WS_METHODS.providersGetCheckpointDiff:504 case WS_METHODS.providersGetCheckpointDiff:
480 return providerManager.getCheckpointDiff(request.params as never);505 return providerManager.getCheckpointDiff(request.params as never);
481506
482 case WS_METHODS.providersRevertToCheckpoint:507 case WS_METHODS.providersRevertToCheckpoint: {
483 {508 const result = await providerManager.revertToCheckpoint(request.params as never);
484 const result = await providerManager.revertToCheckpoint(request.params as never);509 const params = request.params as { sessionId?: string };
485 const params = request.params as { sessionId?: string };510 const sessionId = params.sessionId;
486 const sessionId = params.sessionId;511 if (typeof sessionId === "string") {
487 if (typeof sessionId === "string") {512 const snapshot = orchestrationEngine.getSnapshot();
488 const snapshot = orchestrationEngine.getSnapshot();513 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId);
489 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId);514 if (thread) {
490 if (thread) {515 const now = new Date().toISOString();
491 const now = new Date().toISOString();516 await orchestrationEngine.dispatch({
517 type: "thread.revert",
518 commandId: crypto.randomUUID(),
519 threadId: thread.id,
520 turnCount: result.turnCount,
521 messageCount: result.messageCount,
522 createdAt: now,
523 });
524 if (thread.session) {
492 await orchestrationEngine.dispatch({525 await orchestrationEngine.dispatch({
493 type: "thread.revert",526 type: "thread.session",
494 commandId: crypto.randomUUID(),527 commandId: crypto.randomUUID(),
495 threadId: thread.id,528 threadId: thread.id,
496 turnCount: result.turnCount,529 session: {
497 messageCount: result.messageCount,530 ...thread.session,
531 status: "ready",
532 activeTurnId: null,
533 updatedAt: now,
534 lastError: null,
535 },
498 createdAt: now,536 createdAt: now,
499 });537 });
500 if (thread.session) {
501 await orchestrationEngine.dispatch({
502 type: "thread.session",
503 commandId: crypto.randomUUID(),
504 threadId: thread.id,
505 session: {
506 ...thread.session,
507 status: "ready",
508 activeTurnId: null,
509 updatedAt: now,
510 lastError: null,
511 },
512 createdAt: now,
513 });
514 }
515 }538 }
516 }539 }
517 return result;
518 }540 }
541 return result;
542 }
519543
520 case WS_METHODS.projectsList:544 case WS_METHODS.projectsList:
521 return projectRegistry.list();545 return effectRuntime.runPromise(projectRepository.list());
522546
523 case WS_METHODS.projectsAdd:547 case WS_METHODS.projectsAdd:
524 {548 return effectRuntime.runPromise(projectRepository.add(request.params as never));
525 const result = await projectRegistry.add(request.params as never);
526 await orchestrationEngine.dispatch({
527 type: "project.create",
528 commandId: crypto.randomUUID(),
529 projectId: result.project.id,
530 name: result.project.name,
531 cwd: result.project.cwd,
532 model: "gpt-5-codex",
533 createdAt: new Date().toISOString(),
534 });
535 return result;
536 }
537549
538 case WS_METHODS.projectsRemove:550 case WS_METHODS.projectsRemove:
539 {551 return effectRuntime.runPromise(projectRepository.remove(request.params as never));
540 const params = request.params as { id: string };
541 projectRegistry.remove(request.params as never);
542 await orchestrationEngine.dispatch({
543 type: "project.delete",
544 commandId: crypto.randomUUID(),
545 projectId: params.id,
546 createdAt: new Date().toISOString(),
547 });
548 return undefined;
549 }
550552
551 case WS_METHODS.projectsSearchEntries:553 case WS_METHODS.projectsSearchEntries:
552 return searchWorkspaceEntries(request.params as never);554 return searchWorkspaceEntries(request.params as never);
553 case WS_METHODS.projectsUpdateScripts:555 case WS_METHODS.projectsUpdateScripts:
554 return projectRegistry.updateScripts(request.params as never);556 return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never));
555557
556 case WS_METHODS.shellOpenInEditor: {558 case WS_METHODS.shellOpenInEditor: {
557 const params = request.params as {559 const params = request.params as {
...@@ -597,25 +599,25 @@ export function createServer(options: ServerOptions) {...@@ -597,25 +599,25 @@ export function createServer(options: ServerOptions) {
597 return undefined;599 return undefined;
598 }600 }
599601
600 case WS_METHODS.gitStatus:602 case WS_METHODS.gitStatus: {
601 {603 const params = request.params as { cwd: string };
602 const params = request.params as { cwd: string };604 const status = await gitManager.status(request.params as never);
603 const status = await gitManager.status(request.params as never);605 const projects = await effectRuntime.runPromise(projectRepository.list());
604 const project = projectRegistry.list().find((entry) => entry.cwd === params.cwd);606 const project = projects.find((entry) => entry.cwd === params.cwd);
605 if (project) {607 if (project) {
606 await orchestrationEngine.dispatch({608 await orchestrationEngine.dispatch({
607 type: "git.readModel.upsert",609 type: "git.readModel.upsert",
608 commandId: crypto.randomUUID(),610 commandId: crypto.randomUUID(),
609 projectId: project.id,611 projectId: project.id,
610 branch: status.branch,612 branch: status.branch,
611 hasWorkingTreeChanges: status.hasWorkingTreeChanges,613 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
612 aheadCount: status.aheadCount,614 aheadCount: status.aheadCount,
613 behindCount: status.behindCount,615 behindCount: status.behindCount,
614 createdAt: new Date().toISOString(),616 createdAt: new Date().toISOString(),
615 });617 });
616 }
617 return status;
618 }618 }
619 return status;
620 }
619621
620 case WS_METHODS.gitPull:622 case WS_METHODS.gitPull:
621 return pullGitBranch(request.params as never);623 return pullGitBranch(request.params as never);
...@@ -698,37 +700,57 @@ export function createServer(options: ServerOptions) {...@@ -698,37 +700,57 @@ export function createServer(options: ServerOptions) {
698 }700 }
699 }701 }
700702
701 async function ensureOrchestrationSystemStarted() {703 async function createEffectRuntime() {
702 if (orchestrationSystem) {704 const dbPath = path.join(resolvedStateDir, "orchestration.sqlite");
703 return;705 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
706 const runtime = ManagedRuntime.make(
707 Layer.mergeAll(OrchestrationLive, ProjectRepositoryLive).pipe(
708 Layer.provide(persistenceLayer),
709 ),
710 );
711
712 try {
713 const [nextOrchestrationEngine, repository] = await Promise.all([
714 runtime.runPromise(OrchestrationEngineService),
715 runtime.runPromise(ProjectRepository),
716 ]);
717 orchestrationEngine = nextOrchestrationEngine;
718 projectRepository = repository;
719 await runtime.runPromise(repository.pruneMissing());
720 attachOrchestrationSubscriptions();
721 } catch (error) {
722 await runtime.dispose().catch(() => undefined);
723 throw error;
704 }724 }
705725
706 orchestrationSystem = await createOrchestrationSystem(resolvedStateDir);726 return runtime;
707 attachOrchestrationSubscriptions();
708 }727 }
709728
710 async function disposeOrchestrationSystem() {729 async function disposeEffectRuntime() {
711 unsubscribeReadModel();730 unsubscribeReadModel();
712 unsubscribeDomainEvents();731 unsubscribeDomainEvents();
713 unsubscribeReadModel = noop;732 unsubscribeReadModel = noop;
714 unsubscribeDomainEvents = noop;733 unsubscribeDomainEvents = noop;
715734
716 if (!orchestrationSystem) {735 const runtime = effectRuntime;
736 effectRuntime = null;
737 orchestrationEngine = null;
738 projectRepository = null;
739
740 if (!runtime) {
717 return;741 return;
718 }742 }
719743
720 const system = orchestrationSystem;744 await runtime.dispose();
721 orchestrationSystem = null;
722 await system.dispose();
723 }745 }
724746
725 async function start() {747 async function start() {
726 await ensureOrchestrationSystemStarted();748 effectRuntime = await createEffectRuntime();
727749
728 return new Promise<void>((resolve, reject) => {750 return new Promise<void>((resolve, reject) => {
729 const onError = (error: Error) => {751 const onError = (error: Error) => {
730 httpServer.off("error", onError);752 httpServer.off("error", onError);
731 void disposeOrchestrationSystem().finally(() => reject(error));753 void disposeEffectRuntime().finally(() => reject(error));
732 };754 };
733 httpServer.once("error", onError);755 httpServer.once("error", onError);
734 const onListening = () => {756 const onListening = () => {
...@@ -744,7 +766,7 @@ export function createServer(options: ServerOptions) {...@@ -744,7 +766,7 @@ export function createServer(options: ServerOptions) {
744 }766 }
745767
746 async function stop(): Promise<void> {768 async function stop(): Promise<void> {
747 await disposeOrchestrationSystem();769 await disposeEffectRuntime();
748 terminalManager.off("event", onTerminalEvent);770 terminalManager.off("event", onTerminalEvent);
749 providerManager.stopAll();771 providerManager.stopAll();
750 providerManager.dispose();772 providerManager.dispose();
...@@ -755,15 +777,6 @@ export function createServer(options: ServerOptions) {...@@ -755,15 +777,6 @@ export function createServer(options: ServerOptions) {
755 }777 }
756 clients.clear();778 clients.clear();
757779
758 const isServerNotRunningError = (error: unknown): boolean => {
759 if (!(error instanceof Error)) return false;
760 const maybeCode = (error as NodeJS.ErrnoException).code;
761 return (
762 maybeCode === "ERR_SERVER_NOT_RUNNING" ||
763 error.message.toLowerCase().includes("not running")
764 );
765 };
766
767 const closeWebSocketServer = new Promise<void>((resolve, reject) => {780 const closeWebSocketServer = new Promise<void>((resolve, reject) => {
768 wss.close((error) => {781 wss.close((error) => {
769 if (error && !isServerNotRunningError(error)) {782 if (error && !isServerNotRunningError(error)) {
apps/web/src/components/Sidebar.tsx+23-44
...@@ -28,13 +28,6 @@ function formatRelativeTime(iso: string): string {...@@ -28,13 +28,6 @@ function formatRelativeTime(iso: string): string {
28 return `${Math.floor(hours / 24)}d ago`;28 return `${Math.floor(hours / 24)}d ago`;
29}29}
3030
31function inferProjectName(cwd: string): string {
32 const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
33 const parts = normalized.split("/");
34 const last = parts[parts.length - 1];
35 return last && last.length > 0 ? last : "project";
36}
37
38interface ThreadStatusPill {31interface ThreadStatusPill {
39 label: "Working" | "Connecting" | "Completed" | "Awaiting response";32 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
40 colorClass: string;33 colorClass: string;
...@@ -195,23 +188,16 @@ export default function Sidebar() {...@@ -195,23 +188,16 @@ export default function Sidebar() {
195 return;188 return;
196 }189 }
197190
198 const projectId = crypto.randomUUID();191 const result = await api.projects.add({ cwd });
199 const now = new Date().toISOString();192 const projects = await api.projects.list();
200 const name = inferProjectName(cwd);193 dispatch({ type: "SYNC_PROJECTS", projects });
201 if (isElectron) {194
202 // Keep project registry in sync for desktop integrations.195 const hasThread = state.threads.some((thread) => thread.projectId === result.project.id);
203 await api.projects.add({ cwd });196 if (hasThread) {
197 focusMostRecentThreadForProject(result.project.id);
198 return;
204 }199 }
205 await api.orchestration.dispatchCommand({200 handleNewThread(result.project.id);
206 type: "project.create",
207 commandId: crypto.randomUUID(),
208 projectId,
209 name,
210 cwd,
211 model: DEFAULT_MODEL,
212 createdAt: now,
213 });
214 handleNewThread(projectId);
215 } finally {201 } finally {
216 setIsAddingProject(false);202 setIsAddingProject(false);
217 setNewCwd("");203 setNewCwd("");
...@@ -225,6 +211,7 @@ export default function Sidebar() {...@@ -225,6 +211,7 @@ export default function Sidebar() {
225 handleNewThread,211 handleNewThread,
226 isAddingProject,212 isAddingProject,
227 state.projects,213 state.projects,
214 state.threads,
228 ],215 ],
229 );216 );
230217
...@@ -381,28 +368,20 @@ export default function Sidebar() {...@@ -381,28 +368,20 @@ export default function Sidebar() {
381 );368 );
382 if (!confirmed) return;369 if (!confirmed) return;
383370
384 if (isElectron) {371 try {
385 try {372 await api.projects.remove({ id: projectId });
386 await api.projects.remove({ id: projectId });373 const projects = await api.projects.list();
387 } catch (error) {374 dispatch({ type: "SYNC_PROJECTS", projects });
388 const message =375 } catch (error) {
389 error instanceof Error ? error.message : "Unknown error deleting project.";376 const message =
390 console.error("Failed to remove project", { projectId, error });377 error instanceof Error ? error.message : "Unknown error deleting project.";
391 toastManager.add({378 console.error("Failed to remove project", { projectId, error });
392 type: "error",379 toastManager.add({
393 title: `Failed to delete "${project.name}"`,380 type: "error",
394 description: message,381 title: `Failed to delete "${project.name}"`,
395 });382 description: message,
396 return;383 });
397 }
398 }384 }
399
400 await api.orchestration.dispatchCommand({
401 type: "project.delete",
402 commandId: crypto.randomUUID(),
403 projectId,
404 createdAt: new Date().toISOString(),
405 });
406 },385 },
407 [api, dispatch, state.projects, state.threads],386 [api, dispatch, state.projects, state.threads],
408 );387 );
apps/web/src/routes/__root.tsx+23-25
...@@ -126,9 +126,9 @@ function EventRouter() {...@@ -126,9 +126,9 @@ function EventRouter() {
126126
127 useEffect(() => {127 useEffect(() => {
128 if (!api) return;128 if (!api) return;
129 void api.orchestration129 void Promise.all([api.projects.list(), api.orchestration.getSnapshot()])
130 .getSnapshot()130 .then(([projects, snapshot]) => {
131 .then((snapshot) => {131 dispatch({ type: "SYNC_PROJECTS", projects });
132 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });132 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
133 })133 })
134 .catch(() => undefined);134 .catch(() => undefined);
...@@ -150,8 +150,8 @@ function AutoProjectBootstrap() {...@@ -150,8 +150,8 @@ function AutoProjectBootstrap() {
150150
151 useEffect(() => {151 useEffect(() => {
152 if (!api) return;152 if (!api) return;
153 // Browser mode bootstraps from server welcome.153 // Browser mode auto-adds the current cwd project via server welcome.
154 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.154 // Desktop skips this because projects are already loaded from the repository.
155 if (isElectron) return;155 if (isElectron) return;
156156
157 return onServerWelcome((payload) => {157 return onServerWelcome((payload) => {
...@@ -166,40 +166,38 @@ function AutoProjectBootstrap() {...@@ -166,40 +166,38 @@ function AutoProjectBootstrap() {
166 }166 }
167167
168 bootstrappedRef.current = true;168 bootstrappedRef.current = true;
169 const projectId = crypto.randomUUID();
170 const threadId = crypto.randomUUID();
171 const now = new Date().toISOString();169 const now = new Date().toISOString();
172 void api.orchestration170 void api.projects
173 .dispatchCommand({171 .add({ cwd: payload.cwd })
174 type: "project.create",172 .then(async (result) => {
175 commandId: crypto.randomUUID(),173 const projects = await api.projects.list();
176 projectId,174 dispatch({ type: "SYNC_PROJECTS", projects });
177 name: payload.projectName,175
178 cwd: payload.cwd,176 const hasThread = state.threads.some((thread) => thread.projectId === result.project.id);
179 model: DEFAULT_MODEL,177 if (hasThread) {
180 createdAt: now,178 return;
181 })179 }
182 .then(() =>180
183 api.orchestration.dispatchCommand({181 return api.orchestration.dispatchCommand({
184 type: "thread.create",182 type: "thread.create",
185 commandId: crypto.randomUUID(),183 commandId: crypto.randomUUID(),
186 threadId,184 threadId: crypto.randomUUID(),
187 projectId,185 projectId: result.project.id,
188 title: "New thread",186 title: "New thread",
189 model: DEFAULT_MODEL,187 model: DEFAULT_MODEL,
190 branch: null,188 branch: null,
191 worktreePath: null,189 worktreePath: null,
192 createdAt: now,190 createdAt: now,
193 }),191 });
194 )192 })
195 .catch(() => undefined);193 .catch(() => undefined);
196 });194 });
197 }, [api, state.projects, dispatch]);195 }, [api, state.projects, state.threads, dispatch]);
198196
199 return null;197 return null;
200}198}
201199
202function DesktopProjectBootstrap() {200function DesktopProjectBootstrap() {
203 // Desktop now hydrates from orchestration read-model snapshots.201 // Desktop hydration runs through EventRouter project + orchestration sync.
204 return null;202 return null;
205}203}
apps/web/src/store.ts+32-17
...@@ -10,9 +10,10 @@ import {...@@ -10,9 +10,10 @@ import {
1010
11import {11import {
12 type OrchestrationReadModel,12 type OrchestrationReadModel,
13 type ProjectListResult,
13 normalizeProjectScripts,14 normalizeProjectScripts,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
15import { resolveModelSlug } from "./model-logic";16import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
16import {17import {
17 DEFAULT_THREAD_TERMINAL_HEIGHT,18 DEFAULT_THREAD_TERMINAL_HEIGHT,
18 DEFAULT_THREAD_TERMINAL_ID,19 DEFAULT_THREAD_TERMINAL_ID,
...@@ -29,6 +30,7 @@ import {...@@ -29,6 +30,7 @@ import {
2930
30type Action =31type Action =
31 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }32 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
33 | { type: "SYNC_PROJECTS"; projects: ProjectListResult }
32 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }34 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
33 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }35 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
34 | { type: "TOGGLE_PROJECT"; projectId: string }36 | { type: "TOGGLE_PROJECT"; projectId: string }
...@@ -136,6 +138,29 @@ function updateThread(...@@ -136,6 +138,29 @@ function updateThread(
136 return threads.map((t) => (t.id === threadId ? updater(t) : t));138 return threads.map((t) => (t.id === threadId ? updater(t) : t));
137}139}
138140
141function mapProjects(
142 incoming: ProjectListResult,
143 previous: Project[],
144): Project[] {
145 return incoming.map((project) => {
146 const existing =
147 previous.find((entry) => entry.id === project.id) ??
148 previous.find((entry) => entry.cwd === project.cwd);
149 return {
150 id: project.id,
151 name: project.name,
152 cwd: project.cwd,
153 model: existing?.model ?? DEFAULT_MODEL,
154 expanded:
155 existing?.expanded ??
156 (persistedExpandedProjectCwds.size > 0
157 ? persistedExpandedProjectCwds.has(project.cwd)
158 : true),
159 scripts: normalizeProjectScripts(project.scripts),
160 };
161 });
162}
163
139164
140function normalizeTerminalIds(terminalIds: string[]): string[] {165function normalizeTerminalIds(terminalIds: string[]): string[] {
141 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);166 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
...@@ -330,21 +355,6 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -330,21 +355,6 @@ export function reducer(state: AppState, action: Action): AppState {
330 switch (action.type) {355 switch (action.type) {
331 case "SYNC_SERVER_READ_MODEL": {356 case "SYNC_SERVER_READ_MODEL": {
332 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));357 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));
333 const projects = action.readModel.projects.map((project) => {
334 const existing = state.projects.find((entry) => entry.id === project.id);
335 return {
336 id: project.id,
337 name: project.name,
338 cwd: project.cwd,
339 model: resolveModelSlug(project.model),
340 expanded:
341 existing?.expanded ??
342 (persistedExpandedProjectCwds.size > 0
343 ? persistedExpandedProjectCwds.has(project.cwd)
344 : true),
345 scripts: existing?.scripts ?? [],
346 };
347 });
348 const threads = action.readModel.threads.map((thread) => {358 const threads = action.readModel.threads.map((thread) => {
349 const existing = existingThreadById.get(thread.id);359 const existing = existingThreadById.get(thread.id);
350 return normalizeThreadTerminals({360 return normalizeThreadTerminals({
...@@ -392,12 +402,17 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -392,12 +402,17 @@ export function reducer(state: AppState, action: Action): AppState {
392 });402 });
393 return {403 return {
394 ...state,404 ...state,
395 projects,
396 threads,405 threads,
397 threadsHydrated: true,406 threadsHydrated: true,
398 };407 };
399 }408 }
400409
410 case "SYNC_PROJECTS":
411 return {
412 ...state,
413 projects: mapProjects(action.projects, state.projects),
414 };
415
401 case "SET_PROJECT_SCRIPTS":416 case "SET_PROJECT_SCRIPTS":
402 return {417 return {
403 ...state,418 ...state,
packages/contracts/src/orchestration.ts-31
...@@ -80,17 +80,6 @@ export const OrchestrationThreadSchema = Schema.Struct({...@@ -80,17 +80,6 @@ export const OrchestrationThreadSchema = Schema.Struct({
8080
81export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>;81export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>;
8282
83export const OrchestrationProjectSchema = Schema.Struct({
84 id: IdSchema,
85 name: Schema.String,
86 cwd: Schema.String,
87 model: Schema.String,
88 createdAt: IsoDateTimeSchema,
89 updatedAt: IsoDateTimeSchema,
90});
91
92export type OrchestrationProject = Schema.Schema.Type<typeof OrchestrationProjectSchema>;
93
94export const OrchestrationGitReadModelSchema = Schema.Struct({83export const OrchestrationGitReadModelSchema = Schema.Struct({
95 projectId: IdSchema,84 projectId: IdSchema,
96 branch: Schema.NullOr(Schema.String),85 branch: Schema.NullOr(Schema.String),
...@@ -104,7 +93,6 @@ export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationG...@@ -104,7 +93,6 @@ export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationG
10493
105export const OrchestrationReadModelSchema = Schema.Struct({94export const OrchestrationReadModelSchema = Schema.Struct({
106 sequence: Schema.Number,95 sequence: Schema.Number,
107 projects: Schema.Array(OrchestrationProjectSchema),
108 threads: Schema.Array(OrchestrationThreadSchema),96 threads: Schema.Array(OrchestrationThreadSchema),
109 gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }),97 gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }),
110 updatedAt: IsoDateTimeSchema,98 updatedAt: IsoDateTimeSchema,
...@@ -112,23 +100,6 @@ export const OrchestrationReadModelSchema = Schema.Struct({...@@ -112,23 +100,6 @@ export const OrchestrationReadModelSchema = Schema.Struct({
112100
113export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>;101export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>;
114102
115export const CreateProjectCommandSchema = Schema.Struct({
116 type: Schema.Literal("project.create"),
117 commandId: IdSchema,
118 projectId: IdSchema,
119 name: Schema.String,
120 cwd: Schema.String,
121 model: Schema.String,
122 createdAt: IsoDateTimeSchema,
123});
124
125export const DeleteProjectCommandSchema = Schema.Struct({
126 type: Schema.Literal("project.delete"),
127 commandId: IdSchema,
128 projectId: IdSchema,
129 createdAt: IsoDateTimeSchema,
130});
131
132export const CreateThreadCommandSchema = Schema.Struct({103export const CreateThreadCommandSchema = Schema.Struct({
133 type: Schema.Literal("thread.create"),104 type: Schema.Literal("thread.create"),
134 commandId: IdSchema,105 commandId: IdSchema,
...@@ -212,8 +183,6 @@ export const RevertThreadCommandSchema = Schema.Struct({...@@ -212,8 +183,6 @@ export const RevertThreadCommandSchema = Schema.Struct({
212});183});
213184
214export const OrchestrationCommandSchema = Schema.Union(185export const OrchestrationCommandSchema = Schema.Union(
215 CreateProjectCommandSchema,
216 DeleteProjectCommandSchema,
217 CreateThreadCommandSchema,186 CreateThreadCommandSchema,
218 DeleteThreadCommandSchema,187 DeleteThreadCommandSchema,
219 UpdateThreadMetaCommandSchema,188 UpdateThreadMetaCommandSchema,