mirror-mind 0.33.0 → 0.33.1

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,128 @@
1
+ // Named entries out of a gzipped tar archive, in memory. (CR134)
2
+ //
3
+ // A package's pending release notes are read from the channel version's
4
+ // tarball, which `npm pack` fetched across a trust boundary: the registry is
5
+ // whatever the npm config names, and a project's `.npmrc` can name it (CR135).
6
+ // So nothing here writes a file, and no entry name becomes a path or an
7
+ // argument -- the caller asks for names and gets bytes. That is also why `tar`
8
+ // is not spawned: it would take archive-chosen member names as arguments.
9
+ // Inflation stops at a bound, because a small gzip can inflate to gigabytes.
10
+ //
11
+ // What is read is what tar writers produce: ustar headers with the POSIX
12
+ // `prefix` field, pax extended headers (`x`, whose `path` names the next
13
+ // entry), and GNU long names (`L`). Every header's checksum is checked, and an
14
+ // archive that stops before its end-of-archive block is refused rather than
15
+ // half-read: a tarball cut between two entries would otherwise lose the later
16
+ // ones -- the newest note among them -- without a word.
17
+
18
+ import { gunzipSync } from "node:zlib";
19
+
20
+ const BLOCK = 512;
21
+ const USTAR_MAGIC = Buffer.from("ustar\0", "latin1");
22
+
23
+ /** The bound on inflated bytes. A release tarball inflates to a few MB. */
24
+ export const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
25
+
26
+ export type TarEntries = { ok: true; entries: Map<string, Buffer> } | { ok: false; detail: string };
27
+
28
+ /** A NUL-terminated header field. */
29
+ function field(bytes: Buffer): string {
30
+ const end = bytes.indexOf(0);
31
+ return bytes.subarray(0, end === -1 ? bytes.length : end).toString("utf8");
32
+ }
33
+
34
+ /** An octal header number, or null when the field holds anything else. */
35
+ function octal(bytes: Buffer): number | null {
36
+ const value = field(bytes).trim();
37
+ return /^[0-7]+$/.test(value) ? Number.parseInt(value, 8) : null;
38
+ }
39
+
40
+ /** The checksum counts the header with its own field read as spaces. */
41
+ function checksumHolds(header: Buffer): boolean {
42
+ const recorded = octal(header.subarray(148, 156));
43
+ if (recorded === null) return false;
44
+ let sum = 0;
45
+ for (let index = 0; index < BLOCK; index += 1) {
46
+ sum += index >= 148 && index < 156 ? 0x20 : (header[index] as number);
47
+ }
48
+ return sum === recorded;
49
+ }
50
+
51
+ /** The `path` record of a pax extended header: records are `<len> <key>=<value>\n`. */
52
+ function paxPath(data: Buffer): string | null {
53
+ let path: string | null = null;
54
+ let offset = 0;
55
+ while (offset < data.length) {
56
+ const space = data.indexOf(0x20, offset);
57
+ if (space === -1) break;
58
+ const length = Number.parseInt(data.subarray(offset, space).toString("latin1"), 10);
59
+ if (!Number.isInteger(length) || length <= 0 || offset + length > data.length) break;
60
+ const record = data.subarray(space + 1, offset + length - 1).toString("utf8");
61
+ const equals = record.indexOf("=");
62
+ if (equals !== -1 && record.slice(0, equals) === "path") path = record.slice(equals + 1);
63
+ offset += length;
64
+ }
65
+ return path;
66
+ }
67
+
68
+ /**
69
+ * The regular-file entries of a gzipped tar whose names `wanted` accepts.
70
+ * Refuses, with a reason, an archive that inflates past the bound, is not
71
+ * gzip, fails a header checksum, or ends inside an entry.
72
+ */
73
+ export function readTarGzEntries(
74
+ gz: Buffer,
75
+ wanted: (name: string) => boolean,
76
+ maxInflatedBytes: number = MAX_INFLATED_BYTES,
77
+ ): TarEntries {
78
+ let tar: Buffer;
79
+ try {
80
+ tar = gunzipSync(gz, { maxOutputLength: maxInflatedBytes });
81
+ } catch (error) {
82
+ if ((error as { code?: string }).code === "ERR_BUFFER_TOO_LARGE") {
83
+ return { ok: false, detail: `the archive inflates to more than ${maxInflatedBytes} bytes` };
84
+ }
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ return { ok: false, detail: `unreadable gzip: ${message}` };
87
+ }
88
+
89
+ const entries = new Map<string, Buffer>();
90
+ let nextName: string | null = null;
91
+ let offset = 0;
92
+ while (offset + BLOCK <= tar.length) {
93
+ const header = tar.subarray(offset, offset + BLOCK);
94
+ if (header.every((byte) => byte === 0)) return { ok: true, entries };
95
+ if (!checksumHolds(header)) {
96
+ return { ok: false, detail: `the tar header at byte ${offset} fails its checksum` };
97
+ }
98
+ const size = octal(header.subarray(124, 136));
99
+ if (size === null) {
100
+ return { ok: false, detail: `the tar header at byte ${offset} has an unreadable size` };
101
+ }
102
+ const start = offset + BLOCK;
103
+ const end = start + size;
104
+ if (end > tar.length) return { ok: false, detail: "the archive is truncated" };
105
+ const data = tar.subarray(start, end);
106
+ const type = String.fromCharCode(header[156] as number);
107
+
108
+ if (type === "x") {
109
+ nextName = paxPath(data) ?? nextName;
110
+ } else if (type === "L") {
111
+ nextName = field(data);
112
+ } else if (type !== "g") {
113
+ const prefix = header.subarray(257, 263).equals(USTAR_MAGIC)
114
+ ? field(header.subarray(345, 500))
115
+ : "";
116
+ const base = field(header.subarray(0, 100));
117
+ const name = nextName ?? (prefix ? `${prefix}/${base}` : base);
118
+ nextName = null;
119
+ if ((type === "0" || type === "\0") && wanted(name)) entries.set(name, Buffer.from(data));
120
+ }
121
+ offset = start + Math.ceil(size / BLOCK) * BLOCK;
122
+ }
123
+ // The archive stopped before its end-of-archive block: inside an entry's
124
+ // padding, or cleanly between two entries. Entries after the last whole one
125
+ // may be missing, so nothing read here is presented as the whole (CI at
126
+ // 565b7b97 met the first; the second is the general case).
127
+ return { ok: false, detail: "the archive is truncated" };
128
+ }
@@ -2,36 +2,53 @@
2
2
  // `compose_welcome` from `src/memory/cli/welcome.py`.
3
3
  //
4
4
  // Unlike the status line, the card MAY reach the network: on the stable
5
- // channel it performs a bounded remote update check so a newly published
6
- // release becomes visible without manual cache repair. That check is
7
- // TTL-guarded, refresh-guarded, and switched off entirely by
5
+ // channel it performs a bounded remote update check at every render, so a
6
+ // newly published release becomes visible at the next session start. The
7
+ // check is switched off entirely by
8
8
  // `MIRROR_WELCOME_REMOTE_UPDATE_CHECK=off|0|false|no`.
9
9
  //
10
+ // The check asks the RUNNING INSTALL, never the cwd (CR135). Pi renders the
11
+ // card in the person's project, and the oracle asked whatever repository that
12
+ // was: a project with a tagged `origin/stable` made the card announce its tag.
13
+ // A package now reads its channel's dist-tag, as `runtime update --check`
14
+ // does, with npm run in the install's own root; a clone asks its own checkout
15
+ // and its own remote. One budget bounds the remote reads of a render.
16
+ //
10
17
  // Date rendering is done by hand from `_MONTH_ABBR`, never through
11
18
  // `toLocaleDateString`: the oracle formats the month itself, so a locale-aware
12
19
  // TypeScript formatter would render "since ago. 2025" on a machine whose
13
20
  // locale is not English and call it parity.
14
21
 
