patchrelay 0.78.1 → 0.80.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.
@@ -1,10 +1,14 @@
1
1
  import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
2
2
  import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
3
+ import { parseRunContextOrWarn, serializeRunContext } from "./run-context.js";
4
+ import { assertNever } from "./utils.js";
3
5
  const WRITER = "run-wake-planner";
4
6
  export class RunWakePlanner {
5
7
  db;
6
- constructor(db) {
8
+ logger;
9
+ constructor(db, logger) {
7
10
  this.db = db;
11
+ this.logger = logger;
8
12
  }
9
13
  resolveRunWake(issue) {
10
14
  const sessionWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId);
@@ -22,46 +26,52 @@ export class RunWakePlanner {
22
26
  let eventType;
23
27
  let dedupeKey;
24
28
  let eventContext = context;
25
- if (runType === "queue_repair") {
26
- eventType = "merge_steward_incident";
27
- dedupeKey = `${dedupeScope ?? "wake"}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`;
28
- }
29
- else if (runType === "ci_repair") {
30
- eventType = "settled_red_ci";
31
- dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
32
- }
33
- else if (runType === "review_fix" || runType === "branch_upkeep") {
34
- eventType = "review_changes_requested";
35
- const identity = buildRequestedChangesWakeIdentity({
36
- linearIssueId: issue.linearIssueId,
37
- runType,
38
- headSha: issue.prHeadSha,
39
- });
40
- dedupeKey = identity.dedupeKey;
41
- eventContext = {
42
- ...context,
43
- requestedChangesCoalesceKey: identity.coalesceKey,
44
- ...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
45
- };
46
- }
47
- else {
48
- eventType = "delegated";
49
- dedupeKey = `${dedupeScope ?? "wake"}:implementation:${issue.linearIssueId}`;
29
+ switch (runType) {
30
+ case "queue_repair":
31
+ eventType = "merge_steward_incident";
32
+ dedupeKey = `${dedupeScope ?? "wake"}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`;
33
+ break;
34
+ case "ci_repair":
35
+ eventType = "settled_red_ci";
36
+ dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
37
+ break;
38
+ case "review_fix":
39
+ case "branch_upkeep": {
40
+ eventType = "review_changes_requested";
41
+ const identity = buildRequestedChangesWakeIdentity({
42
+ linearIssueId: issue.linearIssueId,
43
+ runType,
44
+ headSha: issue.prHeadSha,
45
+ });
46
+ dedupeKey = identity.dedupeKey;
47
+ eventContext = {
48
+ ...context,
49
+ requestedChangesCoalesceKey: identity.coalesceKey,
50
+ ...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
51
+ };
52
+ break;
53
+ }
54
+ case "implementation":
55
+ eventType = "delegated";
56
+ dedupeKey = `${dedupeScope ?? "wake"}:implementation:${issue.linearIssueId}`;
57
+ break;
58
+ default:
59
+ return assertNever(runType, "Unhandled run type in wake event append");
50
60
  }
51
61
  return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
52
62
  projectId: issue.projectId,
53
63
  linearIssueId: issue.linearIssueId,
54
64
  eventType,
55
- ...(eventContext ? { eventJson: JSON.stringify(eventContext) } : {}),
65
+ ...(eventContext ? { eventJson: serializeRunContext(eventContext, "wake event context") } : {}),
56
66
  dedupeKey,
57
67
  }));
58
68
  }
