patchrelay 0.8.2 → 0.8.4
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/build-info.json
CHANGED
|
@@ -200,7 +200,7 @@ export class IssueWorkflowCoordinator {
|
|
|
200
200
|
});
|
|
201
201
|
}
|
|
202
202
|
const workspace = this.authoritativeLedger.getWorkspaceOwnership(stageRun.workspaceId);
|
|
203
|
-
if (workspace) {
|
|
203
|
+
if (workspace && workspace.currentRunLeaseId === params.stageRunId) {
|
|
204
204
|
this.authoritativeLedger.upsertWorkspaceOwnership({
|
|
205
205
|
projectId: stageRun.projectId,
|
|
206
206
|
linearIssueId: stageRun.linearIssueId,
|
package/dist/linear-workflow.js
CHANGED
|
@@ -1,11 +1,53 @@
|
|
|
1
1
|
import { resolveWorkflowStageConfig } from "./workflow-policy.js";
|
|
2
2
|
const STATUS_MARKER = "<!-- patchrelay:status-comment -->";
|
|
3
|
+
function normalizeLinearState(value) {
|
|
4
|
+
const trimmed = value?.trim();
|
|
5
|
+
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
6
|
+
}
|
|
3
7
|
export function resolveActiveLinearState(project, stage, workflowDefinitionId) {
|
|
4
8
|
return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.activeState;
|
|
5
9
|
}
|
|
6
10
|
export function resolveFallbackLinearState(project, stage, workflowDefinitionId) {
|
|
7
11
|
return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.fallbackState;
|
|
8
12
|
}
|
|
13
|
+
export function resolveDoneLinearState(issue) {
|
|
14
|
+
const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
|
|
15
|
+
if (typedMatch?.name) {
|
|
16
|
+
return typedMatch.name;
|
|
17
|
+
}
|
|
18
|
+
const nameMatch = issue.workflowStates.find((state) => {
|
|
19
|
+
const normalized = normalizeLinearState(state.name);
|
|
20
|
+
return normalized === "done" || normalized === "completed" || normalized === "complete";
|
|
21
|
+
});
|
|
22
|
+
return nameMatch?.name;
|
|
23
|
+
}
|
|
24
|
+
export function resolveAuthoritativeLinearStopState(issue) {
|
|
25
|
+
const currentStateName = issue.stateName?.trim();
|
|
26
|
+
const normalizedCurrentState = normalizeLinearState(currentStateName);
|
|
27
|
+
if (!currentStateName || !normalizedCurrentState) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const currentWorkflowState = issue.workflowStates.find((state) => normalizeLinearState(state.name) === normalizedCurrentState);
|
|
31
|
+
if (normalizeLinearState(currentWorkflowState?.type) === "completed") {
|
|
32
|
+
return {
|
|
33
|
+
stateName: currentWorkflowState?.name ?? currentStateName,
|
|
34
|
+
lifecycleStatus: "completed",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (normalizedCurrentState === "human needed") {
|
|
38
|
+
return {
|
|
39
|
+
stateName: currentStateName,
|
|
40
|
+
lifecycleStatus: "paused",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (normalizedCurrentState === "done" || normalizedCurrentState === "completed" || normalizedCurrentState === "complete") {
|
|
44
|
+
return {
|
|
45
|
+
stateName: currentStateName,
|
|
46
|
+
lifecycleStatus: "completed",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
9
51
|
export function buildRunningStatusComment(params) {
|
|
10
52
|
return [
|
|
11
53
|
STATUS_MARKER,
|
|
@@ -3,12 +3,11 @@ import { reconcileIssue } from "./reconciliation-engine.js";
|
|
|
3
3
|
import { buildReconciliationSnapshot } from "./reconciliation-snapshot-builder.js";
|
|
4
4
|
import { syncFailedStageToLinear } from "./stage-failure.js";
|
|
5
5
|
import { parseStageHandoff } from "./stage-handoff.js";
|
|
6
|
-
import { resolveFallbackLinearState } from "./linear-workflow.js";
|
|
6
|
+
import { resolveAuthoritativeLinearStopState, resolveDoneLinearState, resolveFallbackLinearState } from "./linear-workflow.js";
|
|
7
7
|
import { resolveDefaultTransitionTarget, transitionTargetAllowed } from "./workflow-policy.js";
|
|
8
8
|
import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
|
|
9
9
|
import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
|
|
10
10
|
import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
|
|
11
|
-
const MAX_AUTOMATIC_TRANSITION_ATTEMPTS = 3;
|
|
12
11
|
export class ServiceStageFinalizer {
|
|
13
12
|
config;
|
|
14
13
|
stores;
|
|
@@ -153,6 +152,7 @@ export class ServiceStageFinalizer {
|
|
|
153
152
|
const report = buildStageReport(finalizedStageRun, issue, thread, countEventMethods(this.stores.stageEvents.listThreadEvents(stageRun.id)));
|
|
154
153
|
this.runAtomically(() => {
|
|
155
154
|
this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "completed", {
|
|
155
|
+
stageRunId: stageRun.id,
|
|
156
156
|
threadId: params.threadId,
|
|
157
157
|
...(params.turnId ? { turnId: params.turnId } : {}),
|
|
158
158
|
nextLifecycleStatus: params.nextLifecycleStatus ?? (issue.desiredStage ? "queued" : "completed"),
|
|
@@ -171,6 +171,7 @@ export class ServiceStageFinalizer {
|
|
|
171
171
|
failStageRun(stageRun, threadId, message, options) {
|
|
172
172
|
this.runAtomically(() => {
|
|
173
173
|
this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "failed", {
|
|
174
|
+
stageRunId: stageRun.id,
|
|
174
175
|
threadId,
|
|
175
176
|
...(options?.turnId ? { turnId: options.turnId } : {}),
|
|
176
177
|
failureReason: message,
|
|
@@ -240,7 +241,7 @@ export class ServiceStageFinalizer {
|
|
|
240
241
|
}
|
|
241
242
|
async maybeQueueAutomaticTransition(stageRun, report) {
|
|
242
243
|
const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
243
|
-
if (!refreshedIssue
|
|
244
|
+
if (!refreshedIssue) {
|
|
244
245
|
return;
|
|
245
246
|
}
|
|
246
247
|
const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
|
|
@@ -259,6 +260,14 @@ export class ServiceStageFinalizer {
|
|
|
259
260
|
if (!linearIssue) {
|
|
260
261
|
return;
|
|
261
262
|
}
|
|
263
|
+
const authoritativeStopState = resolveAuthoritativeLinearStopState(linearIssue);
|
|
264
|
+
if (authoritativeStopState) {
|
|
265
|
+
this.syncIssueToAuthoritativeLinearStopState(stageRun, refreshedIssue, authoritativeStopState);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (refreshedIssue.desiredStage) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
262
271
|
const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(stageRun, refreshedIssue, linear, linearIssue);
|
|
263
272
|
if (!continuationPrecondition.allowed) {
|
|
264
273
|
this.feed?.publish({
|
|
@@ -315,9 +324,19 @@ export class ServiceStageFinalizer {
|
|
|
315
324
|
await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay received ${nextTarget} as the next stage again and needs a human to confirm the intended loop.`);
|
|
316
325
|
return;
|
|
317
326
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
327
|
+
if (this.isTransitionAlreadyInFlight(stageRun, nextTarget)) {
|
|
328
|
+
this.feed?.publish({
|
|
329
|
+
level: "info",
|
|
330
|
+
kind: "workflow",
|
|
331
|
+
issueKey: refreshedIssue.issueKey,
|
|
332
|
+
projectId: stageRun.projectId,
|
|
333
|
+
stage: stageRun.stage,
|
|
334
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
335
|
+
nextStage: nextTarget,
|
|
336
|
+
status: "transition_in_progress",
|
|
337
|
+
summary: `${nextTarget} is already queued or running`,
|
|
338
|
+
detail: `PatchRelay kept ${stageRun.stage} completion from re-queueing ${nextTarget}.`,
|
|
339
|
+
});
|
|
321
340
|
return;
|
|
322
341
|
}
|
|
323
342
|
this.feed?.publish({
|
|
@@ -337,6 +356,32 @@ export class ServiceStageFinalizer {
|
|
|
337
356
|
lifecycleStatus: "queued",
|
|
338
357
|
});
|
|
339
358
|
}
|
|
359
|
+
syncIssueToAuthoritativeLinearStopState(stageRun, issue, stopState) {
|
|
360
|
+
this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
|
|
361
|
+
lifecycleStatus: stopState.lifecycleStatus,
|
|
362
|
+
});
|
|
363
|
+
this.stores.workflowCoordinator.upsertTrackedIssue({
|
|
364
|
+
projectId: stageRun.projectId,
|
|
365
|
+
linearIssueId: stageRun.linearIssueId,
|
|
366
|
+
currentLinearState: stopState.stateName,
|
|
367
|
+
lifecycleStatus: stopState.lifecycleStatus,
|
|
368
|
+
});
|
|
369
|
+
this.feed?.publish({
|
|
370
|
+
level: "info",
|
|
371
|
+
kind: "workflow",
|
|
372
|
+
issueKey: issue.issueKey,
|
|
373
|
+
projectId: stageRun.projectId,
|
|
374
|
+
stage: stageRun.stage,
|
|
375
|
+
...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
|
|
376
|
+
status: stopState.lifecycleStatus === "completed" ? "completed" : "transition_suppressed",
|
|
377
|
+
summary: stopState.lifecycleStatus === "completed"
|
|
378
|
+
? `Kept workflow completed after ${stageRun.stage}`
|
|
379
|
+
: `Ignored stale ${stageRun.stage} completion`,
|
|
380
|
+
detail: stopState.lifecycleStatus === "completed"
|
|
381
|
+
? `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue finished.`
|
|
382
|
+
: `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue paused.`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
340
385
|
async checkAutomaticContinuationPreconditions(stageRun, issue, linear, linearIssue) {
|
|
341
386
|
const actorProfile = await linear.getActorProfile().catch(() => undefined);
|
|
342
387
|
if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
|
|
@@ -360,19 +405,16 @@ export class ServiceStageFinalizer {
|
|
|
360
405
|
}
|
|
361
406
|
return resolveDefaultTransitionTarget(project, stageRun.stage, workflowDefinitionId) ?? "human_needed";
|
|
362
407
|
}
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
let count = 0;
|
|
368
|
-
for (let index = 0; index < stageHistory.length - 1; index += 1) {
|
|
369
|
-
const current = stageHistory[index];
|
|
370
|
-
const next = stageHistory[index + 1];
|
|
371
|
-
if (current?.stage === currentStage && next?.stage === nextTarget) {
|
|
372
|
-
count += 1;
|
|
373
|
-
}
|
|
408
|
+
isTransitionAlreadyInFlight(stageRun, nextTarget) {
|
|
409
|
+
const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
410
|
+
if (!refreshedIssue) {
|
|
411
|
+
return false;
|
|
374
412
|
}
|
|
375
|
-
|
|
413
|
+
if (refreshedIssue.desiredStage === nextTarget) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
const activeStageRun = this.resolveActiveStageRun(refreshedIssue);
|
|
417
|
+
return activeStageRun !== undefined && activeStageRun.id !== stageRun.id && activeStageRun.stage === nextTarget;
|
|
376
418
|
}
|
|
377
419
|
async routeStageToHumanNeeded(project, stageRun, linearIssue, reason) {
|
|
378
420
|
const linear = await this.linearProvider.forProject(stageRun.projectId);
|
|
@@ -408,29 +450,41 @@ export class ServiceStageFinalizer {
|
|
|
408
450
|
}
|
|
409
451
|
finishLedgerRun(projectId, linearIssueId, status, params) {
|
|
410
452
|
const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
|
|
411
|
-
|
|
453
|
+
const targetRunLeaseId = params.stageRunId ?? issueControl?.activeRunLeaseId;
|
|
454
|
+
if (!targetRunLeaseId) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const targetRunLease = this.stores.runLeases.getRunLease(targetRunLeaseId);
|
|
458
|
+
if (!targetRunLease) {
|
|
412
459
|
return;
|
|
413
460
|
}
|
|
414
461
|
this.stores.runLeases.finishRunLease({
|
|
415
|
-
runLeaseId:
|
|
462
|
+
runLeaseId: targetRunLeaseId,
|
|
416
463
|
status,
|
|
417
464
|
...(params.threadId ? { threadId: params.threadId } : {}),
|
|
418
465
|
...(params.turnId ? { turnId: params.turnId } : {}),
|
|
419
466
|
...(params.failureReason ? { failureReason: params.failureReason } : {}),
|
|
420
467
|
});
|
|
421
|
-
if (
|
|
422
|
-
const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(
|
|
468
|
+
if (targetRunLease.workspaceOwnershipId !== undefined) {
|
|
469
|
+
const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(targetRunLease.workspaceOwnershipId);
|
|
423
470
|
if (workspace) {
|
|
424
471
|
this.stores.workspaceOwnership.upsertWorkspaceOwnership({
|
|
425
472
|
projectId,
|
|
426
473
|
linearIssueId,
|
|
427
474
|
branchName: workspace.branchName,
|
|
428
475
|
worktreePath: workspace.worktreePath,
|
|
429
|
-
status: status === "completed" ? "active" : "paused",
|
|
430
|
-
currentRunLeaseId
|
|
476
|
+
status: workspace.currentRunLeaseId === targetRunLeaseId ? (status === "completed" ? "active" : "paused") : workspace.status,
|
|
477
|
+
...(workspace.currentRunLeaseId === targetRunLeaseId
|
|
478
|
+
? { currentRunLeaseId: null }
|
|
479
|
+
: workspace.currentRunLeaseId !== undefined
|
|
480
|
+
? { currentRunLeaseId: workspace.currentRunLeaseId }
|
|
481
|
+
: {}),
|
|
431
482
|
});
|
|
432
483
|
}
|
|
433
484
|
}
|
|
485
|
+
if (!issueControl?.activeRunLeaseId || issueControl.activeRunLeaseId !== targetRunLeaseId) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
434
488
|
this.stores.issueControl.upsertIssueControl({
|
|
435
489
|
projectId,
|
|
436
490
|
linearIssueId,
|
|
@@ -631,14 +685,3 @@ function normalizeLinearState(value) {
|
|
|
631
685
|
const trimmed = value?.trim();
|
|
632
686
|
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
633
687
|
}
|
|
634
|
-
function resolveDoneLinearState(issue) {
|
|
635
|
-
const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
|
|
636
|
-
if (typedMatch?.name) {
|
|
637
|
-
return typedMatch.name;
|
|
638
|
-
}
|
|
639
|
-
const nameMatch = issue.workflowStates.find((state) => {
|
|
640
|
-
const normalized = normalizeLinearState(state.name);
|
|
641
|
-
return normalized === "done" || normalized === "completed" || normalized === "complete";
|
|
642
|
-
});
|
|
643
|
-
return nameMatch?.name;
|
|
644
|
-
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
|
|
1
2
|
import { buildStageLaunchPlan, isCodexThreadId } from "./stage-launch.js";
|
|
2
3
|
import { syncFailedStageToLinear } from "./stage-failure.js";
|
|
3
4
|
import { buildFailedStageReport } from "./stage-reporting.js";
|
|
@@ -47,6 +48,22 @@ export class ServiceStageRunner {
|
|
|
47
48
|
if (!issue) {
|
|
48
49
|
return;
|
|
49
50
|
}
|
|
51
|
+
if (issue.lifecycleStatus === "completed" || issue.lifecycleStatus === "paused") {
|
|
52
|
+
this.feed?.publish({
|
|
53
|
+
level: "info",
|
|
54
|
+
kind: "workflow",
|
|
55
|
+
issueKey: issue.issueKey,
|
|
56
|
+
projectId: item.projectId,
|
|
57
|
+
stage: desiredStage,
|
|
58
|
+
...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
|
|
59
|
+
status: issue.lifecycleStatus === "completed" ? "completed" : "transition_suppressed",
|
|
60
|
+
summary: issue.lifecycleStatus === "completed"
|
|
61
|
+
? `Skipped ${desiredStage} because the issue is already complete`
|
|
62
|
+
: `Skipped ${desiredStage} because the issue is already paused`,
|
|
63
|
+
detail: issue.currentLinearState ? `Live Linear state is ${issue.currentLinearState}.` : undefined,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
50
67
|
const existingWorkspace = this.stores.workspaceOwnership.getWorkspaceOwnershipForIssue(item.projectId, item.issueId);
|
|
51
68
|
const stageHistory = this.stores.issueWorkflows.listStageRunsForIssue(item.projectId, item.issueId);
|
|
52
69
|
const previousStageRun = stageHistory.at(-1);
|
|
@@ -201,13 +218,28 @@ export class ServiceStageRunner {
|
|
|
201
218
|
}
|
|
202
219
|
async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
|
|
203
220
|
const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
|
|
204
|
-
if (existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
|
|
205
|
-
return existing;
|
|
206
|
-
}
|
|
207
221
|
const liveIssue = await this.linearProvider
|
|
208
222
|
.forProject(project.id)
|
|
209
223
|
.then((linear) => linear?.getIssue(linearIssueId))
|
|
210
224
|
.catch(() => undefined);
|
|
225
|
+
const authoritativeStopState = liveIssue ? resolveAuthoritativeLinearStopState(liveIssue) : undefined;
|
|
226
|
+
if (authoritativeStopState) {
|
|
227
|
+
this.stores.workflowCoordinator.setIssueDesiredStage(project.id, linearIssueId, undefined, {
|
|
228
|
+
lifecycleStatus: authoritativeStopState.lifecycleStatus,
|
|
229
|
+
});
|
|
230
|
+
return this.stores.workflowCoordinator.upsertTrackedIssue({
|
|
231
|
+
projectId: project.id,
|
|
232
|
+
linearIssueId,
|
|
233
|
+
...(liveIssue?.identifier ? { issueKey: liveIssue.identifier } : existing?.issueKey ? { issueKey: existing.issueKey } : {}),
|
|
234
|
+
...(liveIssue?.title ? { title: liveIssue.title } : existing?.title ? { title: existing.title } : {}),
|
|
235
|
+
...(liveIssue?.url ? { issueUrl: liveIssue.url } : existing?.issueUrl ? { issueUrl: existing.issueUrl } : {}),
|
|
236
|
+
currentLinearState: authoritativeStopState.stateName,
|
|
237
|
+
lifecycleStatus: authoritativeStopState.lifecycleStatus,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (!liveIssue && existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
|
|
241
|
+
return existing;
|
|
242
|
+
}
|
|
211
243
|
return this.stores.workflowCoordinator.recordDesiredStage({
|
|
212
244
|
projectId: project.id,
|
|
213
245
|
linearIssueId,
|