@rubytech/create-maxy-code 0.1.475 → 0.1.477

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 (89) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  3. package/payload/platform/lib/storage-broker/dist/cf-exec.js +3 -2
  4. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  5. package/payload/platform/lib/storage-broker/src/cf-exec.ts +3 -2
  6. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -3
  7. package/payload/platform/plugins/cloudflare/skills/calendar-site/template/schema.sql +13 -3
  8. package/payload/platform/plugins/docs/references/calendar-booking.md +2 -2
  9. package/payload/platform/plugins/scheduling/PLUGIN.md +2 -2
  10. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.d.ts +2 -0
  11. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.d.ts.map +1 -0
  12. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.js +150 -0
  13. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.js.map +1 -0
  14. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.d.ts +2 -0
  15. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.d.ts.map +1 -0
  16. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.js +248 -0
  17. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.js.map +1 -0
  18. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +158 -23
  19. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
  20. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.d.ts +2 -0
  21. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.d.ts.map +1 -0
  22. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.js +200 -0
  23. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.js.map +1 -0
  24. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.d.ts +2 -0
  25. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.js +309 -0
  27. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.js.map +1 -0
  28. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js +219 -19
  29. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js.map +1 -1
  30. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +184 -11
  31. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  32. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +211 -22
  33. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  34. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.d.ts +109 -0
  35. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.d.ts.map +1 -0
  36. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.js +213 -0
  37. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.js.map +1 -0
  38. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts +80 -8
  39. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts.map +1 -1
  40. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js +202 -8
  41. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js.map +1 -1
  42. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.d.ts +6 -4
  43. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.d.ts.map +1 -1
  44. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +411 -141
  45. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  46. package/payload/platform/scripts/cpu-triage.sh +313 -0
  47. package/payload/server/{chunk-Y6V45R6M.js → chunk-JECAP3Z2.js} +296 -63
  48. package/payload/server/maxy-edge.js +1 -1
  49. package/payload/server/public/activity.html +6 -6
  50. package/payload/server/public/assets/{AdminLoginScreens-B44KbEuC.js → AdminLoginScreens-mEKP4pDi.js} +1 -1
  51. package/payload/server/public/assets/{AdminShell-BjxV2qkb.js → AdminShell-BwSaZ88d.js} +1 -1
  52. package/payload/server/public/assets/{Checkbox-DSeZNGNA.js → Checkbox-CN4uQ80w.js} +1 -1
  53. package/payload/server/public/assets/activity-CIQ8ozNZ.js +1 -0
  54. package/payload/server/public/assets/{admin-B4LpTCur.js → admin-BVhgfgs1.js} +1 -1
  55. package/payload/server/public/assets/{browser-CEFvaWwI.js → browser-D-HMmHcM.js} +1 -1
  56. package/payload/server/public/assets/{calendar-B3zo6V34.js → calendar-tGWvFyFn.js} +1 -1
  57. package/payload/server/public/assets/chat-DkttVxAz.js +1 -0
  58. package/payload/server/public/assets/chevron-left-CZ4ez9G5.js +1 -0
  59. package/payload/server/public/assets/data-P-mcmnNi.js +1 -0
  60. package/payload/server/public/assets/{graph-labels-GlTEIYJj.js → graph-labels-qnBleOE6.js} +1 -1
  61. package/payload/server/public/assets/{graph-RaOrnSf2.js → graph-zEw610xK.js} +1 -1
  62. package/payload/server/public/assets/{maximize-2-CHDa8p5h.js → maximize-2-BlTjXT_Y.js} +1 -1
  63. package/payload/server/public/assets/{operator-DtqHY4jV.js → operator-C7oIw2PG.js} +1 -1
  64. package/payload/server/public/assets/{page-DxnWgPtd.js → page-CTP7OFZa.js} +1 -1
  65. package/payload/server/public/assets/{page-CkhFj8GQ.js → page-DL6Zsdvk.js} +1 -1
  66. package/payload/server/public/assets/{public-D5TUfQ3I.js → public-r1A9dqR_.js} +1 -1
  67. package/payload/server/public/assets/{rotate-ccw-BJjVAQQY.js → rotate-ccw-CzkkKx4-.js} +1 -1
  68. package/payload/server/public/assets/{tasks-CM9pYYG1.js → tasks-fIJwYFWG.js} +1 -1
  69. package/payload/server/public/assets/{time-entry-format-C5RYEYjM.js → time-entry-format-j669DgXj.js} +1 -1
  70. package/payload/server/public/assets/{triangle-alert-DLfIsuo_.js → triangle-alert-BPgCgIef.js} +1 -1
  71. package/payload/server/public/assets/{useCopyFeedback-7yKeiAzV.js → useCopyFeedback-BmLa2aes.js} +1 -1
  72. package/payload/server/public/assets/{useSelectionMode-CWlh6AEq.js → useSelectionMode-DnnM7A8-.js} +1 -1
  73. package/payload/server/public/assets/{useSubAccountSwitcher-B2n9QPVI.css → useSubAccountSwitcher-C_E8h07P.css} +1 -1
  74. package/payload/server/public/assets/{useVoiceRecorder-j3ZArZ2H.js → useVoiceRecorder-u2dwZfen.js} +1 -1
  75. package/payload/server/public/browser.html +5 -5
  76. package/payload/server/public/calendar.html +6 -6
  77. package/payload/server/public/chat.html +12 -12
  78. package/payload/server/public/data.html +11 -11
  79. package/payload/server/public/graph.html +11 -11
  80. package/payload/server/public/index.html +14 -14
  81. package/payload/server/public/operator.html +14 -14
  82. package/payload/server/public/public.html +12 -12
  83. package/payload/server/public/tasks.html +5 -5
  84. package/payload/server/server.js +358 -189
  85. package/payload/server/public/assets/activity-Mz1BhgB1.js +0 -1
  86. package/payload/server/public/assets/chat-5tJdo9i_.js +0 -1
  87. package/payload/server/public/assets/chevron-left-BJllrPBj.js +0 -1
  88. package/payload/server/public/assets/data-Du79EgR-.js +0 -1
  89. /package/payload/server/public/assets/{useSubAccountSwitcher-CBlKgwms.js → useSubAccountSwitcher-CFa6ZAT1.js} +0 -0
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Dispatcher script: reconcile-bookings
3
3
  *
