patchrelay 0.8.1 → 0.8.3
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 +3 -3
- package/dist/config.js +21 -4
- package/dist/db/issue-workflow-coordinator.js +1 -1
- package/dist/preflight.js +7 -1
- package/dist/service-stage-finalizer.js +45 -74
- package/dist/service-webhook-processor.js +1 -2
- package/dist/webhook-desired-stage-recorder.js +0 -6
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/config.js
CHANGED
|
@@ -128,10 +128,28 @@ const configSchema = z.object({
|
|
|
128
128
|
});
|
|
129
129
|
function defaultTriggerEvents(actor) {
|
|
130
130
|
if (actor === "app") {
|
|
131
|
-
return ["agentSessionCreated", "agentPrompted"];
|
|
131
|
+
return ["delegateChanged", "statusChanged", "agentSessionCreated", "agentPrompted", "commentCreated", "commentUpdated"];
|
|
132
132
|
}
|
|
133
133
|
return ["statusChanged"];
|
|
134
134
|
}
|
|
135
|
+
function normalizeTriggerEvents(actor, configured) {
|
|
136
|
+
if (actor !== "app") {
|
|
137
|
+
return configured ?? defaultTriggerEvents(actor);
|
|
138
|
+
}
|
|
139
|
+
const required = defaultTriggerEvents(actor);
|
|
140
|
+
if (!configured || configured.length === 0) {
|
|
141
|
+
return required;
|
|
142
|
+
}
|
|
143
|
+
const seen = new Set(required);
|
|
144
|
+
const extras = configured.filter((event) => {
|
|
145
|
+
if (seen.has(event)) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
seen.add(event);
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
return [...required, ...extras];
|
|
152
|
+
}
|
|
135
153
|
const builtinWorkflows = [
|
|
136
154
|
{
|
|
137
155
|
id: "development",
|
|
@@ -489,9 +507,8 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
489
507
|
issueKeyPrefixes: project.issue_key_prefixes,
|
|
490
508
|
linearTeamIds: project.linear_team_ids,
|
|
491
509
|
allowLabels: project.allow_labels,
|
|
492
|
-
triggerEvents: repoSettings?.trigger_events ??
|
|
493
|
-
project.trigger_events
|
|
494
|
-
defaultTriggerEvents(parsed.linear.oauth.actor),
|
|
510
|
+
triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
|
|
511
|
+
project.trigger_events),
|
|
495
512
|
branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
|
|
496
513
|
...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
|
|
497
514
|
};
|
|
@@ -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/preflight.js
CHANGED
|
@@ -40,8 +40,14 @@ export async function runPreflight(config) {
|
|
|
40
40
|
checks.push(pass("linear_oauth", "Linear app actor includes assignable and mentionable scopes"));
|
|
41
41
|
}
|
|
42
42
|
for (const project of config.projects) {
|
|
43
|
+
if (!project.triggerEvents.includes("delegateChanged")) {
|
|
44
|
+
checks.push(warn(`project:${project.id}:triggers`, "Automatic pipeline pickup works best when trigger_events includes delegateChanged"));
|
|
45
|
+
}
|
|
46
|
+
if (!project.triggerEvents.includes("statusChanged")) {
|
|
47
|
+
checks.push(warn(`project:${project.id}:triggers`, "Automatic stage-to-stage continuation works best when trigger_events includes statusChanged"));
|
|
48
|
+
}
|
|
43
49
|
if (!project.triggerEvents.includes("agentSessionCreated")) {
|
|
44
|
-
checks.push(warn(`project:${project.id}:triggers`, "
|
|
50
|
+
checks.push(warn(`project:${project.id}:triggers`, "Native Linear agent sessions work best when trigger_events includes agentSessionCreated"));
|
|
45
51
|
}
|
|
46
52
|
if (!project.triggerEvents.includes("agentPrompted")) {
|
|
47
53
|
checks.push(warn(`project:${project.id}:triggers`, "Native follow-up agent prompts will not reach an active run unless trigger_events includes agentPrompted"));
|
|
@@ -3,14 +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 {
|
|
6
|
+
import { 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
|
-
import { safeJsonParse } from "./utils.js";
|
|
12
|
-
import { normalizeWebhook } from "./webhooks.js";
|
|
13
|
-
const MAX_AUTOMATIC_TRANSITION_ATTEMPTS = 3;
|
|
14
11
|
export class ServiceStageFinalizer {
|
|
15
12
|
config;
|
|
16
13
|
stores;
|
|
@@ -155,6 +152,7 @@ export class ServiceStageFinalizer {
|
|
|
155
152
|
const report = buildStageReport(finalizedStageRun, issue, thread, countEventMethods(this.stores.stageEvents.listThreadEvents(stageRun.id)));
|
|
156
153
|
this.runAtomically(() => {
|
|
157
154
|
this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "completed", {
|
|
155
|
+
stageRunId: stageRun.id,
|
|
158
156
|
threadId: params.threadId,
|
|
159
157
|
...(params.turnId ? { turnId: params.turnId } : {}),
|
|
160
158
|
nextLifecycleStatus: params.nextLifecycleStatus ?? (issue.desiredStage ? "queued" : "completed"),
|
|
@@ -173,6 +171,7 @@ export class ServiceStageFinalizer {
|
|
|
173
171
|
failStageRun(stageRun, threadId, message, options) {
|
|
174
172
|
this.runAtomically(() => {
|
|
175
173
|
this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "failed", {
|
|
174
|
+
stageRunId: stageRun.id,
|
|
176
175
|
threadId,
|
|
177
176
|
...(options?.turnId ? { turnId: options.turnId } : {}),
|
|
178
177
|
failureReason: message,
|
|
@@ -261,7 +260,7 @@ export class ServiceStageFinalizer {
|
|
|
261
260
|
if (!linearIssue) {
|
|
262
261
|
return;
|
|
263
262
|
}
|
|
264
|
-
const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(
|
|
263
|
+
const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(stageRun, refreshedIssue, linear, linearIssue);
|
|
265
264
|
if (!continuationPrecondition.allowed) {
|
|
266
265
|
this.feed?.publish({
|
|
267
266
|
level: "info",
|
|
@@ -317,9 +316,19 @@ export class ServiceStageFinalizer {
|
|
|
317
316
|
await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay received ${nextTarget} as the next stage again and needs a human to confirm the intended loop.`);
|
|
318
317
|
return;
|
|
319
318
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
319
|
+
if (this.isTransitionAlreadyInFlight(stageRun, nextTarget)) {
|
|
320
|
+
this.feed?.publish({
|
|
321
|
+
level: "info",
|
|
322
|
+
kind: "workflow",
|
|
323
|
+
issueKey: refreshedIssue.issueKey,
|
|
324
|
+
projectId: stageRun.projectId,
|
|
325
|
+
stage: stageRun.stage,
|
|
326
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
327
|
+
nextStage: nextTarget,
|
|
328
|
+
status: "transition_in_progress",
|
|
329
|
+
summary: `${nextTarget} is already queued or running`,
|
|
330
|
+
detail: `PatchRelay kept ${stageRun.stage} completion from re-queueing ${nextTarget}.`,
|
|
331
|
+
});
|
|
323
332
|
return;
|
|
324
333
|
}
|
|
325
334
|
this.feed?.publish({
|
|
@@ -339,14 +348,7 @@ export class ServiceStageFinalizer {
|
|
|
339
348
|
lifecycleStatus: "queued",
|
|
340
349
|
});
|
|
341
350
|
}
|
|
342
|
-
async checkAutomaticContinuationPreconditions(
|
|
343
|
-
const activeState = resolveActiveLinearState(project, stageRun.stage, issue.selectedWorkflowId);
|
|
344
|
-
if (activeState && normalizeLinearState(linearIssue.stateName) !== normalizeLinearState(activeState)) {
|
|
345
|
-
return {
|
|
346
|
-
allowed: false,
|
|
347
|
-
reason: `Linear moved from ${activeState} to ${linearIssue.stateName ?? "an unknown state"} while the stage was running.`,
|
|
348
|
-
};
|
|
349
|
-
}
|
|
351
|
+
async checkAutomaticContinuationPreconditions(stageRun, issue, linear, linearIssue) {
|
|
350
352
|
const actorProfile = await linear.getActorProfile().catch(() => undefined);
|
|
351
353
|
if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
|
|
352
354
|
return {
|
|
@@ -354,48 +356,8 @@ export class ServiceStageFinalizer {
|
|
|
354
356
|
reason: "The issue is no longer delegated to PatchRelay.",
|
|
355
357
|
};
|
|
356
358
|
}
|
|
357
|
-
const stageSession = stageRun.threadId ? this.stores.issueSessions.getIssueSessionByThreadId(stageRun.threadId) : undefined;
|
|
358
|
-
if (stageSession?.linkedAgentSessionId && issue.activeAgentSessionId !== stageSession.linkedAgentSessionId) {
|
|
359
|
-
return {
|
|
360
|
-
allowed: false,
|
|
361
|
-
reason: "The active Linear agent session changed while the stage was running.",
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
const recentInterruptions = this.listInterruptingWebhooksSince(stageRun, actorProfile?.actorId);
|
|
365
|
-
if (recentInterruptions.length > 0) {
|
|
366
|
-
return {
|
|
367
|
-
allowed: false,
|
|
368
|
-
reason: `A newer human webhook (${recentInterruptions[0]}) arrived while the stage was running.`,
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
359
|
return { allowed: true };
|
|
372
360
|
}
|
|
373
|
-
listInterruptingWebhooksSince(stageRun, patchRelayActorId) {
|
|
374
|
-
const events = this.stores.webhookEvents.listWebhookEventsForIssueSince(stageRun.linearIssueId, stageRun.startedAt);
|
|
375
|
-
const interrupts = [];
|
|
376
|
-
for (const event of events) {
|
|
377
|
-
const payload = safeJsonParse(event.payloadJson);
|
|
378
|
-
if (!payload) {
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
const normalized = normalizeWebhook({
|
|
382
|
-
webhookId: event.webhookId,
|
|
383
|
-
payload: payload,
|
|
384
|
-
});
|
|
385
|
-
if (patchRelayActorId && normalized.actor?.id === patchRelayActorId) {
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
if (normalized.triggerEvent === "commentCreated" ||
|
|
389
|
-
normalized.triggerEvent === "commentUpdated" ||
|
|
390
|
-
normalized.triggerEvent === "agentPrompted" ||
|
|
391
|
-
normalized.triggerEvent === "agentSessionCreated" ||
|
|
392
|
-
normalized.triggerEvent === "delegateChanged" ||
|
|
393
|
-
normalized.triggerEvent === "statusChanged") {
|
|
394
|
-
interrupts.push(normalized.triggerEvent);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return interrupts;
|
|
398
|
-
}
|
|
399
361
|
resolveTransitionTarget(project, stageRun, workflowDefinitionId, handoff) {
|
|
400
362
|
if (!handoff) {
|
|
401
363
|
return "human_needed";
|
|
@@ -409,19 +371,16 @@ export class ServiceStageFinalizer {
|
|
|
409
371
|
}
|
|
410
372
|
return resolveDefaultTransitionTarget(project, stageRun.stage, workflowDefinitionId) ?? "human_needed";
|
|
411
373
|
}
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const next = stageHistory[index + 1];
|
|
420
|
-
if (current?.stage === currentStage && next?.stage === nextTarget) {
|
|
421
|
-
count += 1;
|
|
422
|
-
}
|
|
374
|
+
isTransitionAlreadyInFlight(stageRun, nextTarget) {
|
|
375
|
+
const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
376
|
+
if (!refreshedIssue) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
if (refreshedIssue.desiredStage === nextTarget) {
|
|
380
|
+
return true;
|
|
423
381
|
}
|
|
424
|
-
|
|
382
|
+
const activeStageRun = this.resolveActiveStageRun(refreshedIssue);
|
|
383
|
+
return activeStageRun !== undefined && activeStageRun.id !== stageRun.id && activeStageRun.stage === nextTarget;
|
|
425
384
|
}
|
|
426
385
|
async routeStageToHumanNeeded(project, stageRun, linearIssue, reason) {
|
|
427
386
|
const linear = await this.linearProvider.forProject(stageRun.projectId);
|
|
@@ -457,29 +416,41 @@ export class ServiceStageFinalizer {
|
|
|
457
416
|
}
|
|
458
417
|
finishLedgerRun(projectId, linearIssueId, status, params) {
|
|
459
418
|
const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
|
|
460
|
-
|
|
419
|
+
const targetRunLeaseId = params.stageRunId ?? issueControl?.activeRunLeaseId;
|
|
420
|
+
if (!targetRunLeaseId) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const targetRunLease = this.stores.runLeases.getRunLease(targetRunLeaseId);
|
|
424
|
+
if (!targetRunLease) {
|
|
461
425
|
return;
|
|
462
426
|
}
|
|
463
427
|
this.stores.runLeases.finishRunLease({
|
|
464
|
-
runLeaseId:
|
|
428
|
+
runLeaseId: targetRunLeaseId,
|
|
465
429
|
status,
|
|
466
430
|
...(params.threadId ? { threadId: params.threadId } : {}),
|
|
467
431
|
...(params.turnId ? { turnId: params.turnId } : {}),
|
|
468
432
|
...(params.failureReason ? { failureReason: params.failureReason } : {}),
|
|
469
433
|
});
|
|
470
|
-
if (
|
|
471
|
-
const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(
|
|
434
|
+
if (targetRunLease.workspaceOwnershipId !== undefined) {
|
|
435
|
+
const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(targetRunLease.workspaceOwnershipId);
|
|
472
436
|
if (workspace) {
|
|
473
437
|
this.stores.workspaceOwnership.upsertWorkspaceOwnership({
|
|
474
438
|
projectId,
|
|
475
439
|
linearIssueId,
|
|
476
440
|
branchName: workspace.branchName,
|
|
477
441
|
worktreePath: workspace.worktreePath,
|
|
478
|
-
status: status === "completed" ? "active" : "paused",
|
|
479
|
-
currentRunLeaseId
|
|
442
|
+
status: workspace.currentRunLeaseId === targetRunLeaseId ? (status === "completed" ? "active" : "paused") : workspace.status,
|
|
443
|
+
...(workspace.currentRunLeaseId === targetRunLeaseId
|
|
444
|
+
? { currentRunLeaseId: null }
|
|
445
|
+
: workspace.currentRunLeaseId !== undefined
|
|
446
|
+
? { currentRunLeaseId: workspace.currentRunLeaseId }
|
|
447
|
+
: {}),
|
|
480
448
|
});
|
|
481
449
|
}
|
|
482
450
|
}
|
|
451
|
+
if (!issueControl?.activeRunLeaseId || issueControl.activeRunLeaseId !== targetRunLeaseId) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
483
454
|
this.stores.issueControl.upsertIssueControl({
|
|
484
455
|
projectId,
|
|
485
456
|
linearIssueId,
|
|
@@ -154,8 +154,7 @@ export class ServiceWebhookProcessor {
|
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
if (issueState.issue?.selectedWorkflowId &&
|
|
157
|
-
issueState.issue.selectedWorkflowId !== priorIssue?.selectedWorkflowId
|
|
158
|
-
(hydrated.triggerEvent === "agentSessionCreated" || hydrated.triggerEvent === "agentPrompted")) {
|
|
157
|
+
issueState.issue.selectedWorkflowId !== priorIssue?.selectedWorkflowId) {
|
|
159
158
|
this.feed?.publish({
|
|
160
159
|
level: "info",
|
|
161
160
|
kind: "workflow",
|
|
@@ -78,9 +78,6 @@ export class WebhookDesiredStageRecorder {
|
|
|
78
78
|
if (!normalizedIssue) {
|
|
79
79
|
return undefined;
|
|
80
80
|
}
|
|
81
|
-
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
81
|
if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent)) {
|
|
85
82
|
return undefined;
|
|
86
83
|
}
|
|
@@ -102,9 +99,6 @@ export class WebhookDesiredStageRecorder {
|
|
|
102
99
|
if (activeStageRun) {
|
|
103
100
|
return issue?.selectedWorkflowId;
|
|
104
101
|
}
|
|
105
|
-
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
|
|
106
|
-
return issue?.selectedWorkflowId;
|
|
107
|
-
}
|
|
108
102
|
if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent) || !normalized.issue) {
|
|
109
103
|
return issue?.selectedWorkflowId;
|
|
110
104
|
}
|