patchrelay 0.78.0 → 0.79.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) {
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,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.0",
3
+ "version": "0.79.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -49,7 +49,7 @@
49
49
  "start": "node dist/index.js serve",
50
50
  "doctor": "node dist/index.js doctor",
51
51
  "restart": "node dist/index.js service restart",
52
- "deploy": "pnpm build && pnpm add -g . && node dist/index.js service restart",
52
+ "deploy": "pnpm pack --out /tmp/patchrelay-deploy.tgz && pnpm add -g /tmp/patchrelay-deploy.tgz && patchrelay service restart",
53
53
  "lint": "oxlint --ignore-path .gitignore .",
54
54
  "typecheck": "tsgo -p tsconfig.json --noEmit",
55
55
  "check": "pnpm typecheck",