@dsh-enhanced/assistant-growth-experiments 0.1.12
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/LICENSE +21 -0
- package/README.md +106 -0
- package/cordis.patch.yml +15 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/service.d.ts +53 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +422 -0
- package/lib/service.js.map +1 -0
- package/lib/sqlite.d.ts +8 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +210 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +73 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +647 -0
- package/lib/store.js.map +1 -0
- package/lib/types.d.ts +66 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +4 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +83 -0
package/lib/store.js
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { canonicalGrowthJson, exactGrowthDigest, growthObjectDigest, validateWorkflowScope, validateWorkflowTraceEvidence, validateWorkflowTraceSourceAttestation, workflowCandidateSignature as sharedWorkflowCandidateSignature, workflowScopeKey, workflowTraceRevisionDigest as sharedWorkflowTraceRevisionDigest, } from '@dsh-enhanced/assistant-growth-contract';
|
|
2
|
+
import { openGrowthExperimentsDatabase } from './sqlite.js';
|
|
3
|
+
export class GrowthExperimentsStoreError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = 'GrowthExperimentsStoreError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const canonicalJson = canonicalGrowthJson;
|
|
12
|
+
const digestObject = growthObjectDigest;
|
|
13
|
+
const workflowCandidateSignature = sharedWorkflowCandidateSignature;
|
|
14
|
+
const workflowTraceRevisionDigest = sharedWorkflowTraceRevisionDigest;
|
|
15
|
+
function exactDigest(value, label) {
|
|
16
|
+
return exactGrowthDigest(value, label);
|
|
17
|
+
}
|
|
18
|
+
function canonicalSource(input) {
|
|
19
|
+
return validateWorkflowTraceSourceAttestation(input);
|
|
20
|
+
}
|
|
21
|
+
function text(value, label, maxBytes, options = {}) {
|
|
22
|
+
if (typeof value !== 'string') {
|
|
23
|
+
throw new GrowthExperimentsStoreError('invalid-input', `${label} must be a string`);
|
|
24
|
+
}
|
|
25
|
+
const normalized = value.normalize('NFC').trim();
|
|
26
|
+
if (normalized === '' || Buffer.byteLength(normalized, 'utf8') > maxBytes) {
|
|
27
|
+
throw new GrowthExperimentsStoreError('invalid-input', `${label} is invalid`);
|
|
28
|
+
}
|
|
29
|
+
for (const character of normalized) {
|
|
30
|
+
const code = character.codePointAt(0);
|
|
31
|
+
if (code === 0 || code === 0x7f || (!options.multiline && code <= 0x1f)) {
|
|
32
|
+
throw new GrowthExperimentsStoreError('invalid-input', `${label} contains a control character`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
function canonicalScope(input) {
|
|
38
|
+
const scope = validateWorkflowScope(input);
|
|
39
|
+
return Object.freeze({ ...scope, scopeKey: workflowScopeKey(scope) });
|
|
40
|
+
}
|
|
41
|
+
function canonicalEvidence(input) {
|
|
42
|
+
return validateWorkflowTraceEvidence(input);
|
|
43
|
+
}
|
|
44
|
+
function candidate(row) {
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
id: row.id,
|
|
47
|
+
scope: Object.freeze({ workspace: row.workspace, preset: row.preset }),
|
|
48
|
+
ownerBindingId: row.owner_binding_id,
|
|
49
|
+
signature: row.signature,
|
|
50
|
+
revision: row.revision,
|
|
51
|
+
evidenceDigest: row.evidence_digest,
|
|
52
|
+
evidenceCount: row.evidence_count,
|
|
53
|
+
ownerExplicitCount: row.owner_explicit_count,
|
|
54
|
+
verifiedSuccessCount: row.verified_success_count,
|
|
55
|
+
template: JSON.parse(row.template_json),
|
|
56
|
+
steps: JSON.parse(row.steps_json),
|
|
57
|
+
state: row.state,
|
|
58
|
+
createdAt: row.created_at,
|
|
59
|
+
updatedAt: row.updated_at,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function experiment(row) {
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
id: row.id,
|
|
65
|
+
candidateId: row.candidate_id,
|
|
66
|
+
candidateRevision: row.candidate_revision,
|
|
67
|
+
candidateDigest: row.candidate_digest,
|
|
68
|
+
candidateSnapshot: JSON.parse(row.candidate_json),
|
|
69
|
+
state: row.state,
|
|
70
|
+
version: row.version,
|
|
71
|
+
operationId: row.operation_id,
|
|
72
|
+
...(row.operation_kind === null ? {} : { operationKind: row.operation_kind }),
|
|
73
|
+
deadlineAt: row.deadline_at,
|
|
74
|
+
canaryExposureCount: row.canary_exposure_count,
|
|
75
|
+
attemptCount: row.attempt_count,
|
|
76
|
+
nextAttemptAt: row.next_attempt_at,
|
|
77
|
+
...(row.proposal_id === null ? {} : { proposalId: row.proposal_id }),
|
|
78
|
+
...(row.artifact_id === null ? {} : {
|
|
79
|
+
artifactId: row.artifact_id,
|
|
80
|
+
artifactVersion: row.artifact_version,
|
|
81
|
+
artifactDigest: row.artifact_digest,
|
|
82
|
+
}),
|
|
83
|
+
...(row.terminal_code === null ? {} : { terminalCode: row.terminal_code }),
|
|
84
|
+
createdAt: row.created_at,
|
|
85
|
+
updatedAt: row.updated_at,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const terminalExperimentStates = new Set([
|
|
89
|
+
'conflicted', 'expired', 'promoted', 'rejected', 'rolled-back',
|
|
90
|
+
]);
|
|
91
|
+
export class GrowthExperimentsStore {
|
|
92
|
+
database;
|
|
93
|
+
now;
|
|
94
|
+
minRepeatedSuccesses;
|
|
95
|
+
closed = false;
|
|
96
|
+
constructor(options) {
|
|
97
|
+
if (!Number.isSafeInteger(options.minRepeatedSuccesses)
|
|
98
|
+
|| options.minRepeatedSuccesses < 2 || options.minRepeatedSuccesses > 100) {
|
|
99
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'minRepeatedSuccesses must be between 2 and 100');
|
|
100
|
+
}
|
|
101
|
+
this.database = openGrowthExperimentsDatabase(options.path);
|
|
102
|
+
this.minRepeatedSuccesses = options.minRepeatedSuccesses;
|
|
103
|
+
this.now = options.now ?? Date.now;
|
|
104
|
+
}
|
|
105
|
+
projectWorkflowTraceRevision(input) {
|
|
106
|
+
this.assertOpen();
|
|
107
|
+
const scope = canonicalScope(input.scope);
|
|
108
|
+
const source = canonicalSource(input.source);
|
|
109
|
+
const subjectRef = exactDigest(input.subjectRef, 'subjectRef');
|
|
110
|
+
if (!Number.isSafeInteger(input.version) || input.version < 1
|
|
111
|
+
|| !['upsert', 'retract'].includes(input.disposition)
|
|
112
|
+
|| (input.disposition === 'upsert') !== (input.evidence !== undefined)) {
|
|
113
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'workflow trace revision is invalid');
|
|
114
|
+
}
|
|
115
|
+
const evidence = input.evidence === undefined ? undefined : canonicalEvidence(input.evidence);
|
|
116
|
+
const expectedDigest = workflowTraceRevisionDigest({
|
|
117
|
+
scope: { workspace: scope.workspace, preset: scope.preset },
|
|
118
|
+
source,
|
|
119
|
+
subjectRef,
|
|
120
|
+
version: input.version,
|
|
121
|
+
disposition: input.disposition,
|
|
122
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
123
|
+
});
|
|
124
|
+
if (exactDigest(input.digest, 'digest') !== expectedDigest) {
|
|
125
|
+
throw new GrowthExperimentsStoreError('idempotency-conflict', 'workflow trace digest does not match its payload');
|
|
126
|
+
}
|
|
127
|
+
const signature = evidence === undefined
|
|
128
|
+
? undefined
|
|
129
|
+
: workflowCandidateSignature({
|
|
130
|
+
scope: { workspace: scope.workspace, preset: scope.preset },
|
|
131
|
+
evidence,
|
|
132
|
+
});
|
|
133
|
+
const candidateIds = [];
|
|
134
|
+
let outcome = 'applied';
|
|
135
|
+
this.transaction(() => {
|
|
136
|
+
const exact = this.database.prepare(`
|
|
137
|
+
SELECT * FROM workflow_trace_revisions
|
|
138
|
+
WHERE scope_key = ? AND subject_ref = ? AND version = ?
|
|
139
|
+
`).get(scope.scopeKey, subjectRef, input.version);
|
|
140
|
+
if (exact !== undefined) {
|
|
141
|
+
if (exact.digest !== expectedDigest || exact.disposition !== input.disposition) {
|
|
142
|
+
throw new GrowthExperimentsStoreError('idempotency-conflict', 'workflow trace version already exists with different content');
|
|
143
|
+
}
|
|
144
|
+
const previous = this.database.prepare(`
|
|
145
|
+
SELECT signature FROM workflow_trace_revisions
|
|
146
|
+
WHERE scope_key = ? AND subject_ref = ? AND version < ?
|
|
147
|
+
ORDER BY version DESC LIMIT 1
|
|
148
|
+
`).get(scope.scopeKey, subjectRef, input.version);
|
|
149
|
+
const signatures = new Set();
|
|
150
|
+
if (previous?.signature !== null && previous?.signature !== undefined)
|
|
151
|
+
signatures.add(previous.signature);
|
|
152
|
+
if (exact.signature !== null)
|
|
153
|
+
signatures.add(exact.signature);
|
|
154
|
+
for (const replaySignature of signatures) {
|
|
155
|
+
candidateIds.push(this.candidateId(scope.scopeKey, replaySignature));
|
|
156
|
+
}
|
|
157
|
+
outcome = 'replayed';
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const latest = this.database.prepare(`
|
|
161
|
+
SELECT version FROM workflow_trace_revisions
|
|
162
|
+
WHERE scope_key = ? AND subject_ref = ? ORDER BY version DESC LIMIT 1
|
|
163
|
+
`).get(scope.scopeKey, subjectRef);
|
|
164
|
+
if (latest !== undefined && input.version < latest.version) {
|
|
165
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'workflow trace source version moved backwards');
|
|
166
|
+
}
|
|
167
|
+
const current = this.database.prepare(`
|
|
168
|
+
SELECT * FROM workflow_trace_current WHERE scope_key = ? AND subject_ref = ?
|
|
169
|
+
`).get(scope.scopeKey, subjectRef);
|
|
170
|
+
this.database.prepare(`
|
|
171
|
+
INSERT INTO workflow_trace_revisions(
|
|
172
|
+
scope_key, source_id, source_generation, source_authority_digest,
|
|
173
|
+
workspace, preset, subject_ref, version, digest, disposition,
|
|
174
|
+
signature, evidence_json, received_at
|
|
175
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
176
|
+
`).run(scope.scopeKey, source.sourceId, source.generation, source.authorityDigest, scope.workspace, scope.preset, subjectRef, input.version, expectedDigest, input.disposition, signature ?? null, evidence === undefined ? null : canonicalJson(evidence), this.now());
|
|
177
|
+
if (evidence === undefined || signature === undefined) {
|
|
178
|
+
this.database.prepare(`
|
|
179
|
+
DELETE FROM workflow_trace_current WHERE scope_key = ? AND subject_ref = ?
|
|
180
|
+
`).run(scope.scopeKey, subjectRef);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
this.database.prepare(`
|
|
184
|
+
INSERT INTO workflow_trace_current(
|
|
185
|
+
scope_key, source_id, source_generation, source_authority_digest,
|
|
186
|
+
workspace, preset, subject_ref, version, digest, signature, evidence_json
|
|
187
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
188
|
+
ON CONFLICT(scope_key, subject_ref) DO UPDATE SET
|
|
189
|
+
source_id = excluded.source_id, source_generation = excluded.source_generation,
|
|
190
|
+
source_authority_digest = excluded.source_authority_digest,
|
|
191
|
+
workspace = excluded.workspace, preset = excluded.preset, version = excluded.version,
|
|
192
|
+
digest = excluded.digest, signature = excluded.signature, evidence_json = excluded.evidence_json
|
|
193
|
+
`).run(scope.scopeKey, source.sourceId, source.generation, source.authorityDigest, scope.workspace, scope.preset, subjectRef, input.version, expectedDigest, signature, canonicalJson(evidence));
|
|
194
|
+
}
|
|
195
|
+
const changed = new Set();
|
|
196
|
+
if (current !== undefined)
|
|
197
|
+
changed.add(current.signature);
|
|
198
|
+
if (signature !== undefined)
|
|
199
|
+
changed.add(signature);
|
|
200
|
+
for (const changedSignature of [...changed].sort()) {
|
|
201
|
+
candidateIds.push(this.recomputeCandidate(scope, changedSignature));
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
contractVersion: 1,
|
|
206
|
+
scope: Object.freeze({ workspace: scope.workspace, preset: scope.preset }),
|
|
207
|
+
subjectRef,
|
|
208
|
+
version: input.version,
|
|
209
|
+
disposition: input.disposition,
|
|
210
|
+
digest: expectedDigest,
|
|
211
|
+
source,
|
|
212
|
+
outcome,
|
|
213
|
+
candidateIds: Object.freeze(candidateIds.sort()),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
getCandidate(id) {
|
|
217
|
+
this.assertOpen();
|
|
218
|
+
const row = this.database.prepare('SELECT * FROM workflow_candidates WHERE id = ?')
|
|
219
|
+
.get(id);
|
|
220
|
+
return row === undefined ? undefined : candidate(row);
|
|
221
|
+
}
|
|
222
|
+
listCandidates(input = {}) {
|
|
223
|
+
this.assertOpen();
|
|
224
|
+
const limit = input.limit ?? 100;
|
|
225
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
|
|
226
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'candidate limit is invalid');
|
|
227
|
+
}
|
|
228
|
+
const rows = input.state === undefined
|
|
229
|
+
? this.database.prepare('SELECT * FROM workflow_candidates ORDER BY updated_at, id LIMIT ?').all(limit)
|
|
230
|
+
: this.database.prepare(`
|
|
231
|
+
SELECT * FROM workflow_candidates WHERE state = ? ORDER BY updated_at, id LIMIT ?
|
|
232
|
+
`).all(input.state, limit);
|
|
233
|
+
return rows.map(candidate);
|
|
234
|
+
}
|
|
235
|
+
beginReadyExperiment(input) {
|
|
236
|
+
this.assertOpen();
|
|
237
|
+
if (!Number.isSafeInteger(input.maxDurationMs) || input.maxDurationMs < 1_000
|
|
238
|
+
|| input.maxDurationMs > 31_536_000_000) {
|
|
239
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'experiment duration is invalid');
|
|
240
|
+
}
|
|
241
|
+
let output;
|
|
242
|
+
this.transaction(() => {
|
|
243
|
+
const row = this.database.prepare('SELECT * FROM workflow_candidates WHERE id = ?')
|
|
244
|
+
.get(input.candidateId);
|
|
245
|
+
if (row === undefined)
|
|
246
|
+
throw new GrowthExperimentsStoreError('not-found', 'workflow candidate was not found');
|
|
247
|
+
const candidateValue = candidate(row);
|
|
248
|
+
const id = `growth_${digestObject([
|
|
249
|
+
'assistant-growth-experiment/v1', candidateValue.id, candidateValue.revision, candidateValue.evidenceDigest,
|
|
250
|
+
])}`;
|
|
251
|
+
const existing = this.database.prepare('SELECT * FROM growth_experiments WHERE id = ?')
|
|
252
|
+
.get(id);
|
|
253
|
+
if (existing !== undefined) {
|
|
254
|
+
output = experiment(existing);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (candidateValue.state !== 'ready') {
|
|
258
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'workflow candidate is not ready');
|
|
259
|
+
}
|
|
260
|
+
const active = this.database.prepare(`
|
|
261
|
+
SELECT id FROM growth_experiments WHERE candidate_id = ? AND state NOT IN (
|
|
262
|
+
'conflicted', 'expired', 'promoted', 'rejected', 'rolled-back'
|
|
263
|
+
) LIMIT 1
|
|
264
|
+
`).get(candidateValue.id);
|
|
265
|
+
if (active !== undefined) {
|
|
266
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'workflow candidate already has an active experiment');
|
|
267
|
+
}
|
|
268
|
+
const now = this.now();
|
|
269
|
+
const operationId = `${id}:approval-request`;
|
|
270
|
+
this.database.prepare(`
|
|
271
|
+
INSERT INTO growth_experiments(
|
|
272
|
+
id, candidate_id, candidate_revision, candidate_digest, candidate_json, state, version, operation_id,
|
|
273
|
+
operation_kind, deadline_at, canary_exposure_count, attempt_count, next_attempt_at,
|
|
274
|
+
created_at, updated_at
|
|
275
|
+
) VALUES (?, ?, ?, ?, ?, 'approval-requesting', 1, ?, 'approval-proposal', ?, 0, 0, ?, ?, ?)
|
|
276
|
+
`).run(id, candidateValue.id, candidateValue.revision, candidateValue.evidenceDigest, canonicalJson({
|
|
277
|
+
id: candidateValue.id,
|
|
278
|
+
scope: candidateValue.scope,
|
|
279
|
+
ownerBindingId: candidateValue.ownerBindingId,
|
|
280
|
+
signature: candidateValue.signature,
|
|
281
|
+
revision: candidateValue.revision,
|
|
282
|
+
evidenceDigest: candidateValue.evidenceDigest,
|
|
283
|
+
evidenceCount: candidateValue.evidenceCount,
|
|
284
|
+
ownerExplicitCount: candidateValue.ownerExplicitCount,
|
|
285
|
+
verifiedSuccessCount: candidateValue.verifiedSuccessCount,
|
|
286
|
+
template: candidateValue.template,
|
|
287
|
+
steps: candidateValue.steps,
|
|
288
|
+
}), operationId, now + input.maxDurationMs, now, now, now);
|
|
289
|
+
this.database.prepare(`
|
|
290
|
+
UPDATE workflow_candidates SET state = 'running', updated_at = ?
|
|
291
|
+
WHERE id = ? AND revision = ? AND state = 'ready'
|
|
292
|
+
`).run(now, candidateValue.id, candidateValue.revision);
|
|
293
|
+
output = experiment(this.database.prepare('SELECT * FROM growth_experiments WHERE id = ?')
|
|
294
|
+
.get(id));
|
|
295
|
+
});
|
|
296
|
+
return output;
|
|
297
|
+
}
|
|
298
|
+
getExperiment(id) {
|
|
299
|
+
this.assertOpen();
|
|
300
|
+
const row = this.database.prepare('SELECT * FROM growth_experiments WHERE id = ?')
|
|
301
|
+
.get(id);
|
|
302
|
+
return row === undefined ? undefined : experiment(row);
|
|
303
|
+
}
|
|
304
|
+
listActiveExperiments(limit = 100) {
|
|
305
|
+
this.assertOpen();
|
|
306
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
|
|
307
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'experiment limit is invalid');
|
|
308
|
+
}
|
|
309
|
+
const rows = this.database.prepare(`
|
|
310
|
+
SELECT * FROM growth_experiments WHERE state NOT IN (
|
|
311
|
+
'conflicted', 'expired', 'promoted', 'rejected', 'rolled-back'
|
|
312
|
+
) ORDER BY updated_at, id LIMIT ?
|
|
313
|
+
`).all(limit);
|
|
314
|
+
return rows.map(experiment);
|
|
315
|
+
}
|
|
316
|
+
listRunnableExperiments(now, limit = 100) {
|
|
317
|
+
this.assertOpen();
|
|
318
|
+
if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
|
|
319
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'runnable experiment query is invalid');
|
|
320
|
+
}
|
|
321
|
+
const rows = this.database.prepare(`
|
|
322
|
+
SELECT * FROM growth_experiments WHERE state NOT IN (
|
|
323
|
+
'conflicted', 'expired', 'promoted', 'rejected', 'rolled-back'
|
|
324
|
+
) AND next_attempt_at <= ? ORDER BY next_attempt_at, updated_at, id LIMIT ?
|
|
325
|
+
`).all(now, limit);
|
|
326
|
+
return rows.map(experiment);
|
|
327
|
+
}
|
|
328
|
+
transitionExperiment(input) {
|
|
329
|
+
this.assertOpen();
|
|
330
|
+
const id = text(input.experimentId, 'experimentId', 200);
|
|
331
|
+
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1) {
|
|
332
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'experiment version is invalid');
|
|
333
|
+
}
|
|
334
|
+
let output;
|
|
335
|
+
this.transaction(() => {
|
|
336
|
+
const row = this.database.prepare('SELECT * FROM growth_experiments WHERE id = ?')
|
|
337
|
+
.get(id);
|
|
338
|
+
if (row === undefined)
|
|
339
|
+
throw new GrowthExperimentsStoreError('not-found', 'growth experiment was not found');
|
|
340
|
+
if (row.version !== input.expectedVersion || row.state !== input.expectedState) {
|
|
341
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'growth experiment state changed');
|
|
342
|
+
}
|
|
343
|
+
const allowedTransitions = {
|
|
344
|
+
'approval-requesting': ['approval-requesting', 'approval-pending', 'conflicted', 'expired', 'rejected',
|
|
345
|
+
'replay-pending', 'rollback-pending'],
|
|
346
|
+
'approval-pending': ['approval-requesting', 'conflicted', 'expired'],
|
|
347
|
+
'replay-pending': ['conflicted', 'shadow-pending', 'rollback-pending'],
|
|
348
|
+
'shadow-pending': ['conflicted', 'canary-pending', 'rollback-pending'],
|
|
349
|
+
'canary-pending': ['conflicted', 'canary-pending', 'promotion-pending', 'rollback-pending'],
|
|
350
|
+
'promotion-pending': ['conflicted', 'promoted', 'rollback-pending'],
|
|
351
|
+
'rollback-pending': ['rolled-back'],
|
|
352
|
+
conflicted: [], expired: [], promoted: [], rejected: [], 'rolled-back': [],
|
|
353
|
+
};
|
|
354
|
+
if (!allowedTransitions[row.state].includes(input.state)) {
|
|
355
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'growth experiment transition is forbidden');
|
|
356
|
+
}
|
|
357
|
+
const proposalId = input.proposalId === undefined
|
|
358
|
+
? row.proposal_id
|
|
359
|
+
: text(input.proposalId, 'proposalId', 200);
|
|
360
|
+
const artifactId = input.artifact === undefined
|
|
361
|
+
? row.artifact_id
|
|
362
|
+
: text(input.artifact.id, 'artifactId', 200);
|
|
363
|
+
const artifactVersion = input.artifact === undefined ? row.artifact_version : input.artifact.version;
|
|
364
|
+
const artifactDigest = input.artifact === undefined
|
|
365
|
+
? row.artifact_digest
|
|
366
|
+
: exactDigest(input.artifact.digest, 'artifactDigest');
|
|
367
|
+
if (artifactVersion !== null
|
|
368
|
+
&& (!Number.isSafeInteger(artifactVersion) || artifactVersion < 1)) {
|
|
369
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'artifact version is invalid');
|
|
370
|
+
}
|
|
371
|
+
const exposure = input.canaryExposureCount ?? row.canary_exposure_count;
|
|
372
|
+
if (!Number.isSafeInteger(exposure) || exposure < row.canary_exposure_count || exposure > 1) {
|
|
373
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'canary exposure count is invalid');
|
|
374
|
+
}
|
|
375
|
+
const terminalCode = input.terminalCode === undefined
|
|
376
|
+
? row.terminal_code
|
|
377
|
+
: text(input.terminalCode, 'terminalCode', 200);
|
|
378
|
+
const version = row.version + 1;
|
|
379
|
+
const operationKind = input.operationKind ?? null;
|
|
380
|
+
const terminal = terminalExperimentStates.has(input.state);
|
|
381
|
+
if ((input.state === 'approval-pending' || terminal) !== (operationKind === null)) {
|
|
382
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'experiment operation intent is invalid');
|
|
383
|
+
}
|
|
384
|
+
const expectedKind = {
|
|
385
|
+
'replay-pending': 'replay', 'shadow-pending': 'shadow', 'canary-pending': 'canary',
|
|
386
|
+
'promotion-pending': 'promotion', 'rollback-pending': 'rollback',
|
|
387
|
+
};
|
|
388
|
+
if (input.state === 'approval-requesting') {
|
|
389
|
+
if (operationKind !== 'approval-proposal' && operationKind !== 'approval-settlement') {
|
|
390
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'approval operation intent is invalid');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else if (input.state === 'canary-pending') {
|
|
394
|
+
if (operationKind !== 'canary' && operationKind !== 'canary-inspection') {
|
|
395
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'canary operation intent is invalid');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else if (expectedKind[input.state] !== undefined && expectedKind[input.state] !== operationKind) {
|
|
399
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'experiment operation intent does not match state');
|
|
400
|
+
}
|
|
401
|
+
const operationId = input.operationId ?? `${row.id}:${operationKind ?? input.state}`;
|
|
402
|
+
const now = this.now();
|
|
403
|
+
const nextAttemptAt = input.nextAttemptAt ?? now;
|
|
404
|
+
if (!Number.isSafeInteger(nextAttemptAt) || nextAttemptAt < 0) {
|
|
405
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'next attempt time is invalid');
|
|
406
|
+
}
|
|
407
|
+
const updated = this.database.prepare(`
|
|
408
|
+
UPDATE growth_experiments SET
|
|
409
|
+
state = ?, version = ?, operation_id = ?, operation_kind = ?, proposal_id = ?, artifact_id = ?,
|
|
410
|
+
artifact_version = ?, artifact_digest = ?, canary_exposure_count = ?, terminal_code = ?,
|
|
411
|
+
attempt_count = ?, next_attempt_at = ?, updated_at = ?
|
|
412
|
+
WHERE id = ? AND version = ? AND state = ?
|
|
413
|
+
`).run(input.state, version, operationId, operationKind, proposalId, artifactId, artifactVersion, artifactDigest, exposure, terminalCode, input.preserveAttempts === true ? row.attempt_count : 0, nextAttemptAt, now, row.id, row.version, row.state);
|
|
414
|
+
if (updated.changes !== 1) {
|
|
415
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'growth experiment transition lost its fence');
|
|
416
|
+
}
|
|
417
|
+
if (terminalExperimentStates.has(input.state)) {
|
|
418
|
+
const candidateState = input.state === 'promoted'
|
|
419
|
+
? 'promoted'
|
|
420
|
+
: input.state === 'rejected' || input.state === 'expired'
|
|
421
|
+
? 'rejected'
|
|
422
|
+
: input.state === 'rolled-back'
|
|
423
|
+
? 'rolled-back'
|
|
424
|
+
: 'conflicted';
|
|
425
|
+
this.database.prepare(`
|
|
426
|
+
UPDATE workflow_candidates SET state = ?, updated_at = ?
|
|
427
|
+
WHERE id = ? AND revision = ? AND evidence_digest = ? AND state = 'running'
|
|
428
|
+
`).run(candidateState, now, row.candidate_id, row.candidate_revision, row.candidate_digest);
|
|
429
|
+
}
|
|
430
|
+
output = experiment(this.database.prepare('SELECT * FROM growth_experiments WHERE id = ?')
|
|
431
|
+
.get(row.id));
|
|
432
|
+
});
|
|
433
|
+
return output;
|
|
434
|
+
}
|
|
435
|
+
markCanaryIssued(input) {
|
|
436
|
+
const current = this.getExperiment(input.experimentId);
|
|
437
|
+
if (current === undefined)
|
|
438
|
+
throw new GrowthExperimentsStoreError('not-found', 'growth experiment was not found');
|
|
439
|
+
if (current.version !== input.expectedVersion || current.state !== 'canary-pending'
|
|
440
|
+
|| (current.operationKind !== 'canary' && current.operationKind !== 'canary-inspection')) {
|
|
441
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'canary intent changed');
|
|
442
|
+
}
|
|
443
|
+
if (current.canaryExposureCount === 1)
|
|
444
|
+
return current;
|
|
445
|
+
return this.transitionExperiment({
|
|
446
|
+
experimentId: current.id, expectedVersion: current.version, expectedState: current.state,
|
|
447
|
+
state: 'canary-pending', operationKind: 'canary', operationId: current.operationId,
|
|
448
|
+
canaryExposureCount: 1, preserveAttempts: true,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
recordOperationFailure(input) {
|
|
452
|
+
this.assertOpen();
|
|
453
|
+
const code = text(input.code, 'operation failure code', 200);
|
|
454
|
+
if (!Number.isSafeInteger(input.nextAttemptAt) || input.nextAttemptAt < 0) {
|
|
455
|
+
throw new GrowthExperimentsStoreError('invalid-input', 'next attempt time is invalid');
|
|
456
|
+
}
|
|
457
|
+
const row = this.database.prepare('SELECT * FROM growth_experiments WHERE id = ?')
|
|
458
|
+
.get(input.experimentId);
|
|
459
|
+
if (row === undefined)
|
|
460
|
+
throw new GrowthExperimentsStoreError('not-found', 'growth experiment was not found');
|
|
461
|
+
if (row.version !== input.expectedVersion || terminalExperimentStates.has(row.state)) {
|
|
462
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'growth experiment state changed');
|
|
463
|
+
}
|
|
464
|
+
const now = this.now();
|
|
465
|
+
const updated = this.database.prepare(`
|
|
466
|
+
UPDATE growth_experiments SET version = version + 1, attempt_count = attempt_count + 1,
|
|
467
|
+
next_attempt_at = ?, terminal_code = ?, updated_at = ? WHERE id = ? AND version = ?
|
|
468
|
+
`).run(input.nextAttemptAt, code, now, row.id, row.version);
|
|
469
|
+
if (updated.changes !== 1)
|
|
470
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'operation failure lost its fence');
|
|
471
|
+
return experiment(this.database.prepare('SELECT * FROM growth_experiments WHERE id = ?')
|
|
472
|
+
.get(row.id));
|
|
473
|
+
}
|
|
474
|
+
requestRollback(input) {
|
|
475
|
+
const current = this.getExperiment(input.experimentId);
|
|
476
|
+
if (current === undefined)
|
|
477
|
+
throw new GrowthExperimentsStoreError('not-found', 'growth experiment was not found');
|
|
478
|
+
if (current.version !== input.expectedVersion) {
|
|
479
|
+
throw new GrowthExperimentsStoreError('version-conflict', 'growth experiment state changed');
|
|
480
|
+
}
|
|
481
|
+
if (terminalExperimentStates.has(current.state) || current.state === 'rollback-pending')
|
|
482
|
+
return current;
|
|
483
|
+
if (current.artifactId === undefined && current.state === 'approval-requesting') {
|
|
484
|
+
return this.transitionExperiment({
|
|
485
|
+
experimentId: current.id, expectedVersion: current.version, expectedState: current.state,
|
|
486
|
+
state: current.state, operationKind: current.operationKind, operationId: current.operationId,
|
|
487
|
+
terminalCode: input.code, preserveAttempts: true,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return this.transitionExperiment({
|
|
491
|
+
experimentId: current.id,
|
|
492
|
+
expectedVersion: current.version,
|
|
493
|
+
expectedState: current.state,
|
|
494
|
+
state: current.artifactId === undefined ? 'conflicted' : 'rollback-pending',
|
|
495
|
+
...(current.artifactId === undefined ? {} : {
|
|
496
|
+
operationKind: 'rollback',
|
|
497
|
+
operationId: `${current.id}:rollback`,
|
|
498
|
+
}),
|
|
499
|
+
terminalCode: input.code,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
recordError(code) {
|
|
503
|
+
this.assertOpen();
|
|
504
|
+
this.database.prepare(`
|
|
505
|
+
UPDATE growth_runtime_state SET last_error_code = ?, updated_at = ? WHERE singleton = 1
|
|
506
|
+
`).run(code === undefined ? null : text(code, 'error code', 200), this.now());
|
|
507
|
+
}
|
|
508
|
+
health() {
|
|
509
|
+
this.assertOpen();
|
|
510
|
+
const scalar = (sql) => this.database.prepare(sql).get().count;
|
|
511
|
+
const runtime = this.database.prepare('SELECT last_error_code FROM growth_runtime_state WHERE singleton = 1')
|
|
512
|
+
.get();
|
|
513
|
+
return Object.freeze({
|
|
514
|
+
candidates: scalar('SELECT COUNT(*) AS count FROM workflow_candidates'),
|
|
515
|
+
readyCandidates: scalar("SELECT COUNT(*) AS count FROM workflow_candidates WHERE state = 'ready'"),
|
|
516
|
+
activeExperiments: scalar(`SELECT COUNT(*) AS count FROM growth_experiments WHERE state NOT IN (
|
|
517
|
+
'conflicted', 'expired', 'promoted', 'rejected', 'rolled-back')`),
|
|
518
|
+
rollbackPending: scalar("SELECT COUNT(*) AS count FROM growth_experiments WHERE state = 'rollback-pending'"),
|
|
519
|
+
promoted: scalar("SELECT COUNT(*) AS count FROM growth_experiments WHERE state = 'promoted'"),
|
|
520
|
+
traceRevisions: scalar('SELECT COUNT(*) AS count FROM workflow_trace_revisions'),
|
|
521
|
+
currentTraces: scalar('SELECT COUNT(*) AS count FROM workflow_trace_current'),
|
|
522
|
+
exhaustedRollbacks: scalar(`SELECT COUNT(*) AS count FROM growth_experiments
|
|
523
|
+
WHERE state = 'rollback-pending' AND terminal_code = 'rollback-retry-budget-exhausted'`),
|
|
524
|
+
...(runtime.last_error_code === null ? {} : { lastErrorCode: runtime.last_error_code }),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
close() {
|
|
528
|
+
if (this.closed)
|
|
529
|
+
return;
|
|
530
|
+
this.closed = true;
|
|
531
|
+
this.database.close();
|
|
532
|
+
}
|
|
533
|
+
recomputeCandidate(scope, signature) {
|
|
534
|
+
const rows = this.database.prepare(`
|
|
535
|
+
SELECT * FROM workflow_trace_current
|
|
536
|
+
WHERE scope_key = ? AND signature = ? ORDER BY subject_ref
|
|
537
|
+
`).all(scope.scopeKey, signature);
|
|
538
|
+
const id = this.candidateId(scope.scopeKey, signature);
|
|
539
|
+
const existing = this.database.prepare('SELECT * FROM workflow_candidates WHERE id = ?')
|
|
540
|
+
.get(id);
|
|
541
|
+
const now = this.now();
|
|
542
|
+
if (rows.length === 0) {
|
|
543
|
+
if (existing !== undefined && existing.state !== 'retracted') {
|
|
544
|
+
this.invalidateCandidateExperiments(existing, now);
|
|
545
|
+
this.database.prepare(`
|
|
546
|
+
UPDATE workflow_candidates SET revision = revision + 1, evidence_digest = ?,
|
|
547
|
+
evidence_count = 0, owner_explicit_count = 0, verified_success_count = 0,
|
|
548
|
+
state = 'retracted', updated_at = ? WHERE id = ?
|
|
549
|
+
`).run(digestObject(['workflow-evidence/v1', []]), now, id);
|
|
550
|
+
}
|
|
551
|
+
return id;
|
|
552
|
+
}
|
|
553
|
+
const parsed = rows.map(row => ({ row, evidence: canonicalEvidence(JSON.parse(row.evidence_json)) }));
|
|
554
|
+
const evidenceDigest = digestObject({
|
|
555
|
+
contract: 'assistant-growth-evidence-window/v1',
|
|
556
|
+
rows: parsed.map(({ row }) => ({
|
|
557
|
+
subjectRef: row.subject_ref,
|
|
558
|
+
version: row.version,
|
|
559
|
+
digest: row.digest,
|
|
560
|
+
})),
|
|
561
|
+
});
|
|
562
|
+
if (existing?.evidence_digest === evidenceDigest)
|
|
563
|
+
return id;
|
|
564
|
+
const first = parsed[0].evidence;
|
|
565
|
+
for (const entry of parsed) {
|
|
566
|
+
if (canonicalJson(entry.evidence.template) !== canonicalJson(first.template)
|
|
567
|
+
|| canonicalJson(entry.evidence.steps) !== canonicalJson(first.steps)
|
|
568
|
+
|| entry.evidence.ownerBindingId !== first.ownerBindingId) {
|
|
569
|
+
throw new GrowthExperimentsStoreError('idempotency-conflict', 'workflow signature collision contains different canonical traces');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const ownerExplicitCount = parsed.filter(entry => entry.evidence.signal === 'owner-explicit').length;
|
|
573
|
+
const trustedTaskEvidence = new Map();
|
|
574
|
+
for (const entry of parsed) {
|
|
575
|
+
if (entry.evidence.signal !== 'verified-repetition' || entry.evidence.objectiveStatus !== 'achieved')
|
|
576
|
+
continue;
|
|
577
|
+
const taskEvidenceDigest = entry.evidence.taskEvidenceDigest;
|
|
578
|
+
const previous = trustedTaskEvidence.get(entry.evidence.taskRef);
|
|
579
|
+
if (previous !== undefined && previous !== taskEvidenceDigest) {
|
|
580
|
+
throw new GrowthExperimentsStoreError('idempotency-conflict', 'one trusted task reference has conflicting evaluation evidence');
|
|
581
|
+
}
|
|
582
|
+
trustedTaskEvidence.set(entry.evidence.taskRef, taskEvidenceDigest);
|
|
583
|
+
}
|
|
584
|
+
const verifiedSuccessCount = trustedTaskEvidence.size;
|
|
585
|
+
const ready = ownerExplicitCount > 0 || verifiedSuccessCount >= this.minRepeatedSuccesses;
|
|
586
|
+
const state = ready ? 'ready' : 'observing';
|
|
587
|
+
if (existing === undefined) {
|
|
588
|
+
this.database.prepare(`
|
|
589
|
+
INSERT INTO workflow_candidates(
|
|
590
|
+
id, scope_key, workspace, preset, owner_binding_id, signature, revision, evidence_digest, evidence_count,
|
|
591
|
+
owner_explicit_count, verified_success_count, template_json, steps_json, state, created_at, updated_at
|
|
592
|
+
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
593
|
+
`).run(id, scope.scopeKey, scope.workspace, scope.preset, first.ownerBindingId, signature, evidenceDigest, parsed.length, ownerExplicitCount, verifiedSuccessCount, canonicalJson(first.template), canonicalJson(first.steps), state, now, now);
|
|
594
|
+
return id;
|
|
595
|
+
}
|
|
596
|
+
this.invalidateCandidateExperiments(existing, now);
|
|
597
|
+
this.database.prepare(`
|
|
598
|
+
UPDATE workflow_candidates SET
|
|
599
|
+
revision = revision + 1, evidence_digest = ?, evidence_count = ?, owner_explicit_count = ?,
|
|
600
|
+
verified_success_count = ?, owner_binding_id = ?, template_json = ?, steps_json = ?, state = ?, updated_at = ?
|
|
601
|
+
WHERE id = ?
|
|
602
|
+
`).run(evidenceDigest, parsed.length, ownerExplicitCount, verifiedSuccessCount, first.ownerBindingId, canonicalJson(first.template), canonicalJson(first.steps), state, now, id);
|
|
603
|
+
return id;
|
|
604
|
+
}
|
|
605
|
+
candidateId(scopeKey, signature) {
|
|
606
|
+
return `workflow_${digestObject(['workflow-candidate/v1', scopeKey, signature])}`;
|
|
607
|
+
}
|
|
608
|
+
invalidateCandidateExperiments(candidateRow, now) {
|
|
609
|
+
const rows = this.database.prepare(`
|
|
610
|
+
SELECT * FROM growth_experiments
|
|
611
|
+
WHERE candidate_id = ? AND candidate_revision = ? AND candidate_digest = ?
|
|
612
|
+
AND state NOT IN ('conflicted', 'expired', 'rejected', 'rolled-back')
|
|
613
|
+
`).all(candidateRow.id, candidateRow.revision, candidateRow.evidence_digest);
|
|
614
|
+
for (const row of rows) {
|
|
615
|
+
const recoverApprovalSideEffect = row.artifact_id === null && row.state === 'approval-requesting';
|
|
616
|
+
const state = row.artifact_id !== null
|
|
617
|
+
? 'rollback-pending'
|
|
618
|
+
: recoverApprovalSideEffect ? 'approval-requesting' : 'conflicted';
|
|
619
|
+
const operationKind = row.artifact_id !== null ? 'rollback' : recoverApprovalSideEffect
|
|
620
|
+
? row.operation_kind : null;
|
|
621
|
+
const operationId = row.artifact_id !== null ? `${row.id}:rollback` : recoverApprovalSideEffect
|
|
622
|
+
? row.operation_id : `${row.id}:conflicted`;
|
|
623
|
+
this.database.prepare(`
|
|
624
|
+
UPDATE growth_experiments SET state = ?, version = version + 1, operation_id = ?,
|
|
625
|
+
operation_kind = ?, attempt_count = 0, next_attempt_at = ?,
|
|
626
|
+
terminal_code = 'evidence-superseded', updated_at = ? WHERE id = ? AND version = ?
|
|
627
|
+
`).run(state, operationId, operationKind, now, now, row.id, row.version);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
transaction(callback) {
|
|
631
|
+
this.database.exec('BEGIN IMMEDIATE');
|
|
632
|
+
try {
|
|
633
|
+
const output = callback();
|
|
634
|
+
this.database.exec('COMMIT');
|
|
635
|
+
return output;
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
this.database.exec('ROLLBACK');
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
assertOpen() {
|
|
643
|
+
if (this.closed)
|
|
644
|
+
throw new GrowthExperimentsStoreError('disposed', 'growth experiments store is closed');
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
//# sourceMappingURL=store.js.map
|