@plannotator/pi-extension 0.28.37 → 0.28.39

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.
@@ -84,6 +84,15 @@ const RELEASE_CHECK_TIMEOUT_MS = 8_000;
84
84
  */
85
85
  export const RELEASE_ASSETS_FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000;
86
86
  export const RELEASE_ASSETS_FETCH_RETRY_MS = 6 * 60 * 60 * 1000;
87
+ /**
88
+ * A release's notes answered with no "What's New" part while the release is
89
+ * young (published under RELEASE_NOTES_YOUNG_MS ago): GitHub's generated
90
+ * body, read in the minutes before the written notes replaced it. Asked again
91
+ * after this instead of RELEASE_ASSETS_FETCH_INTERVAL_MS, so the "What's new"
92
+ * link shows within the hour, not a day later.
93
+ */
94
+ export const RELEASE_NOTES_PENDING_RETRY_MS = 15 * 60 * 1000;
95
+ export const RELEASE_NOTES_YOUNG_MS = 2 * 24 * 60 * 60 * 1000;
87
96
  /** "Remind me tomorrow" in the Inbox. */
88
97
  export const UPDATE_SNOOZE_MS = 24 * 60 * 60 * 1000;
89
98
  /** The terminal hint shows at most this often. */
@@ -145,6 +154,34 @@ export function releaseUrlFor(version: string): string {
145
154
  // State (update-state.json, shared with auto-update)
146
155
  // ---------------------------------------------------------------------------
147
156
 
157
+ /** One version's notes fetch (`notesFetch[<version>]`). */
158
+ export interface NotesFetchRecord {
159
+ attemptedAt: number;
160
+ failedAt?: number;
161
+ error?: string;
162
+ /**
163
+ * The release's `published_at` (epoch ms), kept when its answer carried no
164
+ * "What's New" part: while it is under RELEASE_NOTES_YOUNG_MS old, the next
165
+ * try waits RELEASE_NOTES_PENDING_RETRY_MS instead of a day.
166
+ */
167
+ publishedAt?: number;
168
+ /**
169
+ * Set on the claim, gone once GitHub answers: a claim still marked was
170
+ * abandoned (its process quit mid-request), and is asked again after
171
+ * RELEASE_NOTES_PENDING_RETRY_MS, not read as an empty answer (24 h).
172
+ */
173
+ inFlight?: boolean;
174
+ /**
175
+ * Set on a failure that may have been the network (not a rate limit, and
176
+ * no release check answered between the failure before it and this one):
177
+ * asked again RELEASE_NOTES_PENDING_RETRY_MS after it once a release check
178
+ * has answered since (the network is back), not after 6 h. One quick retry,
179
+ * then 6 h: a source that keeps failing while the network is up (or a
180
+ * network that drops again before the quick retry) waits 6 h.
181
+ */
182
+ retryOnline?: boolean;
183
+ }
184
+
148
185
  export interface UpdateState {
149
186
  /** Auto-update's install window (epoch ms of the last check that got past its gates). */
150
187
  lastCheckAt?: number;
@@ -217,17 +254,24 @@ export interface UpdateState {
217
254
  * skipped from it, so "Also in the releases since X" survives the update.
218
255
  */
219
256
  inboxPreviousVersion?: string;
257
+ /**
258
+ * The version an Inbox start found newer than `inboxVersion`, kept until its
259
+ * "What's new" link was offered with readable notes: a start on that same
260
+ * version shows the link too, so a restart before the notes were readable
261
+ * does not lose it.
262
+ */
263
+ whatsNewPending?: string;
220
264
  /**
221
265
  * One release's notes fetched by its tag (fetchReleaseNotesFor), per
222
266
  * version: claimed before the request, so concurrent processes ask once.
223
267
  */
224
- notesFetch?: Record<string, { attemptedAt: number; failedAt?: number; error?: string }>;
268
+ notesFetch?: Record<string, NotesFetchRecord>;
225
269
  /**
226
270
  * The Inbox's fetch of one release's images (packages/server/inbox-whats-new-images.ts),
227
- * per version, with the same claim and back-off as `notesFetch`. Only the
228
- * Inbox writes it; nothing here fetches an image.
271
+ * per version, with the same claim and back-off as `notesFetch` (its
272
+ * `inFlight` mark too). Only the Inbox writes it; nothing here fetches an image.
229
273
  */
230
- releaseImagesFetch?: Record<string, { attemptedAt: number; failedAt?: number; error?: string }>;
274
+ releaseImagesFetch?: Record<string, Pick<NotesFetchRecord, "attemptedAt" | "failedAt" | "error" | "inFlight">>;
231
275
  /**
232
276
  * Safe updates: installs of `version` whose download did not match the
233
277
  * checksum Plannotator fetched (install.sh exit 113), counted once each.
@@ -1045,17 +1089,41 @@ export function scheduleReleaseCheck(deps: ReleaseCheckDeps, then?: (outcome: Re
1045
1089
  /** How many versions `notesFetch` remembers. */
1046
1090
  const NOTES_FETCH_REMEMBERED = 10;
1047
1091
 
1092
+ /** The record names a release young enough for the quick retry (a clock far off either way does not). */
1093
+ function isYoungRelease(record: NotesFetchRecord, now: number): boolean {
1094
+ const published = record.publishedAt;
1095
+ return typeof published === "number" && Number.isFinite(published) && Math.abs(now - published) < RELEASE_NOTES_YOUNG_MS;
1096
+ }
1097
+
1098
+ /**
1099
+ * A release check answered after `at` (the network is up again since then).
1100
+ * An answer dated after `now` (the clock moved back) does not count.
1101
+ */
1102
+ function releaseCheckAnsweredAfter(state: UpdateState, at: number, now: number): boolean {
1103
+ const answered = state.latestRelease?.fetchedAt;
1104
+ return typeof answered === "number" && answered > at && answered <= now;
1105
+ }
1106
+
1048
1107
  /**
1049
1108
  * Whether one version's notes may be asked for now: once, then after a
1050
- * failure no sooner than 6 h, and after an answer that carried no "What's
1051
- * New" part no sooner than 24 h (the release body is edited after publishing).
1052
- * Never in a loop.
1109
+ * failure no sooner than 6 h (15 minutes once the network is back, see
1110
+ * `retryOnline`), after an answer that carried no "What's New" part no
1111
+ * sooner than 15 minutes while the release is under 2 days old (the release
1112
+ * body is edited after publishing), else 24 h. A claim nobody answered (its
1113
+ * process quit) waits 15 minutes. Never sooner than 15 minutes, never in a loop.
1053
1114
  */
1054
1115
  export function isNotesFetchDue(state: UpdateState, version: string, now: number): boolean {
1055
1116
  const record = state.notesFetch?.[version];
1056
1117
  const at = record?.attemptedAt;
1057
1118
  if (!record || typeof at !== "number" || !Number.isFinite(at) || at > now) return true;
1058
- return now - at >= (record.failedAt ? RELEASE_ASSETS_FETCH_RETRY_MS : RELEASE_ASSETS_FETCH_INTERVAL_MS);
1119
+ const wait = record.failedAt
1120
+ ? record.retryOnline && releaseCheckAnsweredAfter(state, record.failedAt, now)
1121
+ ? RELEASE_NOTES_PENDING_RETRY_MS
1122
+ : RELEASE_ASSETS_FETCH_RETRY_MS
1123
+ : record.inFlight || isYoungRelease(record, now)
1124
+ ? RELEASE_NOTES_PENDING_RETRY_MS
1125
+ : RELEASE_ASSETS_FETCH_INTERVAL_MS;
1126
+ return now - at >= wait;
1059
1127
  }
1060
1128
 
1061
1129
  export interface ReleaseNotesFetchDeps {
@@ -1088,7 +1156,7 @@ export async function fetchReleaseNotesFor(deps: ReleaseNotesFetchDeps): Promise
1088
1156
  if (hasReleaseNotesContent(stored)) return { outcome: "present", notes: stored };
1089
1157
  if (!deps.enabled()) return { outcome: "disabled" };
1090
1158
  const now = (deps.now ?? Date.now)();
1091
- const record = (patch: { attemptedAt: number; failedAt?: number; error?: string }) =>
1159
+ const record = (patch: NotesFetchRecord) =>
1092
1160
  patchUpdateState((s) => {
1093
1161
  const entries = Object.entries({ ...(s.notesFetch ?? {}), [version]: patch })
1094
1162
  .filter(([, r]) => r && typeof r.attemptedAt === "number")
@@ -1096,10 +1164,18 @@ export async function fetchReleaseNotesFor(deps: ReleaseNotesFetchDeps): Promise
1096
1164
  .slice(0, NOTES_FETCH_REMEMBERED);
1097
1165
  return { ...s, notesFetch: Object.fromEntries(entries) };
1098
1166
  }, dataDir);
1167
+ let pendingBefore = false;
1168
+ let previous: NotesFetchRecord | undefined;
1169
+ let state: UpdateState;
1099
1170
  try {
1100
- if (!isNotesFetchDue(readUpdateState(dataDir), version, now)) return { outcome: "not-due" };
1101
- // Claim the attempt before the network call so concurrent processes ask once.
1102
- record({ attemptedAt: now });
1171
+ state = readUpdateState(dataDir);
1172
+ if (!isNotesFetchDue(state, version, now)) return { outcome: "not-due" };
1173
+ // Claim the attempt before the network call so concurrent processes ask
1174
+ // once. Marked in flight, so an attempt that never finished (the process
1175
+ // quit) is tried again on the quick cadence, not in a day.
1176
+ previous = state.notesFetch?.[version];
1177
+ pendingBefore = !!previous && !previous.failedAt && isYoungRelease(previous, now);
1178
+ record({ attemptedAt: now, inFlight: true, ...(pendingBefore && { publishedAt: previous!.publishedAt }) });
1103
1179
  } catch (err) {
1104
1180
  const error = err instanceof Error ? err.message : String(err);
1105
1181
  updateLog(`could not write update-state.json: ${error}`, dataDir);
@@ -1109,9 +1185,14 @@ export async function fetchReleaseNotesFor(deps: ReleaseNotesFetchDeps): Promise
1109
1185
  const tag = result.ok ? normalizeStableVersion(result.release.tag) : null;
1110
1186
  if (!result.ok || tag !== version) {
1111
1187
  const error = result.ok ? `the release answered for ${JSON.stringify(result.release.tag).slice(0, 80)}` : result.error;
1112
- updateLog(`could not read the release notes for ${version} (${error}); next try in 6h`, dataDir);
1188
+ // Maybe the network (a laptop waking, a VPN): asked again 15 minutes after,
1189
+ // once a release check answers. Not after a rate limit, and not when this
1190
+ // source failed before with a release check answering since (it keeps
1191
+ // failing while the network is up: 6 h).
1192
+ const retryOnline = !(!result.ok && result.rateLimited) && !(previous?.failedAt && releaseCheckAnsweredAfter(state, previous.failedAt, now));
1193
+ updateLog(`could not read the release notes for ${version} (${error}); next try in ${retryOnline ? "15m once online, else 6h" : "6h"}`, dataDir);
1113
1194
  try {
1114
- record({ attemptedAt: now, failedAt: now, error: error.slice(0, 200) });
1195
+ record({ attemptedAt: now, failedAt: now, error: error.slice(0, 200), ...(retryOnline && { retryOnline }) });
1115
1196
  } catch {}
1116
1197
  return { outcome: "failed", error };
1117
1198
  }
@@ -1123,6 +1204,24 @@ export async function fetchReleaseNotesFor(deps: ReleaseNotesFetchDeps): Promise
1123
1204
  } catch (err) {
1124
1205
  updateLog(`could not save the release notes: ${err instanceof Error ? err.message : String(err)}`, dataDir);
1125
1206
  }
1207
+ if (!notes) {
1208
+ // GitHub's generated body, read before the written notes replaced it (or
1209
+ // a release with none): a young release is asked again in 15 minutes.
1210
+ const published = release.publishedAt ? Date.parse(release.publishedAt) : NaN;
1211
+ const pending: NotesFetchRecord = { attemptedAt: now, ...(Number.isFinite(published) && { publishedAt: published }) };
1212
+ try {
1213
+ record(pending);
1214
+ } catch {}
1215
+ // One line per version, not one per retry.
1216
+ if (isYoungRelease(pending, now) && !pendingBefore) {
1217
+ updateLog(`the release notes for ${version} have no "What's New" part yet; next try in ${formatWait(RELEASE_NOTES_PENDING_RETRY_MS)}`, dataDir);
1218
+ }
1219
+ } else {
1220
+ // Answered: no longer in flight (it matters only when the notes could not be saved).
1221
+ try {
1222
+ record({ attemptedAt: now });
1223
+ } catch {}
1224
+ }
1126
1225
  // The release page names no publish date: take this answer's for the latest release.
1127
1226
  if (release.publishedAt) {
1128
1227
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.28.37",
3
+ "version": "0.28.39",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",