@aglyn/aglyn 1.0.0-beta.266 → 1.0.0-beta.268

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/aglyn",
3
- "version": "1.0.0-beta.266",
3
+ "version": "1.0.0-beta.268",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -37,16 +37,16 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@aglyn/shared-data-enums": "1.0.0-beta.266",
41
- "@aglyn/shared-data-mdi": "1.0.0-beta.266",
42
- "@aglyn/shared-data-types": "1.0.0-beta.266",
43
- "@aglyn/shared-util-email": "1.0.0-beta.266",
44
- "@aglyn/shared-util-first-touch": "1.0.0-beta.266",
45
- "@aglyn/shared-util-http": "1.0.0-beta.266",
46
- "@aglyn/shared-util-logger": "1.0.0-beta.266",
47
- "@aglyn/shared-util-timestamp": "1.0.0-beta.266",
48
- "@aglyn/shared-util-tools": "1.0.0-beta.266",
49
- "@aglyn/shared-util-vendor": "1.0.0-beta.266",
40
+ "@aglyn/shared-data-enums": "1.0.0-beta.268",
41
+ "@aglyn/shared-data-mdi": "1.0.0-beta.268",
42
+ "@aglyn/shared-data-types": "1.0.0-beta.268",
43
+ "@aglyn/shared-util-email": "1.0.0-beta.268",
44
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.268",
45
+ "@aglyn/shared-util-http": "1.0.0-beta.268",
46
+ "@aglyn/shared-util-logger": "1.0.0-beta.268",
47
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.268",
48
+ "@aglyn/shared-util-tools": "1.0.0-beta.268",
49
+ "@aglyn/shared-util-vendor": "1.0.0-beta.268",
50
50
  "@data-driven-forms/react-form-renderer": "^4.2.0",
51
51
  "@msgpack/msgpack": "^3.1.3",
52
52
  "@types/unist": "^3.0.3",
@@ -61,7 +61,7 @@
61
61
  "peerDependencies": {
62
62
  "@mui/material": "^9.3.1",
63
63
  "@mui/system": "^9.2.0",
64
- "firebase": "^12.18.0",
64
+ "firebase": "^13.0.0",
65
65
  "next": "^16.3.3",
66
66
  "react": "^19.2.8"
67
67
  },
