@rubytech/create-maxy-code 0.1.474 → 0.1.476

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 (138) 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 +18 -9
  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/docs/references/google-guide.md +15 -6
  10. package/payload/platform/plugins/google/.claude-plugin/plugin.json +1 -1
  11. package/payload/platform/plugins/google/PLUGIN.md +60 -12
  12. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.d.ts +2 -0
  13. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.d.ts.map +1 -0
  14. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.js +229 -0
  15. package/payload/platform/plugins/google/mcp/dist/__tests__/account-audit.test.js.map +1 -0
  16. package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js +9 -9
  17. package/payload/platform/plugins/google/mcp/dist/__tests__/account-register.test.js.map +1 -1
  18. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.d.ts +2 -0
  19. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.d.ts.map +1 -0
  20. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.js +17 -0
  21. package/payload/platform/plugins/google/mcp/dist/__tests__/consent-url-redirect.test.js.map +1 -0
  22. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.d.ts +2 -0
  23. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.d.ts.map +1 -0
  24. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.js +146 -0
  25. package/payload/platform/plugins/google/mcp/dist/__tests__/loopback-listener.test.js.map +1 -0
  26. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.d.ts +2 -0
  27. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.d.ts.map +1 -0
  28. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.js +19 -0
  29. package/payload/platform/plugins/google/mcp/dist/__tests__/pending-redirect.test.js.map +1 -0
  30. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.d.ts +2 -0
  31. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.d.ts.map +1 -0
  32. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.js +41 -0
  33. package/payload/platform/plugins/google/mcp/dist/__tests__/register-redirect-chain.test.js.map +1 -0
  34. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.d.ts +44 -0
  35. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.d.ts.map +1 -0
  36. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.js +112 -0
  37. package/payload/platform/plugins/google/mcp/dist/auth/account-audit.js.map +1 -0
  38. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.d.ts +17 -0
  39. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.d.ts.map +1 -0
  40. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.js +143 -0
  41. package/payload/platform/plugins/google/mcp/dist/auth/loopback-listener.js.map +1 -0
  42. package/payload/platform/plugins/google/mcp/dist/auth/pending-store.d.ts +9 -0
  43. package/payload/platform/plugins/google/mcp/dist/auth/pending-store.d.ts.map +1 -1
  44. package/payload/platform/plugins/google/mcp/dist/auth/pending-store.js.map +1 -1
  45. package/payload/platform/plugins/google/mcp/dist/index.js +73 -5
  46. package/payload/platform/plugins/google/mcp/dist/index.js.map +1 -1
  47. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.d.ts +2 -0
  48. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.d.ts.map +1 -0
  49. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.js +37 -0
  50. package/payload/platform/plugins/google/mcp/dist/scripts/account-audit.js.map +1 -0
  51. package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts +20 -4
  52. package/payload/platform/plugins/google/mcp/dist/tools/account-register.d.ts.map +1 -1
  53. package/payload/platform/plugins/google/mcp/dist/tools/account-register.js +19 -9
  54. package/payload/platform/plugins/google/mcp/dist/tools/account-register.js.map +1 -1
  55. package/payload/platform/plugins/google/references/auth.md +93 -14
  56. package/payload/platform/plugins/scheduling/PLUGIN.md +2 -2
  57. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +158 -23
  58. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
  59. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.d.ts +2 -0
  60. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.d.ts.map +1 -0
  61. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.js +200 -0
  62. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.js.map +1 -0
  63. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.d.ts +2 -0
  64. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.d.ts.map +1 -0
  65. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.js +309 -0
  66. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.js.map +1 -0
  67. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js +219 -19
  68. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js.map +1 -1
  69. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +94 -11
  70. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  71. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +106 -22
  72. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  73. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.d.ts +109 -0
  74. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.d.ts.map +1 -0
  75. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.js +213 -0
  76. package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.js.map +1 -0
  77. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts +80 -8
  78. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts.map +1 -1
  79. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js +202 -8
  80. package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js.map +1 -1
  81. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.d.ts +6 -4
  82. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.d.ts.map +1 -1
  83. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +334 -142
  84. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  85. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +21 -3
  86. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
  87. package/payload/platform/services/claude-session-manager/dist/activity-rows.js +6 -2
  88. package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
  89. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +43 -5
  91. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  92. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +9 -0
  93. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  94. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +78 -0
  95. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  96. package/payload/server/{chunk-Y6V45R6M.js → chunk-LBCMFD4O.js} +173 -29
  97. package/payload/server/maxy-edge.js +1 -1
  98. package/payload/server/public/activity.html +6 -6
  99. package/payload/server/public/assets/{AdminLoginScreens-B44KbEuC.js → AdminLoginScreens-DximwPlS.js} +1 -1
  100. package/payload/server/public/assets/{AdminShell-BjxV2qkb.js → AdminShell-BehbH-Oa.js} +1 -1
  101. package/payload/server/public/assets/{Checkbox-DSeZNGNA.js → Checkbox-DM-eHqOS.js} +1 -1
  102. package/payload/server/public/assets/activity-hDwUeQd6.js +1 -0
  103. package/payload/server/public/assets/{admin-B4LpTCur.js → admin-CAkDnGdk.js} +1 -1
  104. package/payload/server/public/assets/{browser-CEFvaWwI.js → browser-DXl8hOoi.js} +1 -1
  105. package/payload/server/public/assets/{calendar-B3zo6V34.js → calendar-DP6hn4-6.js} +1 -1
  106. package/payload/server/public/assets/chat-DhOfVGYM.js +1 -0
  107. package/payload/server/public/assets/chevron-left-DYNBoLii.js +1 -0
  108. package/payload/server/public/assets/data-bq8fTo_s.js +1 -0
  109. package/payload/server/public/assets/{graph-RaOrnSf2.js → graph-CHcYoEJ5.js} +1 -1
  110. package/payload/server/public/assets/{graph-labels-GlTEIYJj.js → graph-labels-CZykslZM.js} +1 -1
  111. package/payload/server/public/assets/{maximize-2-CHDa8p5h.js → maximize-2-31AZEcMS.js} +1 -1
  112. package/payload/server/public/assets/{operator-DtqHY4jV.js → operator-BGGpdsO_.js} +1 -1
  113. package/payload/server/public/assets/{page-CkhFj8GQ.js → page-BloC6ygA.js} +1 -1
  114. package/payload/server/public/assets/{page-DxnWgPtd.js → page-DOeiiqbR.js} +1 -1
  115. package/payload/server/public/assets/{public-D5TUfQ3I.js → public-CGgO6IZv.js} +1 -1
  116. package/payload/server/public/assets/{rotate-ccw-BJjVAQQY.js → rotate-ccw-BlAi6iFg.js} +1 -1
  117. package/payload/server/public/assets/{tasks-CM9pYYG1.js → tasks-C1giFRRL.js} +1 -1
  118. package/payload/server/public/assets/{time-entry-format-C5RYEYjM.js → time-entry-format-CiUUQdq7.js} +1 -1
  119. package/payload/server/public/assets/{triangle-alert-DLfIsuo_.js → triangle-alert-CVqKuLoa.js} +1 -1
  120. package/payload/server/public/assets/{useCopyFeedback-7yKeiAzV.js → useCopyFeedback-ksyUe_g1.js} +1 -1
  121. package/payload/server/public/assets/{useSelectionMode-CWlh6AEq.js → useSelectionMode-BPrBkdTd.js} +1 -1
  122. package/payload/server/public/assets/{useSubAccountSwitcher-B2n9QPVI.css → useSubAccountSwitcher-K4kYeN6i.css} +1 -1
  123. package/payload/server/public/assets/{useVoiceRecorder-j3ZArZ2H.js → useVoiceRecorder-CTXP8oTW.js} +1 -1
  124. package/payload/server/public/browser.html +5 -5
  125. package/payload/server/public/calendar.html +6 -6
  126. package/payload/server/public/chat.html +12 -12
  127. package/payload/server/public/data.html +11 -11
  128. package/payload/server/public/graph.html +11 -11
  129. package/payload/server/public/index.html +14 -14
  130. package/payload/server/public/operator.html +14 -14
  131. package/payload/server/public/public.html +12 -12
  132. package/payload/server/public/tasks.html +5 -5
  133. package/payload/server/server.js +547 -423
  134. package/payload/server/public/assets/activity-CdipMTDp.js +0 -1
  135. package/payload/server/public/assets/chat-5tJdo9i_.js +0 -1
  136. package/payload/server/public/assets/chevron-left-BJllrPBj.js +0 -1
  137. package/payload/server/public/assets/data-Du79EgR-.js +0 -1
  138. /package/payload/server/public/assets/{useSubAccountSwitcher-CBlKgwms.js → useSubAccountSwitcher-DzgzTRRn.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,13 @@
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";
26
27
  import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from "node:fs";
27
28
  import { resolve, join } from "node:path";
28
29
  import { homedir } from "node:os";
29
- import { reconcileOne, auditCounts, ensureConnectorSyncEvent, countPublishedConflicts } from "../lib/booking-reconcile.js";
30
+ import { reconcileOne, auditCounts, ensureConnectorSyncEvent, classifyPublishedDrift, fetchBookingMeetingUids, selectUnreconciled, ELIGIBLE_BOOKING_SELECT, BOOKING_SCHEMA_MIGRATION, isMissingColumnError, isDuplicateColumnError, } from "../lib/booking-reconcile.js";
31
+ import { detectCalendarConnectors, resolveDispatchDestination, shouldEmitProvisionLine, provisionSignature, emitLabel, readProvisionState, writeProvisionState, } from "../lib/connector-sync-provision.js";
30
32
  import { notifyBooking } from "../lib/booking-notify.js";
31
- import { buildD1Command } from "../lib/d1-command.js";
33
+ import { createD1Client } from "../lib/d1-command.js";
32
34
  import { resolveHouseScopedToken } from "../lib/house-scoped-token.js";
33
35
  function resolveConfigDir() {
34
36
  const platformRoot = process.env.PLATFORM_ROOT;
@@ -44,6 +46,39 @@ function resolveConfigDir() {
44
46
  }
45
47
  const CONFIG_DIR = resolve(homedir(), resolveConfigDir());
46
48
  const LOCK_FILE = resolve(CONFIG_DIR, "reconcile-bookings.lock");
49
+ // Task 1809 — the per-account connector-sync provisioning outcome, persisted so
50
+ // a permanent condition is reported on change plus this heartbeat rather than
51
+ // once per tick. Six hours over a 120 s tick turns 2,880 lines a day per
52
+ // account into four.
53
+ const PROVISION_STATE_FILE = resolve(CONFIG_DIR, "cal-sync-provision-state.json");
54
+ const PROVISION_HEARTBEAT_MS = 6 * 60 * 60 * 1000;
55
+ // Task 1806/1807 — which accounts have completed the bookings schema migration.
56
+ // Written only after all three statements succeed, so a partial migration is
57
+ // retried on the next tick. Gating on the read failing instead would not retry:
58
+ // once the ALTERs land the read succeeds, the missing-column trigger never fires
59
+ // again, and a backfill that failed leaves every block row eligible — which the
60
+ // sweep would then reconcile into a meeting.
61
+ const SCHEMA_STATE_FILE = resolve(CONFIG_DIR, "booking-schema-migrated.json");
62
+ function hasCompletedSchemaMigration(accountId) {
63
+ try {
64
+ const state = JSON.parse(readFileSync(SCHEMA_STATE_FILE, "utf-8"));
65
+ return typeof state[accountId] === "string";
66
+ }
67
+ catch {
68
+ return false; // absent or unreadable — migrate, which is safe to re-run
69
+ }
70
+ }
71
+ function markSchemaMigrated(accountId) {
72
+ let state = {};
73
+ try {
74
+ state = JSON.parse(readFileSync(SCHEMA_STATE_FILE, "utf-8"));
75
+ }
76
+ catch {
77
+ /* first account, or unreadable — start a fresh map */
78
+ }
79
+ state[accountId] = new Date().toISOString();
80
+ writeFileSync(SCHEMA_STATE_FILE, JSON.stringify(state, null, 2));
81
+ }
47
82
  function acquireLock() {
48
83
  mkdirSync(CONFIG_DIR, { recursive: true });
49
84
  if (existsSync(LOCK_FILE)) {
@@ -91,7 +126,13 @@ function connectNeo4j() {
91
126
  return neo4j.driver(uri, neo4j.auth.basic(user, password));
92
127
  }
93
128
  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. */
129
+ /** Read `KEY=value` pairs from a secrets env file without polluting process.env.
130
+ *
131
+ * Values are trimmed, matching the sibling reader in house-scoped-token.ts. The
132
+ * split is on "\n", so a CRLF file leaves a trailing "\r" on every value; since
133
+ * Task 1805 these values reach Cloudflare as a URL path segment and a Bearer
134
+ * header rather than as a child process env, where a stray "\r" fails as an
135
+ * opaque invalid-header error instead of anything nameable. */
95
136
  function readEnvFile(path) {
96
137
  const out = {};
97
138
  if (!existsSync(path))
@@ -99,41 +140,15 @@ function readEnvFile(path) {
99
140
  for (const line of readFileSync(path, "utf-8").split("\n")) {
100
141
  const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
101
142
  if (m)
102
- out[m[1]] = m[2];
143
+ out[m[1]] = m[2].trim();
103
144
  }
104
145
  return out;
105
146
  }
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
- }
147
+ // Task 1809 `readAdminPhone` lived here. It read a top-level `account.json`
148
+ // `adminPhones` key that exists on no account (the authoritative key is
149
+ // `whatsapp.adminPhones`), and gated provisioning on the result, so every tick
150
+ // on every brand logged `skipped` and nothing was ever provisioned. Both the
151
+ // field path and the gate now live in connector-sync-provision.ts.
137
152
  /** Resolve an email sender from the sibling email plugin, defensively. */
138
153
  async function resolveEmailNotify(accountId) {
139
154
  const platformRoot = process.env.PLATFORM_ROOT;
@@ -163,7 +178,25 @@ async function resolveEmailNotify(accountId) {
163
178
  return {};
164
179
  }
165
180
  }
166
- async function reconcileAccount(driver, accountId, accountDir, houseToken, houseAccountId) {
181
+ /**
182
+ * Bring an already-provisioned bookings table up to the schema the sweep needs.
183
+ * Each statement tolerates "duplicate column name" on its own, so a table that
184
+ * already has one of the two columns completes rather than aborting.
185
+ */
186
+ async function migrateBookingSchema(d1, dbName, accountId) {
187
+ for (const sql of BOOKING_SCHEMA_MIGRATION) {
188
+ try {
189
+ await d1.query(dbName, sql);
190
+ }
191
+ catch (err) {
192
+ if (isDuplicateColumnError(err.message))
193
+ continue;
194
+ throw err;
195
+ }
196
+ }
197
+ console.error(`[calendar-reconcile] account=${accountId} op=schema-migrate result=applied`);
198
+ }
199
+ async function reconcileAccount(driver, accountId, accountDir, makeClient, provisionState) {
167
200
  const availPath = join(accountDir, "calendar-availability.json");
168
201
  if (!existsSync(availPath))
169
202
  return; // no booking site for this account
@@ -182,131 +215,273 @@ async function reconcileAccount(driver, accountId, accountDir, houseToken, house
182
215
  }
183
216
  // Task 1654 — provision the recurring connector-sync turn for this booking-site
184
217
  // 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.
218
+ // read the calendar connector.
219
+ //
220
+ // Task 1809 — the gate is the presence of a CALENDAR CONNECTOR, the sync's own
221
+ // input, not the presence of a WhatsApp number (which is only where the turn
222
+ // reports an exception). The outcome distinguishes the four states an operator
223
+ // can act on differently, and is reported on change plus a heartbeat rather
224
+ // than once per 120 s tick.
225
+ const accountsDir = resolve(accountDir, "..");
186
226
  const syncSession = driver.session();
227
+ let outcome;
228
+ let connectors = [];
229
+ let dispatch = null;
230
+ let detail;
187
231
  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`);
232
+ connectors = detectCalendarConnectors(accountDir);
233
+ if (connectors.length === 0) {
234
+ outcome = "not-applicable";
191
235
  }
192
236
  else {
193
- const outcome = await ensureConnectorSyncEvent(syncSession, accountId, adminPhone);
194
- console.error(`[calendar-availability] op=cal-sync-provision account=${accountId} result=${outcome}`);
237
+ const resolved = resolveDispatchDestination(accountsDir, accountId);
238
+ if (resolved.destination === null) {
239
+ // A sync that fails and cannot report is not a clean sync, so an
240
+ // account with no reachable destination is reported unconfigured
241
+ // rather than armed. Task 1812 adds the destination-free route. The
242
+ // refusal reason travels with it — `unconfigured` at an account whose
243
+ // account.json visibly holds a phone number is otherwise unexplainable.
244
+ outcome = "unconfigured";
245
+ detail = resolved.reason;
246
+ }
247
+ else {
248
+ dispatch = resolved.destination;
249
+ outcome = await ensureConnectorSyncEvent(syncSession, accountId, dispatch);
250
+ }
195
251
  }
196
252
  }
197
253
  catch (err) {
198
- console.error(`[calendar-availability] op=cal-sync-provision account=${accountId} result=error reason=${JSON.stringify(err.message)}`);
254
+ outcome = "error";
255
+ detail = err.message;
199
256
  }
200
257
  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);
258
+ // A rejecting close must not carry a computed outcome out of the function
259
+ // unreported — the emit below is the only place this account's state is
260
+ // recorded. Mirrors the guard in agent-turn-dispatch.ts.
261
+ try {
262
+ await syncSession.close();
263
+ }
264
+ catch {
265
+ /* ignore */
266
+ }
213
267
  }
214
- catch (err) {
215
- console.error(`[calendar-reconcile] account=${accountId} d1-read-failed err="${err.message}"`);
216
- return;
268
+ // Every outcome, `error` included, goes through one emit gate. Exempting
269
+ // errors would log a persistent Neo4j fault at tick rate forever — the same
270
+ // volume profile as the 7,868 lines this task removes — and would leave the
271
+ // state map holding the pre-error outcome, making the eventual recovery
272
+ // invisible because it would read as unchanged.
273
+ const nowMs = Date.now();
274
+ const signature = provisionSignature(outcome, detail);
275
+ const prev = provisionState[accountId];
276
+ if (shouldEmitProvisionLine(prev, signature, nowMs, PROVISION_HEARTBEAT_MS)) {
277
+ console.error(`[calendar-availability] op=cal-sync-provision account=${accountId} result=${outcome} ` +
278
+ `connectors=${connectors.length > 0 ? connectors.join("+") : "none"} ` +
279
+ `destination=${dispatch ? dispatch.channel : "none"} ` +
280
+ `${detail ? `reason=${JSON.stringify(detail)} ` : ""}emit=${emitLabel(prev, signature)}`);
281
+ provisionState[accountId] = { outcome, signature, loggedAtMs: nowMs };
217
282
  }
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;
283
+ // One client per account run: it holds this database's name -> uuid
284
+ // resolution for the whole tick, and carries the counters the op=d1 line
285
+ // reports. Task 1631's house-only credential reaches D1 as the Bearer token
286
+ // now rather than as a child process env.
287
+ const d1 = makeClient();
288
+ try {
289
+ // The kind/attempts columns cannot reach an already-provisioned database
290
+ // through the template, so this account's first run migrates it. Retried
291
+ // until it completes, because a half-migrated table (columns added, block
292
+ // rows unclassified) leaves every block row eligible and the sweep would
293
+ // reconcile each into a meeting.
294
+ if (!hasCompletedSchemaMigration(accountId)) {
295
+ try {
296
+ await migrateBookingSchema(d1, dbName, accountId);
297
+ markSchemaMigrated(accountId);
298
+ }
299
+ catch (err) {
300
+ console.error(`[calendar-reconcile] account=${accountId} op=schema-migrate result=failed err="${err.message}"`);
301
+ return; // never sweep against a table whose block rows may be unclassified
302
+ }
227
303
  }
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();
304
+ // Task 1806 — one read serves both the sweep and the audit, so the two can
305
+ // never disagree about the population. Task 1807 — it is no longer filtered
306
+ // on swept; which rows are claimed is decided against the graph below.
307
+ let eligible;
239
308
  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}`);
309
+ eligible = await d1.query(dbName, ELIGIBLE_BOOKING_SELECT);
255
310
  }
256
311
  catch (err) {
257
- console.error(`[calendar-booking] op=reconcile-failed bookingId=${booking.bookingId} err="${err.message}"`);
312
+ if (!isMissingColumnError(err.message)) {
313
+ console.error(`[calendar-reconcile] account=${accountId} d1-read-failed err="${err.message}"`);
314
+ return;
315
+ }
316
+ // The marker says migrated but the column is gone, so the database was
317
+ // replaced under us. Migrate again and retry the read once.
318
+ try {
319
+ await migrateBookingSchema(d1, dbName, accountId);
320
+ markSchemaMigrated(accountId);
321
+ eligible = await d1.query(dbName, ELIGIBLE_BOOKING_SELECT);
322
+ }
323
+ catch (migrateErr) {
324
+ console.error(`[calendar-reconcile] account=${accountId} op=schema-migrate result=failed err="${migrateErr.message}"`);
325
+ return;
326
+ }
327
+ }
328
+ // Task 1806 — a row whose bookingId is not a UUID was not written by our
329
+ // Pages Function, so the sweep does not act on it. Counting it as
330
+ // unreconciled would be this task's exact defect: an audit number naming
331
+ // rows nothing is able to reconcile. Dropped from both populations here,
332
+ // once, and reported under its own name rather than silently. Since Task
333
+ // 1805 the sweep binds its values, so this is a provenance filter, not a
334
+ // SQL-safety guard.
335
+ const malformed = eligible.filter((r) => !UUID_RE.test(String(r.bookingId)));
336
+ if (malformed.length > 0)
337
+ eligible = eligible.filter((r) => UUID_RE.test(String(r.bookingId)));
338
+ // Which rows still need a meeting is a question about the graph, not about
339
+ // a flag in D1. Task 1807 — swept records that a write was attempted; a row
340
+ // whose meeting is later absent must be reclaimed however that flag reads.
341
+ const claimSession = driver.session();
342
+ let pending;
343
+ try {
344
+ const uids = await fetchBookingMeetingUids(claimSession, accountId);
345
+ pending = selectUnreconciled(eligible, new Set(uids));
258
346
  }
259
347
  finally {
260
- await session.close();
348
+ await claimSession.close();
261
349
  }
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 = [];
350
+ const notifyDeps = pending.length > 0 ? await resolveEmailNotify(accountId) : {};
351
+ for (const row of pending) {
352
+ // bookingId was UUID-checked above, before this row reached `pending`.
353
+ const bookingId = String(row.bookingId);
354
+ const booking = {
355
+ bookingId,
356
+ slotStart: String(row.slotStart),
357
+ slotEnd: String(row.slotEnd),
358
+ name: String(row.name),
359
+ email: String(row.email),
360
+ note: row.note == null ? undefined : String(row.note),
361
+ };
362
+ const started = Date.now();
363
+ const attempt = Number(row.attempts ?? 0) + 1;
364
+ console.error(`[calendar-booking] op=reconcile-claim bookingId=${booking.bookingId} accountId=${accountId} attempt=${attempt}`);
365
+ const session = driver.session();
366
+ try {
367
+ // Count the attempt before the write, so a crash mid-write still leaves
368
+ // durable evidence that this row was tried. Nothing gates on the count —
369
+ // a row is retried until it succeeds — but a row on attempt 47 is now
370
+ // distinguishable from one never attempted, which is the signal Task
371
+ // 1351 escalates on. Its own try/catch: this statement is observability,
372
+ // and failing to record a number must never stop the meeting write it
373
+ // describes. The op=reconcile-claim line above already carries the count.
374
+ try {
375
+ await d1.query(dbName, "UPDATE bookings SET attempts = attempts + 1 WHERE bookingId=?1", [
376
+ booking.bookingId,
377
+ ]);
378
+ }
379
+ catch (err) {
380
+ console.error(`[calendar-booking] op=attempt-count-failed bookingId=${booking.bookingId} attempt=${attempt} err="${err.message}"`);
381
+ }
382
+ const result = await reconcileOne(session, accountId, booking);
383
+ console.error(`[calendar-booking] op=meeting-write bookingId=${booking.bookingId} meetingId=${result.meetingId} created=${result.created}`);
384
+ // Notify only when the meeting was freshly created. An idempotent re-run
385
+ // (created=false, e.g. a prior pass wrote the meeting but the sweep
386
+ // failed) must not re-email the admin.
387
+ if (result.created) {
388
+ const notes = await notifyBooking(notifyDeps, booking);
389
+ for (const n of notes) {
390
+ console.error(`[calendar-booking] op=notify bookingId=${booking.bookingId} channel=${n.channel} dispatched=${n.dispatched}`);
391
+ }
392
+ }
393
+ // swept keeps its existing meaning: a write was attempted and succeeded.
394
+ // It no longer decides whether the row is reclaimed, so failing to set
395
+ // it costs nothing but accuracy — and reporting it as reconcile-failed
396
+ // would be untrue, because the meeting exists by this point.
397
+ try {
398
+ await d1.query(dbName, "UPDATE bookings SET swept=1 WHERE bookingId=?1", [
399
+ booking.bookingId,
400
+ ]);
401
+ }
402
+ catch (err) {
403
+ console.error(`[calendar-booking] op=sweep-mark-failed bookingId=${booking.bookingId} err="${err.message}"`);
404
+ }
405
+ console.error(`[calendar-booking] op=booked bookingId=${booking.bookingId} attempt=${attempt} ms=${Date.now() - started}`);
406
+ }
407
+ catch (err) {
408
+ console.error(`[calendar-booking] op=reconcile-failed bookingId=${booking.bookingId} attempt=${attempt} err="${err.message}"`);
409
+ }
410
+ finally {
411
+ await session.close();
412
+ }
413
+ }
414
+ // Standing audit — covers the no-event failure no per-request line can reveal.
415
+ // Task 1806 — it counts the same eligible population the sweep just acted on,
416
+ // so the number can never again name rows nothing is able to reconcile, and
417
+ // the second D1 read this used to cost is gone. The meeting set is re-read so
418
+ // the line reports the state after this run's sweep.
419
+ const auditSession = driver.session();
267
420
  try {
268
- const accepted = d1Query(dbName, "SELECT bookingId FROM bookings WHERE status='accepted'", wranglerEnv);
269
- acceptedIds = accepted.map((r) => String(r.bookingId));
421
+ const eligibleIds = eligible.map((r) => String(r.bookingId));
422
+ const uids = await fetchBookingMeetingUids(auditSession, accountId);
423
+ const counts = auditCounts(eligibleIds, uids);
424
+ console.error(`[calendar-reconcile] account=${accountId} eligibleExamined=${counts.eligibleExamined} pendingUnreconciled=${counts.pendingUnreconciled} orphanMeetings=${counts.orphanMeetings} malformedIds=${malformed.length}`);
270
425
  }
271
- catch {
272
- /* audit degrades to graph-only when D1 is unreachable */
426
+ finally {
427
+ await auditSession.close();
273
428
  }
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 {
286
- let publishedSlots = [];
429
+ // Task 1654 standing connector-drift audit. Independent of any booking: it
430
+ // reconciles the PUBLISHED open slots against the current connector-busy set,
431
+ // so a booking page offering a slot the connector shows busy is surfaced before
432
+ // a customer hits it. connector-busy read from the graph, published slots from D1.
433
+ const driftSession = driver.session();
287
434
  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;
435
+ let publishedSlots = [];
436
+ // The published set could not be read at all: D1 unreachable (throws), or a
437
+ // snapshot row exists whose payload is not a usable slot list. Both are
438
+ // distinct from a D1 that answered and holds no snapshot row, which leaves
439
+ // publishedSlots empty without setting this — the booking site has genuinely
440
+ // never published.
441
+ let snapshotUnreadable = false;
442
+ try {
443
+ const rows = await d1.query(dbName, "SELECT payload FROM availability_snapshot WHERE id=1");
444
+ if (rows.length > 0) {
445
+ const payload = rows[0]?.payload;
446
+ const parsed = typeof payload === "string" ? JSON.parse(payload) : null;
447
+ if (parsed && Array.isArray(parsed.slots))
448
+ publishedSlots = parsed.slots;
449
+ else
450
+ snapshotUnreadable = true;
451
+ }
294
452
  }
453
+ catch {
454
+ snapshotUnreadable = true;
455
+ }
456
+ const busyRes = await driftSession.run(`MATCH (b:ConnectorBusy {accountId: $accountId})
457
+ RETURN toString(b.startsAt) AS start, toString(b.endsAt) AS end`, { accountId });
458
+ const busy = busyRes.records.map((r) => ({ start: r.get("start"), end: r.get("end") }));
459
+ // The busy read runs whatever the snapshot read did, so busy=<n> is the
460
+ // standing per-account :ConnectorBusy census on every tick.
461
+ const drift = classifyPublishedDrift(publishedSlots, busy);
462
+ const outcome = snapshotUnreadable ? "snapshot-unreadable" : drift.outcome;
463
+ // When the published set was never read, its size and the conflict count are
464
+ // not measurements — reporting them as 0 would be the same unqualified
465
+ // number this audit exists to eliminate.
466
+ const published = snapshotUnreadable ? "unknown" : String(drift.published);
467
+ const conflicts = snapshotUnreadable ? "unknown" : String(drift.conflicts);
468
+ console.error(`[calendar-availability] op=cal-drift account=${accountId} outcome=${outcome} published=${published} busy=${drift.busy} conflicts=${conflicts}`);
295
469
  }
296
- catch {
297
- /* no snapshot yet (booking site never published) — audit degrades to 0 */
470
+ catch (err) {
471
+ const outcome = "error";
472
+ console.error(`[calendar-availability] op=cal-drift account=${accountId} outcome=${outcome} reason=${JSON.stringify(err.message)}`);
473
+ }
474
+ finally {
475
+ await driftSession.close();
298
476
  }
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)}`);
307
477
  }
308
478
  finally {
309
- await driftSession.close();
479
+ // Task 1805 — the transport's own footprint, which no line reported
480
+ // before. Post-condition, not intent: `statements` is what was actually
481
+ // issued (the name lookup is not a statement and is not counted), `ms` is
482
+ // total elapsed in the transport including that lookup. A tick that failed
483
+ // its first read still reports, showing statements=0.
484
+ console.error(`[calendar-reconcile] op=d1 account=${accountId} statements=${d1.statements} ms=${d1.elapsedMs}`);
310
485
  }
311
486
  }
312
487
  async function main() {
@@ -332,9 +507,17 @@ async function main() {
332
507
  return;
333
508
  }
334
509
  const houseAccountId = houseCred.CLOUDFLARE_ACCOUNT_ID;
510
+ // Task 1805 — the D1 endpoint is addressed per Cloudflare account, so the id
511
+ // is required rather than inferred. cf-token.sh already refuses to resolve any
512
+ // scope without it, so no install that reconciles today can reach here
513
+ // lacking one; an absent id is reported rather than guessed.
514
+ if (!houseAccountId) {
515
+ console.error("[calendar-reconcile] skip reason=no-house-cloudflare-account-id");
516
+ return;
517
+ }
335
518
  // 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).
519
+ // scoped token from it via cf-token.sh and authenticate on THAT, never the
520
+ // minter directly (a minter returns 10000 against the D1 surface).
338
521
  const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
339
522
  let scopedToken;
340
523
  try {
@@ -345,14 +528,23 @@ async function main() {
345
528
  return;
346
529
  }
347
530
  driver = connectNeo4j();
531
+ // Task 1809 — read once, mutate per account, write once. A crash mid-run
532
+ // costs redundant lines on the next run, never suppressed ones.
533
+ const provisionState = readProvisionState(PROVISION_STATE_FILE);
348
534
  for (const accountId of accountIds) {
349
535
  try {
350
- await reconcileAccount(driver, accountId, join(accountsDir, accountId), scopedToken, houseAccountId);
536
+ await reconcileAccount(driver, accountId, join(accountsDir, accountId), () => createD1Client({ accountId: houseAccountId, token: scopedToken }), provisionState);
351
537
  }
352
538
  catch (err) {
353
539
  console.error(`[calendar-reconcile] account=${accountId} run-failed err="${err.message}"`);
354
540
  }
355
541
  }
542
+ // Drop accounts that no longer exist so the file cannot grow without bound.
543
+ for (const known of Object.keys(provisionState)) {
544
+ if (!accountIds.includes(known))
545
+ delete provisionState[known];
546
+ }
547
+ writeProvisionState(PROVISION_STATE_FILE, provisionState);
356
548
  }
357
549
  finally {
358
550
  if (driver)