History

Start orchestration V2 application services

Julius Marminge committed 4 months ago

Commit 28ea5e11 parent f2481df6 files · +709 −44Browse files

Unified · Split

6 files
  1. apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts +90 −0
  2. apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts +68 −0
  3. apps/server/src/project/ProjectService.test.ts +132 −0
  4. apps/server/src/project/ProjectService.ts +363 −0
  5. packages/contracts/src/orchestration.ts +2 −41
  6. packages/contracts/src/project.ts +54 −3
apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts+90−0
@@ -0,0 +1,90 @@
1+import { assert, it } from "@effect/vitest";
2+import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
3+
4+import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
5+import { decideProviderSessionTransition } from "./ProviderSessionTransitionPolicy.ts";
6+
7+const driver = ProviderDriverKind.make("codex");
8+const instanceId = ProviderInstanceId.make("codex");
9+const base = {
10+ driver,
11+ continuationIdentity: { driverKind: driver, continuationKey: "codex:account:one" },
12+ modelSelection: { instanceId, model: "gpt-5.1-codex" },
13+ runtimeMode: "full-access" as const,
14+ interactionMode: "default" as const,
15+ workspace: "/repo",
16+ capabilities: CodexProviderCapabilitiesV2,
17+};
18+
19+it("reuses compatible sessions and treats interaction mode as turn-scoped", () => {
20+ assert.deepEqual(
21+ decideProviderSessionTransition({
22+ current: base,
23+ target: { ...base, interactionMode: "plan", available: true },
24+ }),
25+ { type: "reuse" },
26+ );
27+});
28+
29+it("switches models in-session only when supported", () => {
30+ assert.deepEqual(
31+ decideProviderSessionTransition({
32+ current: base,
33+ target: {
34+ ...base,
35+ modelSelection: { ...base.modelSelection, model: "gpt-5.2-codex" },
36+ available: true,
37+ },
38+ }),
39+ { type: "switch_model_in_session" },
40+ );
41+ assert.deepEqual(
42+ decideProviderSessionTransition({
43+ current: {
44+ ...base,
45+ capabilities: {
46+ ...base.capabilities,
47+ sessions: { ...base.capabilities.sessions, supportsModelSwitchInSession: false },
48+ },
49+ },
50+ target: {
51+ ...base,
52+ capabilities: {
53+ ...base.capabilities,
54+ sessions: { ...base.capabilities.sessions, supportsModelSwitchInSession: false },
55+ },
56+ modelSelection: { ...base.modelSelection, model: "gpt-5.2-codex" },
57+ available: true,
58+ },
59+ }),
60+ { type: "restart_and_resume" },
61+ );
62+});
63+
64+it("restarts compatible instances for workspace or runtime changes", () => {
65+ assert.deepEqual(
66+ decideProviderSessionTransition({
67+ current: base,
68+ target: { ...base, workspace: "/other", available: true },
69+ }),
70+ { type: "restart_and_resume" },
71+ );
72+});
73+
74+it("uses portable handoff for incompatible continuation identities", () => {
75+ assert.deepEqual(
76+ decideProviderSessionTransition({
77+ current: base,
78+ target: {
79+ ...base,
80+ modelSelection: {
81+ ...base.modelSelection,
82+ instanceId: ProviderInstanceId.make("codex_other"),
83+ },
84+ continuationIdentity: { driverKind: driver, continuationKey: "codex:account:other" },
85+ available: true,
86+ },
87+ }),
88+ { type: "create_with_handoff" },
89+ );
90+});
apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts+68−0
@@ -0,0 +1,68 @@
1+import type {
2+ ModelSelection,
3+ OrchestrationV2ProviderCapabilities,
4+ ProviderDriverKind,
5+ ProviderInteractionMode,
6+ RuntimeMode,
7+} from "@t3tools/contracts";
8+
9+import type { ProviderContinuationIdentity } from "../provider/ProviderDriver.ts";
10+
11+export type ProviderSessionTransition =
12+ | { readonly type: "reuse" }
13+ | { readonly type: "switch_model_in_session" }
14+ | { readonly type: "restart_and_resume" }
15+ | { readonly type: "create_with_handoff" }
16+ | { readonly type: "reject"; readonly reason: string };
17+
18+export interface ProviderSessionTransitionState {
19+ readonly driver: ProviderDriverKind;
20+ readonly continuationIdentity: ProviderContinuationIdentity;
21+ readonly modelSelection: ModelSelection;
22+ readonly runtimeMode: RuntimeMode;
23+ readonly interactionMode: ProviderInteractionMode;
24+ readonly workspace: string;
25+ readonly capabilities: OrchestrationV2ProviderCapabilities;
26+}
27+
28+export interface ProviderSessionTransitionTarget extends ProviderSessionTransitionState {
29+ readonly available: boolean;
30+}
31+
32+export function decideProviderSessionTransition(input: {
33+ readonly current: ProviderSessionTransitionState | null;
34+ readonly target: ProviderSessionTransitionTarget;
35+}): ProviderSessionTransition {
36+ if (!input.target.available) {
37+ return { type: "reject", reason: "The target provider instance is unavailable." };
38+ }
39+ if (input.current === null) {
40+ return { type: "create_with_handoff" };
41+ }
42+
43+ const current = input.current;
44+ const target = input.target;
45+ const continuationCompatible =
46+ current.continuationIdentity.driverKind === target.continuationIdentity.driverKind &&
47+ current.continuationIdentity.continuationKey === target.continuationIdentity.continuationKey;
48+ if (!continuationCompatible || current.driver !== target.driver) {
49+ return { type: "create_with_handoff" };
50+ }
51+
52+ const instanceChanged = current.modelSelection.instanceId !== target.modelSelection.instanceId;
53+ const runtimeChanged = current.runtimeMode !== target.runtimeMode;
54+ const workspaceChanged = current.workspace !== target.workspace;
55+ if (instanceChanged || runtimeChanged || workspaceChanged) {
56+ return { type: "restart_and_resume" };
57+ }
58+
59+ const modelChanged = current.modelSelection.model !== target.modelSelection.model;
60+ if (modelChanged) {
61+ return target.capabilities.sessions.supportsModelSwitchInSession
62+ ? { type: "switch_model_in_session" }
63+ : { type: "restart_and_resume" };
64+ }
65+
66+ // Interaction mode is turn-scoped and is applied when the next turn starts.
67+ return { type: "reuse" };
68+}
apps/server/src/project/ProjectService.test.ts+132−0
@@ -0,0 +1,132 @@
1+import { assert, it } from "@effect/vitest";
2+import { ProjectId, ProviderInstanceId } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import * as Stream from "effect/Stream";
8+import { TestClock } from "effect/testing";
9+
10+import * as ProjectionProjects from "../persistence/Layers/ProjectionProjects.ts";
11+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
12+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
13+import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
14+import * as ProjectService from "./ProjectService.ts";
15+import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
16+
17+const workspacePathsLayer = Layer.succeed(WorkspacePaths.WorkspacePaths, {
18+ normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot.replace(/\/$/, "")),
19+ resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) =>
20+ Effect.succeed({ absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }),
21+});
22+
23+const metadataLayer = Layer.merge(
24+ Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
25+ resolve: (workspaceRoot) =>
26+ Effect.succeed({
27+ canonicalKey: `github.com/t3tools/${workspaceRoot.split("/").at(-1)}`,
28+ locator: {
29+ source: "git-remote" as const,
30+ remoteName: "origin",
31+ remoteUrl: `git@github.com:t3tools/${workspaceRoot.split("/").at(-1)}.git`,
32+ },
33+ rootPath: workspaceRoot,
34+ }),
35+ }),
36+ Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
37+ resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`),
38+ }),
39+);
40+
41+const TestLayer = ProjectService.layer.pipe(
42+ Layer.provideMerge(ProjectionProjects.ProjectionProjectRepositoryLive),
43+ Layer.provideMerge(workspacePathsLayer),
44+ Layer.provideMerge(metadataLayer),
45+ Layer.provideMerge(SqlitePersistenceMemory),
46+);
47+
48+it.layer(TestLayer)("ProjectService", (it) => {
49+ it.effect("creates, updates, resolves, snapshots, and soft-deletes projects", () =>
50+ Effect.gen(function* () {
51+ const service = yield* ProjectService.ProjectService;
52+ const projectId = ProjectId.make("project:service-test");
53+ const modelSelection = {
54+ instanceId: ProviderInstanceId.make("codex_custom"),
55+ model: "gpt-5.1-codex",
56+ } as const;
57+ yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z"));
58+
59+ const changesFiber = yield* service.changes.pipe(
60+ Stream.take(3),
61+ Stream.runCollect,
62+ Effect.forkChild,
63+ );
64+ yield* Effect.yieldNow;
65+
66+ const created = yield* service.create({
67+ projectId,
68+ title: "Project",
69+ workspaceRoot: "/work/project/",
70+ defaultModelSelection: modelSelection,
71+ scripts: [
72+ {
73+ id: "setup",
74+ name: "Setup",
75+ command: "vp install",
76+ icon: "configure",
77+ runOnWorktreeCreate: true,
78+ },
79+ ],
80+ });
81+ assert.equal(created.workspaceRoot, "/work/project");
82+ assert.equal(created.repositoryIdentity?.canonicalKey, "github.com/t3tools/project");
83+ assert.equal(created.faviconPath, "/work/project/favicon.svg");
84+
85+ const updated = yield* service.update({ projectId, title: "Renamed" });
86+ assert.equal(updated.title, "Renamed");
87+ assert.equal(updated.createdAt, created.createdAt);
88+
89+ const byId = yield* service.getById(projectId);
90+ const byWorkspace = yield* service.getByWorkspaceRoot("/work/project/");
91+ assert.isTrue(Option.isSome(byId));
92+ assert.isTrue(Option.isSome(byWorkspace));
93+ assert.equal(Option.getOrThrow(byWorkspace).id, projectId);
94+ assert.deepEqual(
95+ (yield* service.snapshot).projects.map((project) => project.id),
96+ [projectId],
97+ );
98+
99+ const deleted = yield* service.delete(projectId);
100+ assert.isNotNull(deleted.deletedAt);
101+ assert.isTrue(Option.isNone(yield* service.getById(projectId)));
102+ assert.isTrue(Option.isSome(yield* service.getById(projectId, { includeDeleted: true })));
103+ assert.deepEqual((yield* service.snapshot).projects, []);
104+
105+ const changes = Array.from(yield* Fiber.join(changesFiber));
106+ assert.deepEqual(
107+ changes.map((change) => change.type),
108+ ["project.upserted", "project.upserted", "project.deleted"],
109+ );
110+ }),
111+ );
112+
113+ it.effect("rejects active workspace collisions", () =>
114+ Effect.gen(function* () {
115+ const service = yield* ProjectService.ProjectService;
116+ yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z"));
117+ yield* service.create({
118+ projectId: ProjectId.make("project:collision:first"),
119+ title: "First",
120+ workspaceRoot: "/work/shared",
121+ });
122+ const error = yield* service
123+ .create({
124+ projectId: ProjectId.make("project:collision:second"),
125+ title: "Second",
126+ workspaceRoot: "/work/shared",
127+ })
128+ .pipe(Effect.flip);
129+ assert.equal(error._tag, "ProjectConflictError");
130+ }),
131+ );
132+});
apps/server/src/project/ProjectService.ts+363−0
@@ -0,0 +1,363 @@
1+import {
2+ ModelSelection,
3+ ProjectChange,
4+ ProjectId,
5+ type Project,
6+ type ProjectScript,
7+ type ProjectSnapshot,
8+} from "@t3tools/contracts";
9+import * as Context from "effect/Context";
10+import * as DateTime from "effect/DateTime";
11+import * as Effect from "effect/Effect";
12+import * as Layer from "effect/Layer";
13+import * as Option from "effect/Option";
14+import * as PubSub from "effect/PubSub";
15+import * as Schema from "effect/Schema";
16+import * as Stream from "effect/Stream";
17+
18+import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts";
19+import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
20+import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
21+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
22+
23+export interface ProjectCreateInput {
24+ readonly projectId: ProjectId;
25+ readonly title: string;
26+ readonly workspaceRoot: string;
27+ readonly createWorkspaceRootIfMissing?: boolean;
28+ readonly defaultModelSelection?: ModelSelection | null;
29+ readonly scripts?: ReadonlyArray<ProjectScript>;
30+}
31+
32+export interface ProjectUpdateInput {
33+ readonly projectId: ProjectId;
34+ readonly title?: string;
35+ readonly workspaceRoot?: string;
36+ readonly defaultModelSelection?: ModelSelection | null;
37+ readonly scripts?: ReadonlyArray<ProjectScript>;
38+}
39+
40+export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
41+ "ProjectNotFoundError",
42+ { projectId: ProjectId },
43+) {
44+ override get message(): string {
45+ return `Project ${this.projectId} was not found.`;
46+ }
47+}
48+
49+export class ProjectConflictError extends Schema.TaggedErrorClass<ProjectConflictError>()(
50+ "ProjectConflictError",
51+ {
52+ projectId: ProjectId,
53+ workspaceRoot: Schema.String,
54+ conflictingProjectId: ProjectId,
55+ },
56+) {
57+ override get message(): string {
58+ return `Workspace ${this.workspaceRoot} already belongs to project ${this.conflictingProjectId}.`;
59+ }
60+}
61+
62+export class ProjectOperationError extends Schema.TaggedErrorClass<ProjectOperationError>()(
63+ "ProjectOperationError",
64+ {
65+ operation: Schema.Literals([
66+ "normalize-workspace",
67+ "read-project",
68+ "list-projects",
69+ "persist-project",
70+ "resolve-favicon",
71+ ]),
72+ projectId: Schema.optional(ProjectId),
73+ workspaceRoot: Schema.optional(Schema.String),
74+ cause: Schema.Defect(),
75+ },
76+) {
77+ override get message(): string {
78+ return `Project operation '${this.operation}' failed${this.projectId === undefined ? "" : ` for ${this.projectId}`}.`;
79+ }
80+}
81+
82+export type ProjectServiceError =
83+ | ProjectNotFoundError
84+ | ProjectConflictError
85+ | ProjectOperationError;
86+
87+export class ProjectService extends Context.Service<
88+ ProjectService,
89+ {
90+ readonly create: (input: ProjectCreateInput) => Effect.Effect<Project, ProjectServiceError>;
91+ readonly update: (input: ProjectUpdateInput) => Effect.Effect<Project, ProjectServiceError>;
92+ readonly delete: (projectId: ProjectId) => Effect.Effect<Project, ProjectServiceError>;
93+ readonly getById: (
94+ projectId: ProjectId,
95+ options?: { readonly includeDeleted?: boolean },
96+ ) => Effect.Effect<Option.Option<Project>, ProjectOperationError>;
97+ readonly getByWorkspaceRoot: (
98+ workspaceRoot: string,
99+ options?: { readonly includeDeleted?: boolean },
100+ ) => Effect.Effect<Option.Option<Project>, ProjectOperationError>;
101+ readonly snapshot: Effect.Effect<ProjectSnapshot, ProjectOperationError>;
102+ readonly changes: Stream.Stream<ProjectChange>;
103+ }
104+>()("t3/project/ProjectService") {}
105+
106+export const make = Effect.gen(function* () {
107+ const projects = yield* ProjectionProjects.ProjectionProjectRepository;
108+ const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
109+ const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
110+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
111+ const changesPubSub = yield* PubSub.unbounded<ProjectChange>();
112+
113+ const hydrate = Effect.fn("ProjectService.hydrate")(function* (
114+ row: ProjectionProjects.ProjectionProject,
115+ ): Effect.fn.Return<Project, ProjectOperationError> {
116+ const repositoryIdentity = yield* repositoryIdentityResolver.resolve(row.workspaceRoot);
117+ const faviconPath = yield* faviconResolver.resolvePath(row.workspaceRoot).pipe(
118+ Effect.mapError(
119+ (cause) =>
120+ new ProjectOperationError({
121+ operation: "resolve-favicon",
122+ projectId: row.projectId,
123+ workspaceRoot: row.workspaceRoot,
124+ cause,
125+ }),
126+ ),
127+ );
128+ return {
129+ id: row.projectId,
130+ title: row.title,
131+ workspaceRoot: row.workspaceRoot,
132+ repositoryIdentity,
133+ faviconPath,
134+ defaultModelSelection: row.defaultModelSelection,
135+ scripts: row.scripts,
136+ createdAt: row.createdAt,
137+ updatedAt: row.updatedAt,
138+ deletedAt: row.deletedAt,
139+ };
140+ });
141+
142+ const readRows = Effect.fn("ProjectService.readRows")(function* () {
143+ return yield* projects
144+ .listAll()
145+ .pipe(
146+ Effect.mapError(
147+ (cause) => new ProjectOperationError({ operation: "list-projects", cause }),
148+ ),
149+ );
150+ });
151+
152+ const getById: ProjectService["Service"]["getById"] = Effect.fn("ProjectService.getById")(
153+ function* (projectId, options) {
154+ const row = yield* projects
155+ .getById({ projectId })
156+ .pipe(
157+ Effect.mapError(
158+ (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }),
159+ ),
160+ );
161+ if (Option.isNone(row) || (row.value.deletedAt !== null && !options?.includeDeleted)) {
162+ return Option.none();
163+ }
164+ return Option.some(yield* hydrate(row.value));
165+ },
166+ );
167+
168+ const getByWorkspaceRoot: ProjectService["Service"]["getByWorkspaceRoot"] = Effect.fn(
169+ "ProjectService.getByWorkspaceRoot",
170+ )(function* (workspaceRoot, options) {
171+ const normalized = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(
172+ Effect.mapError(
173+ (cause) =>
174+ new ProjectOperationError({
175+ operation: "normalize-workspace",
176+ workspaceRoot,
177+ cause,
178+ }),
179+ ),
180+ );
181+ const row = (yield* readRows()).find(
182+ (candidate) =>
183+ candidate.workspaceRoot === normalized &&
184+ (options?.includeDeleted === true || candidate.deletedAt === null),
185+ );
186+ return row === undefined ? Option.none() : Option.some(yield* hydrate(row));
187+ });
188+
189+ const persist = Effect.fn("ProjectService.persist")(function* (
190+ row: ProjectionProjects.ProjectionProject,
191+ ) {
192+ yield* projects.upsert(row).pipe(
193+ Effect.mapError(
194+ (cause) =>
195+ new ProjectOperationError({
196+ operation: "persist-project",
197+ projectId: row.projectId,
198+ workspaceRoot: row.workspaceRoot,
199+ cause,
200+ }),
201+ ),
202+ );
203+ const project = yield* hydrate(row);
204+ yield* PubSub.publish(changesPubSub, { type: "project.upserted", project });
205+ return project;
206+ });
207+
208+ const assertWorkspaceAvailable = Effect.fn("ProjectService.assertWorkspaceAvailable")(function* (
209+ projectId: ProjectId,
210+ workspaceRoot: string,
211+ ) {
212+ const conflicting = (yield* readRows()).find(
213+ (candidate) =>
214+ candidate.deletedAt === null &&
215+ candidate.projectId !== projectId &&
216+ candidate.workspaceRoot === workspaceRoot,
217+ );
218+ if (conflicting !== undefined) {
219+ return yield* new ProjectConflictError({
220+ projectId,
221+ workspaceRoot,
222+ conflictingProjectId: conflicting.projectId,
223+ });
224+ }
225+ });
226+
227+ const create: ProjectService["Service"]["create"] = Effect.fn("ProjectService.create")(
228+ function* (input) {
229+ const workspaceRoot = yield* workspacePaths
230+ .normalizeWorkspaceRoot(input.workspaceRoot, {
231+ createIfMissing: input.createWorkspaceRootIfMissing ?? false,
232+ })
233+ .pipe(
234+ Effect.mapError(
235+ (cause) =>
236+ new ProjectOperationError({
237+ operation: "normalize-workspace",
238+ projectId: input.projectId,
239+ workspaceRoot: input.workspaceRoot,
240+ cause,
241+ }),
242+ ),
243+ );
244+ yield* assertWorkspaceAvailable(input.projectId, workspaceRoot);
245+ const existing = yield* projects.getById({ projectId: input.projectId }).pipe(
246+ Effect.mapError(
247+ (cause) =>
248+ new ProjectOperationError({
249+ operation: "read-project",
250+ projectId: input.projectId,
251+ cause,
252+ }),
253+ ),
254+ );
255+ const now = DateTime.formatIso(yield* DateTime.now);
256+ return yield* persist({
257+ projectId: input.projectId,
258+ title: input.title,
259+ workspaceRoot,
260+ defaultModelSelection: input.defaultModelSelection ?? null,
261+ scripts: [...(input.scripts ?? [])],
262+ createdAt: Option.isSome(existing) ? existing.value.createdAt : now,
263+ updatedAt: now,
264+ deletedAt: null,
265+ });
266+ },
267+ );
268+
269+ const update: ProjectService["Service"]["update"] = Effect.fn("ProjectService.update")(
270+ function* (input) {
271+ const existing = yield* projects.getById({ projectId: input.projectId }).pipe(
272+ Effect.mapError(
273+ (cause) =>
274+ new ProjectOperationError({
275+ operation: "read-project",
276+ projectId: input.projectId,
277+ cause,
278+ }),
279+ ),
280+ );
281+ if (Option.isNone(existing) || existing.value.deletedAt !== null) {
282+ return yield* new ProjectNotFoundError({ projectId: input.projectId });
283+ }
284+ const workspaceRoot =
285+ input.workspaceRoot === undefined
286+ ? existing.value.workspaceRoot
287+ : yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
288+ Effect.mapError(
289+ (cause) =>
290+ new ProjectOperationError({
291+ operation: "normalize-workspace",
292+ projectId: input.projectId,
293+ workspaceRoot: input.workspaceRoot,
294+ cause,
295+ }),
296+ ),
297+ );
298+ yield* assertWorkspaceAvailable(input.projectId, workspaceRoot);
299+ return yield* persist({
300+ ...existing.value,
301+ ...(input.title === undefined ? {} : { title: input.title }),
302+ workspaceRoot,
303+ ...(input.defaultModelSelection === undefined
304+ ? {}
305+ : { defaultModelSelection: input.defaultModelSelection }),
306+ ...(input.scripts === undefined ? {} : { scripts: [...input.scripts] }),
307+ updatedAt: DateTime.formatIso(yield* DateTime.now),
308+ });
309+ },
310+ );
311+
312+ const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")(
313+ function* (projectId) {
314+ const existing = yield* projects
315+ .getById({ projectId })
316+ .pipe(
317+ Effect.mapError(
318+ (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }),
319+ ),
320+ );
321+ if (Option.isNone(existing) || existing.value.deletedAt !== null) {
322+ return yield* new ProjectNotFoundError({ projectId });
323+ }
324+ const deletedAt = DateTime.formatIso(yield* DateTime.now);
325+ const row = { ...existing.value, updatedAt: deletedAt, deletedAt };
326+ yield* projects.upsert(row).pipe(
327+ Effect.mapError(
328+ (cause) =>
329+ new ProjectOperationError({
330+ operation: "persist-project",
331+ projectId,
332+ workspaceRoot: row.workspaceRoot,
333+ cause,
334+ }),
335+ ),
336+ );
337+ const project = yield* hydrate(row);
338+ yield* PubSub.publish(changesPubSub, { type: "project.deleted", projectId, deletedAt });
339+ return project;
340+ },
341+ );
342+
343+ const snapshot = Effect.gen(function* () {
344+ const rows = (yield* readRows()).filter((row) => row.deletedAt === null);
345+ const hydrated = yield* Effect.forEach(rows, hydrate, { concurrency: 8 });
346+ return {
347+ projects: hydrated,
348+ updatedAt: DateTime.formatIso(yield* DateTime.now),
349+ } satisfies ProjectSnapshot;
350+ });
351+
352+ return ProjectService.of({
353+ create,
354+ update,
355+ delete: deleteProject,
356+ getById,
357+ getByWorkspaceRoot,
358+ snapshot,
359+ changes: Stream.fromPubSub(changesPubSub),
360+ });
361+});
362+
363+export const layer = Layer.effect(ProjectService, make);
packages/contracts/src/orchestration.ts+2−41
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030 RuntimeMode,
3131 } from "./providerPolicy.ts";
3232 import { ProviderInstanceId } from "./providerInstance.ts";
33+import { Project, ProjectScript } from "./project.ts";
3334
3435 export const ORCHESTRATION_WS_METHODS = {
3536 dispatchCommand: "orchestration.dispatchCommand",
@@ -45,47 +46,7 @@ export const ORCHESTRATION_WS_METHODS = { · 9 unmodified lines
4546 export const CorrelationId = CommandId;
4647 export type CorrelationId = typeof CorrelationId.Type;
4748
48−export const ProjectScriptIcon = Schema.Literals([
49− "play",
50− "test",
51− "lint",
52− "configure",
53− "build",
54− "debug",
55−]);
56−export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
57−
58−export const ProjectScript = Schema.Struct({
59− id: TrimmedNonEmptyString,
60− name: TrimmedNonEmptyString,
61− command: TrimmedNonEmptyString,
62− icon: ProjectScriptIcon,
63− runOnWorktreeCreate: Schema.Boolean,
64− /**
65− * URL to open in the in-app browser preview when this script runs (or
66− * when the user explicitly requests a preview). Optional; only honored on
67− * the desktop build.
68− */
69− previewUrl: Schema.optional(TrimmedNonEmptyString),
70− /**
71− * When true, automatically open the preview panel pointed at `previewUrl`
72− * the moment this script starts. Ignored without `previewUrl` or on web.
73− */
74− autoOpenPreview: Schema.optional(Schema.Boolean),
75−});
76−export type ProjectScript = typeof ProjectScript.Type;
77−
78−export const OrchestrationProject = Schema.Struct({
79− id: ProjectId,
80− title: TrimmedNonEmptyString,
81− workspaceRoot: TrimmedNonEmptyString,
82− repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
83− defaultModelSelection: Schema.NullOr(ModelSelection),
84− scripts: Schema.Array(ProjectScript),
85− createdAt: IsoDateTime,
86− updatedAt: IsoDateTime,
87− deletedAt: Schema.NullOr(IsoDateTime),
88−});
49+export const OrchestrationProject = Project.mapFields(Struct.omit(["faviconPath"]));
8950 export type OrchestrationProject = typeof OrchestrationProject.Type;
9051
9152 export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);
packages/contracts/src/project.ts+54−3
@@ -1,9 +1,12 @@
11 import * as Schema from "effect/Schema";
2+import { RepositoryIdentity } from "./environment.ts";
3+import { ModelSelection } from "./modelSelection.ts";
24 import {
5+ IsoDateTime,
36 NonNegativeInt,
47 PositiveInt,
8+ ProjectId,
59 TrimmedNonEmptyString,
6− TrimmedString,
710 } from "./baseSchemas.ts";
811
912 const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
@@ -11,8 +14,56 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; · 1 unmodified line
1114 const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
1215 const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;
1316
14−export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
15−export type ProjectEntryKind = typeof ProjectEntryKind.Type;
17+export const ProjectScriptIcon = Schema.Literals([
18+ "play",
19+ "test",
20+ "lint",
21+ "configure",
22+ "build",
23+ "debug",
24+]);
25+export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
26+
27+export const ProjectScript = Schema.Struct({
28+ id: TrimmedNonEmptyString,
29+ name: TrimmedNonEmptyString,
30+ command: TrimmedNonEmptyString,
31+ icon: ProjectScriptIcon,
32+ runOnWorktreeCreate: Schema.Boolean,
33+ previewUrl: Schema.optional(TrimmedNonEmptyString),
34+ autoOpenPreview: Schema.optional(Schema.Boolean),
35+});
36+export type ProjectScript = typeof ProjectScript.Type;
37+
38+export const Project = Schema.Struct({
39+ id: ProjectId,
40+ title: TrimmedNonEmptyString,
41+ workspaceRoot: TrimmedNonEmptyString,
42+ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
43+ faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
44+ defaultModelSelection: Schema.NullOr(ModelSelection),
45+ scripts: Schema.Array(ProjectScript),
46+ createdAt: IsoDateTime,
47+ updatedAt: IsoDateTime,
48+ deletedAt: Schema.NullOr(IsoDateTime),
49+});
50+export type Project = typeof Project.Type;
51+
52+export const ProjectSnapshot = Schema.Struct({
53+ projects: Schema.Array(Project),
54+ updatedAt: IsoDateTime,
55+});
56+export type ProjectSnapshot = typeof ProjectSnapshot.Type;
57+
58+export const ProjectChange = Schema.Union([
59+ Schema.Struct({ type: Schema.Literal("project.upserted"), project: Project }),
60+ Schema.Struct({
61+ type: Schema.Literal("project.deleted"),
62+ projectId: ProjectId,
63+ deletedAt: IsoDateTime,
64+ }),
65+]);
66+export type ProjectChange = typeof ProjectChange.Type;
1667
1768 export const ProjectSearchEntriesInput = Schema.Struct({
1869 cwd: TrimmedNonEmptyString,