@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.
- package/package.json +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +3 -2
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +3 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -3
- package/payload/platform/plugins/cloudflare/skills/calendar-site/template/schema.sql +13 -3
- package/payload/platform/plugins/docs/references/calendar-booking.md +2 -2
- package/payload/platform/plugins/scheduling/PLUGIN.md +2 -2
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.js +150 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block-sqlite.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.js +248 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-block.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +158 -23
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.js +200 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-schema-migration.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.js +309 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/connector-sync-provision.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js +219 -19
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +184 -11
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +211 -22
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.d.ts +109 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.js +213 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/connector-sync-provision.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts +80 -8
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js +202 -8
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.d.ts +6 -4
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +411 -141
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/scripts/cpu-triage.sh +313 -0
- package/payload/server/{chunk-Y6V45R6M.js → chunk-JECAP3Z2.js} +296 -63
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/activity.html +6 -6
- package/payload/server/public/assets/{AdminLoginScreens-B44KbEuC.js → AdminLoginScreens-mEKP4pDi.js} +1 -1
- package/payload/server/public/assets/{AdminShell-BjxV2qkb.js → AdminShell-BwSaZ88d.js} +1 -1
- package/payload/server/public/assets/{Checkbox-DSeZNGNA.js → Checkbox-CN4uQ80w.js} +1 -1
- package/payload/server/public/assets/activity-CIQ8ozNZ.js +1 -0
- package/payload/server/public/assets/{admin-B4LpTCur.js → admin-BVhgfgs1.js} +1 -1
- package/payload/server/public/assets/{browser-CEFvaWwI.js → browser-D-HMmHcM.js} +1 -1
- package/payload/server/public/assets/{calendar-B3zo6V34.js → calendar-tGWvFyFn.js} +1 -1
- package/payload/server/public/assets/chat-DkttVxAz.js +1 -0
- package/payload/server/public/assets/chevron-left-CZ4ez9G5.js +1 -0
- package/payload/server/public/assets/data-P-mcmnNi.js +1 -0
- package/payload/server/public/assets/{graph-labels-GlTEIYJj.js → graph-labels-qnBleOE6.js} +1 -1
- package/payload/server/public/assets/{graph-RaOrnSf2.js → graph-zEw610xK.js} +1 -1
- package/payload/server/public/assets/{maximize-2-CHDa8p5h.js → maximize-2-BlTjXT_Y.js} +1 -1
- package/payload/server/public/assets/{operator-DtqHY4jV.js → operator-C7oIw2PG.js} +1 -1
- package/payload/server/public/assets/{page-DxnWgPtd.js → page-CTP7OFZa.js} +1 -1
- package/payload/server/public/assets/{page-CkhFj8GQ.js → page-DL6Zsdvk.js} +1 -1
- package/payload/server/public/assets/{public-D5TUfQ3I.js → public-r1A9dqR_.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-BJjVAQQY.js → rotate-ccw-CzkkKx4-.js} +1 -1
- package/payload/server/public/assets/{tasks-CM9pYYG1.js → tasks-fIJwYFWG.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-C5RYEYjM.js → time-entry-format-j669DgXj.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-DLfIsuo_.js → triangle-alert-BPgCgIef.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-7yKeiAzV.js → useCopyFeedback-BmLa2aes.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-CWlh6AEq.js → useSelectionMode-DnnM7A8-.js} +1 -1
- package/payload/server/public/assets/{useSubAccountSwitcher-B2n9QPVI.css → useSubAccountSwitcher-C_E8h07P.css} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-j3ZArZ2H.js → useVoiceRecorder-u2dwZfen.js} +1 -1
- 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/public.html +12 -12
- package/payload/server/public/tasks.html +5 -5
- package/payload/server/server.js +358 -189
- package/payload/server/public/assets/activity-Mz1BhgB1.js +0 -1
- package/payload/server/public/assets/chat-5tJdo9i_.js +0 -1
- package/payload/server/public/assets/chevron-left-BJllrPBj.js +0 -1
- package/payload/server/public/assets/data-Du79EgR-.js +0 -1
- /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
|
|
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),
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
233
|
+
connectors = detectCalendarConnectors(accountDir);
|
|
234
|
+
if (connectors.length === 0) {
|
|
235
|
+
outcome = "not-applicable";
|
|
191
236
|
}
|
|
192
237
|
else {
|
|
193
|
-
const
|
|
194
|
-
|
|
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
|
-
|
|
255
|
+
outcome = "error";
|
|
256
|
+
detail = err.message;
|
|
199
257
|
}
|
|
200
258
|
finally {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
for
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
349
|
+
await claimSession.close();
|
|
261
350
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
427
|
+
finally {
|
|
428
|
+
await auditSession.close();
|
|
273
429
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
|
337
|
-
// minter directly (a minter returns 10000
|
|
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,
|
|
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)
|