@@ -531,20 +531,51 @@ export declare function signupDroughtHealth(markers: SignupServedMarker[] | null
531
531
  * 10/17's). The streak of two was red only on 10/11, AGL-3724, and nowhere
532
532
  * else. A streak of three missed AGL-3724 entirely.
533
533
  *
534
+ * ## A site is not the question; reaching the console is (2026-10-18)
535
+ *
536
+ * The streak rule went red on 2026-10-18 at 15:37Z for the same reason the
537
+ * count did: the two newest people had no site, and both had LEFT. One left
538
+ * nine seconds after the workspace appeared. The other reached the site list,
539
+ * walked four steps of the guided start, and stopped. Sign-ups on either side
540
+ * of them made sites on the same build. "No site" is an outcome that leaving
541
+ * produces as surely as a broken landing does, so no rule over sites alone
542
+ * can tell the two apart.
543
+ *
544
+ * What AGL-3724 broke was the landing itself: the person never REACHED the
545
+ * console after the workspace was made. That is recorded now. Every org is
546
+ * born with `landedAtMs: null` ({@link ORG_LANDED_AT_PATH}), and the org
547
+ * shell's activity beat, which fires the moment the workspace opens, sets it
548
+ * the first time (`stampOrgLastActivity`). So an org counts as stranded only
549
+ * when it has no site AND its owner never reached the console. A person who
550
+ * arrived and then left is a drop-off, which is not this check's business.
551
+ *
552
+ * An org without the field predates the marker, and the check has no
553
+ * evidence about it either way, so it ABSTAINS on it (`unmarkedOrgs`). It is
554
+ * never counted as stranded, the way a check that cannot see its subject
555
+ * says so instead of guessing.
556
+ *
534
557
  * Pure, like its siblings: the route reads, this decides. The body carries
535
558
  * counts only, never an org id, slug or visitor key, because the endpoint is
536
559
  * public.
537
560
  */
538
561
  export interface SignupLandingCheck extends HealthCheck {
539
- /** Orgs in the window that have no site. Null when the read failed. */
562
+ /**
563
+ * Orgs in the window with no site whose owner never reached the console.
564
+ * Null when the read failed.
565
+ */
540
566
  strandedOrgs: number | null;
541
- /** People in the window with no site in any of their orgs. */
567
+ /** People in the window none of whose orgs has a site or a landing. */
542
568
  strandedPeople: number | null;
543
569
  /**
544
- * How many of the newest people in the window, in a row, have no site:
570
+ * How many of the newest people in the window, in a row, are stranded:
545
571
  * what the verdict reads. Null when the read failed.
546
572
  */
547
573
  strandedStreak: number | null;
574
+ /**
575
+ * Orgs with no site that predate the landing marker, which the check
576
+ * abstains on. Null when the read failed.
577
+ */
578
+ unmarkedOrgs: number | null;
548
579
  /** Every org in the window, the context for the counts above. */
549
580
  orgsInWindow: number | null;
550
581
  /** How far back the window reaches. */
@@ -569,12 +600,21 @@ export declare const SIGNUP_LANDING_GRACE_MINUTES = 30;
569
600
  */
570
601
  export declare const MAX_STRANDED_SIGNUPS = 2;
571
602
  /**
572
- * One org as the landing check reads it: whether it has any site, when it
573
- * was made, and who made it. `visitor` is an opaque key that never leaves
574
- * the check; an org without one is its own person.
603
+ * Where an org records that its owner reached the console (see
604
+ * {@link SignupLandingCheck}). `null` from birth, epoch millis once the
605
+ * workspace has been opened, absent on an org made before it existed.
606
+ */
607
+ export declare const ORG_LANDED_AT_PATH = "landedAtMs";
608
+ /**
609
+ * One org as the landing check reads it: whether it has any site, whether
610
+ * its owner reached the console, when it was made, and who made it.
611
+ * `landed` is `undefined` for an org that predates the marker. `visitor` is
612
+ * an opaque key that never leaves the check; an org without one is its own
613
+ * person.
575
614
  */
576
615
  export interface SignupLandingOrg {
577
616
  hostCount: number;
617
+ landed?: boolean;
578
618
  createdAtMs?: number | null;
579
619
  visitor?: string | null;
580
620
  }
@@ -1497,6 +1537,10 @@ export interface CronBeatStore {
1497
1537
  collection(name: string): {
1498
1538
  doc(id: string): {
1499
1539
  set(data: Record<string, unknown>, options?: unknown): Promise<unknown>;
1540
+ /** Present on a real Firestore handle; {@link writeCronBeats} reads the summary through it. */
1541
+ get?(): Promise<{
1542
+ get(field: string): unknown;
1543
+ }>;
1500
1544
  };
1501
1545
  };
1502
1546
  }
@@ -1514,6 +1558,34 @@ export declare const CRON_BEAT_EVERY_MINUTE_MIN_INTERVAL_MS: number;
1514
1558
  /** Test seam. */
1515
1559
  export declare function resetCronBeatThrottleForTests(): void;
1516
1560
  export declare function writeCronBeat(store: CronBeatStore, jobId: string, now?: number): Promise<boolean>;
1561
+ /**
1562
+ * How long a job's own document may lag its summary entry when the jobs are
1563
+ * marked together (AGL-3932).
1564
+ *
1565
+ * The reader judges from the summary and opens a job's own document only to
1566
+ * confirm an entry the summary calls late, so a job marked every fifteen
1567
+ * minutes does not need its own document rewritten every fifteen minutes:
1568
+ * half an hour, under every plugin job's forty-five-minute grace, keeps the
1569
+ * confirming mark fresh enough to agree with a summary that has stopped.
1570
+ */
1571
+ export declare const CRON_BEAT_OWN_MARK_REFRESH_MS: number;
1572
+ /**
1573
+ * The marks one run leaves for several jobs at once — the plugin-crons route
1574
+ * stamps twenty jobs a run, and {@link writeCronBeat} per job was forty
1575
+ * writes every fifteen minutes, half of everything the platform wrote
1576
+ * (AGL-3932).
1577
+ *
1578
+ * One summary write carries every job's entry (merged, like the single mark's).
1579
+ * A job's own document is rewritten only when the summary's current entry
1580
+ * for it is missing or older than {@link CRON_BEAT_OWN_MARK_REFRESH_MS},
1581
+ * which one read of the summary tells; a store without `get`, or a summary
1582
+ * that cannot be read or written, falls back to the single-mark shape for
1583
+ * every job, so no run can leave fewer marks than before.
1584
+ *
1585
+ * NEVER THROWS, as {@link writeCronBeat} never throws, and for the same
1586
+ * reason. Returns false only when nothing landed for some job.
1587
+ */
1588
+ export declare function writeCronBeats(store: CronBeatStore, jobIds: readonly string[], now?: number): Promise<boolean>;
1517
1589
  /**
1518
1590
  * The beats a summary document holds, ignoring anything malformed.
1519
1591
  *
@@ -548,6 +548,11 @@ export function signupDroughtHealth(markers, attemptMarkers, orgCreations, ms, m
548
548
  * Red when the two newest people in the window are both stranded. See
549
549
  * {@link SignupLandingCheck}.
550
550
  */ export const MAX_STRANDED_SIGNUPS = 2;
551
+ /**
552
+ * Where an org records that its owner reached the console (see
553
+ * {@link SignupLandingCheck}). `null` from birth, epoch millis once the
554
+ * workspace has been opened, absent on an org made before it existed.
555
+ */ export const ORG_LANDED_AT_PATH = 'landedAtMs';
551
556
  export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS, windowHours = SIGNUP_LANDING_WINDOW_HOURS, graceMinutes = SIGNUP_LANDING_GRACE_MINUTES) {
552
557
  const shape = {
553
558
  windowHours,
@@ -564,31 +569,41 @@ export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS
564
569
  strandedOrgs: null,
565
570
  strandedPeople: null,
566
571
  strandedStreak: null,
572
+ unmarkedOrgs: null,
567
573
  orgsInWindow: null
568
574
  }, shape);
569
575
  }
