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";
66
77import { fixPath } from "./fixPath";
88import { createLogger } from "./logger";
9import { ProjectRegistry } from "./projectRegistry";
109import { createServer } from "./wsServer";
1110
1211fixPath();
......@@ -102,7 +101,6 @@ async function main() {
102101 const port =
103102 requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT));
104103 const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR);
105 const projectRegistry = new ProjectRegistry(stateDir);
106104 const devUrl = process.env.VITE_DEV_SERVER_URL;
107105 const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop";
108106 const authToken = process.env.T3CODE_AUTH_TOKEN;
......@@ -121,7 +119,6 @@ async function main() {
121119 stateDir,
122120 staticDir,
123121 devUrl,
124 projectRegistry,
125122 authToken,
126123 });
127124 await server.start();
apps/server/src/orchestration/engine.test.ts+53-53
......@@ -8,7 +8,13 @@ import { afterEach, describe, expect, it } from "vitest";
88
99import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents";
1010import { 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
1319const tempDirs: string[] = [];
1420
......@@ -24,6 +30,19 @@ afterEach(() => {
2430 }
2531});
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
2746describe("OrchestrationEngine", () => {
2847 it("replays to the same deterministic snapshot", async () => {
2948 const stateDir = makeTempDir("t3code-orchestration-");
......@@ -33,18 +52,9 @@ describe("OrchestrationEngine", () => {
3352
3453 const firstSystem = await createOrchestrationSystem(stateDir);
3554 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 });
4555 await engineA.dispatch({
4656 type: "thread.create",
47 commandId: "cmd-2",
57 commandId: "cmd-1",
4858 threadId,
4959 projectId,
5060 title: "Thread",
......@@ -82,12 +92,14 @@ describe("OrchestrationEngine", () => {
8292 updates.push(snapshot.sequence);
8393 });
8494 await engine.dispatch({
85 type: "project.create",
86 commandId: "cmd-project",
95 type: "thread.create",
96 commandId: "cmd-thread",
97 threadId: "thread-2",
8798 projectId: "project-2",
88 name: "fanout",
89 cwd: "/tmp/fanout",
99 title: "fanout",
90100 model: "gpt-5-codex",
101 branch: null,
102 worktreePath: null,
91103 createdAt: new Date().toISOString(),
92104 });
93105 unsubscribe();
......@@ -100,24 +112,26 @@ describe("OrchestrationEngine", () => {
100112 const system = await createOrchestrationSystem(stateDir);
101113 const engine = system.engine;
102114 await engine.dispatch({
103 type: "project.create",
115 type: "thread.create",
104116 commandId: "cmd-a",
117 threadId: "thread-replay",
105118 projectId: "project-replay",
106 name: "replay",
107 cwd: "/tmp/replay",
119 title: "replay",
108120 model: "gpt-5-codex",
121 branch: null,
122 worktreePath: null,
109123 createdAt: new Date().toISOString(),
110124 });
111125 await engine.dispatch({
112 type: "project.delete",
126 type: "thread.delete",
113127 commandId: "cmd-b",
114 projectId: "project-replay",
128 threadId: "thread-replay",
115129 createdAt: new Date().toISOString(),
116130 });
117131 const events = await engine.replayEvents(0);
118132 expect(events.length).toBe(2);
119 expect(events[0]?.type).toBe("project.created");
120 expect(events[1]?.type).toBe("project.deleted");
133 expect(events[0]?.type).toBe("thread.created");
134 expect(events[1]?.type).toBe("thread.deleted");
121135 await system.dispose();
122136 });
123137
......@@ -126,15 +140,6 @@ describe("OrchestrationEngine", () => {
126140 const firstSystem = await createOrchestrationSystem(stateDir);
127141 const engine = firstSystem.engine;
128142 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 });
138143 await engine.dispatch({
139144 type: "thread.create",
140145 commandId: "cmd-thread-turn-diff",
......@@ -158,7 +163,9 @@ describe("OrchestrationEngine", () => {
158163 createdAt: completedAt,
159164 });
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");
162169 expect(firstThread?.turnDiffSummaries).toEqual([
163170 {
164171 turnId: "turn-1",
......@@ -192,15 +199,6 @@ describe("OrchestrationEngine", () => {
192199 const firstSystem = await createOrchestrationSystem(stateDir);
193200 const engine = firstSystem.engine;
194201 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 });
204202 await engine.dispatch({
205203 type: "thread.create",
206204 commandId: "cmd-thread-revert",
......@@ -334,9 +332,7 @@ describe("OrchestrationEngine", () => {
334332 append(event) {
335333 if (shouldFailFirstAppend) {
336334 shouldFailFirstAppend = false;
337 return Effect.sync(() => {
338 throw new Error("append failed");
339 });
335 return Effect.die(new Error("append failed"));
340336 }
341337 const savedEvent = {
342338 ...event,
......@@ -360,28 +356,32 @@ describe("OrchestrationEngine", () => {
360356
361357 await expect(
362358 engine.dispatch({
363 type: "project.create",
359 type: "thread.create",
364360 commandId: "cmd-flaky-1",
365 projectId: "project-flaky-fail",
366 name: "flaky-fail",
367 cwd: "/tmp/flaky-fail",
361 threadId: "thread-flaky-fail",
362 projectId: "project-flaky",
363 title: "flaky-fail",
368364 model: "gpt-5-codex",
365 branch: null,
366 worktreePath: null,
369367 createdAt,
370368 }),
371369 ).rejects.toThrow("append failed");
372370
373371 const result = await engine.dispatch({
374 type: "project.create",
372 type: "thread.create",
375373 commandId: "cmd-flaky-2",
376 projectId: "project-flaky-ok",
377 name: "flaky-ok",
378 cwd: "/tmp/flaky-ok",
374 threadId: "thread-flaky-ok",
375 projectId: "project-flaky",
376 title: "flaky-ok",
379377 model: "gpt-5-codex",
378 branch: null,
379 worktreePath: null,
380380 createdAt,
381381 });
382382
383383 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
386386 await engine.stop();
387387 });
apps/server/src/orchestration/engine.ts+11-48
......@@ -34,36 +34,6 @@ function asError(error: unknown, fallbackMessage: string): Error {
3434function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> {
3535 const eventId = crypto.randomUUID();
3636 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 };
6737 case "thread.create":
6838 return {
6939 eventId,
......@@ -226,10 +196,7 @@ export class OrchestrationEngine {
226196 return Effect.gen(this, function* () {
227197 const eventBase = mapCommandToEvent(envelope.command);
228198 const savedEvent = yield* this.eventStore.append(eventBase);
229
230 yield* Effect.sync(() => {
231 this.readModel = reduceEvent(this.readModel, savedEvent);
232 });
199 this.readModel = yield* reduceEvent(this.readModel, savedEvent);
233200
234201 const snapshot = this.readModel;
235202 yield* Effect.all([
......@@ -237,14 +204,12 @@ export class OrchestrationEngine {
237204 PubSub.publish(this.readModelPubSub, snapshot),
238205 ]);
239206
240 yield* Effect.sync(() => {
241 for (const listener of this.domainEventListeners) {
242 listener(savedEvent);
243 }
244 for (const listener of this.readModelListeners) {
245 listener(snapshot);
246 }
247 });
207 for (const listener of this.domainEventListeners) {
208 listener(savedEvent);
209 }
210 for (const listener of this.readModelListeners) {
211 listener(snapshot);
212 }
248213
249214 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });
250215 }).pipe(
......@@ -257,14 +222,12 @@ export class OrchestrationEngine {
257222 );
258223 }
259224
260 private bootstrapReadModel(): Effect.Effect<void> {
225 private bootstrapReadModel(): Effect.Effect<void, unknown> {
261226 return Effect.gen(this, function* () {
262227 const existingEvents = yield* this.eventStore.readAll();
263 yield* Effect.sync(() => {
264 for (const event of existingEvents) {
265 this.readModel = reduceEvent(this.readModel, event);
266 }
267 });
228 for (const event of existingEvents) {
229 this.readModel = yield* reduceEvent(this.readModel, event);
230 }
268231 });
269232 }
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 @@
1import path from "node:path";
2
31import { Effect, Layer } from "effect";
42
53import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents";
6import { makeSqliteOrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents";
4import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents";
75import { OrchestrationEngine } from "./engine";
8import { OrchestrationConfig, 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);
6import { OrchestrationEngineService } from "./services";
167
178export const OrchestrationEngineLive = Layer.scoped(
189 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 {
22 OrchestrationEvent,
33 OrchestrationGitReadModel,
44 OrchestrationMessage,
5 OrchestrationProject,
65 OrchestrationReadModel,
76 OrchestrationSession,
87 OrchestrationThread,
98} 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
1120type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
1221
......@@ -18,38 +27,84 @@ function updateThread(
1827 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
1928}
2029
21function asObject(value: unknown): Record<string, unknown> | null {
22 if (!value || typeof value !== "object") return null;
23 return value as Record<string, unknown>;
24}
30const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModelSchema);
31const decodeMessage = Schema.decodeUnknown(OrchestrationMessageSchema);
32const decodeSession = Schema.decodeUnknown(OrchestrationSessionSchema);
33const decodeThread = Schema.decodeUnknown(OrchestrationThreadSchema);
34const decodeGitReadModel = Schema.decodeUnknown(OrchestrationGitReadModelSchema);
35const decodeTurnDiffSummary = Schema.decodeUnknown(OrchestrationTurnDiffSummarySchema);
2536
26function asString(value: unknown): string | null {
27 return typeof value === "string" ? value : null;
28}
37const ThreadCreatedPayloadSchema = Schema.Struct({
38 id: Schema.String,
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 {
31 return typeof value === "number" && Number.isFinite(value) ? value : null;
32}
49const ThreadMetaUpdatedPayloadSchema = Schema.Struct({
50 threadId: Schema.String,
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[] {
35 return Array.isArray(value) ? value : [];
36}
59const MessageSentPayloadSchema = Schema.Struct({
60 id: Schema.String,
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
3895export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
39 return {
96 return decodeReadModelSync({
4097 sequence: 0,
41 projects: [],
4298 threads: [],
4399 gitByProjectId: {},
44100 updatedAt: nowIso,
45 };
101 });
46102}
47103
48104export function reduceEvent(
49105 model: OrchestrationReadModel,
50106 event: OrchestrationEvent,
51): OrchestrationReadModel {
52 const payload = asObject(event.payload);
107): Effect.Effect<OrchestrationReadModel, unknown> {
53108 const nextBase: OrchestrationReadModel = {
54109 ...model,
55110 sequence: event.sequence,
......@@ -57,266 +112,215 @@ export function reduceEvent(
57112 };
58113
59114 switch (event.type) {
60 case "project.created": {
61 if (!payload) return nextBase;
62 const project: OrchestrationProject = {
63 id: asString(payload.id) ?? event.aggregateId,
64 name: asString(payload.name) ?? "project",
65 cwd: asString(payload.cwd) ?? "",
66 model: asString(payload.model) ?? "gpt-5-codex",
67 createdAt: asString(payload.createdAt) ?? event.occurredAt,
68 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
69 };
70 const existing = nextBase.projects.find((entry) => entry.id === project.id);
71 return {
72 ...nextBase,
73 projects: existing
74 ? nextBase.projects.map((entry) => (entry.id === project.id ? project : entry))
75 : [...nextBase.projects, project],
76 };
77 }
78 case "project.deleted":
79 return {
80 ...nextBase,
81 projects: nextBase.projects.filter((project) => project.id !== event.aggregateId),
82 threads: nextBase.threads.filter((thread) => thread.projectId !== event.aggregateId),
83 };
84 case "thread.created": {
85 if (!payload) return nextBase;
86 const thread: OrchestrationThread = {
87 id: asString(payload.id) ?? event.aggregateId,
88 projectId: asString(payload.projectId) ?? "",
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 }
115 case "thread.created":
116 return Effect.gen(function* () {
117 const payload = yield* decodeThreadCreatedPayload(event.payload);
118 const thread: OrchestrationThread = yield* decodeThread({
119 id: payload.id,
120 projectId: payload.projectId,
121 title: payload.title,
122 model: payload.model,
123 branch: payload.branch,
124 worktreePath: payload.worktreePath,
125 createdAt: payload.createdAt,
126 updatedAt: payload.updatedAt,
127 latestTurnId: null,
128 latestTurnStartedAt: null,
129 latestTurnCompletedAt: null,
130 latestTurnDurationMs: null,
131 messages: [],
132 session: null,
133 turnDiffSummaries: [],
134 error: null,
135 });
136 const existing = nextBase.threads.find((entry) => entry.id === thread.id);
137 return {
138 ...nextBase,
139 threads: existing
140 ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry))
141 : [...nextBase.threads, thread],
142 };
143 });
112144 case "thread.deleted":
113 return {
145 return Effect.succeed({
114146 ...nextBase,
115147 threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId),
116 };
117 case "thread.meta-updated": {
118 if (!payload) return nextBase;
119 const threadId = asString(payload.threadId) ?? event.aggregateId;
120 const thread = nextBase.threads.find((entry) => entry.id === threadId);
121 if (!thread) return nextBase;
122 return {
123 ...nextBase,
124 threads: updateThread(nextBase.threads, threadId, {
125 ...(asString(payload.title) !== null ? { title: asString(payload.title) ?? thread.title } : {}),
126 ...(asString(payload.model) !== null ? { model: asString(payload.model) ?? thread.model } : {}),
127 ...(payload.branch !== undefined ? { branch: asString(payload.branch) } : {}),
128 ...(payload.worktreePath !== undefined ? { worktreePath: asString(payload.worktreePath) } : {}),
129 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
148 });
149 case "thread.meta-updated":
150 return decodeThreadMetaUpdatedPayload(event.payload).pipe(
151 Effect.map((payload) => {
152 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
153 if (!thread) {
154 return nextBase;
155 }
156 return {
157 ...nextBase,
158 threads: updateThread(nextBase.threads, payload.threadId, {
159 ...(payload.title !== undefined ? { title: payload.title } : {}),
160 ...(payload.model !== undefined ? { model: payload.model } : {}),
161 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
162 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
163 updatedAt: payload.updatedAt,
164 }),
165 };
130166 }),
131 };
132 }
133 case "message.sent": {
134 if (!payload) return nextBase;
135 const threadId = asString(payload.threadId) ?? event.aggregateId;
136 const message: OrchestrationMessage = {
137 id: asString(payload.id) ?? crypto.randomUUID(),
138 role: (asString(payload.role) as OrchestrationMessage["role"] | null) ?? "user",
139 text: asString(payload.text) ?? "",
140 createdAt: asString(payload.createdAt) ?? event.occurredAt,
141 streaming: payload.streaming === true,
142 };
143 const targetThread = nextBase.threads.find((thread) => thread.id === threadId);
144 if (!targetThread) return nextBase;
145 const existingMessage = targetThread.messages.find((entry) => entry.id === message.id);
146 const nextMessages = existingMessage
147 ? targetThread.messages.map((entry) =>
148 entry.id === message.id
149 ? {
150 ...entry,
151 text:
152 message.streaming
167 );
168 case "message.sent":
169 return Effect.gen(function* () {
170 const payload = yield* decodeMessageSentPayload(event.payload);
171 const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId);
172 if (!targetThread) {
173 return nextBase;
174 }
175
176 const message: OrchestrationMessage = yield* decodeMessage({
177 id: payload.id,
178 role: payload.role,
179 text: payload.text,
180 createdAt: payload.createdAt,
181 streaming: payload.streaming,
182 });
183 const existingMessage = targetThread.messages.find((entry) => entry.id === message.id);
184 const nextMessages = existingMessage
185 ? targetThread.messages.map((entry) =>
186 entry.id === message.id
187 ? {
188 ...entry,
189 text: message.streaming
153190 ? `${entry.text}${message.text}`
154191 : message.text.length > 0
155192 ? message.text
156193 : entry.text,
157 streaming: message.streaming,
158 createdAt: message.createdAt,
159 }
160 : entry,
161 )
162 : [...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;
194 streaming: message.streaming,
195 createdAt: message.createdAt,
196 }
197 : entry,
198 )
199 : [...targetThread.messages, message];
205200
206 const files = asArray(payload.files).flatMap((filePayload) => {
207 const file = asObject(filePayload);
208 const filePath = asString(file?.path);
209 if (!filePath) {
210 return [];
201 return {
202 ...nextBase,
203 threads: updateThread(nextBase.threads, payload.threadId, {
204 messages: nextMessages,
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;
211215 }
212216
213 const nextFile: {
214 path: string;
215 kind?: string;
216 additions?: number;
217 deletions?: number;
218 } = { path: filePath };
219 const kind = asString(file?.kind);
220 if (kind !== null) {
221 nextFile.kind = kind;
222 }
223 const additions = asNumber(file?.additions);
224 if (additions !== null) {
225 nextFile.additions = additions;
226 }
227 const deletions = asNumber(file?.deletions);
228 if (deletions !== null) {
229 nextFile.deletions = deletions;
230 }
231 return [nextFile];
217 const session: OrchestrationSession = yield* decodeSession({
218 ...payload.session,
219 threadId: payload.threadId,
220 });
221
222 return {
223 ...nextBase,
224 threads: updateThread(nextBase.threads, payload.threadId, {
225 session,
226 updatedAt: event.occurredAt,
227 error: session.lastError,
228 }),
229 };
232230 });
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 = {
235 turnId,
236 completedAt: asString(payload.completedAt) ?? event.occurredAt,
237 ...(asString(payload.status) !== null ? { status: asString(payload.status) ?? undefined } : {}),
238 files,
239 ...(asString(payload.assistantMessageId) !== null
240 ? { assistantMessageId: asString(payload.assistantMessageId) ?? undefined }
241 : {}),
242 ...(asNumber(payload.checkpointTurnCount) !== null
243 ? { checkpointTurnCount: asNumber(payload.checkpointTurnCount) ?? undefined }
244 : {}),
245 };
246 const turnDiffSummaries = [...thread.turnDiffSummaries.filter((summary) => summary.turnId !== turnId), nextSummary]
247 .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
239 const nextSummary = yield* decodeTurnDiffSummary({
240 turnId: payload.turnId,
241 completedAt: payload.completedAt,
242 ...(payload.status !== undefined ? { status: payload.status } : {}),
243 files: payload.files,
244 ...(payload.assistantMessageId !== undefined
245 ? { assistantMessageId: payload.assistantMessageId }
246 : {}),
247 ...(payload.checkpointTurnCount !== undefined
248 ? { checkpointTurnCount: payload.checkpointTurnCount }
249 : {}),
250 });
251 const turnDiffSummaries = [
252 ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId),
253 nextSummary,
254 ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
248255
249 return {
250 ...nextBase,
251 threads: updateThread(nextBase.threads, threadId, {
252 latestTurnId: turnId,
253 latestTurnCompletedAt: nextSummary.completedAt,
254 turnDiffSummaries,
255 updatedAt: event.occurredAt,
256 }),
257 };
258 }
259 case "thread.reverted": {
260 if (!payload) return nextBase;
261 const threadId = asString(payload.threadId) ?? event.aggregateId;
262 const thread = nextBase.threads.find((entry) => entry.id === threadId);
263 if (!thread) return nextBase;
256 return {
257 ...nextBase,
258 threads: updateThread(nextBase.threads, payload.threadId, {
259 latestTurnId: payload.turnId,
260 latestTurnCompletedAt: nextSummary.completedAt,
261 turnDiffSummaries,
262 updatedAt: event.occurredAt,
263 }),
264 };
265 });
266 case "thread.reverted":
267 return decodeThreadRevertedPayload(event.payload).pipe(
268 Effect.map((payload) => {
269 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
270 if (!thread) {
271 return nextBase;
272 }
264273
265 const targetTurnCount = Math.max(0, Math.floor(asNumber(payload.turnCount) ?? 0));
266 const targetMessageCount = Math.max(0, Math.floor(asNumber(payload.messageCount) ?? 0));
267 const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) =>
268 left.completedAt.localeCompare(right.completedAt),
269 );
270 const inferredTurnCountByTurnId = new Map<string, number>();
271 for (let index = 0; index < sortedSummaries.length; index += 1) {
272 const summary = sortedSummaries[index];
273 if (!summary) continue;
274 inferredTurnCountByTurnId.set(summary.turnId, index + 1);
275 }
274 const targetTurnCount = Math.max(0, Math.floor(payload.turnCount));
275 const targetMessageCount = Math.max(0, Math.floor(payload.messageCount));
276 const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) =>
277 left.completedAt.localeCompare(right.completedAt),
278 );
279 const inferredTurnCountByTurnId = new Map<string, number>();
280 for (let index = 0; index < sortedSummaries.length; index += 1) {
281 const summary = sortedSummaries[index];
282 if (!summary) continue;
283 inferredTurnCountByTurnId.set(summary.turnId, index + 1);
284 }
276285
277 const turnDiffSummaries = thread.turnDiffSummaries
278 .filter((summary) => {
279 const checkpointTurnCount =
280 summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0;
281 return checkpointTurnCount <= targetTurnCount;
282 })
283 .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
284 const latestSummary =
285 turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null;
286 const turnDiffSummaries = thread.turnDiffSummaries
287 .filter((summary) => {
288 const checkpointTurnCount =
289 summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0;
290 return checkpointTurnCount <= targetTurnCount;
291 })
292 .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
293 const latestSummary =
294 turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null;
286295
287 return {
288 ...nextBase,
289 threads: updateThread(nextBase.threads, threadId, {
290 messages: thread.messages.slice(0, Math.min(targetMessageCount, thread.messages.length)),
291 turnDiffSummaries,
292 latestTurnId: latestSummary?.turnId ?? null,
293 latestTurnStartedAt: null,
294 latestTurnCompletedAt: latestSummary?.completedAt ?? null,
295 latestTurnDurationMs: null,
296 updatedAt: event.occurredAt,
296 return {
297 ...nextBase,
298 threads: updateThread(nextBase.threads, payload.threadId, {
299 messages: thread.messages.slice(
300 0,
301 Math.min(targetMessageCount, thread.messages.length),
302 ),
303 turnDiffSummaries,
304 latestTurnId: latestSummary?.turnId ?? null,
305 latestTurnStartedAt: null,
306 latestTurnCompletedAt: latestSummary?.completedAt ?? null,
307 latestTurnDurationMs: null,
308 updatedAt: event.occurredAt,
309 }),
310 };
297311 }),
298 };
299 }
300 case "git.read-model-upsert": {
301 if (!payload) return nextBase;
302 const projectId = asString(payload.projectId) ?? event.aggregateId;
303 const gitReadModel: OrchestrationGitReadModel = {
304 projectId,
305 branch: asString(payload.branch),
306 hasWorkingTreeChanges: payload.hasWorkingTreeChanges === true,
307 aheadCount: asNumber(payload.aheadCount) ?? 0,
308 behindCount: asNumber(payload.behindCount) ?? 0,
309 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
310 };
311 return {
312 ...nextBase,
313 gitByProjectId: {
314 ...nextBase.gitByProjectId,
315 [projectId]: gitReadModel,
316 },
317 };
318 }
312 );
313 case "git.read-model-upsert":
314 return decodeGitReadModel(event.payload).pipe(
315 Effect.map((gitReadModel: OrchestrationGitReadModel) => ({
316 ...nextBase,
317 gitByProjectId: {
318 ...nextBase.gitByProjectId,
319 [gitReadModel.projectId]: gitReadModel,
320 },
321 })),
322 );
319323 default:
320 return nextBase;
324 return Effect.succeed(nextBase);
321325 }
322326}
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";
22
33import { 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
145export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")<
156 OrchestrationEngineService,
167 OrchestrationEngine
apps/server/src/orchestration/uiContractInventory.ts+4-6
......@@ -7,11 +7,6 @@ export interface UiEntityContract {
77// Explicit UI inventory used to keep server read models aligned
88// with what the current frontend renders.
99export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [
10 {
11 entity: "Project",
12 requiredFields: ["id", "name", "cwd", "model"],
13 consumedBy: ["apps/web/src/components/Sidebar.tsx"],
14 },
1510 {
1611 entity: "Thread",
1712 requiredFields: [
......@@ -61,6 +56,9 @@ export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [
6156 {
6257 entity: "GitReadModel",
6358 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 ],
6563 },
6664];
apps/server/src/persistence/Layers/OrchestrationEvents.test.ts+5-5
......@@ -35,12 +35,12 @@ describe("OrchestrationEventRepository", () => {
3535 const saved = await Runtime.runPromise(runtime)(
3636 first.append({
3737 eventId: "event-1",
38 type: "project.created",
39 aggregateType: "project",
40 aggregateId: "project-1",
38 type: "thread.created",
39 aggregateType: "thread",
40 aggregateId: "thread-1",
4141 occurredAt: createdAt,
4242 commandId: "cmd-1",
43 payload: { id: "project-1", name: "demo" },
43 payload: { id: "thread-1", projectId: "project-1", title: "demo" },
4444 }),
4545 );
4646 expect(saved.sequence).toBe(1);
......@@ -92,7 +92,7 @@ describe("OrchestrationEventRepository", () => {
9292 `,
9393 )
9494 .all() as Array<Record<string, unknown>>;
95 expect(migrationRows).toHaveLength(1);
95 expect(migrationRows.length).toBeGreaterThanOrEqual(1);
9696 expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0);
9797 db.close();
9898 });
apps/server/src/persistence/Layers/OrchestrationEvents.ts+4-33
......@@ -1,15 +1,10 @@
1import fs from "node:fs";
2import path from "node:path";
3
41import type { OrchestrationEvent } from "@t3tools/contracts";
52import { OrchestrationEventSchema } from "@t3tools/contracts";
6import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient";
73import * as SqlClient from "@effect/sql/SqlClient";
84import * as SqlSchema from "@effect/sql/SqlSchema";
9import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient";
10import { Context, Effect, Layer, Schema } from "effect";
5import { Effect, Layer, Schema } from "effect";
116
12import { runMigrations } from "../Migrations";
7import { makeSqlitePersistenceLive } from "./Sqlite";
138import {
149 OrchestrationEventRepository,
1510 type OrchestrationEventRepositoryShape,
......@@ -45,11 +40,6 @@ const ReadFromSequenceRequestSchema = Schema.Struct({
4540 limit: Schema.Number,
4641});
4742
48class OrchestrationSql extends Context.Tag("persistence/OrchestrationSql")<
49 OrchestrationSql,
50 SqlClientService
51>() {}
52
5343function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent {
5444 return decodeEvent({
5545 sequence: row.sequence,
......@@ -64,7 +54,7 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent {
6454}
6555
6656const makeRepository = Effect.gen(function* () {
67 const sql = yield* OrchestrationSql;
57 const sql = yield* SqlClient.SqlClient;
6858
6959 const appendEventRow = SqlSchema.single({
7060 Request: AppendEventRequestSchema,
......@@ -155,24 +145,5 @@ export const OrchestrationEventRepositoryLive = Layer.effect(
155145);
156146
157147export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) {
158 const SqliteClientLive = Effect.gen(function* () {
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));
148 return OrchestrationEventRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath)));
178149}
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";
1313
1414// Import all migrations statically
1515import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
16
16import Migration0002 from "./Migrations/002_Projects.ts";
1717
1818/**
1919 * Migration loader with all migrations defined inline.
......@@ -27,6 +27,7 @@ import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
2727 */
2828const loader = Migrator.fromRecord({
2929 "1_OrchestrationEvents": Migration0001,
30 "2_Projects": Migration0002,
3031});
3132
3233/**
......@@ -64,4 +65,4 @@ export const runMigrations = run({ loader });
6465 * )
6566 * ```
6667 */
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* () {
2121 CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate
2222 ON orchestration_events(aggregate_type, aggregate_id, sequence)
2323 `;
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";
33import path from "node:path";
44import { EventEmitter } from "node:events";
55
6import { Layer, ManagedRuntime } from "effect";
67import { describe, expect, it, afterEach, vi } from "vitest";
78import { createServer } from "./wsServer";
89import WebSocket from "ws";
......@@ -16,8 +17,8 @@ import {
1617 type WsPush,
1718 type WsResponse,
1819} from "@t3tools/contracts";
19import { ProjectRegistry } from "./projectRegistry";
2020import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
21import { ProjectRepository } from "./persistence/Services/Projects";
2122import type {
2223 TerminalClearInput,
2324 TerminalCloseInput,
......@@ -28,6 +29,8 @@ import type {
2829 TerminalWriteInput,
2930} from "@t3tools/contracts";
3031import type { TerminalManager } from "./terminalManager";
32import { ProjectRepositoryLive } from "./persistence/Layers/Projects";
33import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
3134
3235interface PendingMessages {
3336 queue: unknown[];
......@@ -249,9 +252,9 @@ describe("WebSocket Server", () => {
249252 return createServer({
250253 port: 0,
251254 cwd: options.cwd ?? "/test/project",
255 stateDir,
252256 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
253257 ...(options.authToken ? { authToken: options.authToken } : {}),
254 projectRegistry: new ProjectRegistry(stateDir),
255258 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
256259 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
257260 });
......@@ -888,7 +891,11 @@ describe("WebSocket Server", () => {
888891 it("supports projects.searchEntries", async () => {
889892 const workspace = makeTempDir("t3code-ws-workspace-entries-");
890893 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 );
892899 fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
893900 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
894901 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
......@@ -1025,36 +1032,18 @@ describe("WebSocket Server", () => {
10251032 it("prunes missing projects on startup", async () => {
10261033 const stateDir = makeTempDir("t3code-ws-prune-state-");
10271034 const existing = makeTempDir("t3code-ws-existing-project-");
1028 const missing = path.join(stateDir, "definitely-missing");
1029 const now = new Date().toISOString();
1030 const projectsFile = path.join(stateDir, "projects.json");
1035 const missing = makeTempDir("t3code-ws-missing-project-");
10311036
1032 fs.writeFileSync(
1033 projectsFile,
1034 JSON.stringify(
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,
1037 const projectRuntime = ManagedRuntime.make(
1038 ProjectRepositoryLive.pipe(
1039 Layer.provide(makeSqlitePersistenceLive(path.join(stateDir, "orchestration.sqlite"))),
10561040 ),
10571041 );
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
10591048 server = createTestServer({ stateDir, cwd: "/test" });
10601049 await server.start();
......@@ -1067,9 +1056,9 @@ describe("WebSocket Server", () => {
10671056
10681057 const response = await sendRequest(ws, WS_METHODS.projectsList);
10691058 expect(response.error).toBeUndefined();
1070 const listed = response.result as Array<{ id: string }>;
1059 const listed = response.result as Array<{ cwd: string }>;
10711060 expect(listed).toHaveLength(1);
1072 expect(listed[0]?.id).toBe("project-existing");
1061 expect(listed[0]?.cwd).toBe(existing);
10731062 });
10741063
10751064 it("rejects websocket connections without a valid auth token", async () => {
apps/server/src/wsServer.ts+150-137
......@@ -18,10 +18,10 @@ import {
1818 type WsResponse,
1919 wsRequestSchema,
2020} from "@t3tools/contracts";
21import { Layer, ManagedRuntime } from "effect";
2122import { WebSocketServer, type WebSocket } from "ws";
2223
2324import { createLogger } from "./logger";
24import { ProjectRegistry } from "./projectRegistry";
2525import { ProviderManager } from "./providerManager";
2626import { GitManager } from "./gitManager";
2727import {
......@@ -36,7 +36,13 @@ import {
3636import { TerminalManager } from "./terminalManager";
3737import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
3838import { 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
4147const MIME_TYPES: Record<string, string> = {
4248 ".html": "text/html; charset=utf-8",
......@@ -61,12 +67,31 @@ export interface ServerOptions {
6167 staticDir?: string | undefined;
6268 devUrl?: string | undefined;
6369 logWebSocketEvents?: boolean | undefined;
64 projectRegistry?: ProjectRegistry | undefined;
6570 gitManager?: GitManager | undefined;
6671 terminalManager?: TerminalManager | undefined;
6772 authToken?: string | undefined;
6873}
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
7095function parseBooleanEnv(value: string | undefined): boolean | undefined {
7196 if (value === undefined) return undefined;
7297 const normalized = value.trim().toLowerCase();
......@@ -99,18 +124,19 @@ export function createServer(options: ServerOptions) {
99124 staticDir,
100125 devUrl,
101126 logWebSocketEvents: explicitLogWsEvents,
102 projectRegistry: providedRegistry,
103 gitManager: providedGitManager,
104 terminalManager: providedTerminalManager,
127 gitManager = new GitManager(),
128 terminalManager = new TerminalManager(),
105129 authToken,
106130 } = options;
107131 const providerManager = new ProviderManager();
108 const terminalManager = providedTerminalManager ?? new TerminalManager();
109 const projectRegistry =
110 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
111 const gitManager = providedGitManager ?? new GitManager();
132
112133 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;
114140 const clients = new Set<WebSocket>();
115141 const logger = createLogger("ws");
116142 const logWebSocketEvents =
......@@ -128,15 +154,23 @@ export function createServer(options: ServerOptions) {
128154 });
129155 }
130156
131 function requireOrchestrationEngine() {
132 if (!orchestrationSystem) {
133 throw new Error("Orchestration engine is not started");
134 }
135 return orchestrationSystem.engine;
136 }
157 const getOrchestrationEngine = () => {
158 assert(orchestrationEngine, "Orchestration engine is not started");
159 return orchestrationEngine;
160 };
161
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
138172 function attachOrchestrationSubscriptions() {
139 const orchestrationEngine = requireOrchestrationEngine();
173 const orchestrationEngine = getOrchestrationEngine();
140174
141175 unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => {
142176 const push: WsPush = {
......@@ -179,17 +213,16 @@ export function createServer(options: ServerOptions) {
179213 // Forward provider events to all connected WebSocket clients
180214 providerManager.on("event", (event) => {
181215 void (async () => {
182 const liveOrchestrationSystem = orchestrationSystem;
183 if (!liveOrchestrationSystem) {
216 const liveOrchestrationEngine = orchestrationEngine;
217 if (!liveOrchestrationEngine) {
184218 return;
185219 }
186 const orchestrationEngine = liveOrchestrationSystem.engine;
187 const snapshot = orchestrationEngine.getSnapshot();
220 const snapshot = liveOrchestrationEngine.getSnapshot();
188221 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId);
189222 if (!thread) return;
190223 const now = event.createdAt;
191224 if (event.method === "turn/started" || event.method === "turn/completed") {
192 await orchestrationEngine.dispatch({
225 await liveOrchestrationEngine.dispatch({
193226 type: "thread.session",
194227 commandId: crypto.randomUUID(),
195228 threadId: thread.id,
......@@ -208,7 +241,7 @@ export function createServer(options: ServerOptions) {
208241 }
209242 if (event.textDelta && event.textDelta.length > 0) {
210243 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;
211 await orchestrationEngine.dispatch({
244 await liveOrchestrationEngine.dispatch({
212245 type: "message.send",
213246 commandId: crypto.randomUUID(),
214247 threadId: thread.id,
......@@ -221,7 +254,7 @@ export function createServer(options: ServerOptions) {
221254 }
222255 if (event.method === "turn/completed") {
223256 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;
224 await orchestrationEngine.dispatch({
257 await liveOrchestrationEngine.dispatch({
225258 type: "message.send",
226259 commandId: crypto.randomUUID(),
227260 threadId: thread.id,
......@@ -236,13 +269,15 @@ export function createServer(options: ServerOptions) {
236269 const payload = asObject(event.payload);
237270 const turnId = event.turnId ?? asString(payload?.turnId);
238271 if (turnId) {
239 await orchestrationEngine.dispatch({
272 await liveOrchestrationEngine.dispatch({
240273 type: "thread.turnDiff.complete",
241274 commandId: crypto.randomUUID(),
242275 threadId: thread.id,
243276 turnId,
244277 completedAt: now,
245 ...(asString(payload?.status) !== null ? { status: asString(payload?.status) ?? undefined } : {}),
278 ...(asString(payload?.status) !== null
279 ? { status: asString(payload?.status) ?? undefined }
280 : {}),
246281 files: [],
247282 assistantMessageId: `assistant:${turnId}`,
248283 ...(asNumber(payload?.turnCount) !== null
......@@ -253,7 +288,7 @@ export function createServer(options: ServerOptions) {
253288 }
254289 }
255290 if (event.kind === "error" && event.message) {
256 await orchestrationEngine.dispatch({
291 await liveOrchestrationEngine.dispatch({
257292 type: "thread.session",
258293 commandId: crypto.randomUUID(),
259294 threadId: thread.id,
......@@ -350,18 +385,6 @@ export function createServer(options: ServerOptions) {
350385 // WebSocket server — upgrades from the HTTP server
351386 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
365388 httpServer.on("upgrade", (request, socket, head) => {
366389 if (authToken) {
367390 let providedToken: string | null = null;
......@@ -386,7 +409,7 @@ export function createServer(options: ServerOptions) {
386409
387410 wss.on("connection", (ws) => {
388411 clients.add(ws);
389 const orchestrationEngine = requireOrchestrationEngine();
412 const orchestrationEngine = getOrchestrationEngine();
390413
391414 // Send welcome message with project info
392415 const segments = cwd.split(/[/\\]/).filter(Boolean);
......@@ -453,7 +476,9 @@ export function createServer(options: ServerOptions) {
453476 }
454477
455478 async function routeRequest(request: WsRequest): Promise<unknown> {
456 const orchestrationEngine = requireOrchestrationEngine();
479 const orchestrationEngine = getOrchestrationEngine();
480 const projectRepository = getProjectRepository();
481 const effectRuntime = getEffectRuntime();
457482
458483 switch (request.method) {
459484 case WS_METHODS.providersStartSession:
......@@ -479,79 +504,56 @@ export function createServer(options: ServerOptions) {
479504 case WS_METHODS.providersGetCheckpointDiff:
480505 return providerManager.getCheckpointDiff(request.params as never);
481506
482 case WS_METHODS.providersRevertToCheckpoint:
483 {
484 const result = await providerManager.revertToCheckpoint(request.params as never);
485 const params = request.params as { sessionId?: string };
486 const sessionId = params.sessionId;
487 if (typeof sessionId === "string") {
488 const snapshot = orchestrationEngine.getSnapshot();
489 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId);
490 if (thread) {
491 const now = new Date().toISOString();
507 case WS_METHODS.providersRevertToCheckpoint: {
508 const result = await providerManager.revertToCheckpoint(request.params as never);
509 const params = request.params as { sessionId?: string };
510 const sessionId = params.sessionId;
511 if (typeof sessionId === "string") {
512 const snapshot = orchestrationEngine.getSnapshot();
513 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId);
514 if (thread) {
515 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) {
492525 await orchestrationEngine.dispatch({
493 type: "thread.revert",
526 type: "thread.session",
494527 commandId: crypto.randomUUID(),
495528 threadId: thread.id,
496 turnCount: result.turnCount,
497 messageCount: result.messageCount,
529 session: {
530 ...thread.session,
531 status: "ready",
532 activeTurnId: null,
533 updatedAt: now,
534 lastError: null,
535 },
498536 createdAt: now,
499537 });
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 }
515538 }
516539 }
517 return result;
518540 }
541 return result;
542 }
519543
520544 case WS_METHODS.projectsList:
521 return projectRegistry.list();
545 return effectRuntime.runPromise(projectRepository.list());
522546
523547 case WS_METHODS.projectsAdd:
524 {
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 }
548 return effectRuntime.runPromise(projectRepository.add(request.params as never));
537549
538550 case WS_METHODS.projectsRemove:
539 {
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 }
551 return effectRuntime.runPromise(projectRepository.remove(request.params as never));
550552
551553 case WS_METHODS.projectsSearchEntries:
552554 return searchWorkspaceEntries(request.params as never);
553555 case WS_METHODS.projectsUpdateScripts:
554 return projectRegistry.updateScripts(request.params as never);
556 return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never));
555557
556558 case WS_METHODS.shellOpenInEditor: {
557559 const params = request.params as {
......@@ -597,25 +599,25 @@ export function createServer(options: ServerOptions) {
597599 return undefined;
598600 }
599601
600 case WS_METHODS.gitStatus:
601 {
602 const params = request.params as { cwd: string };
603 const status = await gitManager.status(request.params as never);
604 const project = projectRegistry.list().find((entry) => entry.cwd === params.cwd);
605 if (project) {
606 await orchestrationEngine.dispatch({
607 type: "git.readModel.upsert",
608 commandId: crypto.randomUUID(),
609 projectId: project.id,
610 branch: status.branch,
611 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
612 aheadCount: status.aheadCount,
613 behindCount: status.behindCount,
614 createdAt: new Date().toISOString(),
615 });
616 }
617 return status;
602 case WS_METHODS.gitStatus: {
603 const params = request.params as { cwd: string };
604 const status = await gitManager.status(request.params as never);
605 const projects = await effectRuntime.runPromise(projectRepository.list());
606 const project = projects.find((entry) => entry.cwd === params.cwd);
607 if (project) {
608 await orchestrationEngine.dispatch({
609 type: "git.readModel.upsert",
610 commandId: crypto.randomUUID(),
611 projectId: project.id,
612 branch: status.branch,
613 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
614 aheadCount: status.aheadCount,
615 behindCount: status.behindCount,
616 createdAt: new Date().toISOString(),
617 });
618618 }
619 return status;
620 }
619621
620622 case WS_METHODS.gitPull:
621623 return pullGitBranch(request.params as never);
......@@ -698,37 +700,57 @@ export function createServer(options: ServerOptions) {
698700 }
699701 }
700702
701 async function ensureOrchestrationSystemStarted() {
702 if (orchestrationSystem) {
703 return;
703 async function createEffectRuntime() {
704 const dbPath = path.join(resolvedStateDir, "orchestration.sqlite");
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;
704724 }
705725
706 orchestrationSystem = await createOrchestrationSystem(resolvedStateDir);
707 attachOrchestrationSubscriptions();
726 return runtime;
708727 }
709728
710 async function disposeOrchestrationSystem() {
729 async function disposeEffectRuntime() {
711730 unsubscribeReadModel();
712731 unsubscribeDomainEvents();
713732 unsubscribeReadModel = noop;
714733 unsubscribeDomainEvents = noop;
715734
716 if (!orchestrationSystem) {
735 const runtime = effectRuntime;
736 effectRuntime = null;
737 orchestrationEngine = null;
738 projectRepository = null;
739
740 if (!runtime) {
717741 return;
718742 }
719743
720 const system = orchestrationSystem;
721 orchestrationSystem = null;
722 await system.dispose();
744 await runtime.dispose();
723745 }
724746
725747 async function start() {
726 await ensureOrchestrationSystemStarted();
748 effectRuntime = await createEffectRuntime();
727749
728750 return new Promise<void>((resolve, reject) => {
729751 const onError = (error: Error) => {
730752 httpServer.off("error", onError);
731 void disposeOrchestrationSystem().finally(() => reject(error));
753 void disposeEffectRuntime().finally(() => reject(error));
732754 };
733755 httpServer.once("error", onError);
734756 const onListening = () => {
......@@ -744,7 +766,7 @@ export function createServer(options: ServerOptions) {
744766 }
745767
746768 async function stop(): Promise<void> {
747 await disposeOrchestrationSystem();
769 await disposeEffectRuntime();
748770 terminalManager.off("event", onTerminalEvent);
749771 providerManager.stopAll();
750772 providerManager.dispose();
......@@ -755,15 +777,6 @@ export function createServer(options: ServerOptions) {
755777 }
756778 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
767780 const closeWebSocketServer = new Promise<void>((resolve, reject) => {
768781 wss.close((error) => {
769782 if (error && !isServerNotRunningError(error)) {
apps/web/src/components/Sidebar.tsx+23-44
......@@ -28,13 +28,6 @@ function formatRelativeTime(iso: string): string {
2828 return `${Math.floor(hours / 24)}d ago`;
2929}
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
3831interface ThreadStatusPill {
3932 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
4033 colorClass: string;
......@@ -195,23 +188,16 @@ export default function Sidebar() {
195188 return;
196189 }
197190
198 const projectId = crypto.randomUUID();
199 const now = new Date().toISOString();
200 const name = inferProjectName(cwd);
201 if (isElectron) {
202 // Keep project registry in sync for desktop integrations.
203 await api.projects.add({ cwd });
191 const result = await api.projects.add({ cwd });
192 const projects = await api.projects.list();
193 dispatch({ type: "SYNC_PROJECTS", projects });
194
195 const hasThread = state.threads.some((thread) => thread.projectId === result.project.id);
196 if (hasThread) {
197 focusMostRecentThreadForProject(result.project.id);
198 return;
204199 }
205 await api.orchestration.dispatchCommand({
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);
200 handleNewThread(result.project.id);
215201 } finally {
216202 setIsAddingProject(false);
217203 setNewCwd("");
......@@ -225,6 +211,7 @@ export default function Sidebar() {
225211 handleNewThread,
226212 isAddingProject,
227213 state.projects,
214 state.threads,
228215 ],
229216 );
230217
......@@ -381,28 +368,20 @@ export default function Sidebar() {
381368 );
382369 if (!confirmed) return;
383370
384 if (isElectron) {
385 try {
386 await api.projects.remove({ id: projectId });
387 } catch (error) {
388 const message =
389 error instanceof Error ? error.message : "Unknown error deleting project.";
390 console.error("Failed to remove project", { projectId, error });
391 toastManager.add({
392 type: "error",
393 title: `Failed to delete "${project.name}"`,
394 description: message,
395 });
396 return;
397 }
371 try {
372 await api.projects.remove({ id: projectId });
373 const projects = await api.projects.list();
374 dispatch({ type: "SYNC_PROJECTS", projects });
375 } catch (error) {
376 const message =
377 error instanceof Error ? error.message : "Unknown error deleting project.";
378 console.error("Failed to remove project", { projectId, error });
379 toastManager.add({
380 type: "error",
381 title: `Failed to delete "${project.name}"`,
382 description: message,
383 });
398384 }
399
400 await api.orchestration.dispatchCommand({
401 type: "project.delete",
402 commandId: crypto.randomUUID(),
403 projectId,
404 createdAt: new Date().toISOString(),
405 });
406385 },
407386 [api, dispatch, state.projects, state.threads],
408387 );
apps/web/src/routes/__root.tsx+23-25
......@@ -126,9 +126,9 @@ function EventRouter() {
126126
127127 useEffect(() => {
128128 if (!api) return;
129 void api.orchestration
130 .getSnapshot()
131 .then((snapshot) => {
129 void Promise.all([api.projects.list(), api.orchestration.getSnapshot()])
130 .then(([projects, snapshot]) => {
131 dispatch({ type: "SYNC_PROJECTS", projects });
132132 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
133133 })
134134 .catch(() => undefined);
......@@ -150,8 +150,8 @@ function AutoProjectBootstrap() {
150150
151151 useEffect(() => {
152152 if (!api) return;
153 // Browser mode bootstraps from server welcome.
154 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
153 // Browser mode auto-adds the current cwd project via server welcome.
154 // Desktop skips this because projects are already loaded from the repository.
155155 if (isElectron) return;
156156
157157 return onServerWelcome((payload) => {
......@@ -166,40 +166,38 @@ function AutoProjectBootstrap() {
166166 }
167167
168168 bootstrappedRef.current = true;
169 const projectId = crypto.randomUUID();
170 const threadId = crypto.randomUUID();
171169 const now = new Date().toISOString();
172 void api.orchestration
173 .dispatchCommand({
174 type: "project.create",
175 commandId: crypto.randomUUID(),
176 projectId,
177 name: payload.projectName,
178 cwd: payload.cwd,
179 model: DEFAULT_MODEL,
180 createdAt: now,
181 })
182 .then(() =>
183 api.orchestration.dispatchCommand({
170 void api.projects
171 .add({ cwd: payload.cwd })
172 .then(async (result) => {
173 const projects = await api.projects.list();
174 dispatch({ type: "SYNC_PROJECTS", projects });
175
176 const hasThread = state.threads.some((thread) => thread.projectId === result.project.id);
177 if (hasThread) {
178 return;
179 }
180
181 return api.orchestration.dispatchCommand({
184182 type: "thread.create",
185183 commandId: crypto.randomUUID(),
186 threadId,
187 projectId,
184 threadId: crypto.randomUUID(),
185 projectId: result.project.id,
188186 title: "New thread",
189187 model: DEFAULT_MODEL,
190188 branch: null,
191189 worktreePath: null,
192190 createdAt: now,
193 }),
194 )
191 });
192 })
195193 .catch(() => undefined);
196194 });
197 }, [api, state.projects, dispatch]);
195 }, [api, state.projects, state.threads, dispatch]);
198196
199197 return null;
200198}
201199
202200function DesktopProjectBootstrap() {
203 // Desktop now hydrates from orchestration read-model snapshots.
201 // Desktop hydration runs through EventRouter project + orchestration sync.
204202 return null;
205203}
apps/web/src/store.ts+32-17
......@@ -10,9 +10,10 @@ import {
1010
1111import {
1212 type OrchestrationReadModel,
13 type ProjectListResult,
1314 normalizeProjectScripts,
1415} from "@t3tools/contracts";
15import { resolveModelSlug } from "./model-logic";
16import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
1617import {
1718 DEFAULT_THREAD_TERMINAL_HEIGHT,
1819 DEFAULT_THREAD_TERMINAL_ID,
......@@ -29,6 +30,7 @@ import {
2930
3031type Action =
3132 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
33 | { type: "SYNC_PROJECTS"; projects: ProjectListResult }
3234 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
3335 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
3436 | { type: "TOGGLE_PROJECT"; projectId: string }
......@@ -136,6 +138,29 @@ function updateThread(
136138 return threads.map((t) => (t.id === threadId ? updater(t) : t));
137139}
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
140165function normalizeTerminalIds(terminalIds: string[]): string[] {
141166 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
......@@ -330,21 +355,6 @@ export function reducer(state: AppState, action: Action): AppState {
330355 switch (action.type) {
331356 case "SYNC_SERVER_READ_MODEL": {
332357 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 });
348358 const threads = action.readModel.threads.map((thread) => {
349359 const existing = existingThreadById.get(thread.id);
350360 return normalizeThreadTerminals({
......@@ -392,12 +402,17 @@ export function reducer(state: AppState, action: Action): AppState {
392402 });
393403 return {
394404 ...state,
395 projects,
396405 threads,
397406 threadsHydrated: true,
398407 };
399408 }
400409
410 case "SYNC_PROJECTS":
411 return {
412 ...state,
413 projects: mapProjects(action.projects, state.projects),
414 };
415
401416 case "SET_PROJECT_SCRIPTS":
402417 return {
403418 ...state,
packages/contracts/src/orchestration.ts-31
......@@ -80,17 +80,6 @@ export const OrchestrationThreadSchema = Schema.Struct({
8080
8181export 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
9483export const OrchestrationGitReadModelSchema = Schema.Struct({
9584 projectId: IdSchema,
9685 branch: Schema.NullOr(Schema.String),
......@@ -104,7 +93,6 @@ export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationG
10493
10594export const OrchestrationReadModelSchema = Schema.Struct({
10695 sequence: Schema.Number,
107 projects: Schema.Array(OrchestrationProjectSchema),
10896 threads: Schema.Array(OrchestrationThreadSchema),
10997 gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }),
11098 updatedAt: IsoDateTimeSchema,
......@@ -112,23 +100,6 @@ export const OrchestrationReadModelSchema = Schema.Struct({
112100
113101export 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
132103export const CreateThreadCommandSchema = Schema.Struct({
133104 type: Schema.Literal("thread.create"),
134105 commandId: IdSchema,
......@@ -212,8 +183,6 @@ export const RevertThreadCommandSchema = Schema.Struct({
212183});
213184
214185export const OrchestrationCommandSchema = Schema.Union(
215 CreateProjectCommandSchema,
216 DeleteProjectCommandSchema,
217186 CreateThreadCommandSchema,
218187 DeleteThreadCommandSchema,
219188 UpdateThreadMetaCommandSchema,