patchrelay 0.74.8 → 0.75.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,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.74.8",
4
- "commit": "5016ef557bde",
5
- "builtAt": "2026-05-29T12:02:10.583Z"
3
+ "version": "0.75.0",
4
+ "commit": "ad9b09da1d53",
5
+ "builtAt": "2026-05-29T13:21:05.647Z"
6
6
  }
@@ -1,4 +1,7 @@
1
1
  import { useEffect, useRef } from "react";
2
+ const DETAIL_REHYDRATE_INTERVAL_MS = 3000;
3
+ const FEED_REHYDRATE_LIMIT = 100;
4
+ const MAX_CACHED_FEED_EVENTS = 300;
2
5
  export function useDetailStream(options) {
3
6
  const optionsRef = useRef(options);
4
7
  optionsRef.current = options;
@@ -14,10 +17,24 @@ export function useDetailStream(options) {
14
17
  if (bearerToken) {
15
18
  headers.authorization = `Bearer ${bearerToken}`;
16
19
  }
17
- void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
20
+ const feedState = {
21
+ lastFeedEventId: undefined,
22
+ feedEvents: [],
23
+ };
24
+ let inFlight = false;
25
+ const runRehydrate = () => {
26
+ if (inFlight)
27
+ return;
28
+ inFlight = true;
29
+ void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch, feedState)
30
+ .finally(() => {
31
+ inFlight = false;
32
+ });
33
+ };
34
+ runRehydrate();
18
35
  const intervalId = setInterval(() => {
19
- void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
20
- }, 3000);
36
+ runRehydrate();
37
+ }, DETAIL_REHYDRATE_INTERVAL_MS);
21
38
  return () => {
22
39
  clearInterval(intervalId);
23
40
  abortController.abort();
@@ -25,17 +42,22 @@ export function useDetailStream(options) {
25
42
  }, [options.issueKey, options.active]);
26
43
  }
27
44
  // ─── Rehydration ──────────────────────────────────────────────────