4
- * Self-contained, like check-due-events. Pulls accepted booking rows from each
4
+ * Self-contained, like check-due-events. Pulls eligible booking rows from each
5
5
  * account's Cloudflare D1 `bookings` table (written by the booking site's Pages
6
- * Function), writes each into Neo4j as a :Meeting so it appears on the admin
7
- * calendar, links the booker as a :Person, and notifies the admin. Marks each
8
- * row swept so it reconciles exactly once. Ends with the standing audit.
6
+ * Function), claims the ones with no :Meeting in the graph, writes each into
7
+ * Neo4j as a :Meeting so it appears on the admin calendar, links the booker as
8
+ * a :Person, and notifies the admin. A row is claimed on the absence of its
9
+ * meeting, not on a flag, so a meeting that later goes missing self-heals.
10
+ * Ends with the standing audit over that same population.
9
11
  *
10
12
  * Task 1654 — for each booking-site account it also create-only provisions the
11
13
  * recurring connector-sync :Event (the admin turn that reads the calendar connector
@@ -22,13 +24,14 @@
22
24
  * PLATFORM_ROOT=... NEO4J_URI=... node dist/scripts/reconcile-bookings.js
23
25
  */
24
26
  import neo4j from "neo4j-driver";
25
- import { spawnSync } from "node:child_process";
27
+ import { randomUUID } from "node:crypto";
26
28
  import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from "node:fs";
27
29
  import { resolve, join } from "node:path";
28
30
  import { homedir } from "node:os";
29
- import { reconcileOne, auditCounts, ensureConnectorSyncEvent, countPublishedConflicts } from "../lib/booking-reconcile.js";
31
+ import { reconcileOne, auditCounts, ensureConnectorSyncEvent, classifyPublishedDrift, fetchBookingMeetingUids, selectUnreconciled, ELIGIBLE_BOOKING_SELECT, BOOKING_SCHEMA_MIGRATION, isMissingColumnError, isDuplicateColumnError, planBlockWrites, BLOCK_ROWS_SELECT, BLOCK_ROW_INSERT, BLOCK_ROW_DELETE, blockInsertParams, } from "../lib/booking-reconcile.js";
32
+ import { detectCalendarConnectors, resolveDispatchDestination, shouldEmitProvisionLine, provisionSignature, emitLabel, readProvisionState, writeProvisionState, } from "../lib/connector-sync-provision.js";
30
33
  import { notifyBooking } from "../lib/booking-notify.js";
31
- import { buildD1Command } from "../lib/d1-command.js";
34
+ import { createD1Client } from "../lib/d1-command.js";
32
35
  import { resolveHouseScopedToken } from "../lib/house-scoped-token.js";
33
36
  function resolveConfigDir() {
34
37
  const platformRoot = process.env.PLATFORM_ROOT;
@@ -44,6 +47,39 @@ function resolveConfigDir() {
44
47
  }
45
48
  const CONFIG_DIR = resolve(homedir(), resolveConfigDir());
46
49
  const LOCK_FILE = resolve(CONFIG_DIR, "reconcile-bookings.lock");
50
+ // Task 1809 — the per-account connector-sync provisioning outcome, persisted so
51
+ // a permanent condition is reported on change plus this heartbeat rather than
52
+ // once per tick. Six hours over a 120 s tick turns 2,880 lines a day per
53
+ // account into four.
54
+ const PROVISION_STATE_FILE = resolve(CONFIG_DIR, "cal-sync-provision-state.json");
55
+ const PROVISION_HEARTBEAT_MS = 6 * 60 * 60 * 1000;
56
+ // Task 1806/1807 — which accounts have completed the bookings schema migration.
57
+ // Written only after all three statements succeed, so a partial migration is
58
+ // retried on the next tick. Gating on the read failing instead would not retry:
59
+ // once the ALTERs land the read succeeds, the missing-column trigger never fires
60
+ // again, and a backfill that failed leaves every block row eligible — which the
61
+ // sweep would then reconcile into a meeting.
62
+ const SCHEMA_STATE_FILE = resolve(CONFIG_DIR, "booking-schema-migrated.json");
63
+ function hasCompletedSchemaMigration(accountId) {
64
+ try {
65
+ const state = JSON.parse(readFileSync(SCHEMA_STATE_FILE, "utf-8"));
66
+ return typeof state[accountId] === "string";
67
+ }
68
+ catch {
69
+ return false; // absent or unreadable — migrate, which is safe to re-run
70
+ }
71
+ }
72
+ function markSchemaMigrated(accountId) {
73
+ let state = {};
74
+ try {
75
+ state = JSON.parse(readFileSync(SCHEMA_STATE_FILE, "utf-8"));
76
+ }
77
+ catch {
78
+ /* first account, or unreadable — start a fresh map */
79
+ }
80
+ state[accountId] = new Date().toISOString();
81
+ writeFileSync(SCHEMA_STATE_FILE, JSON.stringify(state, null, 2));
82
+ }
47
83
  function acquireLock() {
48
84
  mkdirSync(CONFIG_DIR, { recursive: true });
49
85
  if (existsSync(LOCK_FILE)) {
@@ -91,7 +127,13 @@ function connectNeo4j() {
91
127
  return neo4j.driver(uri, neo4j.auth.basic(user, password));
92
128
  }
93
129
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
94
- /** Read `KEY=value` pairs from a secrets env file without polluting process.env. */
130
+ /** Read `KEY=value` pairs from a secrets env file without polluting process.env.
131
+ *
132
+ * Values are trimmed, matching the sibling reader in house-scoped-token.ts. The
133
+ * split is on "\n", so a CRLF file leaves a trailing "\r" on every value; since
134
+ * Task 1805 these values reach Cloudflare as a URL path segment and a Bearer
135
+ * header rather than as a child process env, where a stray "\r" fails as an
136
+ * opaque invalid-header error instead of anything nameable. */
95
137
  function readEnvFile(path) {
96
138
  const out = {};
97
139
  if (!existsSync(path))
@@ -99,41 +141,15 @@ function readEnvFile(path) {
99
141
  for (const line of readFileSync(path, "utf-8").split("\n")) {
100
142
  const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
101
143
  if (m)
102
- out[m[1]] = m[2];
144
+ out[m[1]] = m[2].trim();
103
145
  }
104
146
  return out;
105
147
  }
106
- /** First registered admin WhatsApp phone for this account, or null. Provisioning
107
- * skips (observably) when no admin destination exists to dispatch the sync to. */
108
- function readAdminPhone(accountDir) {
109
- try {
110
- const cfg = JSON.parse(readFileSync(join(accountDir, "account.json"), "utf-8"));
111
- const phones = Array.isArray(cfg.adminPhones) ? cfg.adminPhones : [];
112
- return typeof phones[0] === "string" && phones[0].length > 0 ? phones[0] : null;
113
- }
114
- catch {
115
- return null;
116
- }
117
- }
118
- /** Run a D1 SQL statement via wrangler with the per-scope token. Returns parsed rows. */
119
- function d1Query(dbName, sql, env) {
120
- const { command, args } = buildD1Command(dbName, sql);
121
- const res = spawnSync(command, args, {
122
- env: { ...process.env, ...env },
123
- encoding: "utf-8",
124
- maxBuffer: 16 * 1024 * 1024,
125
- // Bound each call so a stalled wrangler/network cannot wedge the whole
126
- // run (which would hold the PID lock and silently stall reconcile).
127
- timeout: 60_000,
128
- });
129
- if (res.status !== 0) {
130
- throw new Error(`wrangler d1 execute failed (status ${res.status}${res.signal ? `, signal ${res.signal}` : ""})`);
131
- }
132
- // wrangler --json prints an array of result objects, each with a `results` array.
133
- const parsed = JSON.parse(res.stdout || "[]");
134
- const first = Array.isArray(parsed) ? parsed[0] : parsed;
135
- return (first && Array.isArray(first.results) ? first.results : []);
136
- }
148
+ // Task 1809 `readAdminPhone` lived here. It read a top-level `account.json`
149
+ // `adminPhones` key that exists on no account (the authoritative key is
150
+ // `whatsapp.adminPhones`), and gated provisioning on the result, so every tick
151
+ // on every brand logged `skipped` and nothing was ever provisioned. Both the
152
+ // field path and the gate now live in connector-sync-provision.ts.
137
153
  /** Resolve an email sender from the sibling email plugin, defensively. */
138
154
  async function resolveEmailNotify(accountId) {
139
155
  const platformRoot = process.env.PLATFORM_ROOT;
@@ -163,7 +179,25 @@ async function resolveEmailNotify(accountId) {
163
179
  return {};
164
180
  }
165
181
  }
166
- async function reconcileAccount(driver, accountId, accountDir, houseToken, houseAccountId) {
182
+ /**
183
+ * Bring an already-provisioned bookings table up to the schema the sweep needs.
184
+ * Each statement tolerates "duplicate column name" on its own, so a table that
185
+ * already has one of the two columns completes rather than aborting.
186
+ */
187
+ async function migrateBookingSchema(d1, dbName, accountId) {
188
+ for (const sql of BOOKING_SCHEMA_MIGRATION) {
189
+ try {
190
+ await d1.query(dbName, sql);
191
+ }
192
+ catch (err) {
193
+ if (isDuplicateColumnError(err.message))
194
+ continue;
195
+ throw err;
196
+ }
197
+ }
198
+ console.error(`[calendar-reconcile] account=${accountId} op=schema-migrate result=applied`);
199
+ }
200
+ async function reconcileAccount(driver, accountId, accountDir, makeClient, provisionState) {
167
201
  const availPath = join(accountDir, "calendar-availability.json");
168
202
  if (!existsSync(availPath))
169
203
  return; // no booking site for this account
@@ -182,131 +216,350 @@ async function reconcileAccount(driver, accountId, accountDir, houseToken, house
182
216
  }
183
217
  // Task 1654 — provision the recurring connector-sync turn for this booking-site
184
218
  // account. Create-only; the admin session it spawns is the only seat that can
185
- // read the calendar connector. Skips observably when no admin destination exists.
219
+ // read the calendar connector.
220
+ //
221
+ // Task 1809 — the gate is the presence of a CALENDAR CONNECTOR, the sync's own
222
+ // input, not the presence of a WhatsApp number (which is only where the turn
223
+ // reports an exception). The outcome distinguishes the four states an operator
224
+ // can act on differently, and is reported on change plus a heartbeat rather
225
+ // than once per 120 s tick.
226
+ const accountsDir = resolve(accountDir, "..");
186
227
  const syncSession = driver.session();
228
+ let outcome;
229
+ let connectors = [];
230
+ let dispatch = null;
231
+ let detail;
187
232
  try {
188
- const adminPhone = readAdminPhone(accountDir);
189
- if (!adminPhone) {
190
- console.error(`[calendar-availability] op=cal-sync-provision account=${accountId} result=skipped reason=no-admin-destination`);
233
+ connectors = detectCalendarConnectors(accountDir);
234
+ if (connectors.length === 0) {
235
+ outcome = "not-applicable";
191
236
  }
192
237
  else {
193
- const outcome = await ensureConnectorSyncEvent(syncSession, accountId, adminPhone);
194
- console.error(`[calendar-availability] op=cal-sync-provision account=${accountId} result=${outcome}`);
238
+ const resolved = resolveDispatchDestination(accountsDir, accountId);
239
+ if (resolved.destination === null) {
240
+ // A sync that fails and cannot report is not a clean sync, so an
241
+ // account with no reachable destination is reported unconfigured
242
+ // rather than armed. Task 1812 adds the destination-free route. The
243
+ // refusal reason travels with it — `unconfigured` at an account whose
244
+ // account.json visibly holds a phone number is otherwise unexplainable.
245
+ outcome = "unconfigured";
246
+ detail = resolved.reason;
247
+ }
248
+ else {
249
+ dispatch = resolved.destination;
250
+ outcome = await ensureConnectorSyncEvent(syncSession, accountId, dispatch);
251
+ }
195
252
  }
196
253
  }
197
254
  catch (err) {
198
- console.error(`[calendar-availability] op=cal-sync-provision account=${accountId} result=error reason=${JSON.stringify(err.message)}`);
255
+ outcome = "error";
256
+ detail = err.message;
199
257
  }
200
258
  finally {
201
- await syncSession.close();
202
- }
203
- // Task 1631 house code uses the house-only Cloudflare credential, never a
204
- // per-account token. The account-wide token no longer lives in a sub-account
205
- // secrets file; the booking DB is still targeted by its per-account name.
206
- const wranglerEnv = {
207
- CLOUDFLARE_API_TOKEN: houseToken,
208
- ...(houseAccountId ? { CLOUDFLARE_ACCOUNT_ID: houseAccountId } : {}),
209
- };
210
- let pending;
211
- try {
212
- pending = d1Query(dbName, "SELECT bookingId, slotStart, slotEnd, name, email, note FROM bookings WHERE status='accepted' AND swept=0 ORDER BY id", wranglerEnv);
259
+ // A rejecting close must not carry a computed outcome out of the function
260
+ // unreported — the emit below is the only place this account's state is
261
+ // recorded. Mirrors the guard in agent-turn-dispatch.ts.
262
+ try {
263
+ await syncSession.close();
264
+ }
265
+ catch {
266
+ /* ignore */
267
+ }
213
268
  }
214
- catch (err) {
215
- console.error(`[calendar-reconcile] account=${accountId} d1-read-failed err="${err.message}"`);
216
- return;
269
+ // Every outcome, `error` included, goes through one emit gate. Exempting
270
+ // errors would log a persistent Neo4j fault at tick rate forever — the same
271
+ // volume profile as the 7,868 lines this task removes — and would leave the
272
+ // state map holding the pre-error outcome, making the eventual recovery
273
+ // invisible because it would read as unchanged.
274
+ const nowMs = Date.now();
275
+ const signature = provisionSignature(outcome, detail);
276
+ const prev = provisionState[accountId];
277
+ if (shouldEmitProvisionLine(prev, signature, nowMs, PROVISION_HEARTBEAT_MS)) {
278
+ console.error(`[calendar-availability] op=cal-sync-provision account=${accountId} result=${outcome} ` +
279
+ `connectors=${connectors.length > 0 ? connectors.join("+") : "none"} ` +
280
+ `destination=${dispatch ? dispatch.channel : "none"} ` +
281
+ `${detail ? `reason=${JSON.stringify(detail)} ` : ""}emit=${emitLabel(prev, signature)}`);
282
+ provisionState[accountId] = { outcome, signature, loggedAtMs: nowMs };
217
283
  }
218
- const notifyDeps = pending.length > 0 ? await resolveEmailNotify(accountId) : {};
219
- for (const row of pending) {
220
- const bookingId = String(row.bookingId);
221
- // bookingId is written by the Pages Function as crypto.randomUUID(); a row
222
- // whose id is not a UUID was not written by us, so refuse to interpolate it
223
- // into the sweep statement (defence in depth against a non-parameterized id).
224
- if (!UUID_RE.test(bookingId)) {
225
- console.error(`[calendar-reconcile] account=${accountId} skip-row reason=non-uuid-bookingId`);
226
- continue;
284
+ // One client per account run: it holds this database's name -> uuid
285
+ // resolution for the whole tick, and carries the counters the op=d1 line
286
+ // reports. Task 1631's house-only credential reaches D1 as the Bearer token
287
+ // now rather than as a child process env.
288
+ const d1 = makeClient();
289
+ try {
290
+ // The kind/attempts columns cannot reach an already-provisioned database
291
+ // through the template, so this account's first run migrates it. Retried
292
+ // until it completes, because a half-migrated table (columns added, block
293
+ // rows unclassified) leaves every block row eligible and the sweep would
294
+ // reconcile each into a meeting.
295
+ if (!hasCompletedSchemaMigration(accountId)) {
296
+ try {
297
+ await migrateBookingSchema(d1, dbName, accountId);
298
+ markSchemaMigrated(accountId);
299
+ }
300
+ catch (err) {
301
+ console.error(`[calendar-reconcile] account=${accountId} op=schema-migrate result=failed err="${err.message}"`);
302
+ return; // never sweep against a table whose block rows may be unclassified
303
+ }
227
304
  }
228
- const booking = {
229
- bookingId,
230
- slotStart: String(row.slotStart),
231
- slotEnd: String(row.slotEnd),
232
- name: String(row.name),
233
- email: String(row.email),
234
- note: row.note == null ? undefined : String(row.note),
235
- };
236
- const started = Date.now();
237
- console.error(`[calendar-booking] op=reconcile-claim bookingId=${booking.bookingId} accountId=${accountId}`);
238
- const session = driver.session();
305
+ // Task 1806 — one read serves both the sweep and the audit, so the two can
306
+ // never disagree about the population. Task 1807 — it is no longer filtered
307
+ // on swept; which rows are claimed is decided against the graph below.
308
+ let eligible;
239
309
  try {
240
- const result = await reconcileOne(session, accountId, booking);
241
- console.error(`[calendar-booking] op=meeting-write bookingId=${booking.bookingId} meetingId=${result.meetingId} created=${result.created}`);
242
- // Notify only when the meeting was freshly created. An idempotent re-run
243
- // (created=false, e.g. a prior pass wrote the meeting but the sweep
244
- // failed) must not re-email the admin.
245
- if (result.created) {
246
- const notes = await notifyBooking(notifyDeps, booking);
247
- for (const n of notes) {
248
- console.error(`[calendar-booking] op=notify bookingId=${booking.bookingId} channel=${n.channel} dispatched=${n.dispatched}`);
249
- }
250
- }
251
- // Mark swept only after a successful meeting write, so a reconcile crash
252
- // leaves the row for the next run rather than dropping it silently.
253
- d1Query(dbName, `UPDATE bookings SET swept=1 WHERE bookingId='${booking.bookingId}'`, wranglerEnv);
254
- console.error(`[calendar-booking] op=booked bookingId=${booking.bookingId} ms=${Date.now() - started}`);
310
+ eligible = await d1.query(dbName, ELIGIBLE_BOOKING_SELECT);
255
311
  }
256
312
  catch (err) {
257
- console.error(`[calendar-booking] op=reconcile-failed bookingId=${booking.bookingId} err="${err.message}"`);
313
+ if (!isMissingColumnError(err.message)) {
314
+ console.error(`[calendar-reconcile] account=${accountId} d1-read-failed err="${err.message}"`);
315
+ return;
316
+ }
317
+ // The marker says migrated but the column is gone, so the database was
318
+ // replaced under us. Migrate again and retry the read once.
319
+ try {
320
+ await migrateBookingSchema(d1, dbName, accountId);
321
+ markSchemaMigrated(accountId);
322
+ eligible = await d1.query(dbName, ELIGIBLE_BOOKING_SELECT);
323
+ }
324
+ catch (migrateErr) {
325
+ console.error(`[calendar-reconcile] account=${accountId} op=schema-migrate result=failed err="${migrateErr.message}"`);
326
+ return;
327
+ }
328
+ }
329
+ // Task 1806 — a row whose bookingId is not a UUID was not written by our
330
+ // Pages Function, so the sweep does not act on it. Counting it as
331
+ // unreconciled would be this task's exact defect: an audit number naming
332
+ // rows nothing is able to reconcile. Dropped from both populations here,
333
+ // once, and reported under its own name rather than silently. Since Task
334
+ // 1805 the sweep binds its values, so this is a provenance filter, not a
335
+ // SQL-safety guard.
336
+ const malformed = eligible.filter((r) => !UUID_RE.test(String(r.bookingId)));
337
+ if (malformed.length > 0)
338
+ eligible = eligible.filter((r) => UUID_RE.test(String(r.bookingId)));
339
+ // Which rows still need a meeting is a question about the graph, not about
340
+ // a flag in D1. Task 1807 — swept records that a write was attempted; a row
341
+ // whose meeting is later absent must be reclaimed however that flag reads.
342
+ const claimSession = driver.session();
343
+ let pending;
344
+ try {
345
+ const uids = await fetchBookingMeetingUids(claimSession, accountId);
346
+ pending = selectUnreconciled(eligible, new Set(uids));
258
347
  }
259
348
  finally {
260
- await session.close();
349
+ await claimSession.close();
261
350
  }
262
- }
263
- // Standing audit covers the no-event failure no per-request line can reveal.
264
- const auditSession = driver.session();
265
- try {
266
- let acceptedIds = [];
351
+ const notifyDeps = pending.length > 0 ? await resolveEmailNotify(accountId) : {};
352
+ for (const row of pending) {
353
+ // bookingId was UUID-checked above, before this row reached `pending`.
354
+ const bookingId = String(row.bookingId);
355
+ const booking = {
356
+ bookingId,
357
+ slotStart: String(row.slotStart),
358
+ slotEnd: String(row.slotEnd),
359
+ name: String(row.name),
360
+ email: String(row.email),
361
+ note: row.note == null ? undefined : String(row.note),
362
+ };
363
+ const started = Date.now();
364
+ const attempt = Number(row.attempts ?? 0) + 1;
365
+ console.error(`[calendar-booking] op=reconcile-claim bookingId=${booking.bookingId} accountId=${accountId} attempt=${attempt}`);
366
+ const session = driver.session();
367
+ try {
368
+ // Count the attempt before the write, so a crash mid-write still leaves
369
+ // durable evidence that this row was tried. Nothing gates on the count —
370
+ // a row is retried until it succeeds — but a row on attempt 47 is now
371
+ // distinguishable from one never attempted, which is the signal Task
372
+ // 1351 escalates on. Its own try/catch: this statement is observability,
373
+ // and failing to record a number must never stop the meeting write it
374
+ // describes. The op=reconcile-claim line above already carries the count.
375
+ try {
376
+ await d1.query(dbName, "UPDATE bookings SET attempts = attempts + 1 WHERE bookingId=?1", [
377
+ booking.bookingId,
378
+ ]);
379
+ }
380
+ catch (err) {
381
+ console.error(`[calendar-booking] op=attempt-count-failed bookingId=${booking.bookingId} attempt=${attempt} err="${err.message}"`);
382
+ }
383
+ const result = await reconcileOne(session, accountId, booking);
384
+ console.error(`[calendar-booking] op=meeting-write bookingId=${booking.bookingId} meetingId=${result.meetingId} created=${result.created}`);
385
+ // Notify only when the meeting was freshly created. An idempotent re-run
386
+ // (created=false, e.g. a prior pass wrote the meeting but the sweep
387
+ // failed) must not re-email the admin.
388
+ if (result.created) {
389
+ const notes = await notifyBooking(notifyDeps, booking);
390
+ for (const n of notes) {
391
+ console.error(`[calendar-booking] op=notify bookingId=${booking.bookingId} channel=${n.channel} dispatched=${n.dispatched}`);
392
+ }
393
+ }
394
+ // swept keeps its existing meaning: a write was attempted and succeeded.
395
+ // It no longer decides whether the row is reclaimed, so failing to set
396
+ // it costs nothing but accuracy — and reporting it as reconcile-failed
397
+ // would be untrue, because the meeting exists by this point.
398
+ try {
399
+ await d1.query(dbName, "UPDATE bookings SET swept=1 WHERE bookingId=?1", [
400
+ booking.bookingId,
401
+ ]);
402
+ }
403
+ catch (err) {
404
+ console.error(`[calendar-booking] op=sweep-mark-failed bookingId=${booking.bookingId} err="${err.message}"`);
405
+ }
406
+ console.error(`[calendar-booking] op=booked bookingId=${booking.bookingId} attempt=${attempt} ms=${Date.now() - started}`);
407
+ }
408
+ catch (err) {
409
+ console.error(`[calendar-booking] op=reconcile-failed bookingId=${booking.bookingId} attempt=${attempt} err="${err.message}"`);
410
+ }
411
+ finally {
412
+ await session.close();
413
+ }
414
+ }
415
+ // Standing audit — covers the no-event failure no per-request line can reveal.
416
+ // Task 1806 — it counts the same eligible population the sweep just acted on,
417
+ // so the number can never again name rows nothing is able to reconcile, and
418
+ // the second D1 read this used to cost is gone. The meeting set is re-read so
419
+ // the line reports the state after this run's sweep.
420
+ const auditSession = driver.session();
267
421
  try {
268
- const accepted = d1Query(dbName, "SELECT bookingId FROM bookings WHERE status='accepted'", wranglerEnv);
269
- acceptedIds = accepted.map((r) => String(r.bookingId));
422
+ const eligibleIds = eligible.map((r) => String(r.bookingId));
423
+ const uids = await fetchBookingMeetingUids(auditSession, accountId);
424
+ const counts = auditCounts(eligibleIds, uids);
425
+ console.error(`[calendar-reconcile] account=${accountId} eligibleExamined=${counts.eligibleExamined} pendingUnreconciled=${counts.pendingUnreconciled} orphanMeetings=${counts.orphanMeetings} malformedIds=${malformed.length}`);
270
426
  }
271
- catch {
272
- /* audit degrades to graph-only when D1 is unreachable */
427
+ finally {
428
+ await auditSession.close();
273
429
  }
274
- const counts = await auditCounts(auditSession, accountId, acceptedIds);
275
- console.error(`[calendar-reconcile] account=${accountId} pendingUnreconciled=${counts.pendingUnreconciled} orphanMeetings=${counts.orphanMeetings}`);
276
- }
277
- finally {
278
- await auditSession.close();
279
- }
280
- // Task 1654 standing connector-drift audit. Independent of any booking: it
281
- // reconciles the PUBLISHED open slots against the current connector-busy set,
282
- // so a booking page offering a slot the connector shows busy is surfaced before
283
- // a customer hits it. connector-busy read from the graph, published slots from D1.
284
- const driftSession = driver.session();
285
- try {
430
+ // Task 1654 standing connector-drift audit. Independent of any booking: it
431
+ // reconciles the PUBLISHED open slots against the current connector-busy set,
432
+ // so a booking page offering a slot the connector shows busy is surfaced before
433
+ // a customer hits it. connector-busy read from the graph, published slots from D1.
434
+ // Task 1811 — these four are read by the drift audit and consumed by the
435
+ // blocking step below, which runs in its own try so its line is emitted
436
+ // whatever the drift step did.
286
437
  let publishedSlots = [];
438
+ // The published set could not be read at all: D1 unreachable (throws), or a
439
+ // snapshot row exists whose payload is not a usable slot list. Both are
440
+ // distinct from a D1 that answered and holds no snapshot row, which leaves
441
+ // publishedSlots empty without setting this — the booking site has genuinely
442
+ // never published.
443
+ let snapshotUnreadable = false;
444
+ let busy = [];
445
+ let drift = null;
446
+ const driftSession = driver.session();
287
447
  try {
288
- const rows = d1Query(dbName, "SELECT payload FROM availability_snapshot WHERE id=1", wranglerEnv);
289
- const payload = rows[0]?.payload;
290
- if (typeof payload === "string") {
291
- const parsed = JSON.parse(payload);
292
- if (Array.isArray(parsed.slots))
293
- publishedSlots = parsed.slots;
448
+ try {
449
+ const rows = await d1.query(dbName, "SELECT payload FROM availability_snapshot WHERE id=1");
450
+ if (rows.length > 0) {
451
+ const payload = rows[0]?.payload;
452
+ const parsed = typeof payload === "string" ? JSON.parse(payload) : null;
453
+ if (parsed && Array.isArray(parsed.slots))
454
+ publishedSlots = parsed.slots;
455
+ else
456
+ snapshotUnreadable = true;
457
+ }
458
+ }
459
+ catch {
460
+ snapshotUnreadable = true;
294
461
  }
462
+ const busyRes = await driftSession.run(`MATCH (b:ConnectorBusy {accountId: $accountId})
463
+ RETURN toString(b.startsAt) AS start, toString(b.endsAt) AS end`, { accountId });
464
+ busy = busyRes.records.map((r) => ({ start: r.get("start"), end: r.get("end") }));
465
+ // The busy read runs whatever the snapshot read did, so busy=<n> is the
466
+ // standing per-account :ConnectorBusy census on every tick.
467
+ drift = classifyPublishedDrift(publishedSlots, busy);
468
+ const outcome = snapshotUnreadable ? "snapshot-unreadable" : drift.outcome;
469
+ // When the published set was never read, its size and the conflict count are
470
+ // not measurements — reporting them as 0 would be the same unqualified
471
+ // number this audit exists to eliminate.
472
+ const published = snapshotUnreadable ? "unknown" : String(drift.published);
473
+ const conflicts = snapshotUnreadable ? "unknown" : String(drift.conflicts);
474
+ console.error(`[calendar-availability] op=cal-drift account=${accountId} outcome=${outcome} published=${published} busy=${drift.busy} conflicts=${conflicts}`);
295
475
  }
296
- catch {
297
- /* no snapshot yet (booking site never published) — audit degrades to 0 */
476
+ catch (err) {
477
+ const outcome = "error";
478
+ console.error(`[calendar-availability] op=cal-drift account=${accountId} outcome=${outcome} reason=${JSON.stringify(err.message)}`);
298
479
  }
299
- const busyRes = await driftSession.run(`MATCH (b:ConnectorBusy {accountId: $accountId})
300
- RETURN toString(b.startsAt) AS start, toString(b.endsAt) AS end`, { accountId });
301
- const busy = busyRes.records.map((r) => ({ start: r.get("start"), end: r.get("end") }));
302
- const conflicts = countPublishedConflicts(publishedSlots, busy);
303
- console.error(`[calendar-availability] op=cal-drift account=${accountId} conflicts=${conflicts}`);
304
- }
305
- catch (err) {
306
- console.error(`[calendar-availability] op=cal-drift account=${accountId} result=error reason=${JSON.stringify(err.message)}`);
480
+ finally {
481
+ await driftSession.close();
482
+ }
483
+ // Task 1811 — act on the conflicts the audit above just measured. The
484
+ // published set, the busy set and the D1 client are all already in hand,
485
+ // so this adds one read and writes only when the set changes.
486
+ //
487
+ // Which writes are safe is decided by planBlockWrites, not here: the rule
488
+ // that an absent or unreadable busy set must never delete is the highest
489
+ // consequence line in this feature, and a bare `if` at this call site is
490
+ // unreachable by any test, since reconcileAccount resolves its config dir
491
+ // at module load and cannot be imported.
492
+ let blockOutcome = "error";
493
+ let held = "unknown";
494
+ let added = "unknown";
495
+ let removed = "unknown";
496
+ let blockReason = null;
497
+ try {
498
+ const current = (await d1.query(dbName, BLOCK_ROWS_SELECT)).map((r) => ({
499
+ bookingId: String(r.bookingId),
500
+ slotStart: String(r.slotStart),
501
+ }));
502
+ held = current.length;
503
+ const plan = planBlockWrites({
504
+ publishedReadable: !snapshotUnreadable,
505
+ // `drift` is null only when the drift try threw, which means the graph
506
+ // read did not complete and `busy` is absence of a measurement rather
507
+ // than a measured empty set.
508
+ busyReadable: drift !== null,
509
+ published: publishedSlots,
510
+ busy,
511
+ // Built from the eligible rows AND the malformed ones: the edge
512
+ // subtracts every accepted row regardless of whether its bookingId is
513
+ // a UUID, so a non-UUID real booking still holds its slot and must not
514
+ // be blocked a second time.
515
+ taken: new Set([...eligible, ...malformed].map((r) => String(r.slotStart))),
516
+ current,
517
+ nowMs: Date.now(),
518
+ });
519
+ blockOutcome = plan.outcome;
520
+ if (plan.outcome === "verified") {
521
+ // Counted as the writes land, not from the plan, so a failure part way
522
+ // through still reports what actually happened rather than the intent.
523
+ added = 0;
524
+ removed = 0;
525
+ const nowIso = new Date().toISOString();
526
+ for (const id of plan.diff.toDelete) {
527
+ await d1.query(dbName, BLOCK_ROW_DELETE, [id]);
528
+ removed++;
529
+ held--;
530
+ }
531
+ for (const s of plan.diff.toInsert) {
532
+ await d1.query(dbName, BLOCK_ROW_INSERT, blockInsertParams(s, randomUUID(), nowIso));
533
+ added++;
534
+ held++;
535
+ }
536
+ }
537
+ }
538
+ catch (err) {
539
+ blockOutcome = "error";
540
+ blockReason = err.message;
541
+ }
542
+ // ONE emit point, always reached. An earlier revision logged the error case
543
+ // separately and suppressed this line for it, which meant a tick whose
544
+ // drift step threw reported no cal-block line at all — the standing check
545
+ // silently absent, which is this task family's own defect class.
546
+ //
547
+ // On any outcome but `verified` no desired set was computed, so added and
548
+ // removed are not measurements — reporting them as 0 would be the same
549
+ // unqualified number Task 1808 removed. `held` is a real read whenever the
550
+ // D1 read succeeded, so `no-busy-data blocked=3` says "no input, three
551
+ // stale blocks retained" and `verified blocked=0` says "calendar clear".
552
+ console.error(`[calendar-availability] op=cal-block account=${accountId} outcome=${blockOutcome} ` +
553
+ `blocked=${held} added=${added} removed=${removed}` +
554
+ `${blockReason ? ` reason=${JSON.stringify(blockReason)}` : ""}`);
307
555
  }
308
556
  finally {
309
- await driftSession.close();
557
+ // Task 1805 — the transport's own footprint, which no line reported
558
+ // before. Post-condition, not intent: `statements` is what was actually
559
+ // issued (the name lookup is not a statement and is not counted), `ms` is
560
+ // total elapsed in the transport including that lookup. A tick that failed
561
+ // its first read still reports, showing statements=0.
562
+ console.error(`[calendar-reconcile] op=d1 account=${accountId} statements=${d1.statements} ms=${d1.elapsedMs}`);
310
563
  }
311
564
  }
312
565
  async function main() {
@@ -332,9 +585,17 @@ async function main() {
332
585
  return;
333
586
  }
334
587
  const houseAccountId = houseCred.CLOUDFLARE_ACCOUNT_ID;
588
+ // Task 1805 — the D1 endpoint is addressed per Cloudflare account, so the id
589
+ // is required rather than inferred. cf-token.sh already refuses to resolve any
590
+ // scope without it, so no install that reconciles today can reach here
591
+ // lacking one; an absent id is reported rather than guessed.
592
+ if (!houseAccountId) {
593
+ console.error("[calendar-reconcile] skip reason=no-house-cloudflare-account-id");
594
+ return;
595
+ }
335
596
  // Task 1670 — the house credential is a minter; mint (then reuse) a D1-only
336
- // scoped token from it via cf-token.sh and run wrangler on THAT, never the
337
- // minter directly (a minter returns 10000 on wrangler d1).
597
+ // scoped token from it via cf-token.sh and authenticate on THAT, never the
598
+ // minter directly (a minter returns 10000 against the D1 surface).
338
599
  const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
339
600
  let scopedToken;
340
601
  try {
@@ -345,14 +606,23 @@ async function main() {
345
606
  return;
346
607
  }
347
608
  driver = connectNeo4j();
609
+ // Task 1809 — read once, mutate per account, write once. A crash mid-run
610
+ // costs redundant lines on the next run, never suppressed ones.
611
+ const provisionState = readProvisionState(PROVISION_STATE_FILE);
348
612
  for (const accountId of accountIds) {
349
613
  try {
350
- await reconcileAccount(driver, accountId, join(accountsDir, accountId), scopedToken, houseAccountId);
614
+ await reconcileAccount(driver, accountId, join(accountsDir, accountId), () => createD1Client({ accountId: houseAccountId, token: scopedToken }), provisionState);
351
615
  }
352
616
  catch (err) {
353
617
  console.error(`[calendar-reconcile] account=${accountId} run-failed err="${err.message}"`);
354
618
  }
355
619
  }
620
+ // Drop accounts that no longer exist so the file cannot grow without bound.
621
+ for (const known of Object.keys(provisionState)) {
622
+ if (!accountIds.includes(known))
623
+ delete provisionState[known];
624
+ }
625
+ writeProvisionState(PROVISION_STATE_FILE, provisionState);
356
626
  }
357
627
  finally {
358
628
  if (driver)