@rubytech/create-maxy-code 0.1.478 → 0.1.479
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 +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +22 -0
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
- package/payload/platform/lib/graph-style/src/index.ts +20 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +13 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
- package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
- package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +122 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/server/public/activity.html +8 -6
- package/payload/server/public/assets/{AdminLoginScreens-DR5eLF-a.js → AdminLoginScreens-DkxQZnvB.js} +1 -1
- package/payload/server/public/assets/AdminShell-Cv_nIFGe.js +2 -0
- package/payload/server/public/assets/Checkbox-CsTWK8mm.js +1 -0
- package/payload/server/public/assets/activity-BbnTiVBT.js +1 -0
- package/payload/server/public/assets/{admin-D2KLZnQW.js → admin-BZY3i_Ff.js} +1 -1
- package/payload/server/public/assets/{browser-CDWqEiCD.js → browser-BbIiSFKr.js} +1 -1
- package/payload/server/public/assets/{calendar-BG7fOpbB.js → calendar-BvxeTHvQ.js} +1 -1
- package/payload/server/public/assets/chat-DUKxufRk.js +1 -0
- package/payload/server/public/assets/chevron-left-BtfJD6LL.js +1 -0
- package/payload/server/public/assets/data-hl1wa-Ax.js +1 -0
- package/payload/server/public/assets/{graph-DRloNic4.js → graph-CDSGRoeb.js} +1 -1
- package/payload/server/public/assets/graph-labels-C5qLwnWA.js +1 -0
- package/payload/server/public/assets/{maximize-2-CzDaBugs.js → maximize-2-CAzH8dED.js} +1 -1
- package/payload/server/public/assets/{operator-BZeeyait.js → operator-BK7r8DAB.js} +1 -1
- package/payload/server/public/assets/{page-ef-6rRPL.js → page-B5YngOrK.js} +1 -1
- package/payload/server/public/assets/{page-B0obcg3b.js → page-BsCS0Dmc.js} +1 -1
- package/payload/server/public/assets/{public-9D3MMOkC.js → public-C1Pz-IBs.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-DXEKUhXP.js → rotate-ccw-UDln4Tou.js} +1 -1
- package/payload/server/public/assets/{tasks-BB0L7e5a.js → tasks-B-cLsn3p.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-CsYAjplS.js → time-entry-format-DPOBIE18.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-0lSBIUg-.js → triangle-alert-Dz-CZKo5.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-jTBQ2h0V.js → useCopyFeedback-wtUHGohk.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-BooArySZ.js → useSelectionMode-sph7AQT5.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-BRIluIKf.css +1 -0
- package/payload/server/public/assets/{useVoiceRecorder-PUT8q_sm.js → useVoiceRecorder-DN4jYQ0A.js} +1 -1
- package/payload/server/public/brand-defaults.css +1 -0
- package/payload/server/public/browser.html +5 -5
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +12 -12
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +11 -11
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/privacy.html +1 -0
- package/payload/server/public/public.html +12 -12
- package/payload/server/public/tasks.html +6 -5
- package/payload/server/public/vnc-popout.html +1 -0
- package/payload/server/server.js +741 -366
- package/payload/server/public/assets/AdminShell-D9zaUxPd.js +0 -2
- package/payload/server/public/assets/Checkbox-DcbbAIe5.js +0 -1
- package/payload/server/public/assets/activity-C9P5NjEZ.js +0 -1
- package/payload/server/public/assets/chat-RyH-WYQC.js +0 -1
- package/payload/server/public/assets/chevron-left-DmXCr6nB.js +0 -1
- package/payload/server/public/assets/data-BaY_LkLR.js +0 -1
- package/payload/server/public/assets/graph-labels-DD-GqS4v.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +0 -1
- /package/payload/server/public/assets/{useSubAccountSwitcher-BZnzc5C3.js → useSubAccountSwitcher-v8LyBzZN.js} +0 -0
|
@@ -122,3 +122,59 @@ body {
|
|
|
122
122
|
color: var(--dp-muted);
|
|
123
123
|
padding: 0.75rem 0;
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
/* "Shared with you" — the folder tree the device publishes. Deliberately reuses
|
|
127
|
+
the existing variables and adds none: portal-brand-css.mjs rewrites the one
|
|
128
|
+
:root block from the brand palette and validates every value it finds there,
|
|
129
|
+
so a new variable declared here would either be dropped or fail that check. */
|
|
130
|
+
.dp-shared {
|
|
131
|
+
margin-top: 28px;
|
|
132
|
+
border-top: 1px solid var(--dp-rule);
|
|
133
|
+
padding-top: 16px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.dp-subtitle {
|
|
137
|
+
font-size: 15px;
|
|
138
|
+
font-weight: 600;
|
|
139
|
+
margin: 0 0 8px;
|
|
140
|
+
color: var(--dp-ink);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.dp-crumbs {
|
|
144
|
+
font-size: 13px;
|
|
145
|
+
color: var(--dp-muted);
|
|
146
|
+
margin-bottom: 10px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Crumbs and folder rows are buttons (navigation is a fetch, not a page load),
|
|
150
|
+
so they need the button chrome stripped to sit beside the upload list's
|
|
151
|
+
anchors without looking like form controls. */
|
|
152
|
+
.dp-crumb,
|
|
153
|
+
.dp-folder-name {
|
|
154
|
+
background: none;
|
|
155
|
+
border: 0;
|
|
156
|
+
padding: 0;
|
|
157
|
+
font: inherit;
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
color: var(--dp-accent);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.dp-crumb:hover,
|
|
163
|
+
.dp-folder-name:hover {
|
|
164
|
+
text-decoration: underline;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.dp-folder-name {
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* The file rows inside the tree are buttons too — same reset, but they must
|
|
172
|
+
match .dp-file-name's weight so the two lists read as one surface. */
|
|
173
|
+
.dp-shared .dp-file-name {
|
|
174
|
+
background: none;
|
|
175
|
+
border: 0;
|
|
176
|
+
padding: 0;
|
|
177
|
+
font: inherit;
|
|
178
|
+
cursor: pointer;
|
|
179
|
+
text-align: left;
|
|
180
|
+
}
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
var loginEl = document.getElementById('dp-login');
|
|
17
17
|
var panelEl = document.getElementById('dp-panel');
|
|
18
18
|
var filesEl = document.getElementById('dp-files');
|
|
19
|
+
var sharedEl = document.getElementById('dp-shared');
|
|
20
|
+
var crumbsEl = document.getElementById('dp-crumbs');
|
|
21
|
+
var entriesEl = document.getElementById('dp-entries');
|
|
22
|
+
|
|
23
|
+
/* The folder currently open in the "shared with you" tree; '' is its root. */
|
|
24
|
+
var prefix = '';
|
|
19
25
|
|
|
20
26
|
function say(msg) {
|
|
21
27
|
statusEl.textContent = msg;
|
|
@@ -64,13 +70,103 @@
|
|
|
64
70
|
});
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
/* One crumb. A button rather than a link: navigating the tree is a fetch, not
|
|
74
|
+
a page load, and the session cookie stays where it is. */
|
|
75
|
+
function crumb(label, target) {
|
|
76
|
+
var b = document.createElement('button');
|
|
77
|
+
b.className = 'dp-crumb';
|
|
78
|
+
b.type = 'button';
|
|
79
|
+
b.textContent = label;
|
|
80
|
+
b.addEventListener('click', function () {
|
|
81
|
+
prefix = target;
|
|
82
|
+
refresh();
|
|
83
|
+
});
|
|
84
|
+
return b;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderCrumbs() {
|
|
88
|
+
crumbsEl.replaceChildren();
|
|
89
|
+
crumbsEl.appendChild(crumb('All folders', ''));
|
|
90
|
+
var parts = prefix ? prefix.split('/') : [];
|
|
91
|
+
parts.forEach(function (part, i) {
|
|
92
|
+
crumbsEl.appendChild(document.createTextNode(' / '));
|
|
93
|
+
crumbsEl.appendChild(crumb(part, parts.slice(0, i + 1).join('/')));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Every label goes through textContent, same rule as the upload list: these
|
|
98
|
+
names come off the device and are never trusted as markup. */
|
|
99
|
+
function renderEntries(entries) {
|
|
100
|
+
renderCrumbs();
|
|
101
|
+
entriesEl.replaceChildren();
|
|
102
|
+
if (!entries.length) {
|
|
103
|
+
entriesEl.appendChild(item('Nothing here.'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
entries.forEach(function (e) {
|
|
107
|
+
var li = document.createElement('li');
|
|
108
|
+
li.className = 'dp-file-row';
|
|
109
|
+
|
|
110
|
+
var b = document.createElement('button');
|
|
111
|
+
b.className = e.kind === 'directory' ? 'dp-folder-name' : 'dp-file-name';
|
|
112
|
+
b.type = 'button';
|
|
113
|
+
b.textContent = e.kind === 'directory' ? e.name + '/' : e.name;
|
|
114
|
+
b.addEventListener('click', function () {
|
|
115
|
+
if (e.kind === 'directory') {
|
|
116
|
+
prefix = e.relPath;
|
|
117
|
+
refresh();
|
|
118
|
+
} else {
|
|
119
|
+
openShared(e.relPath);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
li.appendChild(b);
|
|
123
|
+
entriesEl.appendChild(li);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* These files live on the device, not in the portal's own store, so the
|
|
128
|
+
portal checks the device is reachable and then hands back a link that
|
|
129
|
+
expires. A device that is down says so plainly instead of failing as a
|
|
130
|
+
broken download. */
|
|
131
|
+
function openShared(relPath) {
|
|
132
|
+
say('Opening…');
|
|
133
|
+
api('/api/download?path=' + encodeURIComponent(relPath))
|
|
134
|
+
.then(function (r) {
|
|
135
|
+
return r.json().then(function (d) {
|
|
136
|
+
return { s: r.status, d: d };
|
|
137
|
+
});
|
|
138
|
+
})
|
|
139
|
+
.then(function (res) {
|
|
140
|
+
if (res.s === 503) {
|
|
141
|
+
say('That file is on the device, which is not reachable right now. Try again shortly.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!res.d.ok || !res.d.url) {
|
|
145
|
+
say('That file could not be opened.');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
say('');
|
|
149
|
+
window.location.assign(res.d.url);
|
|
150
|
+
})
|
|
151
|
+
.catch(function () {
|
|
152
|
+
say('That file could not be opened.');
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
67
156
|
function refresh() {
|
|
68
|
-
return api('/api/files')
|
|
157
|
+
return api('/api/files?prefix=' + encodeURIComponent(prefix))
|
|
69
158
|
.then(function (r) {
|
|
70
159
|
return r.json();
|
|
71
160
|
})
|
|
72
161
|
.then(function (d) {
|
|
73
|
-
if (d.ok)
|
|
162
|
+
if (!d.ok) return;
|
|
163
|
+
render(d.files || []);
|
|
164
|
+
var entries = d.entries || [];
|
|
165
|
+
// Hidden at the root only: once inside a folder the section stays put,
|
|
166
|
+
// so an empty folder reads as empty rather than as the whole tree
|
|
167
|
+
// vanishing.
|
|
168
|
+
sharedEl.hidden = prefix === '' && entries.length === 0;
|
|
169
|
+
renderEntries(entries);
|
|
74
170
|
})
|
|
75
171
|
.catch(function () {
|
|
76
172
|
say('Could not load your files.');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# __D1_DATABASE_NAME__ the D1 database name (<accountId>-<purpose>)
|
|
5
5
|
# __D1_DATABASE_ID__ the id printed by `wrangler d1 create`
|
|
6
6
|
# __R2_BUCKET_NAME__ the R2 bucket name (<accountId>-<purpose>)
|
|
7
|
+
# __PORTAL_INSTALL_ORIGIN__ the install's public origin (https://<tunnel-hostname>)
|
|
7
8
|
name = "__PROJECT_NAME__"
|
|
8
9
|
pages_build_output_dir = "."
|
|
9
10
|
compatibility_date = "2024-01-01"
|
|
@@ -19,3 +20,14 @@ database_id = "__D1_DATABASE_ID__"
|
|
|
19
20
|
[[r2_buckets]]
|
|
20
21
|
binding = "BUCKET"
|
|
21
22
|
bucket_name = "__R2_BUCKET_NAME__"
|
|
23
|
+
|
|
24
|
+
# Where the install answers, so a download of a device-held file can be signed
|
|
25
|
+
# and sent there. Public information — it is the hostname a client's browser
|
|
26
|
+
# ends up at.
|
|
27
|
+
#
|
|
28
|
+
# Its partner, PORTAL_FETCH_SECRET, is deliberately NOT here. This file is
|
|
29
|
+
# committed to the account's assembled tree and the deploy publishes the
|
|
30
|
+
# directory it is handed, so a secret in it is a secret in a repo and one deploy
|
|
31
|
+
# from being served. Set it with `wrangler pages secret put`.
|
|
32
|
+
[vars]
|
|
33
|
+
PORTAL_INSTALL_ORIGIN = "__PORTAL_INSTALL_ORIGIN__"
|
|
@@ -101,6 +101,18 @@ The most common cause is wrong nameservers on the domain. The domain must use Cl
|
|
|
101
101
|
- The `/data` page handles this itself: files over 95 MB are split into ~48 MB parts and sent one at a time under the cap, and the server reassembles them. No plan change or dashboard action is needed for uploads up to the 2 GB app ceiling.
|
|
102
102
|
- Raising the proxied body limit is a paid-plan change made on the Cloudflare dashboard, not something the agent does.
|
|
103
103
|
|
|
104
|
+
## The client file portal
|
|
105
|
+
|
|
106
|
+
A portal gives named people their own passcode-protected page on a custom domain. Two things sit on it.
|
|
107
|
+
|
|
108
|
+
**What they send you.** Anything they upload lands in the account's own storage, one area per person, and nobody sees anyone else's files.
|
|
109
|
+
|
|
110
|
+
**What you have prepared for them.** The same page shows the account's deliverable folders — the work produced for that client. Which folders those are is not a setting to maintain: it follows the account's own filing structure, so a business whose work is organised into quotes sees quotes, and a business organised some other way sees that instead. Operator working data, the client's own uploads, and anything internal are never shown.
|
|
111
|
+
|
|
112
|
+
Folder names and sizes are published to the portal on a schedule, so the client can browse even while the device is off. The files themselves stay on the device. Opening one checks the device is reachable and then hands the browser a link that expires after a few minutes; if the device is down the client is told exactly that, rather than getting a broken download.
|
|
113
|
+
|
|
114
|
+
Each person is bound to one account when they are enrolled. Someone enrolled before this existed has no account bound and sees no folders until they are enrolled again.
|
|
115
|
+
|
|
104
116
|
## What the agent does and does not do
|
|
105
117
|
|
|
106
118
|
**Does:** invokes `cloudflared` directly via Bash, following `plugins/cloudflare/references/manual-setup.md` step by step; quotes click-paths from the reference files verbatim; verifies external reachability with `curl -I` and surfaces the response.
|
package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal-index-audit.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/portal-index-audit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { auditPortalIndex, formatPortalIndexAudit, STALE_AFTER_INTERVALS, } from "../lib/portal-index-audit.js";
|
|
3
|
+
const HOUR = 3_600_000;
|
|
4
|
+
const NOW = 1_800_000_000_000;
|
|
5
|
+
const fresh = (ms) => ({
|
|
6
|
+
"acc-a": { lastPushMs: NOW - ms, rows: 41, exposed: ["output", "quotes"] },
|
|
7
|
+
});
|
|
8
|
+
describe("auditPortalIndex", () => {
|
|
9
|
+
it("reports a recently pushed account as healthy", () => {
|
|
10
|
+
const r = auditPortalIndex(["acc-a"], fresh(HOUR - 1), NOW, HOUR);
|
|
11
|
+
expect(r.stale).toBe(0);
|
|
12
|
+
expect(r.neverRan).toBe(0);
|
|
13
|
+
expect(r.accounts[0].ageMin).toBe(59);
|
|
14
|
+
expect(r.accounts[0].rows).toBe(41);
|
|
15
|
+
});
|
|
16
|
+
it("tolerates one missed cycle, flags two", () => {
|
|
17
|
+
// One miss is ordinary (a slow push, a device asleep at the tick). The
|
|
18
|
+
// threshold has to be loose enough that firing means something.
|
|
19
|
+
expect(auditPortalIndex(["acc-a"], fresh(HOUR * 1.5), NOW, HOUR).stale).toBe(0);
|
|
20
|
+
expect(auditPortalIndex(["acc-a"], fresh(HOUR * STALE_AFTER_INTERVALS + 1), NOW, HOUR).stale).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
// The signature of the failure this module exists for: the spawn never fires,
|
|
23
|
+
// so no state is ever written and no other line is ever emitted.
|
|
24
|
+
it("reports an enrolled account with no state as never-ran, not as stale", () => {
|
|
25
|
+
const r = auditPortalIndex(["acc-a"], {}, NOW, HOUR);
|
|
26
|
+
expect(r.neverRan).toBe(1);
|
|
27
|
+
expect(r.stale).toBe(0);
|
|
28
|
+
expect(r.accounts[0].ageMin).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it("ignores accounts that have no portal", () => {
|
|
31
|
+
const r = auditPortalIndex([], fresh(HOUR * 99), NOW, HOUR);
|
|
32
|
+
expect(r.accounts).toEqual([]);
|
|
33
|
+
expect(r.stale).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
it("survives a malformed state entry rather than throwing", () => {
|
|
36
|
+
const bad = { "acc-a": { rows: 1, exposed: [] } };
|
|
37
|
+
expect(auditPortalIndex(["acc-a"], bad, NOW, HOUR).neverRan).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
it("never reports a negative age when a clock moves backwards", () => {
|
|
40
|
+
const r = auditPortalIndex(["acc-a"], fresh(-HOUR), NOW, HOUR);
|
|
41
|
+
expect(r.accounts[0].ageMin).toBe(0);
|
|
42
|
+
expect(r.accounts[0].stale).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("formatPortalIndexAudit", () => {
|
|
46
|
+
it("emits a summary line even when everything is healthy", () => {
|
|
47
|
+
// An audit that goes quiet on success is indistinguishable from an audit
|
|
48
|
+
// that stopped running — the exact defect this module closes.
|
|
49
|
+
const lines = formatPortalIndexAudit(auditPortalIndex(["acc-a"], fresh(60_000), NOW, HOUR));
|
|
50
|
+
expect(lines).toHaveLength(1);
|
|
51
|
+
expect(lines[0]).toBe("[portal-index] op=audit accounts=1 stale=0 never-ran=0");
|
|
52
|
+
});
|
|
53
|
+
it("names a stale account with its age and exposed set", () => {
|
|
54
|
+
const lines = formatPortalIndexAudit(auditPortalIndex(["acc-a"], fresh(HOUR * 3), NOW, HOUR));
|
|
55
|
+
expect(lines[0]).toContain("account=acc-a");
|
|
56
|
+
expect(lines[0]).toContain("ageMin=180");
|
|
57
|
+
expect(lines[0]).toContain("exposed=output,quotes");
|
|
58
|
+
expect(lines[0]).toContain("result=stale");
|
|
59
|
+
});
|
|
60
|
+
it("distinguishes never-ran from stale in the line itself", () => {
|
|
61
|
+
const lines = formatPortalIndexAudit(auditPortalIndex(["acc-a"], {}, NOW, HOUR));
|
|
62
|
+
expect(lines[0]).toContain("result=never-ran");
|
|
63
|
+
expect(lines[0]).toContain("rows=0");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
//# sourceMappingURL=portal-index-audit.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal-index-audit.test.js","sourceRoot":"","sources":["../../src/__tests__/portal-index-audit.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface PortalIndexEntry {
|
|
2
|
+
lastPushMs: number;
|
|
3
|
+
rows: number;
|
|
4
|
+
exposed: string[];
|
|
5
|
+
}
|
|
6
|
+
export type PortalIndexState = Record<string, PortalIndexEntry>;
|
|
7
|
+
export interface PortalAccountAudit {
|
|
8
|
+
accountId: string;
|
|
9
|
+
/** No state entry at all: enrolled for a portal, never successfully pushed. */
|
|
10
|
+
neverRan: boolean;
|
|
11
|
+
/** Whole minutes since the last verified push; null when it never ran. */
|
|
12
|
+
ageMin: number | null;
|
|
13
|
+
rows: number | null;
|
|
14
|
+
exposed: string[];
|
|
15
|
+
stale: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PortalIndexAuditResult {
|
|
18
|
+
accounts: PortalAccountAudit[];
|
|
19
|
+
stale: number;
|
|
20
|
+
neverRan: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Two intervals, not one: a single missed cycle is ordinary (a slow push, a
|
|
24
|
+
* device asleep at the tick). Two consecutive misses is a pattern, and the
|
|
25
|
+
* threshold has to be loose enough that the line means something when it fires.
|
|
26
|
+
*/
|
|
27
|
+
export declare const STALE_AFTER_INTERVALS = 2;
|
|
28
|
+
/**
|
|
29
|
+
* @param portalAccountIds accounts carrying a data-portal.json — the enrolment
|
|
30
|
+
* signal. An account absent from this list has no portal and is not audited.
|
|
31
|
+
* @param state the push's own success record, keyed by accountId.
|
|
32
|
+
* @param nowMs
|
|
33
|
+
* @param intervalMs the push's cadence.
|
|
34
|
+
*/
|
|
35
|
+
export declare function auditPortalIndex(portalAccountIds: string[], state: PortalIndexState, nowMs: number, intervalMs: number): PortalIndexAuditResult;
|
|
36
|
+
/** One line per account needing attention, plus one summary line that fires
|
|
37
|
+
* even when everything is healthy — an audit that goes quiet when it finds
|
|
38
|
+
* nothing is indistinguishable from an audit that stopped running, which is
|
|
39
|
+
* the exact defect this whole module exists to close. */
|
|
40
|
+
export declare function formatPortalIndexAudit(result: PortalIndexAuditResult): string[];
|
|
41
|
+
//# sourceMappingURL=portal-index-audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal-index-audit.d.ts","sourceRoot":"","sources":["../../src/lib/portal-index-audit.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;AAE/D,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,+EAA+E;IAC/E,QAAQ,EAAE,OAAO,CAAA;IACjB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAA;AAEtC;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,gBAAgB,EAAE,MAAM,EAAE,EAC1B,KAAK,EAAE,gBAAgB,EACvB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,GACjB,sBAAsB,CA0BxB;AAED;;;0DAG0D;AAC1D,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,EAAE,CAgB/E"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Standing check: is each portal-serving account's folder index still being
|
|
2
|
+
// refreshed?
|
|
3
|
+
//
|
|
4
|
+
// Every other signal in the index push is event-driven — the script logs when
|
|
5
|
+
// it runs. So the one failure it cannot report is its own absence: if the
|
|
6
|
+
// hourly spawn never fires, there is no line at all, the client's folder tree
|
|
7
|
+
// silently freezes at whatever it last held, and silence is indistinguishable
|
|
8
|
+
// from a clean run.
|
|
9
|
+
//
|
|
10
|
+
// This audit therefore must NOT depend on the thing it audits. It runs
|
|
11
|
+
// in-process inside the heartbeat rather than as a spawned child, and it reads
|
|
12
|
+
// only local files — the state the push writes on success, and the presence of
|
|
13
|
+
// a data-portal.json. No D1, no Cloudflare credential, no second spawn that
|
|
14
|
+
// could fail the same way.
|
|
15
|
+
//
|
|
16
|
+
// The push writes state ONLY after a verified write, so a stale entry means the
|
|
17
|
+
// push stopped succeeding, not merely that it stopped logging.
|
|
18
|
+
/**
|
|
19
|
+
* Two intervals, not one: a single missed cycle is ordinary (a slow push, a
|
|
20
|
+
* device asleep at the tick). Two consecutive misses is a pattern, and the
|
|
21
|
+
* threshold has to be loose enough that the line means something when it fires.
|
|
22
|
+
*/
|
|
23
|
+
export const STALE_AFTER_INTERVALS = 2;
|
|
24
|
+
/**
|
|
25
|
+
* @param portalAccountIds accounts carrying a data-portal.json — the enrolment
|
|
26
|
+
* signal. An account absent from this list has no portal and is not audited.
|
|
27
|
+
* @param state the push's own success record, keyed by accountId.
|
|
28
|
+
* @param nowMs
|
|
29
|
+
* @param intervalMs the push's cadence.
|
|
30
|
+
*/
|
|
31
|
+
export function auditPortalIndex(portalAccountIds, state, nowMs, intervalMs) {
|
|
32
|
+
const threshold = intervalMs * STALE_AFTER_INTERVALS;
|
|
33
|
+
const accounts = portalAccountIds.map((accountId) => {
|
|
34
|
+
const entry = state[accountId];
|
|
35
|
+
if (!entry || typeof entry.lastPushMs !== "number") {
|
|
36
|
+
// Never-ran is reported as its own condition rather than as infinite
|
|
37
|
+
// staleness: the remedies differ (a first run vs a broken cycle), and a
|
|
38
|
+
// brand-new portal must not look like a regression on its first tick.
|
|
39
|
+
return { accountId, neverRan: true, ageMin: null, rows: null, exposed: [], stale: false };
|
|
40
|
+
}
|
|
41
|
+
const ageMs = Math.max(0, nowMs - entry.lastPushMs);
|
|
42
|
+
return {
|
|
43
|
+
accountId,
|
|
44
|
+
neverRan: false,
|
|
45
|
+
ageMin: Math.floor(ageMs / 60_000),
|
|
46
|
+
rows: typeof entry.rows === "number" ? entry.rows : null,
|
|
47
|
+
exposed: Array.isArray(entry.exposed) ? entry.exposed : [],
|
|
48
|
+
stale: ageMs > threshold,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
accounts,
|
|
53
|
+
stale: accounts.filter((a) => a.stale).length,
|
|
54
|
+
neverRan: accounts.filter((a) => a.neverRan).length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** One line per account needing attention, plus one summary line that fires
|
|
58
|
+
* even when everything is healthy — an audit that goes quiet when it finds
|
|
59
|
+
* nothing is indistinguishable from an audit that stopped running, which is
|
|
60
|
+
* the exact defect this whole module exists to close. */
|
|
61
|
+
export function formatPortalIndexAudit(result) {
|
|
62
|
+
const lines = [];
|
|
63
|
+
for (const a of result.accounts) {
|
|
64
|
+
if (a.neverRan) {
|
|
65
|
+
lines.push(`[portal-index] op=audit account=${a.accountId} rows=0 result=never-ran`);
|
|
66
|
+
}
|
|
67
|
+
else if (a.stale) {
|
|
68
|
+
lines.push(`[portal-index] op=audit account=${a.accountId} rows=${a.rows} ageMin=${a.ageMin} ` +
|
|
69
|
+
`exposed=${a.exposed.join(",") || "none"} result=stale`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
lines.push(`[portal-index] op=audit accounts=${result.accounts.length} stale=${result.stale} never-ran=${result.neverRan}`);
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=portal-index-audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal-index-audit.js","sourceRoot":"","sources":["../../src/lib/portal-index-audit.ts"],"names":[],"mappings":"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"}
|
|
@@ -28,6 +28,7 @@ import cronParser from "cron-parser";
|
|
|
28
28
|
const { parseExpression } = cronParser;
|
|
29
29
|
import { chooseDispatchMode, dispatchAgentTurn, runStandingAudit, auditSelfCancels } from "./agent-turn-dispatch.js";
|
|
30
30
|
import { spawn } from "node:child_process";
|
|
31
|
+
import { auditPortalIndex, formatPortalIndexAudit, } from "../lib/portal-index-audit.js";
|
|
31
32
|
import { shouldRunSentSweep, SENT_SWEEP_INTERVAL_MS, } from "../lib/sent-sweep-throttle.js";
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
33
34
|
// Brand-aware config directory — reads configDir from brand.json stamped at install time.
|
|
@@ -492,6 +493,110 @@ function maybeRunSentSweep(platformRoot) {
|
|
|
492
493
|
});
|
|
493
494
|
child.unref();
|
|
494
495
|
}
|
|
496
|
+
const PORTAL_INDEX_STATE_FILE = resolve(CONFIG_DIR, "portal-index-push-state.json");
|
|
497
|
+
const PORTAL_INDEX_INTERVAL_MS = 60 * 60 * 1000;
|
|
498
|
+
/**
|
|
499
|
+
* Publish each portal-serving account's deliverable folder index, at most hourly.
|
|
500
|
+
*
|
|
501
|
+
* Spawned rather than imported, for the same reason as the sent-copy sweep: the
|
|
502
|
+
* script lives in the cloudflare plugin and talks to Cloudflare's API, which does
|
|
503
|
+
* not belong inside the scheduling build.
|
|
504
|
+
*
|
|
505
|
+
* The script decides WHICH accounts have portals (a `data-portal.json` is the
|
|
506
|
+
* signal) and which folders each one exposes. Nothing about that is configured
|
|
507
|
+
* here, so this stays a spawn on a timer.
|
|
508
|
+
*
|
|
509
|
+
* State is written before the spawn, not after it completes: a push that hangs
|
|
510
|
+
* must not cause a fresh one to start behind it every minute.
|
|
511
|
+
*/
|
|
512
|
+
function maybeRunPortalIndexPush(platformRoot) {
|
|
513
|
+
let prev;
|
|
514
|
+
try {
|
|
515
|
+
prev = JSON.parse(readFileSync(PORTAL_INDEX_STATE_FILE, "utf8"));
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
prev = undefined;
|
|
519
|
+
}
|
|
520
|
+
const now = Date.now();
|
|
521
|
+
if (prev?.lastRunMs && now - prev.lastRunMs < PORTAL_INDEX_INTERVAL_MS)
|
|
522
|
+
return;
|
|
523
|
+
const script = resolve(platformRoot, "plugins/cloudflare/bin/portal-index-push.mjs");
|
|
524
|
+
if (!existsSync(script)) {
|
|
525
|
+
console.error(`[portal-index] skipped: ${script} not found`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
writeFileSync(PORTAL_INDEX_STATE_FILE, JSON.stringify({ lastRunMs: now }));
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
console.error(`[portal-index] state write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const child = spawn(process.execPath, [script, "--all-accounts"], {
|
|
536
|
+
env: { ...process.env, PLATFORM_ROOT: platformRoot },
|
|
537
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
538
|
+
detached: true,
|
|
539
|
+
});
|
|
540
|
+
child.unref();
|
|
541
|
+
}
|
|
542
|
+
const PORTAL_AUDIT_STATE_FILE = resolve(CONFIG_DIR, "portal-index-audit-state.json");
|
|
543
|
+
/**
|
|
544
|
+
* Standing check: is each portal-serving account's index still being refreshed?
|
|
545
|
+
*
|
|
546
|
+
* Runs IN-PROCESS, not as a spawn, and reads only local files. That is the
|
|
547
|
+
* whole point — every other signal in the push is event-driven, so the one
|
|
548
|
+
* failure none of them can report is the push not running at all. An audit that
|
|
549
|
+
* went through the same spawn, or needed the same Cloudflare credential, would
|
|
550
|
+
* be blind to exactly the case it exists for.
|
|
551
|
+
*
|
|
552
|
+
* Throttled to the push's own cadence so the summary line stays readable in a
|
|
553
|
+
* log that already carries a line a minute.
|
|
554
|
+
*/
|
|
555
|
+
function auditPortalIndexPush(platformRoot) {
|
|
556
|
+
let prev;
|
|
557
|
+
try {
|
|
558
|
+
prev = JSON.parse(readFileSync(PORTAL_AUDIT_STATE_FILE, "utf8"));
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
prev = undefined;
|
|
562
|
+
}
|
|
563
|
+
const now = Date.now();
|
|
564
|
+
if (prev?.lastRunMs && now - prev.lastRunMs < PORTAL_INDEX_INTERVAL_MS)
|
|
565
|
+
return;
|
|
566
|
+
const dataRoot = resolve(platformRoot, "..", "data");
|
|
567
|
+
const accountsDir = join(dataRoot, "accounts");
|
|
568
|
+
if (!existsSync(accountsDir))
|
|
569
|
+
return;
|
|
570
|
+
// A data-portal.json is the enrolment signal — the same one the push uses, so
|
|
571
|
+
// the two cannot disagree about which accounts are in scope.
|
|
572
|
+
let portalAccountIds = [];
|
|
573
|
+
try {
|
|
574
|
+
portalAccountIds = readdirSync(accountsDir).filter((d) => existsSync(join(accountsDir, d, "data-portal.json")));
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (portalAccountIds.length === 0)
|
|
580
|
+
return;
|
|
581
|
+
let state = {};
|
|
582
|
+
try {
|
|
583
|
+
state = JSON.parse(readFileSync(join(dataRoot, "portal-index-state.json"), "utf8"));
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// Absent state with portals enrolled is itself the never-ran signal, so an
|
|
587
|
+
// empty object is the correct input here rather than a reason to bail.
|
|
588
|
+
state = {};
|
|
589
|
+
}
|
|
590
|
+
for (const line of formatPortalIndexAudit(auditPortalIndex(portalAccountIds, state, now, PORTAL_INDEX_INTERVAL_MS))) {
|
|
591
|
+
console.error(line);
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
writeFileSync(PORTAL_AUDIT_STATE_FILE, JSON.stringify({ lastRunMs: now }));
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
console.error(`[portal-index] op=audit-state-write-failed err="${err instanceof Error ? err.message : String(err)}"`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
495
600
|
async function main() {
|
|
496
601
|
if (!acquireLock()) {
|
|
497
602
|
console.error("[check-due] another instance running, exiting");
|
|
@@ -672,6 +777,23 @@ async function main() {
|
|
|
672
777
|
catch (err) {
|
|
673
778
|
console.error(`[email-sent-sweep] spawn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
674
779
|
}
|
|
780
|
+
// --- Data-portal folder index (hourly) ---
|
|
781
|
+
// Guarded on the same principle: an optional hourly publish must not cost
|
|
782
|
+
// the every-minute dispatcher its completion line.
|
|
783
|
+
try {
|
|
784
|
+
maybeRunPortalIndexPush(process.env.PLATFORM_ROOT);
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
console.error(`[portal-index] spawn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
788
|
+
}
|
|
789
|
+
// Deliberately AFTER the spawn and independent of whether it succeeded:
|
|
790
|
+
// this is the check that speaks when the push does not.
|
|
791
|
+
try {
|
|
792
|
+
auditPortalIndexPush(process.env.PLATFORM_ROOT);
|
|
793
|
+
}
|
|
794
|
+
catch (err) {
|
|
795
|
+
console.error(`[portal-index] op=audit-failed err="${err instanceof Error ? err.message : String(err)}"`);
|
|
796
|
+
}
|
|
675
797
|
console.error(`[check-due] done: ${processed} processed, ${dispatched} dispatched, ${errors} errors`);
|
|
676
798
|
}
|
|
677
799
|
finally {
|