28
- async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
45
+ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch, feedState) {
29
46
  try {
30
47
  const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}`, baseUrl);
31
- const response = await fetch(url, { headers: { ...headers, accept: "application/json" }, signal });
48
+ const feedUrl = buildFeedUrl(baseUrl, issueKey, feedState.lastFeedEventId);
49
+ const [response, newFeedEvents] = await Promise.all([
50
+ fetch(url, { headers: { ...headers, accept: "application/json" }, signal }),
51
+ fetchFeedEvents(feedUrl, headers, signal),
52
+ ]);
32
53
  if (!response.ok)
33
54
  return;
55
+ updateFeedState(feedState, newFeedEvents);
34
56
  const data = await response.json();
35
57
  dispatch({
36
58
  type: "timeline-rehydrate",
37
59
  runs: Array.isArray(data.runs) ? data.runs : [],
38
- feedEvents: [],
60
+ feedEvents: feedState.feedEvents,
39
61
  liveThread: data.liveThread ?? null,
40
62
  activeRunId: data.activeRun?.id ?? null,
41
63
  activeRunStartedAt: data.activeRun?.startedAt ?? null,
@@ -46,3 +68,39 @@ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
46
68
  // Rehydration is best-effort
47
69
  }
48
70
  }
71
+ function buildFeedUrl(baseUrl, issueKey, afterId) {
72
+ const feedUrl = new URL(`/api/issues/${encodeURIComponent(issueKey)}/feed`, baseUrl);
73
+ feedUrl.searchParams.set("limit", String(FEED_REHYDRATE_LIMIT));
74
+ if (afterId !== undefined) {
75
+ feedUrl.searchParams.set("afterId", String(afterId));
76
+ }
77
+ return feedUrl;
78
+ }
79
+ async function fetchFeedEvents(url, headers, signal) {
80
+ try {
81
+ const response = await fetch(url, { headers: { ...headers, accept: "application/json" }, signal });
82
+ if (!response.ok)
83
+ return [];
84
+ const data = await response.json();
85
+ return Array.isArray(data.events) ? data.events : [];
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ function updateFeedState(feedState, newEvents) {
92
+ if (newEvents.length === 0)
93
+ return;
94
+ const byId = new Map();
95
+ for (const event of feedState.feedEvents) {
96
+ byId.set(event.id, event);
97
+ }
98
+ for (const event of newEvents) {
99
+ byId.set(event.id, event);
100
+ }
101
+ const feedEvents = [...byId.values()]
102
+ .sort((left, right) => left.id - right.id)
103
+ .slice(-MAX_CACHED_FEED_EVENTS);
104
+ feedState.feedEvents = feedEvents;
105
+ feedState.lastFeedEventId = feedEvents.at(-1)?.id;
106
+ }
package/dist/http.js CHANGED
@@ -264,6 +264,19 @@ export async function buildHttpServer(config, service, logger) {
264
264
  }
265
265
  return reply.send({ ok: true, ...result });
266
266
  });
267
+ app.get("/api/issues/:issueKey/feed", async (request, reply) => {
268
+ const issueKey = request.params.issueKey;
269
+ const afterId = getPositiveIntegerQueryParam(request, "afterId");
270
+ const requestedLimit = getPositiveIntegerQueryParam(request, "limit");
271
+ const result = service.listIssueFeedEvents(issueKey, {
272
+ ...(afterId !== undefined ? { afterId } : {}),
273
+ limit: requestedLimit ? Math.min(requestedLimit, 100) : 100,
274
+ });
275
+ if (!result) {
276
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
277
+ }
278
+ return reply.send({ ok: true, events: result.events });
279
+ });
267
280
  app.get("/api/issues/:issueKey/live", async (request, reply) => {
268
281
  const issueKey = request.params.issueKey;
269
282
  const result = await service.getActiveRunStatus(issueKey);
package/dist/service.js CHANGED
@@ -338,6 +338,22 @@ export class PatchRelayService {
338
338
  async getIssueOverview(issueKey) {
339
339
  return await this.queryService.getIssueOverview(issueKey);
340
340
  }
341
+ listIssueFeedEvents(issueKey, options) {
342
+ const session = this.db.issueSessions.getIssueSessionByKey(issueKey);
343
+ const issue = this.db.issues.getIssueByKey(issueKey);
344
+ const projectId = session?.projectId ?? issue?.projectId;
345
+ const resolvedIssueKey = session?.issueKey ?? issue?.issueKey ?? issueKey;
346
+ if (!projectId)
347
+ return undefined;
348
+ return {
349
+ events: this.db.operatorFeed.list({
350
+ issueKey: resolvedIssueKey,
351
+ projectId,
352
+ ...(options?.afterId !== undefined ? { afterId: options.afterId } : {}),
353
+ limit: Math.min(options?.limit ?? 100, 100),
354
+ }),
355
+ };
356
+ }
341
357
  async getActiveRunStatus(issueKey) {
342
358
  return await this.orchestrator.getActiveRunStatus(issueKey);
343
359
  }
package/dist/telemetry.js CHANGED
@@ -78,8 +78,24 @@ export class OperatorFeedTelemetrySink {
78
78
  };
79
79
  }
80
80
  return undefined;
81
+ case "health.invariant":
82
+ return {
83
+ level: event.status === "observed" ? "warn" : "info",
84
+ kind: "workflow",
85
+ ...(event.issueKey ? { issueKey: event.issueKey } : {}),
86
+ ...(event.projectId ? { projectId: event.projectId } : {}),
87
+ ...(event.runType ? { stage: event.runType } : {}),
88
+ status: `health_${event.status}`,
89
+ summary: event.status === "observed"
90
+ ? `Health warning: ${formatInvariant(event.invariant)}`
91
+ : `Health repaired: ${formatInvariant(event.invariant)}`,
92
+ ...(event.detail ? { detail: event.detail } : {}),
93
+ };
81
94
  default:
82
95
  return undefined;
83
96
  }
84
97
  }
85
98
  }
99
+ function formatInvariant(invariant) {
100
+ return invariant.replaceAll("_", " ");
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.74.8",
3
+ "version": "0.75.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {