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.
- package/package.json +4 -4
- package/src/app.ts +57 -1
- package/src/context.ts +3 -0
- package/src/lib/agent-config.ts +10 -1
- package/src/lib/git-runtime.ts +447 -0
- package/src/lib/instance-paths.ts +75 -10
- package/src/lib/workspace-policy.ts +153 -10
- package/src/middleware/request-actor.ts +67 -2
- package/src/realtime/hub.ts +31 -2
- package/src/routes/agents.ts +146 -107
- package/src/routes/auth.ts +54 -0
- package/src/routes/companies.ts +2 -0
- package/src/routes/governance.ts +8 -0
- package/src/routes/issues.ts +23 -10
- package/src/routes/projects.ts +361 -63
- package/src/routes/templates.ts +439 -0
- package/src/scripts/backfill-project-workspaces.ts +61 -24
- package/src/scripts/db-init.ts +7 -1
- package/src/scripts/onboard-seed.ts +140 -12
- package/src/security/actor-token.ts +133 -0
- package/src/security/deployment-mode.ts +73 -0
- package/src/server.ts +72 -4
- package/src/services/governance-service.ts +122 -15
- package/src/services/heartbeat-service.ts +136 -36
- package/src/services/plugin-runtime.ts +2 -2
- package/src/services/template-apply-service.ts +138 -0
- package/src/services/template-catalog.ts +325 -0
- package/src/services/template-preview-service.ts +78 -0
package/src/routes/projects.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
workspaceGithubRepo: parsed.data.workspaceGithubRepo
|
|
143
|
+
executionWorkspacePolicy: parsed.data.executionWorkspacePolicy ?? null
|
|
73
144
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
205
|
-
if (
|
|
206
|
-
|
|
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
|
}
|