570
576
  // `!(n > 0)` rather than `n === 0`, so a corrupt count (NaN, a string)
571
577
  // reads as "no site" and cannot hide a stranded org.
572
578
  const hasSite = (landing)=>Number(landing.hostCount) > 0;
573
- const strandedOrgs = orgs.filter((landing)=>!hasSite(landing)).length;
579
+ // Each org is one of: arrived (a site, or its owner reached the console),
580
+ // stranded (neither, and the marker says so), or unknown (neither, and it
581
+ // predates the marker).
582
+ const arrived = (landing)=>hasSite(landing) || landing.landed === true;
583
+ const isStranded = (landing)=>!arrived(landing) && landing.landed === false;
584
+ const strandedOrgs = orgs.filter(isStranded).length;
585
+ const unmarkedOrgs = orgs.filter((landing)=>!arrived(landing) && landing.landed === undefined).length;
574
586
  const people = new Map();
575
587
  orgs.forEach((landing, index)=>{
576
- var _ref, _ref1;
588
+ var _ref, _ref1, _ref2;
577
589
  const key = landing.visitor ? `visitor:${landing.visitor}` : `org:${index}`;
578
590
  const createdAtMs = Number(landing.createdAtMs);
579
591
  const latestMs = Number.isFinite(createdAtMs) ? createdAtMs : 0;
580
592
  const person = people.get(key);
581
593
  people.set(key, {
582
594
  latestMs: Math.max((_ref = person == null ? void 0 : person.latestMs) != null ? _ref : 0, latestMs),
583
- landed: ((_ref1 = person == null ? void 0 : person.landed) != null ? _ref1 : false) || hasSite(landing)
595
+ arrived: ((_ref1 = person == null ? void 0 : person.arrived) != null ? _ref1 : false) || arrived(landing),
596
+ stranded: ((_ref2 = person == null ? void 0 : person.stranded) != null ? _ref2 : false) || isStranded(landing)
584
597
  });
585
598
  });
599
+ // A person with nothing but unmarked orgs is not graded at all: neither
600
+ // arrived nor stranded, so they neither start nor break a streak.
586
601
  const newestFirst = [
587
602
  ...people.values()
588
- ].sort((a, b)=>b.latestMs - a.latestMs);
589
- const strandedPeople = newestFirst.filter((person)=>!person.landed).length;
603
+ ].filter((person)=>person.arrived || person.stranded).sort((a, b)=>b.latestMs - a.latestMs);
604
+ const strandedPeople = newestFirst.filter((person)=>!person.arrived).length;
590
605
  let strandedStreak = 0;
591
- while(strandedStreak < newestFirst.length && !newestFirst[strandedStreak].landed){
606
+ while(strandedStreak < newestFirst.length && !newestFirst[strandedStreak].arrived){
592
607
  strandedStreak += 1;
593
608
  }
594
609
  const stranded = strandedStreak >= maxStranded;
@@ -601,6 +616,7 @@ export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS
601
616
  strandedOrgs,
602
617
  strandedPeople,
603
618
  strandedStreak,
619
+ unmarkedOrgs,
604
620
  orgsInWindow: orgs.length
605
621
  }, shape);
