patchrelay 0.68.0 → 0.68.2

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.
@@ -0,0 +1,52 @@
1
+ import { appendDelegationObservedEvent } from "../delegation-audit.js";
2
+ export function isDelegatedToPatchRelay(db, project, issue) {
3
+ const installation = db.linearInstallations.getLinearInstallationForProject(project.id);
4
+ if (!installation?.actorId)
5
+ return false;
6
+ return issue.delegateId === installation.actorId;
7
+ }
8
+ /**
9
+ * Resolves whether the issue is currently delegated to PatchRelay, applying
10
+ * the "preserve previous value when the webhook didn't carry a delegate
11
+ * identity" guard so a stale webhook can't accidentally un-delegate the
12
+ * issue. Emits a `delegation_observed` audit entry whenever the resolved
13
+ * value diverges from what we previously stored, so the audit log captures
14
+ * both raw observation and applied decision.
15
+ */
16
+ export function resolveDelegationTruth(input) {
17
+ const previousDelegated = input.existingIssue?.delegatedToPatchRelay;
18
+ const observedDelegated = isDelegatedToPatchRelay(input.db, input.project, input.hydratedIssue);
19
+ const explicitDelegateSignal = input.triggerEvent === "delegateChanged";
20
+ const hasObservedDelegate = input.hydratedIssue.delegateId !== undefined;
21
+ let delegated = observedDelegated;
22
+ let reason = hasObservedDelegate
23
+ ? "delegate_id_present"
24
+ : `missing_delegate_identity_after_${input.hydration}`;
25
+ if (!hasObservedDelegate && !explicitDelegateSignal && previousDelegated !== undefined) {
26
+ delegated = previousDelegated;
27
+ reason = `preserved_previous_delegation_after_${input.hydration}`;
28
+ }
29
+ if (previousDelegated !== delegated
30
+ || input.hydration === "live_linear_failed"
31
+ || (!hasObservedDelegate && previousDelegated !== undefined)) {
32
+ appendDelegationObservedEvent(input.db, {
33
+ projectId: input.project.id,
34
+ linearIssueId: input.normalizedIssue.id,
35
+ payload: {
36
+ source: "linear_webhook",
37
+ webhookId: input.webhookId,
38
+ triggerEvent: input.triggerEvent,
39
+ ...(input.actorId ? { actorId: input.actorId } : {}),
40
+ ...(input.hydratedIssue.delegateId ? { observedDelegateId: input.hydratedIssue.delegateId } : {}),
41
+ ...(previousDelegated !== undefined ? { previousDelegatedToPatchRelay: previousDelegated } : {}),
42
+ observedDelegatedToPatchRelay: observedDelegated,
43
+ appliedDelegatedToPatchRelay: delegated,
44
+ hydration: input.hydration,
45
+ ...(input.activeRunId !== undefined ? { activeRunId: input.activeRunId } : {}),
46
+ decision: "none",
47
+ reason,
48
+ },
49
+ });
50
+ }
51
+ return { delegated };
52
+ }
@@ -1,8 +1,10 @@
1
1
  export class DependencyReadinessHandler {
2
2
  db;
3
+ wakeDispatcher;
3
4
  peekPendingSessionWakeRunType;
4
- constructor(db, peekPendingSessionWakeRunType) {
5
+ constructor(db, wakeDispatcher, peekPendingSessionWakeRunType) {
5
6
  this.db = db;
7
+ this.wakeDispatcher = wakeDispatcher;
6
8
  this.peekPendingSessionWakeRunType = peekPendingSessionWakeRunType;
7
9
  }
8
10
  reconcile(projectId, blockerLinearIssueId) {
@@ -40,9 +42,7 @@ export class DependencyReadinessHandler {
40
42
  pendingRunContextJson: null,
41
43
  });
42
44
  }
43
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, dependent.linearIssueId, {
44
- projectId,
45
- linearIssueId: dependent.linearIssueId,
45
+ this.wakeDispatcher.recordEventAndDispatch(projectId, dependent.linearIssueId, {
46
46
  eventType: "delegated",
47
47
  dedupeKey: `delegated:${dependent.linearIssueId}`,
48
48
  });
@@ -2,19 +2,20 @@ import { classifyIssue } from "../issue-class.js";
2
2
  import { computeOrchestrationSettleUntil, wakeOrchestrationParentsForChildEvent, } from "../orchestration-parent-wake.js";
3
3
  import { triggerEventAllowed } from "../project-resolution.js";
4
4
  import { resolveAwaitingInputReason } from "../awaiting-input-reason.js";
5
- import { appendDelegationObservedEvent } from "../delegation-audit.js";
6
- import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isResolvedLinearState, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
5
+ import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isResolvedLinearState, isTerminalDelegationState, resolveReDelegationResume, } from "./decision-helpers.js";
6
+ import { isDelegatedToPatchRelay, resolveDelegationTruth } from "./delegation-truth.js";
7
+ import { syncIssueDependencies } from "./issue-dependency-sync.js";
8
+ import { resolveLinkedPrAdoption } from "./linked-pr-adoption.js";
7
9
  import { buildOperatorRetryEvent } from "../operator-retry-event.js";
8
- import { resolveLinkedPullRequest } from "../linear-linked-pr-reconciliation.js";
9
- import { readRemotePrState } from "../remote-pr-state.js";
10
- import { deriveLinkedPrAdoptionOutcome } from "../delegation-linked-pr.js";
11
10
  export class DesiredStageRecorder {
12
11
  db;
13
12
  linearProvider;
13
+ wakeDispatcher;
14
14
  feed;
15
- constructor(db, linearProvider, feed) {
15
+ constructor(db, linearProvider, wakeDispatcher, feed) {
16
16
  this.db = db;
17
17
  this.linearProvider = linearProvider;
18
+ this.wakeDispatcher = wakeDispatcher;
18
19
  this.feed = feed;
19
20
  }
20
21
  async record(params) {
@@ -28,12 +29,13 @@ export class DesiredStageRecorder {
28
29
  const triggerAllowed = triggerEventAllowed(params.project, params.normalized.triggerEvent);
29
30
  const incomingAgentSessionId = params.normalized.agentSession?.id;
30
31
  const hasPendingWake = this.db.issueSessions.peekIssueSessionWake(params.project.id, normalizedIssue.id) !== undefined;
31
- if (!existingIssue && !this.isDelegatedToPatchRelay(params.project, normalizedIssue) && !incomingAgentSessionId) {
32
+ if (!existingIssue && !isDelegatedToPatchRelay(this.db, params.project, normalizedIssue) && !incomingAgentSessionId) {
32
33
  return { issue: undefined, wakeRunType: undefined, delegated: false };
33
34
  }
34
- const syncResult = await this.syncIssueDependencies(params.project.id, normalizedIssue);
35
+ const syncResult = await syncIssueDependencies(this.db, this.linearProvider, params.project.id, normalizedIssue);
35
36
  const hydratedIssue = syncResult.issue;
36
- const delegation = this.resolveDelegationTruth({
37
+ const delegation = resolveDelegationTruth({
38
+ db: this.db,
37
39
  project: params.project,
38
40
  normalizedIssue,
39
41
  hydratedIssue,
@@ -45,7 +47,7 @@ export class DesiredStageRecorder {
45
47
  activeRunId: activeRun?.id,
46
48
  });
47
49
  const delegated = delegation.delegated;
48
- const linkedPrAdoption = await this.resolveLinkedPrAdoption({
50
+ const linkedPrAdoption = await resolveLinkedPrAdoption({
49
51
  project: params.project,
50
52
  issue: hydratedIssue,
51
53
  existingIssue,
@@ -288,6 +290,7 @@ export class DesiredStageRecorder {
288
290
  },
289
291
  eventType: "child_changed",
290
292
  changeKind: "detached",
293
+ wakeDispatcher: this.wakeDispatcher,
291
294
  });
292
295
  }
293
296
  if (currentParentIssueId) {
@@ -311,6 +314,7 @@ export class DesiredStageRecorder {
311
314
  child: issue,
312
315
  eventType,
313
316
  changeKind,
317
+ wakeDispatcher: this.wakeDispatcher,
314
318
  });
315
319
  }
316
320
  }
@@ -320,115 +324,4 @@ export class DesiredStageRecorder {
320
324
  delegated,
321
325
  };
322
326
  }
323
- isDelegatedToPatchRelay(project, issue) {
324
- const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
325
- if (!installation?.actorId)
326
- return false;
327
- return issue.delegateId === installation.actorId;
328
- }
329
- resolveDelegationTruth(params) {
330
- const previousDelegated = params.existingIssue?.delegatedToPatchRelay;
331
- const observedDelegated = this.isDelegatedToPatchRelay(params.project, params.hydratedIssue);
332
- const explicitDelegateSignal = params.triggerEvent === "delegateChanged";
333
- const hasObservedDelegate = params.hydratedIssue.delegateId !== undefined;
334
- let delegated = observedDelegated;
335
- let reason = hasObservedDelegate
336
- ? "delegate_id_present"
337
- : `missing_delegate_identity_after_${params.hydration}`;
338
- if (!hasObservedDelegate && !explicitDelegateSignal && previousDelegated !== undefined) {
339
- delegated = previousDelegated;
340
- reason = `preserved_previous_delegation_after_${params.hydration}`;
341
- }
342
- if (previousDelegated !== delegated
343
- || params.hydration === "live_linear_failed"
344
- || (!hasObservedDelegate && previousDelegated !== undefined)) {
345
- appendDelegationObservedEvent(this.db, {
346
- projectId: params.project.id,
347
- linearIssueId: params.normalizedIssue.id,
348
- payload: {
349
- source: "linear_webhook",
350
- webhookId: params.webhookId,
351
- triggerEvent: params.triggerEvent,
352
- ...(params.actorId ? { actorId: params.actorId } : {}),
353
- ...(params.hydratedIssue.delegateId ? { observedDelegateId: params.hydratedIssue.delegateId } : {}),
354
- ...(previousDelegated !== undefined ? { previousDelegatedToPatchRelay: previousDelegated } : {}),
355
- observedDelegatedToPatchRelay: observedDelegated,
356
- appliedDelegatedToPatchRelay: delegated,
357
- hydration: params.hydration,
358
- ...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
359
- decision: "none",
360
- reason,
361
- },
362
- });
363
- }
364
- return { delegated };
365
- }
366
- async syncIssueDependencies(projectId, issue) {
367
- let source = issue;
368
- let hydration = "webhook_only";
369
- if (!source.relationsKnown) {
370
- const linear = await this.linearProvider.forProject(projectId);
371
- if (linear) {
372
- try {
373
- source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
374
- hydration = "live_linear";
375
- }
376
- catch {
377
- // Preserve existing dependency rows when webhook relation data is incomplete.
378
- hydration = "live_linear_failed";
379
- }
380
- }
381
- }
382
- if (source.relationsKnown) {
383
- this.db.issues.replaceIssueDependencies({
384
- projectId,
385
- linearIssueId: source.id,
386
- blockers: source.blockedBy.map((blocker) => ({
387
- blockerLinearIssueId: blocker.id,
388
- ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
389
- ...(blocker.title ? { blockerTitle: blocker.title } : {}),
390
- ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
391
- ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
392
- })),
393
- });
394
- }
395
- this.db.issues.replaceIssueParentLink({
396
- projectId,
397
- childLinearIssueId: source.id,
398
- parentLinearIssueId: source.parentId ?? null,
399
- });
400
- return { issue: source, hydration };
401
- }
402
- async resolveLinkedPrAdoption(params) {
403
- if (!params.delegated)
404
- return undefined;
405
- if (params.triggerEvent !== "delegateChanged")
406
- return undefined;
407
- if (params.existingIssue?.prNumber !== undefined)
408
- return undefined;
409
- const resolution = resolveLinkedPullRequest(params.issue.attachments, params.project.github?.repoFullName);
410
- if (resolution.kind === "none")
411
- return undefined;
412
- if (resolution.kind === "ambiguous") {
413
- return {
414
- factoryState: "awaiting_input",
415
- pendingRunType: null,
416
- pendingRunContext: undefined,
417
- issueUpdates: {},
418
- };
419
- }
420
- const remote = await readRemotePrState(resolution.reference.repoFullName, resolution.reference.prNumber);
421
- if (!remote) {
422
- return {
423
- factoryState: "awaiting_input",
424
- pendingRunType: null,
425
- pendingRunContext: undefined,
426
- issueUpdates: {
427
- prNumber: resolution.reference.prNumber,
428
- prUrl: resolution.reference.url,
429
- },
430
- };
431
- }
432
- return deriveLinkedPrAdoptionOutcome(params.project, resolution.reference.prNumber, remote);
433
- }
434
327
  }
@@ -0,0 +1,45 @@
1
+ import { mergeIssueMetadata } from "./decision-helpers.js";
2
+ /**
3
+ * Brings the local dependency / parent-link state for `issue` up to date.
4
+ * If the webhook payload doesn't already include relation data we fetch it
5
+ * from Linear directly so subsequent decisions don't operate on a
6
+ * stale-by-omission snapshot. Returns the resolved `IssueMetadata` plus a
7
+ * label describing where the relation data came from (used by the audit
8
+ * trail).
9
+ */
10
+ export async function syncIssueDependencies(db, linearProvider, projectId, issue) {
11
+ let source = issue;
12
+ let hydration = "webhook_only";
13
+ if (!source.relationsKnown) {
14
+ const linear = await linearProvider.forProject(projectId);
15
+ if (linear) {
16
+ try {
17
+ source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
18
+ hydration = "live_linear";
19
+ }
20
+ catch {
21
+ // Preserve existing dependency rows when webhook relation data is incomplete.
22
+ hydration = "live_linear_failed";
23
+ }
24
+ }
25
+ }
26
+ if (source.relationsKnown) {
27
+ db.issues.replaceIssueDependencies({
28
+ projectId,
29
+ linearIssueId: source.id,
30
+ blockers: source.blockedBy.map((blocker) => ({
31
+ blockerLinearIssueId: blocker.id,
32
+ ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
33
+ ...(blocker.title ? { blockerTitle: blocker.title } : {}),
34
+ ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
35
+ ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
36
+ })),
37
+ });
38
+ }
39
+ db.issues.replaceIssueParentLink({
40
+ projectId,
41
+ childLinearIssueId: source.id,
42
+ parentLinearIssueId: source.parentId ?? null,
43
+ });
44
+ return { issue: source, hydration };
45
+ }
@@ -0,0 +1,41 @@
1
+ import { resolveLinkedPullRequest } from "../linear-linked-pr-reconciliation.js";
2
+ import { readRemotePrState } from "../remote-pr-state.js";
3
+ import { deriveLinkedPrAdoptionOutcome } from "../delegation-linked-pr.js";
4
+ /**
5
+ * On `delegateChanged` for a newly-delegated issue with no recorded PR yet,
6
+ * try to adopt any pull request referenced in Linear attachments. Returns
7
+ * the desired stage / pending-run shape, or `undefined` if no adoption
8
+ * applies (wrong trigger, not delegated, PR already tracked, no candidate).
9
+ */
10
+ export async function resolveLinkedPrAdoption(input) {
11
+ if (!input.delegated)
12
+ return undefined;
13
+ if (input.triggerEvent !== "delegateChanged")
14
+ return undefined;
15
+ if (input.existingIssue?.prNumber !== undefined)
16
+ return undefined;
17
+ const resolution = resolveLinkedPullRequest(input.issue.attachments, input.project.github?.repoFullName);
18
+ if (resolution.kind === "none")
19
+ return undefined;
20
+ if (resolution.kind === "ambiguous") {
21
+ return {
22
+ factoryState: "awaiting_input",
23
+ pendingRunType: null,
24
+ pendingRunContext: undefined,
25
+ issueUpdates: {},
26
+ };
27
+ }
28
+ const remote = await readRemotePrState(resolution.reference.repoFullName, resolution.reference.prNumber);
29
+ if (!remote) {
30
+ return {
31
+ factoryState: "awaiting_input",
32
+ pendingRunType: null,
33
+ pendingRunContext: undefined,
34
+ issueUpdates: {
35
+ prNumber: resolution.reference.prNumber,
36
+ prUrl: resolution.reference.url,
37
+ },
38
+ };
39
+ }
40
+ return deriveLinkedPrAdoptionOutcome(input.project, resolution.reference.prNumber, remote);
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.68.0",
3
+ "version": "0.68.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {