@rubytech/create-maxy-code 0.1.477 → 0.1.478

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.
Files changed (151) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1704-data-portal-standing-audit.md +673 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1704-data-portal-standing-audit-design.md +177 -0
  4. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1820-cpu-triage-admin-tools-design.md +97 -0
  5. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +44 -0
  6. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  7. package/payload/platform/lib/storage-broker/dist/audit.d.ts +56 -0
  8. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  9. package/payload/platform/lib/storage-broker/dist/audit.js +34 -0
  10. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  11. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +70 -1
  12. package/payload/platform/lib/storage-broker/src/audit.ts +111 -0
  13. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  14. package/payload/platform/plugins/admin/PLUGIN.md +7 -1
  15. package/payload/platform/plugins/admin/mcp/dist/index.js +72 -0
  16. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  17. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts +94 -0
  18. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts.map +1 -0
  19. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js +229 -0
  20. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js.map +1 -0
  21. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
  22. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  23. package/payload/platform/plugins/email/PLUGIN.md +2 -1
  24. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts +2 -0
  25. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js +92 -0
  27. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js.map +1 -0
  28. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js +112 -12
  29. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js.map +1 -1
  30. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts +2 -0
  31. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts.map +1 -0
  32. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js +54 -0
  33. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js.map +1 -0
  34. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js +170 -1
  35. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js.map +1 -1
  36. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts +2 -0
  37. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts.map +1 -0
  38. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js +86 -0
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js.map +1 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts +2 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts.map +1 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js +164 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js.map +1 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts +2 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts.map +1 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js +82 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js.map +1 -0
  48. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +95 -0
  49. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  50. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +223 -0
  51. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  52. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts +18 -0
  53. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts.map +1 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js +59 -0
  55. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js.map +1 -0
  56. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts +48 -0
  57. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts.map +1 -0
  58. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js +79 -0
  59. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js.map +1 -0
  60. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts +44 -0
  61. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts.map +1 -0
  62. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js +63 -0
  63. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js.map +1 -0
  64. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts +2 -0
  65. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts.map +1 -0
  66. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js +55 -0
  67. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js.map +1 -0
  68. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.d.ts.map +1 -1
  69. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js +64 -13
  70. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js.map +1 -1
  71. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.d.ts.map +1 -1
  72. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js +11 -1
  73. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js.map +1 -1
  74. package/payload/platform/plugins/email/mcp/dist/tools/email-send.d.ts.map +1 -1
  75. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js +16 -3
  76. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js.map +1 -1
  77. package/payload/platform/plugins/email/references/email-reference.md +36 -0
  78. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts +2 -0
  79. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts.map +1 -0
  80. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js +18 -0
  81. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js.map +1 -0
  82. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts +13 -0
  83. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts.map +1 -0
  84. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js +10 -0
  85. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js.map +1 -0
  86. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +53 -0
  87. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  88. package/payload/platform/scripts/cpu-triage-run.sh +117 -0
  89. package/payload/platform/scripts/cpu-triage.sh +23 -4
  90. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts +40 -0
  91. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts.map +1 -0
  92. package/payload/platform/services/claude-session-manager/dist/activity-range.js +68 -0
  93. package/payload/platform/services/claude-session-manager/dist/activity-range.js.map +1 -0
  94. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +40 -8
  95. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
  96. package/payload/platform/services/claude-session-manager/dist/activity-rows.js +74 -7
  97. package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
  98. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  99. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  100. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  101. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  102. package/payload/platform/services/claude-session-manager/dist/http-server.js +9 -0
  103. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  104. package/payload/server/{chunk-56WJMBQQ.js → chunk-3XLLTG6R.js} +14 -0
  105. package/payload/server/{chunk-JECAP3Z2.js → chunk-XGNRSM57.js} +3 -0
  106. package/payload/server/maxy-edge.js +1 -1
  107. package/payload/server/public/activity.html +6 -6
  108. package/payload/server/public/assets/{AdminLoginScreens-mEKP4pDi.js → AdminLoginScreens-DR5eLF-a.js} +1 -1
  109. package/payload/server/public/assets/AdminShell-D9zaUxPd.js +2 -0
  110. package/payload/server/public/assets/{Checkbox-CN4uQ80w.js → Checkbox-DcbbAIe5.js} +1 -1
  111. package/payload/server/public/assets/activity-C9P5NjEZ.js +1 -0
  112. package/payload/server/public/assets/{admin-BVhgfgs1.js → admin-D2KLZnQW.js} +1 -1
  113. package/payload/server/public/assets/{browser-D-HMmHcM.js → browser-CDWqEiCD.js} +1 -1
  114. package/payload/server/public/assets/{calendar-tGWvFyFn.js → calendar-BG7fOpbB.js} +1 -1
  115. package/payload/server/public/assets/chat-RyH-WYQC.js +1 -0
  116. package/payload/server/public/assets/chevron-left-DmXCr6nB.js +1 -0
  117. package/payload/server/public/assets/data-BaY_LkLR.js +1 -0
  118. package/payload/server/public/assets/{graph-zEw610xK.js → graph-DRloNic4.js} +1 -1
  119. package/payload/server/public/assets/{graph-labels-qnBleOE6.js → graph-labels-DD-GqS4v.js} +1 -1
  120. package/payload/server/public/assets/{maximize-2-BlTjXT_Y.js → maximize-2-CzDaBugs.js} +1 -1
  121. package/payload/server/public/assets/{operator-C7oIw2PG.js → operator-BZeeyait.js} +1 -1
  122. package/payload/server/public/assets/{page-DL6Zsdvk.js → page-B0obcg3b.js} +1 -1
  123. package/payload/server/public/assets/{page-CTP7OFZa.js → page-ef-6rRPL.js} +1 -1
  124. package/payload/server/public/assets/{public-r1A9dqR_.js → public-9D3MMOkC.js} +1 -1
  125. package/payload/server/public/assets/{rotate-ccw-CzkkKx4-.js → rotate-ccw-DXEKUhXP.js} +1 -1
  126. package/payload/server/public/assets/tasks-BB0L7e5a.js +1 -0
  127. package/payload/server/public/assets/{time-entry-format-j669DgXj.js → time-entry-format-CsYAjplS.js} +1 -1
  128. package/payload/server/public/assets/{triangle-alert-BPgCgIef.js → triangle-alert-0lSBIUg-.js} +1 -1
  129. package/payload/server/public/assets/{useCopyFeedback-BmLa2aes.js → useCopyFeedback-jTBQ2h0V.js} +1 -1
  130. package/payload/server/public/assets/{useSelectionMode-DnnM7A8-.js → useSelectionMode-BooArySZ.js} +1 -1
  131. package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +1 -0
  132. package/payload/server/public/assets/{useVoiceRecorder-u2dwZfen.js → useVoiceRecorder-PUT8q_sm.js} +1 -1
  133. package/payload/server/public/browser.html +5 -5
  134. package/payload/server/public/calendar.html +6 -6
  135. package/payload/server/public/chat.html +12 -12
  136. package/payload/server/public/data.html +11 -11
  137. package/payload/server/public/graph.html +11 -11
  138. package/payload/server/public/index.html +14 -14
  139. package/payload/server/public/operator.html +14 -14
  140. package/payload/server/public/public.html +12 -12
  141. package/payload/server/public/tasks.html +5 -5
  142. package/payload/server/server.js +153 -18
  143. package/payload/server/{src-JYPKMWJR.js → src-XQ63FPRE.js} +3 -1
  144. package/payload/server/public/assets/AdminShell-BwSaZ88d.js +0 -2
  145. package/payload/server/public/assets/activity-CIQ8ozNZ.js +0 -1
  146. package/payload/server/public/assets/chat-DkttVxAz.js +0 -1
  147. package/payload/server/public/assets/chevron-left-CZ4ez9G5.js +0 -1
  148. package/payload/server/public/assets/data-P-mcmnNi.js +0 -1
  149. package/payload/server/public/assets/tasks-fIJwYFWG.js +0 -1
  150. package/payload/server/public/assets/useSubAccountSwitcher-C_E8h07P.css +0 -1
  151. /package/payload/server/public/assets/{useSubAccountSwitcher-CFa6ZAT1.js → useSubAccountSwitcher-BZnzc5C3.js} +0 -0
@@ -0,0 +1,177 @@
1
+ # Task 1704 — data portal standing audit
2
+
3
+ **Date:** 2026-07-20 · **Lane:** cloudflare + storage-broker · data-portal observability
4
+ **Depends on:** 1689 (portal, landed), 1694 (broker R2 object ops, landed), 1810 (loop registry, landed)
5
+
6
+ ## The problem
7
+
8
+ The `[data-portal]` lifeline traces one upload end to end and ends in a re-read post-condition, so
9
+ every failure that emits an event is already covered. Three conditions emit nothing:
10
+
11
+ - **An orphan object** — an object in R2 with no manifest row. Stored, invisible to ingestion
12
+ forever, and the upload that produced it returned `200`.
13
+ - **A phantom row** — a manifest row whose object is absent. Ingestion will pull a missing file,
14
+ and nothing says so until ingestion runs, which may be never.
15
+ - **A stalled ingestion sweep** — an ingestion that never runs logs nothing, so a climbing backlog
16
+ and an empty one look identical.
17
+
18
+ Only a standing reconcile reveals any of them. Absence of the audit's line is itself the signal that
19
+ all three are unobservable.
20
+
21
+ ## Premise corrections carried from the task file
22
+
23
+ The task brief predates three changes. All three point the same way; none change its intent.
24
+
25
+ 1. **The `[storage-audit]` idiom is now `registerLoop`.** Task 1810 moved every standing loop onto
26
+ `platform/ui/server/loop-registry.ts`, and `scripts/check-loops-registered.mjs` fails the build on
27
+ a bare `setInterval`. "Copy the `[storage-audit]` shape" therefore means registering a loop, not
28
+ hand-rolling two unref'd timers.
29
+ 2. **Every line number in the brief is stale**, though every cited thing still exists.
30
+ `runStorageAudit` is `storage-broker.ts:773`; the "silent when the config names no portal" idiom
31
+ is the `hasBookingSite` check in the availability loop; `r2ObjectList`'s drain-to-exhaustion
32
+ comment at `cf-exec.ts:443` names this audit as its own reason for existing.
33
+ 3. **`reconcilePages` (Task 1728) is a closer template than the brief knew.** It is already a pure
34
+ orphan/phantom set difference over two lists in `lib/storage-broker/src/audit.ts`, carrying the
35
+ concurrency contract and the refusal to print `orphan=<everything>` off a failed read. This audit
36
+ is that shape one level down: objects against rows instead of projects against records.
37
+
38
+ ## Components
39
+
40
+ ### 1. `reconcileDataPortal` — pure, in `lib/storage-broker/src/audit.ts`
41
+
42
+ Third reconcile beside `reconcileStorage` and `reconcilePages`. It is D1 rows against R2 objects,
43
+ which is that lib's domain, and the ui server imports the lib by source path so there is no dist
44
+ rebuild in the loop.
45
+
46
+ ```ts
47
+ interface DataPortalReconcile {
48
+ objects: number
49
+ rows: number
50
+ orphanObjects: string[] // object keys with no manifest row
51
+ phantomRows: string[] // manifest objectKeys with no object
52
+ oldestUningestedHrs: number | null // null renders as `na`
53
+ }
54
+
55
+ function reconcileDataPortal(
56
+ objects: Array<{ key: string }>,
57
+ rows: Array<{ objectKey: string; ingested: number; uploadedAt: string }>,
58
+ nowMs: number,
59
+ ): DataPortalReconcile
60
+ ```
61
+
62
+ `nowMs` is injected so the age arithmetic is deterministic under test rather than clock-dependent.
63
+
64
+ `oldestUningestedHrs` is computed from rows with `ingested === 0` only, and is `null` when there are
65
+ none. **An `uploadedAt` that will not parse is excluded from the minimum rather than allowed to
66
+ produce `NaN`** — that is the `snapshotAgeMs=NaN` lesson from the availability loop, where a truthy
67
+ but non-ISO value slipped past a `??` guard and logged `NaN`. If exclusion leaves no usable row, the
68
+ result is `null`.
69
+
70
+ ### 2. `parseManifestRows` — pure, exported from the route module
71
+
72
+ `cf.d1Query` shells `npx wrangler d1 execute --json` and is typed `unknown`. This function parses
73
+ that envelope into the row shape above and **throws on anything it does not recognise**.
74
+
75
+ Throwing rather than returning `[]` is the load-bearing decision. Zero rows against a populated
76
+ bucket reads as "every object is an orphan" — a fabricated defect count off a failed read, which is
77
+ precisely what `reconcilePages` refuses to print. A throw lands in the d1-degraded branch, where the
78
+ line says the read failed instead of asserting a number it does not have.
79
+
80
+ ### 3. `runDataPortalAudit(root)` — the caller, in `ui/server/routes/storage-broker.ts`
81
+
82
+ Sits with `runStorageAudit` and `runPagesAudit` at the foot of the same file.
83
+
84
+ **Discovery.** Walks `data/accounts/*/data-portal.json`. A config that is absent, unparseable, or
85
+ parses but names no `portalDbName` and `bucketName` is **silent** — the `hasBookingSite` idiom. An
86
+ account with no portal is not a fault and must not produce a line.
87
+
88
+ **It returns before minting a house token when no account has a portal.** Most installs have no data
89
+ portal and no house Cloudflare credential; minting unconditionally would make this loop report an
90
+ error forever on every one of them.
91
+
92
+ **Reads.** One `makeHouseCfExec(root)` for the whole run, then per account:
93
+
94
+ - rows — `d1Query(portalDbName, 'SELECT objectKey, ingested, uploadedAt FROM manifest')`
95
+ - objects — `r2ObjectList(bucketName)`
96
+
97
+ The two halves are caught **separately**, and the line is emitted unconditionally afterwards. This is
98
+ `.tasks/backlog/1351` made structural: the log is not downstream of either read, so neither read can
99
+ skip it. An audit that a data-source failure silences is the same blind spot one layer up, in a task
100
+ that exists because failures emit nothing.
101
+
102
+ **Degradation.** Each line carries exactly what is still measurable:
103
+
104
+ | Failure | Line still carries |
105
+ |---|---|
106
+ | R2 read fails | `rows=N oldestUningestedHrs=N`, remainder `na`, `degraded=r2` |
107
+ | D1 read fails | `objects=N`, remainder `na`, `degraded=d1` |
108
+ | Both fail | all `na`, `degraded=d1+r2` |
109
+
110
+ `oldestUningestedHrs` surviving an R2 failure is not incidental: the backlog age is computable from
111
+ the manifest alone, and it is the one of the three conditions that a half-broken audit can still
112
+ report.
113
+
114
+ **Line format.** Counts only:
115
+
116
+ ```
117
+ [data-portal-audit] account=<id> objects=N rows=N orphanObjects=N phantomRows=N oldestUningestedHrs=N
118
+ ```
119
+
120
+ Healthy is `orphanObjects=0 phantomRows=0` with a bounded age. **Any non-zero orphan or phantom count
121
+ is a defect, not noise.**
122
+
123
+ `reconcilePages` reports orphan and phantom *names*, on the grounds that a count says something is
124
+ wrong and a name says what to fix. This audit deliberately does not, and the pure function returning
125
+ the keys does not change that: the keys here are `<ownerId>/<filename>`, real people's filenames,
126
+ and the journal is not where they belong. The task's success criterion specifies counts, and the
127
+ audit owning its own ownership discipline is called out in the brief as load-bearing precisely
128
+ because the in-process path holds the account-wide credential and bypasses `objectGate` entirely.
129
+
130
+ ### 4. Arming, in `ui/server/index.ts`
131
+
132
+ ```ts
133
+ registerLoop({
134
+ name: 'data-portal-audit',
135
+ intervalMs: 300_000,
136
+ firstRunDelayMs: 50_000,
137
+ run: runDataPortalAuditSafe,
138
+ })
139
+ ```
140
+
141
+ Same interval as the storage and pages audits. The first run is offset past storage (20s), pages
142
+ (35s) and google-account (45s) so a boot does not fire four token mints in the same instant.
143
+
144
+ **Degradation is not an error.** A degraded run returns a note (`portals=N degraded=M`) and the
145
+ registry records `ok`, because the audit did the job it could and said so. A genuine throw — no
146
+ house credential where a portal exists, an unreachable registry — is logged and **rethrown**, the
147
+ `runStorageAuditSafe` shape, so /activity shows a fresh Last run against a stale Last OK.
148
+
149
+ ## Testing
150
+
151
+ **Unit, `lib/storage-broker/src/__tests__/audit.test.ts`** — an object with no row counts one
152
+ `orphanObjects`; a row with no object counts one `phantomRows`; a matched pair counts neither;
153
+ `oldestUningestedHrs` comes from the oldest `ingested=0` row; `ingested=1` rows are excluded from it;
154
+ `na` when there are no uningested rows; an unparseable `uploadedAt` does not yield `NaN`.
155
+
156
+ **Envelope, same run** — `parseManifestRows` on a well-formed wrangler payload, and a throw on a
157
+ malformed one. The throw case is what stops a parse failure reading as an empty manifest.
158
+
159
+ **Route, new `ui/server/__tests__/data-portal-audit.test.ts`** — modelled on
160
+ `storage-broker-object-routes.test.ts`: `// @vitest-environment node`, `vi.mock` the neo4j store,
161
+ partially mock the lib via `importOriginal`, stub `makeHouseCfExec` with a fake carrying
162
+ `r2ObjectList` and `d1Query`. Cases: two accounts, one with a portal, yields one line; a D1 throw
163
+ still emits a line carrying `degraded=d1`; an R2 throw emits `degraded=r2` with `rows` still counted;
164
+ a malformed D1 payload degrades rather than reporting zero rows; a config naming no portal is silent;
165
+ no portal anywhere mints no token.
166
+
167
+ **Mutation check** — reverting the reconcile to compare in one direction only must fail the opposite
168
+ direction's test.
169
+
170
+ ## Scope boundaries
171
+
172
+ **In scope:** the reconcile and its tests, the caller, the arming, the route-level tests, and
173
+ `.docs/data-portal.md` § Observability, which currently states the audit is not built.
174
+
175
+ **Out of scope:** the device-side ingestion sweep (unbuilt, agent-side); any change to the portal
176
+ template or the gate (1689, landed); escalation beyond a log line — `.tasks/backlog/1351` records
177
+ that a non-zero count escalating nowhere is its own problem, and that is lane-wide, not this task's.
@@ -0,0 +1,97 @@
1
+ # Task 1820 — `cpu-triage` as a two-call admin diagnostic
2
+
3
+ Design agreed 2026-07-20. Implements `.tasks/1820-expose-cpu-triage-to-the-admin-assistant-as-a-two-call-device-diagnostic.md`.
4
+
5
+ ## Problem restated
6
+
7
+ `platform/scripts/cpu-triage.sh` answers "which cores are busy and what is on them" and is reachable only by an engineer with SSH. The measurement needs at least one full 120 s reconcile cycle to be trustworthy, and no admin tool anywhere exceeds a 12 s envelope. So the diagnostic has to be split across two calls: one that starts a run and returns immediately, one that reads a result.
8
+
9
+ ## Shape
10
+
11
+ Two tools in the admin plugin, one new wrapper script, one JSON-contract fix in the measurement script.
12
+
13
+ ### `cpu-triage.sh` — JSON contract only
14
+
15
+ The `--control` comparison is currently computed only inside the text-report branch (lines 275–285); the JSON branch ends at line 251 and emits no control field. Passing `--control` together with `--json` is therefore inert today, which would silently drop the single most valuable input to this measurement.
16
+
17
+ Add to the JSON object:
18
+
19
+ ```json
20
+ "control": { "brand": "<name>", "meanPct": <number>, "found": true }
21
+ ```
22
+
23
+ `null` when `--control` was not passed. `{ "brand": "<name>", "found": false }` when the named brand is not among the running services. The measurement design, sampling loop, exit codes and text report are untouched.
24
+
25
+ ### `cpu-triage-run.sh` — new wrapper
26
+
27
+ ```
28
+ cpu-triage-run.sh --run-id ID --out-dir DIR --log LOGFILE -- <cpu-triage args…>
29
+ ```
30
+
31
+ Runs `cpu-triage.sh --json <args>`, streaming stdout to `DIR/cpu-triage-<ID>.json`. **After** that file is closed it writes `DIR/cpu-triage-<ID>.status`, then appends one completion line to `LOGFILE`.
32
+
33
+ The ordering is load-bearing. The result file exists from the first byte of output, so its existence is not a readiness signal. `.status` is the only readiness signal, and it is written strictly after the result. A reader that keys on the `.json` existing reports a half-written run as complete — which is the exact failure the task's mutation check exists to catch.
34
+
35
+ The wrapper exists rather than an inline `sh -c` string because the ordering guarantee should be reviewable in a file with exit-code gates, per the deterministic doctrine.
36
+
37
+ ### Per-run files, under `<configDir>/logs/`
38
+
39
+ | File | Written by | Carries |
40
+ |---|---|---|
41
+ | `cpu-triage-<id>.meta.json` | `cpu-triage-start` | runId, startedAt, expectedCompleteAt, windowSec, args |
42
+ | `cpu-triage-<id>.json` | wrapper, streaming | the report — **not** a readiness signal |
43
+ | `cpu-triage-<id>.status` | wrapper, last | `exit=<n>` on line one, stderr on the lines below |
44
+
45
+ Start-time metadata and end-time status cannot share one file, which is why the layout is three files rather than the task's "sibling status marker" phrasing implies.
46
+
47
+ Run ids are timestamp-prefixed — `20260720T143000-a1b2` — so "most recent" resolves by filename sort rather than a stat walk.
48
+
49
+ ### `cpu-triage-start { windowSec?, intervalSec?, thresholdPct?, control? }`
50
+
51
+ Returns `{ runId, startedAt, expectedCompleteAt, windowSec, resultPath }`. Spawns the wrapper detached with `stdio: "ignore"` and `unref()`, so the MCP process does not hold the run open. Logs `[cpu-triage] op=start runId=<id> windowSec=<n>`. Prunes to the 20 most recent runs.
52
+
53
+ `windowSec` is validated at the tool boundary with a 120 s floor. The script already refuses a shorter window with exit 2, but detached that refusal arrives one full window late — the refusal has to be synchronous.
54
+
55
+ A missing `cpu-triage.sh` returns a typed refusal naming the resolved path with `isError: true`, never an empty report.
56
+
57
+ ### `cpu-triage-read { runId? }`
58
+
59
+ Defaults to the most recent run. Decides state from `.status` alone.
60
+
61
+ | On disk | Returns |
62
+ |---|---|
63
+ | no runs at all | typed "no runs started" — not a failure |
64
+ | `.meta.json` present, no `.status` | `running`, carrying `expectedCompleteAt` so an overdue run is visible |
65
+ | `.status` exit 0 or 1 | `complete` plus the parsed report. Exit 1 is a finding, not a failure. |
66
+ | `.status` any other code | `failed` plus exitCode and stderr |
67
+ | `.status` exit 0 or 1, JSON unparseable | `failed`, reason naming the parse error |
68
+
69
+ The exit-code contract survives to the agent: 0 clean, 1 sustained hot core, 2 precondition failure.
70
+
71
+ ## Registration
72
+
73
+ `platform/plugins/admin/mcp/src/index.ts` (via `eagerTool`) and `platform/plugins/admin/PLUGIN.md` move in one commit. Both tools take `publicAllowlist: false`, `adminAllowlist: true` — the operator's "why is this box busy" question should not cost a permission round trip during an incident. `ADMIN_CORE_TOOLS` is a dead name and needs no edit. `npm run gen:canonical-tools` regenerates the byte-match artifact the build gate compares against.
74
+
75
+ `platform/scripts/` is copied wholesale into the installer payload by `packages/create-maxy-code/scripts/bundle.js`, and both scripts are mode 755 in git, so they land executable by the same path `logs-read.sh` already uses.
76
+
77
+ ## Observability
78
+
79
+ `[cpu-triage] op=start runId=<id> windowSec=<n>` from the start tool, `[cpu-triage] op=complete runId=<id> exit=<code> sustainedHotCores=<n>` from the wrapper. The completion line is written after the result file exists — a post-condition, not an intent. A run that starts and dies is visible as a start with no completion rather than as silence.
80
+
81
+ ## Tests
82
+
83
+ Ephemeral, discarded after the sprint.
84
+
85
+ 1. `.meta.json` present, no `.status` → `running`
86
+ 2. `.status` `exit=0` plus valid JSON → `complete`, `perCore` populated, `sustainedHotCores` present
87
+ 3. `.status` `exit=2` → `failed`, exitCode 2, distinct from exit 0
88
+ 4. script absent → typed refusal naming the path
89
+ 5. wrapper against a stub exiting 2 → `.status` written and the log line appended, both after the result file
90
+
91
+ Tests 1 and 2 are a pair. An implementation that always reports `complete` passes 2 and fails 1; neither alone pins the behaviour.
92
+
93
+ ## Scope boundaries
94
+
95
+ **In scope:** the two tools, their registration, the wrapper, the JSON control field, result persistence and retention, shipping to devices.
96
+
97
+ **Out of scope:** the measurement design, which is fixed and documented in the script header; the activity view (1810); escalation or alerting on a finding (1351); any remediation a report implies; remote targeting — this measures the box it runs on.
@@ -70,4 +70,48 @@ const clean = {
70
70
  strict_1.default.deepEqual(r.orphans, []);
71
71
  strict_1.default.deepEqual(r.phantoms, []);
72
72
  });