606
622
  }
@@ -1947,6 +1963,97 @@ export async function writeCronBeat(store, jobId, now = Date.now()) {
1947
1963
  return false;
1948
1964
  }
1949
1965
  }
1966
+ /**
1967
+ * How long a job's own document may lag its summary entry when the jobs are
1968
+ * marked together (AGL-3932).
1969
+ *
1970
+ * The reader judges from the summary and opens a job's own document only to
1971
+ * confirm an entry the summary calls late, so a job marked every fifteen
1972
+ * minutes does not need its own document rewritten every fifteen minutes:
1973
+ * half an hour, under every plugin job's forty-five-minute grace, keeps the
1974
+ * confirming mark fresh enough to agree with a summary that has stopped.
1975
+ */ export const CRON_BEAT_OWN_MARK_REFRESH_MS = 30 * 60000;
1976
+ /**
1977
+ * The marks one run leaves for several jobs at once — the plugin-crons route
1978
+ * stamps twenty jobs a run, and {@link writeCronBeat} per job was forty
1979
+ * writes every fifteen minutes, half of everything the platform wrote
1980
+ * (AGL-3932).
1981
+ *
1982
+ * One summary write carries every job's entry (merged, like the single mark's).
1983
+ * A job's own document is rewritten only when the summary's current entry
1984
+ * for it is missing or older than {@link CRON_BEAT_OWN_MARK_REFRESH_MS},
1985
+ * which one read of the summary tells; a store without `get`, or a summary
1986
+ * that cannot be read or written, falls back to the single-mark shape for
1987
+ * every job, so no run can leave fewer marks than before.
1988
+ *
1989
+ * NEVER THROWS, as {@link writeCronBeat} never throws, and for the same
1990
+ * reason. Returns false only when nothing landed for some job.
1991
+ */ export async function writeCronBeats(store, jobIds, now = Date.now()) {
1992
+ const ids = [
1993
+ ...new Set(jobIds)
1994
+ ].filter((id)=>!CRON_BEAT_RESERVED_DOCS.has(id));
1995
+ if (!ids.length) return true;
1996
+ const beats = store.collection(CRON_BEAT_COLLECTION);
1997
+ const summary = beats.doc(CRON_BEAT_SUMMARY_DOC);
1998
+ let current = null;
1999
+ try {
2000
+ if (summary.get) {
2001
+ const snapshot = await summary.get();
2002
+ current = new Map(cronBeatsFromSummary(snapshot.get('beats')).map((beat)=>[
2003
+ beat.jobId,
2004
+ beat.atMs
2005
+ ]));
2006
+ }
2007
+ } catch (unused) {
2008
+ // Unreadable: every job gets its own mark below, as before.
2009
+ }
2010
+ const refresh = current === null ? ids : ids.filter((id)=>{
2011
+ const last = current.get(id);
2012
+ return last === undefined || now - last >= CRON_BEAT_OWN_MARK_REFRESH_MS || now < last;
2013
+ });
2014
+ let landed = true;
2015
+ for (const id of refresh){
2016
+ try {
2017
+ await beats.doc(id).set({
2018
+ jobId: id,
2019
+ atMs: now,
2020
+ at: new Date(now).toISOString()
2021
+ }, {
2022
+ merge: true
2023
+ });
2024
+ } catch (unused) {
2025
+ landed = false;
2026
+ }
2027
+ }
2028
+ try {
2029
+ await summary.set({
2030
+ beats: Object.fromEntries(ids.map((id)=>[
2031
+ id,
2032
+ now
2033
+ ]))
2034
+ }, {
2035
+ merge: true
2036
+ });
2037
+ } catch (unused) {
2038
+ // The summary is what the reader reads first; without it, give every job
2039
+ // the mark the reader confirms against, as the single mark would have.
2040
+ for (const id of ids){
2041
+ if (refresh.includes(id)) continue;
2042
+ try {
2043
+ await beats.doc(id).set({
2044
+ jobId: id,
2045
+ atMs: now,
2046
+ at: new Date(now).toISOString()
2047
+ }, {
2048
+ merge: true
2049
+ });
2050
+ } catch (unused) {
2051
+ landed = false;
2052
+ }
2053
+ }
2054
+ }
2055
+ return landed;
2056
+ }
1950
2057
  /**
1951
2058
  * The beats a summary document holds, ignoring anything malformed.
1952
2059
  *