fullstackgtm 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -1
- package/dist/bulkUpdate.d.ts +37 -0
- package/dist/bulkUpdate.js +315 -0
- package/dist/cli.js +72 -1
- package/dist/connector.d.ts +6 -0
- package/dist/connector.js +158 -17
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/market.js +3 -0
- package/dist/mcp.js +13 -2
- package/dist/types.d.ts +44 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +375 -0
- package/src/cli.ts +75 -1
- package/src/connector.ts +169 -23
- package/src/index.ts +2 -0
- package/src/market.ts +3 -0
- package/src/mcp.ts +15 -2
- package/src/types.ts +39 -0
package/src/connector.ts
CHANGED
|
@@ -27,6 +27,12 @@ export type ApplyPatchPlanOptions = {
|
|
|
27
27
|
* `readField`.
|
|
28
28
|
*/
|
|
29
29
|
checkConflicts?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* For plans carrying a filter or guards: re-run the snapshot checks after
|
|
32
|
+
* the first applied write and then every N applied writes, so a record
|
|
33
|
+
* edited mid-apply is conflicted out instead of overwritten. Default 25.
|
|
34
|
+
*/
|
|
35
|
+
recheckEvery?: number;
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
|
|
@@ -60,6 +66,129 @@ export async function applyPatchPlan(
|
|
|
60
66
|
const results: PatchOperationResult[] = [];
|
|
61
67
|
let attempted = 0;
|
|
62
68
|
let applied = 0;
|
|
69
|
+
let appliedSinceRecheck = 0;
|
|
70
|
+
|
|
71
|
+
// Pass 0 — snapshot-backed checks: plan-level guards and per-record
|
|
72
|
+
// filter re-verification, both against a FRESH snapshot. Cross-record
|
|
73
|
+
// eligibility ("the account still has no open contractsent deal") cannot
|
|
74
|
+
// be checked through per-operation field reads.
|
|
75
|
+
//
|
|
76
|
+
// These checks are NOT one-shot: a concurrent edit can land mid-apply,
|
|
77
|
+
// after the initial verification but before later writes (the TOCTOU
|
|
78
|
+
// window). Providers offer no compare-and-swap, so the window cannot be
|
|
79
|
+
// closed — but it can be shrunk: re-run the snapshot checks after the
|
|
80
|
+
// first write and every `recheckEvery` writes, conflicting out any
|
|
81
|
+
// operation whose record went stale mid-run.
|
|
82
|
+
const needsSnapshot =
|
|
83
|
+
((plan.guards && plan.guards.length > 0) || plan.filter) && connector.fetchSnapshot;
|
|
84
|
+
const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
|
|
85
|
+
const staleIds = new Set<string>();
|
|
86
|
+
let guardFailure: string | null = null;
|
|
87
|
+
const refreshSnapshotChecks = async (): Promise<void> => {
|
|
88
|
+
if (!needsSnapshot) return;
|
|
89
|
+
const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.ts");
|
|
90
|
+
const liveSnapshot = await connector.fetchSnapshot!();
|
|
91
|
+
if (plan.filter) {
|
|
92
|
+
const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where);
|
|
93
|
+
staleIds.clear();
|
|
94
|
+
for (const operation of plan.operations) {
|
|
95
|
+
if (!stillEligible.has(operation.objectId)) staleIds.add(operation.objectId);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const guard of plan.guards ?? []) {
|
|
99
|
+
const failure = evaluateGuard(liveSnapshot, guard);
|
|
100
|
+
if (failure) {
|
|
101
|
+
guardFailure = failure;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
await refreshSnapshotChecks();
|
|
107
|
+
if (guardFailure) {
|
|
108
|
+
for (const operation of plan.operations) {
|
|
109
|
+
results.push({
|
|
110
|
+
operationId: operation.id,
|
|
111
|
+
status: approved.has(operation.id) ? "conflict" : "skipped",
|
|
112
|
+
detail: approved.has(operation.id)
|
|
113
|
+
? `${guardFailure} No operation in this plan was applied — re-plan against current data.`
|
|
114
|
+
: "Operation was not approved.",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
planId: plan.id,
|
|
119
|
+
provider: connector.provider,
|
|
120
|
+
startedAt,
|
|
121
|
+
finishedAt: new Date().toISOString(),
|
|
122
|
+
status: "rejected",
|
|
123
|
+
results,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const staleByFilter: Set<string> | null = plan.filter ? staleIds : null;
|
|
127
|
+
|
|
128
|
+
// Pass 1 — conflict detection. Re-read the written field (beforeValue
|
|
129
|
+
// compare-and-set) and any explicit preconditions. A conflicted operation
|
|
130
|
+
// never writes; if it carries a groupId, the whole group is poisoned so
|
|
131
|
+
// multi-record changes stay all-or-nothing.
|
|
132
|
+
const conflicts = new Map<string, PatchOperationResult>();
|
|
133
|
+
const poisonedGroups = new Set<string>();
|
|
134
|
+
if (staleByFilter && staleByFilter.size > 0) {
|
|
135
|
+
for (const operation of plan.operations) {
|
|
136
|
+
if (!approved.has(operation.id) || !staleByFilter.has(operation.objectId)) continue;
|
|
137
|
+
conflicts.set(operation.id, {
|
|
138
|
+
operationId: operation.id,
|
|
139
|
+
status: "conflict",
|
|
140
|
+
detail: `Record ${operation.objectType}/${operation.objectId} no longer matches the plan's filter [${plan.filter!.where.join(" AND ")}] — it changed since the plan was built. Re-plan against current data.`,
|
|
141
|
+
});
|
|
142
|
+
if (operation.groupId) poisonedGroups.add(operation.groupId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (checkConflicts && connector.readField) {
|
|
146
|
+
for (const operation of plan.operations) {
|
|
147
|
+
if (!approved.has(operation.id) || conflicts.has(operation.id)) continue;
|
|
148
|
+
let conflict: PatchOperationResult | null = null;
|
|
149
|
+
if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
|
|
150
|
+
const current = await connector.readField(
|
|
151
|
+
operation.objectType,
|
|
152
|
+
operation.objectId,
|
|
153
|
+
operation.field,
|
|
154
|
+
);
|
|
155
|
+
const expected = normalizeForComparison(operation.beforeValue);
|
|
156
|
+
const found = normalizeForComparison(current);
|
|
157
|
+
if (expected !== found) {
|
|
158
|
+
conflict = {
|
|
159
|
+
operationId: operation.id,
|
|
160
|
+
status: "conflict",
|
|
161
|
+
detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
|
|
162
|
+
providerData: { currentValue: current ?? null },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!conflict && operation.preconditions) {
|
|
167
|
+
for (const precondition of operation.preconditions) {
|
|
168
|
+
const current = await connector.readField(
|
|
169
|
+
operation.objectType,
|
|
170
|
+
operation.objectId,
|
|
171
|
+
precondition.field,
|
|
172
|
+
);
|
|
173
|
+
const expected = normalizeForComparison(precondition.expectedValue);
|
|
174
|
+
const found = normalizeForComparison(current);
|
|
175
|
+
if (expected !== found) {
|
|
176
|
+
conflict = {
|
|
177
|
+
operationId: operation.id,
|
|
178
|
+
status: "conflict",
|
|
179
|
+
detail: `Precondition failed: ${precondition.field} was ${expected ?? "∅"} when the plan was built, found ${found ?? "∅"} now. The record changed — re-plan before writing.`,
|
|
180
|
+
providerData: { preconditionField: precondition.field, currentValue: current ?? null },
|
|
181
|
+
};
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (conflict) {
|
|
187
|
+
conflicts.set(operation.id, conflict);
|
|
188
|
+
if (operation.groupId) poisonedGroups.add(operation.groupId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
63
192
|
|
|
64
193
|
for (const operation of plan.operations) {
|
|
65
194
|
if (!approved.has(operation.id)) {
|
|
@@ -81,28 +210,35 @@ export async function applyPatchPlan(
|
|
|
81
210
|
continue;
|
|
82
211
|
}
|
|
83
212
|
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
213
|
+
if (guardFailure) {
|
|
214
|
+
results.push({
|
|
215
|
+
operationId: operation.id,
|
|
216
|
+
status: "conflict",
|
|
217
|
+
detail: `${guardFailure} Detected mid-apply — this and all remaining operations were not applied.`,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const conflict = conflicts.get(operation.id);
|
|
222
|
+
if (conflict) {
|
|
223
|
+
results.push(conflict);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (staleByFilter && staleByFilter.has(operation.objectId)) {
|
|
227
|
+
results.push({
|
|
228
|
+
operationId: operation.id,
|
|
229
|
+
status: "conflict",
|
|
230
|
+
detail: `Record ${operation.objectType}/${operation.objectId} no longer matches the plan's filter [${plan.filter!.where.join(" AND ")}] (changed mid-apply). Not applied — re-plan against current data.`,
|
|
231
|
+
});
|
|
232
|
+
if (operation.groupId) poisonedGroups.add(operation.groupId);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (operation.groupId && poisonedGroups.has(operation.groupId)) {
|
|
236
|
+
results.push({
|
|
237
|
+
operationId: operation.id,
|
|
238
|
+
status: "skipped",
|
|
239
|
+
detail: `Skipped: another operation in group ${operation.groupId} hit a conflict — the group applies all-or-nothing.`,
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
106
242
|
}
|
|
107
243
|
|
|
108
244
|
const resolved: PatchOperation =
|
|
@@ -112,7 +248,17 @@ export async function applyPatchPlan(
|
|
|
112
248
|
try {
|
|
113
249
|
const result = await connector.applyOperation(resolved);
|
|
114
250
|
results.push(result);
|
|
115
|
-
if (result.status === "applied")
|
|
251
|
+
if (result.status === "applied") {
|
|
252
|
+
applied += 1;
|
|
253
|
+
appliedSinceRecheck += 1;
|
|
254
|
+
// shrink the TOCTOU window: first write fires a re-check (a
|
|
255
|
+
// concurrent editor reacting to our changes shows up immediately),
|
|
256
|
+
// then re-check on a fixed cadence
|
|
257
|
+
if (applied === 1 || appliedSinceRecheck >= recheckEvery) {
|
|
258
|
+
appliedSinceRecheck = 0;
|
|
259
|
+
await refreshSnapshotChecks();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
116
262
|
} catch (error) {
|
|
117
263
|
results.push({
|
|
118
264
|
operationId: operation.id,
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
2
|
+
export { buildBulkUpdatePlan, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
|
|
2
3
|
export {
|
|
3
4
|
CONFIG_FILE_NAME,
|
|
4
5
|
loadConfig,
|
|
@@ -118,6 +119,7 @@ export {
|
|
|
118
119
|
DEFAULT_RUBRIC,
|
|
119
120
|
detectProviderFromKey,
|
|
120
121
|
extractInsightsLlm,
|
|
122
|
+
forcedToolCall,
|
|
121
123
|
parseRubric,
|
|
122
124
|
resolveLlmCredential,
|
|
123
125
|
scoreCallLlm,
|
package/src/market.ts
CHANGED
|
@@ -172,6 +172,9 @@ export function parseMarketConfig(raw: string): MarketConfig {
|
|
|
172
172
|
if (!axis.id) throw new Error("market config: axis missing id");
|
|
173
173
|
if (axisIds.has(axis.id)) throw new Error(`market config: duplicate axis id "${axis.id}"`);
|
|
174
174
|
axisIds.add(axis.id);
|
|
175
|
+
if (!axis.negativePole || !axis.positivePole) {
|
|
176
|
+
throw new Error(`market config: axis "${axis.id}" needs negativePole and positivePole labels (the strategic map renders them as axis ends)`);
|
|
177
|
+
}
|
|
175
178
|
for (const claimId of Object.keys(axis.claimScores ?? {})) {
|
|
176
179
|
if (!claimIds.has(claimId)) {
|
|
177
180
|
throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
|
package/src/mcp.ts
CHANGED
|
@@ -335,7 +335,7 @@ export async function startMcpServer() {
|
|
|
335
335
|
},
|
|
336
336
|
},
|
|
337
337
|
async ({ vendorId, configPath, captureRun }) => {
|
|
338
|
-
const config =
|
|
338
|
+
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
339
339
|
return content(buildWorksheet(config, vendorId, { captureRun }));
|
|
340
340
|
},
|
|
341
341
|
);
|
|
@@ -355,7 +355,7 @@ export async function startMcpServer() {
|
|
|
355
355
|
},
|
|
356
356
|
},
|
|
357
357
|
async ({ observationsPath, configPath }) => {
|
|
358
|
-
const config =
|
|
358
|
+
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
359
359
|
const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8")) as ObservationSet;
|
|
360
360
|
const problems = validateObservationSet(config, set);
|
|
361
361
|
const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
|
|
@@ -375,3 +375,16 @@ export async function startMcpServer() {
|
|
|
375
375
|
const transport = new StdioServerTransport();
|
|
376
376
|
await server.connect(transport);
|
|
377
377
|
}
|
|
378
|
+
|
|
379
|
+
function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
|
|
380
|
+
try {
|
|
381
|
+
return loadMarketConfig(path);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`No market config at ${path} — run \`fullstackgtm market init --category <name>\` in that directory first, or pass configPath.`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -289,6 +289,20 @@ export type PatchOperation = {
|
|
|
289
289
|
evidenceIds?: string[];
|
|
290
290
|
findingIds?: string[];
|
|
291
291
|
verification?: PatchVerification;
|
|
292
|
+
/**
|
|
293
|
+
* Compare-and-set guards beyond the written field: each precondition is
|
|
294
|
+
* re-read at apply time and a mismatch turns the operation into a
|
|
295
|
+
* conflict instead of a write. Guards against a record drifting on a
|
|
296
|
+
* DIFFERENT field than the one being written (e.g. stage changed while
|
|
297
|
+
* an owner write was pending).
|
|
298
|
+
*/
|
|
299
|
+
preconditions?: Array<{ field: string; expectedValue: unknown }>;
|
|
300
|
+
/**
|
|
301
|
+
* Operations sharing a groupId are all-or-nothing at apply time: a
|
|
302
|
+
* conflict (beforeValue or precondition) on any member skips every
|
|
303
|
+
* member of the group.
|
|
304
|
+
*/
|
|
305
|
+
groupId?: string;
|
|
292
306
|
};
|
|
293
307
|
|
|
294
308
|
/**
|
|
@@ -306,6 +320,31 @@ export type PatchPlan = {
|
|
|
306
320
|
pipelineFindings?: PipelineFinding[];
|
|
307
321
|
evidence?: GtmEvidence[];
|
|
308
322
|
operations: PatchOperation[];
|
|
323
|
+
/**
|
|
324
|
+
* The filter this plan's operations were selected by. Re-evaluated per
|
|
325
|
+
* record against a FRESH snapshot at apply time: any operation whose
|
|
326
|
+
* record no longer matches is reported as a conflict instead of applied.
|
|
327
|
+
* Unlike per-operation preconditions, this enforces the FULL filter —
|
|
328
|
+
* negations and relational pseudo-fields included.
|
|
329
|
+
*/
|
|
330
|
+
filter?: { objectType: "account" | "contact" | "deal"; where: string[] };
|
|
331
|
+
/**
|
|
332
|
+
* Plan-level guards re-evaluated against a FRESH snapshot at apply time.
|
|
333
|
+
* If any guard fails, NO operation in the plan is applied. This is how a
|
|
334
|
+
* plan expresses cross-record eligibility ("apply only while the account
|
|
335
|
+
* still has no open deal in contractsent") that per-operation
|
|
336
|
+
* preconditions cannot reach.
|
|
337
|
+
*/
|
|
338
|
+
guards?: PlanGuard[];
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export type PlanGuard = {
|
|
342
|
+
objectType: "account" | "contact" | "deal";
|
|
343
|
+
/** filter expressions in bulk-update --where grammar, AND-ed */
|
|
344
|
+
where: string[];
|
|
345
|
+
/** none: guard passes when ZERO records match; some: when at least one matches */
|
|
346
|
+
expect: "none" | "some";
|
|
347
|
+
description?: string;
|
|
309
348
|
};
|
|
310
349
|
|
|
311
350
|
// ── Audit rule engine ──────────────────────────────────────────
|