73
+ // --- Data portal reconcile (Task 1704) ------------------------------------
74
+ const row = (objectKey, ingested, uploadedAt) => ({
75
+ objectKey,
76
+ ingested,
77
+ uploadedAt,
78
+ });
79
+ (0, node_test_1.default)("reconcileDataPortal counts an object with no row as an orphan", () => {
80
+ const r = (0, audit_js_1.reconcileDataPortal)([{ key: "alice/a.pdf" }], [], 0);
81
+ strict_1.default.deepEqual(r.orphanObjects, ["alice/a.pdf"]);
82
+ strict_1.default.deepEqual(r.phantomRows, []);
83
+ strict_1.default.equal(r.distinctObjects, 1);
84
+ strict_1.default.equal(r.distinctRows, 0);
85
+ });
86
+ (0, node_test_1.default)("reconcileDataPortal counts a row with no object as a phantom", () => {
87
+ const r = (0, audit_js_1.reconcileDataPortal)([], [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")], 0);
88
+ strict_1.default.deepEqual(r.phantomRows, ["alice/a.pdf"]);
89
+ strict_1.default.deepEqual(r.orphanObjects, []);
90
+ });
91
+ (0, node_test_1.default)("reconcileDataPortal counts a matched pair as neither", () => {
92
+ const r = (0, audit_js_1.reconcileDataPortal)([{ key: "alice/a.pdf" }], [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")], 0);
93
+ strict_1.default.deepEqual(r.orphanObjects, []);
94
+ strict_1.default.deepEqual(r.phantomRows, []);
95
+ });
96
+ (0, node_test_1.default)("oldestUningestedHrs comes from the oldest ingested=0 row", () => {
97
+ const now = Date.parse("2026-07-20T12:00:00Z");
98
+ const r = (0, audit_js_1.reconcileDataPortal)([], [row("a", 0, "2026-07-20T09:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")], now);
99
+ strict_1.default.equal(r.oldestUningestedHrs, 3);
100
+ });
101
+ (0, node_test_1.default)("an ingested row is excluded from the backlog age", () => {
102
+ const now = Date.parse("2026-07-20T12:00:00Z");
103
+ const r = (0, audit_js_1.reconcileDataPortal)([], [row("a", 1, "2026-07-01T00:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")], now);
104
+ strict_1.default.equal(r.oldestUningestedHrs, 1);
105
+ });
106
+ (0, node_test_1.default)("oldestUningestedHrs is null when nothing is uningested", () => {
107
+ const r = (0, audit_js_1.reconcileDataPortal)([], [row("a", 1, "2026-07-20T00:00:00Z")], 0);
108
+ strict_1.default.equal(r.oldestUningestedHrs, null);
109
+ });
110
+ (0, node_test_1.default)("an unparseable uploadedAt never yields NaN", () => {
111
+ const now = Date.parse("2026-07-20T12:00:00Z");
112
+ const only = (0, audit_js_1.reconcileDataPortal)([], [row("a", 0, "not-a-date")], now);
113
+ strict_1.default.equal(only.oldestUningestedHrs, null);
114
+ const mixed = (0, audit_js_1.reconcileDataPortal)([], [row("a", 0, "not-a-date"), row("b", 0, "2026-07-20T11:00:00Z")], now);
115
+ strict_1.default.equal(mixed.oldestUningestedHrs, 1);
116
+ });
73
117
  //# sourceMappingURL=audit.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit.test.js","sourceRoot":"","sources":["../../src/__tests__/audit.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"audit.test.js","sourceRoot":"","sources":["../../src/__tests__/audit.test.ts"],"names":[],"mappings":"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"}
@@ -3,9 +3,28 @@ export interface Stray {
3
3
  kind: StrayKind;
4
4
  detail: string;
5
5
  }
6
+ /** CONCURRENCY CONTRACT — read before implementing these callbacks.
7
+ *
8
+ * reconcileStorage invokes all five through ONE Promise.all. They overlap,
9
+ * including the two registeredNames calls, which run at the same time as each
10
+ * other. So no callback may share, with any other callback or with a second
11
+ * invocation of itself, a resource that admits one operation at a time.
12
+ *
13
+ * The resource that has actually caused this is a Neo4j session: it rejects a
14
+ * query issued while one of its own is in flight. A caller that opened one
15
+ * session in the enclosing scope and used it in registeredNames made the
16
+ * second read reject, which left the standing storage audit reporting nothing
17
+ * for 802 consecutive ticks on maxy-code and 295 on sitedesk-code (Task 1813).
18
+ * Open the session inside the callback, one per invocation, and close it
19
+ * there; driver sessions come from a pool, so this is cheap.
20
+ *
21
+ * The contract is stated here rather than on reconcileStorage because this
22
+ * interface is what a caller writing an implementation is looking at, and the
23
+ * natural thing to write is the thing that breaks. */
6
24
  export interface AuditDeps {
7
25
  cfD1(): Promise<string[]>;
8
26
  cfR2(): Promise<string[]>;
27
+ /** Called twice, concurrently, once per kind. See the contract above. */
9
28
  registeredNames(kind: "d1" | "r2"): Promise<string[]>;
10
29
  accountSecretsFiles(): Promise<Array<{
11
30
  accountId: string;
@@ -16,6 +35,13 @@ export interface AuditDeps {
16
35
  export declare function reconcileStorage(deps: AuditDeps): Promise<{
17
36
  strays: Stray[];
18
37
  }>;
38
+ /** Same concurrency contract as AuditDeps above: reconcilePages invokes both
39
+ * callbacks through one Promise.all, so neither may share a single-operation
40
+ * resource with the other.
41
+ *
42
+ * That registeredProjects happens to issue exactly one query today is not the
43
+ * property that makes a shared session safe here, and relying on it would put
44
+ * the 1813 defect one added read away. */
19
45
  export interface PagesAuditDeps {
20
46
  cfProjects(): Promise<string[]>;
21
47
  registeredProjects(): Promise<string[]>;
@@ -27,4 +53,34 @@ export interface PagesReconcile {
27
53
  phantoms: string[];
28
54
  }
29
55
  export declare function reconcilePages(deps: PagesAuditDeps): Promise<PagesReconcile>;
56
+ export interface DataPortalObject {
57
+ key: string;
58
+ }
59
+ export interface DataPortalRow {
60
+ objectKey: string;
61
+ ingested: number;
62
+ uploadedAt: string;
63
+ }
64
+ export interface DataPortalReconcile {
65
+ /** DISTINCT keys, which is not the same quantity as the length of the list
66
+ * the read returned. The caller logs the lengths — "how many did I see" —
67
+ * and these are "how many are distinct". They agree on a healthy store
68
+ * (manifest.objectKey is UNIQUE and R2 keys are unique by construction), and
69
+ * giving them one name would conflate two facts that diverge exactly when
70
+ * the store is unhealthy, which is the case this audit is for. */
71
+ distinctObjects: number;
72
+ distinctRows: number;
73
+ orphanObjects: string[];
74
+ phantomRows: string[];
75
+ /** Null when nothing is uningested, or when no uningested row carries a
76
+ * parseable timestamp. Rendered `na` by the caller. */
77
+ oldestUningestedHrs: number | null;
78
+ }
79
+ /** Pure over two lists and an injected clock, so the age arithmetic is
80
+ * deterministic under test rather than dependent on when the suite runs.
81
+ *
82
+ * Unlike its two siblings this takes lists rather than callbacks: both sides
83
+ * are read per account by a caller that must catch each independently, so
84
+ * there is no fan-out here to carry a concurrency contract. */
85
+ export declare function reconcileDataPortal(objects: DataPortalObject[], rows: DataPortalRow[], nowMs: number): DataPortalReconcile;
30
86
  //# sourceMappingURL=audit.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,oBAAoB,CAAC;AAErF,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAMD,MAAM,WAAW,SAAS;IACxB,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACtD,mBAAmB,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC9F;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC,CA8BpF;AAmBD,MAAM,WAAW,cAAc;IAC7B,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChC,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAclF"}
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,oBAAoB,CAAC;AAErF,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;;;;;uDAiBuD;AACvD,MAAM,WAAW,SAAS;IACxB,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,yEAAyE;IACzE,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACtD,mBAAmB,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC9F;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC,CA8BpF;AAmBD;;;;;;2CAM2C;AAC3C,MAAM,WAAW,cAAc;IAC7B,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChC,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAclF;AAoBD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;;uEAKmE;IACnE,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB;4DACwD;IACxD,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;;;;gEAKgE;AAChE,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,gBAAgB,EAAE,EAC3B,IAAI,EAAE,aAAa,EAAE,EACrB,KAAK,EAAE,MAAM,GACZ,mBAAmB,CA6BrB"}
@@ -7,6 +7,7 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.reconcileStorage = reconcileStorage;
9
9
  exports.reconcilePages = reconcilePages;
10
+ exports.reconcileDataPortal = reconcileDataPortal;
10
11
  // Keys that must never remain in a sub-account's cloudflare.env after
11
12
  // remediation. Each is an account-wide capability.
12
13
  const ACCOUNT_WIDE_KEYS = ["CLOUDFLARE_API_TOKEN", "CF_PAGES_D1_TOKEN", "CF_PAGES_TOKEN"];
@@ -54,4 +55,37 @@ async function reconcilePages(deps) {
54
55
  phantoms: [...regSet].filter((name) => !cfSet.has(name)),
55
56
  };
56
57
  }
58
+ /** Pure over two lists and an injected clock, so the age arithmetic is
59
+ * deterministic under test rather than dependent on when the suite runs.
60
+ *
61
+ * Unlike its two siblings this takes lists rather than callbacks: both sides
62
+ * are read per account by a caller that must catch each independently, so
63
+ * there is no fan-out here to carry a concurrency contract. */
64
+ function reconcileDataPortal(objects, rows, nowMs) {
65
+ const objectKeys = new Set(objects.map((o) => o.key));
66
+ const rowKeys = new Set(rows.map((r) => r.objectKey));
67
+ // Date.parse of a truthy non-ISO value is NaN, which slips past a `??` guard
68
+ // and renders as `oldestUningestedHrs=NaN` — the defect the availability
69
+ // loop's snapshotAgeMs already had to fix. Unparseable rows are excluded from
70
+ // the minimum; if that leaves none, the answer is null, never NaN.
71
+ const uningestedMs = rows
72
+ .filter((r) => r.ingested === 0)
73
+ .map((r) => Date.parse(r.uploadedAt))
74
+ .filter((ms) => !Number.isNaN(ms));
75
+ // Reduced, never `Math.min(...uningestedMs)`. A spread passes one argument per
76
+ // element and blows the call stack — measured: fine at 125k, RangeError at
77
+ // 130k. One element per uningested row, and an ingestion sweep that has
78
+ // stalled accumulates those without bound. A stalled sweep is one of the three
79
+ // conditions this audit exists to reveal, so the arithmetic must not be what
80
+ // goes dark once the backlog it is watching grows large. The throw would also
81
+ // escape the caller's per-side catches and cost every later account its line.
82
+ const oldestMs = uningestedMs.reduce((min, ms) => (ms < min ? ms : min), Infinity);
83
+ return {
84
+ distinctObjects: objectKeys.size,
85
+ distinctRows: rowKeys.size,
86
+ orphanObjects: [...objectKeys].filter((k) => !rowKeys.has(k)),
87
+ phantomRows: [...rowKeys].filter((k) => !objectKeys.has(k)),
88
+ oldestUningestedHrs: uningestedMs.length === 0 ? null : (nowMs - oldestMs) / 3_600_000,
89
+ };
90
+ }
57
91
  //# sourceMappingURL=audit.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { reconcileStorage, reconcilePages, type AuditDeps } from "../audit.js";
3
+ import { reconcileStorage, reconcilePages, reconcileDataPortal, type AuditDeps } from "../audit.js";
4
4
 
5
5
  const clean: AuditDeps = {
6
6
  cfD1: async () => ["gls-leads"],
@@ -78,3 +78,72 @@ test("reconcilePages is clean when every live project has exactly one owner", as
78
78
  assert.deepEqual(r.orphans, []);
79
79
  assert.deepEqual(r.phantoms, []);
80
80
  });
81
+
82
+ // --- Data portal reconcile (Task 1704) ------------------------------------
83
+
84
+ const row = (objectKey: string, ingested: number, uploadedAt: string) => ({
85
+ objectKey,
86
+ ingested,
87
+ uploadedAt,
88
+ });
89
+
90
+ test("reconcileDataPortal counts an object with no row as an orphan", () => {
91
+ const r = reconcileDataPortal([{ key: "alice/a.pdf" }], [], 0);
92
+ assert.deepEqual(r.orphanObjects, ["alice/a.pdf"]);
93
+ assert.deepEqual(r.phantomRows, []);
94
+ assert.equal(r.distinctObjects, 1);
95
+ assert.equal(r.distinctRows, 0);
96
+ });
97
+
98
+ test("reconcileDataPortal counts a row with no object as a phantom", () => {
99
+ const r = reconcileDataPortal([], [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")], 0);
100
+ assert.deepEqual(r.phantomRows, ["alice/a.pdf"]);
101
+ assert.deepEqual(r.orphanObjects, []);
102
+ });
103
+
104
+ test("reconcileDataPortal counts a matched pair as neither", () => {
105
+ const r = reconcileDataPortal(
106
+ [{ key: "alice/a.pdf" }],
107
+ [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")],
108
+ 0,
109
+ );
110
+ assert.deepEqual(r.orphanObjects, []);
111
+ assert.deepEqual(r.phantomRows, []);
112
+ });
113
+
114
+ test("oldestUningestedHrs comes from the oldest ingested=0 row", () => {
115
+ const now = Date.parse("2026-07-20T12:00:00Z");
116
+ const r = reconcileDataPortal(
117
+ [],
118
+ [row("a", 0, "2026-07-20T09:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
119
+ now,
120
+ );
121
+ assert.equal(r.oldestUningestedHrs, 3);
122
+ });
123
+
124
+ test("an ingested row is excluded from the backlog age", () => {
125
+ const now = Date.parse("2026-07-20T12:00:00Z");
126
+ const r = reconcileDataPortal(
127
+ [],
128
+ [row("a", 1, "2026-07-01T00:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
129
+ now,
130
+ );
131
+ assert.equal(r.oldestUningestedHrs, 1);
132
+ });
133
+
134
+ test("oldestUningestedHrs is null when nothing is uningested", () => {
135
+ const r = reconcileDataPortal([], [row("a", 1, "2026-07-20T00:00:00Z")], 0);
136
+ assert.equal(r.oldestUningestedHrs, null);
137
+ });
138
+
139
+ test("an unparseable uploadedAt never yields NaN", () => {
140
+ const now = Date.parse("2026-07-20T12:00:00Z");
141
+ const only = reconcileDataPortal([], [row("a", 0, "not-a-date")], now);
142
+ assert.equal(only.oldestUningestedHrs, null);
143
+ const mixed = reconcileDataPortal(
144
+ [],
145
+ [row("a", 0, "not-a-date"), row("b", 0, "2026-07-20T11:00:00Z")],
146
+ now,
147
+ );
148
+ assert.equal(mixed.oldestUningestedHrs, 1);
149
+ });