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 @@
1import { assert, it } from "@effect/vitest";
2import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
3
4import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
5import { decideProviderSessionTransition } from "./ProviderSessionTransitionPolicy.ts";
6
7const driver = ProviderDriverKind.make("codex");
8const instanceId = ProviderInstanceId.make("codex");
9const 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
19it("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
29it("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
64it("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
74it("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 @@
1import type {
2 ModelSelection,
3 OrchestrationV2ProviderCapabilities,
4 ProviderDriverKind,
5 ProviderInteractionMode,
6 RuntimeMode,
7} from "@t3tools/contracts";
8
9import type { ProviderContinuationIdentity } from "../provider/ProviderDriver.ts";
10
11export 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
18export 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
28export interface ProviderSessionTransitionTarget extends ProviderSessionTransitionState {
29 readonly available: boolean;
30}
31
32export 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 @@
1import { assert, it } from "@effect/vitest";
2import { ProjectId, ProviderInstanceId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
8import { TestClock } from "effect/testing";
9
10import * as ProjectionProjects from "../persistence/Layers/ProjectionProjects.ts";
11import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
12import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
13import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
14import * as ProjectService from "./ProjectService.ts";
15import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
16
17const 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
23const 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
41const TestLayer = ProjectService.layer.pipe(
42 Layer.provideMerge(ProjectionProjects.ProjectionProjectRepositoryLive),
43 Layer.provideMerge(workspacePathsLayer),
44 Layer.provideMerge(metadataLayer),
45 Layer.provideMerge(SqlitePersistenceMemory),
46);
47
48it.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 @@
1import {
2 ModelSelection,
3 ProjectChange,
4 ProjectId,
5 type Project,
6 type ProjectScript,
7 type ProjectSnapshot,
8} from "@t3tools/contracts";
9import * as Context from "effect/Context";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as PubSub from "effect/PubSub";
15import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";
17
18import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts";
19import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
20import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
21import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
22
23export 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
32export 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
40export 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
49export 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
62export 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
82export type ProjectServiceError =
83 | ProjectNotFoundError
84 | ProjectConflictError
85 | ProjectOperationError;
86
87export 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
106export 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
363export const layer = Layer.effect(ProjectService, make);
packages/contracts/src/orchestration.ts+2−41
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
30 RuntimeMode,30 RuntimeMode,
31} from "./providerPolicy.ts";31} from "./providerPolicy.ts";
32import { ProviderInstanceId } from "./providerInstance.ts";32import { ProviderInstanceId } from "./providerInstance.ts";
33import { Project, ProjectScript } from "./project.ts";
33 34
34export const ORCHESTRATION_WS_METHODS = {35export const ORCHESTRATION_WS_METHODS = {
35 dispatchCommand: "orchestration.dispatchCommand",36 dispatchCommand: "orchestration.dispatchCommand",
@@ -45,47 +46,7 @@ export const ORCHESTRATION_WS_METHODS = { · 9 unmodified lines
45export const CorrelationId = CommandId;46export const CorrelationId = CommandId;
46export type CorrelationId = typeof CorrelationId.Type;47export type CorrelationId = typeof CorrelationId.Type;
47 48
48export const ProjectScriptIcon = Schema.Literals([49export const OrchestrationProject = Project.mapFields(Struct.omit(["faviconPath"]));
49 "play",
50 "test",
51 "lint",
52 "configure",
53 "build",
54 "debug",
55]);
56export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
57
58export 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});
76export type ProjectScript = typeof ProjectScript.Type;
77
78export 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});
89export type OrchestrationProject = typeof OrchestrationProject.Type;50export type OrchestrationProject = typeof OrchestrationProject.Type;
90 51
91export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);52export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);
packages/contracts/src/project.ts+54−3
@@ -1,9 +1,12 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { RepositoryIdentity } from "./environment.ts";
3import { ModelSelection } from "./modelSelection.ts";
2import {4import {
5 IsoDateTime,
3 NonNegativeInt,6 NonNegativeInt,
4 PositiveInt,7 PositiveInt,
8 ProjectId,
5 TrimmedNonEmptyString,9 TrimmedNonEmptyString,
6 TrimmedString,
7} from "./baseSchemas.ts";10} from "./baseSchemas.ts";
8 11
9const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;12const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
@@ -11,8 +14,56 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; · 1 unmodified line
11const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;14const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
12const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;15const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;
13 16
14export const ProjectEntryKind = Schema.Literals(["file", "directory"]);17export const ProjectScriptIcon = Schema.Literals([
15export type ProjectEntryKind = typeof ProjectEntryKind.Type;18 "play",
19 "test",
20 "lint",
21 "configure",
22 "build",
23 "debug",
24]);
25export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
26
27export 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});
36export type ProjectScript = typeof ProjectScript.Type;
37
38export 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});
50export type Project = typeof Project.Type;
51
52export const ProjectSnapshot = Schema.Struct({
53 projects: Schema.Array(Project),
54 updatedAt: IsoDateTime,
55});
56export type ProjectSnapshot = typeof ProjectSnapshot.Type;
57
58export 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]);
66export type ProjectChange = typeof ProjectChange.Type;
16 67
17export const ProjectSearchEntriesInput = Schema.Struct({68export const ProjectSearchEntriesInput = Schema.Struct({
18 cwd: TrimmedNonEmptyString,69 cwd: TrimmedNonEmptyString,