@daeda/mcp-pro 0.1.11 → 0.1.13

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 (2) hide show
  1. package/dist/index.js +1797 -888
  2. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { Effect as Effect91, Layer as Layer6, pipe as pipe77 } from "effect";
6
+ import { Effect as Effect93, Layer as Layer6, pipe as pipe78 } from "effect";
7
7
 
8
8
  // src/plugins/crm-data.ts
9
9
  import { Effect as Effect3, pipe as pipe3 } from "effect";
@@ -78,6 +78,7 @@ var SyncError = class extends Data.TaggedError("SyncError") {
78
78
 
79
79
  // src/pure/csv-parser.ts
80
80
  import { unzipSync, gunzipSync } from "fflate";
81
+ import Papa from "papaparse";
81
82
  var ZIP_MAGIC = [80, 75, 3, 4];
82
83
  var GZIP_MAGIC = [31, 139];
83
84
  var hasMagicBytes = (bytes, magic) => bytes.length >= magic.length && magic.every((b, i) => bytes[i] === b);
@@ -106,74 +107,6 @@ var decompressBytes = (bytes, contentType = "", contentEncoding = "", url = "")
106
107
  }
107
108
  return new TextDecoder().decode(bytes);
108
109
  };
109
- var splitCsvRows = (csv) => {
110
- const rows = [];
111
- let current = "";
112
- let inQuotes = false;
113
- for (let i = 0; i < csv.length; i++) {
114
- const char = csv[i];
115
- if (inQuotes) {
116
- if (char === '"') {
117
- if (i + 1 < csv.length && csv[i + 1] === '"') {
118
- current += '""';
119
- i++;
120
- } else {
121
- inQuotes = false;
122
- current += char;
123
- }
124
- } else {
125
- current += char;
126
- }
127
- } else {
128
- if (char === '"') {
129
- inQuotes = true;
130
- current += char;
131
- } else if (char === "\r") {
132
- if (i + 1 < csv.length && csv[i + 1] === "\n") i++;
133
- if (current.trim().length > 0) rows.push(current);
134
- current = "";
135
- } else if (char === "\n") {
136
- if (current.trim().length > 0) rows.push(current);
137
- current = "";
138
- } else {
139
- current += char;
140
- }
141
- }
142
- }
143
- if (current.trim().length > 0) rows.push(current);
144
- return rows;
145
- };
146
- var parseCSVLine = (line) => {
147
- const fields44 = [];
148
- let current = "";
149
- let inQuotes = false;
150
- for (let i = 0; i < line.length; i++) {
151
- const char = line[i];
152
- if (inQuotes) {
153
- if (char === '"') {
154
- if (i + 1 < line.length && line[i + 1] === '"') {
155
- current += '"';
156
- i++;
157
- } else {
158
- inQuotes = false;
159
- }
160
- } else {
161
- current += char;
162
- }
163
- } else {
164
- if (char === '"') {
165
- inQuotes = true;
166
- } else if (char === ",") {
167
- fields44.push(current);
168
- current = "";
169
- } else {
170
- current += char;
171
- }
172
- }
173
- }
174
- fields44.push(current);
175
- return fields44;
176
- };
177
110
  var EXPORT_NAME_TO_OBJECT_TYPE = {
178
111
  CONTACT: "contacts",
179
112
  COMPANY: "companies",
@@ -188,65 +121,54 @@ var EXPORT_NAME_TO_OBJECT_TYPE = {
188
121
  NOTE: "notes",
189
122
  TASK: "tasks",
190
123
  COMMUNICATION: "communications",
191
- POSTAL_MAIL: "postal_mail"
124
+ POSTAL_MAIL: "postal_mail",
125
+ "0-421": "appointments",
126
+ "0-420": "listings",
127
+ "0-970": "projects",
128
+ "0-162": "services"
192
129
  };
193
130
  var resolveObjectTypeName = (exportName) => EXPORT_NAME_TO_OBJECT_TYPE[exportName] ?? exportName.toLowerCase();
194
131
  var normalizeAssociationLabel = (value) => {
195
132
  if (value === void 0) return null;
196
133
  return value.trim().length > 0 ? value : null;
197
134
  };
198
- var parseSupabaseAssociationCsv = (lines, headers, from, to) => {
199
- const fromIdIndex = headers.findIndex((h) => h === "fromObjectId");
200
- const toIdIndex = headers.findIndex((h) => h === "toObjectId");
201
- const typeIndex = headers.findIndex((h) => h === "associationType");
202
- const labelIndex = headers.findIndex((h) => h === "label");
203
- if (fromIdIndex === -1 || toIdIndex === -1) {
204
- throw new Error(`Missing fromObjectId/toObjectId columns in headers: ${headers.join(", ")}`);
205
- }
206
- const rows = [];
207
- for (let i = 1; i < lines.length; i++) {
208
- const values = parseCSVLine(lines[i]);
209
- const fromId = values[fromIdIndex] ?? "";
210
- const toId = values[toIdIndex] ?? "";
211
- if (!fromId || !toId) continue;
212
- rows.push({
213
- fromObjectType: from,
214
- fromId,
215
- toObjectType: to,
216
- toId,
217
- associationType: typeIndex !== -1 ? values[typeIndex] ?? "" : "",
218
- label: labelIndex !== -1 ? normalizeAssociationLabel(values[labelIndex]) : null
219
- });
135
+ var isAssociationHeader = (h) => h.startsWith("associations.ids.") || h.startsWith("associations.labels.") || h.startsWith("associations.");
136
+ var parseRow = (csvContent) => Papa.parse(csvContent, {
137
+ header: true,
138
+ skipEmptyLines: true,
139
+ dynamicTyping: false
140
+ });
141
+ var findAssociationMappings = (headers) => {
142
+ const mappings = [];
143
+ for (const h of headers) {
144
+ if (!h.startsWith("associations.ids.")) continue;
145
+ const exportName = h.replace("associations.ids.", "");
146
+ const toObjectType = resolveObjectTypeName(exportName);
147
+ const labelsHeader = headers.find((lh) => lh === `associations.labels.${exportName}`) ?? headers.find((lh) => lh === `associations.${exportName}`) ?? null;
148
+ mappings.push({ toObjectType, idsHeader: h, labelsHeader });
220
149
  }
221
- return rows;
150
+ return mappings;
222
151
  };
223
- var parseHubSpotAssociationCsv = (lines, headers, from, to) => {
224
- const idIndex = headers.findIndex(
225
- (h) => h === "hs_object_id" || h === "Record ID"
226
- );
227
- if (idIndex === -1) {
228
- throw new Error(`No id column found in headers: ${headers.join(", ")}`);
229
- }
230
- const assocIdsIndex = headers.findIndex((h) => h.startsWith("associations.ids."));
231
- if (assocIdsIndex === -1) {
232
- throw new Error(`No associations.ids column found in headers: ${headers.join(", ")}`);
152
+ var findIdField = (headers) => {
153
+ for (const candidate of ["hs_object_id", "Record ID", "id", "ID"]) {
154
+ if (headers.includes(candidate)) return candidate;
233
155
  }
234
- const labelHeader = headers[assocIdsIndex].replace("associations.ids.", "associations.labels.");
235
- const labelIndex = headers.findIndex((h) => h === labelHeader);
156
+ const lowerMatch = headers.find((h) => h.toLowerCase() === "id");
157
+ if (lowerMatch) return lowerMatch;
158
+ throw new Error(`No id column found in headers: ${headers.join(", ")}`);
159
+ };
160
+ var extractAssociations = (row, fromId, objectType, mappings) => {
236
161
  const rows = [];
237
- for (let i = 1; i < lines.length; i++) {
238
- const values = parseCSVLine(lines[i]);
239
- const fromId = values[idIndex] ?? "";
240
- if (!fromId) continue;
241
- const idsRaw = values[assocIdsIndex] ?? "";
162
+ for (const mapping of mappings) {
163
+ const idsRaw = row[mapping.idsHeader] ?? "";
242
164
  if (!idsRaw) continue;
243
165
  const toIds = idsRaw.split(";").map((s) => s.trim()).filter(Boolean);
244
- const labels = labelIndex !== -1 ? (values[labelIndex] ?? "").split(";").map((s) => s.trim()) : [];
166
+ const labels = mapping.labelsHeader ? (row[mapping.labelsHeader] ?? "").split(";").map((s) => s.trim()) : [];
245
167
  for (let j = 0; j < toIds.length; j++) {
246
168
  rows.push({
247
- fromObjectType: from,
169
+ fromObjectType: objectType,
248
170
  fromId,
249
- toObjectType: to,
171
+ toObjectType: mapping.toObjectType,
250
172
  toId: toIds[j],
251
173
  associationType: "",
252
174
  label: normalizeAssociationLabel(labels[j])
@@ -255,78 +177,85 @@ var parseHubSpotAssociationCsv = (lines, headers, from, to) => {
255
177
  }
256
178
  return rows;
257
179
  };
258
- var findAssociationColumns = (headers) => {
259
- const columns = [];
260
- for (let i = 0; i < headers.length; i++) {
261
- const h = headers[i];
262
- if (!h.startsWith("associations.ids.")) continue;
263
- const exportName = h.replace("associations.ids.", "");
264
- const toObjectType = resolveObjectTypeName(exportName);
265
- const labelHeader = `associations.labels.${exportName}`;
266
- const labelsIndex = headers.findIndex((lh) => lh === labelHeader);
267
- columns.push({ toObjectType, idsIndex: i, labelsIndex });
268
- }
269
- return columns;
270
- };
271
- var isAssociationHeader = (h) => h.startsWith("associations.ids.") || h.startsWith("associations.labels.");
272
180
  var parseMergedExportCsv = (csvContent, objectType) => {
273
- const lines = splitCsvRows(csvContent);
274
- if (lines.length === 0) return { objects: [], associations: [] };
275
- const headers = parseCSVLine(lines[0]);
276
- const idIndex = headers.findIndex(
277
- (h) => h.toLowerCase() === "id" || h === "Record ID" || h === "hs_object_id"
278
- );
279
- if (idIndex === -1) {
280
- throw new Error(`No id column found in headers: ${headers.join(", ")}`);
281
- }
282
- const assocColumns = findAssociationColumns(headers);
283
- const assocColumnIndices = /* @__PURE__ */ new Set();
284
- for (const col of assocColumns) {
285
- assocColumnIndices.add(col.idsIndex);
286
- if (col.labelsIndex !== -1) assocColumnIndices.add(col.labelsIndex);
181
+ const result = parseRow(csvContent);
182
+ if (result.data.length === 0) return { objects: [], associations: [] };
183
+ const headers = result.meta.fields ?? [];
184
+ const idField = findIdField(headers);
185
+ const assocMappings = findAssociationMappings(headers);
186
+ const assocHeaders = /* @__PURE__ */ new Set();
187
+ for (const m of assocMappings) {
188
+ assocHeaders.add(m.idsHeader);
189
+ if (m.labelsHeader) assocHeaders.add(m.labelsHeader);
287
190
  }
191
+ const propertyHeaders = headers.filter(
192
+ (h) => h !== idField && !assocHeaders.has(h) && !isAssociationHeader(h)
193
+ );
288
194
  const objects = [];
289
195
  const associations = [];
290
- for (let i = 1; i < lines.length; i++) {
291
- const values = parseCSVLine(lines[i]);
292
- const id = values[idIndex] ?? "";
196
+ for (const row of result.data) {
197
+ const id = row[idField] ?? "";
293
198
  if (!id) continue;
294
199
  const properties = {};
295
- for (let j = 0; j < headers.length; j++) {
296
- if (j === idIndex) continue;
297
- if (assocColumnIndices.has(j)) continue;
298
- if (isAssociationHeader(headers[j])) continue;
299
- const key = headers[j];
300
- const value = values[j];
200
+ for (const key of propertyHeaders) {
201
+ const value = row[key];
301
202
  properties[key] = value !== void 0 && value !== "" ? value : null;
302
203
  }
303
204
  objects.push({ id, properties });
304
- for (const col of assocColumns) {
305
- const idsRaw = values[col.idsIndex] ?? "";
306
- if (!idsRaw) continue;
307
- const toIds = idsRaw.split(";").map((s) => s.trim()).filter(Boolean);
308
- const labels = col.labelsIndex !== -1 ? (values[col.labelsIndex] ?? "").split(";").map((s) => s.trim()) : [];
309
- for (let j = 0; j < toIds.length; j++) {
310
- associations.push({
311
- fromObjectType: objectType,
312
- fromId: id,
313
- toObjectType: col.toObjectType,
314
- toId: toIds[j],
315
- associationType: "",
316
- label: normalizeAssociationLabel(labels[j])
317
- });
318
- }
319
- }
205
+ associations.push(...extractAssociations(row, id, objectType, assocMappings));
320
206
  }
321
207
  return { objects, associations };
322
208
  };
323
209
  var parseAssociationRows = (csvContent, objectType) => {
324
210
  const { from, to } = parseAssociationType(objectType);
325
- const lines = splitCsvRows(csvContent);
326
- if (lines.length === 0) return [];
327
- const headers = parseCSVLine(lines[0]);
328
- const isSupabaseFormat = headers.some((h) => h === "fromObjectId");
329
- return isSupabaseFormat ? parseSupabaseAssociationCsv(lines, headers, from, to) : parseHubSpotAssociationCsv(lines, headers, from, to);
211
+ const result = parseRow(csvContent);
212
+ if (result.data.length === 0) return [];
213
+ const headers = result.meta.fields ?? [];
214
+ const isSupabaseFormat = headers.includes("fromObjectId");
215
+ if (isSupabaseFormat) {
216
+ const rows2 = [];
217
+ for (const row of result.data) {
218
+ const fromId = row["fromObjectId"] ?? "";
219
+ const toId = row["toObjectId"] ?? "";
220
+ if (!fromId || !toId) continue;
221
+ rows2.push({
222
+ fromObjectType: from,
223
+ fromId,
224
+ toObjectType: to,
225
+ toId,
226
+ associationType: row["associationType"] ?? "",
227
+ label: normalizeAssociationLabel(row["label"])
228
+ });
229
+ }
230
+ return rows2;
231
+ }
232
+ const idField = findIdField(headers);
233
+ const assocIdsHeader = headers.find((h) => h.startsWith("associations.ids."));
234
+ if (!assocIdsHeader) {
235
+ throw new Error(`No associations.ids column found in headers: ${headers.join(", ")}`);
236
+ }
237
+ const exportName = assocIdsHeader.replace("associations.ids.", "");
238
+ const labelHeader = headers.find((h) => h === `associations.labels.${exportName}`) ?? headers.find((h) => h === `associations.${exportName}`) ?? null;
239
+ const rows = [];
240
+ for (const row of result.data) {
241
+ const fromId = row[idField] ?? "";
242
+ if (!fromId) continue;
243
+ const idsRaw = row[assocIdsHeader] ?? "";
244
+ if (!idsRaw) continue;
245
+ const toIds = idsRaw.split(";").map((s) => s.trim()).filter(Boolean);
246
+ const labels = labelHeader ? (row[labelHeader] ?? "").split(";").map((s) => s.trim()) : [];
247
+ for (let j = 0; j < toIds.length; j++) {
248
+ rows.push({
249
+ fromObjectType: from,
250
+ fromId,
251
+ toObjectType: to,
252
+ toId: toIds[j],
253
+ associationType: "",
254
+ label: normalizeAssociationLabel(labels[j])
255
+ });
256
+ }
257
+ }
258
+ return rows;
330
259
  };
331
260
 
332
261
  // src/effects/csv-parser.ts
@@ -386,15 +315,16 @@ import path3 from "path";
386
315
  // src/paths.ts
387
316
  import os2 from "os";
388
317
  import path2 from "path";
389
- var DATA_ROOT = path2.join(os2.homedir(), ".daeda-mcp");
390
- var PORTALS_DIR = path2.join(DATA_ROOT, "portals");
391
- var portalDir = (portalId) => path2.join(PORTALS_DIR, String(portalId));
318
+ var _dataRoot = path2.join(os2.homedir(), ".daeda-mcp");
319
+ var DATA_ROOT = () => _dataRoot;
320
+ var PORTALS_DIR = () => path2.join(_dataRoot, "portals");
321
+ var portalDir = (portalId) => path2.join(PORTALS_DIR(), String(portalId));
392
322
  var dbPath = (portalId) => path2.join(portalDir(portalId), "hubspot.duckdb");
393
- var stateFilePath = () => path2.join(DATA_ROOT, "client_state.json");
323
+ var stateFilePath = () => path2.join(_dataRoot, "client_state.json");
394
324
  var portalStatePath = (portalId) => path2.join(portalDir(portalId), "portal_state.json");
395
325
  var errorsDirPath = (portalId) => path2.join(portalDir(portalId), "errors");
396
- var CHARTS_DIR = path2.join(DATA_ROOT, "charts");
397
- var chartFilePath = (filename) => path2.join(CHARTS_DIR, filename);
326
+ var CHARTS_DIR = () => path2.join(_dataRoot, "charts");
327
+ var chartFilePath = (filename) => path2.join(CHARTS_DIR(), filename);
398
328
 
399
329
  // src/effects/save-error-csv.ts
400
330
  var logStderr = (message) => Effect2.sync(() => console.error(message));
@@ -1647,7 +1577,7 @@ var ConfigLive = Layer.succeed(
1647
1577
  ),
1648
1578
  save: (state) => Effect19.try({
1649
1579
  try: () => {
1650
- fs3.mkdirSync(DATA_ROOT, { recursive: true });
1580
+ fs3.mkdirSync(DATA_ROOT(), { recursive: true });
1651
1581
  fs3.writeFileSync(stateFilePath(), JSON.stringify(state, null, 2), "utf-8");
1652
1582
  },
1653
1583
  catch: (error) => new ConfigWriteError({ path: stateFilePath(), cause: error })
@@ -2197,6 +2127,13 @@ var mergePluginsByName = (existing, incoming) => {
2197
2127
  }
2198
2128
  return Array.from(merged.values());
2199
2129
  };
2130
+ var resetStuckPlugins = (plugins) => {
2131
+ const hasStuck = plugins.some((p) => p.status === "SYNCING");
2132
+ if (!hasStuck) return plugins;
2133
+ return plugins.map(
2134
+ (p) => p.status === "SYNCING" ? { ...p, status: "NOT_STARTED", error: null } : p
2135
+ );
2136
+ };
2200
2137
  var updatePluginStatus = (plugins, name, status, error, lastSynced) => plugins.map(
2201
2138
  (p) => p.name === name ? {
2202
2139
  ...p,
@@ -2322,6 +2259,12 @@ var PortalStateLive = Layer3.effect(
2322
2259
  };
2323
2260
  return [void 0, next];
2324
2261
  }).pipe(Effect21.asVoid),
2262
+ resetStuckPlugins: (portalId) => modifyAndPersist(portalId, (sa) => {
2263
+ const fixed = resetStuckPlugins(sa.plugins);
2264
+ if (fixed === sa.plugins) return [void 0, sa];
2265
+ const next = { ...sa, plugins: fixed };
2266
+ return [void 0, next];
2267
+ }).pipe(Effect21.asVoid),
2325
2268
  touchSyncedAt: (portalId) => modifyAndPersist(portalId, (current) => {
2326
2269
  const next = { ...current, syncedAt: /* @__PURE__ */ new Date() };
2327
2270
  return [void 0, next];
@@ -2332,103 +2275,113 @@ var PortalStateLive = Layer3.effect(
2332
2275
  );
2333
2276
 
2334
2277
  // src/layers/SyncLayerLive.ts
2335
- import { Effect as Effect24, Layer as Layer4, Ref as Ref3, pipe as pipe18 } from "effect";
2278
+ import { Effect as Effect24, Layer as Layer4, pipe as pipe18 } from "effect";
2336
2279
 
2337
2280
  // src/effects/run-full-sync.ts
2338
2281
  import { Effect as Effect22, Ref as Ref2, pipe as pipe16 } from "effect";
2339
2282
  var logStderr5 = (message) => Effect22.sync(() => console.error(message));
2340
- var runFullSync = (ws, db, portalState, portalId, artifacts, progressRef, objectTypesToSync) => pipe16(
2341
- Effect22.Do,
2342
- Effect22.let(
2343
- "objectTypesToSyncSet",
2344
- () => objectTypesToSync ? new Set(objectTypesToSync) : void 0
2345
- ),
2346
- Effect22.let(
2347
- "toSync",
2348
- ({ objectTypesToSyncSet }) => filterUnsyncedArtifacts(artifacts, objectTypesToSyncSet)
2349
- ),
2350
- Effect22.tap(
2351
- ({ toSync }) => Ref2.set(progressRef, {
2352
- totalArtifacts: toSync.length,
2353
- completedArtifacts: 0,
2354
- currentArtifact: null,
2355
- status: "syncing"
2356
- })
2357
- ),
2358
- Effect22.tap(() => db.initializeDb(portalId)),
2359
- Effect22.let("artifactCtx", () => ({
2360
- db,
2361
- ws,
2362
- portalState,
2363
- portalId
2364
- })),
2365
- Effect22.tap(
2366
- ({ toSync, artifactCtx }) => Effect22.forEach(
2367
- toSync,
2368
- (artifact, idx) => pipe16(
2369
- Ref2.update(progressRef, (p) => ({
2370
- ...p,
2371
- currentArtifact: artifact.object_type
2372
- })),
2373
- Effect22.tap(
2374
- () => logStderr5(
2375
- `[sync] (${idx + 1}/${toSync.length}) Processing: ${artifact.object_type}`
2376
- )
2377
- ),
2378
- Effect22.flatMap(() => {
2379
- const plugin = findArtifactPlugin(artifact.object_type);
2380
- if (!plugin) {
2381
- return logStderr5(`[sync] No artifact plugin found for ${artifact.object_type}, skipping`);
2382
- }
2383
- return plugin.processArtifact(artifactCtx, artifact);
2384
- }),
2385
- Effect22.tap(
2386
- () => Ref2.update(progressRef, (p) => ({
2387
- ...p,
2388
- completedArtifacts: p.completedArtifacts + 1
2389
- }))
2283
+ var runFullSync = (ws, db, portalState, portalId, artifacts, objectTypesToSync) => pipe16(
2284
+ Ref2.make({
2285
+ totalArtifacts: 0,
2286
+ completedArtifacts: 0,
2287
+ currentArtifact: null,
2288
+ status: "idle"
2289
+ }),
2290
+ Effect22.flatMap(
2291
+ (progressRef) => pipe16(
2292
+ Effect22.Do,
2293
+ Effect22.let(
2294
+ "objectTypesToSyncSet",
2295
+ () => objectTypesToSync ? new Set(objectTypesToSync) : void 0
2296
+ ),
2297
+ Effect22.let(
2298
+ "toSync",
2299
+ ({ objectTypesToSyncSet }) => filterUnsyncedArtifacts(artifacts, objectTypesToSyncSet)
2300
+ ),
2301
+ Effect22.tap(
2302
+ ({ toSync }) => Ref2.set(progressRef, {
2303
+ totalArtifacts: toSync.length,
2304
+ completedArtifacts: 0,
2305
+ currentArtifact: null,
2306
+ status: "syncing"
2307
+ })
2308
+ ),
2309
+ Effect22.tap(() => db.initializeDb(portalId)),
2310
+ Effect22.let("artifactCtx", () => ({
2311
+ db,
2312
+ ws,
2313
+ portalState,
2314
+ portalId
2315
+ })),
2316
+ Effect22.tap(
2317
+ ({ toSync, artifactCtx }) => Effect22.forEach(
2318
+ toSync,
2319
+ (artifact, idx) => pipe16(
2320
+ Ref2.update(progressRef, (p) => ({
2321
+ ...p,
2322
+ currentArtifact: artifact.object_type
2323
+ })),
2324
+ Effect22.tap(
2325
+ () => logStderr5(
2326
+ `[sync] (${idx + 1}/${toSync.length}) Processing: ${artifact.object_type}`
2327
+ )
2328
+ ),
2329
+ Effect22.flatMap(() => {
2330
+ const plugin = findArtifactPlugin(artifact.object_type);
2331
+ if (!plugin) {
2332
+ return logStderr5(`[sync] No artifact plugin found for ${artifact.object_type}, skipping`);
2333
+ }
2334
+ return plugin.processArtifact(artifactCtx, artifact);
2335
+ }),
2336
+ Effect22.tap(
2337
+ () => Ref2.update(progressRef, (p) => ({
2338
+ ...p,
2339
+ completedArtifacts: p.completedArtifacts + 1
2340
+ }))
2341
+ )
2342
+ ),
2343
+ { concurrency: 1 }
2390
2344
  )
2391
2345
  ),
2392
- { concurrency: 1 }
2346
+ Effect22.tap(
2347
+ () => db.setMetadata("last_synced", (/* @__PURE__ */ new Date()).toISOString())
2348
+ ),
2349
+ Effect22.tap(({ toSync }) => {
2350
+ const objectTypes = filterObjectTypes(toSync);
2351
+ return Effect22.forEach(
2352
+ objectTypes,
2353
+ (ot) => db.setMetadata(`last_synced:${ot}`, (/* @__PURE__ */ new Date()).toISOString()),
2354
+ { concurrency: "unbounded" }
2355
+ );
2356
+ }),
2357
+ Effect22.tap(
2358
+ () => db.setMetadata("portal_id", String(portalId))
2359
+ ),
2360
+ Effect22.tap(
2361
+ () => portalState.touchSyncedAt(portalId).pipe(Effect22.catchAll(() => Effect22.void))
2362
+ ),
2363
+ Effect22.tap(
2364
+ () => Ref2.update(progressRef, (p) => ({
2365
+ ...p,
2366
+ currentArtifact: null,
2367
+ status: "complete"
2368
+ }))
2369
+ ),
2370
+ Effect22.flatMap(() => Ref2.get(progressRef)),
2371
+ Effect22.tapError(
2372
+ (err) => Ref2.update(progressRef, (p) => ({
2373
+ ...p,
2374
+ status: "error",
2375
+ error: String(err)
2376
+ }))
2377
+ ),
2378
+ Effect22.mapError(
2379
+ (err) => new SyncError({
2380
+ message: `Full sync failed for portal ${portalId}`,
2381
+ cause: err
2382
+ })
2383
+ )
2393
2384
  )
2394
- ),
2395
- Effect22.tap(
2396
- () => db.setMetadata("last_synced", (/* @__PURE__ */ new Date()).toISOString())
2397
- ),
2398
- Effect22.tap(({ toSync }) => {
2399
- const objectTypes = filterObjectTypes(toSync);
2400
- return Effect22.forEach(
2401
- objectTypes,
2402
- (ot) => db.setMetadata(`last_synced:${ot}`, (/* @__PURE__ */ new Date()).toISOString()),
2403
- { concurrency: "unbounded" }
2404
- );
2405
- }),
2406
- Effect22.tap(
2407
- () => db.setMetadata("portal_id", String(portalId))
2408
- ),
2409
- Effect22.tap(
2410
- () => portalState.touchSyncedAt(portalId).pipe(Effect22.catchAll(() => Effect22.void))
2411
- ),
2412
- Effect22.tap(
2413
- () => Ref2.update(progressRef, (p) => ({
2414
- ...p,
2415
- currentArtifact: null,
2416
- status: "complete"
2417
- }))
2418
- ),
2419
- Effect22.flatMap(() => Ref2.get(progressRef)),
2420
- Effect22.tapError(
2421
- (err) => Ref2.update(progressRef, (p) => ({
2422
- ...p,
2423
- status: "error",
2424
- error: String(err)
2425
- }))
2426
- ),
2427
- Effect22.mapError(
2428
- (err) => new SyncError({
2429
- message: `Full sync failed for portal ${portalId}`,
2430
- cause: err
2431
- })
2432
2385
  )
2433
2386
  );
2434
2387
 
@@ -2591,14 +2544,8 @@ var SyncLive = Layer4.effect(
2591
2544
  const ws = yield* WebSocketService;
2592
2545
  const db = yield* DatabaseService;
2593
2546
  const portalState = yield* PortalStateService;
2594
- const progressRef = yield* Ref3.make({
2595
- totalArtifacts: 0,
2596
- completedArtifacts: 0,
2597
- currentArtifact: null,
2598
- status: "idle"
2599
- });
2600
2547
  return {
2601
- syncArtifacts: (portalId, artifacts, objectTypesToSync) => runFullSync(ws, db, portalState, portalId, artifacts, progressRef, objectTypesToSync),
2548
+ syncArtifacts: (portalId, artifacts, objectTypesToSync) => runFullSync(ws, db, portalState, portalId, artifacts, objectTypesToSync),
2602
2549
  syncMessagePlugins: (portalId) => pipe18(
2603
2550
  syncAllMessagePlugins(ws, db, portalState, portalId),
2604
2551
  Effect24.mapError(
@@ -2607,66 +2554,52 @@ var SyncLive = Layer4.effect(
2607
2554
  cause: err
2608
2555
  })
2609
2556
  )
2610
- ),
2611
- getProgress: () => Ref3.get(progressRef)
2557
+ )
2612
2558
  };
2613
2559
  })
2614
2560
  );
2615
2561
 
2616
2562
  // src/layers/WebSocketLayerLive.ts
2617
- import { Effect as Effect36, Layer as Layer5, Match, Ref as Ref15, Schedule, Duration, pipe as pipe30 } from "effect";
2563
+ import { Effect as Effect36, Layer as Layer5, Match, Ref as Ref14, Schedule, Duration, pipe as pipe30 } from "effect";
2618
2564
 
2619
2565
  // src/handlers/handle-register-response.ts
2620
- import { Effect as Effect25, Ref as Ref4, pipe as pipe19 } from "effect";
2566
+ import { Effect as Effect25, Ref as Ref3, pipe as pipe19 } from "effect";
2621
2567
  var handleRegisterResponse = (payload, ctx) => !payload.success || !payload.target_portals ? Effect25.sync(
2622
2568
  () => console.error(`Registration failed: ${payload.error ?? "unknown"}`)
2623
2569
  ) : pipe19(
2624
- Ref4.set(ctx.portalsRef, payload.target_portals),
2570
+ Ref3.set(ctx.portalsRef, payload.target_portals),
2625
2571
  Effect25.flatMap(
2626
- () => payload.encryption_key ? Ref4.set(ctx.encryptionKeyRef, payload.encryption_key) : Effect25.void
2572
+ () => payload.encryption_key ? Ref3.set(ctx.encryptionKeyRef, payload.encryption_key) : Effect25.void
2627
2573
  ),
2628
- Effect25.flatMap(() => Ref4.get(ctx.handlersRef)),
2574
+ Effect25.flatMap(() => Ref3.get(ctx.handlersRef)),
2629
2575
  Effect25.flatMap(
2630
2576
  (handlers) => Effect25.sync(() => {
2631
2577
  const portals = payload.target_portals;
2632
- const selectedPortalId = handlers?.autoSelectSinglePortal(portals) ?? null;
2633
- if (selectedPortalId !== null) {
2634
- Effect25.runFork(
2635
- pipe19(
2636
- Ref4.set(ctx.currentSyncPortalRef, selectedPortalId),
2637
- Effect25.flatMap(() => ctx.requireWs),
2638
- Effect25.flatMap((ws) => ctx.sendJson(ws, "sync:full", { target_portal: selectedPortalId })),
2639
- Effect25.catchAll(() => Effect25.void)
2640
- )
2641
- );
2642
- } else {
2643
- handlers?.onRegisterSuccess(portals);
2644
- }
2578
+ handlers?.autoSelectSinglePortal(portals);
2579
+ handlers?.onRegisterSuccess(portals);
2645
2580
  })
2646
2581
  )
2647
2582
  );
2648
2583
 
2649
2584
  // src/handlers/handle-sync-full-response.ts
2650
- import { Effect as Effect26, Ref as Ref5, pipe as pipe20 } from "effect";
2585
+ import { Effect as Effect26, Ref as Ref4, pipe as pipe20 } from "effect";
2651
2586
  var handleSyncFullResponse = (payload, ctx) => !payload.success ? Effect26.sync(() => console.error(`Sync failed: ${payload.error ?? "unknown"}`)) : pipe20(
2652
2587
  Effect26.sync(() => console.error(`[sync:full] Received ${payload.artifacts.length} artifacts`)),
2653
2588
  Effect26.flatMap(() => {
2654
2589
  const artifacts = payload.artifacts.map((a) => ({ ...a, status: "NOT_STARTED" }));
2655
2590
  return pipe20(
2656
- Ref5.get(ctx.currentSyncPortalRef),
2657
- Effect26.flatMap((portalId) => Ref5.get(ctx.handlersRef).pipe(
2658
- Effect26.flatMap(
2659
- (handlers) => portalId !== null && handlers?.onSyncArtifacts ? Effect26.sync(() => handlers.onSyncArtifacts(portalId, artifacts)) : Effect26.void
2660
- )
2661
- ))
2591
+ Ref4.get(ctx.handlersRef),
2592
+ Effect26.flatMap(
2593
+ (handlers) => handlers?.onSyncArtifacts ? Effect26.sync(() => handlers.onSyncArtifacts(payload.target_portal, artifacts)) : Effect26.void
2594
+ )
2662
2595
  );
2663
2596
  })
2664
2597
  );
2665
2598
 
2666
2599
  // src/handlers/handle-sync-artifact-response.ts
2667
- import { Effect as Effect27, Ref as Ref6, pipe as pipe21 } from "effect";
2600
+ import { Effect as Effect27, Ref as Ref5, pipe as pipe21 } from "effect";
2668
2601
  var handleSyncArtifactResponse = (payload, ctx) => pipe21(
2669
- Ref6.getAndSet(ctx.pendingArtifactRef, null),
2602
+ Ref5.getAndSet(ctx.pendingArtifactRef, null),
2670
2603
  Effect27.flatMap(
2671
2604
  (pending) => pending !== null ? Effect27.sync(() => pending.resolve(payload)) : Effect27.void
2672
2605
  ),
@@ -2678,21 +2611,21 @@ var handleSyncArtifactResponse = (payload, ctx) => pipe21(
2678
2611
  );
2679
2612
 
2680
2613
  // src/handlers/handle-new-artifacts.ts
2681
- import { Effect as Effect28, Ref as Ref7, pipe as pipe22 } from "effect";
2614
+ import { Effect as Effect28, Ref as Ref6, pipe as pipe22 } from "effect";
2682
2615
  var handleNewArtifacts = (payload, ctx) => pipe22(
2683
2616
  Effect28.sync(
2684
2617
  () => console.error(
2685
2618
  `[sync:new-artifacts] Received ${payload.artifacts.length} new artifact(s) for portal ${payload.target_portal}: ${payload.artifacts.map((a) => a.object_type).join(", ")}`
2686
2619
  )
2687
2620
  ),
2688
- Effect28.flatMap(() => Ref7.get(ctx.handlersRef)),
2621
+ Effect28.flatMap(() => Ref6.get(ctx.handlersRef)),
2689
2622
  Effect28.flatMap(
2690
2623
  (handlers) => handlers?.onNewArtifacts ? Effect28.sync(() => handlers.onNewArtifacts(payload.target_portal, payload.artifacts)) : Effect28.void
2691
2624
  )
2692
2625
  );
2693
2626
 
2694
2627
  // src/handlers/handle-diff-batch.ts
2695
- import { Effect as Effect29, Ref as Ref8, pipe as pipe23 } from "effect";
2628
+ import { Effect as Effect29, Ref as Ref7, pipe as pipe23 } from "effect";
2696
2629
  var handleDiffBatch = (payload, ctx) => !payload.success ? Effect29.sync(
2697
2630
  () => console.error(`Diff batch failed: ${payload.error ?? "unknown"}`)
2698
2631
  ) : pipe23(
@@ -2701,46 +2634,42 @@ var handleDiffBatch = (payload, ctx) => !payload.success ? Effect29.sync(
2701
2634
  `[sync:diff:batch] ${payload.object_type}: ${payload.records.length} records, ${payload.associations.length} associations (server_time: ${payload.server_time})${payload.truncated ? " (TRUNCATED)" : ""}`
2702
2635
  )
2703
2636
  ),
2704
- Effect29.flatMap(() => Ref8.get(ctx.currentSyncPortalRef)),
2705
- Effect29.flatMap((portalId) => Ref8.get(ctx.handlersRef).pipe(
2706
- Effect29.flatMap(
2707
- (handlers) => portalId !== null && handlers?.onDiffBatch ? Effect29.sync(() => handlers.onDiffBatch(portalId, payload)) : Effect29.void
2708
- )
2709
- ))
2637
+ Effect29.flatMap(() => Ref7.get(ctx.handlersRef)),
2638
+ Effect29.flatMap(
2639
+ (handlers) => handlers?.onDiffBatch ? Effect29.sync(() => handlers.onDiffBatch(payload.target_portal, payload)) : Effect29.void
2640
+ )
2710
2641
  );
2711
2642
 
2712
2643
  // src/handlers/handle-diff-complete.ts
2713
- import { Effect as Effect30, Ref as Ref9, pipe as pipe24 } from "effect";
2644
+ import { Effect as Effect30, Ref as Ref8, pipe as pipe24 } from "effect";
2714
2645
  var handleDiffComplete = (payload, ctx) => pipe24(
2715
2646
  Effect30.sync(
2716
2647
  () => console.error(
2717
2648
  `[sync:diff:complete] ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations`
2718
2649
  )
2719
2650
  ),
2720
- Effect30.flatMap(() => Ref9.get(ctx.currentSyncPortalRef)),
2721
- Effect30.flatMap((portalId) => Ref9.get(ctx.handlersRef).pipe(
2722
- Effect30.flatMap(
2723
- (handlers) => portalId !== null && handlers?.onDiffComplete ? Effect30.sync(() => handlers.onDiffComplete(portalId, payload)) : Effect30.void
2724
- )
2725
- ))
2651
+ Effect30.flatMap(() => Ref8.get(ctx.handlersRef)),
2652
+ Effect30.flatMap(
2653
+ (handlers) => handlers?.onDiffComplete ? Effect30.sync(() => handlers.onDiffComplete(payload.target_portal, payload)) : Effect30.void
2654
+ )
2726
2655
  );
2727
2656
 
2728
2657
  // src/handlers/handle-subscribe-ack.ts
2729
- import { Effect as Effect31, Ref as Ref10, pipe as pipe25 } from "effect";
2658
+ import { Effect as Effect31, Ref as Ref9, pipe as pipe25 } from "effect";
2730
2659
  var handleSubscribeAck = (payload, ctx) => pipe25(
2731
2660
  Effect31.sync(
2732
2661
  () => console.error(
2733
2662
  `[sync:subscribe:ack] Portal ${payload.target_portal}: ${payload.success ? "subscribed" : payload.error}`
2734
2663
  )
2735
2664
  ),
2736
- Effect31.flatMap(() => Ref10.get(ctx.handlersRef)),
2665
+ Effect31.flatMap(() => Ref9.get(ctx.handlersRef)),
2737
2666
  Effect31.flatMap(
2738
2667
  (handlers) => handlers?.onSubscribeAck ? Effect31.sync(() => handlers.onSubscribeAck(payload)) : Effect31.void
2739
2668
  )
2740
2669
  );
2741
2670
 
2742
2671
  // src/handlers/handle-sync-update.ts
2743
- import { Effect as Effect32, Ref as Ref11, pipe as pipe26 } from "effect";
2672
+ import { Effect as Effect32, Ref as Ref10, pipe as pipe26 } from "effect";
2744
2673
  var handleSyncUpdate = (payload, ctx) => !payload.success ? Effect32.sync(
2745
2674
  () => console.error(`Sync update failed: ${payload.error ?? "unknown"}`)
2746
2675
  ) : pipe26(
@@ -2749,36 +2678,34 @@ var handleSyncUpdate = (payload, ctx) => !payload.success ? Effect32.sync(
2749
2678
  `[sync:update] ${payload.object_type}: ${payload.records.length} records, ${payload.associations.length} associations (server_time: ${payload.server_time})`
2750
2679
  )
2751
2680
  ),
2752
- Effect32.flatMap(() => Ref11.get(ctx.currentSyncPortalRef)),
2753
- Effect32.flatMap((portalId) => Ref11.get(ctx.handlersRef).pipe(
2754
- Effect32.flatMap(
2755
- (handlers) => portalId !== null && handlers?.onDiffBatch ? Effect32.sync(() => handlers.onDiffBatch(portalId, payload)) : Effect32.void
2756
- )
2757
- ))
2681
+ Effect32.flatMap(() => Ref10.get(ctx.handlersRef)),
2682
+ Effect32.flatMap(
2683
+ (handlers) => handlers?.onDiffBatch ? Effect32.sync(() => handlers.onDiffBatch(payload.target_portal, payload)) : Effect32.void
2684
+ )
2758
2685
  );
2759
2686
 
2760
2687
  // src/handlers/handle-plan-create-response.ts
2761
- import { Effect as Effect33, Ref as Ref12, pipe as pipe27 } from "effect";
2688
+ import { Effect as Effect33, Ref as Ref11, pipe as pipe27 } from "effect";
2762
2689
  var handlePlanCreateResponse = (payload, ctx) => pipe27(
2763
- Ref12.getAndSet(ctx.pendingPlanCreateRef, null),
2690
+ Ref11.getAndSet(ctx.pendingPlanCreateRef, null),
2764
2691
  Effect33.flatMap(
2765
2692
  (pending) => pending ? Effect33.sync(() => pending.resolve(payload)) : Effect33.sync(() => console.error("[plan:create] No pending request for response"))
2766
2693
  )
2767
2694
  );
2768
2695
 
2769
2696
  // src/handlers/handle-plan-list-response.ts
2770
- import { Effect as Effect34, Ref as Ref13, pipe as pipe28 } from "effect";
2697
+ import { Effect as Effect34, Ref as Ref12, pipe as pipe28 } from "effect";
2771
2698
  var handlePlanListResponse = (payload, ctx) => pipe28(
2772
- Ref13.getAndSet(ctx.pendingPlanListRef, null),
2699
+ Ref12.getAndSet(ctx.pendingPlanListRef, null),
2773
2700
  Effect34.flatMap(
2774
2701
  (pending) => pending ? Effect34.sync(() => pending.resolve(payload)) : Effect34.sync(() => console.error("[plan:list] No pending request for response"))
2775
2702
  )
2776
2703
  );
2777
2704
 
2778
2705
  // src/handlers/handle-plugin-data-response.ts
2779
- import { Effect as Effect35, Ref as Ref14, pipe as pipe29 } from "effect";
2706
+ import { Effect as Effect35, Ref as Ref13, pipe as pipe29 } from "effect";
2780
2707
  var handlePluginDataResponse = (payload, ctx) => pipe29(
2781
- Ref14.modify(ctx.pendingPluginRequestsRef, (pendingMap) => {
2708
+ Ref13.modify(ctx.pendingPluginRequestsRef, (pendingMap) => {
2782
2709
  const next = new Map(pendingMap);
2783
2710
  const pending = next.get(payload.request_id);
2784
2711
  next.delete(payload.request_id);
@@ -2812,20 +2739,18 @@ var WS_URL = process.env.websocket_url ?? "wss://mcp-ws.daeda.tech";
2812
2739
  var WebSocketLive = Layer5.effect(
2813
2740
  WebSocketService,
2814
2741
  Effect36.gen(function* () {
2815
- const stateRef = yield* Ref15.make("disconnected");
2816
- const portalsRef = yield* Ref15.make([]);
2817
- const wsRef = yield* Ref15.make(null);
2818
- const syncedArtifactsRef = yield* Ref15.make(null);
2819
- const isReconnectingRef = yield* Ref15.make(false);
2820
- const currentSyncPortalRef = yield* Ref15.make(null);
2821
- const pendingArtifactRef = yield* Ref15.make(null);
2822
- const pendingPluginRequestsRef = yield* Ref15.make(/* @__PURE__ */ new Map());
2823
- const pendingPlanCreateRef = yield* Ref15.make(null);
2824
- const pendingPlanListRef = yield* Ref15.make(null);
2825
- const handlersRef = yield* Ref15.make(null);
2826
- const encryptionKeyRef = yield* Ref15.make(null);
2742
+ const stateRef = yield* Ref14.make("disconnected");
2743
+ const portalsRef = yield* Ref14.make([]);
2744
+ const wsRef = yield* Ref14.make(null);
2745
+ const isReconnectingRef = yield* Ref14.make(false);
2746
+ const pendingArtifactRef = yield* Ref14.make(null);
2747
+ const pendingPluginRequestsRef = yield* Ref14.make(/* @__PURE__ */ new Map());
2748
+ const pendingPlanCreateRef = yield* Ref14.make(null);
2749
+ const pendingPlanListRef = yield* Ref14.make(null);
2750
+ const handlersRef = yield* Ref14.make(null);
2751
+ const encryptionKeyRef = yield* Ref14.make(null);
2827
2752
  const requireWs = pipe30(
2828
- Ref15.get(wsRef),
2753
+ Ref14.get(wsRef),
2829
2754
  Effect36.flatMap(
2830
2755
  (ws) => ws === null ? Effect36.fail(new WebSocketError({ url: WS_URL, cause: new Error("WebSocket not connected") })) : Effect36.succeed(ws)
2831
2756
  )
@@ -2833,8 +2758,6 @@ var WebSocketLive = Layer5.effect(
2833
2758
  const sendJson = (ws, type, payload) => Effect36.sync(() => ws.send(JSON.stringify({ type, payload })));
2834
2759
  const ctx = {
2835
2760
  portalsRef,
2836
- syncedArtifactsRef,
2837
- currentSyncPortalRef,
2838
2761
  pendingArtifactRef,
2839
2762
  pendingPluginRequestsRef,
2840
2763
  pendingPlanCreateRef,
@@ -2875,13 +2798,13 @@ var WebSocketLive = Layer5.effect(
2875
2798
  });
2876
2799
  const wireCloseHandler = (ws) => Effect36.sync(() => {
2877
2800
  ws.onclose = () => {
2878
- const pending = Effect36.runSync(Ref15.getAndSet(pendingArtifactRef, null));
2801
+ const pending = Effect36.runSync(Ref14.getAndSet(pendingArtifactRef, null));
2879
2802
  if (pending) {
2880
2803
  pending.reject(new WebSocketError({ url: WS_URL, cause: new Error("WebSocket closed") }));
2881
2804
  }
2882
2805
  pipe30(
2883
- Ref15.set(stateRef, "disconnected"),
2884
- Effect36.flatMap(() => Ref15.set(wsRef, null)),
2806
+ Ref14.set(stateRef, "disconnected"),
2807
+ Effect36.flatMap(() => Ref14.set(wsRef, null)),
2885
2808
  Effect36.flatMap(() => scheduleReconnect()),
2886
2809
  Effect36.runFork
2887
2810
  );
@@ -2889,7 +2812,7 @@ var WebSocketLive = Layer5.effect(
2889
2812
  });
2890
2813
  const connectInternal = () => pipe30(
2891
2814
  Effect36.sync(() => new WebSocket(WS_URL)),
2892
- Effect36.tap((ws) => Ref15.set(wsRef, ws)),
2815
+ Effect36.tap((ws) => Ref14.set(wsRef, ws)),
2893
2816
  Effect36.tap((ws) => wireMessageHandler(ws)),
2894
2817
  Effect36.tap((ws) => wireCloseHandler(ws)),
2895
2818
  Effect36.flatMap(
@@ -2897,8 +2820,8 @@ var WebSocketLive = Layer5.effect(
2897
2820
  ws.onopen = () => {
2898
2821
  console.error(`[ws] Connected to ${WS_URL}, sending register`);
2899
2822
  pipe30(
2900
- Ref15.set(stateRef, "connected"),
2901
- Effect36.flatMap(() => Ref15.set(isReconnectingRef, false)),
2823
+ Ref14.set(stateRef, "connected"),
2824
+ Effect36.flatMap(() => Ref14.set(isReconnectingRef, false)),
2902
2825
  Effect36.flatMap(() => sendJson(ws, "register", { license_key: LICENSE_KEY })),
2903
2826
  Effect36.runFork
2904
2827
  );
@@ -2911,7 +2834,7 @@ var WebSocketLive = Layer5.effect(
2911
2834
  )
2912
2835
  );
2913
2836
  const scheduleReconnect = () => pipe30(
2914
- Ref15.getAndSet(isReconnectingRef, true),
2837
+ Ref14.getAndSet(isReconnectingRef, true),
2915
2838
  Effect36.flatMap(
2916
2839
  (wasAlready) => wasAlready ? Effect36.void : pipe30(
2917
2840
  Effect36.sync(() => console.error(`[ws] Scheduling reconnect to ${WS_URL}`)),
@@ -2926,21 +2849,19 @@ var WebSocketLive = Layer5.effect(
2926
2849
  )
2927
2850
  );
2928
2851
  const connect = () => pipe30(
2929
- Ref15.getAndSet(isReconnectingRef, true),
2852
+ Ref14.getAndSet(isReconnectingRef, true),
2930
2853
  Effect36.flatMap(
2931
2854
  (wasAlready) => wasAlready ? Effect36.void : pipe30(connectInternal(), Effect36.retry(Schedule.fixed(Duration.seconds(3))))
2932
2855
  )
2933
2856
  );
2934
2857
  const send = (data) => pipe30(requireWs, Effect36.flatMap((ws) => Effect36.sync(() => ws.send(JSON.stringify(data)))));
2935
2858
  const sendSyncFull = (portalId) => pipe30(
2936
- Ref15.set(currentSyncPortalRef, portalId),
2937
- Effect36.flatMap(() => requireWs),
2859
+ requireWs,
2938
2860
  Effect36.tap(() => Effect36.sync(() => console.error(`[sync:full] Sending request for portal ${portalId}`))),
2939
2861
  Effect36.flatMap((ws) => sendJson(ws, "sync:full", { target_portal: portalId }))
2940
2862
  );
2941
2863
  const sendSyncDiff = (portalId, lastSyncTimes) => pipe30(
2942
- Ref15.set(currentSyncPortalRef, portalId),
2943
- Effect36.flatMap(() => requireWs),
2864
+ requireWs,
2944
2865
  Effect36.tap(() => Effect36.sync(() => console.error(`[sync:diff] Sending request for portal ${portalId}`))),
2945
2866
  Effect36.flatMap((ws) => sendJson(ws, "sync:diff", { target_portal: portalId, last_sync_times: lastSyncTimes }))
2946
2867
  );
@@ -2949,17 +2870,21 @@ var WebSocketLive = Layer5.effect(
2949
2870
  Effect36.tap(() => Effect36.sync(() => console.error(`[sync:subscribe] Sending request for portal ${portalId}`))),
2950
2871
  Effect36.flatMap((ws) => sendJson(ws, "sync:subscribe", { target_portal: portalId }))
2951
2872
  );
2873
+ const sendSyncUnsubscribe = (portalId) => pipe30(
2874
+ requireWs,
2875
+ Effect36.tap(() => Effect36.sync(() => console.error(`[sync:unsubscribe] Sending request for portal ${portalId}`))),
2876
+ Effect36.flatMap((ws) => sendJson(ws, "sync:unsubscribe", { target_portal: portalId }))
2877
+ );
2952
2878
  return {
2953
2879
  connect,
2954
2880
  send,
2955
2881
  sendSyncFull,
2956
2882
  sendSyncDiff,
2957
2883
  sendSyncSubscribe,
2958
- getState: () => Ref15.get(stateRef),
2959
- getPortals: () => Ref15.get(portalsRef),
2960
- setPortals: (portals) => Ref15.set(portalsRef, portals),
2961
- getSyncedArtifacts: () => Ref15.get(syncedArtifactsRef),
2962
- setSyncedArtifacts: (syncedArtifacts) => Ref15.set(syncedArtifactsRef, syncedArtifacts),
2884
+ sendSyncUnsubscribe,
2885
+ getState: () => Ref14.get(stateRef),
2886
+ getPortals: () => Ref14.get(portalsRef),
2887
+ setPortals: (portals) => Ref14.set(portalsRef, portals),
2963
2888
  requestArtifactUrl: (artifactId) => pipe30(
2964
2889
  requireWs,
2965
2890
  Effect36.flatMap(
@@ -2968,7 +2893,7 @@ var WebSocketLive = Layer5.effect(
2968
2893
  resolve: (payload) => resume(Effect36.succeed(payload)),
2969
2894
  reject: (error) => resume(Effect36.fail(error))
2970
2895
  };
2971
- Effect36.runSync(Ref15.set(pendingArtifactRef, pending));
2896
+ Effect36.runSync(Ref14.set(pendingArtifactRef, pending));
2972
2897
  ws.send(JSON.stringify({ type: "sync:artifact", payload: { artifact_id: artifactId } }));
2973
2898
  })
2974
2899
  ),
@@ -2987,10 +2912,10 @@ var WebSocketLive = Layer5.effect(
2987
2912
  resolve: (payload) => resume(Effect36.succeed(payload)),
2988
2913
  reject: (error) => resume(Effect36.fail(error))
2989
2914
  };
2990
- const pendingMap = Effect36.runSync(Ref15.get(pendingPluginRequestsRef));
2915
+ const pendingMap = Effect36.runSync(Ref14.get(pendingPluginRequestsRef));
2991
2916
  const next = new Map(pendingMap);
2992
2917
  next.set(requestId, pending);
2993
- Effect36.runSync(Ref15.set(pendingPluginRequestsRef, next));
2918
+ Effect36.runSync(Ref14.set(pendingPluginRequestsRef, next));
2994
2919
  ws.send(JSON.stringify({
2995
2920
  type: "sync:plugin-data",
2996
2921
  payload: {
@@ -3008,7 +2933,7 @@ var WebSocketLive = Layer5.effect(
3008
2933
  })
3009
2934
  }),
3010
2935
  Effect36.tapError(
3011
- () => Ref15.update(pendingPluginRequestsRef, (pendingMap) => {
2936
+ () => Ref14.update(pendingPluginRequestsRef, (pendingMap) => {
3012
2937
  const next = new Map(pendingMap);
3013
2938
  next.delete(requestId);
3014
2939
  return next;
@@ -3025,7 +2950,7 @@ var WebSocketLive = Layer5.effect(
3025
2950
  resolve: (payload) => resume(Effect36.succeed(payload)),
3026
2951
  reject: (error) => resume(Effect36.fail(error))
3027
2952
  };
3028
- Effect36.runSync(Ref15.set(pendingPlanCreateRef, pending));
2953
+ Effect36.runSync(Ref14.set(pendingPlanCreateRef, pending));
3029
2954
  ws.send(JSON.stringify({ type: "plan:create", payload: { target_portal: portalId, ...plan.dry_run ? { dry_run: true } : {}, plan } }));
3030
2955
  })
3031
2956
  ),
@@ -3042,7 +2967,7 @@ var WebSocketLive = Layer5.effect(
3042
2967
  resolve: (payload) => resume(Effect36.succeed(payload)),
3043
2968
  reject: (error) => resume(Effect36.fail(error))
3044
2969
  };
3045
- Effect36.runSync(Ref15.set(pendingPlanListRef, pending));
2970
+ Effect36.runSync(Ref14.set(pendingPlanListRef, pending));
3046
2971
  ws.send(JSON.stringify({ type: "plan:list", payload: { target_portal: portalId, ...status && { status } } }));
3047
2972
  })
3048
2973
  ),
@@ -3051,89 +2976,33 @@ var WebSocketLive = Layer5.effect(
3051
2976
  onTimeout: () => new WebSocketError({ url: WS_URL, cause: new Error("Plan list request timed out") })
3052
2977
  })
3053
2978
  ),
3054
- registerHandlers: (handlers) => Ref15.set(handlersRef, handlers),
3055
- getEncryptionKey: () => Ref15.get(encryptionKeyRef)
2979
+ registerHandlers: (handlers) => Ref14.set(handlersRef, handlers),
2980
+ getEncryptionKey: () => Ref14.get(encryptionKeyRef)
3056
2981
  };
3057
2982
  })
3058
2983
  );
3059
2984
 
3060
2985
  // src/tools/portal_selection.ts
3061
2986
  import { z as z12 } from "zod";
3062
- import { Effect as Effect38, pipe as pipe32 } from "effect";
3063
-
3064
- // src/effects/read-connection.ts
3065
2987
  import { Effect as Effect37, pipe as pipe31 } from "effect";
3066
- import { DuckDBInstance as DuckDBInstance2 } from "@duckdb/node-api";
3067
- import fs6 from "fs";
3068
- var handles = /* @__PURE__ */ new Map();
3069
- var getReadConnection = (portalId, encryptionKey) => pipe31(
3070
- Effect37.sync(() => handles.get(portalId)),
3071
- Effect37.flatMap(
3072
- (existing) => existing ? Effect37.succeed(existing.conn) : pipe31(
3073
- Effect37.sync(() => dbPath(portalId)),
3074
- Effect37.filterOrFail(
3075
- (dbFile) => fs6.existsSync(dbFile),
3076
- () => new DatabaseError({
3077
- message: `Database not found for portal ${portalId}. Has it been synced?`
3078
- })
3079
- ),
3080
- Effect37.flatMap(
3081
- (dbFile) => Effect37.tryPromise({
3082
- try: async () => {
3083
- const instance = await DuckDBInstance2.create(":memory:");
3084
- const conn = await instance.connect();
3085
- const escapedPath = dbFile.replace(/'/g, "''");
3086
- if (encryptionKey) {
3087
- const escapedKey = encryptionKey.replace(/'/g, "''");
3088
- await conn.run(`ATTACH '${escapedPath}' AS portal_db (ENCRYPTION_KEY '${escapedKey}', READ_ONLY)`);
3089
- } else {
3090
- await conn.run(`ATTACH '${escapedPath}' AS portal_db (READ_ONLY)`);
3091
- }
3092
- await conn.run(`USE portal_db`);
3093
- handles.set(portalId, { conn, instance });
3094
- return conn;
3095
- },
3096
- catch: (e) => new DatabaseError({
3097
- message: `Failed to open read connection for portal ${portalId}`,
3098
- cause: e
3099
- })
3100
- })
3101
- )
3102
- )
3103
- )
3104
- );
3105
- var closeAllReadConnections = () => Effect37.sync(() => {
3106
- for (const [portalId] of handles) {
3107
- const handle = handles.get(portalId);
3108
- if (!handle) continue;
3109
- try {
3110
- handle.conn.closeSync();
3111
- handle.instance.closeSync();
3112
- } catch {
3113
- }
3114
- handles.delete(portalId);
3115
- }
3116
- });
3117
-
3118
- // src/tools/portal_selection.ts
3119
2988
  function getSelectedPortalId(config) {
3120
- return Effect38.runSync(
3121
- config.load().pipe(Effect38.map((state) => state.selectedPortalId))
2989
+ return Effect37.runSync(
2990
+ config.load().pipe(Effect37.map((state) => state.selectedPortalId))
3122
2991
  );
3123
2992
  }
3124
2993
  function setSelectedPortalId(config, portalId) {
3125
- Effect38.runSync(config.save({ selectedPortalId: portalId }));
2994
+ Effect37.runSync(config.save({ selectedPortalId: portalId }));
3126
2995
  }
3127
2996
  function registerPortalSelection(server2, deps) {
3128
- const { ws, config } = deps;
2997
+ const { ws, config, handoffSelectedPortal } = deps;
3129
2998
  server2.registerPrompt(
3130
2999
  "select-portal",
3131
3000
  {
3132
3001
  description: "Show available HubSpot portals and select one to work with"
3133
3002
  },
3134
3003
  async () => {
3135
- const targetPortals = Effect38.runSync(ws.getPortals());
3136
- const selectedPortalId = getSelectedPortalId(config);
3004
+ const targetPortals = Effect37.runSync(ws.getPortals());
3005
+ const selectedPortalId2 = getSelectedPortalId(config);
3137
3006
  if (targetPortals.length === 0) {
3138
3007
  return {
3139
3008
  messages: [{
@@ -3142,7 +3011,7 @@ function registerPortalSelection(server2, deps) {
3142
3011
  }]
3143
3012
  };
3144
3013
  }
3145
- const currentSelection = selectedPortalId ? targetPortals.find((p) => p.target_portal === selectedPortalId) : null;
3014
+ const currentSelection = selectedPortalId2 ? targetPortals.find((p) => p.target_portal === selectedPortalId2) : null;
3146
3015
  const portalList = targetPortals.map((p) => `- **${p.target_name ?? "Unnamed"}** (ID: ${p.target_portal})`).join("\n");
3147
3016
  const statusText = currentSelection ? `Currently selected: **${currentSelection.target_name ?? "Unnamed"}** (${currentSelection.target_portal})` : "No portal currently selected.";
3148
3017
  return {
@@ -3165,13 +3034,15 @@ Which portal would you like to use? Use the \`set_portal\` tool to select one.`
3165
3034
  server2.registerTool(
3166
3035
  "set_portal",
3167
3036
  {
3168
- description: "Select which HubSpot portal (account) to work with. This must be set before running any queries or creating plans.",
3037
+ description: `Select which HubSpot portal (account) to work with. Sets the default portal used by tools when portalIds is not explicitly provided. This also triggers a full artifact sync and real-time subscription for the selected portal.
3038
+
3039
+ Other tools accept an optional portalIds parameter to target specific portals regardless of this selection. All registered portals are kept warm with background data syncing.`,
3169
3040
  inputSchema: {
3170
3041
  portalId: z12.number().describe("The portal ID to select")
3171
3042
  }
3172
3043
  },
3173
3044
  async ({ portalId }) => {
3174
- const targetPortals = Effect38.runSync(ws.getPortals());
3045
+ const targetPortals = Effect37.runSync(ws.getPortals());
3175
3046
  const portal = targetPortals.find((p) => p.target_portal === portalId);
3176
3047
  if (!portal) {
3177
3048
  const available = targetPortals.map((p) => p.target_portal).join(", ");
@@ -3183,9 +3054,12 @@ Which portal would you like to use? Use the \`set_portal\` tool to select one.`
3183
3054
  isError: true
3184
3055
  };
3185
3056
  }
3186
- Effect38.runSync(closeAllReadConnections());
3187
- setSelectedPortalId(config, portalId);
3188
- Effect38.runFork(pipe32(ws.sendSyncFull(portalId), Effect38.catchAll(() => Effect38.void)));
3057
+ if (handoffSelectedPortal) {
3058
+ handoffSelectedPortal(portalId);
3059
+ } else {
3060
+ setSelectedPortalId(config, portalId);
3061
+ Effect37.runFork(pipe31(ws.sendSyncFull(portalId), Effect37.catchAll(() => Effect37.void)));
3062
+ }
3189
3063
  return {
3190
3064
  content: [{
3191
3065
  type: "text",
@@ -3208,8 +3082,139 @@ function autoSelectSinglePortal(config, portals) {
3208
3082
  // src/tools/query.ts
3209
3083
  import { z as z13 } from "zod";
3210
3084
  import { Effect as Effect39 } from "effect";
3085
+
3086
+ // src/effects/read-connection.ts
3087
+ import { Effect as Effect38, pipe as pipe32 } from "effect";
3088
+ import { DuckDBInstance as DuckDBInstance2 } from "@duckdb/node-api";
3089
+ import fs6 from "fs";
3090
+ var handles = /* @__PURE__ */ new Map();
3091
+ var closeHandle = (portalId) => {
3092
+ const handle = handles.get(portalId);
3093
+ if (!handle) return;
3094
+ try {
3095
+ handle.conn.closeSync();
3096
+ handle.instance.closeSync();
3097
+ } catch {
3098
+ }
3099
+ handles.delete(portalId);
3100
+ };
3101
+ var getFreshnessToken = (portalId, dbFile) => Effect38.try({
3102
+ try: () => {
3103
+ const stat = fs6.statSync(dbFile);
3104
+ return `${stat.mtimeMs}:${stat.size}`;
3105
+ },
3106
+ catch: (e) => new DatabaseError({
3107
+ message: `Failed to read database freshness for portal ${portalId}`,
3108
+ cause: e
3109
+ })
3110
+ });
3111
+ var openReadHandle = (portalId, dbFile, encryptionKey, freshnessToken) => Effect38.tryPromise({
3112
+ try: async () => {
3113
+ const instance = await DuckDBInstance2.create(":memory:");
3114
+ const conn = await instance.connect();
3115
+ const escapedPath = dbFile.replace(/'/g, "''");
3116
+ if (encryptionKey) {
3117
+ const escapedKey = encryptionKey.replace(/'/g, "''");
3118
+ await conn.run(`ATTACH '${escapedPath}' AS portal_db (ENCRYPTION_KEY '${escapedKey}', READ_ONLY)`);
3119
+ } else {
3120
+ await conn.run(`ATTACH '${escapedPath}' AS portal_db (READ_ONLY)`);
3121
+ }
3122
+ await conn.run("USE portal_db");
3123
+ handles.set(portalId, { conn, instance, freshnessToken });
3124
+ return conn;
3125
+ },
3126
+ catch: (e) => new DatabaseError({
3127
+ message: `Failed to open read connection for portal ${portalId}`,
3128
+ cause: e
3129
+ })
3130
+ });
3131
+ var getReadConnection = (portalId, encryptionKey) => pipe32(
3132
+ Effect38.Do,
3133
+ Effect38.let("dbFile", () => dbPath(portalId)),
3134
+ Effect38.filterOrFail(
3135
+ ({ dbFile }) => fs6.existsSync(dbFile),
3136
+ () => new DatabaseError({
3137
+ message: `Database not found for portal ${portalId}. Has it been synced?`
3138
+ })
3139
+ ),
3140
+ Effect38.bind("freshnessToken", ({ dbFile }) => getFreshnessToken(portalId, dbFile)),
3141
+ Effect38.tap(
3142
+ ({ freshnessToken }) => Effect38.sync(() => {
3143
+ const existing = handles.get(portalId);
3144
+ if (!existing || existing.freshnessToken === freshnessToken) return;
3145
+ closeHandle(portalId);
3146
+ })
3147
+ ),
3148
+ Effect38.flatMap(
3149
+ ({ dbFile, freshnessToken }) => pipe32(
3150
+ Effect38.sync(() => handles.get(portalId)),
3151
+ Effect38.flatMap(
3152
+ (existing) => existing ? Effect38.succeed(existing.conn) : openReadHandle(portalId, dbFile, encryptionKey, freshnessToken)
3153
+ )
3154
+ )
3155
+ )
3156
+ );
3157
+
3158
+ // src/pure/resolve-portal-ids.ts
3159
+ var dedupePortalIds = (portalIds) => {
3160
+ const seen = /* @__PURE__ */ new Set();
3161
+ const deduped = [];
3162
+ for (const portalId of portalIds) {
3163
+ if (seen.has(portalId)) continue;
3164
+ seen.add(portalId);
3165
+ deduped.push(portalId);
3166
+ }
3167
+ return deduped;
3168
+ };
3169
+ var resolvePortalIds = (explicitPortalIds, selectedPortalId2, portals) => {
3170
+ if (explicitPortalIds !== void 0) {
3171
+ if (explicitPortalIds.length === 0) {
3172
+ return { error: "portalIds must include at least one portal ID." };
3173
+ }
3174
+ const deduped = dedupePortalIds(explicitPortalIds);
3175
+ const availableIds = new Set(portals.map((portal) => portal.target_portal));
3176
+ const invalidPortalIds = deduped.filter((portalId) => !availableIds.has(portalId));
3177
+ if (invalidPortalIds.length > 0) {
3178
+ return {
3179
+ error: `Portal ID(s) not available: ${invalidPortalIds.join(", ")}`
3180
+ };
3181
+ }
3182
+ return {
3183
+ portalIds: deduped,
3184
+ explicit: true
3185
+ };
3186
+ }
3187
+ if (selectedPortalId2 === null) {
3188
+ return {
3189
+ error: "No portal selected. Use the set_portal tool first or pass portalIds."
3190
+ };
3191
+ }
3192
+ return {
3193
+ portalIds: [selectedPortalId2],
3194
+ explicit: false
3195
+ };
3196
+ };
3197
+
3198
+ // src/tools/query.ts
3211
3199
  var MAX_ROWS = 200;
3212
3200
  var hasLimitClause = (sql) => /\bLIMIT\s+\d+/i.test(sql);
3201
+ var runQueryForPortal = async (portalId, trimmedSql, finalSql, getEncryptionKey) => {
3202
+ const conn = await Effect39.runPromise(getReadConnection(portalId, getEncryptionKey()));
3203
+ const start = performance.now();
3204
+ const reader = await conn.runAndReadAll(finalSql);
3205
+ const rows = reader.getRowObjects();
3206
+ const elapsed = (performance.now() - start).toFixed(1);
3207
+ return {
3208
+ rows,
3209
+ rowCount: rows.length,
3210
+ executionTimeMs: elapsed,
3211
+ ...rows.length >= MAX_ROWS && !hasLimitClause(trimmedSql) ? {
3212
+ truncated: true,
3213
+ note: `Results capped at ${MAX_ROWS} rows. Add your own LIMIT clause for different limits.`
3214
+ } : {}
3215
+ };
3216
+ };
3217
+ var stringifyResult = (value) => JSON.stringify(value, (_, inner) => typeof inner === "bigint" ? Number(inner) : inner, 2);
3213
3218
  function registerQueryTool(server2, deps) {
3214
3219
  const schemaDocs = [
3215
3220
  '- The "sync_metadata" table has: key TEXT, value TEXT',
@@ -3230,6 +3235,10 @@ Use this to answer questions about HubSpot data such as:
3230
3235
 
3231
3236
  Only SELECT statements are allowed. Results are capped at ${MAX_ROWS} rows.
3232
3237
 
3238
+ Optional: pass portalIds to run the same query across one or more specific portals. When portalIds is provided, the result is always a JSON object keyed by portal ID, where each value is the query result or an error.
3239
+
3240
+ Data freshness: each portal's data is automatically checked for freshness before querying. If new artifacts have been synced since the last query, they are ingested first. Results reflect the latest synced state.
3241
+
3233
3242
  IMPORTANT: Always use the 'status' tool with section "schema" first to discover available object types and property names before writing queries. Do not guess property names.
3234
3243
 
3235
3244
  The database schema:
@@ -3240,14 +3249,15 @@ Example queries:
3240
3249
  - SELECT from_object_type, to_object_type, COUNT(*) as cnt FROM associations GROUP BY 1, 2 ORDER BY cnt DESC
3241
3250
  - SELECT id, json_extract_string(properties, '$.dealname') as name, json_extract_string(properties, '$.amount') as amount FROM deals WHERE json_extract_string(properties, '$.dealstage') = 'closedwon' LIMIT 20`,
3242
3251
  inputSchema: {
3243
- sql: z13.string().describe("A SELECT SQL query to run against the DuckDB database")
3252
+ sql: z13.string().describe("A SELECT SQL query to run against the DuckDB database"),
3253
+ portalIds: z13.array(z13.number()).optional().describe("Optional explicit portal IDs to query. If provided, results are keyed by portal ID.")
3244
3254
  }
3245
3255
  },
3246
- async ({ sql }) => {
3247
- const portalId = deps.getSelectedPortalId();
3248
- if (portalId === null) {
3256
+ async ({ sql, portalIds }) => {
3257
+ const portalResolution = resolvePortalIds(portalIds, deps.getSelectedPortalId(), deps.getPortals());
3258
+ if ("error" in portalResolution) {
3249
3259
  return {
3250
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
3260
+ content: [{ type: "text", text: portalResolution.error }],
3251
3261
  isError: true
3252
3262
  };
3253
3263
  }
@@ -3259,28 +3269,48 @@ Example queries:
3259
3269
  };
3260
3270
  }
3261
3271
  const finalSql = hasLimitClause(trimmed) ? trimmed : `${trimmed} LIMIT ${MAX_ROWS}`;
3262
- try {
3263
- const conn = await Effect39.runPromise(getReadConnection(portalId, deps.getEncryptionKey()));
3264
- const start = performance.now();
3265
- const reader = await conn.runAndReadAll(finalSql);
3266
- const rows = reader.getRowObjects();
3267
- const elapsed = (performance.now() - start).toFixed(1);
3268
- const result = {
3269
- rows,
3270
- rowCount: rows.length,
3271
- executionTimeMs: elapsed,
3272
- ...rows.length >= MAX_ROWS && !hasLimitClause(trimmed) ? { truncated: true, note: `Results capped at ${MAX_ROWS} rows. Add your own LIMIT clause for different limits.` } : {}
3273
- };
3274
- return {
3275
- content: [{ type: "text", text: JSON.stringify(result, (_, v) => typeof v === "bigint" ? Number(v) : v, 2) }]
3276
- };
3277
- } catch (e) {
3278
- const message = e instanceof Error ? e.message : String(e);
3279
- return {
3280
- content: [{ type: "text", text: `Query error: ${message}` }],
3281
- isError: true
3282
- };
3272
+ if (!portalResolution.explicit) {
3273
+ const [portalId] = portalResolution.portalIds;
3274
+ try {
3275
+ await deps.ensureFresh(portalId);
3276
+ const result = await runQueryForPortal(
3277
+ portalId,
3278
+ trimmed,
3279
+ finalSql,
3280
+ deps.getEncryptionKey
3281
+ );
3282
+ return {
3283
+ content: [{ type: "text", text: stringifyResult(result) }]
3284
+ };
3285
+ } catch (e) {
3286
+ const message = e instanceof Error ? e.message : String(e);
3287
+ return {
3288
+ content: [{ type: "text", text: `Query error: ${message}` }],
3289
+ isError: true
3290
+ };
3291
+ }
3292
+ }
3293
+ const keyedResults = {};
3294
+ let hadAnyErrors = false;
3295
+ for (const portalId of portalResolution.portalIds) {
3296
+ try {
3297
+ await deps.ensureFresh(portalId);
3298
+ keyedResults[String(portalId)] = await runQueryForPortal(
3299
+ portalId,
3300
+ trimmed,
3301
+ finalSql,
3302
+ deps.getEncryptionKey
3303
+ );
3304
+ } catch (e) {
3305
+ hadAnyErrors = true;
3306
+ const message = e instanceof Error ? e.message : String(e);
3307
+ keyedResults[String(portalId)] = { error: message };
3308
+ }
3283
3309
  }
3310
+ return {
3311
+ content: [{ type: "text", text: stringifyResult(keyedResults) }],
3312
+ ...hadAnyErrors ? { isError: true } : {}
3313
+ };
3284
3314
  }
3285
3315
  );
3286
3316
  }
@@ -3327,8 +3357,8 @@ function mapQueryResultsToChartData(rows, columns) {
3327
3357
  }));
3328
3358
  return { labels, datasets };
3329
3359
  }
3330
- function generateChartHtml(options) {
3331
- const { chartType, data, title, sql } = options;
3360
+ function generateChartHtml(options2) {
3361
+ const { chartType, data, title, sql } = options2;
3332
3362
  const datasets = data.datasets.map((ds, i) => ({
3333
3363
  label: ds.label,
3334
3364
  data: ds.data,
@@ -3490,7 +3520,64 @@ function openInBrowser(target) {
3490
3520
  // src/tools/chart.ts
3491
3521
  var MAX_ROWS2 = 200;
3492
3522
  var hasLimitClause2 = (sql) => /\bLIMIT\s+\d+/i.test(sql);
3523
+ var runQueryForPortal2 = async (portalId, sql, getEncryptionKey) => {
3524
+ const conn = await Effect40.runPromise(getReadConnection(portalId, getEncryptionKey()));
3525
+ const start = performance.now();
3526
+ const reader = await conn.runAndReadAll(sql);
3527
+ const rows = reader.getRowObjects();
3528
+ const elapsed = (performance.now() - start).toFixed(1);
3529
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
3530
+ return {
3531
+ portalId,
3532
+ rows,
3533
+ columns,
3534
+ executionTimeMs: elapsed
3535
+ };
3536
+ };
3537
+ var buildMergedPortalChartData = (portalRows) => {
3538
+ const allLabels = [];
3539
+ const seenLabels = /* @__PURE__ */ new Set();
3540
+ for (const entry of portalRows) {
3541
+ const labelColumn = entry.columns[0];
3542
+ for (const row of entry.rows) {
3543
+ const label = String(row[labelColumn] ?? "");
3544
+ if (seenLabels.has(label)) continue;
3545
+ seenLabels.add(label);
3546
+ allLabels.push(label);
3547
+ }
3548
+ }
3549
+ const datasets = [];
3550
+ for (const entry of portalRows) {
3551
+ const labelColumn = entry.columns[0];
3552
+ const dataColumns = entry.columns.slice(1);
3553
+ for (const dataColumn of dataColumns) {
3554
+ const valuesByLabel = /* @__PURE__ */ new Map();
3555
+ for (const row of entry.rows) {
3556
+ const label = String(row[labelColumn] ?? "");
3557
+ const value = Number(row[dataColumn]);
3558
+ valuesByLabel.set(label, Number.isFinite(value) ? value : 0);
3559
+ }
3560
+ datasets.push({
3561
+ label: `Portal ${entry.portalId} - ${dataColumn}`,
3562
+ data: allLabels.map((label) => valuesByLabel.get(label) ?? 0)
3563
+ });
3564
+ }
3565
+ }
3566
+ return {
3567
+ labels: allLabels,
3568
+ datasets
3569
+ };
3570
+ };
3571
+ var writeChartFile = (chartType, html) => {
3572
+ mkdirSync4(CHARTS_DIR(), { recursive: true });
3573
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3574
+ const filename = `${timestamp}-${chartType}.html`;
3575
+ const filePath = chartFilePath(filename);
3576
+ writeFileSync4(filePath, html, "utf-8");
3577
+ return filePath;
3578
+ };
3493
3579
  function registerChartTool(server2, deps) {
3580
+ const openChartFile = deps.openChartFile ?? openInBrowser;
3494
3581
  server2.registerTool(
3495
3582
  "chart",
3496
3583
  {
@@ -3504,6 +3591,10 @@ Use this when the user wants to visualize HubSpot data, such as:
3504
3591
 
3505
3592
  Only SELECT statements are allowed. Results are capped at ${MAX_ROWS2} rows.
3506
3593
 
3594
+ Optional: pass portalIds to run the same chart query across one or more specific portals. When portalIds is provided, results include a JSON object keyed by portal ID with per-portal summaries, and chart datasets are labeled by portal.
3595
+
3596
+ Data freshness: each portal's data is automatically checked for freshness before charting. Results reflect the latest synced state.
3597
+
3507
3598
  The query results map to the chart as follows:
3508
3599
  - First column \u2192 X-axis labels (categories)
3509
3600
  - Remaining columns \u2192 one dataset per column (Y-axis values, must be numeric)
@@ -3519,14 +3610,15 @@ SELECT json_extract_string(properties, '$.dealstage') as stage, COUNT(*) as coun
3519
3610
  inputSchema: {
3520
3611
  sql: z14.string().describe("A SELECT SQL query. First column = labels, remaining columns = numeric datasets."),
3521
3612
  chart_type: z14.enum(["bar", "line"]).describe("The chart type to render."),
3522
- title: z14.string().optional().describe("Chart title. Auto-generated from chart type if omitted.")
3613
+ title: z14.string().optional().describe("Chart title. Auto-generated from chart type if omitted."),
3614
+ portalIds: z14.array(z14.number()).optional().describe("Optional explicit portal IDs to chart. If provided, output includes keyed portal results.")
3523
3615
  }
3524
3616
  },
3525
- async ({ sql, chart_type, title }) => {
3526
- const portalId = deps.getSelectedPortalId();
3527
- if (portalId === null) {
3617
+ async ({ sql, chart_type, title, portalIds }) => {
3618
+ const portalResolution = resolvePortalIds(portalIds, deps.getSelectedPortalId(), deps.getPortals());
3619
+ if ("error" in portalResolution) {
3528
3620
  return {
3529
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
3621
+ content: [{ type: "text", text: portalResolution.error }],
3530
3622
  isError: true
3531
3623
  };
3532
3624
  }
@@ -3538,55 +3630,120 @@ SELECT json_extract_string(properties, '$.dealstage') as stage, COUNT(*) as coun
3538
3630
  };
3539
3631
  }
3540
3632
  const finalSql = hasLimitClause2(trimmed) ? trimmed : `${trimmed} LIMIT ${MAX_ROWS2}`;
3541
- try {
3542
- const conn = await Effect40.runPromise(getReadConnection(portalId, deps.getEncryptionKey()));
3543
- const start = performance.now();
3544
- const reader = await conn.runAndReadAll(finalSql);
3545
- const rows = reader.getRowObjects();
3546
- const elapsed = (performance.now() - start).toFixed(1);
3547
- if (rows.length === 0) {
3633
+ if (!portalResolution.explicit) {
3634
+ const [portalId] = portalResolution.portalIds;
3635
+ try {
3636
+ await deps.ensureFresh(portalId);
3637
+ const result = await runQueryForPortal2(portalId, finalSql, deps.getEncryptionKey);
3638
+ if (result.rows.length === 0) {
3639
+ return {
3640
+ content: [{ type: "text", text: "Query returned no rows - nothing to chart." }],
3641
+ isError: true
3642
+ };
3643
+ }
3644
+ if (result.columns.length < 2) {
3645
+ return {
3646
+ content: [{
3647
+ type: "text",
3648
+ text: "Chart requires at least 2 columns: one for labels (x-axis) and one for values (y-axis). Your query returned only 1 column."
3649
+ }],
3650
+ isError: true
3651
+ };
3652
+ }
3653
+ const chartData = mapQueryResultsToChartData(result.rows, result.columns);
3654
+ const chartTitle = title ?? `${chart_type === "bar" ? "Bar" : "Line"} Chart - ${result.columns.slice(1).join(", ")} by ${result.columns[0]}`;
3655
+ const html = generateChartHtml({
3656
+ chartType: chart_type,
3657
+ data: chartData,
3658
+ title: chartTitle,
3659
+ sql: trimmed
3660
+ });
3661
+ const filePath = writeChartFile(chart_type, html);
3662
+ openChartFile(filePath);
3663
+ const dataColumns = result.columns.slice(1);
3664
+ const summary = [
3665
+ `Chart generated and opened in browser.`,
3666
+ ``,
3667
+ `Type: ${chart_type}`,
3668
+ `Title: ${chartTitle}`,
3669
+ `Labels (x-axis): ${result.columns[0]} (${result.rows.length} values)`,
3670
+ `Datasets: ${dataColumns.join(", ")}`,
3671
+ `Query time: ${result.executionTimeMs}ms`,
3672
+ `File: ${filePath}`
3673
+ ].join("\n");
3548
3674
  return {
3549
- content: [{ type: "text", text: "Query returned no rows \u2014 nothing to chart." }],
3550
- isError: true
3675
+ content: [{ type: "text", text: summary }]
3551
3676
  };
3552
- }
3553
- const columns = Object.keys(rows[0]);
3554
- if (columns.length < 2) {
3677
+ } catch (e) {
3678
+ const message = e instanceof Error ? e.message : String(e);
3555
3679
  return {
3556
- content: [{
3557
- type: "text",
3558
- text: "Chart requires at least 2 columns: one for labels (x-axis) and one for values (y-axis). Your query returned only 1 column."
3559
- }],
3680
+ content: [{ type: "text", text: `Chart error: ${message}` }],
3560
3681
  isError: true
3561
3682
  };
3562
3683
  }
3563
- const chartData = mapQueryResultsToChartData(rows, columns);
3564
- const chartTitle = title ?? `${chart_type === "bar" ? "Bar" : "Line"} Chart \u2014 ${columns.slice(1).join(", ")} by ${columns[0]}`;
3684
+ }
3685
+ const keyedPortalResults = {};
3686
+ const successfulPortals = [];
3687
+ let hadErrors = false;
3688
+ for (const portalId of portalResolution.portalIds) {
3689
+ try {
3690
+ await deps.ensureFresh(portalId);
3691
+ const result = await runQueryForPortal2(portalId, finalSql, deps.getEncryptionKey);
3692
+ if (result.rows.length === 0) {
3693
+ hadErrors = true;
3694
+ keyedPortalResults[String(portalId)] = { error: "Query returned no rows - nothing to chart." };
3695
+ continue;
3696
+ }
3697
+ if (result.columns.length < 2) {
3698
+ hadErrors = true;
3699
+ keyedPortalResults[String(portalId)] = {
3700
+ error: "Chart requires at least 2 columns: one for labels (x-axis) and one for values (y-axis)."
3701
+ };
3702
+ continue;
3703
+ }
3704
+ successfulPortals.push(result);
3705
+ keyedPortalResults[String(portalId)] = {
3706
+ rowCount: result.rows.length,
3707
+ executionTimeMs: result.executionTimeMs,
3708
+ labelColumn: result.columns[0],
3709
+ datasetColumns: result.columns.slice(1)
3710
+ };
3711
+ } catch (e) {
3712
+ hadErrors = true;
3713
+ const message = e instanceof Error ? e.message : String(e);
3714
+ keyedPortalResults[String(portalId)] = { error: message };
3715
+ }
3716
+ }
3717
+ if (successfulPortals.length === 0) {
3718
+ return {
3719
+ content: [{ type: "text", text: JSON.stringify(keyedPortalResults, null, 2) }],
3720
+ isError: true
3721
+ };
3722
+ }
3723
+ try {
3724
+ const chartData = buildMergedPortalChartData(successfulPortals);
3725
+ const chartTitle = title ?? `${chart_type === "bar" ? "Bar" : "Line"} Chart - Portal Comparison`;
3565
3726
  const html = generateChartHtml({
3566
3727
  chartType: chart_type,
3567
3728
  data: chartData,
3568
3729
  title: chartTitle,
3569
3730
  sql: trimmed
3570
3731
  });
3571
- mkdirSync4(CHARTS_DIR, { recursive: true });
3572
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3573
- const filename = `${timestamp}-${chart_type}.html`;
3574
- const filePath = chartFilePath(filename);
3575
- writeFileSync4(filePath, html, "utf-8");
3576
- openInBrowser(filePath);
3577
- const dataColumns = columns.slice(1);
3578
- const summary = [
3579
- `Chart generated and opened in browser.`,
3580
- ``,
3581
- `Type: ${chart_type}`,
3582
- `Title: ${chartTitle}`,
3583
- `Labels (x-axis): ${columns[0]} (${rows.length} values)`,
3584
- `Datasets: ${dataColumns.join(", ")}`,
3585
- `Query time: ${elapsed}ms`,
3586
- `File: ${filePath}`
3587
- ].join("\n");
3732
+ const filePath = writeChartFile(chart_type, html);
3733
+ openChartFile(filePath);
3734
+ const response = {
3735
+ chart: {
3736
+ type: chart_type,
3737
+ title: chartTitle,
3738
+ labels: chartData.labels.length,
3739
+ datasets: chartData.datasets.length,
3740
+ file: filePath
3741
+ },
3742
+ portals: keyedPortalResults
3743
+ };
3588
3744
  return {
3589
- content: [{ type: "text", text: summary }]
3745
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
3746
+ ...hadErrors ? { isError: true } : {}
3590
3747
  };
3591
3748
  } catch (e) {
3592
3749
  const message = e instanceof Error ? e.message : String(e);
@@ -3616,25 +3773,21 @@ function getDbFileSize(portalId) {
3616
3773
  return fs7.statSync(db).size;
3617
3774
  }
3618
3775
  async function buildConnectionSection(deps) {
3619
- const selectedPortalId = deps.getSelectedPortalId();
3776
+ const selectedPortalId2 = deps.getSelectedPortalId();
3620
3777
  const connectionState = deps.getConnectionState();
3621
3778
  const portals = deps.getPortals();
3622
3779
  return {
3623
3780
  connectionState,
3624
- selectedPortalId,
3781
+ selectedPortalId: selectedPortalId2,
3625
3782
  portals: portals.map((p) => ({
3626
3783
  portalId: p.target_portal,
3627
3784
  name: p.target_name,
3628
- selected: p.target_portal === selectedPortalId
3785
+ selected: p.target_portal === selectedPortalId2
3629
3786
  })),
3630
3787
  totalPortals: portals.length
3631
3788
  };
3632
3789
  }
3633
- async function buildSchemaSection(getSelectedPortalId2, getEncryptionKey) {
3634
- const portalId = getSelectedPortalId2();
3635
- if (portalId === null) {
3636
- return { error: "No portal selected. Use the set_portal tool first." };
3637
- }
3790
+ async function buildSchemaSection(portalId, getEncryptionKey) {
3638
3791
  const conn = await Effect41.runPromise(getReadConnection(portalId, getEncryptionKey()));
3639
3792
  const allPluginTableNames = new Set(getAllTableNames());
3640
3793
  const sectionResults = await Promise.all(
@@ -3666,25 +3819,66 @@ function registerStatusTool(server2, deps) {
3666
3819
 
3667
3820
  IMPORTANT: Always call this with section "schema" before writing any queries. It reveals which CRM object types are synced (contacts, companies, deals, tickets, custom objects, etc.), their available property names, record counts, and association types. This prevents query errors from incorrect property or object names.
3668
3821
 
3822
+ Optional: pass portalIds to fetch schema status for specific portals. When portalIds is provided, schema output is a JSON object keyed by portal ID.
3823
+
3824
+ Data readiness: all registered portals are synced in the background. Schema status reflects which object types have completed syncing and are queryable. If a portal is still syncing, some object types may not yet appear.
3825
+
3669
3826
  Use "connection" to check which portals are available and which is selected.
3670
3827
  Use "schema" to discover object types, property keys, record counts, and associations before writing queries.
3671
3828
  Use "all" to get everything at once.`,
3672
3829
  inputSchema: {
3673
3830
  section: SECTION_SCHEMA.describe(
3674
3831
  'Which information to retrieve: "connection" for portal list and state, "schema" for database structure, "all" for both.'
3675
- )
3832
+ ),
3833
+ portalIds: z15.array(z15.number()).optional().describe("Optional explicit portal IDs for schema status. If provided, schema output is keyed by portal ID.")
3676
3834
  }
3677
- }, async ({ section }) => {
3835
+ }, async ({ section, portalIds }) => {
3678
3836
  try {
3679
3837
  let result = {};
3838
+ let explicitSchemaHadErrors = false;
3680
3839
  if (section === "connection" || section === "all") {
3681
3840
  result.connection = await buildConnectionSection(deps);
3682
3841
  }
3683
3842
  if (section === "schema" || section === "all") {
3684
- result.schema = await buildSchemaSection(deps.getSelectedPortalId, deps.getEncryptionKey);
3843
+ const portalResolution = resolvePortalIds(
3844
+ portalIds,
3845
+ deps.getSelectedPortalId(),
3846
+ deps.getPortals()
3847
+ );
3848
+ if ("error" in portalResolution) {
3849
+ if (portalIds === void 0) {
3850
+ result.schema = { error: portalResolution.error };
3851
+ } else {
3852
+ return {
3853
+ content: [{ type: "text", text: portalResolution.error }],
3854
+ isError: true
3855
+ };
3856
+ }
3857
+ } else if (portalResolution.explicit) {
3858
+ const schemaByPortal = {};
3859
+ for (const portalId of portalResolution.portalIds) {
3860
+ try {
3861
+ await deps.ensureFresh(portalId);
3862
+ schemaByPortal[String(portalId)] = await buildSchemaSection(
3863
+ portalId,
3864
+ deps.getEncryptionKey
3865
+ );
3866
+ } catch (e) {
3867
+ explicitSchemaHadErrors = true;
3868
+ const message = e instanceof Error ? e.message : String(e);
3869
+ schemaByPortal[String(portalId)] = { error: message };
3870
+ }
3871
+ }
3872
+ result.schema = schemaByPortal;
3873
+ } else {
3874
+ const [portalId] = portalResolution.portalIds;
3875
+ await deps.ensureFresh(portalId);
3876
+ result.schema = await buildSchemaSection(portalId, deps.getEncryptionKey);
3877
+ }
3685
3878
  }
3686
3879
  return {
3687
- content: [{ type: "text", text: JSON.stringify(result) }]
3880
+ content: [{ type: "text", text: JSON.stringify(result) }],
3881
+ ...explicitSchemaHadErrors ? { isError: true } : {}
3688
3882
  };
3689
3883
  } catch (e) {
3690
3884
  const message = e instanceof Error ? e.message : String(e);
@@ -3700,34 +3894,39 @@ Use "all" to get everything at once.`,
3700
3894
  import { z as z61 } from "zod";
3701
3895
 
3702
3896
  // src/tools/draft_state.ts
3703
- var currentDraft = null;
3704
- var getDraft = () => currentDraft;
3705
- var hasDraft = () => currentDraft !== null;
3706
- var setDraft = (plan) => {
3707
- currentDraft = plan;
3708
- };
3709
- var addOperationsToDraft = (ops) => {
3710
- if (!currentDraft) throw new Error("No draft plan exists");
3711
- currentDraft.operations.push(...ops);
3712
- return currentDraft.operations.length;
3713
- };
3714
- var replaceOperationInDraft = (index, op) => {
3715
- if (!currentDraft) throw new Error("No draft plan exists");
3716
- if (index < 0 || index >= currentDraft.operations.length) {
3717
- throw new Error(`Operation index ${index} out of bounds (0-${currentDraft.operations.length - 1})`);
3897
+ var drafts = /* @__PURE__ */ new Map();
3898
+ var requireDraft = (portalId) => {
3899
+ const draft = drafts.get(portalId);
3900
+ if (!draft) throw new Error("No draft plan exists");
3901
+ return draft;
3902
+ };
3903
+ var getDraft = (portalId) => drafts.get(portalId) ?? null;
3904
+ var hasDraft = (portalId) => drafts.has(portalId);
3905
+ var setDraft = (portalId, plan) => {
3906
+ drafts.set(portalId, plan);
3907
+ };
3908
+ var addOperationsToDraft = (portalId, ops) => {
3909
+ const draft = requireDraft(portalId);
3910
+ draft.operations.push(...ops);
3911
+ return draft.operations.length;
3912
+ };
3913
+ var replaceOperationInDraft = (portalId, index, op) => {
3914
+ const draft = requireDraft(portalId);
3915
+ if (index < 0 || index >= draft.operations.length) {
3916
+ throw new Error(`Operation index ${index} out of bounds (0-${draft.operations.length - 1})`);
3718
3917
  }
3719
- currentDraft.operations[index] = op;
3918
+ draft.operations[index] = op;
3720
3919
  };
3721
- var removeOperationFromDraft = (index) => {
3722
- if (!currentDraft) throw new Error("No draft plan exists");
3723
- if (index < 0 || index >= currentDraft.operations.length) {
3724
- throw new Error(`Operation index ${index} out of bounds (0-${currentDraft.operations.length - 1})`);
3920
+ var removeOperationFromDraft = (portalId, index) => {
3921
+ const draft = requireDraft(portalId);
3922
+ if (index < 0 || index >= draft.operations.length) {
3923
+ throw new Error(`Operation index ${index} out of bounds (0-${draft.operations.length - 1})`);
3725
3924
  }
3726
- currentDraft.operations.splice(index, 1);
3727
- return currentDraft.operations.length;
3925
+ draft.operations.splice(index, 1);
3926
+ return draft.operations.length;
3728
3927
  };
3729
- var clearDraft = () => {
3730
- currentDraft = null;
3928
+ var clearDraft = (portalId) => {
3929
+ drafts.delete(portalId);
3731
3930
  };
3732
3931
 
3733
3932
  // src/pure/operation-conversion.ts
@@ -3907,12 +4106,12 @@ var updatePropertyMeta = {
3907
4106
  }
3908
4107
  },
3909
4108
  toNested: (description, fields44) => {
3910
- const { object_type, property_name, new_label, new_description, new_group_name, options, ...rest } = fields44;
4109
+ const { object_type, property_name, new_label, new_description, new_group_name, options: options2, ...rest } = fields44;
3911
4110
  const updates = {};
3912
4111
  if (new_label !== void 0) updates.label = new_label;
3913
4112
  if (new_description !== void 0) updates.description = new_description;
3914
4113
  if (new_group_name !== void 0) updates.group_name = new_group_name;
3915
- if (options !== void 0) updates.options = options;
4114
+ if (options2 !== void 0) updates.options = options2;
3916
4115
  return { type: "update_property", object_type, property_name, description, updates, ...rest };
3917
4116
  },
3918
4117
  requiredScopes: (op) => [getWriteScope(op.object_type)],
@@ -6358,7 +6557,7 @@ var buildRuleLines = (rules, optionLabelMap) => rules.map((rule) => {
6358
6557
  const labels = rule.visible_options.map((v) => optionLabelMap.get(v) ?? v).join(", ");
6359
6558
  return ` - When "${rule.controlling_property}" = "${rule.controlling_value}": Show "${labels}"`;
6360
6559
  }).join("\n");
6361
- var buildOptionLabelMap = (options) => new Map(options.map((o) => [o.value, o.label]));
6560
+ var buildOptionLabelMap = (options2) => new Map(options2.map((o) => [o.value, o.label]));
6362
6561
  var getUniqueControllingProperties = (rules) => [...new Set(rules.map((r) => r.controlling_property))];
6363
6562
  var buildConditionalOptionsTask = (property, objectType, rules) => {
6364
6563
  const subject = `Configure conditional options for "${property.label}" on ${formatObjectType(objectType)}`;
@@ -10765,12 +10964,18 @@ var summariseFields = (op) => {
10765
10964
  return `${k}: ${String(v)}`;
10766
10965
  }).join(", ");
10767
10966
  };
10768
- var formatPlanView = () => {
10769
- const draft = getDraft();
10967
+ var formatPlanView = (portalId) => {
10968
+ const draft = getDraft(portalId);
10969
+ if (!draft) {
10970
+ return [
10971
+ `Portal ${portalId}: no draft plan exists.`,
10972
+ "Provide title and description to start a new plan."
10973
+ ].join("\n");
10974
+ }
10770
10975
  const lines = [
10771
- `Draft Plan: "${draft.title}"`,
10976
+ `Portal ${portalId} Draft Plan: "${draft.title}"`,
10772
10977
  `Description: ${draft.description}`,
10773
- ``
10978
+ ""
10774
10979
  ];
10775
10980
  if (draft.operations.length === 0) {
10776
10981
  lines.push("Operations: (none)");
@@ -10788,9 +10993,36 @@ var formatPlanView = () => {
10788
10993
  }
10789
10994
  return lines.join("\n");
10790
10995
  };
10791
- function registerBuildPlanTool(server2) {
10996
+ var serialiseDraft = (portalId) => {
10997
+ const draft = getDraft(portalId);
10998
+ if (!draft) return null;
10999
+ return {
11000
+ title: draft.title,
11001
+ description: draft.description,
11002
+ operationCount: draft.operations.length,
11003
+ operations: draft.operations.map((operation, index) => {
11004
+ const op = operation;
11005
+ return {
11006
+ index: index + 1,
11007
+ type: String(op.type ?? "unknown"),
11008
+ description: String(op.description ?? "")
11009
+ };
11010
+ })
11011
+ };
11012
+ };
11013
+ var formatDraftResponse = (portalIds, explicit) => {
11014
+ if (!explicit) {
11015
+ return formatPlanView(portalIds[0]);
11016
+ }
11017
+ const keyed = {};
11018
+ for (const portalId of portalIds) {
11019
+ keyed[String(portalId)] = serialiseDraft(portalId);
11020
+ }
11021
+ return JSON.stringify(keyed, null, 2);
11022
+ };
11023
+ function registerBuildPlanTool(server2, deps) {
10792
11024
  server2.registerTool("build_plan", {
10793
- description: `Use this tool whenever the user wants to make changes to their HubSpot CRM configuration or data. This is the ONLY way to modify HubSpot \u2014 there is no direct API access. All CRM write operations must go through a plan.
11025
+ description: `Use this tool whenever the user wants to make changes to their HubSpot CRM configuration or data. This is the ONLY way to modify HubSpot - there is no direct API access. All CRM write operations must go through a plan.
10794
11026
 
10795
11027
  Supports ${OPERATION_TYPE_COUNT} operation types across these categories:
10796
11028
  - Properties & groups: create, update, or delete custom properties and property groups
@@ -10803,20 +11035,25 @@ Supports ${OPERATION_TYPE_COUNT} operation types across these categories:
10803
11035
 
10804
11036
  CREATING A PLAN:
10805
11037
  Provide title + description (+ optional operations) to start a new draft.
10806
- If a draft already exists, pass force: true to discard it and start fresh.
11038
+ If a targeted draft already exists, pass force: true to discard it and start fresh.
11039
+
11040
+ PORTAL TARGETING:
11041
+ Optionally pass portalIds to target one or more specific portals.
11042
+ If omitted, the selected portal is used.
11043
+ When portalIds is explicit, the response is a JSON object keyed by portal ID.
10807
11044
 
10808
11045
  ADDING OPERATIONS:
10809
- Provide operations to append them to the existing draft.
11046
+ Provide operations to append them to existing targeted drafts.
10810
11047
  Each operation needs: type, description, and fields.
10811
- Use describe_operations first to discover available types and their required fields.
11048
+ Use describe_operations first to discover available types and required fields.
10812
11049
 
10813
11050
  EDITING OPERATIONS:
10814
11051
  Provide edits to replace or remove operations by their 1-based index.
10815
- Edits are applied atomically \u2014 if any replacement fails validation, no changes are made.
11052
+ Edits require exactly one targeted portal.
11053
+ Edits are applied atomically - if any replacement fails validation, no changes are made.
10816
11054
 
10817
11055
  VIEWING THE PLAN:
10818
- Call with no parameters (or just title/description) to see the current draft state.
10819
- The current plan view is always returned in the response.
11056
+ Call with no operations/edits to see current draft state for the targeted portal(s).
10820
11057
 
10821
11058
  IMPORTANT: After building the plan, present it to the user and ask for confirmation before calling submit_plan.
10822
11059
 
@@ -10827,43 +11064,77 @@ WORKFLOW:
10827
11064
  4. submit_plan (dry_run: true) - Validate without saving (optional)
10828
11065
  5. submit_plan - Submit after user confirms`,
10829
11066
  inputSchema: {
10830
- title: z61.string().optional().describe("Short, descriptive title (required when creating a new plan)"),
10831
- description: z61.string().optional().describe("Detailed explanation of what the plan does and why (required when creating a new plan)"),
10832
- operations: z61.array(FlatOperationSchema).optional().describe("Operations to add to the plan"),
11067
+ title: z61.string().optional().describe("Short, descriptive title (required when creating a new draft)"),
11068
+ description: z61.string().optional().describe("Detailed explanation of what the plan does and why (required when creating a new draft)"),
11069
+ operations: z61.array(FlatOperationSchema).optional().describe("Operations to add to targeted draft(s)"),
10833
11070
  edits: z61.array(EditSchema).optional().describe("Edits to apply: replace or remove operations by 1-based index"),
10834
- force: z61.boolean().optional().describe("If true, discard any existing draft and start fresh")
11071
+ force: z61.boolean().optional().describe("If true, discard targeted draft(s) and start fresh"),
11072
+ portalIds: z61.array(z61.number()).optional().describe("Optional explicit portal IDs to target. If provided, responses are keyed by portal ID.")
11073
+ }
11074
+ }, async ({ title, description, operations, edits, force, portalIds }) => {
11075
+ const portalResolution = resolvePortalIds(
11076
+ portalIds,
11077
+ deps.getSelectedPortalId(),
11078
+ deps.getPortals()
11079
+ );
11080
+ if ("error" in portalResolution) {
11081
+ return {
11082
+ content: [{ type: "text", text: portalResolution.error }],
11083
+ isError: true
11084
+ };
11085
+ }
11086
+ const targetPortalIds = portalResolution.portalIds;
11087
+ const explicit = portalResolution.explicit;
11088
+ if (edits && edits.length > 0 && targetPortalIds.length !== 1) {
11089
+ return {
11090
+ content: [{ type: "text", text: "When using edits, target exactly one portal. Provide a single portal ID in portalIds." }],
11091
+ isError: true
11092
+ };
10835
11093
  }
10836
- }, async ({ title, description, operations, edits, force }) => {
10837
- if (!hasDraft()) {
11094
+ const missingPortalIds = targetPortalIds.filter((portalId) => !hasDraft(portalId));
11095
+ if (force) {
10838
11096
  if (!title || !description) {
10839
11097
  return {
10840
- content: [{ type: "text", text: "No draft plan exists. Provide title and description to start a new plan." }],
11098
+ content: [{ type: "text", text: "When using force: true, provide title and description for the new draft." }],
10841
11099
  isError: true
10842
11100
  };
10843
11101
  }
10844
- setDraft({ title, description, operations: [] });
10845
- } else if (force) {
10846
- if (!title || !description) {
11102
+ for (const portalId of targetPortalIds) {
11103
+ clearDraft(portalId);
11104
+ setDraft(portalId, { title, description, operations: [] });
11105
+ }
11106
+ } else {
11107
+ if (missingPortalIds.length > 0 && (!title || !description)) {
11108
+ const plural = missingPortalIds.length > 1 ? "portals" : "portal";
10847
11109
  return {
10848
- content: [{ type: "text", text: "When using force: true, provide title and description for the new plan." }],
11110
+ content: [{
11111
+ type: "text",
11112
+ text: `No draft plan exists for ${plural}: ${missingPortalIds.join(", ")}. Provide title and description to start a new draft.`
11113
+ }],
10849
11114
  isError: true
10850
11115
  };
10851
11116
  }
10852
- clearDraft();
10853
- setDraft({ title, description, operations: [] });
10854
- } else {
10855
- if (title || description) {
10856
- const draft = getDraft();
10857
- if (title) draft.title = title;
10858
- if (description) draft.description = description;
11117
+ for (const portalId of targetPortalIds) {
11118
+ if (!hasDraft(portalId)) {
11119
+ setDraft(portalId, { title, description, operations: [] });
11120
+ continue;
11121
+ }
11122
+ if (title || description) {
11123
+ const draft = getDraft(portalId);
11124
+ if (title) draft.title = title;
11125
+ if (description) draft.description = description;
11126
+ }
10859
11127
  }
10860
11128
  }
10861
11129
  if (edits && edits.length > 0) {
10862
- const draft = getDraft();
11130
+ const portalId = targetPortalIds[0];
11131
+ const draft = getDraft(portalId);
10863
11132
  const opCount = draft.operations.length;
10864
11133
  if (opCount === 0) {
10865
11134
  return {
10866
- content: [{ type: "text", text: "Draft has no operations to edit. Provide operations to add them first.\n\n" + formatPlanView() }],
11135
+ content: [{ type: "text", text: `Draft has no operations to edit. Provide operations to add them first.
11136
+
11137
+ ${formatPlanView(portalId)}` }],
10867
11138
  isError: true
10868
11139
  };
10869
11140
  }
@@ -10872,7 +11143,7 @@ WORKFLOW:
10872
11143
  return {
10873
11144
  content: [{ type: "text", text: `Index ${edit.index} is out of range. Draft has ${opCount} operation(s) (indices 1-${opCount}).
10874
11145
 
10875
- ${formatPlanView()}` }],
11146
+ ${formatPlanView(portalId)}` }],
10876
11147
  isError: true
10877
11148
  };
10878
11149
  }
@@ -10880,18 +11151,17 @@ ${formatPlanView()}` }],
10880
11151
  return {
10881
11152
  content: [{ type: "text", text: `Edit at index ${edit.index}: "replace" action requires an operation.
10882
11153
 
10883
- ${formatPlanView()}` }],
11154
+ ${formatPlanView(portalId)}` }],
10884
11155
  isError: true
10885
11156
  };
10886
11157
  }
10887
11158
  }
10888
- const replaces = edits.filter((e) => e.action === "replace");
10889
- const removes = edits.filter((e) => e.action === "remove");
11159
+ const replaces = edits.filter((edit) => edit.action === "replace");
11160
+ const removes = edits.filter((edit) => edit.action === "remove");
10890
11161
  const nestedReplacements = [];
10891
11162
  const errors = [];
10892
11163
  for (const edit of replaces) {
10893
- const flat = edit.operation;
10894
- const nested = nestOperation(flat);
11164
+ const nested = nestOperation(edit.operation);
10895
11165
  if (typeof nested === "string") {
10896
11166
  errors.push(`Index ${edit.index}: ${nested}`);
10897
11167
  continue;
@@ -10904,7 +11174,6 @@ ${formatPlanView()}` }],
10904
11174
  nestedReplacements.push({ zeroIndex: edit.index - 1, nested });
10905
11175
  }
10906
11176
  if (errors.length > 0) {
10907
- const hint = "Use describe_operations to check field requirements.";
10908
11177
  return {
10909
11178
  content: [{
10910
11179
  type: "text",
@@ -10912,44 +11181,58 @@ ${formatPlanView()}` }],
10912
11181
 
10913
11182
  ${errors.join("\n\n")}
10914
11183
 
10915
- ${hint}
11184
+ Use describe_operations to check field requirements.
10916
11185
 
10917
- ${formatPlanView()}`
11186
+ ${formatPlanView(portalId)}`
10918
11187
  }],
10919
11188
  isError: true
10920
11189
  };
10921
11190
  }
10922
11191
  for (const { zeroIndex, nested } of nestedReplacements) {
10923
- replaceOperationInDraft(zeroIndex, nested);
11192
+ replaceOperationInDraft(portalId, zeroIndex, nested);
10924
11193
  }
10925
- const removeIndices = removes.map((e) => e.index - 1).sort((a, b) => b - a);
10926
- for (const idx of removeIndices) {
10927
- removeOperationFromDraft(idx);
11194
+ const removeIndices = removes.map((edit) => edit.index - 1).sort((a, b) => b - a);
11195
+ for (const index of removeIndices) {
11196
+ removeOperationFromDraft(portalId, index);
10928
11197
  }
10929
11198
  }
10930
11199
  if (operations && operations.length > 0) {
10931
- const draft = getDraft();
10932
- const startIndex = draft.operations.length + 1;
10933
- const { nested, errors } = convertAndValidateOperations(operations, startIndex);
10934
- if (errors.length > 0) {
10935
- const hint = "Use describe_operations to see the required fields for each operation type.";
11200
+ const validatedByPortal = /* @__PURE__ */ new Map();
11201
+ const portalErrors = [];
11202
+ for (const portalId of targetPortalIds) {
11203
+ const draft = getDraft(portalId);
11204
+ const startIndex = draft.operations.length + 1;
11205
+ const { nested, errors } = convertAndValidateOperations(operations, startIndex);
11206
+ if (errors.length > 0) {
11207
+ portalErrors.push(`Portal ${portalId}:
11208
+ ${errors.join("\n")}`);
11209
+ continue;
11210
+ }
11211
+ validatedByPortal.set(portalId, nested);
11212
+ }
11213
+ if (portalErrors.length > 0) {
10936
11214
  return {
10937
11215
  content: [{
10938
11216
  type: "text",
10939
- text: `${errors.length} operation(s) failed validation:
10940
-
10941
- ${errors.join("\n\n")}
11217
+ text: `${portalErrors.length} portal(s) failed operation validation:
10942
11218
 
10943
- ${hint}
11219
+ ${portalErrors.join("\n\n")}
10944
11220
 
10945
- ${formatPlanView()}`
11221
+ Use describe_operations to see the required fields for each operation type.`
10946
11222
  }],
10947
11223
  isError: true
10948
11224
  };
10949
11225
  }
10950
- addOperationsToDraft(nested);
11226
+ for (const portalId of targetPortalIds) {
11227
+ addOperationsToDraft(portalId, validatedByPortal.get(portalId) ?? []);
11228
+ }
10951
11229
  }
10952
- return { content: [{ type: "text", text: formatPlanView() }] };
11230
+ return {
11231
+ content: [{
11232
+ type: "text",
11233
+ text: formatDraftResponse(targetPortalIds, explicit)
11234
+ }]
11235
+ };
10953
11236
  });
10954
11237
  }
10955
11238
 
@@ -10997,16 +11280,16 @@ import { Effect as Effect89 } from "effect";
10997
11280
  var formatFixHint = (issue, summary) => {
10998
11281
  const opNum = issue.operation_index + 1;
10999
11282
  const opInfo = summary?.operations[issue.operation_index];
11000
- const label = opInfo ? `${opInfo.type} "${opInfo.description}"` : `operation`;
11283
+ const label = opInfo ? `${opInfo.type} "${opInfo.description}"` : "operation";
11001
11284
  const severity = issue.severity === "error" ? "ERROR" : "WARNING";
11002
- return ` ${severity} [Op ${opNum}] (${label}): ${issue.code} \u2014 ${issue.message}`;
11285
+ return ` ${severity} [Op ${opNum}] (${label}): ${issue.code} - ${issue.message}`;
11003
11286
  };
11004
11287
  var formatValidationIssues = (response) => {
11005
11288
  const v = response.validation;
11006
11289
  const lines = [];
11007
11290
  if (response.summary) {
11008
11291
  lines.push(`Summary: ${response.summary.operation_count} operation(s)`);
11009
- lines.push(``);
11292
+ lines.push("");
11010
11293
  }
11011
11294
  const errors = v.issues.filter((i) => i.severity === "error");
11012
11295
  const warnings = v.issues.filter((i) => i.severity === "warning");
@@ -11015,165 +11298,272 @@ var formatValidationIssues = (response) => {
11015
11298
  for (const issue of errors) {
11016
11299
  lines.push(formatFixHint(issue, response.summary));
11017
11300
  }
11018
- lines.push(``);
11301
+ lines.push("");
11019
11302
  }
11020
11303
  if (warnings.length > 0) {
11021
11304
  lines.push(`Warnings (${warnings.length}):`);
11022
11305
  for (const issue of warnings) {
11023
11306
  lines.push(formatFixHint(issue, response.summary));
11024
11307
  }
11025
- lines.push(``);
11308
+ lines.push("");
11026
11309
  }
11027
11310
  if (!v.scope_check?.valid) {
11028
11311
  lines.push(`Scope issue: Missing scopes: ${v.scope_check.missingScopes.join(", ")}`);
11029
- lines.push(`The user may need to re-authorize the portal with write permissions.`);
11030
- lines.push(``);
11312
+ lines.push("The user may need to re-authorize the portal with write permissions.");
11313
+ lines.push("");
11031
11314
  }
11032
11315
  return lines;
11033
11316
  };
11317
+ var submitPortalDraft = async (portalId, dryRun, deps) => {
11318
+ const draft = getDraft(portalId);
11319
+ if (!draft) {
11320
+ return {
11321
+ kind: "error",
11322
+ message: "No draft plan exists. Start one with build_plan first."
11323
+ };
11324
+ }
11325
+ if (draft.operations.length === 0) {
11326
+ return {
11327
+ kind: "error",
11328
+ message: "Draft plan has no operations. Use build_plan to add operations before submitting."
11329
+ };
11330
+ }
11331
+ try {
11332
+ const response = await Effect89.runPromise(
11333
+ deps.ws.sendPlanCreate(portalId, {
11334
+ title: draft.title,
11335
+ description: draft.description,
11336
+ operations: draft.operations,
11337
+ dry_run: dryRun
11338
+ })
11339
+ );
11340
+ if (!response.success && !response.validation) {
11341
+ return {
11342
+ kind: "error",
11343
+ message: `Failed to submit plan: ${response.error ?? "Unknown error"}`
11344
+ };
11345
+ }
11346
+ if (!response.success && response.validation) {
11347
+ return {
11348
+ kind: "validation_failed",
11349
+ response
11350
+ };
11351
+ }
11352
+ if (response.dry_run) {
11353
+ return {
11354
+ kind: "dry_run_success",
11355
+ response
11356
+ };
11357
+ }
11358
+ clearDraft(portalId);
11359
+ return {
11360
+ kind: "submit_success",
11361
+ response
11362
+ };
11363
+ } catch (e) {
11364
+ const message = e instanceof Error ? e.message : String(e);
11365
+ return {
11366
+ kind: "error",
11367
+ message: `Error submitting plan: ${message}`
11368
+ };
11369
+ }
11370
+ };
11371
+ var toKeyedResult = (result) => {
11372
+ switch (result.kind) {
11373
+ case "error":
11374
+ return { success: false, error: result.message };
11375
+ case "validation_failed":
11376
+ return {
11377
+ success: false,
11378
+ error: "Plan validation failed",
11379
+ summary: result.response.summary,
11380
+ validation: result.response.validation,
11381
+ scope_warning: result.response.scope_warning
11382
+ };
11383
+ case "dry_run_success":
11384
+ return {
11385
+ success: true,
11386
+ dry_run: true,
11387
+ summary: result.response.summary,
11388
+ validation: result.response.validation,
11389
+ scope_warning: result.response.scope_warning
11390
+ };
11391
+ case "submit_success":
11392
+ return {
11393
+ success: true,
11394
+ dry_run: false,
11395
+ plan_id: result.response.plan_id,
11396
+ summary: result.response.summary,
11397
+ validation: result.response.validation,
11398
+ scope_warning: result.response.scope_warning
11399
+ };
11400
+ }
11401
+ };
11402
+ var formatSinglePortalResult = (result) => {
11403
+ if (result.kind === "error") {
11404
+ return {
11405
+ text: result.message,
11406
+ isError: true
11407
+ };
11408
+ }
11409
+ if (result.kind === "validation_failed") {
11410
+ const response2 = result.response;
11411
+ const lines2 = [
11412
+ "Plan validation failed - the plan was NOT saved. Fix the issues below and resubmit.",
11413
+ "",
11414
+ ...formatValidationIssues(response2),
11415
+ "Use build_plan with edits to fix issues, then submit_plan again."
11416
+ ];
11417
+ return { text: lines2.join("\n") };
11418
+ }
11419
+ if (result.kind === "dry_run_success") {
11420
+ const response2 = result.response;
11421
+ const summary2 = response2.summary;
11422
+ const opLines2 = summary2.operations.map(
11423
+ (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11424
+ );
11425
+ const lines2 = [
11426
+ "Validation passed - plan is ready to submit.",
11427
+ "",
11428
+ "Summary:",
11429
+ ` Operations: ${summary2.operation_count}`,
11430
+ ` Total records affected: ${summary2.total_record_count}`,
11431
+ "",
11432
+ "Operations:",
11433
+ ...opLines2
11434
+ ];
11435
+ if (response2.validation?.issues && response2.validation.issues.length > 0) {
11436
+ lines2.push("", ...formatValidationIssues(response2));
11437
+ }
11438
+ if (response2.scope_warning) {
11439
+ lines2.push("", `WARNING: ${response2.scope_warning}`);
11440
+ }
11441
+ lines2.push("", "Draft is preserved. Call submit_plan() (without dry_run) to save the plan.");
11442
+ return { text: lines2.join("\n") };
11443
+ }
11444
+ const response = result.response;
11445
+ const summary = response.summary;
11446
+ const opLines = summary.operations.map(
11447
+ (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11448
+ );
11449
+ const lines = [
11450
+ "Plan submitted successfully.",
11451
+ "",
11452
+ `Plan ID: ${response.plan_id}`,
11453
+ "Status: DRAFT (awaiting review in HubSpot UI)",
11454
+ "",
11455
+ "Summary:",
11456
+ ` Operations: ${summary.operation_count}`,
11457
+ ` Total records affected: ${summary.total_record_count}`,
11458
+ "",
11459
+ "Operations:",
11460
+ ...opLines
11461
+ ];
11462
+ if (response.validation) {
11463
+ const validation = response.validation;
11464
+ if (validation.issues && validation.issues.length > 0) {
11465
+ lines.push("", "Validation warnings:");
11466
+ for (const issue of validation.issues) {
11467
+ lines.push(` - [Op ${issue.operation_index + 1}] ${issue.code}: ${issue.message}`);
11468
+ }
11469
+ }
11470
+ if (!validation.scope_check?.valid) {
11471
+ lines.push("", `Scope warning: Missing scopes: ${validation.scope_check.missingScopes.join(", ")}`);
11472
+ }
11473
+ }
11474
+ if (response.scope_warning) {
11475
+ lines.push("", `WARNING: ${response.scope_warning}`);
11476
+ }
11477
+ lines.push("", "The user can review and execute this plan in their HubSpot settings page.");
11478
+ return { text: lines.join("\n") };
11479
+ };
11034
11480
  function registerSubmitPlanTool(server2, deps) {
11035
11481
  server2.registerTool("submit_plan", {
11036
- description: `Validate and/or submit the current draft write plan.
11482
+ description: `Validate and/or submit draft write plans.
11037
11483
 
11038
- IMPORTANT: Always present the plan to the user and get their explicit confirmation BEFORE calling this tool. Never submit a plan without user approval.
11484
+ IMPORTANT: Always present the plan to the user and get explicit confirmation BEFORE calling this tool. Never submit a plan without user approval.
11485
+
11486
+ PORTAL TARGETING:
11487
+ Optionally pass portalIds to submit one or more specific portal drafts.
11488
+ If omitted, the selected portal is used.
11489
+ When portalIds is explicit, results are a JSON object keyed by portal ID.
11039
11490
 
11040
11491
  MODES:
11041
- - dry_run: true \u2014 Validate only. Runs full server-side validation (scopes, HubSpot checks) without saving. The draft is preserved so you can fix issues with build_plan.
11042
- - dry_run: false (default) \u2014 Validate AND save. If valid, saves the plan as a DRAFT for user review in HubSpot. The draft is cleared on success.
11492
+ - dry_run: true - Validate only. Runs full server-side validation (scopes, HubSpot checks) without saving. Drafts are preserved.
11493
+ - dry_run: false (default) - Validate AND save. Successful portals are saved as DRAFT and their drafts are cleared.
11043
11494
 
11044
11495
  RECOMMENDED WORKFLOW:
11045
11496
  1. Build the plan with build_plan
11046
11497
  2. Present the plan to the user and ask for confirmation
11047
11498
  3. Call submit_plan(dry_run: true) to validate
11048
11499
  4. Fix any issues with build_plan(edits: [...])
11049
- 5. Call submit_plan() to save after user confirms
11050
-
11051
- After a successful submission, the user can review and execute the plan in their HubSpot settings page.`,
11500
+ 5. Call submit_plan() to save after user confirms`,
11052
11501
  inputSchema: {
11053
- dry_run: z63.boolean().optional().describe("If true, validate only without saving. Defaults to false.")
11054
- }
11055
- }, async ({ dry_run }) => {
11056
- const draft = getDraft();
11057
- if (!draft) {
11058
- return {
11059
- content: [{ type: "text", text: "No draft plan exists. Start one with build_plan first." }],
11060
- isError: true
11061
- };
11062
- }
11063
- if (draft.operations.length === 0) {
11064
- return {
11065
- content: [{ type: "text", text: "Draft plan has no operations. Use build_plan to add operations before submitting." }],
11066
- isError: true
11067
- };
11068
- }
11069
- const portalId = deps.getSelectedPortalId();
11070
- if (portalId === null) {
11502
+ dry_run: z63.boolean().optional().describe("If true, validate only without saving. Defaults to false."),
11503
+ portalIds: z63.array(z63.number()).optional().describe("Optional explicit portal IDs to submit. If provided, results are keyed by portal ID.")
11504
+ }
11505
+ }, async ({ dry_run, portalIds }) => {
11506
+ const portalResolution = resolvePortalIds(
11507
+ portalIds,
11508
+ deps.getSelectedPortalId(),
11509
+ deps.getPortals()
11510
+ );
11511
+ if ("error" in portalResolution) {
11071
11512
  return {
11072
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
11513
+ content: [{ type: "text", text: portalResolution.error }],
11073
11514
  isError: true
11074
11515
  };
11075
11516
  }
11076
- try {
11077
- const response = await Effect89.runPromise(
11078
- deps.ws.sendPlanCreate(portalId, {
11079
- title: draft.title,
11080
- description: draft.description,
11081
- operations: draft.operations,
11082
- dry_run: dry_run ?? false
11083
- })
11084
- );
11085
- if (!response.success && !response.validation) {
11086
- return {
11087
- content: [{ type: "text", text: `Failed to submit plan: ${response.error ?? "Unknown error"}` }],
11088
- isError: true
11089
- };
11090
- }
11091
- if (!response.success && response.validation) {
11092
- const lines2 = [
11093
- `Plan validation failed \u2014 the plan was NOT saved. Fix the issues below and resubmit.`,
11094
- ``,
11095
- ...formatValidationIssues(response),
11096
- `Use build_plan with edits to fix issues, then submit_plan again.`
11097
- ];
11098
- return { content: [{ type: "text", text: lines2.join("\n") }] };
11099
- }
11100
- if (response.dry_run) {
11101
- const summary2 = response.summary;
11102
- const opLines2 = summary2.operations.map(
11103
- (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11104
- );
11105
- const lines2 = [
11106
- `Validation passed \u2014 plan is ready to submit.`,
11107
- ``,
11108
- `Summary:`,
11109
- ` Operations: ${summary2.operation_count}`,
11110
- ` Total records affected: ${summary2.total_record_count}`,
11111
- ``,
11112
- `Operations:`,
11113
- ...opLines2
11114
- ];
11115
- if (response.validation?.issues && response.validation.issues.length > 0) {
11116
- lines2.push(``, ...formatValidationIssues(response));
11117
- }
11118
- if (response.scope_warning) {
11119
- lines2.push(``, `WARNING: ${response.scope_warning}`);
11120
- }
11121
- lines2.push(``, `Draft is preserved. Call submit_plan() (without dry_run) to save the plan.`);
11122
- return { content: [{ type: "text", text: lines2.join("\n") }] };
11123
- }
11124
- clearDraft();
11125
- const summary = response.summary;
11126
- const opLines = summary.operations.map(
11127
- (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11128
- );
11129
- const lines = [
11130
- `Plan submitted successfully.`,
11131
- ``,
11132
- `Plan ID: ${response.plan_id}`,
11133
- `Status: DRAFT (awaiting review in HubSpot UI)`,
11134
- ``,
11135
- `Summary:`,
11136
- ` Operations: ${summary.operation_count}`,
11137
- ` Total records affected: ${summary.total_record_count}`,
11138
- ``,
11139
- `Operations:`,
11140
- ...opLines
11141
- ];
11142
- if (response.validation) {
11143
- const v = response.validation;
11144
- if (v.issues && v.issues.length > 0) {
11145
- lines.push(``, `Validation warnings:`);
11146
- for (const issue of v.issues) {
11147
- lines.push(` - [Op ${issue.operation_index + 1}] ${issue.code}: ${issue.message}`);
11148
- }
11149
- }
11150
- if (!v.scope_check?.valid) {
11151
- lines.push(``, `Scope warning: Missing scopes: ${v.scope_check.missingScopes.join(", ")}`);
11517
+ const targetPortalIds = portalResolution.portalIds;
11518
+ const dryRun = dry_run ?? false;
11519
+ if (portalResolution.explicit) {
11520
+ const keyed = {};
11521
+ let hadAnyErrors = false;
11522
+ for (const portalId2 of targetPortalIds) {
11523
+ const result2 = await submitPortalDraft(portalId2, dryRun, deps);
11524
+ const keyedResult = toKeyedResult(result2);
11525
+ keyed[String(portalId2)] = keyedResult;
11526
+ if (!keyedResult.success) {
11527
+ hadAnyErrors = true;
11152
11528
  }
11153
11529
  }
11154
- if (response.scope_warning) {
11155
- lines.push(``, `WARNING: ${response.scope_warning}`);
11156
- }
11157
- lines.push(``, `The user can review and execute this plan in their HubSpot settings page.`);
11158
- return { content: [{ type: "text", text: lines.join("\n") }] };
11159
- } catch (e) {
11160
- const message = e instanceof Error ? e.message : String(e);
11161
11530
  return {
11162
- content: [{ type: "text", text: `Error submitting plan: ${message}` }],
11163
- isError: true
11531
+ content: [{ type: "text", text: JSON.stringify(keyed, null, 2) }],
11532
+ ...hadAnyErrors ? { isError: true } : {}
11164
11533
  };
11165
11534
  }
11535
+ const portalId = targetPortalIds[0];
11536
+ const result = await submitPortalDraft(portalId, dryRun, deps);
11537
+ const formatted = formatSinglePortalResult(result);
11538
+ return {
11539
+ content: [{ type: "text", text: formatted.text }],
11540
+ ...formatted.isError ? { isError: true } : {}
11541
+ };
11166
11542
  });
11167
11543
  }
11168
11544
 
11169
11545
  // src/tools/get_plans.ts
11170
11546
  import { z as z64 } from "zod";
11171
11547
  import { Effect as Effect90 } from "effect";
11548
+ var serialiseDraft2 = (portalId) => {
11549
+ const draft = getDraft(portalId);
11550
+ if (!draft) return null;
11551
+ return {
11552
+ title: draft.title,
11553
+ description: draft.description,
11554
+ operationCount: draft.operations.length
11555
+ };
11556
+ };
11172
11557
  function registerGetPlansTool(server2, deps) {
11173
11558
  server2.registerTool("get_plans", {
11174
- description: `List write plans for the selected HubSpot portal. Shows plans created by AI along with their current execution status.
11559
+ description: `List write plans for one or more HubSpot portals, including current local draft state.
11175
11560
 
11176
- Use this to check on previously submitted plans, see if they've been reviewed, or find failed plans that need attention.
11561
+ Use this to check submitted plans and view draft readiness before calling submit_plan.
11562
+
11563
+ PORTAL TARGETING:
11564
+ Optionally pass portalIds to fetch plans for specific portals.
11565
+ If omitted, the selected portal is used.
11566
+ When portalIds is explicit, results are a JSON object keyed by portal ID.
11177
11567
 
11178
11568
  Statuses:
11179
11569
  - DRAFT: Awaiting user review in HubSpot UI
@@ -11182,16 +11572,59 @@ Statuses:
11182
11572
  - FAILED: Execution failed
11183
11573
  - REJECTED: User rejected the plan`,
11184
11574
  inputSchema: {
11185
- status: z64.enum(["DRAFT", "REJECTED", "EXECUTING", "EXECUTED", "FAILED"]).optional().describe("Filter by plan status. Omit to show all plans.")
11186
- }
11187
- }, async ({ status }) => {
11188
- const portalId = deps.getSelectedPortalId();
11189
- if (portalId === null) {
11575
+ status: z64.enum(["DRAFT", "REJECTED", "EXECUTING", "EXECUTED", "FAILED"]).optional().describe("Filter by plan status. Omit to show all plans."),
11576
+ portalIds: z64.array(z64.number()).optional().describe("Optional explicit portal IDs. If provided, results are keyed by portal ID.")
11577
+ }
11578
+ }, async ({ status, portalIds }) => {
11579
+ const portalResolution = resolvePortalIds(
11580
+ portalIds,
11581
+ deps.getSelectedPortalId(),
11582
+ deps.getPortals()
11583
+ );
11584
+ if ("error" in portalResolution) {
11190
11585
  return {
11191
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
11586
+ content: [{ type: "text", text: portalResolution.error }],
11192
11587
  isError: true
11193
11588
  };
11194
11589
  }
11590
+ const targetPortalIds = portalResolution.portalIds;
11591
+ if (portalResolution.explicit) {
11592
+ const keyed = {};
11593
+ let hadAnyErrors = false;
11594
+ for (const portalId2 of targetPortalIds) {
11595
+ const draft2 = serialiseDraft2(portalId2);
11596
+ try {
11597
+ const response = await Effect90.runPromise(
11598
+ deps.ws.sendPlanList(portalId2, status)
11599
+ );
11600
+ if (!response.success) {
11601
+ hadAnyErrors = true;
11602
+ keyed[String(portalId2)] = {
11603
+ draft: draft2,
11604
+ error: `Failed to list plans: ${response.error}`
11605
+ };
11606
+ continue;
11607
+ }
11608
+ keyed[String(portalId2)] = {
11609
+ draft: draft2,
11610
+ plans: response.plans ?? []
11611
+ };
11612
+ } catch (e) {
11613
+ hadAnyErrors = true;
11614
+ const message = e instanceof Error ? e.message : String(e);
11615
+ keyed[String(portalId2)] = {
11616
+ draft: draft2,
11617
+ error: `Error listing plans: ${message}`
11618
+ };
11619
+ }
11620
+ }
11621
+ return {
11622
+ content: [{ type: "text", text: JSON.stringify(keyed, null, 2) }],
11623
+ ...hadAnyErrors ? { isError: true } : {}
11624
+ };
11625
+ }
11626
+ const portalId = targetPortalIds[0];
11627
+ const draft = serialiseDraft2(portalId);
11195
11628
  try {
11196
11629
  const response = await Effect90.runPromise(
11197
11630
  deps.ws.sendPlanList(portalId, status)
@@ -11203,19 +11636,22 @@ Statuses:
11203
11636
  };
11204
11637
  }
11205
11638
  const plans = response.plans ?? [];
11639
+ const draftLine = draft ? `Current draft: "${draft.title}" (${draft.operationCount} operation(s))` : "Current draft: none";
11206
11640
  if (plans.length === 0) {
11207
11641
  const filterMsg = status ? ` with status ${status}` : "";
11208
11642
  return {
11209
- content: [{ type: "text", text: `No plans found${filterMsg}.` }]
11643
+ content: [{ type: "text", text: `${draftLine}
11644
+ No plans found${filterMsg}.` }]
11210
11645
  };
11211
11646
  }
11212
- const lines = plans.map((p) => {
11213
- const executed = p.executed_at ? ` | Executed: ${p.executed_at}` : "";
11214
- return `- [${p.status}] ${p.title} (${p.operation_count} ops, ${p.total_record_count} records) | Created: ${p.created_at}${executed} | ID: ${p.id}`;
11647
+ const lines = plans.map((plan) => {
11648
+ const executed = plan.executed_at ? ` | Executed: ${plan.executed_at}` : "";
11649
+ return `- [${plan.status}] ${plan.title} (${plan.operation_count} ops, ${plan.total_record_count} records) | Created: ${plan.created_at}${executed} | ID: ${plan.id}`;
11215
11650
  });
11216
11651
  const result = [
11217
11652
  `Plans for portal ${portalId}:`,
11218
- ``,
11653
+ draftLine,
11654
+ "",
11219
11655
  ...lines
11220
11656
  ].join("\n");
11221
11657
  return { content: [{ type: "text", text: result }] };
@@ -11229,182 +11665,624 @@ Statuses:
11229
11665
  });
11230
11666
  }
11231
11667
 
11668
+ // src/pure/sync-lifecycle.ts
11669
+ var dedupePortalIds2 = (portalIds) => {
11670
+ const seen = /* @__PURE__ */ new Set();
11671
+ const deduped = [];
11672
+ for (const portalId of portalIds) {
11673
+ if (seen.has(portalId)) continue;
11674
+ seen.add(portalId);
11675
+ deduped.push(portalId);
11676
+ }
11677
+ return deduped;
11678
+ };
11679
+ var orderPortalWarmupIds = (portals, selectedPortalId2) => {
11680
+ const portalIds = dedupePortalIds2(portals.map((portal) => portal.target_portal));
11681
+ if (selectedPortalId2 === null || !portalIds.includes(selectedPortalId2)) {
11682
+ return portalIds;
11683
+ }
11684
+ return [selectedPortalId2, ...portalIds.filter((portalId) => portalId !== selectedPortalId2)];
11685
+ };
11686
+ var resolveDiffFlow = (portalId, selectedPortalId2) => selectedPortalId2 === portalId ? "diff_then_subscribe" : "diff";
11687
+ var shouldSubscribeAfterDiffComplete = (portalId, selectedPortalId2, success) => success && selectedPortalId2 === portalId;
11688
+
11689
+ // src/effects/artifact-queue.ts
11690
+ import { Effect as Effect91 } from "effect";
11691
+ var DEFAULT_OPTIONS = {
11692
+ maxRetries: 2,
11693
+ baseBackoffMs: 500
11694
+ };
11695
+ var DEFAULT_PROCESSOR = (portalId, artifact) => Effect91.sync(() => {
11696
+ console.error(
11697
+ `[artifact-queue] No processor configured for portal ${portalId} artifact ${artifact.object_type}`
11698
+ );
11699
+ return false;
11700
+ });
11701
+ var selectedPortalId = null;
11702
+ var inFlightJob = null;
11703
+ var workerRunning = false;
11704
+ var workerGeneration = 0;
11705
+ var options = { ...DEFAULT_OPTIONS };
11706
+ var processArtifact2 = DEFAULT_PROCESSOR;
11707
+ var pendingJobs = [];
11708
+ var activeKeys = /* @__PURE__ */ new Set();
11709
+ var drainWaiters = /* @__PURE__ */ new Map();
11710
+ var makeJobKey = (portalId, artifactId) => `${portalId}:${artifactId}`;
11711
+ var sleep = (ms) => new Promise((resolve) => {
11712
+ setTimeout(resolve, ms);
11713
+ });
11714
+ var isPortalDrained = (portalId) => inFlightJob?.portalId !== portalId && !pendingJobs.some((job) => job.portalId === portalId);
11715
+ var resolvePortalWaiters = (portalId, drained) => {
11716
+ const waiters = drainWaiters.get(portalId);
11717
+ if (!waiters) return;
11718
+ for (const waiter of waiters) {
11719
+ if (waiter.timeoutId !== null) {
11720
+ clearTimeout(waiter.timeoutId);
11721
+ }
11722
+ waiter.resolve(drained);
11723
+ }
11724
+ drainWaiters.delete(portalId);
11725
+ };
11726
+ var maybeResolvePortalDrain = (portalId) => {
11727
+ if (isPortalDrained(portalId)) {
11728
+ resolvePortalWaiters(portalId, true);
11729
+ }
11730
+ };
11731
+ var dequeueNextJob = () => {
11732
+ if (pendingJobs.length === 0) return null;
11733
+ if (selectedPortalId === null) {
11734
+ return pendingJobs.shift() ?? null;
11735
+ }
11736
+ const selectedIdx = pendingJobs.findIndex((job) => job.portalId === selectedPortalId);
11737
+ if (selectedIdx === -1) {
11738
+ return pendingJobs.shift() ?? null;
11739
+ }
11740
+ return pendingJobs.splice(selectedIdx, 1)[0] ?? null;
11741
+ };
11742
+ var processJobWithRetry = async (job, generation) => {
11743
+ const maxAttempts = options.maxRetries + 1;
11744
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
11745
+ if (generation !== workerGeneration) return;
11746
+ const succeeded = await Effect91.runPromise(
11747
+ processArtifact2(job.portalId, job.artifact).pipe(
11748
+ Effect91.catchAll(
11749
+ (error) => Effect91.sync(() => {
11750
+ console.error(
11751
+ `[artifact-queue] Ingest failed for portal ${job.portalId} artifact ${job.artifact.object_type}:`,
11752
+ error
11753
+ );
11754
+ return false;
11755
+ })
11756
+ )
11757
+ )
11758
+ );
11759
+ if (succeeded) return;
11760
+ if (attempt >= maxAttempts) {
11761
+ console.error(
11762
+ `[artifact-queue] Exhausted retries for portal ${job.portalId} artifact ${job.artifact.object_type}`
11763
+ );
11764
+ return;
11765
+ }
11766
+ const backoffMs = options.baseBackoffMs * 2 ** (attempt - 1);
11767
+ console.error(
11768
+ `[artifact-queue] Retry ${attempt}/${options.maxRetries} for portal ${job.portalId} artifact ${job.artifact.object_type} in ${backoffMs}ms`
11769
+ );
11770
+ await sleep(backoffMs);
11771
+ }
11772
+ };
11773
+ var runWorker = async (generation) => {
11774
+ while (generation === workerGeneration) {
11775
+ const job = dequeueNextJob();
11776
+ if (!job) {
11777
+ if (generation === workerGeneration) {
11778
+ workerRunning = false;
11779
+ }
11780
+ return;
11781
+ }
11782
+ inFlightJob = job;
11783
+ try {
11784
+ await processJobWithRetry(job, generation);
11785
+ } catch (error) {
11786
+ console.error("[artifact-queue] Unexpected worker failure:", error);
11787
+ } finally {
11788
+ if (generation !== workerGeneration) return;
11789
+ inFlightJob = null;
11790
+ activeKeys.delete(job.key);
11791
+ maybeResolvePortalDrain(job.portalId);
11792
+ }
11793
+ }
11794
+ };
11795
+ var startWorkerIfNeeded = () => {
11796
+ if (workerRunning || pendingJobs.length === 0) return;
11797
+ workerRunning = true;
11798
+ const generation = workerGeneration;
11799
+ void runWorker(generation);
11800
+ };
11801
+ var configureArtifactQueue = (processor, nextOptions) => {
11802
+ processArtifact2 = processor;
11803
+ options = {
11804
+ ...DEFAULT_OPTIONS,
11805
+ ...nextOptions ?? {}
11806
+ };
11807
+ startWorkerIfNeeded();
11808
+ };
11809
+ var enqueueArtifacts = (portalId, artifacts) => {
11810
+ let enqueued = 0;
11811
+ for (const artifact of artifacts) {
11812
+ const key = makeJobKey(portalId, artifact.id);
11813
+ if (activeKeys.has(key)) continue;
11814
+ activeKeys.add(key);
11815
+ pendingJobs.push({ portalId, artifact, key });
11816
+ enqueued += 1;
11817
+ }
11818
+ if (enqueued > 0) {
11819
+ startWorkerIfNeeded();
11820
+ }
11821
+ };
11822
+ var waitForPortalDrain = (portalId, timeoutMs) => {
11823
+ if (!hasPendingForPortal(portalId)) {
11824
+ return Promise.resolve(true);
11825
+ }
11826
+ if (timeoutMs <= 0) {
11827
+ return Promise.resolve(false);
11828
+ }
11829
+ return new Promise((resolve) => {
11830
+ const waiter = {
11831
+ resolve,
11832
+ timeoutId: null
11833
+ };
11834
+ const timeoutId = setTimeout(() => {
11835
+ const waiters2 = drainWaiters.get(portalId);
11836
+ if (waiters2) {
11837
+ waiters2.delete(waiter);
11838
+ if (waiters2.size === 0) {
11839
+ drainWaiters.delete(portalId);
11840
+ }
11841
+ }
11842
+ waiter.timeoutId = null;
11843
+ resolve(false);
11844
+ }, timeoutMs);
11845
+ waiter.timeoutId = timeoutId;
11846
+ const waiters = drainWaiters.get(portalId) ?? /* @__PURE__ */ new Set();
11847
+ waiters.add(waiter);
11848
+ drainWaiters.set(portalId, waiters);
11849
+ maybeResolvePortalDrain(portalId);
11850
+ });
11851
+ };
11852
+ var hasPendingForPortal = (portalId) => inFlightJob?.portalId === portalId || pendingJobs.some((job) => job.portalId === portalId);
11853
+ var setSelectedPortal = (portalId) => {
11854
+ selectedPortalId = portalId;
11855
+ };
11856
+
11857
+ // src/effects/ensure-fresh.ts
11858
+ import fs8 from "fs";
11859
+ import { Effect as Effect92, pipe as pipe77 } from "effect";
11860
+ var DEFAULT_MAX_AGE_MS = 3e5;
11861
+ var DEFAULT_QUEUE_DRAIN_TIMEOUT_MS = 12e4;
11862
+ var DEFAULT_DIFF_TIMEOUT_MS = 3e4;
11863
+ var DIFF_POLL_INTERVAL_MS = 100;
11864
+ var LAST_DIFF_CHECKED_AT_KEY = "last_diff_checked_at";
11865
+ var createEnsureFresh = (deps, options2) => {
11866
+ const maxAgeMs = options2?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
11867
+ const queueDrainTimeoutMs = options2?.queueDrainTimeoutMs ?? DEFAULT_QUEUE_DRAIN_TIMEOUT_MS;
11868
+ const diffTimeoutMs = options2?.diffTimeoutMs ?? DEFAULT_DIFF_TIMEOUT_MS;
11869
+ return async (portalId) => {
11870
+ const selectedPortalId2 = deps.getSelectedPortalId();
11871
+ if (selectedPortalId2 === portalId) {
11872
+ return;
11873
+ }
11874
+ const drained = await deps.waitForPortalDrain(portalId, queueDrainTimeoutMs);
11875
+ if (!drained) {
11876
+ throw new Error(`Timed out waiting for portal ${portalId} artifact queue drain.`);
11877
+ }
11878
+ if (!deps.hasLocalDatabase(portalId)) {
11879
+ throw new Error(
11880
+ `Portal ${portalId} has no synced data yet. Background sync in progress - retry shortly.`
11881
+ );
11882
+ }
11883
+ const checkedAt = await deps.getFreshnessCheckedAt(portalId);
11884
+ const stale = checkedAt === null || Date.now() - checkedAt.getTime() > maxAgeMs;
11885
+ if (!stale) {
11886
+ return;
11887
+ }
11888
+ const objectSyncTimes = await deps.getObjectSyncTimes(portalId);
11889
+ if (Object.keys(objectSyncTimes).length === 0) {
11890
+ await deps.setFreshnessCheckedAt(portalId, /* @__PURE__ */ new Date());
11891
+ return;
11892
+ }
11893
+ const diffRequestedAt = Date.now();
11894
+ await deps.sendSyncDiff(portalId, objectSyncTimes);
11895
+ const completed = await deps.waitForDiffCompletion(portalId, diffRequestedAt, diffTimeoutMs);
11896
+ if (!completed) {
11897
+ throw new Error(`Timed out waiting for sync diff completion for portal ${portalId}.`);
11898
+ }
11899
+ await deps.setFreshnessCheckedAt(portalId, /* @__PURE__ */ new Date());
11900
+ };
11901
+ };
11902
+ var parseDate = (value) => {
11903
+ if (!value) return null;
11904
+ const parsed = Date.parse(value);
11905
+ return Number.isNaN(parsed) ? null : new Date(parsed);
11906
+ };
11907
+ var sleep2 = (ms) => new Promise((resolve) => {
11908
+ setTimeout(resolve, ms);
11909
+ });
11910
+ var runDatabaseForPortal = (portalId, getEncryptionKey, run) => Effect92.runPromise(
11911
+ pipe77(
11912
+ DatabaseService,
11913
+ Effect92.flatMap((db) => run(db)),
11914
+ Effect92.provide(makeDatabaseLive(portalId, getEncryptionKey()))
11915
+ )
11916
+ );
11917
+ var waitForPortalSyncedAtAfter = (portalState, portalId, sinceMs, timeoutMs) => (async () => {
11918
+ const startedAt = Date.now();
11919
+ while (Date.now() - startedAt <= timeoutMs) {
11920
+ const syncedAtMs = await Effect92.runPromise(
11921
+ pipe77(
11922
+ portalState.get(portalId),
11923
+ Effect92.map((state) => state.syncedAt.getTime()),
11924
+ Effect92.catchAll(() => Effect92.succeed(0))
11925
+ )
11926
+ );
11927
+ if (syncedAtMs > sinceMs) {
11928
+ return true;
11929
+ }
11930
+ await sleep2(DIFF_POLL_INTERVAL_MS);
11931
+ }
11932
+ return false;
11933
+ })();
11934
+ var makeEnsureFresh = (deps, options2) => createEnsureFresh(
11935
+ {
11936
+ getSelectedPortalId: deps.getSelectedPortalId,
11937
+ waitForPortalDrain,
11938
+ hasLocalDatabase: (portalId) => fs8.existsSync(dbPath(portalId)),
11939
+ getFreshnessCheckedAt: (portalId) => runDatabaseForPortal(
11940
+ portalId,
11941
+ deps.getEncryptionKey,
11942
+ (db) => pipe77(
11943
+ db.getMetadata(LAST_DIFF_CHECKED_AT_KEY),
11944
+ Effect92.map((value) => parseDate(value))
11945
+ )
11946
+ ),
11947
+ getObjectSyncTimes: (portalId) => runDatabaseForPortal(portalId, deps.getEncryptionKey, (db) => db.getObjectSyncTimes()),
11948
+ sendSyncDiff: (portalId, lastSyncTimes) => Effect92.runPromise(
11949
+ deps.ws.sendSyncDiff(portalId, lastSyncTimes).pipe(
11950
+ Effect92.mapError(
11951
+ (error) => error instanceof Error ? error : new Error(`Failed to request sync diff for portal ${portalId}`)
11952
+ )
11953
+ )
11954
+ ).then(() => void 0),
11955
+ waitForDiffCompletion: (portalId, sinceMs, timeoutMs) => waitForPortalSyncedAtAfter(deps.portalState, portalId, sinceMs, timeoutMs),
11956
+ setFreshnessCheckedAt: (portalId, checkedAt) => runDatabaseForPortal(
11957
+ portalId,
11958
+ deps.getEncryptionKey,
11959
+ (db) => db.setMetadata(LAST_DIFF_CHECKED_AT_KEY, checkedAt.toISOString())
11960
+ ).then(() => void 0)
11961
+ },
11962
+ options2
11963
+ );
11964
+
11965
+ // src/pure/plugin-sync-gate.ts
11966
+ var PLUGIN_SYNC_DEBOUNCE_MS = 30 * 60 * 1e3;
11967
+ var LAST_PLUGIN_SYNC_ALL_KEY = "last_plugin_sync_all";
11968
+ var shouldRunPluginSync = (lastSyncAllRaw) => {
11969
+ if (!lastSyncAllRaw) return true;
11970
+ const parsed = Date.parse(lastSyncAllRaw);
11971
+ if (Number.isNaN(parsed)) return true;
11972
+ return Date.now() - parsed >= PLUGIN_SYNC_DEBOUNCE_MS;
11973
+ };
11974
+
11232
11975
  // src/index.ts
11233
11976
  var server = new McpServer({
11234
11977
  name: "mcp-pro-client",
11235
11978
  version: "1.0.0"
11236
11979
  });
11237
- var mainProgram = Effect91.gen(function* () {
11980
+ var QUEUE_DRAIN_TIMEOUT_MS = 12e4;
11981
+ var mainProgram = Effect93.gen(function* () {
11238
11982
  const ws = yield* WebSocketService;
11239
11983
  const config = yield* ConfigService;
11240
11984
  const portalState = yield* PortalStateService;
11241
11985
  const getPortalId = () => getSelectedPortalId(config);
11242
- const getEncryptionKey = () => Effect91.runSync(ws.getEncryptionKey());
11243
- registerPortalSelection(server, { ws, config });
11244
- registerQueryTool(server, { getSelectedPortalId: getPortalId, getEncryptionKey });
11245
- registerChartTool(server, { getSelectedPortalId: getPortalId, getEncryptionKey });
11986
+ const getPortals = () => Effect93.runSync(ws.getPortals());
11987
+ const getEncryptionKey = () => Effect93.runSync(ws.getEncryptionKey());
11988
+ const ensureFresh = makeEnsureFresh({
11989
+ getSelectedPortalId: getPortalId,
11990
+ getEncryptionKey,
11991
+ ws,
11992
+ portalState
11993
+ });
11994
+ const handoffSelectedPortal = (nextPortalId) => {
11995
+ const previousPortalId = getSelectedPortalId(config);
11996
+ if (previousPortalId !== null && previousPortalId !== nextPortalId) {
11997
+ Effect93.runSync(
11998
+ pipe78(
11999
+ ws.sendSyncUnsubscribe(previousPortalId),
12000
+ Effect93.catchAll(() => Effect93.void)
12001
+ )
12002
+ );
12003
+ }
12004
+ setSelectedPortal(nextPortalId);
12005
+ Effect93.runSync(config.save({ selectedPortalId: nextPortalId }));
12006
+ Effect93.runFork(pipe78(ws.sendSyncFull(nextPortalId), Effect93.catchAll(() => Effect93.void)));
12007
+ };
12008
+ registerPortalSelection(server, { ws, config, handoffSelectedPortal });
12009
+ registerQueryTool(server, {
12010
+ getSelectedPortalId: getPortalId,
12011
+ getPortals,
12012
+ ensureFresh,
12013
+ getEncryptionKey
12014
+ });
12015
+ registerChartTool(server, {
12016
+ getSelectedPortalId: getPortalId,
12017
+ getPortals,
12018
+ ensureFresh,
12019
+ getEncryptionKey
12020
+ });
11246
12021
  registerStatusTool(server, {
11247
- getConnectionState: () => Effect91.runSync(ws.getState()),
11248
- getPortals: () => Effect91.runSync(ws.getPortals()),
12022
+ getConnectionState: () => Effect93.runSync(ws.getState()),
12023
+ getPortals,
11249
12024
  getSelectedPortalId: getPortalId,
12025
+ ensureFresh,
11250
12026
  getEncryptionKey
11251
12027
  });
11252
- registerBuildPlanTool(server);
12028
+ registerBuildPlanTool(server, {
12029
+ getSelectedPortalId: getPortalId,
12030
+ getPortals
12031
+ });
11253
12032
  registerDescribeOperationsTool(server);
11254
- registerSubmitPlanTool(server, { getSelectedPortalId: getPortalId, ws });
11255
- registerGetPlansTool(server, { getSelectedPortalId: getPortalId, ws });
12033
+ registerSubmitPlanTool(server, {
12034
+ getSelectedPortalId: getPortalId,
12035
+ getPortals,
12036
+ ws
12037
+ });
12038
+ registerGetPlansTool(server, {
12039
+ getSelectedPortalId: getPortalId,
12040
+ getPortals,
12041
+ ws
12042
+ });
11256
12043
  const state = yield* config.load();
11257
12044
  yield* config.save(state);
12045
+ setSelectedPortal(state.selectedPortalId);
11258
12046
  if (state.selectedPortalId !== null) {
11259
- const cached = yield* portalState.load(state.selectedPortalId).pipe(Effect91.catchAll(() => Effect91.succeed(null)));
12047
+ const cached = yield* portalState.load(state.selectedPortalId).pipe(Effect93.catchAll(() => Effect93.succeed(null)));
11260
12048
  if (cached !== null) {
11261
- yield* ws.setSyncedArtifacts(cached);
11262
- yield* Effect91.sync(
12049
+ yield* Effect93.sync(
11263
12050
  () => console.error(`[startup] Loaded ${cached.artifacts.length} cached artifacts for portal ${state.selectedPortalId}`)
11264
12051
  );
11265
12052
  }
11266
12053
  }
12054
+ const pendingPortalFollowUps = /* @__PURE__ */ new Set();
12055
+ const runDiffFlow = (portalId) => {
12056
+ const selectedPortalId2 = getSelectedPortalId(config);
12057
+ const flow = resolveDiffFlow(portalId, selectedPortalId2);
12058
+ if (flow === "diff_then_subscribe") {
12059
+ triggerDiffThenSubscribe(portalId);
12060
+ return;
12061
+ }
12062
+ triggerDiff(portalId);
12063
+ };
12064
+ const schedulePortalFollowUpAfterDrain = (portalId, source) => {
12065
+ if (pendingPortalFollowUps.has(portalId)) {
12066
+ console.error(`[live-sync] Follow-up already scheduled for portal ${portalId}, coalescing ${source}`);
12067
+ return;
12068
+ }
12069
+ pendingPortalFollowUps.add(portalId);
12070
+ Effect93.runFork(
12071
+ pipe78(
12072
+ Effect93.promise(() => waitForPortalDrain(portalId, QUEUE_DRAIN_TIMEOUT_MS)),
12073
+ Effect93.flatMap(
12074
+ (drained) => drained ? Effect93.sync(() => {
12075
+ console.error(`[live-sync] Queue drained for portal ${portalId} after ${source} \u2014 triggering diff flow`);
12076
+ runDiffFlow(portalId);
12077
+ }) : Effect93.sync(
12078
+ () => console.error(`[live-sync] Timed out waiting for portal ${portalId} queue drain after ${source}`)
12079
+ )
12080
+ ),
12081
+ Effect93.ensuring(
12082
+ Effect93.sync(() => {
12083
+ pendingPortalFollowUps.delete(portalId);
12084
+ })
12085
+ ),
12086
+ Effect93.catchAll(
12087
+ (error) => Effect93.sync(() => {
12088
+ console.error(`[live-sync] Failed post-queue follow-up for portal ${portalId}:`, error);
12089
+ })
12090
+ )
12091
+ )
12092
+ );
12093
+ };
12094
+ const subscribeIfStillSelected = (portalId) => {
12095
+ const selectedPortalId2 = getSelectedPortalId(config);
12096
+ if (selectedPortalId2 !== portalId) {
12097
+ console.error(
12098
+ `[live-sync] Skipping subscribe for portal ${portalId} because selected portal is ${selectedPortalId2 ?? "none"}`
12099
+ );
12100
+ return;
12101
+ }
12102
+ Effect93.runFork(pipe78(ws.sendSyncSubscribe(portalId), Effect93.catchAll(() => Effect93.void)));
12103
+ };
12104
+ const triggerDiff = (portalId) => {
12105
+ const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
12106
+ pipe78(
12107
+ DatabaseService,
12108
+ Effect93.flatMap((db) => db.getObjectSyncTimes()),
12109
+ Effect93.provide(dbLayer),
12110
+ Effect93.catchAll(() => Effect93.succeed({})),
12111
+ Effect93.flatMap((syncTimes) => {
12112
+ const count = Object.keys(syncTimes).length;
12113
+ if (count === 0) {
12114
+ return Effect93.sync(() => {
12115
+ console.error(`[live-sync] No watermarks found in DuckDB for portal ${portalId}, skipping diff`);
12116
+ });
12117
+ }
12118
+ return pipe78(
12119
+ Effect93.sync(() => {
12120
+ console.error(`[live-sync] Triggering catch-up diff for portal ${portalId} with ${count} object types: ${JSON.stringify(syncTimes)}`);
12121
+ }),
12122
+ Effect93.flatMap(() => pipe78(ws.sendSyncDiff(portalId, syncTimes), Effect93.catchAll(() => Effect93.void)))
12123
+ );
12124
+ }),
12125
+ Effect93.runFork
12126
+ );
12127
+ };
11267
12128
  const triggerDiffThenSubscribe = (portalId) => {
11268
12129
  const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11269
- pipe77(
12130
+ pipe78(
11270
12131
  DatabaseService,
11271
- Effect91.flatMap((db) => db.getObjectSyncTimes()),
11272
- Effect91.provide(dbLayer),
11273
- Effect91.catchAll(() => Effect91.succeed({})),
11274
- Effect91.tap(
11275
- (syncTimes) => Effect91.sync(() => {
11276
- const count = Object.keys(syncTimes).length;
11277
- if (count > 0) {
12132
+ Effect93.flatMap((db) => db.getObjectSyncTimes()),
12133
+ Effect93.provide(dbLayer),
12134
+ Effect93.catchAll(() => Effect93.succeed({})),
12135
+ Effect93.flatMap((syncTimes) => {
12136
+ const count = Object.keys(syncTimes).length;
12137
+ if (count === 0) {
12138
+ return Effect93.sync(() => {
12139
+ console.error(`[live-sync] No watermarks found in DuckDB for portal ${portalId}, subscribing directly`);
12140
+ subscribeIfStillSelected(portalId);
12141
+ });
12142
+ }
12143
+ return pipe78(
12144
+ Effect93.sync(() => {
11278
12145
  console.error(`[live-sync] Triggering catch-up diff for portal ${portalId} with ${count} object types: ${JSON.stringify(syncTimes)}`);
11279
- Effect91.runFork(pipe77(ws.sendSyncDiff(portalId, syncTimes), Effect91.catchAll(() => Effect91.void)));
11280
- } else {
11281
- console.error(`[live-sync] No watermarks found in DuckDB, subscribing directly for portal ${portalId}`);
11282
- Effect91.runFork(pipe77(ws.sendSyncSubscribe(portalId), Effect91.catchAll(() => Effect91.void)));
12146
+ }),
12147
+ Effect93.flatMap(() => pipe78(ws.sendSyncDiff(portalId, syncTimes), Effect93.catchAll(() => Effect93.void)))
12148
+ );
12149
+ }),
12150
+ Effect93.runFork
12151
+ );
12152
+ };
12153
+ const makeSyncLayer = (portalId) => {
12154
+ const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
12155
+ const wsLayer = Layer6.succeed(WebSocketService, ws);
12156
+ const configLayer = Layer6.succeed(ConfigService, config);
12157
+ const portalStateLayer = Layer6.succeed(PortalStateService, portalState);
12158
+ return SyncLive.pipe(
12159
+ Layer6.provide(Layer6.mergeAll(wsLayer, dbLayer, configLayer, portalStateLayer))
12160
+ );
12161
+ };
12162
+ const triggerPluginSync = (portalId) => {
12163
+ const syncLayer = makeSyncLayer(portalId);
12164
+ const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
12165
+ Effect93.runFork(
12166
+ pipe78(
12167
+ DatabaseService,
12168
+ Effect93.flatMap((db) => db.getMetadata(LAST_PLUGIN_SYNC_ALL_KEY)),
12169
+ Effect93.provide(dbLayer),
12170
+ Effect93.catchAll(() => Effect93.succeed(null)),
12171
+ Effect93.flatMap((lastSyncAll) => {
12172
+ if (!shouldRunPluginSync(lastSyncAll)) {
12173
+ return Effect93.sync(
12174
+ () => console.error(`[plugin-sync] Skipping plugin sync for portal ${portalId} (debounce \u2014 last sync: ${lastSyncAll})`)
12175
+ );
11283
12176
  }
12177
+ return pipe78(
12178
+ portalState.resetStuckPlugins(portalId),
12179
+ Effect93.catchAll(() => Effect93.void),
12180
+ Effect93.tap(() => Effect93.sync(
12181
+ () => console.error(`[plugin-sync] Starting message plugin sync for portal ${portalId}`)
12182
+ )),
12183
+ Effect93.flatMap(
12184
+ () => pipe78(
12185
+ SyncService,
12186
+ Effect93.flatMap((sync) => sync.syncMessagePlugins(portalId)),
12187
+ Effect93.provide(syncLayer)
12188
+ )
12189
+ ),
12190
+ Effect93.flatMap(
12191
+ () => pipe78(
12192
+ DatabaseService,
12193
+ Effect93.flatMap((db) => db.setMetadata(LAST_PLUGIN_SYNC_ALL_KEY, (/* @__PURE__ */ new Date()).toISOString())),
12194
+ Effect93.provide(dbLayer)
12195
+ )
12196
+ ),
12197
+ Effect93.tap(() => Effect93.sync(
12198
+ () => console.error(`[plugin-sync] Completed message plugin sync for portal ${portalId}`)
12199
+ )),
12200
+ Effect93.catchAll(
12201
+ (error) => Effect93.sync(
12202
+ () => console.error(`[plugin-sync] Failed for portal ${portalId}:`, error)
12203
+ )
12204
+ )
12205
+ );
11284
12206
  })
11285
- ),
11286
- Effect91.runFork
12207
+ )
11287
12208
  );
11288
12209
  };
12210
+ configureArtifactQueue(
12211
+ (portalId, artifact) => pipe78(
12212
+ SyncService,
12213
+ Effect93.flatMap((sync) => sync.syncArtifacts(portalId, [artifact], [artifact.object_type])),
12214
+ Effect93.flatMap(() => portalState.get(portalId)),
12215
+ Effect93.map((portal) => {
12216
+ const status = portal.artifacts.find((a) => a.object_type === artifact.object_type)?.status;
12217
+ if (status !== "SYNCED") {
12218
+ console.error(
12219
+ `[artifact-queue] Artifact ${artifact.object_type} for portal ${portalId} finished with status ${status ?? "MISSING"}`
12220
+ );
12221
+ }
12222
+ return status === "SYNCED";
12223
+ }),
12224
+ Effect93.catchAll(
12225
+ (err) => Effect93.sync(() => {
12226
+ console.error(
12227
+ `[artifact-queue] Failed ingest for portal ${portalId} artifact ${artifact.object_type}:`,
12228
+ err
12229
+ );
12230
+ return false;
12231
+ })
12232
+ ),
12233
+ Effect93.provide(makeSyncLayer(portalId))
12234
+ )
12235
+ );
11289
12236
  yield* ws.registerHandlers({
11290
12237
  onSyncArtifacts: (portalId, artifacts) => {
11291
12238
  const incomingWithStatus = artifacts.map((a) => ({ ...a, status: "NOT_STARTED" }));
11292
- const updated = pipe77(
12239
+ const updated = pipe78(
11293
12240
  portalState.setArtifacts(portalId, incomingWithStatus),
11294
- Effect91.tap((s) => ws.setSyncedArtifacts(s)),
11295
- Effect91.tap(
11296
- (s) => Effect91.sync(
12241
+ Effect93.tap(
12242
+ (s) => Effect93.sync(
11297
12243
  () => console.error(`[sync:full] Saved ${s.artifacts.length} artifacts for portal ${portalId}`)
11298
12244
  )
11299
12245
  ),
11300
- Effect91.catchAll(() => Effect91.succeed(null)),
11301
- Effect91.runSync
12246
+ Effect93.catchAll(() => Effect93.succeed(null)),
12247
+ Effect93.runSync
11302
12248
  );
11303
12249
  if (!updated) return;
11304
12250
  const objectTypesToSync = incomingWithStatus.map((a) => a.object_type);
11305
12251
  const toSync = filterUnsyncedArtifacts(updated.artifacts, new Set(objectTypesToSync));
11306
12252
  if (toSync.length === 0) {
11307
- console.error(`[live-sync] All ${artifacts.length} artifacts already SYNCED for portal ${portalId} \u2014 syncing message plugins then catch-up diff`);
11308
- const dbLayer2 = makeDatabaseLive(portalId, getEncryptionKey());
11309
- const wsLayer2 = Layer6.succeed(WebSocketService, ws);
11310
- const configLayer2 = Layer6.succeed(ConfigService, config);
11311
- const portalStateLayer2 = Layer6.succeed(PortalStateService, portalState);
11312
- const syncLayer2 = SyncLive.pipe(
11313
- Layer6.provide(Layer6.mergeAll(wsLayer2, dbLayer2, configLayer2, portalStateLayer2))
12253
+ console.error(
12254
+ `[artifact-queue] All ${artifacts.length} artifacts already SYNCED for portal ${portalId} \u2014 no enqueue needed`
11314
12255
  );
11315
- pipe77(
11316
- SyncService,
11317
- Effect91.flatMap((sync) => sync.syncMessagePlugins(portalId)),
11318
- Effect91.tap(
11319
- () => Effect91.sync(() => {
11320
- console.error(`[live-sync] Message plugins synced for portal ${portalId} \u2014 starting catch-up diff`);
11321
- triggerDiffThenSubscribe(portalId);
11322
- })
11323
- ),
11324
- Effect91.catchAll(
11325
- (err) => Effect91.sync(() => {
11326
- console.error(`[live-sync] Message plugin sync failed:`, err);
11327
- triggerDiffThenSubscribe(portalId);
11328
- })
11329
- ),
11330
- Effect91.provide(syncLayer2),
11331
- Effect91.runFork
12256
+ } else {
12257
+ console.error(
12258
+ `[artifact-queue] Enqueueing ${toSync.length}/${artifacts.length} artifacts for portal ${portalId}`
11332
12259
  );
11333
- return;
12260
+ enqueueArtifacts(portalId, toSync);
11334
12261
  }
11335
- console.error(`[sync] ${toSync.length}/${artifacts.length} artifacts need syncing`);
11336
- const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11337
- const wsLayer = Layer6.succeed(WebSocketService, ws);
11338
- const configLayer = Layer6.succeed(ConfigService, config);
11339
- const portalStateLayer = Layer6.succeed(PortalStateService, portalState);
11340
- const syncLayer = SyncLive.pipe(
11341
- Layer6.provide(Layer6.mergeAll(wsLayer, dbLayer, configLayer, portalStateLayer))
11342
- );
11343
- pipe77(
11344
- SyncService,
11345
- Effect91.flatMap(
11346
- (sync) => Effect91.all([
11347
- sync.syncArtifacts(portalId, updated.artifacts, objectTypesToSync).pipe(
11348
- Effect91.tap(
11349
- (progress) => Effect91.sync(() => {
11350
- console.error(`[sync] Complete: ${progress.completedArtifacts}/${progress.totalArtifacts} artifacts synced`);
11351
- console.error(`[live-sync] Artifact sync done for portal ${portalId} \u2014 starting catch-up diff`);
11352
- triggerDiffThenSubscribe(portalId);
11353
- })
11354
- ),
11355
- Effect91.catchAll(
11356
- (err) => Effect91.sync(() => console.error(`[sync] Sync failed:`, err))
11357
- )
11358
- ),
11359
- sync.syncMessagePlugins(portalId).pipe(
11360
- Effect91.tap(
11361
- () => Effect91.sync(() => console.error(`[sync] Message plugins synced independently for portal ${portalId}`))
11362
- ),
11363
- Effect91.catchAll(
11364
- (err) => Effect91.sync(() => console.error(`[sync] Message plugin sync failed:`, err))
11365
- )
11366
- )
11367
- ], { concurrency: 2 })
11368
- ),
11369
- Effect91.catchAll(() => Effect91.void),
11370
- Effect91.provide(syncLayer),
11371
- Effect91.runFork
11372
- );
12262
+ schedulePortalFollowUpAfterDrain(portalId, "sync:full");
11373
12263
  },
11374
12264
  onNewArtifacts: (portalId, artifacts) => {
11375
12265
  console.error(`[sync:new-artifacts] Discovered ${artifacts.length} new artifact(s) for portal ${portalId}: ${artifacts.map((a) => a.object_type).join(", ")}`);
11376
12266
  const newWithStatus = artifacts.map((a) => ({ ...a, status: "NOT_STARTED" }));
11377
12267
  const objectTypesToSync = newWithStatus.map((a) => a.object_type);
11378
- const merged = pipe77(
12268
+ const merged = pipe78(
11379
12269
  portalState.mergeArtifacts(portalId, newWithStatus),
11380
- Effect91.tap((m) => ws.setSyncedArtifacts(m)),
11381
- Effect91.catchAll(() => Effect91.succeed(null)),
11382
- Effect91.runSync
12270
+ Effect93.catchAll(() => Effect93.succeed(null)),
12271
+ Effect93.runSync
11383
12272
  );
11384
12273
  if (!merged) return;
11385
- const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11386
- const wsLayer = Layer6.succeed(WebSocketService, ws);
11387
- const configLayer = Layer6.succeed(ConfigService, config);
11388
- const portalStateLayer = Layer6.succeed(PortalStateService, portalState);
11389
- const syncLayer = SyncLive.pipe(
11390
- Layer6.provide(Layer6.mergeAll(wsLayer, dbLayer, configLayer, portalStateLayer))
11391
- );
11392
- pipe77(
11393
- SyncService,
11394
- Effect91.flatMap((sync) => sync.syncArtifacts(portalId, merged.artifacts, objectTypesToSync)),
11395
- Effect91.tap(
11396
- (progress) => Effect91.sync(() => {
11397
- console.error(`[sync:new-artifacts] Download complete: ${progress.completedArtifacts}/${progress.totalArtifacts} new artifacts synced`);
11398
- console.error(`[sync:new-artifacts] Triggering diff for new types on portal ${portalId}`);
11399
- triggerDiffThenSubscribe(portalId);
11400
- })
11401
- ),
11402
- Effect91.catchAll(
11403
- (err) => Effect91.sync(() => console.error(`[sync:new-artifacts] Sync failed:`, err))
11404
- ),
11405
- Effect91.provide(syncLayer),
11406
- Effect91.runFork
11407
- );
12274
+ const toSync = filterUnsyncedArtifacts(merged.artifacts, new Set(objectTypesToSync));
12275
+ if (toSync.length === 0) {
12276
+ console.error(
12277
+ `[artifact-queue] All ${artifacts.length} new artifacts already SYNCED for portal ${portalId} \u2014 no enqueue needed`
12278
+ );
12279
+ } else {
12280
+ console.error(
12281
+ `[artifact-queue] Enqueueing ${toSync.length}/${artifacts.length} new artifact(s) for portal ${portalId}`
12282
+ );
12283
+ enqueueArtifacts(portalId, toSync);
12284
+ }
12285
+ schedulePortalFollowUpAfterDrain(portalId, "sync:new-artifacts");
11408
12286
  },
11409
12287
  onDiffBatch: (portalId, batch) => {
11410
12288
  const plugin = findArtifactPlugin(batch.object_type);
@@ -11413,54 +12291,85 @@ var mainProgram = Effect91.gen(function* () {
11413
12291
  return;
11414
12292
  }
11415
12293
  const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11416
- pipe77(
12294
+ pipe78(
11417
12295
  DatabaseService,
11418
- Effect91.flatMap((db) => {
12296
+ Effect93.flatMap((db) => {
11419
12297
  const ctx = { db, ws, portalState, portalId };
11420
12298
  return plugin.processDiffBatch(ctx, batch);
11421
12299
  }),
11422
- Effect91.catchAll(
11423
- (err) => Effect91.sync(() => console.error(`[diff-sync] Error processing batch:`, err))
12300
+ Effect93.catchAll(
12301
+ (err) => Effect93.sync(() => console.error(`[diff-sync] Error processing batch:`, err))
11424
12302
  ),
11425
- Effect91.provide(dbLayer),
11426
- Effect91.runFork
12303
+ Effect93.provide(dbLayer),
12304
+ Effect93.runFork
11427
12305
  );
11428
12306
  },
11429
12307
  onDiffComplete: (portalId, payload) => {
11430
- console.error(`[live-sync] Catch-up diff complete for portal ${portalId} \u2014 ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations \u2014 subscribing for real-time updates`);
11431
- Effect91.runFork(pipe77(ws.sendSyncSubscribe(portalId), Effect91.catchAll(() => Effect91.void)));
12308
+ if (!payload.success) {
12309
+ console.error(`[live-sync] Catch-up diff failed for portal ${portalId}: ${payload.error ?? "unknown"}`);
12310
+ return;
12311
+ }
12312
+ Effect93.runFork(pipe78(portalState.touchSyncedAt(portalId), Effect93.catchAll(() => Effect93.void)));
12313
+ const selectedPortalId2 = getSelectedPortalId(config);
12314
+ const shouldSubscribe = shouldSubscribeAfterDiffComplete(portalId, selectedPortalId2, payload.success);
12315
+ if (!shouldSubscribe) {
12316
+ console.error(
12317
+ `[live-sync] Catch-up diff complete for portal ${portalId} \u2014 ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations \u2014 not subscribing (selected portal: ${selectedPortalId2 ?? "none"})`
12318
+ );
12319
+ return;
12320
+ }
12321
+ console.error(
12322
+ `[live-sync] Catch-up diff complete for portal ${portalId} \u2014 ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations \u2014 subscribing for real-time updates`
12323
+ );
12324
+ Effect93.runFork(pipe78(ws.sendSyncSubscribe(portalId), Effect93.catchAll(() => Effect93.void)));
11432
12325
  },
11433
12326
  onSubscribeAck: (_payload) => {
11434
12327
  },
11435
- autoSelectSinglePortal: (portals) => autoSelectSinglePortal(config, portals),
12328
+ autoSelectSinglePortal: (portals) => {
12329
+ const autoSelected = autoSelectSinglePortal(config, portals);
12330
+ if (autoSelected !== null) {
12331
+ setSelectedPortal(autoSelected);
12332
+ }
12333
+ return autoSelected;
12334
+ },
11436
12335
  onRegisterSuccess: (portals) => {
11437
- if (state.selectedPortalId === null) return;
11438
- const portalId = state.selectedPortalId;
11439
- const match = portals.find((p) => p.target_portal === portalId);
11440
- if (!match) {
11441
- console.error(`[live-sync] Previously selected portal ${portalId} not in portal list, skipping diff`);
11442
- return;
12336
+ const selectedPortalId2 = getSelectedPortalId(config);
12337
+ setSelectedPortal(selectedPortalId2);
12338
+ const warmupPortalIds = orderPortalWarmupIds(portals, selectedPortalId2);
12339
+ if (selectedPortalId2 !== null && !warmupPortalIds.includes(selectedPortalId2)) {
12340
+ console.error(`[live-sync] Previously selected portal ${selectedPortalId2} not in portal list; warming available portals only`);
12341
+ }
12342
+ console.error(`[live-sync] Registration complete \u2014 warming ${warmupPortalIds.length} portal(s): ${warmupPortalIds.join(", ")}`);
12343
+ for (const portalId of warmupPortalIds) {
12344
+ Effect93.runFork(pipe78(ws.sendSyncFull(portalId), Effect93.catchAll(() => Effect93.void)));
12345
+ }
12346
+ for (const portalId of warmupPortalIds) {
12347
+ triggerPluginSync(portalId);
11443
12348
  }
11444
- console.error(`[live-sync] Portal ${portalId} reconnected \u2014 sending sync:full to refresh artifacts`);
11445
- Effect91.runFork(pipe77(ws.sendSyncFull(portalId), Effect91.catchAll(() => Effect91.void)));
11446
12349
  },
11447
12350
  onSyncDiffNeeded: (portalId) => {
11448
- console.error(`[live-sync] onSyncDiffNeeded called for portal ${portalId}`);
11449
- triggerDiffThenSubscribe(portalId);
12351
+ const selectedPortalId2 = getSelectedPortalId(config);
12352
+ const flow = resolveDiffFlow(portalId, selectedPortalId2);
12353
+ console.error(`[live-sync] onSyncDiffNeeded called for portal ${portalId} \u2014 running ${flow}`);
12354
+ if (flow === "diff_then_subscribe") {
12355
+ triggerDiffThenSubscribe(portalId);
12356
+ return;
12357
+ }
12358
+ triggerDiff(portalId);
11450
12359
  }
11451
12360
  });
11452
12361
  yield* ws.connect().pipe(
11453
- Effect91.catchAll((e) => Effect91.sync(() => console.error("WebSocket error:", e)))
12362
+ Effect93.catchAll((e) => Effect93.sync(() => console.error("WebSocket error:", e)))
11454
12363
  );
11455
12364
  const transport = new StdioServerTransport();
11456
- yield* Effect91.promise(() => server.connect(transport));
11457
- yield* Effect91.sync(() => console.error("MCP Pro Client server started"));
11458
- yield* Effect91.never;
12365
+ yield* Effect93.promise(() => server.connect(transport));
12366
+ yield* Effect93.sync(() => console.error("MCP Pro Client server started"));
12367
+ yield* Effect93.never;
11459
12368
  });
11460
- pipe77(
12369
+ pipe78(
11461
12370
  mainProgram,
11462
- Effect91.provide(Layer6.mergeAll(WebSocketLive, ConfigLive, PortalStateLive)),
11463
- Effect91.runPromise
12371
+ Effect93.provide(Layer6.mergeAll(WebSocketLive, ConfigLive, PortalStateLive)),
12372
+ Effect93.runPromise
11464
12373
  ).catch((error) => {
11465
12374
  console.error("Fatal error:", error);
11466
12375
  process.exit(1);