@funnelsgrove/cli 0.1.19 → 0.1.24
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/README.md +16 -0
- package/dist/analyticsOutput.d.ts +2 -1
- package/dist/analyticsOutput.js +35 -10
- package/dist/apiClient.d.ts +9 -1
- package/dist/apiClient.js +6 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +37 -1
- package/dist/experimentCreate.d.ts +100 -0
- package/dist/experimentCreate.js +887 -0
- package/dist/localSync.d.ts +8 -0
- package/dist/localSync.js +71 -11
- package/package.json +1 -1
- package/template_docs/.funnelsgrove-docs.json +4 -4
- package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_docs/docs/funnelsgrove/recipes/add-experiment.md +156 -7
- package/template_docs/funnel-docs.config.json +1 -1
- package/template_scaffold/.funnelsgrove-docs.json +4 -4
- package/template_scaffold/.funnelsgrove-scaffold.json +9 -9
- package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_scaffold/docs/funnelsgrove/recipes/add-experiment.md +156 -7
- package/template_scaffold/funnel-agent-docs.test.ts +27 -1
- package/template_scaffold/funnel-docs.config.json +1 -1
- package/template_scaffold/package-lock.json +4 -4
- package/template_scaffold/package.json +1 -1
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, open, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { SYNC_MANIFEST_FILE, assertSafeRootFileWriteTarget, assertSafeSyncPath, readSyncManifest, readSafeRootFile, writeSafeRootFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
|
|
8
|
+
export const EXPERIMENT_GENERATED_PATHS = [
|
|
9
|
+
'src/config/experiments.generated.ts',
|
|
10
|
+
'src/config/experiments.ts',
|
|
11
|
+
];
|
|
12
|
+
const EXPERIMENT_GENERATED_CONTENT_TYPE = 'text/typescript';
|
|
13
|
+
const LOWERCASE_SHA256 = /^[a-f0-9]{64}$/;
|
|
14
|
+
const EXPERIMENT_METRICS = [
|
|
15
|
+
'step_completion',
|
|
16
|
+
'next_step_reached',
|
|
17
|
+
'checkout_opened',
|
|
18
|
+
'funnel_completed',
|
|
19
|
+
'paying_customer',
|
|
20
|
+
];
|
|
21
|
+
export class ExperimentCreateContractError extends Error {
|
|
22
|
+
constructor(fieldPath, reason) {
|
|
23
|
+
super(`${fieldPath}: ${reason}`);
|
|
24
|
+
this.name = 'ExperimentCreateContractError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function fail(fieldPath, reason) {
|
|
28
|
+
throw new ExperimentCreateContractError(fieldPath, reason);
|
|
29
|
+
}
|
|
30
|
+
const isRecord = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
31
|
+
const requireRecord = (value, fieldPath) => {
|
|
32
|
+
if (!isRecord(value)) {
|
|
33
|
+
fail(fieldPath, 'must be an object');
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
const assertExactKeys = (value, allowedKeys, fieldPath) => {
|
|
38
|
+
const allowed = new Set(allowedKeys);
|
|
39
|
+
const unknownKey = Object.keys(value).find((key) => !allowed.has(key));
|
|
40
|
+
if (unknownKey !== undefined) {
|
|
41
|
+
fail(fieldPath ? `${fieldPath}.${unknownKey}` : unknownKey, 'unknown field');
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const requireString = (value, fieldPath, maxLength) => {
|
|
45
|
+
if (typeof value !== 'string') {
|
|
46
|
+
fail(fieldPath, 'must be a string');
|
|
47
|
+
}
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
if (!trimmed) {
|
|
50
|
+
fail(fieldPath, 'must not be empty');
|
|
51
|
+
}
|
|
52
|
+
if (maxLength !== undefined && trimmed.length > maxLength) {
|
|
53
|
+
fail(fieldPath, `must contain at most ${maxLength} characters`);
|
|
54
|
+
}
|
|
55
|
+
return trimmed;
|
|
56
|
+
};
|
|
57
|
+
const requireBoolean = (value, fieldPath) => {
|
|
58
|
+
if (typeof value !== 'boolean') {
|
|
59
|
+
fail(fieldPath, 'must be a boolean');
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
};
|
|
63
|
+
const requireInteger = (value, fieldPath, minimum, maximum) => {
|
|
64
|
+
if (typeof value !== 'number'
|
|
65
|
+
|| !Number.isInteger(value)
|
|
66
|
+
|| value < minimum
|
|
67
|
+
|| value > maximum) {
|
|
68
|
+
fail(fieldPath, `must be an integer from ${minimum} to ${maximum}`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
};
|
|
72
|
+
const requireMetric = (value, fieldPath) => {
|
|
73
|
+
if (!EXPERIMENT_METRICS.includes(value)) {
|
|
74
|
+
fail(fieldPath, `must be one of ${EXPERIMENT_METRICS.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
};
|
|
78
|
+
const compareExperimentVariants = (left, right) => {
|
|
79
|
+
if (left.isControl !== right.isControl) {
|
|
80
|
+
return left.isControl ? -1 : 1;
|
|
81
|
+
}
|
|
82
|
+
const leftIsLiteralControl = left.variantKey === 'control';
|
|
83
|
+
const rightIsLiteralControl = right.variantKey === 'control';
|
|
84
|
+
if (leftIsLiteralControl !== rightIsLiteralControl) {
|
|
85
|
+
return leftIsLiteralControl ? -1 : 1;
|
|
86
|
+
}
|
|
87
|
+
return Buffer.compare(Buffer.from(left.variantKey), Buffer.from(right.variantKey));
|
|
88
|
+
};
|
|
89
|
+
const parseVariant = (value, index, type) => {
|
|
90
|
+
const fieldPath = `variants[${index}]`;
|
|
91
|
+
const record = requireRecord(value, fieldPath);
|
|
92
|
+
const keys = [
|
|
93
|
+
'variantKey',
|
|
94
|
+
'label',
|
|
95
|
+
'routeToStepId',
|
|
96
|
+
'trafficPercent',
|
|
97
|
+
'isControl',
|
|
98
|
+
...(type === 'pricing' ? ['offerSetKey'] : []),
|
|
99
|
+
];
|
|
100
|
+
assertExactKeys(record, keys, fieldPath);
|
|
101
|
+
const shared = {
|
|
102
|
+
variantKey: requireString(record.variantKey, `${fieldPath}.variantKey`, 120),
|
|
103
|
+
label: requireString(record.label, `${fieldPath}.label`, 200),
|
|
104
|
+
routeToStepId: requireString(record.routeToStepId, `${fieldPath}.routeToStepId`, 200),
|
|
105
|
+
trafficPercent: requireInteger(record.trafficPercent, `${fieldPath}.trafficPercent`, 0, 100),
|
|
106
|
+
isControl: requireBoolean(record.isControl, `${fieldPath}.isControl`),
|
|
107
|
+
};
|
|
108
|
+
if (type !== 'pricing') {
|
|
109
|
+
return shared;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
...shared,
|
|
113
|
+
offerSetKey: requireString(record.offerSetKey, `${fieldPath}.offerSetKey`, 200),
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
export function parseAgentExperimentSpec(value) {
|
|
117
|
+
const record = requireRecord(value, 'spec');
|
|
118
|
+
assertExactKeys(record, [
|
|
119
|
+
'id',
|
|
120
|
+
'name',
|
|
121
|
+
'type',
|
|
122
|
+
'stepId',
|
|
123
|
+
'primaryMetric',
|
|
124
|
+
'trackedMetrics',
|
|
125
|
+
'variants',
|
|
126
|
+
], '');
|
|
127
|
+
if (record.type !== 'step' && record.type !== 'paywall' && record.type !== 'pricing') {
|
|
128
|
+
fail('type', 'must be one of step, paywall, pricing');
|
|
129
|
+
}
|
|
130
|
+
const type = record.type;
|
|
131
|
+
const id = requireString(record.id, 'id', 200);
|
|
132
|
+
const name = requireString(record.name, 'name', 200);
|
|
133
|
+
const stepId = requireString(record.stepId, 'stepId', 200);
|
|
134
|
+
const primaryMetric = requireMetric(record.primaryMetric, 'primaryMetric');
|
|
135
|
+
if (!Array.isArray(record.trackedMetrics) || record.trackedMetrics.length < 1) {
|
|
136
|
+
fail('trackedMetrics', 'must contain at least one metric');
|
|
137
|
+
}
|
|
138
|
+
const trackedMetrics = record.trackedMetrics.map((metric, index) => (requireMetric(metric, `trackedMetrics[${index}]`)));
|
|
139
|
+
if (new Set(trackedMetrics).size !== trackedMetrics.length) {
|
|
140
|
+
fail('trackedMetrics', 'must contain unique metrics');
|
|
141
|
+
}
|
|
142
|
+
if (!trackedMetrics.includes(primaryMetric)) {
|
|
143
|
+
fail('primaryMetric', 'must be included in trackedMetrics');
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(record.variants) || record.variants.length < 2 || record.variants.length > 5) {
|
|
146
|
+
fail('variants', 'must contain two to five variants');
|
|
147
|
+
}
|
|
148
|
+
const variants = record.variants.map((variant, index) => parseVariant(variant, index, type));
|
|
149
|
+
const keys = variants.map((variant) => variant.variantKey);
|
|
150
|
+
if (new Set(keys).size !== keys.length) {
|
|
151
|
+
fail('variants', 'variant keys must be unique');
|
|
152
|
+
}
|
|
153
|
+
if (variants.filter((variant) => variant.isControl).length !== 1) {
|
|
154
|
+
fail('variants', 'must include exactly one control');
|
|
155
|
+
}
|
|
156
|
+
if (variants.reduce((total, variant) => total + variant.trafficPercent, 0) !== 100) {
|
|
157
|
+
fail('variants', 'trafficPercent values must total 100');
|
|
158
|
+
}
|
|
159
|
+
if (type === 'pricing') {
|
|
160
|
+
variants.forEach((variant, index) => {
|
|
161
|
+
if (variant.routeToStepId !== stepId) {
|
|
162
|
+
fail(`variants[${index}].routeToStepId`, 'must match stepId for pricing experiments');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const canonicalVariants = [...variants].sort(compareExperimentVariants);
|
|
167
|
+
const shared = { id, name, stepId, primaryMetric, trackedMetrics };
|
|
168
|
+
if (type === 'pricing') {
|
|
169
|
+
return {
|
|
170
|
+
...shared,
|
|
171
|
+
type,
|
|
172
|
+
variants: canonicalVariants,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
...shared,
|
|
177
|
+
type,
|
|
178
|
+
variants: canonicalVariants,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export async function readAgentExperimentSpec(filePath) {
|
|
182
|
+
let value;
|
|
183
|
+
try {
|
|
184
|
+
value = JSON.parse(await readFile(filePath, 'utf8'));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof SyntaxError) {
|
|
188
|
+
fail(filePath, 'invalid JSON');
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
return parseAgentExperimentSpec(value);
|
|
193
|
+
}
|
|
194
|
+
const parseGeneratedExperimentFiles = (value) => {
|
|
195
|
+
if (!Array.isArray(value) || value.length !== EXPERIMENT_GENERATED_PATHS.length) {
|
|
196
|
+
fail('generatedFiles', 'must contain exactly the two generated experiment files');
|
|
197
|
+
}
|
|
198
|
+
const files = value.map((entry, index) => {
|
|
199
|
+
const fieldPath = `generatedFiles[${index}]`;
|
|
200
|
+
const record = requireRecord(entry, fieldPath);
|
|
201
|
+
assertExactKeys(record, ['path', 'content', 'contentType'], fieldPath);
|
|
202
|
+
const expectedPath = EXPERIMENT_GENERATED_PATHS[index];
|
|
203
|
+
if (record.path !== expectedPath) {
|
|
204
|
+
fail(`${fieldPath}.path`, `must equal ${expectedPath}`);
|
|
205
|
+
}
|
|
206
|
+
if (record.contentType !== EXPERIMENT_GENERATED_CONTENT_TYPE) {
|
|
207
|
+
fail(`${fieldPath}.contentType`, `must equal ${EXPERIMENT_GENERATED_CONTENT_TYPE}`);
|
|
208
|
+
}
|
|
209
|
+
if (typeof record.content !== 'string') {
|
|
210
|
+
fail(`${fieldPath}.content`, 'must be a string');
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
path: expectedPath,
|
|
214
|
+
content: record.content,
|
|
215
|
+
contentType: EXPERIMENT_GENERATED_CONTENT_TYPE,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
return [files[0], files[1]];
|
|
219
|
+
};
|
|
220
|
+
const EXPERIMENT_ROW_KEYS = [
|
|
221
|
+
'conversion_config',
|
|
222
|
+
'created_at',
|
|
223
|
+
'ended_at',
|
|
224
|
+
'funnel_id',
|
|
225
|
+
'id',
|
|
226
|
+
'name',
|
|
227
|
+
'paused_at',
|
|
228
|
+
'posthog_flag_id',
|
|
229
|
+
'posthog_flag_key',
|
|
230
|
+
'primary_metric',
|
|
231
|
+
'project_id',
|
|
232
|
+
'started_at',
|
|
233
|
+
'status',
|
|
234
|
+
'step_id',
|
|
235
|
+
'tracked_metrics',
|
|
236
|
+
'updated_at',
|
|
237
|
+
];
|
|
238
|
+
const VARIANT_ROW_KEYS = [
|
|
239
|
+
'created_at',
|
|
240
|
+
'experiment_id',
|
|
241
|
+
'id',
|
|
242
|
+
'is_control',
|
|
243
|
+
'label',
|
|
244
|
+
'route_to_step_id',
|
|
245
|
+
'traffic_percent',
|
|
246
|
+
'updated_at',
|
|
247
|
+
'variant_key',
|
|
248
|
+
];
|
|
249
|
+
const requireNull = (value, fieldPath) => {
|
|
250
|
+
if (value !== null) {
|
|
251
|
+
fail(fieldPath, 'must be null for a draft experiment');
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
};
|
|
255
|
+
const expectedConversionConfig = (spec) => (spec.type === 'pricing'
|
|
256
|
+
? {
|
|
257
|
+
source: 'agent',
|
|
258
|
+
type: 'pricing',
|
|
259
|
+
paywallStepId: spec.stepId,
|
|
260
|
+
offerSetKeys: spec.variants.map((variant) => variant.offerSetKey),
|
|
261
|
+
}
|
|
262
|
+
: {
|
|
263
|
+
source: 'agent',
|
|
264
|
+
type: spec.type,
|
|
265
|
+
stepId: spec.stepId,
|
|
266
|
+
variantStepIds: spec.variants.map((variant) => variant.routeToStepId),
|
|
267
|
+
});
|
|
268
|
+
export function parseExperimentCreateResponse(value, requestedSpec, expectedFunnelIdValue) {
|
|
269
|
+
const spec = parseAgentExperimentSpec(requestedSpec);
|
|
270
|
+
const expectedFunnelId = requireString(expectedFunnelIdValue, 'expectedFunnelId');
|
|
271
|
+
const response = requireRecord(value, 'response');
|
|
272
|
+
assertExactKeys(response, [
|
|
273
|
+
'experiment',
|
|
274
|
+
'variants',
|
|
275
|
+
'draftVersionId',
|
|
276
|
+
'generatedFiles',
|
|
277
|
+
'githubConnected',
|
|
278
|
+
], '');
|
|
279
|
+
const experiment = requireRecord(response.experiment, 'experiment');
|
|
280
|
+
assertExactKeys(experiment, EXPERIMENT_ROW_KEYS, 'experiment');
|
|
281
|
+
const experimentId = requireString(experiment.id, 'experiment.id');
|
|
282
|
+
requireString(experiment.project_id, 'experiment.project_id');
|
|
283
|
+
const experimentFunnelId = requireString(experiment.funnel_id, 'experiment.funnel_id');
|
|
284
|
+
if (experimentFunnelId !== expectedFunnelId) {
|
|
285
|
+
fail('experiment.funnel_id', 'does not match the requested funnel');
|
|
286
|
+
}
|
|
287
|
+
if (experiment.status !== 'draft')
|
|
288
|
+
fail('experiment.status', 'must equal draft');
|
|
289
|
+
if (experiment.posthog_flag_key !== spec.id) {
|
|
290
|
+
fail('experiment.posthog_flag_key', `must equal requested experiment id ${spec.id}`);
|
|
291
|
+
}
|
|
292
|
+
if (experiment.step_id !== spec.stepId)
|
|
293
|
+
fail('experiment.step_id', 'does not match requested spec');
|
|
294
|
+
if (experiment.name !== spec.name)
|
|
295
|
+
fail('experiment.name', 'does not match requested spec');
|
|
296
|
+
if (experiment.primary_metric !== spec.primaryMetric) {
|
|
297
|
+
fail('experiment.primary_metric', 'does not match requested spec');
|
|
298
|
+
}
|
|
299
|
+
if (!isDeepStrictEqual(experiment.tracked_metrics, spec.trackedMetrics)) {
|
|
300
|
+
fail('experiment.tracked_metrics', 'does not match requested spec');
|
|
301
|
+
}
|
|
302
|
+
if (!isDeepStrictEqual(experiment.conversion_config, expectedConversionConfig(spec))) {
|
|
303
|
+
fail('experiment.conversion_config', 'does not match requested spec');
|
|
304
|
+
}
|
|
305
|
+
requireNull(experiment.posthog_flag_id, 'experiment.posthog_flag_id');
|
|
306
|
+
requireNull(experiment.started_at, 'experiment.started_at');
|
|
307
|
+
requireNull(experiment.paused_at, 'experiment.paused_at');
|
|
308
|
+
requireNull(experiment.ended_at, 'experiment.ended_at');
|
|
309
|
+
requireString(experiment.created_at, 'experiment.created_at');
|
|
310
|
+
requireString(experiment.updated_at, 'experiment.updated_at');
|
|
311
|
+
if (!Array.isArray(response.variants) || response.variants.length !== spec.variants.length) {
|
|
312
|
+
fail('variants', 'must match the requested canonical variants');
|
|
313
|
+
}
|
|
314
|
+
const variants = response.variants.map((value, index) => {
|
|
315
|
+
const fieldPath = `variants[${index}]`;
|
|
316
|
+
const variant = requireRecord(value, fieldPath);
|
|
317
|
+
assertExactKeys(variant, VARIANT_ROW_KEYS, fieldPath);
|
|
318
|
+
const expected = spec.variants[index];
|
|
319
|
+
requireString(variant.id, `${fieldPath}.id`);
|
|
320
|
+
if (variant.experiment_id !== experimentId) {
|
|
321
|
+
fail(`${fieldPath}.experiment_id`, 'must equal experiment.id');
|
|
322
|
+
}
|
|
323
|
+
if (variant.variant_key !== expected.variantKey) {
|
|
324
|
+
fail('variants', 'must use canonical experiment variant ordering');
|
|
325
|
+
}
|
|
326
|
+
if (variant.label !== expected.label)
|
|
327
|
+
fail(`${fieldPath}.label`, 'does not match requested spec');
|
|
328
|
+
if (variant.route_to_step_id !== expected.routeToStepId) {
|
|
329
|
+
fail(`${fieldPath}.route_to_step_id`, 'does not match requested spec');
|
|
330
|
+
}
|
|
331
|
+
if (variant.traffic_percent !== expected.trafficPercent) {
|
|
332
|
+
fail(`${fieldPath}.traffic_percent`, 'does not match requested spec');
|
|
333
|
+
}
|
|
334
|
+
if (variant.is_control !== expected.isControl) {
|
|
335
|
+
fail(`${fieldPath}.is_control`, 'does not match requested spec');
|
|
336
|
+
}
|
|
337
|
+
requireString(variant.created_at, `${fieldPath}.created_at`);
|
|
338
|
+
requireString(variant.updated_at, `${fieldPath}.updated_at`);
|
|
339
|
+
return variant;
|
|
340
|
+
});
|
|
341
|
+
const variantIds = variants.map((variant) => variant.id);
|
|
342
|
+
if (new Set(variantIds).size !== variantIds.length) {
|
|
343
|
+
fail('variants', 'must contain unique persisted variant ids');
|
|
344
|
+
}
|
|
345
|
+
const draftVersionId = requireString(response.draftVersionId, 'draftVersionId');
|
|
346
|
+
const generatedFiles = parseGeneratedExperimentFiles(response.generatedFiles);
|
|
347
|
+
const githubConnected = requireBoolean(response.githubConnected, 'githubConnected');
|
|
348
|
+
return {
|
|
349
|
+
experiment: experiment,
|
|
350
|
+
variants,
|
|
351
|
+
draftVersionId,
|
|
352
|
+
generatedFiles,
|
|
353
|
+
githubConnected,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const validateManifest = (value) => {
|
|
357
|
+
const manifest = requireRecord(value, 'manifest');
|
|
358
|
+
assertExactKeys(manifest, ['version', 'workspaceId', 'funnelId', 'draftVersionId', 'files'], 'manifest');
|
|
359
|
+
if (manifest.version !== 1)
|
|
360
|
+
fail('manifest.version', 'must equal 1');
|
|
361
|
+
const workspaceId = requireString(manifest.workspaceId, 'manifest.workspaceId');
|
|
362
|
+
const funnelId = requireString(manifest.funnelId, 'manifest.funnelId');
|
|
363
|
+
const draftVersionId = requireString(manifest.draftVersionId, 'manifest.draftVersionId');
|
|
364
|
+
if (!Array.isArray(manifest.files))
|
|
365
|
+
fail('manifest.files', 'must be an array');
|
|
366
|
+
const seenPaths = new Set();
|
|
367
|
+
const generatedPathByFoldedPath = new Map(EXPERIMENT_GENERATED_PATHS.map((generatedPath) => [generatedPath.toLowerCase(), generatedPath]));
|
|
368
|
+
const files = manifest.files.map((value, index) => {
|
|
369
|
+
const fieldPath = `manifest.files[${index}]`;
|
|
370
|
+
const entry = requireRecord(value, fieldPath);
|
|
371
|
+
assertExactKeys(entry, ['path', 'hash'], fieldPath);
|
|
372
|
+
const rawPath = entry.path;
|
|
373
|
+
const filePath = requireString(rawPath, `${fieldPath}.path`);
|
|
374
|
+
try {
|
|
375
|
+
assertSafeSyncPath(rawPath);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
fail(`${fieldPath}.path`, 'must be a safe canonical relative POSIX path');
|
|
379
|
+
}
|
|
380
|
+
if (typeof entry.hash !== 'string' || !LOWERCASE_SHA256.test(entry.hash)) {
|
|
381
|
+
fail(`${fieldPath}.hash`, 'must be a lowercase SHA-256 hash');
|
|
382
|
+
}
|
|
383
|
+
if (seenPaths.has(filePath)) {
|
|
384
|
+
fail('manifest.files', `contains duplicate path ${filePath}`);
|
|
385
|
+
}
|
|
386
|
+
const foldedPath = filePath.toLowerCase();
|
|
387
|
+
const generatedPath = generatedPathByFoldedPath.get(foldedPath);
|
|
388
|
+
if (generatedPath !== undefined && filePath !== generatedPath) {
|
|
389
|
+
fail('manifest.files', `contains a non-canonical case variant ${filePath}`);
|
|
390
|
+
}
|
|
391
|
+
seenPaths.add(filePath);
|
|
392
|
+
return { path: filePath, hash: entry.hash };
|
|
393
|
+
});
|
|
394
|
+
return { version: 1, workspaceId, funnelId, draftVersionId, files };
|
|
395
|
+
};
|
|
396
|
+
const isErrno = (error, code) => (error instanceof Error && 'code' in error && error.code === code);
|
|
397
|
+
export async function assertGeneratedExperimentFilesUnchanged(root, manifestValue) {
|
|
398
|
+
const manifest = validateManifest(manifestValue);
|
|
399
|
+
const baselineByPath = new Map(manifest.files.map((file) => [file.path, file.hash]));
|
|
400
|
+
for (const generatedPath of EXPERIMENT_GENERATED_PATHS) {
|
|
401
|
+
const baselineHash = baselineByPath.get(generatedPath);
|
|
402
|
+
let currentBytes;
|
|
403
|
+
try {
|
|
404
|
+
currentBytes = await readSafeRootFile(root, generatedPath);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (!isErrno(error, 'ENOENT'))
|
|
408
|
+
throw error;
|
|
409
|
+
if (baselineHash !== undefined) {
|
|
410
|
+
fail(generatedPath, 'was locally modified or deleted');
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (baselineHash === undefined) {
|
|
415
|
+
fail(generatedPath, 'exists without a trustworthy manifest baseline');
|
|
416
|
+
}
|
|
417
|
+
const currentHash = createHash('sha256').update(currentBytes).digest('hex');
|
|
418
|
+
if (currentHash !== baselineHash) {
|
|
419
|
+
fail(generatedPath, 'was locally modified');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
export function buildExperimentCreateManifest(manifestValue, draftVersionIdValue, filesValue) {
|
|
424
|
+
const manifest = validateManifest(manifestValue);
|
|
425
|
+
const draftVersionId = requireString(draftVersionIdValue, 'draftVersionId');
|
|
426
|
+
const files = parseGeneratedExperimentFiles(filesValue);
|
|
427
|
+
const nextHashByPath = new Map(files.map((file) => [
|
|
428
|
+
file.path,
|
|
429
|
+
createHash('sha256').update(file.content).digest('hex'),
|
|
430
|
+
]));
|
|
431
|
+
const replacedPaths = new Set();
|
|
432
|
+
const nextFiles = manifest.files.map((file) => {
|
|
433
|
+
const nextHash = nextHashByPath.get(file.path);
|
|
434
|
+
if (nextHash === undefined)
|
|
435
|
+
return { ...file };
|
|
436
|
+
replacedPaths.add(file.path);
|
|
437
|
+
return { path: file.path, hash: nextHash };
|
|
438
|
+
});
|
|
439
|
+
for (const generatedPath of EXPERIMENT_GENERATED_PATHS) {
|
|
440
|
+
if (!replacedPaths.has(generatedPath)) {
|
|
441
|
+
nextFiles.push({ path: generatedPath, hash: nextHashByPath.get(generatedPath) });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
...manifest,
|
|
446
|
+
draftVersionId,
|
|
447
|
+
files: nextFiles,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const EXPERIMENT_CREATE_TRANSACTION_PARENT = '.funnelsgrove/transactions';
|
|
451
|
+
const EXPERIMENT_CREATE_READY_DIRECTORY = `${EXPERIMENT_CREATE_TRANSACTION_PARENT}/experiment-create`;
|
|
452
|
+
const EXPERIMENT_CREATE_PREPARING_PREFIX = `${EXPERIMENT_CREATE_TRANSACTION_PARENT}/experiment-create-preparing-`;
|
|
453
|
+
const EXPERIMENT_CREATE_STAGED_PATHS = [
|
|
454
|
+
'experiments.generated.ts',
|
|
455
|
+
'experiments.ts',
|
|
456
|
+
];
|
|
457
|
+
const EXPERIMENT_CREATE_MANIFEST_STAGED_PATH = 'manifest.json';
|
|
458
|
+
const EXPERIMENT_CREATE_JOURNAL_PATH = 'journal.json';
|
|
459
|
+
const EXPERIMENT_CREATE_MANIFEST_CONTENT_TYPE = 'application/json';
|
|
460
|
+
const experimentCreateTransactionError = (reason) => (new Error(`Invalid experiment-create transaction: ${reason}`));
|
|
461
|
+
const sha256Buffer = (content) => createHash('sha256')
|
|
462
|
+
.update(content)
|
|
463
|
+
.digest('hex');
|
|
464
|
+
const serializedManifest = (manifest) => Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
465
|
+
const transactionRelativePath = (directory, filePath) => (`${directory}/${filePath}`);
|
|
466
|
+
const syncDirectory = async (directoryPath) => {
|
|
467
|
+
const handle = await open(directoryPath, constants.O_RDONLY);
|
|
468
|
+
try {
|
|
469
|
+
await handle.sync();
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
await handle.close();
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
const readManifestBytes = async (root) => {
|
|
476
|
+
const bytes = await readSafeRootFile(root, SYNC_MANIFEST_FILE);
|
|
477
|
+
let value;
|
|
478
|
+
try {
|
|
479
|
+
value = JSON.parse(bytes.toString('utf8'));
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
throw experimentCreateTransactionError('sync manifest is not valid JSON');
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
return { manifest: validateManifest(value), bytes };
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
throw experimentCreateTransactionError('sync manifest is invalid');
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const assertTransactionArtifactEntries = async (root) => {
|
|
492
|
+
const readyPath = path.join(root, ...EXPERIMENT_CREATE_READY_DIRECTORY.split('/'));
|
|
493
|
+
const entries = await readdir(readyPath, { withFileTypes: true });
|
|
494
|
+
const expected = [
|
|
495
|
+
...EXPERIMENT_CREATE_STAGED_PATHS,
|
|
496
|
+
EXPERIMENT_CREATE_MANIFEST_STAGED_PATH,
|
|
497
|
+
EXPERIMENT_CREATE_JOURNAL_PATH,
|
|
498
|
+
].sort();
|
|
499
|
+
if (entries.some((entry) => !entry.isFile() || entry.isSymbolicLink())
|
|
500
|
+
|| !isDeepStrictEqual(entries.map((entry) => entry.name).sort(), expected)) {
|
|
501
|
+
throw experimentCreateTransactionError('artifact set or type is invalid');
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
const requireTransactionString = (value, fieldPath) => {
|
|
505
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
506
|
+
throw experimentCreateTransactionError(`${fieldPath} must be a non-empty string`);
|
|
507
|
+
}
|
|
508
|
+
return value;
|
|
509
|
+
};
|
|
510
|
+
const requireTransactionHash = (value, fieldPath) => {
|
|
511
|
+
if (typeof value !== 'string' || !LOWERCASE_SHA256.test(value)) {
|
|
512
|
+
throw experimentCreateTransactionError(`${fieldPath} must be a lowercase SHA-256 hash`);
|
|
513
|
+
}
|
|
514
|
+
return value;
|
|
515
|
+
};
|
|
516
|
+
const requireTransactionBytes = (value, fieldPath) => {
|
|
517
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
518
|
+
throw experimentCreateTransactionError(`${fieldPath} must be a non-negative byte count`);
|
|
519
|
+
}
|
|
520
|
+
return value;
|
|
521
|
+
};
|
|
522
|
+
const requireExactTransactionKeys = (value, keys, fieldPath) => {
|
|
523
|
+
if (Object.keys(value).length !== keys.length
|
|
524
|
+
|| keys.some((key) => !Object.hasOwn(value, key))) {
|
|
525
|
+
throw experimentCreateTransactionError(`${fieldPath} has an invalid shape`);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
const parseExperimentCreateJournal = (value) => {
|
|
529
|
+
if (!isRecord(value))
|
|
530
|
+
throw experimentCreateTransactionError('journal must be an object');
|
|
531
|
+
requireExactTransactionKeys(value, [
|
|
532
|
+
'version',
|
|
533
|
+
'workspaceId',
|
|
534
|
+
'funnelId',
|
|
535
|
+
'baseDraftVersionId',
|
|
536
|
+
'draftVersionId',
|
|
537
|
+
'baseManifestSha256',
|
|
538
|
+
'files',
|
|
539
|
+
'manifest',
|
|
540
|
+
], 'journal');
|
|
541
|
+
if (value.version !== 1)
|
|
542
|
+
throw experimentCreateTransactionError('journal version is unsupported');
|
|
543
|
+
if (!Array.isArray(value.files) || value.files.length !== 2) {
|
|
544
|
+
throw experimentCreateTransactionError('journal files must contain exactly two entries');
|
|
545
|
+
}
|
|
546
|
+
const files = value.files.map((file, index) => {
|
|
547
|
+
if (!isRecord(file))
|
|
548
|
+
throw experimentCreateTransactionError(`journal.files[${index}] must be an object`);
|
|
549
|
+
requireExactTransactionKeys(file, ['path', 'stagedPath', 'contentType', 'sha256', 'bytes'], `journal.files[${index}]`);
|
|
550
|
+
if (file.path !== EXPERIMENT_GENERATED_PATHS[index]
|
|
551
|
+
|| file.stagedPath !== EXPERIMENT_CREATE_STAGED_PATHS[index]
|
|
552
|
+
|| file.contentType !== EXPERIMENT_GENERATED_CONTENT_TYPE) {
|
|
553
|
+
throw experimentCreateTransactionError(`journal.files[${index}] does not use the fixed file contract`);
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
path: EXPERIMENT_GENERATED_PATHS[index],
|
|
557
|
+
stagedPath: EXPERIMENT_CREATE_STAGED_PATHS[index],
|
|
558
|
+
contentType: EXPERIMENT_GENERATED_CONTENT_TYPE,
|
|
559
|
+
sha256: requireTransactionHash(file.sha256, `journal.files[${index}].sha256`),
|
|
560
|
+
bytes: requireTransactionBytes(file.bytes, `journal.files[${index}].bytes`),
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
if (!isRecord(value.manifest)) {
|
|
564
|
+
throw experimentCreateTransactionError('journal.manifest must be an object');
|
|
565
|
+
}
|
|
566
|
+
requireExactTransactionKeys(value.manifest, ['path', 'stagedPath', 'contentType', 'sha256', 'bytes'], 'journal.manifest');
|
|
567
|
+
if (value.manifest.path !== SYNC_MANIFEST_FILE
|
|
568
|
+
|| value.manifest.stagedPath !== EXPERIMENT_CREATE_MANIFEST_STAGED_PATH
|
|
569
|
+
|| value.manifest.contentType !== EXPERIMENT_CREATE_MANIFEST_CONTENT_TYPE) {
|
|
570
|
+
throw experimentCreateTransactionError('journal.manifest does not use the fixed manifest contract');
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
version: 1,
|
|
574
|
+
workspaceId: requireTransactionString(value.workspaceId, 'journal.workspaceId'),
|
|
575
|
+
funnelId: requireTransactionString(value.funnelId, 'journal.funnelId'),
|
|
576
|
+
baseDraftVersionId: requireTransactionString(value.baseDraftVersionId, 'journal.baseDraftVersionId'),
|
|
577
|
+
draftVersionId: requireTransactionString(value.draftVersionId, 'journal.draftVersionId'),
|
|
578
|
+
baseManifestSha256: requireTransactionHash(value.baseManifestSha256, 'journal.baseManifestSha256'),
|
|
579
|
+
files: [files[0], files[1]],
|
|
580
|
+
manifest: {
|
|
581
|
+
path: SYNC_MANIFEST_FILE,
|
|
582
|
+
stagedPath: EXPERIMENT_CREATE_MANIFEST_STAGED_PATH,
|
|
583
|
+
contentType: EXPERIMENT_CREATE_MANIFEST_CONTENT_TYPE,
|
|
584
|
+
sha256: requireTransactionHash(value.manifest.sha256, 'journal.manifest.sha256'),
|
|
585
|
+
bytes: requireTransactionBytes(value.manifest.bytes, 'journal.manifest.bytes'),
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
const assertStagedBytes = (content, receipt, fieldPath) => {
|
|
590
|
+
if (content.byteLength !== receipt.bytes || sha256Buffer(content) !== receipt.sha256) {
|
|
591
|
+
throw experimentCreateTransactionError(`${fieldPath} bytes or hash do not match the journal`);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const loadPreparedExperimentCreateTransaction = async (root) => {
|
|
595
|
+
await assertTransactionArtifactEntries(root);
|
|
596
|
+
let journalValue;
|
|
597
|
+
try {
|
|
598
|
+
journalValue = JSON.parse((await readSafeRootFile(root, transactionRelativePath(EXPERIMENT_CREATE_READY_DIRECTORY, EXPERIMENT_CREATE_JOURNAL_PATH))).toString('utf8'));
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
if (error instanceof SyntaxError) {
|
|
602
|
+
throw experimentCreateTransactionError('journal is not valid JSON');
|
|
603
|
+
}
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
const journal = parseExperimentCreateJournal(journalValue);
|
|
607
|
+
const stagedBuffers = await Promise.all(journal.files.map(async (file) => {
|
|
608
|
+
const content = await readSafeRootFile(root, transactionRelativePath(EXPERIMENT_CREATE_READY_DIRECTORY, file.stagedPath));
|
|
609
|
+
assertStagedBytes(content, file, file.stagedPath);
|
|
610
|
+
return content;
|
|
611
|
+
}));
|
|
612
|
+
const manifestBytes = await readSafeRootFile(root, transactionRelativePath(EXPERIMENT_CREATE_READY_DIRECTORY, EXPERIMENT_CREATE_MANIFEST_STAGED_PATH));
|
|
613
|
+
assertStagedBytes(manifestBytes, journal.manifest, 'manifest.json');
|
|
614
|
+
let manifestValue;
|
|
615
|
+
try {
|
|
616
|
+
manifestValue = JSON.parse(manifestBytes.toString('utf8'));
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
throw experimentCreateTransactionError('manifest.json is not valid JSON');
|
|
620
|
+
}
|
|
621
|
+
let manifest;
|
|
622
|
+
try {
|
|
623
|
+
manifest = validateManifest(manifestValue);
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
throw experimentCreateTransactionError('manifest.json is invalid');
|
|
627
|
+
}
|
|
628
|
+
if (manifest.workspaceId !== journal.workspaceId
|
|
629
|
+
|| manifest.funnelId !== journal.funnelId
|
|
630
|
+
|| manifest.draftVersionId !== journal.draftVersionId) {
|
|
631
|
+
throw experimentCreateTransactionError('manifest identity does not match the journal');
|
|
632
|
+
}
|
|
633
|
+
const manifestHashByPath = new Map(manifest.files.map((file) => [file.path, file.hash]));
|
|
634
|
+
journal.files.forEach((file) => {
|
|
635
|
+
if (manifestHashByPath.get(file.path) !== file.sha256) {
|
|
636
|
+
throw experimentCreateTransactionError(`manifest hash does not match ${file.path}`);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
const stagedContents = stagedBuffers.map((content, index) => {
|
|
640
|
+
const decoded = content.toString('utf8');
|
|
641
|
+
if (!Buffer.from(decoded, 'utf8').equals(content)) {
|
|
642
|
+
throw experimentCreateTransactionError(`${journal.files[index].stagedPath} is not canonical UTF-8 text`);
|
|
643
|
+
}
|
|
644
|
+
return decoded;
|
|
645
|
+
});
|
|
646
|
+
return {
|
|
647
|
+
journal,
|
|
648
|
+
generatedFiles: journal.files.map((file, index) => ({
|
|
649
|
+
path: file.path,
|
|
650
|
+
content: stagedContents[index],
|
|
651
|
+
contentType: file.contentType,
|
|
652
|
+
})),
|
|
653
|
+
manifest,
|
|
654
|
+
manifestBytes,
|
|
655
|
+
};
|
|
656
|
+
};
|
|
657
|
+
const removeReadyExperimentCreateTransaction = async (root) => {
|
|
658
|
+
const resolvedRoot = await realpath(root);
|
|
659
|
+
const readyPath = path.join(resolvedRoot, ...EXPERIMENT_CREATE_READY_DIRECTORY.split('/'));
|
|
660
|
+
const parentPath = path.dirname(readyPath);
|
|
661
|
+
await rm(readyPath, { recursive: true });
|
|
662
|
+
await syncDirectory(parentPath);
|
|
663
|
+
};
|
|
664
|
+
const assertGeneratedTargetsRecoverable = async (root, baseManifest, journal) => {
|
|
665
|
+
const baseHashByPath = new Map(baseManifest.files.map((file) => [file.path, file.hash]));
|
|
666
|
+
for (const file of journal.files) {
|
|
667
|
+
let currentHash;
|
|
668
|
+
try {
|
|
669
|
+
currentHash = sha256Buffer(await readSafeRootFile(root, file.path));
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
if (!isErrno(error, 'ENOENT'))
|
|
673
|
+
throw error;
|
|
674
|
+
currentHash = null;
|
|
675
|
+
}
|
|
676
|
+
const baseHash = baseHashByPath.get(file.path);
|
|
677
|
+
const matchesAllowedState = currentHash === file.sha256
|
|
678
|
+
|| (baseHash === undefined ? currentHash === null : currentHash === baseHash);
|
|
679
|
+
if (!matchesAllowedState) {
|
|
680
|
+
throw experimentCreateTransactionError(`${file.path} contains a local edit made after experiment creation started`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
const applyPreparedExperimentCreateTransaction = async (root, prepared, hooks = {}) => {
|
|
685
|
+
const current = await readManifestBytes(root);
|
|
686
|
+
const currentHash = sha256Buffer(current.bytes);
|
|
687
|
+
if (current.bytes.equals(prepared.manifestBytes)) {
|
|
688
|
+
await removeReadyExperimentCreateTransaction(root);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (currentHash !== prepared.journal.baseManifestSha256
|
|
692
|
+
|| current.manifest.workspaceId !== prepared.journal.workspaceId
|
|
693
|
+
|| current.manifest.funnelId !== prepared.journal.funnelId
|
|
694
|
+
|| current.manifest.draftVersionId !== prepared.journal.baseDraftVersionId) {
|
|
695
|
+
throw experimentCreateTransactionError('checkout sync manifest changed before recovery');
|
|
696
|
+
}
|
|
697
|
+
const expectedManifestBytes = serializedManifest(buildExperimentCreateManifest(current.manifest, prepared.journal.draftVersionId, prepared.generatedFiles));
|
|
698
|
+
if (!expectedManifestBytes.equals(prepared.manifestBytes)) {
|
|
699
|
+
throw experimentCreateTransactionError('staged manifest is not the canonical rebase of the checkout sync manifest');
|
|
700
|
+
}
|
|
701
|
+
for (const targetPath of [...EXPERIMENT_GENERATED_PATHS, SYNC_MANIFEST_FILE]) {
|
|
702
|
+
await assertSafeRootFileWriteTarget(root, targetPath);
|
|
703
|
+
}
|
|
704
|
+
await assertGeneratedTargetsRecoverable(root, current.manifest, prepared.journal);
|
|
705
|
+
for (const file of prepared.generatedFiles) {
|
|
706
|
+
await writeSourceFiles(root, [file]);
|
|
707
|
+
await hooks.afterPhase?.(`generated:${file.path}`);
|
|
708
|
+
}
|
|
709
|
+
await hooks.afterPhase?.('before-manifest');
|
|
710
|
+
await writeSyncManifest(root, prepared.manifest);
|
|
711
|
+
await hooks.afterPhase?.('manifest');
|
|
712
|
+
await removeReadyExperimentCreateTransaction(root);
|
|
713
|
+
};
|
|
714
|
+
const assertNoReadyExperimentCreateTransaction = async (root) => {
|
|
715
|
+
const readyPath = path.join(root, ...EXPERIMENT_CREATE_READY_DIRECTORY.split('/'));
|
|
716
|
+
try {
|
|
717
|
+
await lstat(readyPath);
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
if (isErrno(error, 'ENOENT'))
|
|
721
|
+
return;
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
throw experimentCreateTransactionError('an unfinished experiment-create transaction already exists; recover it before continuing');
|
|
725
|
+
};
|
|
726
|
+
export async function recoverExperimentCreateTransaction(root) {
|
|
727
|
+
const readyPath = path.join(root, ...EXPERIMENT_CREATE_READY_DIRECTORY.split('/'));
|
|
728
|
+
let metadata;
|
|
729
|
+
try {
|
|
730
|
+
metadata = await lstat(readyPath);
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
if (isErrno(error, 'ENOENT'))
|
|
734
|
+
return false;
|
|
735
|
+
throw error;
|
|
736
|
+
}
|
|
737
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
738
|
+
throw experimentCreateTransactionError('ready path must be a real directory');
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
await applyPreparedExperimentCreateTransaction(root, await loadPreparedExperimentCreateTransaction(root));
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error instanceof Error && error.message.includes('experiment-create transaction')) {
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
throw experimentCreateTransactionError(error instanceof Error ? error.message : 'recovery failed');
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
export async function installExperimentCreateTransaction(root, input, hooks = {}) {
|
|
752
|
+
await assertNoReadyExperimentCreateTransaction(root);
|
|
753
|
+
const generatedFiles = parseGeneratedExperimentFiles(input.generatedFiles);
|
|
754
|
+
const manifest = validateManifest(input.manifest);
|
|
755
|
+
const current = await readManifestBytes(root);
|
|
756
|
+
if (current.manifest.workspaceId !== manifest.workspaceId
|
|
757
|
+
|| current.manifest.funnelId !== manifest.funnelId) {
|
|
758
|
+
throw experimentCreateTransactionError('next manifest does not match the checkout identity');
|
|
759
|
+
}
|
|
760
|
+
const manifestHashByPath = new Map(manifest.files.map((file) => [file.path, file.hash]));
|
|
761
|
+
generatedFiles.forEach((file) => {
|
|
762
|
+
if (manifestHashByPath.get(file.path) !== sha256Buffer(file.content)) {
|
|
763
|
+
throw experimentCreateTransactionError(`next manifest hash does not match ${file.path}`);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
const manifestBytes = serializedManifest(manifest);
|
|
767
|
+
const expectedManifestBytes = serializedManifest(buildExperimentCreateManifest(current.manifest, manifest.draftVersionId, generatedFiles));
|
|
768
|
+
if (!manifestBytes.equals(expectedManifestBytes)) {
|
|
769
|
+
throw experimentCreateTransactionError('next manifest is not the canonical rebase of the checkout sync manifest');
|
|
770
|
+
}
|
|
771
|
+
const preparingDirectory = `${EXPERIMENT_CREATE_PREPARING_PREFIX}${randomUUID()}`;
|
|
772
|
+
const resolvedRoot = await realpath(root);
|
|
773
|
+
const preparingPath = path.join(resolvedRoot, ...preparingDirectory.split('/'));
|
|
774
|
+
const readyPath = path.join(resolvedRoot, ...EXPERIMENT_CREATE_READY_DIRECTORY.split('/'));
|
|
775
|
+
const parentPath = path.dirname(readyPath);
|
|
776
|
+
let published = false;
|
|
777
|
+
try {
|
|
778
|
+
for (const [index, file] of generatedFiles.entries()) {
|
|
779
|
+
await writeSafeRootFile(root, transactionRelativePath(preparingDirectory, EXPERIMENT_CREATE_STAGED_PATHS[index]), file.content);
|
|
780
|
+
}
|
|
781
|
+
await writeSafeRootFile(root, transactionRelativePath(preparingDirectory, EXPERIMENT_CREATE_MANIFEST_STAGED_PATH), manifestBytes);
|
|
782
|
+
const journal = {
|
|
783
|
+
version: 1,
|
|
784
|
+
workspaceId: manifest.workspaceId,
|
|
785
|
+
funnelId: manifest.funnelId,
|
|
786
|
+
baseDraftVersionId: current.manifest.draftVersionId,
|
|
787
|
+
draftVersionId: manifest.draftVersionId,
|
|
788
|
+
baseManifestSha256: sha256Buffer(current.bytes),
|
|
789
|
+
files: generatedFiles.map((file, index) => ({
|
|
790
|
+
path: file.path,
|
|
791
|
+
stagedPath: EXPERIMENT_CREATE_STAGED_PATHS[index],
|
|
792
|
+
contentType: file.contentType,
|
|
793
|
+
sha256: sha256Buffer(file.content),
|
|
794
|
+
bytes: Buffer.byteLength(file.content, 'utf8'),
|
|
795
|
+
})),
|
|
796
|
+
manifest: {
|
|
797
|
+
path: SYNC_MANIFEST_FILE,
|
|
798
|
+
stagedPath: EXPERIMENT_CREATE_MANIFEST_STAGED_PATH,
|
|
799
|
+
contentType: EXPERIMENT_CREATE_MANIFEST_CONTENT_TYPE,
|
|
800
|
+
sha256: sha256Buffer(manifestBytes),
|
|
801
|
+
bytes: manifestBytes.byteLength,
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
await writeSafeRootFile(root, transactionRelativePath(preparingDirectory, EXPERIMENT_CREATE_JOURNAL_PATH), `${JSON.stringify(journal, null, 2)}\n`);
|
|
805
|
+
await syncDirectory(preparingPath);
|
|
806
|
+
await hooks.afterPhase?.('prepared');
|
|
807
|
+
await assertNoReadyExperimentCreateTransaction(root);
|
|
808
|
+
try {
|
|
809
|
+
await rename(preparingPath, readyPath);
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
if (isErrno(error, 'EEXIST') || isErrno(error, 'ENOTEMPTY')) {
|
|
813
|
+
throw experimentCreateTransactionError('an unfinished experiment-create transaction already exists; recover it before continuing');
|
|
814
|
+
}
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
817
|
+
published = true;
|
|
818
|
+
await syncDirectory(parentPath);
|
|
819
|
+
await hooks.afterPhase?.('ready');
|
|
820
|
+
await applyPreparedExperimentCreateTransaction(root, await loadPreparedExperimentCreateTransaction(root), hooks);
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
if (!published) {
|
|
824
|
+
await rm(preparingPath, { recursive: true, force: true }).catch(() => undefined);
|
|
825
|
+
await syncDirectory(parentPath).catch(() => undefined);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const executeExperimentCreateDependencies = {
|
|
830
|
+
recover: recoverExperimentCreateTransaction,
|
|
831
|
+
readManifest: readSyncManifest,
|
|
832
|
+
readSpec: readAgentExperimentSpec,
|
|
833
|
+
assertGeneratedFilesUnchanged: assertGeneratedExperimentFilesUnchanged,
|
|
834
|
+
parseResponse: parseExperimentCreateResponse,
|
|
835
|
+
install: installExperimentCreateTransaction,
|
|
836
|
+
};
|
|
837
|
+
export async function executeExperimentCreate(input, dependencyOverrides = {}) {
|
|
838
|
+
const dependencies = { ...executeExperimentCreateDependencies, ...dependencyOverrides };
|
|
839
|
+
await dependencies.recover(input.sourceDir);
|
|
840
|
+
const manifest = await dependencies.readManifest(input.sourceDir);
|
|
841
|
+
if (!manifest) {
|
|
842
|
+
throw new Error(`No ${SYNC_MANIFEST_FILE} found. Run \`fgrove sync down --dir ${input.sourceDir}\` first.`);
|
|
843
|
+
}
|
|
844
|
+
const spec = await dependencies.readSpec(input.specPath);
|
|
845
|
+
await dependencies.assertGeneratedFilesUnchanged(input.sourceDir, manifest);
|
|
846
|
+
const rawResponse = await input.createFromSpec({
|
|
847
|
+
workspaceId: manifest.workspaceId,
|
|
848
|
+
funnelId: manifest.funnelId,
|
|
849
|
+
expectedDraftVersionId: manifest.draftVersionId,
|
|
850
|
+
spec,
|
|
851
|
+
});
|
|
852
|
+
const response = dependencies.parseResponse(rawResponse, spec, manifest.funnelId);
|
|
853
|
+
await dependencies.assertGeneratedFilesUnchanged(input.sourceDir, manifest);
|
|
854
|
+
const nextManifest = buildExperimentCreateManifest(manifest, response.draftVersionId, response.generatedFiles);
|
|
855
|
+
await dependencies.install(input.sourceDir, {
|
|
856
|
+
generatedFiles: response.generatedFiles,
|
|
857
|
+
manifest: nextManifest,
|
|
858
|
+
});
|
|
859
|
+
return {
|
|
860
|
+
experimentId: response.experiment.id,
|
|
861
|
+
experimentKey: response.experiment.posthog_flag_key,
|
|
862
|
+
draftVersionId: response.draftVersionId,
|
|
863
|
+
writtenPaths: [...EXPERIMENT_GENERATED_PATHS],
|
|
864
|
+
githubConnected: response.githubConnected,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
export function formatExperimentCreateSuccess(result, json) {
|
|
868
|
+
const machineOutput = {
|
|
869
|
+
experimentId: result.experimentId,
|
|
870
|
+
experimentKey: result.experimentKey,
|
|
871
|
+
draftVersionId: result.draftVersionId,
|
|
872
|
+
writtenPaths: result.writtenPaths,
|
|
873
|
+
};
|
|
874
|
+
if (json) {
|
|
875
|
+
return `${JSON.stringify(machineOutput, null, 2)}\n`;
|
|
876
|
+
}
|
|
877
|
+
const guidance = result.githubConnected
|
|
878
|
+
? 'For this GitHub-connected funnel, commit and push the generated source files with your other source changes, then run `fgrove github pull`.'
|
|
879
|
+
: 'Run `fgrove sync up` to deliver the local funnel changes.';
|
|
880
|
+
return [
|
|
881
|
+
`Created experiment ${result.experimentId} (${result.experimentKey})`,
|
|
882
|
+
`Draft version ${result.draftVersionId}`,
|
|
883
|
+
`Wrote ${result.writtenPaths.join(', ')}`,
|
|
884
|
+
guidance,
|
|
885
|
+
'',
|
|
886
|
+
].join('\n');
|
|
887
|
+
}
|