15
22
  import { existsSync, readFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
16
24
  import { basename, join } from "node:path";
17
25
  import { type Database, openDatabaseReadOnly } from "#db/database.ts";
18
26
  import { dbNameForEnv } from "#frontDoor/dbPath.ts";
19
27
  import { listJourneysForListCommand } from "#identity/journeyListing.ts";
28
+ import { channelFor } from "#runtime/channel.ts";
20
29
  import {
21
30
  checkUpdateAvailability,
31
+ GIT_NETWORK_TIMEOUT_MS,
22
32
  type GitStatus,
23
33
  inspectGit,
24
34
  inspectGitUpdatePlan,
25
35
  type MarkerValue,
36
+ NO_REPOSITORY,
26
37
  runGit,
27
38
  } from "#runtime/git.ts";
39
+ import type { InstallKind } from "#runtime/installKind.ts";
40
+ import {
41
+ checkPackageUpdateAvailability,
42
+ defaultNpmRunner,
43
+ type NpmRunner,
44
+ } from "#runtime/strategies/package.ts";
28
45
  import { compareSemver } from "#util/semver.ts";
29
46
  import { SEPARATOR } from "./statusLine.ts";
30
47
  import {
31
- cacheIsStale,
32
- cacheShouldRefresh,
48
+ isoNow,
33
49
  readUpdateCache,
34
50
  type UpdateAwareness,
51
+ verdictApplies,
35
52
  writeUpdateCache,
36
53
  } from "./updateCache.ts";
37
54
 
@@ -159,16 +176,21 @@ function commitsMatch(candidate: string, target: string): boolean {
159
176
  * Port of `_remote_tag_for_commit`: which published tag names the commit the
160
177
  * remote is at. `^{}` refs are the dereferenced peel of an annotated tag and
161
178
  * are skipped, so a tag is not counted twice.
179
+ *
180
+ * A network read: it ran on git's 2 s LOCAL budget until CR135, so a lookup
181
+ * slower than that cached an update without its version. The card passes
182
+ * what is left of its own budget.
162
183
  */
163
184
  export function remoteTagForCommit(
164
185
  upstream: string,
165
186
  remoteCommit: string,
166
187
  repository: string | null,
188
+ timeoutMs: number = GIT_NETWORK_TIMEOUT_MS,
167
189
  ): string | null {
168
190
  if (!upstream.includes("/")) return null;
169
191
  const remote = upstream.split("/", 1)[0] as string;
170
192
  if (repository === null) return null;
171
- const result = runGit(["ls-remote", "--tags", remote, "refs/tags/v*"], repository);
193
+ const result = runGit(["ls-remote", "--tags", remote, "refs/tags/v*"], repository, timeoutMs);
172
194
  if (result.code !== 0 || !result.stdout) return null;
173
195
 
174
196
  const matches: string[] = [];
@@ -206,76 +228,174 @@ export function localReleaseTitle(version: string | null, treeRoot: string): str
206
228
  return null;
207
229
  }
208
230
 
231
+ /** How long the card's remote reads may take, together (CR135, decision 1). */
232
+ export const WELCOME_REMOTE_BUDGET_MS = 5_000;
233
+
209
234
  export interface WelcomeOptions {
210
235
  /** The resolved mirror home. Null renders "", as in the oracle. */
211
236
  mirrorHome: string | null;
212
- /** Where git is inspected for the update check (a clone's working tree). */
213
- cwd?: string;
214
- /** The tree holding the running program, where `docs/releases` is read. Defaults to `cwd`. */
215
- treeRoot?: string;
237
+ /**
238
+ * The running install, detected from where the front door lives. The card's
239
+ * channel, its update check, and its "commits behind" line are this
240
+ * install's, whatever the cwd (CR135).
241
+ */
242
+ install: InstallKind;
216
243
  env?: NodeJS.ProcessEnv;
217
244
  version: string;
218
- updateChannel: MarkerValue;
245
+ /** The OS home whose config directory holds a package's channel file. */
246
+ osHome?: string;
247
+ /** The npm seam, injected as the update route's is. */
248
+ npm?: NpmRunner;
249
+ /** The budget of the remote reads; production takes `WELCOME_REMOTE_BUDGET_MS`. */
250
+ remoteBudgetMs?: number;
219
251
  /** Injected only so the written cache is byte-comparable; production omits it. */
220
252
  now?: () => string;
221
253
  }
222
254
 
223
- /** Port of `_iso_now`. */
224
- function isoNow(): string {
225
- return new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000+00:00");
255
+ /** What one render's remote check asked, how it answered, and how long it took. */
256
+ export interface RemoteCheck {
257
+ source: "registry" | "git";
258
+ verdict: string;
259
+ ms: number;
226
260
  }
227
261
 
228
- /** Port of `_refresh_update_cache`. Any failure leaves the cache untouched. */
229
- function refreshUpdateCache(
230
- homePath: string,
262
+ export interface WelcomeRender {
263
+ card: string;
264
+ /** Null when no remote check ran. */
265
+ check: RemoteCheck | null;
266
+ }
267
+
268
+ interface CheckSeams {
269
+ npm: NpmRunner;
270
+ budgetMs: number;
271
+ now: () => string;
272
+ }
273
+
274
+ type CheckedInstall = Exclude<InstallKind, { kind: "unknown" }>;
275
+
276
+ /**
277
+ * A package's verdict: its channel's dist-tag, read as `runtime update
278
+ * --check` reads it, in the install's own root. One read, so it gets the
279
+ * whole budget, and no retries: unanswered, npm retries for over a minute.
280
+ */
281
+ function checkPackage(
282
+ install: Extract<InstallKind, { kind: "package" }>,
283
+ channel: MarkerValue,
284
+ seams: CheckSeams,
285
+ ): UpdateAwareness {
286
+ const report = checkPackageUpdateAvailability(install, channel, seams.npm, {
287
+ timeoutMs: seams.budgetMs,
288
+ fetchRetries: 0,
289
+ });
290
+ const version =
291
+ report.status === "update_available" && report.resolved !== null ? `v${report.resolved}` : null;
292
+ return {
293
+ // `unresolved` is the cache's `unknown`: the check could not answer.
294
+ availability: report.status === "unresolved" ? "unknown" : report.status,
295
+ checked_at: seams.now(),
296
+ channel: channel.value,
297
+ current_commit: null,
298
+ remote_commit: null,
299
+ version,
300
+ title: localReleaseTitle(version, install.root),
301
+ note: report.note,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Port of `_refresh_update_cache`'s question, asked of the clone's own
307
+ * checkout. The `ls-remote` and then the tag lookup share one deadline.
308
+ */
309
+ function checkClone(
310
+ repository: string,
231
311
  channel: MarkerValue,
232
- cwd: string,
233
- treeRoot: string,
234
312
  version: string,
235
- now: () => string,
236
- ): UpdateAwareness | null {
237
- let report: ReturnType<typeof checkUpdateAvailability>;
238
- try {
239
- report = checkUpdateAvailability(cwd, channel.value, version);
240
- } catch {
241
- return null;
242
- }
313
+ seams: CheckSeams,
314
+ ): UpdateAwareness {
315
+ const deadline = Date.now() + seams.budgetMs;
316
+ // At least 1 ms: a spawn timeout of 0 is no timeout at all.
317
+ const report = checkUpdateAvailability(
318
+ repository,
319
+ channel.value,
320
+ version,
321
+ Math.max(1, deadline - Date.now()),
322
+ );
243
323
  let tag: string | null = null;
244
- let title: string | null = null;
245
324
  if (report.status === "update_available" && report.remote_commit && report.upstream) {
246
- tag = remoteTagForCommit(report.upstream, report.remote_commit, inspectGit(cwd).repository);
247
- title = localReleaseTitle(tag, treeRoot);
325
+ const left = deadline - Date.now();
326
+ if (left >= 1)
327
+ tag = remoteTagForCommit(report.upstream, report.remote_commit, repository, left);
248
328
  }
249
- const awareness: UpdateAwareness = {
329
+ return {
250
330
  availability: report.status,
251
- checked_at: now(),
331
+ checked_at: seams.now(),
252
332
  channel: channel.value,
253
333
  current_commit: report.local_commit,
254
334
  remote_commit: report.remote_commit,
255
335
  version: tag,
256
- title,
336
+ title: localReleaseTitle(tag, repository),
257
337
  note: report.note,
258
338
  };
259
- if (report.status === "up_to_date" || report.status === "update_available") {
260
- writeUpdateCache(homePath, awareness);
339
+ }
340
+
341
+ function checkInstall(
342
+ install: CheckedInstall,
343
+ channel: MarkerValue,
344
+ version: string,
345
+ seams: CheckSeams,
346
+ ): UpdateAwareness {
347
+ try {
348
+ return install.kind === "package"
349
+ ? checkPackage(install, channel, seams)
350
+ : checkClone(install.repository, channel, version, seams);
351
+ } catch (error) {
352
+ return {
353
+ availability: "unknown",
354
+ checked_at: seams.now(),
355
+ channel: channel.value,
356
+ current_commit: null,
357
+ remote_commit: null,
358
+ version: null,
359
+ title: null,
360
+ note: error instanceof Error ? error.message : String(error),
361
+ };
261
362
  }
262
- return awareness;
263
363
  }
264
364
 
265
- /** Port of `_update_awareness`: cache first, remote only when it must. */
365
+ /**
366
+ * Port of `_update_awareness`, asking the install instead of the cwd.
367
+ *
368
+ * On `stable` the install is asked at every render; on `main`, never. Only
369
+ * `up_to_date` and `update_available` are written. A check that could not
370
+ * answer (`unknown`) leaves the cache as it is, and the card shows what the
371
+ * cache knows, as the status line does. Any other answer (`local_ahead`,
372
+ * `diverged`, `channel_behind`) is an answer: no update to take, and nothing
373
+ * written.
374
+ */
266
375
  export function updateAwareness(
267
376
  homePath: string,
377
+ install: InstallKind,
268
378
  channel: MarkerValue,
269
- cwd: string,
270
379
  version: string,
271
380
  env: NodeJS.ProcessEnv,
272
- now: () => string,
273
- treeRoot: string = cwd,
274
- ): UpdateAwareness | null {
381
+ seams: CheckSeams,
382
+ ): { awareness: UpdateAwareness | null; check: RemoteCheck | null } {
275
383
  const cached = readUpdateCache(homePath);
276
- if (cached && !cacheIsStale(cached) && !cacheShouldRefresh(cached, channel)) return cached;
277
- if (remoteUpdateCheckDisabled(env) || channel.value !== "stable") return cached;
278
- return refreshUpdateCache(homePath, channel, cwd, treeRoot, version, now) ?? cached;
384
+ if (remoteUpdateCheckDisabled(env) || channel.value !== "stable" || install.kind === "unknown") {
385
+ return { awareness: cached, check: null };
386
+ }
387
+ const started = Date.now();
388
+ const fresh = checkInstall(install, channel, version, seams);
389
+ const check: RemoteCheck = {
390
+ source: install.kind === "package" ? "registry" : "git",
391
+ verdict: fresh.availability,
392
+ ms: Date.now() - started,
393
+ };
394
+ if (fresh.availability === "up_to_date" || fresh.availability === "update_available") {
395
+ writeUpdateCache(homePath, fresh);
396
+ return { awareness: fresh, check };
397
+ }
398
+ return { awareness: fresh.availability === "unknown" ? cached : fresh, check };
279
399
  }
280
400
 
281
401
  /** Port of `_update_line`. */
@@ -315,25 +435,27 @@ export function updateLine(
315
435
  * mirror home: a fresh database with nothing in it still renders the full card
316
436
  * with zeroes.
317
437
  */
318
- export function composeWelcome(options: WelcomeOptions): string {
438
+ export function composeWelcome(options: WelcomeOptions): WelcomeRender {
319
439
  const homePath = options.mirrorHome;
320
- if (homePath === null) return "";
321
- const cwd = options.cwd ?? process.cwd();
440
+ if (homePath === null) return { card: "", check: null };
322
441
  const env = options.env ?? process.env;
323
- const now = options.now ?? isoNow;
442
+ const install = options.install;
443
+ // Nothing here reads the cwd (CR135): the channel is the one `runtime
444
+ // channel` reports for this install, and so is everything below.
445
+ const channel = channelFor(install, env, options.osHome ?? homedir());
324
446
 
325
447
  const dbPath = join(homePath, dbNameForEnv(env.MEMORY_ENV || "production"));
326
448
  const stats = formatStats(readWelcomeStats(existsSync(dbPath) ? dbPath : null));
327
- const versionLine = `${options.version} · channel ${options.updateChannel.value}`;
328
- const awareness = updateAwareness(
329
- homePath,
330
- options.updateChannel,
331
- cwd,
332
- options.version,
333
- env,
334
- now,
335
- options.treeRoot ?? cwd,
336
- );
337
- const update = updateLine(options.updateChannel, awareness, inspectGit(cwd));
338
- return renderWelcome(basename(homePath), stats, versionLine, update);
449
+ const versionLine = `${options.version} · channel ${channel.value}`;
450
+ const { awareness, check } = updateAwareness(homePath, install, channel, options.version, env, {
451
+ npm: options.npm ?? defaultNpmRunner,
452
+ budgetMs: options.remoteBudgetMs ?? WELCOME_REMOTE_BUDGET_MS,
453
+ now: options.now ?? isoNow,
454
+ });
455
+ // A cached update the install already has is not shown (CR135).
456
+ const shown = awareness !== null && verdictApplies(awareness, options.version) ? awareness : null;
457
+ // A package has no checkout to be behind: its "commits behind" line is none.
458
+ const git = install.kind === "clone" ? inspectGit(install.repository) : NO_REPOSITORY;
459
+ const update = updateLine(channel, shown, git);
460
+ return { card: renderWelcome(basename(homePath), stats, versionLine, update), check };
339
461
  }
@@ -10,6 +10,10 @@
10
10
  // It reads the update CACHE, never the remote -- refreshing the cache is the
11
11
  // full card's job, and the one time that budget was allowed to leak into the
12
12
  // per-turn path it cost a 120s network timeout on a status bar.
13
+ //
14
+ // Since CR135 it also drops a cached update the install already has. The
15
+ // installed version is the caller's: the route reads it from the running
16
+ // tree's manifest, a file read, so this module still spawns nothing.
13
17
 
14
18
  import { existsSync } from "node:fs";
15
19
  import { basename, join } from "node:path";
@@ -18,13 +22,15 @@ import { dbNameForEnv } from "#frontDoor/dbPath.ts";
18
22
  import { getIdentityContent } from "#identity/identityRead.ts";
19
23
  import { GLOBAL_STICKY_DEFAULTS_SESSION_ID, getRuntimeSession } from "#mirror/runtimeSession.ts";
20
24
  import { getActiveOperatingMode, modeLabel } from "#mode/operatingMode.ts";
21
- import { readUpdateCache } from "./updateCache.ts";
25
+ import { readUpdateCache, verdictApplies } from "./updateCache.ts";
22
26
 
23
27
  export const SEPARATOR = " · ";
24
28
 
25
29
  export interface StatusLineOptions {
26
30
  /** The resolved mirror home. Null renders nothing, as in the oracle. */
27
31
  mirrorHome: string | null;
32
+ /** The running install's version: a cached update to it, or to an older one, is not shown. */
33
+ installedVersion: string;
28
34
  sessionId?: string | null;
29
35
  env?: NodeJS.ProcessEnv;
30
36
  }
@@ -92,7 +98,11 @@ export function composeStatusLine(options: StatusLineOptions): string {
92
98
  if (mode) parts.push(mode);
93
99
 
94
100
  const awareness = readUpdateCache(homePath);
95
- if (awareness && awareness.availability === "update_available") {
101
+ if (
102
+ awareness &&
103
+ awareness.availability === "update_available" &&
104
+ verdictApplies(awareness, options.installedVersion)
105
+ ) {
96
106
  parts.push(`⬆${awareness.version ? ` ${awareness.version}` : ""}`);
97
107
  } else {
98
108
  parts.push("✓");
@@ -7,14 +7,19 @@
7
7
  // nicety: two cores that serialize the same state differently would rewrite the
8
8
  // file on every alternating invocation and thrash the 6h TTL, turning a bounded
9
9
  // remote check into one on every turn.
10
+ //
11
+ // CR135 removed that TTL, which gated nothing: on `stable` every verdict the
12
+ // card writes is refreshed at the next render, and on `main` the card never
13
+ // checks. What a reader needs instead is to know whether a verdict still
14
+ // APPLIES to the install reading it (`verdictApplies`), and a successful
15
+ // `runtime update` now says what it installed (`recordUpToDate`).
10
16
 
11
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
18
  import { dirname, join } from "node:path";
13
- import type { MarkerValue } from "#runtime/git.ts";
14
19
  import { pythonJsonDumpsIndented } from "#util/pyGenerators.ts";
20
+ import { compareSemver } from "#util/semver.ts";
15
21
 
16
22
  export const UPDATE_CHECK_CACHE = "runtime/update-check.json";
17
- export const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
18
23
 
19
24
  export interface UpdateAwareness {
20
25
  availability: string;
@@ -92,38 +97,51 @@ export function writeUpdateCache(homePath: string, awareness: UpdateAwareness):
92
97
  }
93
98
  }
94
99
 
95
- /** Port of `_parse_iso`, narrowed to the timestamps this cache carries. */
96
- export function parseIso(value: string | null): Date | null {
97
- if (!value) return null;
98
- const parsed = Date.parse(value.replace("Z", "+00:00"));
99
- return Number.isNaN(parsed) ? null : new Date(parsed);
100
+ /** Port of `_iso_now`: the cache's timestamp shape, microseconds and all. */
101
+ export function isoNow(): string {
102
+ return new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000+00:00");
100
103
  }
101
104
 
102
105
  /**
103
- * Port of `_cache_is_stale`. A timestamp that cannot be parsed is stale, not
104
- * fresh -- the safe direction for a cache that gates a network call.
106
+ * Does a cached verdict still apply to the install reading it? (CR135)
105
107
  *
106
- * A NAIVE timestamp (no offset) is read as UTC, matching the oracle's explicit
107
- * `replace(tzinfo=timezone.utc)`. JavaScript would otherwise read it as local
108
- * time and shift the TTL by the machine's offset.
108
+ * One verdict goes stale in a way the reader can see: an update to a version
109
+ * the install already has, or has passed -- after an update, after a manual
110
+ * install, or when another install over the same home wrote it. Such a
111
+ * verdict is not shown, by the card or by the status line. A verdict without
112
+ * a version, from a clone's tag lookup that found none, cannot be judged and
113
+ * stands. A version that does not parse as `vX.Y.Z` sorts below every real one
114
+ * (`#util/semver.ts`), so it is never newer and is not shown.
109
115
  */
110
- export function cacheIsStale(awareness: UpdateAwareness, now: Date = new Date()): boolean {
111
- const hasOffset = /(?:Z|[+-]\d{2}:\d{2})$/.test(awareness.checked_at);
112
- const checkedAt = parseIso(hasOffset ? awareness.checked_at : `${awareness.checked_at}Z`);
113
- if (checkedAt === null) return true;
114
- return now.getTime() - checkedAt.getTime() > UPDATE_CHECK_TTL_MS;
116
+ export function verdictApplies(awareness: UpdateAwareness, installedVersion: string): boolean {
117
+ if (awareness.availability !== "update_available" || awareness.version === null) return true;
118
+ return compareSemver(awareness.version, installedVersion) > 0;
119
+ }
120
+
121
+ export interface UpToDateRecord {
122
+ /** The channel the update followed. */
123
+ channel: string;
124
+ /** A clone's commit after the update; null for a package, which has none. */
125
+ commit: string | null;
126
+ checkedAt: string;
115
127
  }
116
128
 
117
129
  /**
118
- * Port of `_cache_should_refresh`: stable-channel awareness is refreshed even
119
- * inside the TTL. A cached notice may point at an intermediate release, and a
120
- * cached up-to-date answer goes wrong the moment stable advances -- opening
121
- * Mirror should show the newly published version without manually fetching
122
- * refs or deleting this file.
130
+ * What a successful `runtime update` knows, written where the card and the
131
+ * status line read it (CR135): the install now holds its channel's version.
132
+ * Until CR135 nothing rewrote the cache after an update, so the status line
133
+ * kept announcing the version just installed until some later check, from
134
+ * some repository, said otherwise.
123
135
  */
124
- export function cacheShouldRefresh(awareness: UpdateAwareness, channel: MarkerValue): boolean {
125
- return (
126
- (awareness.availability === "up_to_date" || awareness.availability === "update_available") &&
127
- channel.value === "stable"
128
- );
136
+ export function recordUpToDate(homePath: string, record: UpToDateRecord): void {
137
+ writeUpdateCache(homePath, {
138
+ availability: "up_to_date",
139
+ checked_at: record.checkedAt,
140
+ channel: record.channel,
141
+ current_commit: record.commit,
142
+ remote_commit: record.commit,
143
+ version: null,
144
+ title: null,
145
+ note: null,
146
+ });
129
147
  }