bopodev-api 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,65 @@
1
1
  import { Router } from "express";
2
- import { mkdir } from "node:fs/promises";
2
+ import { mkdir, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
3
4
  import { z } from "zod";
4
- import { appendAuditEvent, createProject, deleteProject, listProjects, syncProjectGoals, updateProject } from "bopodev-db";
5
+ import {
6
+ appendAuditEvent,
7
+ createProject,
8
+ createProjectWorkspace,
9
+ deleteProject,
10
+ deleteProjectWorkspace,
11
+ listProjects,
12
+ listProjectWorkspaces,
13
+ syncProjectGoals,
14
+ updateProject,
15
+ updateProjectWorkspace
16
+ } from "bopodev-db";
5
17
  import type { AppContext } from "../context";
6
18
  import { sendError, sendOk } from "../http";
7
- import { normalizeAbsolutePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
19
+ import { normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
8
20
  import { requireCompanyScope } from "../middleware/company-scope";
9
21
  import { requirePermission } from "../middleware/request-actor";
10
22
 
11
23
  const projectStatusSchema = z.enum(["planned", "active", "paused", "blocked", "completed", "archived"]);
24
+ const executionWorkspacePolicySchema = z
25
+ .object({
26
+ mode: z.enum(["project_primary", "isolated", "agent_default"]).optional(),
27
+ strategy: z
28
+ .object({
29
+ type: z.enum(["git_worktree"]).optional(),
30
+ rootDir: z.string().optional().nullable(),
31
+ branchPrefix: z.string().optional().nullable()
32
+ })
33
+ .optional()
34
+ .nullable(),
35
+ credentials: z
36
+ .object({
37
+ mode: z.enum(["host", "env_token"]).optional(),
38
+ tokenEnvVar: z.string().optional().nullable(),
39
+ username: z.string().optional().nullable()
40
+ })
41
+ .optional()
42
+ .nullable(),
43
+ allowRemotes: z.array(z.string().min(1)).optional().nullable(),
44
+ allowBranchPrefixes: z.array(z.string().min(1)).optional().nullable()
45
+ })
46
+ .partial();
12
47
 
13
48
  const createProjectSchema = z.object({
14
49
  name: z.string().min(1),
15
50
  description: z.string().optional(),
16
51
  status: projectStatusSchema.default("planned"),
17
52
  plannedStartAt: z.string().optional(),
18
- workspaceLocalPath: z.string().optional(),
19
- workspaceGithubRepo: z.string().url().optional(),
53
+ executionWorkspacePolicy: executionWorkspacePolicySchema.optional().nullable(),
54
+ workspace: z
55
+ .object({
56
+ name: z.string().min(1).optional(),
57
+ cwd: z.string().optional().nullable(),
58
+ repoUrl: z.string().url().optional().nullable(),
59
+ repoRef: z.string().optional().nullable(),
60
+ isPrimary: z.boolean().optional().default(true)
61
+ })
62
+ .optional(),
20
63
  goalIds: z.array(z.string().min(1)).default([])
21
64
  });
22
65
 
@@ -26,12 +69,41 @@ const updateProjectSchema = z
26
69
  description: z.string().nullable().optional(),
27
70
  status: projectStatusSchema.optional(),
28
71
  plannedStartAt: z.string().nullable().optional(),
29
- workspaceLocalPath: z.string().nullable().optional(),
30
- workspaceGithubRepo: z.string().url().nullable().optional(),
72
+ executionWorkspacePolicy: executionWorkspacePolicySchema.nullable().optional(),
31
73
  goalIds: z.array(z.string().min(1)).optional()
32
74
  })
33
75
  .refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
34
76
 
77
+ const createProjectWorkspaceSchema = z
78
+ .object({
79
+ name: z.string().min(1).optional(),
80
+ cwd: z.string().optional().nullable(),
81
+ repoUrl: z.string().url().optional().nullable(),
82
+ repoRef: z.string().optional().nullable(),
83
+ isPrimary: z.boolean().optional().default(false)
84
+ })
85
+ .superRefine((value, ctx) => {
86
+ const hasCwd = Boolean(value.cwd?.trim());
87
+ const hasRepoUrl = Boolean(value.repoUrl?.trim());
88
+ if (!hasCwd && !hasRepoUrl) {
89
+ ctx.addIssue({
90
+ code: "custom",
91
+ message: "Workspace must include at least one of cwd or repoUrl.",
92
+ path: ["cwd"]
93
+ });
94
+ }
95
+ });
96
+
97
+ const updateProjectWorkspaceSchema = z
98
+ .object({
99
+ name: z.string().min(1).optional(),
100
+ cwd: z.string().nullable().optional(),
101
+ repoUrl: z.string().url().nullable().optional(),
102
+ repoRef: z.string().nullable().optional(),
103
+ isPrimary: z.boolean().optional()
104
+ })
105
+ .refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
106
+
35
107
  function parsePlannedStartAt(value?: string | null) {
36
108
  if (!value) {
37
109
  return null;
@@ -49,7 +121,8 @@ export function createProjectsRouter(ctx: AppContext) {
49
121
 
50
122
  router.get("/", async (req, res) => {
51
123
  const projects = await listProjects(ctx.db, req.companyId!);
52
- return sendOk(res, projects);
124
+ const withDiagnostics = await Promise.all(projects.map((project) => enrichProjectDiagnostics(req.companyId!, project)));
125
+ return sendOk(res, withDiagnostics);
53
126
  });
54
127
 
55
128
  router.post("/", async (req, res) => {
@@ -61,37 +134,68 @@ export function createProjectsRouter(ctx: AppContext) {
61
134
  if (!parsed.success) {
62
135
  return sendError(res, parsed.error.message, 422);
63
136
  }
64
- const explicitWorkspaceLocalPath = normalizeOptionalPath(parsed.data.workspaceLocalPath);
65
- const created = await createProject(ctx.db, {
137
+ const project = await createProject(ctx.db, {
66
138
  companyId: req.companyId!,
67
139
  name: parsed.data.name,
68
140
  description: parsed.data.description,
69
141
  status: parsed.data.status,
70
142
  plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
71
- workspaceLocalPath: explicitWorkspaceLocalPath ?? undefined,
72
- workspaceGithubRepo: parsed.data.workspaceGithubRepo
143
+ executionWorkspacePolicy: parsed.data.executionWorkspacePolicy ?? null
73
144
  });
74
- const project =
75
- explicitWorkspaceLocalPath
76
- ? created
77
- : await ensureAutoWorkspaceLocalPath(ctx, req.companyId!, created.id);
78
- if (explicitWorkspaceLocalPath) {
79
- await mkdir(explicitWorkspaceLocalPath, { recursive: true });
145
+ if (!project) {
146
+ return sendError(res, "Project creation failed.", 500);
147
+ }
148
+ if (parsed.data.workspace) {
149
+ let normalizedWorkspace: ReturnType<typeof normalizeWorkspaceInput>;
150
+ try {
151
+ normalizedWorkspace = normalizeWorkspaceInput(req.companyId!, parsed.data.workspace);
152
+ } catch (error) {
153
+ return sendError(res, String(error), 422);
154
+ }
155
+ if (!normalizedWorkspace.cwd && !normalizedWorkspace.repoUrl) {
156
+ return sendError(res, "Workspace must include at least one of cwd or repoUrl.", 422);
157
+ }
158
+ const workspace = await createProjectWorkspace(ctx.db, {
159
+ companyId: req.companyId!,
160
+ projectId: project!.id,
161
+ name: normalizedWorkspace.name,
162
+ cwd: normalizedWorkspace.cwd ?? null,
163
+ repoUrl: normalizedWorkspace.repoUrl ?? null,
164
+ repoRef: normalizedWorkspace.repoRef ?? null,
165
+ isPrimary: normalizedWorkspace.isPrimary
166
+ });
167
+ if (workspace?.cwd) {
168
+ await mkdir(workspace.cwd, { recursive: true });
169
+ }
170
+ } else {
171
+ const defaultWorkspaceCwd = resolveProjectWorkspacePath(req.companyId!, project.id);
172
+ await mkdir(defaultWorkspaceCwd, { recursive: true });
173
+ const workspace = await createProjectWorkspace(ctx.db, {
174
+ companyId: req.companyId!,
175
+ projectId: project.id,
176
+ name: "Primary workspace",
177
+ cwd: defaultWorkspaceCwd,
178
+ isPrimary: true
179
+ });
180
+ if (!workspace) {
181
+ return sendError(res, "Project workspace provisioning failed.", 500);
182
+ }
80
183
  }
81
184
  await syncProjectGoals(ctx.db, {
82
185
  companyId: req.companyId!,
83
186
  projectId: project.id,
84
187
  goalIds: parsed.data.goalIds
85
188
  });
189
+ const [hydratedProject] = await listProjects(ctx.db, req.companyId!).then((projects) => projects.filter((entry) => entry.id === project.id));
86
190
  await appendAuditEvent(ctx.db, {
87
191
  companyId: req.companyId!,
88
192
  actorType: "human",
89
193
  eventType: "project.created",
90
194
  entityType: "project",
91
195
  entityId: project.id,
92
- payload: project
196
+ payload: hydratedProject ?? project
93
197
  });
94
- return sendOk(res, project);
198
+ return sendOk(res, await enrichProjectDiagnostics(req.companyId!, hydratedProject ?? project));
95
199
  });
96
200
 
97
201
  router.put("/:projectId", async (req, res) => {
@@ -112,37 +216,11 @@ export function createProjectsRouter(ctx: AppContext) {
112
216
  status: parsed.data.status,
113
217
  plannedStartAt:
114
218
  parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
115
- workspaceLocalPath:
116
- parsed.data.workspaceLocalPath === undefined
117
- ? undefined
118
- : parsed.data.workspaceLocalPath === null
119
- ? null
120
- : normalizeAbsolutePath(parsed.data.workspaceLocalPath),
121
- workspaceGithubRepo: parsed.data.workspaceGithubRepo
219
+ executionWorkspacePolicy: parsed.data.executionWorkspacePolicy
122
220
  });
123
- if (
124
- parsed.data.workspaceLocalPath !== undefined &&
125
- parsed.data.workspaceLocalPath !== null &&
126
- parsed.data.workspaceLocalPath.trim().length > 0
127
- ) {
128
- await mkdir(normalizeAbsolutePath(parsed.data.workspaceLocalPath), { recursive: true });
129
- }
130
221
  if (!project) {
131
222
  return sendError(res, "Project not found.", 404);
132
223
  }
133
- if (parsed.data.workspaceLocalPath === null) {
134
- const autoWorkspacePath = resolveProjectWorkspacePath(req.companyId!, project.id);
135
- await mkdir(autoWorkspacePath, { recursive: true });
136
- const updated = await updateProject(ctx.db, {
137
- companyId: req.companyId!,
138
- id: req.params.projectId,
139
- workspaceLocalPath: autoWorkspacePath
140
- });
141
- if (!updated) {
142
- return sendError(res, "Project not found.", 404);
143
- }
144
- Object.assign(project, updated);
145
- }
146
224
 
147
225
  if (parsed.data.goalIds) {
148
226
  await syncProjectGoals(ctx.db, {
@@ -160,7 +238,140 @@ export function createProjectsRouter(ctx: AppContext) {
160
238
  entityId: project.id,
161
239
  payload: project
162
240
  });
163
- return sendOk(res, project);
241
+ return sendOk(res, await enrichProjectDiagnostics(req.companyId!, project));
242
+ });
243
+
244
+ router.get("/:projectId/workspaces", async (req, res) => {
245
+ const projects = await listProjects(ctx.db, req.companyId!);
246
+ const project = projects.find((entry) => entry.id === req.params.projectId);
247
+ if (!project) {
248
+ return sendError(res, "Project not found.", 404);
249
+ }
250
+ const workspaces = await listProjectWorkspaces(ctx.db, req.companyId!, req.params.projectId);
251
+ return sendOk(res, workspaces);
252
+ });
253
+
254
+ router.post("/:projectId/workspaces", async (req, res) => {
255
+ requirePermission("projects:write")(req, res, () => {});
256
+ if (res.headersSent) {
257
+ return;
258
+ }
259
+ const parsed = createProjectWorkspaceSchema.safeParse(req.body);
260
+ if (!parsed.success) {
261
+ return sendError(res, parsed.error.message, 422);
262
+ }
263
+ const projects = await listProjects(ctx.db, req.companyId!);
264
+ const project = projects.find((entry) => entry.id === req.params.projectId);
265
+ if (!project) {
266
+ return sendError(res, "Project not found.", 404);
267
+ }
268
+ let workspaceInput: ReturnType<typeof normalizeWorkspaceInput>;
269
+ try {
270
+ workspaceInput = normalizeWorkspaceInput(req.companyId!, parsed.data);
271
+ } catch (error) {
272
+ return sendError(res, String(error), 422);
273
+ }
274
+ if (!workspaceInput.cwd && !workspaceInput.repoUrl) {
275
+ return sendError(res, "Workspace must include at least one of cwd or repoUrl.", 422);
276
+ }
277
+ const created = await createProjectWorkspace(ctx.db, {
278
+ companyId: req.companyId!,
279
+ projectId: req.params.projectId,
280
+ name: workspaceInput.name,
281
+ cwd: workspaceInput.cwd ?? null,
282
+ repoUrl: workspaceInput.repoUrl ?? null,
283
+ repoRef: workspaceInput.repoRef ?? null,
284
+ isPrimary: workspaceInput.isPrimary
285
+ });
286
+ if (!created) {
287
+ return sendError(res, "Project workspace creation failed.", 500);
288
+ }
289
+ if (created.cwd) {
290
+ await mkdir(created.cwd, { recursive: true });
291
+ }
292
+ await appendAuditEvent(ctx.db, {
293
+ companyId: req.companyId!,
294
+ actorType: "human",
295
+ eventType: "project.workspace_created",
296
+ entityType: "project_workspace",
297
+ entityId: created.id,
298
+ payload: created as unknown as Record<string, unknown>
299
+ });
300
+ return sendOk(res, created);
301
+ });
302
+
303
+ router.put("/:projectId/workspaces/:workspaceId", async (req, res) => {
304
+ requirePermission("projects:write")(req, res, () => {});
305
+ if (res.headersSent) {
306
+ return;
307
+ }
308
+ const parsed = updateProjectWorkspaceSchema.safeParse(req.body);
309
+ if (!parsed.success) {
310
+ return sendError(res, parsed.error.message, 422);
311
+ }
312
+ let workspaceInput: ReturnType<typeof normalizeWorkspaceInput>;
313
+ try {
314
+ workspaceInput = normalizeWorkspaceInput(req.companyId!, parsed.data);
315
+ } catch (error) {
316
+ return sendError(res, String(error), 422);
317
+ }
318
+ if (parsed.data.cwd !== undefined || parsed.data.repoUrl !== undefined) {
319
+ const hasCwd = workspaceInput.cwd !== null && workspaceInput.cwd !== undefined && workspaceInput.cwd.length > 0;
320
+ const hasRepo = workspaceInput.repoUrl !== null && workspaceInput.repoUrl !== undefined && workspaceInput.repoUrl.length > 0;
321
+ if (!hasCwd && !hasRepo) {
322
+ return sendError(res, "Workspace must include at least one of cwd or repoUrl.", 422);
323
+ }
324
+ }
325
+
326
+ const updated = await updateProjectWorkspace(ctx.db, {
327
+ companyId: req.companyId!,
328
+ projectId: req.params.projectId,
329
+ id: req.params.workspaceId,
330
+ name: workspaceInput.name,
331
+ cwd: workspaceInput.cwd,
332
+ repoUrl: workspaceInput.repoUrl,
333
+ repoRef: workspaceInput.repoRef,
334
+ isPrimary: workspaceInput.isPrimary
335
+ });
336
+ if (!updated) {
337
+ return sendError(res, "Project workspace not found.", 404);
338
+ }
339
+ if (updated.cwd) {
340
+ await mkdir(updated.cwd, { recursive: true });
341
+ }
342
+ await appendAuditEvent(ctx.db, {
343
+ companyId: req.companyId!,
344
+ actorType: "human",
345
+ eventType: "project.workspace_updated",
346
+ entityType: "project_workspace",
347
+ entityId: updated.id,
348
+ payload: updated as unknown as Record<string, unknown>
349
+ });
350
+ return sendOk(res, updated);
351
+ });
352
+
353
+ router.delete("/:projectId/workspaces/:workspaceId", async (req, res) => {
354
+ requirePermission("projects:write")(req, res, () => {});
355
+ if (res.headersSent) {
356
+ return;
357
+ }
358
+ const deleted = await deleteProjectWorkspace(ctx.db, {
359
+ companyId: req.companyId!,
360
+ projectId: req.params.projectId,
361
+ id: req.params.workspaceId
362
+ });
363
+ if (!deleted) {
364
+ return sendError(res, "Project workspace not found.", 404);
365
+ }
366
+ await appendAuditEvent(ctx.db, {
367
+ companyId: req.companyId!,
368
+ actorType: "human",
369
+ eventType: "project.workspace_deleted",
370
+ entityType: "project_workspace",
371
+ entityId: req.params.workspaceId,
372
+ payload: deleted as unknown as Record<string, unknown>
373
+ });
374
+ return sendOk(res, { deleted: true });
164
375
  });
165
376
 
166
377
  router.delete("/:projectId", async (req, res) => {
@@ -187,23 +398,110 @@ export function createProjectsRouter(ctx: AppContext) {
187
398
  return router;
188
399
  }
189
400
 
190
- async function ensureAutoWorkspaceLocalPath(ctx: AppContext, companyId: string, projectId: string) {
191
- const workspaceLocalPath = resolveProjectWorkspacePath(companyId, projectId);
192
- await mkdir(workspaceLocalPath, { recursive: true });
193
- const updated = await updateProject(ctx.db, {
194
- companyId,
195
- id: projectId,
196
- workspaceLocalPath
197
- });
198
- if (!updated) {
199
- throw new Error("Project not found after creation.");
401
+ function normalizeWorkspaceInput(
402
+ companyId: string,
403
+ value:
404
+ | {
405
+ name?: string;
406
+ cwd?: string | null;
407
+ repoUrl?: string | null;
408
+ repoRef?: string | null;
409
+ isPrimary?: boolean;
410
+ }
411
+ | undefined
412
+ ) {
413
+ if (!value) {
414
+ return {
415
+ name: "Workspace",
416
+ cwd: null,
417
+ repoUrl: null,
418
+ repoRef: null,
419
+ isPrimary: false
420
+ };
200
421
  }
201
- return updated;
422
+ const cwd =
423
+ value.cwd && value.cwd.trim().length > 0
424
+ ? normalizeCompanyWorkspacePath(companyId, value.cwd, { requireAbsoluteInput: true })
425
+ : null;
426
+ const repoUrl = value.repoUrl && value.repoUrl.trim().length > 0 ? value.repoUrl.trim() : null;
427
+ const repoRef = value.repoRef && value.repoRef.trim().length > 0 ? value.repoRef.trim() : null;
428
+ const name = value.name && value.name.trim().length > 0 ? value.name.trim() : inferWorkspaceName(cwd, repoUrl);
429
+ return {
430
+ name,
431
+ cwd,
432
+ repoUrl,
433
+ repoRef,
434
+ isPrimary: value.isPrimary ?? false
435
+ };
202
436
  }
203
437
 
204
- function normalizeOptionalPath(value: string | undefined) {
205
- if (!value || value.trim().length === 0) {
206
- return null;
438
+ function inferWorkspaceName(cwd: string | null, repoUrl: string | null) {
439
+ if (cwd) {
440
+ const segments = cwd.split("/").filter(Boolean);
441
+ return segments[segments.length - 1] ?? "Workspace";
442
+ }
443
+ if (repoUrl) {
444
+ const parts = repoUrl.replace(/\/+$/, "").split("/");
445
+ return parts[parts.length - 1] || "Workspace";
446
+ }
447
+ return "Workspace";
448
+ }
449
+
450
+ async function enrichProjectDiagnostics(
451
+ companyId: string,
452
+ project: {
453
+ id: string;
454
+ executionWorkspacePolicy?: Record<string, unknown> | null;
455
+ primaryWorkspace?: {
456
+ cwd?: string | null;
457
+ repoUrl?: string | null;
458
+ repoRef?: string | null;
459
+ } | null;
460
+ workspaces?: Array<{
461
+ id: string;
462
+ cwd?: string | null;
463
+ repoUrl?: string | null;
464
+ repoRef?: string | null;
465
+ }>;
466
+ } & Record<string, unknown>
467
+ ) {
468
+ const policy = project.executionWorkspacePolicy ?? null;
469
+ const credentials = (policy?.credentials ?? {}) as { mode?: string; tokenEnvVar?: string | null };
470
+ const primaryWorkspace = project.primaryWorkspace;
471
+ const effectiveCwd =
472
+ primaryWorkspace?.cwd?.trim() ||
473
+ (primaryWorkspace?.repoUrl ? resolveProjectWorkspacePath(companyId, project.id) : null);
474
+ const hasRepo = Boolean(primaryWorkspace?.repoUrl?.trim());
475
+ const hasLocal = Boolean(primaryWorkspace?.cwd?.trim());
476
+ const gitDirReady = effectiveCwd ? await pathExists(join(effectiveCwd, ".git")) : false;
477
+ const workspaceStatus = hasRepo
478
+ ? hasLocal
479
+ ? "hybrid"
480
+ : "repo_only"
481
+ : hasLocal
482
+ ? "local_only"
483
+ : "unconfigured";
484
+ const cloneState = hasRepo ? (gitDirReady ? "ready" : "missing") : "n/a";
485
+ return {
486
+ ...project,
487
+ gitDiagnostics: {
488
+ workspaceStatus,
489
+ effectiveCwd,
490
+ cloneState,
491
+ authMode: credentials.mode === "env_token" ? "env_token" : "host",
492
+ tokenEnvVar:
493
+ credentials.mode === "env_token" && typeof credentials.tokenEnvVar === "string"
494
+ ? credentials.tokenEnvVar
495
+ : null
496
+ }
497
+ };
498
+ }
499
+
500
+ async function pathExists(path: string) {
501
+ try {
502
+ await stat(path);
503
+ return true;
504
+ } catch {
505
+ return false;
207
506
  }
208
- return normalizeAbsolutePath(value);
209
507
  }