agent-tower 0.5.3 → 0.5.4-beta.0
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/dist/mcp/http-client.d.ts +31 -1
- package/dist/mcp/http-client.d.ts.map +1 -1
- package/dist/mcp/http-client.js +14 -2
- package/dist/mcp/http-client.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +62 -8
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/__tests__/workspaces.test.d.ts +2 -0
- package/dist/mcp/tools/__tests__/workspaces.test.d.ts.map +1 -0
- package/dist/mcp/tools/__tests__/workspaces.test.js +72 -0
- package/dist/mcp/tools/__tests__/workspaces.test.js.map +1 -0
- package/dist/mcp/tools/workspaces.d.ts +9 -1
- package/dist/mcp/tools/workspaces.d.ts.map +1 -1
- package/dist/mcp/tools/workspaces.js +96 -2
- package/dist/mcp/tools/workspaces.js.map +1 -1
- package/dist/mcp/types.d.ts +46 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/mcp/types.js +23 -0
- package/dist/mcp/types.js.map +1 -1
- package/dist/routes/__tests__/team-runs-merge.test.d.ts +2 -0
- package/dist/routes/__tests__/team-runs-merge.test.d.ts.map +1 -0
- package/dist/routes/__tests__/team-runs-merge.test.js +156 -0
- package/dist/routes/__tests__/team-runs-merge.test.js.map +1 -0
- package/dist/routes/__tests__/team-runs-private-messages.test.d.ts +2 -0
- package/dist/routes/__tests__/team-runs-private-messages.test.d.ts.map +1 -0
- package/dist/routes/__tests__/team-runs-private-messages.test.js +82 -0
- package/dist/routes/__tests__/team-runs-private-messages.test.js.map +1 -0
- package/dist/routes/team-runs.d.ts +2 -0
- package/dist/routes/team-runs.d.ts.map +1 -1
- package/dist/routes/team-runs.js +48 -1
- package/dist/routes/team-runs.js.map +1 -1
- package/dist/routes/workspaces.d.ts.map +1 -1
- package/dist/routes/workspaces.js +43 -4
- package/dist/routes/workspaces.js.map +1 -1
- package/dist/services/__tests__/session-manager.team-run.test.js +75 -0
- package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -1
- package/dist/services/__tests__/team-reconciler.service.test.js +127 -8
- package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -1
- package/dist/services/__tests__/team-run.service.test.js +172 -0
- package/dist/services/__tests__/team-run.service.test.js.map +1 -1
- package/dist/services/__tests__/team-scheduler.service.test.js +397 -0
- package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -1
- package/dist/services/__tests__/workspace.service.test.js +959 -3
- package/dist/services/__tests__/workspace.service.test.js.map +1 -1
- package/dist/services/session-manager.d.ts.map +1 -1
- package/dist/services/session-manager.js +16 -0
- package/dist/services/session-manager.js.map +1 -1
- package/dist/services/team-run.service.d.ts +5 -1
- package/dist/services/team-run.service.d.ts.map +1 -1
- package/dist/services/team-run.service.js +63 -2
- package/dist/services/team-run.service.js.map +1 -1
- package/dist/services/team-scheduler.service.d.ts +21 -1
- package/dist/services/team-scheduler.service.d.ts.map +1 -1
- package/dist/services/team-scheduler.service.js +230 -8
- package/dist/services/team-scheduler.service.js.map +1 -1
- package/dist/services/workspace.service.d.ts +56 -1
- package/dist/services/workspace.service.d.ts.map +1 -1
- package/dist/services/workspace.service.js +837 -4
- package/dist/services/workspace.service.js.map +1 -1
- package/node_modules/@agent-tower/shared/dist/types.d.ts +168 -0
- package/node_modules/@agent-tower/shared/dist/types.d.ts.map +1 -1
- package/node_modules/@agent-tower/shared/dist/types.js.map +1 -1
- package/node_modules/@prisma/client/.prisma/client/edge.js +39 -5
- package/node_modules/@prisma/client/.prisma/client/index-browser.js +34 -0
- package/node_modules/@prisma/client/.prisma/client/index.d.ts +2871 -158
- package/node_modules/@prisma/client/.prisma/client/index.js +39 -5
- package/node_modules/@prisma/client/.prisma/client/package.json +1 -1
- package/node_modules/@prisma/client/.prisma/client/schema.prisma +81 -30
- package/node_modules/@prisma/client/.prisma/client/wasm.js +34 -0
- package/package.json +1 -1
- package/prisma/migrations/20260618010000_add_workspace_verdicts/migration.sql +27 -0
- package/prisma/migrations/20260702000000_add_teamrun_target_commit_fields/migration.sql +38 -0
- package/prisma/schema.prisma +51 -0
|
@@ -17,6 +17,18 @@ import { isMainDirectoryWorkspace, isWorktreeWorkspace, } from './workspace-kind
|
|
|
17
17
|
const DEFAULT_IDLE_THRESHOLD_HOURS = 24;
|
|
18
18
|
const WORKSPACE_READY_RETRY_COUNT = 20;
|
|
19
19
|
const WORKSPACE_READY_RETRY_DELAY_MS = 50;
|
|
20
|
+
const DEFAULT_TEAM_MEMBER_CAPABILITIES = {
|
|
21
|
+
readRoom: false,
|
|
22
|
+
postRoomMessage: false,
|
|
23
|
+
mentionMembers: false,
|
|
24
|
+
stopMemberWork: false,
|
|
25
|
+
markReadyForReview: false,
|
|
26
|
+
readFiles: false,
|
|
27
|
+
writeFiles: false,
|
|
28
|
+
runCommands: false,
|
|
29
|
+
readDiff: false,
|
|
30
|
+
mergeWorkspace: false,
|
|
31
|
+
};
|
|
20
32
|
const execAsync = promisify(exec);
|
|
21
33
|
/** 过滤条件:只返回用户可见的 CHAT session,且不在 workspace 热路径携带 prompt/logSnapshot。 */
|
|
22
34
|
const visibleSessionsFilter = {
|
|
@@ -35,6 +47,7 @@ const visibleSessionsFilter = {
|
|
|
35
47
|
},
|
|
36
48
|
};
|
|
37
49
|
const activeSessionStatuses = [SessionStatus.PENDING, SessionStatus.RUNNING];
|
|
50
|
+
const activeInvocationStatuses = ['QUEUED', 'RUNNING', 'SESSION_ENDED', 'WAITING_ROOM_REPLY'];
|
|
38
51
|
const finalChildWorkspaceStatuses = [WorkspaceStatus.MERGED, WorkspaceStatus.ABANDONED];
|
|
39
52
|
function normalizeCreateOptions(input) {
|
|
40
53
|
if (typeof input === 'string') {
|
|
@@ -76,6 +89,14 @@ function branchFromOptions(workspaceId, options) {
|
|
|
76
89
|
function teamRunBranchPrefix(teamRunId) {
|
|
77
90
|
return `at/team/${teamRunId.slice(0, 8)}`;
|
|
78
91
|
}
|
|
92
|
+
function targetExecutionBranchName(input) {
|
|
93
|
+
return `${teamRunBranchPrefix(input.teamRunId)}/target/${input.targetPurpose.toLowerCase()}-${input.memberId.slice(0, 8)}-${input.targetHeadSha.slice(0, 12)}`;
|
|
94
|
+
}
|
|
95
|
+
function assertFullCommitSha(value) {
|
|
96
|
+
if (!/^[0-9a-f]{40}$/i.test(value)) {
|
|
97
|
+
throw new ServiceError('targetHeadSha must be a full 40-character commit SHA', 'TARGET_HEAD_SHA_INVALID', 400);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
79
100
|
function sleep(ms) {
|
|
80
101
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
81
102
|
}
|
|
@@ -91,6 +112,70 @@ function mainDirectoryGitStatus() {
|
|
|
91
112
|
untrackedCount: 0,
|
|
92
113
|
};
|
|
93
114
|
}
|
|
115
|
+
function toIso(date) {
|
|
116
|
+
return date.toISOString();
|
|
117
|
+
}
|
|
118
|
+
function serializeWorkspaceVerdict(verdict) {
|
|
119
|
+
return {
|
|
120
|
+
id: verdict.id,
|
|
121
|
+
workspaceId: verdict.workspaceId,
|
|
122
|
+
teamRunId: verdict.teamRunId,
|
|
123
|
+
kind: verdict.kind,
|
|
124
|
+
verdict: verdict.verdict,
|
|
125
|
+
reviewedSha: verdict.reviewedSha,
|
|
126
|
+
reviewerMemberId: verdict.reviewerMemberId,
|
|
127
|
+
reason: verdict.reason,
|
|
128
|
+
sequence: verdict.sequence,
|
|
129
|
+
createdAt: toIso(verdict.createdAt),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function parseCapabilities(value) {
|
|
133
|
+
if (!value) {
|
|
134
|
+
return DEFAULT_TEAM_MEMBER_CAPABILITIES;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return {
|
|
138
|
+
...DEFAULT_TEAM_MEMBER_CAPABILITIES,
|
|
139
|
+
...JSON.parse(value),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return DEFAULT_TEAM_MEMBER_CAPABILITIES;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function blocker(code, severity, message, details) {
|
|
147
|
+
return details
|
|
148
|
+
? { code, severity, message, details }
|
|
149
|
+
: { code, severity, message };
|
|
150
|
+
}
|
|
151
|
+
function serializeLatestVerdict(verdict, currentHeadSha, ownerMemberId) {
|
|
152
|
+
if (!verdict) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
id: verdict.id,
|
|
157
|
+
verdict: verdict.verdict,
|
|
158
|
+
reviewedSha: verdict.reviewedSha,
|
|
159
|
+
reviewerMemberId: verdict.reviewerMemberId,
|
|
160
|
+
reason: verdict.reason,
|
|
161
|
+
sequence: verdict.sequence,
|
|
162
|
+
createdAt: toIso(verdict.createdAt),
|
|
163
|
+
matchesHead: currentHeadSha !== null && verdict.reviewedSha === currentHeadSha,
|
|
164
|
+
isSelfReview: verdict.reviewerMemberId === ownerMemberId,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function resultSummary(results, requested = results.length) {
|
|
168
|
+
return {
|
|
169
|
+
requested,
|
|
170
|
+
considered: results.length,
|
|
171
|
+
merged: results.filter((item) => item.status === 'MERGED').length,
|
|
172
|
+
alreadyMerged: results.filter((item) => item.status === 'ALREADY_MERGED').length,
|
|
173
|
+
wouldMerge: results.filter((item) => item.status === 'WOULD_MERGE').length,
|
|
174
|
+
skipped: results.filter((item) => item.status === 'SKIPPED').length,
|
|
175
|
+
conflicts: results.filter((item) => item.status === 'CONFLICT').length,
|
|
176
|
+
failed: results.filter((item) => item.status === 'FAILED').length,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
94
179
|
export class WorkspaceService {
|
|
95
180
|
lockService;
|
|
96
181
|
static mainWorkspaceClaims = new Map();
|
|
@@ -138,6 +223,593 @@ export class WorkspaceService {
|
|
|
138
223
|
orderBy: { createdAt: 'desc' },
|
|
139
224
|
});
|
|
140
225
|
}
|
|
226
|
+
async listVerdicts(workspaceId) {
|
|
227
|
+
const workspace = await prisma.workspace.findUnique({
|
|
228
|
+
where: { id: workspaceId },
|
|
229
|
+
include: { task: true },
|
|
230
|
+
});
|
|
231
|
+
if (!workspace) {
|
|
232
|
+
throw new NotFoundError('Workspace', workspaceId);
|
|
233
|
+
}
|
|
234
|
+
ensureTaskNotDeleted(workspace.task);
|
|
235
|
+
const verdicts = await prisma.workspaceVerdict.findMany({
|
|
236
|
+
where: { workspaceId },
|
|
237
|
+
orderBy: { createdAt: 'desc' },
|
|
238
|
+
});
|
|
239
|
+
return verdicts.map(serializeWorkspaceVerdict);
|
|
240
|
+
}
|
|
241
|
+
async listTeamRunMergeableWorkspaces(teamRunId) {
|
|
242
|
+
const context = await this.loadTeamRunMergeableContext(teamRunId);
|
|
243
|
+
return this.buildMergeableWorkspacesResponse(context);
|
|
244
|
+
}
|
|
245
|
+
async mergeTeamRunMembers(teamRunId, options) {
|
|
246
|
+
await this.assertTeamRunMergeInvocation(teamRunId, options.invocationId, options.requesterMemberId);
|
|
247
|
+
const initialContext = await this.loadTeamRunMergeableContext(teamRunId);
|
|
248
|
+
ensureProjectIsMutable(initialContext.project, 'merge TeamRun member workspaces');
|
|
249
|
+
const dryRun = options.dryRun === true;
|
|
250
|
+
const stopOnConflict = options.stopOnConflict === true;
|
|
251
|
+
const hasExplicitWorkspaceIds = Array.isArray(options.workspaceIds);
|
|
252
|
+
const requestedWorkspaceIds = hasExplicitWorkspaceIds
|
|
253
|
+
? [...new Set(options.workspaceIds)]
|
|
254
|
+
: undefined;
|
|
255
|
+
const requestedCount = requestedWorkspaceIds?.length ?? initialContext.workspaces.length;
|
|
256
|
+
const run = async () => {
|
|
257
|
+
const context = await this.loadTeamRunMergeableContext(teamRunId);
|
|
258
|
+
const response = await this.buildMergeableWorkspacesResponse(context);
|
|
259
|
+
const results = [];
|
|
260
|
+
const selected = [];
|
|
261
|
+
if (requestedWorkspaceIds) {
|
|
262
|
+
const itemsById = new Map(response.workspaces.map((item) => [item.workspaceId, item]));
|
|
263
|
+
for (const workspaceId of requestedWorkspaceIds) {
|
|
264
|
+
const item = itemsById.get(workspaceId);
|
|
265
|
+
if (item) {
|
|
266
|
+
selected.push(item);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
results.push({
|
|
270
|
+
workspaceId,
|
|
271
|
+
ownerMemberId: null,
|
|
272
|
+
status: 'SKIPPED',
|
|
273
|
+
code: 'INVALID_WORKSPACE_STATE',
|
|
274
|
+
message: 'Workspace is not a mergeable dedicated TeamRun child workspace',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
selected.push(...response.workspaces);
|
|
281
|
+
}
|
|
282
|
+
for (const item of selected) {
|
|
283
|
+
if (item.status === WorkspaceStatus.MERGED) {
|
|
284
|
+
results.push({
|
|
285
|
+
workspaceId: item.workspaceId,
|
|
286
|
+
ownerMemberId: item.owner.memberId,
|
|
287
|
+
status: dryRun ? 'ALREADY_MERGED' : 'ALREADY_MERGED',
|
|
288
|
+
sha: item.headSha ?? undefined,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!item.mergeReady) {
|
|
293
|
+
const firstBlocking = item.blockers.find((entry) => entry.severity === 'BLOCKING') ?? item.blockers[0];
|
|
294
|
+
results.push({
|
|
295
|
+
workspaceId: item.workspaceId,
|
|
296
|
+
ownerMemberId: item.owner.memberId,
|
|
297
|
+
status: 'SKIPPED',
|
|
298
|
+
code: firstBlocking?.code ?? 'WORKSPACE_NOT_ACTIVE',
|
|
299
|
+
message: firstBlocking?.message ?? 'Workspace is not ready to merge',
|
|
300
|
+
blockers: item.blockers,
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (dryRun) {
|
|
305
|
+
results.push({
|
|
306
|
+
workspaceId: item.workspaceId,
|
|
307
|
+
ownerMemberId: item.owner.memberId,
|
|
308
|
+
status: 'WOULD_MERGE',
|
|
309
|
+
sha: item.headSha ?? undefined,
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const sha = await this.merge(item.workspaceId, {
|
|
315
|
+
invocationId: options.invocationId,
|
|
316
|
+
lockOwnerId: options.invocationId,
|
|
317
|
+
requesterMemberId: options.requesterMemberId,
|
|
318
|
+
});
|
|
319
|
+
results.push({
|
|
320
|
+
workspaceId: item.workspaceId,
|
|
321
|
+
ownerMemberId: item.owner.memberId,
|
|
322
|
+
status: 'MERGED',
|
|
323
|
+
sha,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
const result = this.mergeErrorToResult(item, error);
|
|
328
|
+
results.push(result);
|
|
329
|
+
if (stopOnConflict && result.status === 'CONFLICT') {
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
teamRunId: response.teamRunId,
|
|
336
|
+
taskId: response.taskId,
|
|
337
|
+
projectId: response.projectId,
|
|
338
|
+
mainWorkspaceId: response.mainWorkspace.id,
|
|
339
|
+
dryRun,
|
|
340
|
+
stopOnConflict,
|
|
341
|
+
requestedWorkspaceIds,
|
|
342
|
+
summary: resultSummary(results, requestedCount),
|
|
343
|
+
results,
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
if (dryRun) {
|
|
347
|
+
return run();
|
|
348
|
+
}
|
|
349
|
+
return this.withProjectMergeLock(initialContext.projectId, options.invocationId, run);
|
|
350
|
+
}
|
|
351
|
+
async recordVerdict(workspaceId, input) {
|
|
352
|
+
if (input.kind === 'REVIEW' && !['APPROVED', 'CHANGES_REQUESTED'].includes(input.verdict)) {
|
|
353
|
+
throw new ServiceError('Invalid review verdict', 'INVALID_WORKSPACE_VERDICT', 400);
|
|
354
|
+
}
|
|
355
|
+
if (input.kind === 'TEST' && !['PASSED', 'FAILED'].includes(input.verdict)) {
|
|
356
|
+
throw new ServiceError('Invalid test verdict', 'INVALID_WORKSPACE_VERDICT', 400);
|
|
357
|
+
}
|
|
358
|
+
if (!input.reviewedSha.trim()) {
|
|
359
|
+
throw new ServiceError('reviewedSha is required', 'VALIDATION_ERROR', 400);
|
|
360
|
+
}
|
|
361
|
+
const workspace = await prisma.workspace.findUnique({
|
|
362
|
+
where: { id: workspaceId },
|
|
363
|
+
include: { task: { include: { teamRun: true } } },
|
|
364
|
+
});
|
|
365
|
+
if (!workspace) {
|
|
366
|
+
throw new NotFoundError('Workspace', workspaceId);
|
|
367
|
+
}
|
|
368
|
+
ensureTaskNotDeleted(workspace.task);
|
|
369
|
+
const teamRun = workspace.task.teamRun;
|
|
370
|
+
if (!teamRun) {
|
|
371
|
+
throw new ServiceError('Workspace verdicts are only supported for TeamRun workspaces', 'WORKSPACE_VERDICT_TEAM_RUN_REQUIRED', 400);
|
|
372
|
+
}
|
|
373
|
+
this.assertWorktreeWorkspace(workspace);
|
|
374
|
+
const reviewedSha = input.reviewedSha.trim();
|
|
375
|
+
const currentHeadSha = await this.getHeadSha(workspace.worktreePath);
|
|
376
|
+
const expectedTargetHeadSha = input.expectedTargetHeadSha?.trim() || null;
|
|
377
|
+
if (expectedTargetHeadSha && currentHeadSha !== expectedTargetHeadSha) {
|
|
378
|
+
throw new ServiceError('Targeted workspace verdict is stale because the source workspace HEAD changed', 'TARGET_VERDICT_STALE', 409);
|
|
379
|
+
}
|
|
380
|
+
if (expectedTargetHeadSha && reviewedSha !== expectedTargetHeadSha) {
|
|
381
|
+
throw new ServiceError('Targeted workspace verdict SHA must match the invocation targetHeadSha', 'TARGET_VERDICT_SHA_MISMATCH', 409);
|
|
382
|
+
}
|
|
383
|
+
if (reviewedSha !== currentHeadSha) {
|
|
384
|
+
throw new ServiceError('Workspace verdict SHA must match the current workspace HEAD', 'WORKSPACE_VERDICT_SHA_MISMATCH', 409);
|
|
385
|
+
}
|
|
386
|
+
const reviewer = await prisma.teamMember.findFirst({
|
|
387
|
+
where: {
|
|
388
|
+
id: input.reviewerMemberId,
|
|
389
|
+
teamRunId: teamRun.id,
|
|
390
|
+
membershipStatus: { not: 'REMOVED' },
|
|
391
|
+
},
|
|
392
|
+
select: { id: true, capabilities: true },
|
|
393
|
+
});
|
|
394
|
+
if (!reviewer) {
|
|
395
|
+
throw new ServiceError('Reviewer is not an active member of this TeamRun', 'WORKSPACE_VERDICT_REVIEWER_INVALID', 403);
|
|
396
|
+
}
|
|
397
|
+
const reviewerCapabilities = parseCapabilities(reviewer.capabilities);
|
|
398
|
+
const requiredCapability = input.kind === 'REVIEW' ? 'readDiff' : 'runCommands';
|
|
399
|
+
if (reviewerCapabilities[requiredCapability] !== true) {
|
|
400
|
+
throw new ServiceError(`Current TeamRun member lacks required capabilities: ${requiredCapability}`, 'TEAM_RUN_MEMBER_CAPABILITY_REQUIRED', 403);
|
|
401
|
+
}
|
|
402
|
+
const verdict = await prisma.$transaction(async (tx) => {
|
|
403
|
+
const latest = await tx.workspaceVerdict.findFirst({
|
|
404
|
+
where: {
|
|
405
|
+
workspaceId,
|
|
406
|
+
kind: input.kind,
|
|
407
|
+
},
|
|
408
|
+
orderBy: { sequence: 'desc' },
|
|
409
|
+
select: { sequence: true },
|
|
410
|
+
});
|
|
411
|
+
return tx.workspaceVerdict.create({
|
|
412
|
+
data: {
|
|
413
|
+
workspaceId,
|
|
414
|
+
teamRunId: teamRun.id,
|
|
415
|
+
kind: input.kind,
|
|
416
|
+
verdict: input.verdict,
|
|
417
|
+
reviewedSha,
|
|
418
|
+
reviewerMemberId: input.reviewerMemberId,
|
|
419
|
+
reason: input.reason ?? null,
|
|
420
|
+
sequence: (latest?.sequence ?? 0) + 1,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
return serializeWorkspaceVerdict(verdict);
|
|
425
|
+
}
|
|
426
|
+
async resolveInvocationMemberForWorkspace(workspaceId, invocationId) {
|
|
427
|
+
if (!invocationId) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const workspace = await prisma.workspace.findUnique({
|
|
431
|
+
where: { id: workspaceId },
|
|
432
|
+
include: { task: { include: { teamRun: true } } },
|
|
433
|
+
});
|
|
434
|
+
if (!workspace) {
|
|
435
|
+
throw new NotFoundError('Workspace', workspaceId);
|
|
436
|
+
}
|
|
437
|
+
ensureTaskNotDeleted(workspace.task);
|
|
438
|
+
const teamRunId = workspace.task.teamRun?.id;
|
|
439
|
+
if (!teamRunId) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
const invocation = await prisma.agentInvocation.findFirst({
|
|
443
|
+
where: {
|
|
444
|
+
id: invocationId,
|
|
445
|
+
teamRunId,
|
|
446
|
+
OR: [
|
|
447
|
+
{ workspaceId },
|
|
448
|
+
{ targetSourceWorkspaceId: workspaceId },
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
select: {
|
|
452
|
+
id: true,
|
|
453
|
+
memberId: true,
|
|
454
|
+
targetHeadSha: true,
|
|
455
|
+
targetSourceWorkspaceId: true,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
return invocation
|
|
459
|
+
? {
|
|
460
|
+
teamRunId,
|
|
461
|
+
memberId: invocation.memberId,
|
|
462
|
+
invocationId: invocation.id,
|
|
463
|
+
targetHeadSha: invocation.targetSourceWorkspaceId === workspaceId
|
|
464
|
+
? invocation.targetHeadSha
|
|
465
|
+
: null,
|
|
466
|
+
targetSourceWorkspaceId: invocation.targetSourceWorkspaceId,
|
|
467
|
+
}
|
|
468
|
+
: null;
|
|
469
|
+
}
|
|
470
|
+
async resolveInvocationMemberForTeamRun(teamRunId, invocationId) {
|
|
471
|
+
if (!invocationId) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
const invocation = await prisma.agentInvocation.findFirst({
|
|
475
|
+
where: {
|
|
476
|
+
id: invocationId,
|
|
477
|
+
teamRunId,
|
|
478
|
+
},
|
|
479
|
+
select: {
|
|
480
|
+
id: true,
|
|
481
|
+
memberId: true,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
return invocation
|
|
485
|
+
? { teamRunId, memberId: invocation.memberId, invocationId: invocation.id }
|
|
486
|
+
: null;
|
|
487
|
+
}
|
|
488
|
+
async assertTeamRunMergeInvocation(teamRunId, invocationId, requesterMemberId) {
|
|
489
|
+
if (!invocationId) {
|
|
490
|
+
throw new ServiceError('TeamRun member merge requires an agent invocation identity', 'TEAM_RUN_MERGE_INVOCATION_REQUIRED', 403);
|
|
491
|
+
}
|
|
492
|
+
const identity = await this.resolveInvocationMemberForTeamRun(teamRunId, invocationId);
|
|
493
|
+
if (!identity) {
|
|
494
|
+
throw new ServiceError('Agent invocation identity is invalid for this TeamRun', 'FORBIDDEN', 403);
|
|
495
|
+
}
|
|
496
|
+
if (requesterMemberId && requesterMemberId !== identity.memberId) {
|
|
497
|
+
throw new ServiceError('Requester member does not match the agent invocation identity', 'FORBIDDEN', 403);
|
|
498
|
+
}
|
|
499
|
+
const member = await prisma.teamMember.findFirst({
|
|
500
|
+
where: {
|
|
501
|
+
id: identity.memberId,
|
|
502
|
+
teamRunId,
|
|
503
|
+
membershipStatus: { not: 'REMOVED' },
|
|
504
|
+
},
|
|
505
|
+
select: {
|
|
506
|
+
capabilities: true,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
if (!member) {
|
|
510
|
+
throw new ServiceError('Current TeamRun member was not found', 'FORBIDDEN', 403);
|
|
511
|
+
}
|
|
512
|
+
const capabilities = parseCapabilities(member.capabilities);
|
|
513
|
+
if (capabilities.mergeWorkspace !== true) {
|
|
514
|
+
throw new ServiceError('Current TeamRun member lacks required capabilities: mergeWorkspace', 'TEAM_RUN_MEMBER_CAPABILITY_REQUIRED', 403);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async loadTeamRunMergeableContext(teamRunId) {
|
|
518
|
+
const teamRun = await prisma.teamRun.findUnique({
|
|
519
|
+
where: { id: teamRunId },
|
|
520
|
+
include: {
|
|
521
|
+
task: { include: { project: true } },
|
|
522
|
+
mainWorkspace: true,
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
if (!teamRun) {
|
|
526
|
+
throw new NotFoundError('TeamRun', teamRunId);
|
|
527
|
+
}
|
|
528
|
+
ensureTaskNotDeleted(teamRun.task);
|
|
529
|
+
const mainWorkspace = teamRun.mainWorkspace;
|
|
530
|
+
const workspaces = mainWorkspace
|
|
531
|
+
? await prisma.workspace.findMany({
|
|
532
|
+
where: {
|
|
533
|
+
taskId: teamRun.taskId,
|
|
534
|
+
parentWorkspaceId: mainWorkspace.id,
|
|
535
|
+
ownerMemberId: { not: null },
|
|
536
|
+
},
|
|
537
|
+
include: {
|
|
538
|
+
ownerMember: true,
|
|
539
|
+
task: { include: { project: true, teamRun: true } },
|
|
540
|
+
},
|
|
541
|
+
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
|
542
|
+
})
|
|
543
|
+
: [];
|
|
544
|
+
const dedicatedWorkspaces = workspaces.filter((workspace) => {
|
|
545
|
+
return Boolean(workspace.ownerMember
|
|
546
|
+
&& workspace.ownerMemberId
|
|
547
|
+
&& workspace.parentWorkspaceId
|
|
548
|
+
&& workspace.task.teamRun);
|
|
549
|
+
});
|
|
550
|
+
const workspaceIds = dedicatedWorkspaces.map((workspace) => workspace.id);
|
|
551
|
+
const ownerMemberIds = dedicatedWorkspaces.map((workspace) => workspace.ownerMemberId);
|
|
552
|
+
const [verdicts, ownerInvocations, parentActiveWriteSession, mainHeadSha] = await Promise.all([
|
|
553
|
+
workspaceIds.length > 0
|
|
554
|
+
? prisma.workspaceVerdict.findMany({
|
|
555
|
+
where: {
|
|
556
|
+
teamRunId,
|
|
557
|
+
workspaceId: { in: workspaceIds },
|
|
558
|
+
kind: { in: ['REVIEW', 'TEST'] },
|
|
559
|
+
},
|
|
560
|
+
orderBy: [
|
|
561
|
+
{ workspaceId: 'asc' },
|
|
562
|
+
{ kind: 'asc' },
|
|
563
|
+
{ sequence: 'desc' },
|
|
564
|
+
{ createdAt: 'desc' },
|
|
565
|
+
{ id: 'desc' },
|
|
566
|
+
],
|
|
567
|
+
})
|
|
568
|
+
: Promise.resolve([]),
|
|
569
|
+
ownerMemberIds.length > 0
|
|
570
|
+
? prisma.agentInvocation.findMany({
|
|
571
|
+
where: {
|
|
572
|
+
teamRunId,
|
|
573
|
+
memberId: { in: ownerMemberIds },
|
|
574
|
+
status: { in: activeInvocationStatuses },
|
|
575
|
+
},
|
|
576
|
+
select: { memberId: true },
|
|
577
|
+
})
|
|
578
|
+
: Promise.resolve([]),
|
|
579
|
+
mainWorkspace
|
|
580
|
+
? prisma.session.findFirst({
|
|
581
|
+
where: {
|
|
582
|
+
workspaceId: mainWorkspace.id,
|
|
583
|
+
status: { in: activeSessionStatuses },
|
|
584
|
+
purpose: SessionPurpose.CHAT,
|
|
585
|
+
},
|
|
586
|
+
select: { id: true },
|
|
587
|
+
})
|
|
588
|
+
: Promise.resolve(null),
|
|
589
|
+
mainWorkspace?.worktreePath
|
|
590
|
+
? this.getHeadSha(mainWorkspace.worktreePath).catch(() => null)
|
|
591
|
+
: Promise.resolve(null),
|
|
592
|
+
]);
|
|
593
|
+
const latestReviews = new Map();
|
|
594
|
+
const latestTests = new Map();
|
|
595
|
+
for (const verdict of verdicts) {
|
|
596
|
+
const key = verdict.workspaceId;
|
|
597
|
+
if (verdict.kind === 'REVIEW' && !latestReviews.has(key)) {
|
|
598
|
+
latestReviews.set(key, verdict);
|
|
599
|
+
}
|
|
600
|
+
if (verdict.kind === 'TEST' && !latestTests.has(key)) {
|
|
601
|
+
latestTests.set(key, verdict);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
teamRunId,
|
|
606
|
+
taskId: teamRun.taskId,
|
|
607
|
+
projectId: teamRun.task.projectId,
|
|
608
|
+
project: {
|
|
609
|
+
name: teamRun.task.project.name,
|
|
610
|
+
repoPath: teamRun.task.project.repoPath,
|
|
611
|
+
mainBranch: teamRun.task.project.mainBranch,
|
|
612
|
+
archivedAt: teamRun.task.project.archivedAt,
|
|
613
|
+
repoDeletedAt: teamRun.task.project.repoDeletedAt,
|
|
614
|
+
},
|
|
615
|
+
mainWorkspace: {
|
|
616
|
+
id: mainWorkspace?.id ?? null,
|
|
617
|
+
branchName: mainWorkspace?.branchName ?? null,
|
|
618
|
+
status: mainWorkspace?.status ?? null,
|
|
619
|
+
worktreePath: mainWorkspace?.worktreePath ?? null,
|
|
620
|
+
headSha: mainHeadSha,
|
|
621
|
+
hasActiveWriteSession: Boolean(parentActiveWriteSession),
|
|
622
|
+
},
|
|
623
|
+
generatedAt: new Date().toISOString(),
|
|
624
|
+
workspaces: dedicatedWorkspaces,
|
|
625
|
+
latestReviews,
|
|
626
|
+
latestTests,
|
|
627
|
+
ownerActiveMemberIds: new Set(ownerInvocations.map((invocation) => invocation.memberId)),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
async buildMergeableWorkspacesResponse(context) {
|
|
631
|
+
const workspaceOrder = new Map(context.workspaces.map((workspace, index) => [workspace.id, index]));
|
|
632
|
+
const items = (await Promise.all(context.workspaces.map((workspace) => this.buildMergeableWorkspaceItem(context, workspace))))
|
|
633
|
+
.sort((left, right) => {
|
|
634
|
+
const leftBehind = left.git.behindMain ?? Number.MAX_SAFE_INTEGER;
|
|
635
|
+
const rightBehind = right.git.behindMain ?? Number.MAX_SAFE_INTEGER;
|
|
636
|
+
if (leftBehind !== rightBehind)
|
|
637
|
+
return leftBehind - rightBehind;
|
|
638
|
+
const leftOrder = workspaceOrder.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER;
|
|
639
|
+
const rightOrder = workspaceOrder.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER;
|
|
640
|
+
if (leftOrder !== rightOrder)
|
|
641
|
+
return leftOrder - rightOrder;
|
|
642
|
+
return left.workspaceId.localeCompare(right.workspaceId);
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
teamRunId: context.teamRunId,
|
|
646
|
+
taskId: context.taskId,
|
|
647
|
+
projectId: context.projectId,
|
|
648
|
+
mainWorkspace: {
|
|
649
|
+
id: context.mainWorkspace.id,
|
|
650
|
+
branchName: context.mainWorkspace.branchName,
|
|
651
|
+
status: context.mainWorkspace.status,
|
|
652
|
+
headSha: context.mainWorkspace.headSha,
|
|
653
|
+
hasActiveWriteSession: context.mainWorkspace.hasActiveWriteSession,
|
|
654
|
+
},
|
|
655
|
+
generatedAt: context.generatedAt,
|
|
656
|
+
workspaces: items,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
async buildMergeableWorkspaceItem(context, workspace) {
|
|
660
|
+
const blockers = [];
|
|
661
|
+
let headSha = null;
|
|
662
|
+
let gitStatus = null;
|
|
663
|
+
let gitStatusAvailable = false;
|
|
664
|
+
const worktreeManager = new WorktreeManager(context.project.repoPath);
|
|
665
|
+
const isWorktree = workspace.workspaceKind === WorkspaceKind.WORKTREE;
|
|
666
|
+
if (!isWorktree) {
|
|
667
|
+
blockers.push(blocker('WORKSPACE_GIT_UNAVAILABLE', 'BLOCKING', 'Workspace git operations are unavailable for this workspace type'));
|
|
668
|
+
}
|
|
669
|
+
else if (!workspace.worktreePath) {
|
|
670
|
+
blockers.push(blocker('MISSING_HEAD_SHA', 'BLOCKING', 'Workspace worktree path is missing'));
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
try {
|
|
674
|
+
headSha = await this.getHeadSha(workspace.worktreePath);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
blockers.push(blocker('MISSING_HEAD_SHA', 'BLOCKING', 'Cannot read workspace HEAD SHA'));
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
gitStatus = await worktreeManager.getGitOperationStatus(workspace.worktreePath, context.mainWorkspace.branchName ?? workspace.baseBranch ?? context.project.mainBranch);
|
|
681
|
+
gitStatusAvailable = true;
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
blockers.push(blocker('GIT_STATUS_UNAVAILABLE', 'BLOCKING', error instanceof Error ? error.message : 'Cannot read workspace git status'));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const latestReview = serializeLatestVerdict(context.latestReviews.get(workspace.id), headSha, workspace.ownerMemberId);
|
|
688
|
+
const latestTest = serializeLatestVerdict(context.latestTests.get(workspace.id), headSha, workspace.ownerMemberId);
|
|
689
|
+
const ownerHasActiveInvocation = context.ownerActiveMemberIds.has(workspace.ownerMemberId);
|
|
690
|
+
const clean = gitStatusAvailable && gitStatus
|
|
691
|
+
? gitStatus.operation === 'idle'
|
|
692
|
+
&& !gitStatus.hasUncommittedChanges
|
|
693
|
+
&& gitStatus.untrackedCount === 0
|
|
694
|
+
: null;
|
|
695
|
+
if (workspace.status === WorkspaceStatus.MERGED) {
|
|
696
|
+
blockers.push(blocker('WORKSPACE_ALREADY_MERGED', 'WARNING', 'Workspace has already been merged'));
|
|
697
|
+
}
|
|
698
|
+
else if (workspace.status === WorkspaceStatus.ABANDONED) {
|
|
699
|
+
blockers.push(blocker('WORKSPACE_ABANDONED', 'BLOCKING', 'Workspace has been abandoned'));
|
|
700
|
+
}
|
|
701
|
+
else if (workspace.status === WorkspaceStatus.HIBERNATED) {
|
|
702
|
+
blockers.push(blocker('WORKSPACE_HIBERNATED', 'BLOCKING', 'Workspace is hibernated'));
|
|
703
|
+
}
|
|
704
|
+
else if (workspace.status !== WorkspaceStatus.ACTIVE) {
|
|
705
|
+
blockers.push(blocker('INVALID_WORKSPACE_STATE', 'BLOCKING', `Workspace is in ${workspace.status} status`));
|
|
706
|
+
}
|
|
707
|
+
if (context.mainWorkspace.id !== workspace.parentWorkspaceId) {
|
|
708
|
+
blockers.push(blocker('INVALID_PARENT_WORKSPACE', 'BLOCKING', 'Workspace parent is not the TeamRun main workspace'));
|
|
709
|
+
}
|
|
710
|
+
if (context.mainWorkspace.status !== WorkspaceStatus.ACTIVE) {
|
|
711
|
+
blockers.push(blocker('INVALID_PARENT_WORKSPACE_STATE', 'BLOCKING', `TeamRun main workspace is in ${context.mainWorkspace.status ?? 'missing'} status`));
|
|
712
|
+
}
|
|
713
|
+
if (workspace.status === WorkspaceStatus.ACTIVE) {
|
|
714
|
+
if (!latestReview || latestReview.verdict !== 'APPROVED') {
|
|
715
|
+
blockers.push(blocker('REVIEW_REQUIRED', 'BLOCKING', 'TeamRun workspace merge requires an approved review'));
|
|
716
|
+
}
|
|
717
|
+
else if (!latestReview.matchesHead) {
|
|
718
|
+
blockers.push(blocker('REVIEW_STALE', 'BLOCKING', 'Approved review is stale because the workspace HEAD changed'));
|
|
719
|
+
}
|
|
720
|
+
else if (!latestReview.reviewerMemberId || latestReview.isSelfReview) {
|
|
721
|
+
blockers.push(blocker('SELF_REVIEW_FORBIDDEN', 'BLOCKING', 'Workspace owner cannot approve their own workspace for merge'));
|
|
722
|
+
}
|
|
723
|
+
if (ownerHasActiveInvocation) {
|
|
724
|
+
blockers.push(blocker('OWNER_HAS_ACTIVE_INVOCATION', 'BLOCKING', 'Cannot merge while the workspace owner has active work'));
|
|
725
|
+
}
|
|
726
|
+
if (context.mainWorkspace.hasActiveWriteSession) {
|
|
727
|
+
blockers.push(blocker('PARENT_WORKSPACE_HAS_ACTIVE_SESSION', 'BLOCKING', 'Cannot merge into parent workspace while it has an active write session'));
|
|
728
|
+
}
|
|
729
|
+
if (gitStatus?.operation === 'rebase') {
|
|
730
|
+
blockers.push(blocker('REBASE_IN_PROGRESS', 'BLOCKING', 'Workspace has a rebase in progress'));
|
|
731
|
+
}
|
|
732
|
+
if (gitStatus?.operation === 'merge') {
|
|
733
|
+
blockers.push(blocker('MERGE_CONFLICT', 'BLOCKING', 'Workspace has a merge in progress', {
|
|
734
|
+
conflictedFiles: gitStatus.conflictedFiles,
|
|
735
|
+
}));
|
|
736
|
+
}
|
|
737
|
+
if (gitStatus && (gitStatus.hasUncommittedChanges || gitStatus.untrackedCount > 0)) {
|
|
738
|
+
blockers.push(blocker('WORKTREE_DIRTY', 'BLOCKING', 'Workspace has uncommitted or untracked changes', {
|
|
739
|
+
uncommittedCount: gitStatus.uncommittedCount,
|
|
740
|
+
untrackedCount: gitStatus.untrackedCount,
|
|
741
|
+
}));
|
|
742
|
+
}
|
|
743
|
+
if ((gitStatus?.behind ?? 0) > 0) {
|
|
744
|
+
blockers.push(blocker('BEHIND_MAIN', 'WARNING', 'Workspace branch is behind the TeamRun main workspace', {
|
|
745
|
+
behindMain: gitStatus?.behind ?? 0,
|
|
746
|
+
aheadOfMain: gitStatus?.ahead ?? 0,
|
|
747
|
+
}));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
workspaceId: workspace.id,
|
|
752
|
+
owner: {
|
|
753
|
+
memberId: workspace.ownerMemberId,
|
|
754
|
+
name: workspace.ownerMember.name,
|
|
755
|
+
membershipStatus: workspace.ownerMember.membershipStatus,
|
|
756
|
+
},
|
|
757
|
+
status: workspace.status,
|
|
758
|
+
branchName: workspace.branchName,
|
|
759
|
+
baseBranch: workspace.baseBranch,
|
|
760
|
+
parentWorkspaceId: workspace.parentWorkspaceId,
|
|
761
|
+
headSha,
|
|
762
|
+
git: {
|
|
763
|
+
clean,
|
|
764
|
+
aheadOfMain: gitStatus?.ahead ?? null,
|
|
765
|
+
behindMain: gitStatus?.behind ?? null,
|
|
766
|
+
operation: gitStatus?.operation ?? null,
|
|
767
|
+
conflictedFiles: gitStatus?.conflictedFiles ?? [],
|
|
768
|
+
hasUncommittedChanges: gitStatus?.hasUncommittedChanges ?? null,
|
|
769
|
+
uncommittedCount: gitStatus?.uncommittedCount ?? null,
|
|
770
|
+
untrackedCount: gitStatus?.untrackedCount ?? null,
|
|
771
|
+
statusAvailable: gitStatusAvailable,
|
|
772
|
+
},
|
|
773
|
+
activity: {
|
|
774
|
+
ownerHasActiveInvocation,
|
|
775
|
+
parentHasActiveWriteSession: context.mainWorkspace.hasActiveWriteSession,
|
|
776
|
+
},
|
|
777
|
+
latestReview,
|
|
778
|
+
latestTest,
|
|
779
|
+
mergeReady: workspace.status === WorkspaceStatus.ACTIVE
|
|
780
|
+
&& blockers.every((entry) => entry.severity !== 'BLOCKING'),
|
|
781
|
+
blockers,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
mergeErrorToResult(item, error) {
|
|
785
|
+
const errorCode = typeof error === 'object' && error !== null && 'code' in error
|
|
786
|
+
? String(error.code ?? '')
|
|
787
|
+
: '';
|
|
788
|
+
if (error instanceof MergeConflictError || errorCode === 'MERGE_CONFLICT') {
|
|
789
|
+
const conflict = error;
|
|
790
|
+
return {
|
|
791
|
+
workspaceId: item.workspaceId,
|
|
792
|
+
ownerMemberId: item.owner.memberId,
|
|
793
|
+
status: 'CONFLICT',
|
|
794
|
+
code: errorCode || 'MERGE_CONFLICT',
|
|
795
|
+
message: error instanceof Error ? error.message : 'Merge conflict',
|
|
796
|
+
conflictedFiles: Array.isArray(conflict.conflictedFiles) ? conflict.conflictedFiles : [],
|
|
797
|
+
sourceBranch: conflict.sourceBranch,
|
|
798
|
+
targetBranch: conflict.targetBranch,
|
|
799
|
+
sourceWorkspaceId: conflict.sourceWorkspaceId,
|
|
800
|
+
targetWorkspaceId: conflict.targetWorkspaceId,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const code = errorCode || 'UNKNOWN';
|
|
804
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
805
|
+
return {
|
|
806
|
+
workspaceId: item.workspaceId,
|
|
807
|
+
ownerMemberId: item.owner.memberId,
|
|
808
|
+
status: 'FAILED',
|
|
809
|
+
code,
|
|
810
|
+
message,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
141
813
|
// ── Create ───────────────────────────────────────────────────────────────────
|
|
142
814
|
/**
|
|
143
815
|
* 创建 Workspace
|
|
@@ -407,6 +1079,71 @@ export class WorkspaceService {
|
|
|
407
1079
|
return this.activateDedicatedWorkspace(await this.waitForWorkspaceReady(raced));
|
|
408
1080
|
}
|
|
409
1081
|
}
|
|
1082
|
+
async prepareTargetedExecutionWorkspace(input) {
|
|
1083
|
+
assertFullCommitSha(input.targetHeadSha);
|
|
1084
|
+
const [teamRun, sourceWorkspace, executionWorkspace] = await Promise.all([
|
|
1085
|
+
prisma.teamRun.findUnique({
|
|
1086
|
+
where: { id: input.teamRunId },
|
|
1087
|
+
include: { task: { include: { project: true } }, mainWorkspace: true },
|
|
1088
|
+
}),
|
|
1089
|
+
prisma.workspace.findFirst({
|
|
1090
|
+
where: {
|
|
1091
|
+
id: input.targetSourceWorkspaceId,
|
|
1092
|
+
task: { teamRun: { id: input.teamRunId } },
|
|
1093
|
+
},
|
|
1094
|
+
include: { task: { include: { project: true } } },
|
|
1095
|
+
}),
|
|
1096
|
+
prisma.workspace.findFirst({
|
|
1097
|
+
where: {
|
|
1098
|
+
id: input.executionWorkspaceId,
|
|
1099
|
+
ownerMemberId: input.memberId,
|
|
1100
|
+
task: { teamRun: { id: input.teamRunId } },
|
|
1101
|
+
},
|
|
1102
|
+
include: { task: { include: { project: true } } },
|
|
1103
|
+
}),
|
|
1104
|
+
]);
|
|
1105
|
+
if (!teamRun) {
|
|
1106
|
+
throw new NotFoundError('TeamRun', input.teamRunId);
|
|
1107
|
+
}
|
|
1108
|
+
ensureTaskNotDeleted(teamRun.task);
|
|
1109
|
+
ensureProjectSupportsGit(teamRun.task.project, 'prepare targeted TeamRun workspace');
|
|
1110
|
+
if (!sourceWorkspace) {
|
|
1111
|
+
throw new ServiceError('Target source workspace must belong to this TeamRun', 'TARGET_SOURCE_WORKSPACE_INVALID', 400);
|
|
1112
|
+
}
|
|
1113
|
+
if (!executionWorkspace) {
|
|
1114
|
+
throw new ServiceError('Execution workspace must be a dedicated workspace owned by the target member', 'TARGET_EXECUTION_WORKSPACE_INVALID', 400);
|
|
1115
|
+
}
|
|
1116
|
+
this.assertWorktreeWorkspace(sourceWorkspace);
|
|
1117
|
+
this.assertWorktreeWorkspace(executionWorkspace);
|
|
1118
|
+
if (!teamRun.mainWorkspaceId || executionWorkspace.parentWorkspaceId !== teamRun.mainWorkspaceId) {
|
|
1119
|
+
throw new ServiceError('Targeted review/test requires a dedicated TeamRun execution workspace', 'TARGET_EXECUTION_WORKSPACE_NOT_DEDICATED', 400);
|
|
1120
|
+
}
|
|
1121
|
+
if (executionWorkspace.id === sourceWorkspace.id) {
|
|
1122
|
+
throw new ServiceError('Execution workspace cannot be the source workspace being reviewed or tested', 'TARGET_EXECUTION_WORKSPACE_SOURCE_CONFLICT', 400);
|
|
1123
|
+
}
|
|
1124
|
+
const repoPath = teamRun.task.project.repoPath;
|
|
1125
|
+
await execGit(repoPath, ['cat-file', '-e', `${input.targetHeadSha}^{commit}`]);
|
|
1126
|
+
const clean = await new WorktreeManager(repoPath).isWorktreeClean(executionWorkspace.worktreePath);
|
|
1127
|
+
if (!clean) {
|
|
1128
|
+
throw new ServiceError('Execution workspace has uncommitted changes; targeted sync was not applied', 'TARGET_EXECUTION_WORKSPACE_DIRTY', 409);
|
|
1129
|
+
}
|
|
1130
|
+
const executionBranch = targetExecutionBranchName(input);
|
|
1131
|
+
await execGit(executionWorkspace.worktreePath, ['checkout', '-B', executionBranch, input.targetHeadSha]);
|
|
1132
|
+
await execGit(executionWorkspace.worktreePath, ['reset', '--hard', input.targetHeadSha]);
|
|
1133
|
+
const actualHead = (await execGit(executionWorkspace.worktreePath, ['rev-parse', 'HEAD'])).trim();
|
|
1134
|
+
if (actualHead !== input.targetHeadSha) {
|
|
1135
|
+
throw new ServiceError('Execution workspace HEAD does not match targetHeadSha after sync', 'TARGET_SYNC_HEAD_MISMATCH', 500);
|
|
1136
|
+
}
|
|
1137
|
+
await prisma.workspace.update({
|
|
1138
|
+
where: { id: executionWorkspace.id },
|
|
1139
|
+
data: {
|
|
1140
|
+
branchName: executionBranch,
|
|
1141
|
+
baseBranch: input.targetBranchName,
|
|
1142
|
+
},
|
|
1143
|
+
});
|
|
1144
|
+
this.refreshGitWatcher(executionWorkspace.id);
|
|
1145
|
+
return { executionBranch };
|
|
1146
|
+
}
|
|
410
1147
|
async findDedicatedWorkspace(mainWorkspaceId, memberId) {
|
|
411
1148
|
return prisma.workspace.findFirst({
|
|
412
1149
|
where: {
|
|
@@ -625,16 +1362,112 @@ export class WorkspaceService {
|
|
|
625
1362
|
}
|
|
626
1363
|
ensureProjectIsMutable(workspace.task.project, 'merge workspaces');
|
|
627
1364
|
this.assertWorktreeWorkspace(workspace);
|
|
628
|
-
return this.withProjectMergeLock(workspace.task.projectId, options.lockOwnerId, () => this.mergeWithLock(workspace, options
|
|
1365
|
+
return this.withProjectMergeLock(workspace.task.projectId, options.lockOwnerId, () => this.mergeWithLock(workspace, options));
|
|
629
1366
|
}
|
|
630
|
-
async mergeWithLock(workspace,
|
|
1367
|
+
async mergeWithLock(workspace, options) {
|
|
1368
|
+
if (this.isTeamRunDedicatedChildWorkspace(workspace) && workspace.status === WorkspaceStatus.MERGED) {
|
|
1369
|
+
if (!workspace.worktreePath) {
|
|
1370
|
+
return '';
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
return await this.getHeadSha(workspace.worktreePath);
|
|
1374
|
+
}
|
|
1375
|
+
catch {
|
|
1376
|
+
return '';
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
631
1379
|
if (workspace.status !== WorkspaceStatus.ACTIVE) {
|
|
632
1380
|
throw new ServiceError(`Cannot merge workspace in ${workspace.status} status`, 'INVALID_WORKSPACE_STATE', 400);
|
|
633
1381
|
}
|
|
634
1382
|
if (workspace.parentWorkspaceId) {
|
|
635
|
-
|
|
1383
|
+
if (this.isTeamRunDedicatedChildWorkspace(workspace)) {
|
|
1384
|
+
await this.assertTeamRunChildMergeGate(workspace, options);
|
|
1385
|
+
}
|
|
1386
|
+
return this.mergeChildIntoParent(workspace, options.commitMessage);
|
|
1387
|
+
}
|
|
1388
|
+
return this.mergeRootWorkspaceToMain(workspace, options.commitMessage);
|
|
1389
|
+
}
|
|
1390
|
+
isTeamRunDedicatedChildWorkspace(workspace) {
|
|
1391
|
+
return Boolean(workspace.task.teamRun
|
|
1392
|
+
&& workspace.task.teamRun.mainWorkspaceId === workspace.parentWorkspaceId
|
|
1393
|
+
&& workspace.parentWorkspaceId
|
|
1394
|
+
&& workspace.ownerMemberId);
|
|
1395
|
+
}
|
|
1396
|
+
async getHeadSha(worktreePath) {
|
|
1397
|
+
return (await execGit(worktreePath, ['rev-parse', 'HEAD'])).trim();
|
|
1398
|
+
}
|
|
1399
|
+
async assertTeamRunChildMergeGate(workspace, options) {
|
|
1400
|
+
const invocationId = options.invocationId ?? options.lockOwnerId;
|
|
1401
|
+
if (!invocationId) {
|
|
1402
|
+
throw new ServiceError('TeamRun workspace merge requires an agent invocation identity', 'TEAM_RUN_MERGE_INVOCATION_REQUIRED', 403);
|
|
1403
|
+
}
|
|
1404
|
+
const invocation = await prisma.agentInvocation.findFirst({
|
|
1405
|
+
where: {
|
|
1406
|
+
id: invocationId,
|
|
1407
|
+
teamRunId: workspace.task.teamRun.id,
|
|
1408
|
+
},
|
|
1409
|
+
select: {
|
|
1410
|
+
id: true,
|
|
1411
|
+
memberId: true,
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
if (!invocation) {
|
|
1415
|
+
throw new ServiceError('Agent invocation identity is invalid for this TeamRun', 'FORBIDDEN', 403);
|
|
1416
|
+
}
|
|
1417
|
+
if (options.requesterMemberId && options.requesterMemberId !== invocation.memberId) {
|
|
1418
|
+
throw new ServiceError('Requester member does not match the agent invocation identity', 'FORBIDDEN', 403);
|
|
1419
|
+
}
|
|
1420
|
+
const member = await prisma.teamMember.findFirst({
|
|
1421
|
+
where: {
|
|
1422
|
+
id: invocation.memberId,
|
|
1423
|
+
teamRunId: workspace.task.teamRun.id,
|
|
1424
|
+
membershipStatus: { not: 'REMOVED' },
|
|
1425
|
+
},
|
|
1426
|
+
select: {
|
|
1427
|
+
id: true,
|
|
1428
|
+
capabilities: true,
|
|
1429
|
+
},
|
|
1430
|
+
});
|
|
1431
|
+
if (!member) {
|
|
1432
|
+
throw new ServiceError('Current TeamRun member was not found', 'FORBIDDEN', 403);
|
|
1433
|
+
}
|
|
1434
|
+
const capabilities = parseCapabilities(member.capabilities);
|
|
1435
|
+
if (capabilities.mergeWorkspace !== true) {
|
|
1436
|
+
throw new ServiceError('Current TeamRun member lacks required capabilities: mergeWorkspace', 'TEAM_RUN_MEMBER_CAPABILITY_REQUIRED', 403);
|
|
1437
|
+
}
|
|
1438
|
+
const currentHeadSha = await this.getHeadSha(workspace.worktreePath);
|
|
1439
|
+
const latestReview = await prisma.workspaceVerdict.findFirst({
|
|
1440
|
+
where: {
|
|
1441
|
+
workspaceId: workspace.id,
|
|
1442
|
+
teamRunId: workspace.task.teamRun.id,
|
|
1443
|
+
kind: 'REVIEW',
|
|
1444
|
+
},
|
|
1445
|
+
orderBy: [
|
|
1446
|
+
{ sequence: 'desc' },
|
|
1447
|
+
{ createdAt: 'desc' },
|
|
1448
|
+
{ id: 'desc' },
|
|
1449
|
+
],
|
|
1450
|
+
});
|
|
1451
|
+
if (!latestReview || latestReview.verdict !== 'APPROVED') {
|
|
1452
|
+
throw new ServiceError('TeamRun workspace merge requires an approved review', 'REVIEW_REQUIRED', 409);
|
|
1453
|
+
}
|
|
1454
|
+
if (latestReview.reviewedSha !== currentHeadSha) {
|
|
1455
|
+
throw new ServiceError('Approved review is stale because the workspace HEAD changed', 'REVIEW_STALE', 409);
|
|
1456
|
+
}
|
|
1457
|
+
if (!latestReview.reviewerMemberId || latestReview.reviewerMemberId === workspace.ownerMemberId) {
|
|
1458
|
+
throw new ServiceError('Workspace owner cannot approve their own workspace for merge', 'SELF_REVIEW_FORBIDDEN', 409);
|
|
1459
|
+
}
|
|
1460
|
+
const ownerActiveInvocation = await prisma.agentInvocation.findFirst({
|
|
1461
|
+
where: {
|
|
1462
|
+
teamRunId: workspace.task.teamRun.id,
|
|
1463
|
+
memberId: workspace.ownerMemberId,
|
|
1464
|
+
status: { in: ['QUEUED', 'RUNNING', 'SESSION_ENDED', 'WAITING_ROOM_REPLY'] },
|
|
1465
|
+
},
|
|
1466
|
+
select: { id: true },
|
|
1467
|
+
});
|
|
1468
|
+
if (ownerActiveInvocation) {
|
|
1469
|
+
throw new ServiceError('Cannot merge while the workspace owner has active work', 'OWNER_HAS_ACTIVE_INVOCATION', 409);
|
|
636
1470
|
}
|
|
637
|
-
return this.mergeRootWorkspaceToMain(workspace, commitMessage);
|
|
638
1471
|
}
|
|
639
1472
|
async mergeChildIntoParent(workspace, commitMessage) {
|
|
640
1473
|
this.assertWorktreeWorkspace(workspace);
|