59
69
  materializeLegacyPendingWake(issue, lease) {
60
70
  if (!issue.pendingRunType)
61
71
  return issue;
62
- const context = issue.pendingRunContextJson
63
- ? JSON.parse(issue.pendingRunContextJson)
64
- : undefined;
72
+ // Boundary over possibly-old DB rows: a legacy pending context that no
73
+ // longer parses is dropped (with a warning) instead of wedging the wake.
74
+ const context = parseRunContextOrWarn(issue.pendingRunContextJson, (message) => this.logger?.warn({ issueKey: issue.issueKey, linearIssueId: issue.linearIssueId }, `Dropping unparseable legacy pending run context: ${message}`), "legacy pending run context");
65
75
  this.appendWakeEventWithLease(lease, issue, issue.pendingRunType, context, "legacy_pending");
66
76
  const commit = this.db.issueSessions.commitIssueState({
67
77
  writer: WRITER,
@@ -1,42 +1,60 @@
1
1
  import { extractCompletionCheck } from "./completion-check.js";
2
- import { extractLatestAssistantSummary } from "./issue-session-events.js";
2
+ import { extractLatestAssistantSummary, parseIssueSessionEventOrWarn } from "./issue-session-events.js";
3
3
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
4
+ import { assertNever } from "./utils.js";
4
5
  function clean(value) {
5
6
  return sanitizeOperatorFacingText(value);
6
7
  }
8
+ function dirtyWorktreeSummary(payload) {
9
+ return payload?.dirtyWorktree === true ? payload.summary : undefined;
10
+ }
7
11
  function eventStatusNote(event) {
8
12
  if (!event)
9
13
  return undefined;
10
- const payload = event.eventJson ? parseEventJson(event.eventJson) : undefined;
11
- const dirtySummary = typeof payload?.summary === "string" && payload.dirtyWorktree === true
12
- ? payload.summary
13
- : undefined;
14
- switch (event.eventType) {
15
- case "stop_requested":
14
+ // Read-model boundary over possibly-old DB rows: degrade a bad payload to
15
+ // the payload-less note instead of failing the status view.
16
+ const typed = parseIssueSessionEventOrWarn(event);
17
+ if (!typed)
18
+ return undefined;
19
+ switch (typed.eventType) {
20
+ case "stop_requested": {
21
+ const dirtySummary = dirtyWorktreeSummary(typed.payload);
16
22
  if (dirtySummary)
17
23
  return `Operator stopped the run with dirty worktree: ${dirtySummary}. Use retry or delegate again to resume.`;
18
24
  return "Operator stopped the run. Use retry or delegate again to resume.";
19
- case "undelegated":
25
+ }
26
+ case "undelegated": {
27
+ const dirtySummary = dirtyWorktreeSummary(typed.payload);
20
28
  if (dirtySummary)
21
29
  return `Issue was un-delegated from PatchRelay with dirty worktree: ${dirtySummary}`;
22
30
  return "Issue was un-delegated from PatchRelay. Delegate it again to resume.";
31
+ }
23
32
  case "issue_removed":
24
33
  return "Issue was removed from Linear.";
25
34
  case "pr_closed":
26
35
  return "Pull request was closed without merging.";
27
36
  case "pr_merged":
28
37
  return "Pull request merged successfully.";
29
- default:
38
+ case "delegated":
39
+ case "delegation_observed":
40
+ case "child_changed":
41
+ case "child_delivered":
42
+ case "child_regressed":
43
+ case "direct_reply":
44
+ case "completion_check_continue":
45
+ case "followup_prompt":
46
+ case "followup_comment":
47
+ case "prompt_delivered":
48
+ case "self_comment":
49
+ case "operator_prompt":
50
+ case "review_changes_requested":
51
+ case "settled_red_ci":
52
+ case "merge_steward_incident":
53
+ case "operator_closed":
54
+ case "run_released_authority":
30
55
  return undefined;
31
- }
32
- }
33
- function parseEventJson(value) {
34
- try {
35
- const parsed = JSON.parse(value);
36
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
37
- }
38
- catch {
39
- return undefined;
56
+ default:
57
+ return assertNever(typed, "Unhandled issue session event in status note");
40
58
  }
41
59
  }
42
60
  export function deriveIssueStatusNote(params) {
@@ -137,7 +137,11 @@ export class TrackedIssueListQuery {
137
137
  ...(row.last_blocking_review_head_sha !== null ? { lastBlockingReviewHeadSha: String(row.last_blocking_review_head_sha) } : {}),
138
138
  ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
139
139
  });
140
- const waitingReason = detachedActiveRun ? derivedWaitingReason : sessionWaitingReason ?? derivedWaitingReason;
140
+ // The derivation (issue-execution-state.ts via waiting-reason.ts) is the
141
+ // single source; the stored session projection is only a fallback for
142
+ // rows whose live facts derive no reason. A detached active run means
143
+ // the projection is stale, so it is not consulted at all.
144
+ const waitingReason = derivedWaitingReason ?? (detachedActiveRun ? undefined : sessionWaitingReason);
141
145
  const latestRun = row.latest_run_type !== null && row.latest_run_status !== null
142
146
  ? {
143
147
  id: 0,
package/dist/utils.js CHANGED
@@ -87,6 +87,15 @@ export function safeJsonParse(value) {
87
87
  return undefined;
88
88
  }
89
89
  }
90
+ /**
91
+ * Exhaustiveness guard for discriminated-union switches (plan §D2): the call
92
+ * only typechecks when every union member is handled, so adding a new variant
93
+ * fails compilation at every consumer. At runtime it throws — reaching it
94
+ * means a value outside the union leaked past a parse boundary.
95
+ */
96
+ export function assertNever(value, message = "Unexpected value") {
97
+ throw new Error(`${message}: ${JSON.stringify(value)}`);
98
+ }
90
99
  export function redactSensitiveHeaders(headers) {
91
100
  return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, REDACTED_HEADER_NAMES.has(name.toLowerCase()) ? "[redacted]" : value]));
92
101
  }
@@ -1,4 +1,4 @@
1
- import { hasOpenPr } from "./pr-state.js";
1
+ import { deriveIssueExecutionState, } from "./issue-execution-state.js";
2
2
  export const PATCHRELAY_WAITING_REASONS = {
3
3
  activeWork: "PatchRelay is actively working",
4
4
  automationPaused: "PatchRelay automation is paused because the issue is undelegated",
@@ -16,82 +16,82 @@ export const PATCHRELAY_WAITING_REASONS = {
16
16
  waitingForOperatorIntervention: "Waiting on operator intervention",
17
17
  waitingForExternalReview: "Waiting on external review",
18
18
  };
19
+ /**
20
+ * `waitingReason` is a pure function of {@link IssueExecutionState} — the
21
+ * union (issue-execution-state.ts) is the single derivation of "why is this
22
+ * issue not moving"; this module only renders it for operators.
23
+ */
19
24
  export function derivePatchRelayWaitingReason(params) {
20
- if (params.delegatedToPatchRelay === false && params.factoryState !== "done" && params.factoryState !== "failed" && params.factoryState !== "escalated") {
21
- return params.factoryState === "awaiting_queue" || (hasLiveOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved")
22
- ? PATCHRELAY_WAITING_REASONS.automationPausedDownstream
23
- : PATCHRELAY_WAITING_REASONS.automationPaused;
24
- }
25
- if (params.activeRunType) {
26
- if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
27
- return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
28
- }
29
- if (params.factoryState === "done") {
30
- return PATCHRELAY_WAITING_REASONS.finalizingMergedChange;
31
- }
32
- return `PatchRelay is running ${humanize(params.activeRunType)}`;
33
- }
34
- if (params.activeRunId !== undefined) {
35
- return PATCHRELAY_WAITING_REASONS.activeWork;
36
- }
37
- if (params.orchestrationSettleUntil) {
38
- const settleAt = Date.parse(params.orchestrationSettleUntil);
39
- if (Number.isFinite(settleAt) && settleAt > Date.now()) {
25
+ return waitingReasonForExecutionState(deriveIssueExecutionState(params));
26
+ }
27
+ export function waitingReasonForExecutionState(state) {
28
+ switch (state.kind) {
29
+ case "undelegated":
30
+ return state.downstreamMayContinue
31
+ ? PATCHRELAY_WAITING_REASONS.automationPausedDownstream
32
+ : PATCHRELAY_WAITING_REASONS.automationPaused;
33
+ case "running":
34
+ // An inconsistent row still describes what is observably happening (a run
35
+ // occupies the slot); reconcilers act on the union kind, not this string.
36
+ case "inconsistent":
37
+ return describeRun(state.run);
38
+ case "settling":
40
39
  return PATCHRELAY_WAITING_REASONS.waitingForChildSettle;
41
- }
42
- }
43
- const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
44
- if (blockedByKeys.length > 0) {
45
- return `Blocked by ${blockedByKeys.join(", ")}`;
46
- }
47
- const checkName = params.latestFailureCheckName ?? "CI";
48
- switch (params.factoryState) {
49
- case "awaiting_input":
40
+ case "blocked":
41
+ return `Blocked by ${state.blockedByKeys.join(", ")}`;
42
+ case "waiting_input":
50
43
  return PATCHRELAY_WAITING_REASONS.waitingForOperatorInput;
51
- case "changes_requested":
52
- return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
53
- case "repairing_ci":
54
- return `Waiting to repair ${checkName}`;
55
- case "repairing_queue":
56
- return PATCHRELAY_WAITING_REASONS.waitingForMergeStewardRepair;
57
- case "awaiting_queue":
58
- return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
59
- case "done":
60
- return PATCHRELAY_WAITING_REASONS.workComplete;
61
- case "failed":
62
- case "escalated":
63
- return PATCHRELAY_WAITING_REASONS.waitingForOperatorIntervention;
64
- default:
44
+ case "awaiting_followup":
45
+ switch (state.followup) {
46
+ case "review_fix":
47
+ return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
48
+ case "ci_repair":
49
+ return `Waiting to repair ${state.checkName ?? "CI"}`;
50
+ case "queue_repair":
51
+ return PATCHRELAY_WAITING_REASONS.waitingForMergeStewardRepair;
52
+ }
65
53
  break;
66
- }
67
- if (params.prCheckStatus === "failed" || params.prCheckStatus === "failure") {
68
- return `${checkName} failed`;
69
- }
70
- if (params.prReviewState === "changes_requested") {
71
- if (params.prCheckStatus === "passed" || params.prCheckStatus === "success") {
72
- if (params.prHeadSha
73
- && params.lastBlockingReviewHeadSha
74
- && params.prHeadSha !== params.lastBlockingReviewHeadSha) {
75
- return PATCHRELAY_WAITING_REASONS.waitingForReviewOnNewHead;
54
+ case "terminal":
55
+ return state.outcome === "done"
56
+ ? PATCHRELAY_WAITING_REASONS.workComplete
57
+ : PATCHRELAY_WAITING_REASONS.waitingForOperatorIntervention;
58
+ case "idle_awaiting_external":
59
+ switch (state.waitingOn) {
60
+ case "merge_queue":
61
+ case "downstream_automation":
62
+ return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
63
+ case "ci_failure":
64
+ return `${state.checkName ?? "CI"} failed`;
65
+ case "review_of_new_head":
66
+ return PATCHRELAY_WAITING_REASONS.waitingForReviewOnNewHead;
67
+ case "blocking_review_same_head":
68
+ return PATCHRELAY_WAITING_REASONS.sameHeadStillBlocked;
69
+ case "review_feedback":
70
+ return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
71
+ case "external_review":
72
+ return PATCHRELAY_WAITING_REASONS.waitingForExternalReview;
76
73
  }
77
- return PATCHRELAY_WAITING_REASONS.sameHeadStillBlocked;
78
- }
79
- return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
80
- }
81
- if (params.prReviewState === "approved") {
82
- return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
74
+ break;
75
+ case "ready":
76
+ return `Ready to run ${humanize(state.pendingRunType)}`;
77
+ case "idle":
78
+ return undefined;
83
79
  }
84
- if (hasOpenPr(params.prNumber, params.prState)) {
85
- return PATCHRELAY_WAITING_REASONS.waitingForExternalReview;
80
+ return undefined;
81
+ }
82
+ function describeRun(run) {
83
+ if (!run.runType) {
84
+ return PATCHRELAY_WAITING_REASONS.activeWork;
86
85
  }
87
- if (params.pendingRunType) {
88
- return `Ready to run ${humanize(params.pendingRunType)}`;
86
+ switch (run.phase) {
87
+ case "finalizing_published_pr":
88
+ return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
89
+ case "finalizing_merged_change":
90
+ return PATCHRELAY_WAITING_REASONS.finalizingMergedChange;
91
+ case "working":
92
+ return `PatchRelay is running ${humanize(run.runType)}`;
89
93
  }
90
- return undefined;
91
94
  }
92
95
  function humanize(value) {
93
96
  return value.replaceAll("_", " ");
94
97
  }
95
- function hasLiveOpenPr(prNumber, prState) {
96
- return prNumber !== undefined && (prState === undefined || prState === "open");
97
- }
@@ -1,3 +1,4 @@
1
+ import { serializeRunContext } from "../run-context.js";
1
2
  /**
2
3
  * Picks the single factoryState the caller should write. Priority is the
3
4
  * inverse of the previous spread order — the LAST spread wins in JS, so we
@@ -48,7 +49,7 @@ function buildStartupResumeContextJson(input) {
48
49
  if (input.startupResume.pendingRunType === undefined)
49
50
  return undefined;
50
51
  return input.startupResume.pendingRunContext
51
- ? JSON.stringify(input.startupResume.pendingRunContext)
52
+ ? serializeRunContext(input.startupResume.pendingRunContext, "startup resume context")
52
53
  : null;
53
54
  }
54
55
  export function resolveIssueUpdatePlan(input) {
@@ -1,4 +1,5 @@
1
1
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
2
+ import { tryParseRunContextValue } from "./run-context.js";
2
3
  function parseObjectJson(raw) {
3
4
  if (!raw)
4
5
  return undefined;
@@ -12,6 +13,17 @@ function parseObjectJson(raw) {
12
13
  return undefined;
13
14
  }
14
15
  }
16
+ // Boundary over reconciliation columns (failure context / queue incident /
17
+ // CI snapshot JSON) merged into an implicit wake's run context. Degrading a
18
+ // schema-rejected value to "no context" matches the pre-existing behavior of
19
+ // parseObjectJson for malformed JSON; the persistence layer has no logger to
20
+ // warn through.
21
+ function parseRunContextColumn(raw) {
22
+ const value = parseObjectJson(raw);
23
+ if (!value)
24
+ return undefined;
25
+ return tryParseRunContextValue(value);
26
+ }
15
27
  function hasUnattemptedFailureSignature(issue, fallbackHeadSha) {
16
28
  const signature = issue.lastGitHubFailureSignature;
17
29
  if (!signature)
@@ -33,11 +45,11 @@ export function deriveImplicitReactiveWake(issue) {
33
45
  if (!reactiveIntent)
34
46
  return undefined;
35
47
  if (reactiveIntent.runType === "ci_repair") {
36
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson) ?? {};
37
- const snapshot = parseObjectJson(issue.lastGitHubCiSnapshotJson);
38
- const fallbackHeadSha = typeof failureContext.failureHeadSha === "string"
39
- ? failureContext.failureHeadSha
40
- : issue.lastGitHubFailureHeadSha ?? issue.prHeadSha;
48
+ const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson) ?? {};
49
+ const snapshotValue = parseObjectJson(issue.lastGitHubCiSnapshotJson);
50
+ const snapshot = snapshotValue ? tryParseRunContextValue({ ciSnapshot: snapshotValue })?.ciSnapshot : undefined;
51
+ const fallbackHeadSha = failureContext.failureHeadSha
52
+ ?? issue.lastGitHubFailureHeadSha ?? issue.prHeadSha;
41
53
  const failureSignature = issue.lastGitHubFailureSignature
42
54
  ?? (fallbackHeadSha ? `implicit_branch_ci::${fallbackHeadSha}` : undefined);
43
55
  if (!failureSignature || issue.prState !== "open")
@@ -59,11 +71,9 @@ export function deriveImplicitReactiveWake(issue) {
59
71
  };
60
72
  }
61
73
  if (reactiveIntent.runType === "queue_repair") {
62
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson) ?? {};
63
- const incidentContext = parseObjectJson(issue.lastQueueIncidentJson) ?? {};
64
- const fallbackHeadSha = typeof failureContext.failureHeadSha === "string"
65
- ? failureContext.failureHeadSha
66
- : undefined;
74
+ const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson) ?? {};
75
+ const incidentContext = parseRunContextColumn(issue.lastQueueIncidentJson) ?? {};
76
+ const fallbackHeadSha = failureContext.failureHeadSha;
67
77
  if (!hasUnattemptedFailureSignature(issue, fallbackHeadSha))
68
78
  return undefined;
69
79
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.78.1",
3
+ "version": "0.80.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {