crewly 1.20.193 → 1.20.194

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/config/skills/agent/core/experiment-card/SKILL.md +96 -0
  2. package/config/skills/agent/core/experiment-card/execute.sh +147 -0
  3. package/config/skills/agent/core/experiment-card/execute.test.sh +91 -0
  4. package/config/skills/registry.json +6 -5
  5. package/dist/backend/backend/src/constants.d.ts +58 -1
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +61 -1
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/experiments/experiments.routes.d.ts +27 -0
  10. package/dist/backend/backend/src/controllers/experiments/experiments.routes.d.ts.map +1 -0
  11. package/dist/backend/backend/src/controllers/experiments/experiments.routes.js +96 -0
  12. package/dist/backend/backend/src/controllers/experiments/experiments.routes.js.map +1 -0
  13. package/dist/backend/backend/src/index.d.ts.map +1 -1
  14. package/dist/backend/backend/src/index.js +39 -3
  15. package/dist/backend/backend/src/index.js.map +1 -1
  16. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  17. package/dist/backend/backend/src/routes/api.routes.js +3 -0
  18. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  19. package/dist/backend/backend/src/services/agent/auditor-scheduler.service.d.ts.map +1 -1
  20. package/dist/backend/backend/src/services/agent/auditor-scheduler.service.js +3 -0
  21. package/dist/backend/backend/src/services/agent/auditor-scheduler.service.js.map +1 -1
  22. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  23. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  24. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  25. package/dist/backend/backend/src/services/canva/canva-token.service.d.ts.map +1 -1
  26. package/dist/backend/backend/src/services/canva/canva-token.service.js +5 -0
  27. package/dist/backend/backend/src/services/canva/canva-token.service.js.map +1 -1
  28. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +14 -3
  29. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +28 -4
  31. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/experiments/experiment-verdict.d.ts +88 -0
  33. package/dist/backend/backend/src/services/experiments/experiment-verdict.d.ts.map +1 -0
  34. package/dist/backend/backend/src/services/experiments/experiment-verdict.js +226 -0
  35. package/dist/backend/backend/src/services/experiments/experiment-verdict.js.map +1 -0
  36. package/dist/backend/backend/src/services/experiments/experiment.service.d.ts +301 -0
  37. package/dist/backend/backend/src/services/experiments/experiment.service.d.ts.map +1 -0
  38. package/dist/backend/backend/src/services/experiments/experiment.service.js +754 -0
  39. package/dist/backend/backend/src/services/experiments/experiment.service.js.map +1 -0
  40. package/dist/backend/backend/src/services/experiments/experiment.wiring.d.ts +73 -0
  41. package/dist/backend/backend/src/services/experiments/experiment.wiring.d.ts.map +1 -0
  42. package/dist/backend/backend/src/services/experiments/experiment.wiring.js +142 -0
  43. package/dist/backend/backend/src/services/experiments/experiment.wiring.js.map +1 -0
  44. package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.d.ts +60 -0
  45. package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.d.ts.map +1 -0
  46. package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.js +113 -0
  47. package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.js.map +1 -0
  48. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/google/google-workspace-token.service.js +5 -0
  50. package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
  51. package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts +11 -3
  52. package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/messaging/queue-processor.service.js +24 -7
  54. package/dist/backend/backend/src/services/messaging/queue-processor.service.js.map +1 -1
  55. package/dist/backend/backend/src/services/microsoft/microsoft-token.service.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/microsoft/microsoft-token.service.js +5 -0
  57. package/dist/backend/backend/src/services/microsoft/microsoft-token.service.js.map +1 -1
  58. package/dist/backend/backend/src/services/orc/orc-status-router.service.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/orc/orc-status-router.service.js +3 -2
  60. package/dist/backend/backend/src/services/orc/orc-status-router.service.js.map +1 -1
  61. package/dist/backend/backend/src/services/people/acting-for.service.d.ts +42 -5
  62. package/dist/backend/backend/src/services/people/acting-for.service.d.ts.map +1 -1
  63. package/dist/backend/backend/src/services/people/acting-for.service.js +80 -5
  64. package/dist/backend/backend/src/services/people/acting-for.service.js.map +1 -1
  65. package/dist/backend/backend/src/services/people/grant-sharing.d.ts +5 -4
  66. package/dist/backend/backend/src/services/people/grant-sharing.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/people/grant-sharing.js +12 -4
  68. package/dist/backend/backend/src/services/people/grant-sharing.js.map +1 -1
  69. package/dist/backend/backend/src/services/people/people-directory.service.d.ts +71 -13
  70. package/dist/backend/backend/src/services/people/people-directory.service.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/people/people-directory.service.js +164 -27
  72. package/dist/backend/backend/src/services/people/people-directory.service.js.map +1 -1
  73. package/dist/backend/backend/src/services/slack/slack-bot-ids.d.ts +33 -0
  74. package/dist/backend/backend/src/services/slack/slack-bot-ids.d.ts.map +1 -0
  75. package/dist/backend/backend/src/services/slack/slack-bot-ids.js +56 -0
  76. package/dist/backend/backend/src/services/slack/slack-bot-ids.js.map +1 -0
  77. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +12 -0
  78. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  79. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +27 -8
  80. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  81. package/dist/backend/backend/src/services/slack/slack.service.d.ts +6 -0
  82. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  83. package/dist/backend/backend/src/services/slack/slack.service.js +8 -0
  84. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  85. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
  86. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
  87. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/workflow/message-scheduler.service.d.ts.map +1 -1
  89. package/dist/backend/backend/src/services/workflow/message-scheduler.service.js +3 -0
  90. package/dist/backend/backend/src/services/workflow/message-scheduler.service.js.map +1 -1
  91. package/dist/backend/backend/src/services/workflow/scheduler.service.d.ts.map +1 -1
  92. package/dist/backend/backend/src/services/workflow/scheduler.service.js +3 -0
  93. package/dist/backend/backend/src/services/workflow/scheduler.service.js.map +1 -1
  94. package/dist/backend/backend/src/types/experiment.types.d.ts +153 -0
  95. package/dist/backend/backend/src/types/experiment.types.d.ts.map +1 -0
  96. package/dist/backend/backend/src/types/experiment.types.js +32 -0
  97. package/dist/backend/backend/src/types/experiment.types.js.map +1 -0
  98. package/dist/backend/backend/src/types/messaging.types.d.ts +6 -0
  99. package/dist/backend/backend/src/types/messaging.types.d.ts.map +1 -1
  100. package/dist/backend/backend/src/types/messaging.types.js.map +1 -1
  101. package/dist/backend/build-info.json +2 -2
  102. package/dist/cli/backend/src/constants.d.ts +58 -1
  103. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  104. package/dist/cli/backend/src/constants.js +61 -1
  105. package/dist/cli/backend/src/constants.js.map +1 -1
  106. package/dist/cli/backend/src/services/agent/auditor-scheduler.service.d.ts.map +1 -1
  107. package/dist/cli/backend/src/services/agent/auditor-scheduler.service.js +3 -0
  108. package/dist/cli/backend/src/services/agent/auditor-scheduler.service.js.map +1 -1
  109. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  110. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  111. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  112. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +14 -3
  113. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  114. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +28 -4
  115. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  116. package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts +11 -3
  117. package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
  118. package/dist/cli/backend/src/services/messaging/queue-processor.service.js +24 -7
  119. package/dist/cli/backend/src/services/messaging/queue-processor.service.js.map +1 -1
  120. package/dist/cli/backend/src/services/people/acting-for.service.d.ts +42 -5
  121. package/dist/cli/backend/src/services/people/acting-for.service.d.ts.map +1 -1
  122. package/dist/cli/backend/src/services/people/acting-for.service.js +80 -5
  123. package/dist/cli/backend/src/services/people/acting-for.service.js.map +1 -1
  124. package/dist/cli/backend/src/services/people/people-directory.service.d.ts +71 -13
  125. package/dist/cli/backend/src/services/people/people-directory.service.d.ts.map +1 -1
  126. package/dist/cli/backend/src/services/people/people-directory.service.js +164 -27
  127. package/dist/cli/backend/src/services/people/people-directory.service.js.map +1 -1
  128. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +12 -0
  129. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  130. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +27 -8
  131. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  132. package/dist/cli/backend/src/services/slack/slack.service.d.ts +6 -0
  133. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  134. package/dist/cli/backend/src/services/slack/slack.service.js +8 -0
  135. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  136. package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
  137. package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
  138. package/dist/cli/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
  139. package/dist/cli/backend/src/types/messaging.types.d.ts +6 -0
  140. package/dist/cli/backend/src/types/messaging.types.d.ts.map +1 -1
  141. package/dist/cli/backend/src/types/messaging.types.js.map +1 -1
  142. package/frontend/dist/assets/{index-465d1f8f.js → index-800118ca.js} +274 -274
  143. package/frontend/dist/index.html +1 -1
  144. package/package.json +1 -1
@@ -0,0 +1,754 @@
1
+ /**
2
+ * Experiment cards (issue #986, epic #982).
3
+ *
4
+ * An optimisation ticket carries an experiment: a hypothesis ("change X →
5
+ * metric Y from a to b"), a metric (Search Console / GA4 through the seo-ops
6
+ * skill), a baseline captured when the change ships and an observation
7
+ * window (default 14 days). When the window has settled the harness fetches
8
+ * the metric, labels the result worked / didn't / inconclusive (simple
9
+ * significance rules plus a minimum volume, see experiment-verdict.ts),
10
+ * resolves the agent's prediction, appends the result to the wiki
11
+ * experiment log and tells the owner.
12
+ *
13
+ * The experiment is a trace root: `traceId` (`exp:EXP-n`) names the run, and
14
+ * the timeline records every step (created, shipped, baseline, each fetch
15
+ * failure, measured, logged, reported), so the whole run can be reviewed.
16
+ *
17
+ * State: `~/.crewly/experiments.json`. A tick (every 15 min) moves
18
+ * experiments forward, so restarts lose nothing.
19
+ *
20
+ * specs/experiment-cards.md
21
+ *
22
+ * @module services/experiments/experiment.service
23
+ */
24
+ import { promises as fs } from 'fs';
25
+ import path from 'path';
26
+ import { EXPERIMENT_CONSTANTS } from '../../constants.js';
27
+ import { LoggerService } from '../core/logger.service.js';
28
+ import { isExperimentSource, isExperimentStatus, } from '../../types/experiment.types.js';
29
+ import { decideVerdict, defaultDirection, experimentWindows, formatValue } from './experiment-verdict.js';
30
+ /** An error with an HTTP status. */
31
+ export class ExperimentError extends Error {
32
+ status;
33
+ /**
34
+ * @param status - HTTP status
35
+ * @param message - What went wrong
36
+ */
37
+ constructor(status, message) {
38
+ super(message);
39
+ this.status = status;
40
+ this.name = 'ExperimentError';
41
+ }
42
+ }
43
+ /**
44
+ * Error text.
45
+ *
46
+ * @param err - Anything thrown
47
+ * @returns Message
48
+ */
49
+ function errText(err) {
50
+ return err instanceof Error ? err.message : String(err);
51
+ }
52
+ /**
53
+ * Trimmed, length-capped string, or undefined.
54
+ *
55
+ * @param v - Value
56
+ * @returns Text
57
+ */
58
+ function text(v) {
59
+ if (typeof v !== 'string')
60
+ return undefined;
61
+ const t = v.trim();
62
+ return t ? t.slice(0, EXPERIMENT_CONSTANTS.MAX_TEXT_LENGTH) : undefined;
63
+ }
64
+ /**
65
+ * Validate a metric definition.
66
+ *
67
+ * @param raw - Body
68
+ * @returns Metric
69
+ * @throws ExperimentError(400) with what is wrong
70
+ */
71
+ export function validateMetric(raw) {
72
+ if (!raw || typeof raw !== 'object')
73
+ throw new ExperimentError(400, 'metric is required: {source, measure, config, …}');
74
+ const m = raw;
75
+ if (!isExperimentSource(m.source))
76
+ throw new ExperimentError(400, 'metric.source must be gsc or ga4');
77
+ const measures = EXPERIMENT_CONSTANTS.SOURCE_MEASURES[m.source] ?? [];
78
+ if (typeof m.measure !== 'string' || !measures.includes(m.measure)) {
79
+ throw new ExperimentError(400, `metric.measure for ${m.source} must be one of: ${measures.join(', ')}`);
80
+ }
81
+ const config = text(m.config);
82
+ if (!config || !path.isAbsolute(config))
83
+ throw new ExperimentError(400, 'metric.config must be the absolute path of the seo-ops site config');
84
+ const out = { source: m.source, measure: m.measure, config };
85
+ const page = text(m.page);
86
+ if (page)
87
+ out.page = page;
88
+ if (m.pageMatch !== undefined) {
89
+ if (m.pageMatch !== 'exact' && m.pageMatch !== 'contains')
90
+ throw new ExperimentError(400, 'metric.pageMatch must be exact or contains');
91
+ out.pageMatch = m.pageMatch;
92
+ }
93
+ const query = text(m.query);
94
+ if (query) {
95
+ if (m.source !== 'gsc')
96
+ throw new ExperimentError(400, 'metric.query is a Search Console filter (source gsc)');
97
+ out.query = query;
98
+ }
99
+ if (m.queryMatch !== undefined) {
100
+ if (m.queryMatch !== 'exact' && m.queryMatch !== 'contains')
101
+ throw new ExperimentError(400, 'metric.queryMatch must be exact or contains');
102
+ out.queryMatch = m.queryMatch;
103
+ }
104
+ const event = text(m.event);
105
+ if (out.measure === 'events' && !event)
106
+ throw new ExperimentError(400, 'metric.event is required for ga4 events (e.g. generate_lead)');
107
+ if (event)
108
+ out.event = event;
109
+ const channel = text(m.channel);
110
+ if (channel) {
111
+ if (m.source !== 'ga4')
112
+ throw new ExperimentError(400, 'metric.channel is a GA4 filter (source ga4)');
113
+ out.channel = channel;
114
+ }
115
+ const label = text(m.label);
116
+ if (label)
117
+ out.label = label;
118
+ return out;
119
+ }
120
+ /**
121
+ * Validate a ticket link.
122
+ *
123
+ * @param raw - Body
124
+ * @returns Link or undefined
125
+ * @throws ExperimentError(400)
126
+ */
127
+ export function validateTicketLink(raw) {
128
+ if (raw === undefined || raw === null || raw === '')
129
+ return undefined;
130
+ if (typeof raw !== 'object')
131
+ throw new ExperimentError(400, 'ticket must be {kind: "project", project, id} or {kind: "harness", id}');
132
+ const t = raw;
133
+ const id = text(t.id);
134
+ if (!id)
135
+ throw new ExperimentError(400, 'ticket.id is required');
136
+ if (t.kind === 'harness')
137
+ return { kind: 'harness', id };
138
+ if (t.kind === 'project') {
139
+ const project = text(t.project);
140
+ if (!project)
141
+ throw new ExperimentError(400, 'ticket.project is required for a project ticket');
142
+ return { kind: 'project', project, id };
143
+ }
144
+ throw new ExperimentError(400, 'ticket.kind must be project or harness');
145
+ }
146
+ /**
147
+ * How a ticket link reads.
148
+ *
149
+ * @param link - Link
150
+ * @returns e.g. "ce-site/T-12" or "TKT-40"
151
+ */
152
+ export function ticketLabel(link) {
153
+ return link.kind === 'project' ? `${link.project}/${link.id}` : link.id;
154
+ }
155
+ /**
156
+ * The verdict word for people.
157
+ *
158
+ * @param e - Experiment
159
+ * @returns worked / didn't work / inconclusive
160
+ */
161
+ function verdictWord(e) {
162
+ return e.verdict === 'worked' ? 'worked' : e.verdict === 'didnt' ? "didn't work" : 'inconclusive';
163
+ }
164
+ /**
165
+ * The metric as one line.
166
+ *
167
+ * @param m - Metric
168
+ * @returns e.g. "gsc clicks · page https://… · query visa"
169
+ */
170
+ export function metricLabel(m) {
171
+ const parts = [m.label ?? `${m.source} ${m.measure}`];
172
+ if (m.page)
173
+ parts.push(`page ${m.page}`);
174
+ if (m.query)
175
+ parts.push(`query ${m.query}`);
176
+ if (m.event)
177
+ parts.push(`event ${m.event}`);
178
+ return parts.join(' · ');
179
+ }
180
+ /**
181
+ * The result as markdown (wiki log entry and owner message share it).
182
+ *
183
+ * @param e - A measured experiment
184
+ * @returns Markdown
185
+ */
186
+ export function resultSummary(e) {
187
+ const lines = [
188
+ `**${e.id} ${verdictWord(e)}: ${e.title}**`,
189
+ `Hypothesis: ${e.hypothesis}`,
190
+ `Metric: ${metricLabel(e.metric)}`,
191
+ `Result: ${e.verdictReason ?? 'n/a'}`,
192
+ ];
193
+ if (e.expected?.to !== undefined && e.result) {
194
+ const reached = e.direction === 'increase' ? (e.result.total ?? -Infinity) >= e.expected.to : (e.result.total ?? Infinity) <= e.expected.to;
195
+ lines.push(`Target ${formatValue(e.expected.to, e.metric.measure)}: ${reached ? 'reached' : 'not reached'}`);
196
+ }
197
+ if (e.baseline && e.result)
198
+ lines.push(`Baseline ${e.baseline.start}..${e.baseline.end} · Result ${e.result.start}..${e.result.end}`);
199
+ if (e.ticket)
200
+ lines.push(`Ticket: ${ticketLabel(e.ticket)}`);
201
+ lines.push(`Trace: ${e.traceId}`);
202
+ return lines.join('\n');
203
+ }
204
+ /**
205
+ * Experiment cards: store, lifecycle and the measuring tick.
206
+ */
207
+ export class ExperimentService {
208
+ deps;
209
+ static instance = null;
210
+ logger;
211
+ now;
212
+ chain = Promise.resolve();
213
+ timer = null;
214
+ /** A tick is running (fetches can be slow; ticks never overlap) */
215
+ ticking = false;
216
+ /**
217
+ * @param deps - Dependencies
218
+ */
219
+ constructor(deps) {
220
+ this.deps = deps;
221
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('ExperimentService');
222
+ this.now = deps.now ?? (() => new Date());
223
+ }
224
+ /**
225
+ * The installed service (null before boot).
226
+ *
227
+ * @returns Service or null
228
+ */
229
+ static getInstance() {
230
+ return ExperimentService.instance;
231
+ }
232
+ /**
233
+ * Install the process service.
234
+ *
235
+ * @param service - Service, or null to clear
236
+ */
237
+ static setInstance(service) {
238
+ ExperimentService.instance = service;
239
+ }
240
+ // ---------------------------------------------------------------------------
241
+ // Lifecycle
242
+ // ---------------------------------------------------------------------------
243
+ /**
244
+ * Start the periodic tick (runs once right away).
245
+ *
246
+ * @param intervalMs - Interval (0 = no timer; tests call {@link tick})
247
+ */
248
+ start(intervalMs = EXPERIMENT_CONSTANTS.TICK_INTERVAL_MS) {
249
+ if (intervalMs > 0 && !this.timer) {
250
+ const run = () => {
251
+ void this.tick().catch((err) => this.logger.warn('Experiment tick failed (non-fatal)', { error: errText(err) }));
252
+ };
253
+ this.timer = setInterval(run, intervalMs);
254
+ this.timer.unref?.();
255
+ run();
256
+ }
257
+ }
258
+ /** Stop the tick. */
259
+ stop() {
260
+ if (this.timer)
261
+ clearInterval(this.timer);
262
+ this.timer = null;
263
+ }
264
+ // ---------------------------------------------------------------------------
265
+ // Store
266
+ // ---------------------------------------------------------------------------
267
+ /**
268
+ * Run one store operation at a time.
269
+ *
270
+ * @param fn - Operation
271
+ * @returns Its result
272
+ */
273
+ serial(fn) {
274
+ const next = this.chain.then(fn, fn);
275
+ this.chain = next.catch(() => undefined);
276
+ return next;
277
+ }
278
+ /**
279
+ * Read the store.
280
+ *
281
+ * @returns Store data (empty when missing)
282
+ */
283
+ async load() {
284
+ try {
285
+ const data = JSON.parse(await fs.readFile(this.deps.storeFile, 'utf-8'));
286
+ return { version: 1, nextNumber: data.nextNumber ?? 1, experiments: Array.isArray(data.experiments) ? data.experiments : [] };
287
+ }
288
+ catch (err) {
289
+ if (err.code !== 'ENOENT')
290
+ this.logger.warn('Experiment store unreadable; starting empty', { error: errText(err) });
291
+ return { version: 1, nextNumber: 1, experiments: [] };
292
+ }
293
+ }
294
+ /**
295
+ * Write the store atomically.
296
+ *
297
+ * @param data - Store data
298
+ */
299
+ async save(data) {
300
+ await fs.mkdir(path.dirname(this.deps.storeFile), { recursive: true });
301
+ const tmp = `${this.deps.storeFile}.${process.pid}.tmp`;
302
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2));
303
+ await fs.rename(tmp, this.deps.storeFile);
304
+ }
305
+ /**
306
+ * Load, change one experiment, save.
307
+ *
308
+ * @param id - Experiment id
309
+ * @param fn - Change (mutates the experiment)
310
+ * @returns The experiment after the change
311
+ * @throws ExperimentError(404) when not found
312
+ */
313
+ async mutate(id, fn) {
314
+ return this.serial(async () => {
315
+ const data = await this.load();
316
+ const e = data.experiments.find((x) => x.id.toLowerCase() === String(id).trim().toLowerCase());
317
+ if (!e)
318
+ throw new ExperimentError(404, `Experiment not found: ${id}`);
319
+ await fn(e);
320
+ e.updatedAt = this.now().toISOString();
321
+ await this.save(data);
322
+ return e;
323
+ });
324
+ }
325
+ /**
326
+ * Add a timeline entry.
327
+ *
328
+ * @param e - Experiment
329
+ * @param event - Event name
330
+ * @param detail - Optional detail
331
+ */
332
+ record(e, event, detail) {
333
+ e.timeline.push({ at: this.now().toISOString(), event, ...(detail ? { detail: detail.slice(0, 500) } : {}) });
334
+ if (e.timeline.length > EXPERIMENT_CONSTANTS.MAX_TIMELINE)
335
+ e.timeline.splice(0, e.timeline.length - EXPERIMENT_CONSTANTS.MAX_TIMELINE);
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // Reads
339
+ // ---------------------------------------------------------------------------
340
+ /**
341
+ * All experiments, newest first.
342
+ *
343
+ * @param filter - Optional status / ticket filter
344
+ * @returns Experiments
345
+ */
346
+ async list(filter = {}) {
347
+ const data = await this.serial(() => this.load());
348
+ return data.experiments
349
+ .filter((e) => !filter.status || e.status === filter.status)
350
+ .filter((e) => !filter.ticket || (e.ticket && (e.ticket.id === filter.ticket || ticketLabel(e.ticket) === filter.ticket)))
351
+ .reverse();
352
+ }
353
+ /**
354
+ * One experiment.
355
+ *
356
+ * @param id - EXP-n
357
+ * @returns Experiment or null
358
+ */
359
+ async get(id) {
360
+ const data = await this.serial(() => this.load());
361
+ return data.experiments.find((e) => e.id.toLowerCase() === String(id).trim().toLowerCase()) ?? null;
362
+ }
363
+ // ---------------------------------------------------------------------------
364
+ // Writes
365
+ // ---------------------------------------------------------------------------
366
+ /**
367
+ * Create an experiment card.
368
+ *
369
+ * @param input - Hypothesis, metric, window, ticket, confidence
370
+ * @param caller - Agent session, or `owner`
371
+ * @returns The new experiment (status planned)
372
+ * @throws ExperimentError(400) on invalid input
373
+ */
374
+ async create(input, caller) {
375
+ const C = EXPERIMENT_CONSTANTS;
376
+ const hypothesis = text(input.hypothesis);
377
+ if (!hypothesis)
378
+ throw new ExperimentError(400, 'hypothesis is required: "change X → metric Y from a to b"');
379
+ const metric = validateMetric(input.metric);
380
+ if (this.deps.fileExists && !(await this.deps.fileExists(metric.config))) {
381
+ throw new ExperimentError(400, `seo-ops config not found: ${metric.config}`);
382
+ }
383
+ let windowDays = C.DEFAULT_WINDOW_DAYS;
384
+ if (input.windowDays !== undefined && input.windowDays !== null && input.windowDays !== '') {
385
+ const n = Number(input.windowDays);
386
+ if (!Number.isInteger(n) || n < C.MIN_WINDOW_DAYS || n > C.MAX_WINDOW_DAYS) {
387
+ throw new ExperimentError(400, `windowDays must be a whole number from ${C.MIN_WINDOW_DAYS} to ${C.MAX_WINDOW_DAYS}`);
388
+ }
389
+ windowDays = n;
390
+ }
391
+ let direction = defaultDirection(metric.measure);
392
+ if (input.direction !== undefined && input.direction !== null && input.direction !== '') {
393
+ if (input.direction !== 'increase' && input.direction !== 'decrease')
394
+ throw new ExperimentError(400, 'direction must be increase or decrease');
395
+ direction = input.direction;
396
+ }
397
+ let expected;
398
+ if (input.expected && typeof input.expected === 'object') {
399
+ const raw = input.expected;
400
+ const from = raw.from === undefined || raw.from === null || raw.from === '' ? undefined : Number(raw.from);
401
+ const to = raw.to === undefined || raw.to === null || raw.to === '' ? undefined : Number(raw.to);
402
+ if ((from !== undefined && !Number.isFinite(from)) || (to !== undefined && !Number.isFinite(to))) {
403
+ throw new ExperimentError(400, 'expected.from / expected.to must be numbers');
404
+ }
405
+ if (from !== undefined || to !== undefined)
406
+ expected = { ...(from !== undefined ? { from } : {}), ...(to !== undefined ? { to } : {}) };
407
+ }
408
+ let confidence = C.DEFAULT_CONFIDENCE;
409
+ if (input.confidence !== undefined && input.confidence !== null && input.confidence !== '') {
410
+ const c = Number(input.confidence);
411
+ if (!Number.isFinite(c) || c < 0 || c > 1)
412
+ throw new ExperimentError(400, 'confidence must be between 0 and 1');
413
+ confidence = c;
414
+ }
415
+ const ticket = validateTicketLink(input.ticket);
416
+ const title = text(input.title) ?? hypothesis.slice(0, 80);
417
+ const created = await this.serial(async () => {
418
+ const data = await this.load();
419
+ const id = `${C.ID_PREFIX}${data.nextNumber}`;
420
+ data.nextNumber += 1;
421
+ const at = this.now().toISOString();
422
+ const e = {
423
+ id,
424
+ traceId: `exp:${id}`,
425
+ title,
426
+ hypothesis,
427
+ direction,
428
+ ...(expected ? { expected } : {}),
429
+ metric,
430
+ windowDays,
431
+ ...(ticket ? { ticket } : {}),
432
+ createdBy: caller,
433
+ confidence,
434
+ status: 'planned',
435
+ createdAt: at,
436
+ updatedAt: at,
437
+ timeline: [],
438
+ };
439
+ this.record(e, 'created', `by ${caller}${ticket ? ` on ${ticketLabel(ticket)}` : ''}`);
440
+ data.experiments.push(e);
441
+ await this.save(data);
442
+ return e;
443
+ });
444
+ if (ticket && this.deps.noteOnTicket) {
445
+ await this.deps.noteOnTicket(ticket, `Experiment ${created.id}: ${hypothesis} (metric: ${metricLabel(metric)}, ${windowDays}-day window; measured automatically after ship)`).catch((err) => this.logger.debug('Could not note the experiment on its ticket (non-fatal)', { id: created.id, error: errText(err) }));
446
+ }
447
+ this.logger.info('Experiment created', { id: created.id, caller, ticket: ticket ? ticketLabel(ticket) : undefined });
448
+ return created;
449
+ }
450
+ /**
451
+ * Mark an experiment shipped: fix its windows, record the prediction and
452
+ * capture the baseline.
453
+ *
454
+ * @param id - EXP-n
455
+ * @param caller - Who shipped it
456
+ * @param shippedAt - When it went live (default now; not in the future)
457
+ * @returns The experiment
458
+ * @throws ExperimentError(404 / 409 / 400)
459
+ */
460
+ async ship(id, caller, shippedAt) {
461
+ const at = shippedAt ?? this.now().toISOString();
462
+ const t = Date.parse(at);
463
+ if (Number.isNaN(t))
464
+ throw new ExperimentError(400, 'shippedAt must be an ISO time');
465
+ if (t > this.now().getTime())
466
+ throw new ExperimentError(400, 'shippedAt is in the future');
467
+ await this.mutate(id, (e) => {
468
+ if (e.status !== 'planned')
469
+ throw new ExperimentError(409, `${e.id} is ${e.status}; only a planned experiment can ship`);
470
+ const w = experimentWindows(new Date(t).toISOString(), e.windowDays, e.metric.source);
471
+ e.status = 'running';
472
+ e.shippedAt = new Date(t).toISOString();
473
+ e.dueAt = w.dueAt;
474
+ e.fetchAttempts = 0;
475
+ e.stuckReported = false;
476
+ this.record(e, 'shipped', `by ${caller}; baseline ${w.baseline.start}..${w.baseline.end}, result ${w.observation.start}..${w.observation.end}, due ${w.dueAt.slice(0, 10)}`);
477
+ });
478
+ await this.recordPrediction(id);
479
+ await this.captureBaseline(id);
480
+ return (await this.get(id));
481
+ }
482
+ /**
483
+ * Cancel an experiment that has not been measured.
484
+ *
485
+ * @param id - EXP-n
486
+ * @param caller - Who
487
+ * @param reason - Why
488
+ * @returns The experiment
489
+ * @throws ExperimentError(404 / 409)
490
+ */
491
+ async cancel(id, caller, reason) {
492
+ return this.mutate(id, (e) => {
493
+ if (e.status === 'done' || e.status === 'cancelled')
494
+ throw new ExperimentError(409, `${e.id} is already ${e.status}`);
495
+ e.status = 'cancelled';
496
+ this.record(e, 'cancelled', `by ${caller}${reason ? `: ${reason}` : ''}`);
497
+ });
498
+ }
499
+ /**
500
+ * Measure a due experiment now (instead of waiting for the tick).
501
+ *
502
+ * @param id - EXP-n
503
+ * @returns The experiment
504
+ * @throws ExperimentError(404 / 409) when it is not running or not yet due
505
+ */
506
+ async measureNow(id) {
507
+ const e = await this.get(id);
508
+ if (!e)
509
+ throw new ExperimentError(404, `Experiment not found: ${id}`);
510
+ if (e.status !== 'running')
511
+ throw new ExperimentError(409, `${e.id} is ${e.status}; only a running experiment can be measured`);
512
+ if (e.dueAt && Date.parse(e.dueAt) > this.now().getTime()) {
513
+ throw new ExperimentError(409, `${e.id} is not due until ${e.dueAt.slice(0, 10)} (the window's numbers have not settled)`);
514
+ }
515
+ if (!e.baseline)
516
+ await this.captureBaseline(id);
517
+ await this.measure(id);
518
+ await this.followUp(id);
519
+ return (await this.get(id));
520
+ }
521
+ // ---------------------------------------------------------------------------
522
+ // The tick
523
+ // ---------------------------------------------------------------------------
524
+ /**
525
+ * Move every experiment forward: ship planned ones whose ticket shipped,
526
+ * retry missing baselines, measure due ones, finish follow-ups.
527
+ *
528
+ * @returns Counts of what happened (zeros when a tick is already running)
529
+ */
530
+ async tick() {
531
+ if (this.ticking)
532
+ return { shipped: 0, measured: 0 };
533
+ this.ticking = true;
534
+ try {
535
+ return await this.runTick();
536
+ }
537
+ finally {
538
+ this.ticking = false;
539
+ }
540
+ }
541
+ /**
542
+ * One pass over all experiments (see {@link tick}).
543
+ *
544
+ * @returns Counts of what happened
545
+ */
546
+ async runTick() {
547
+ const all = await this.serial(() => this.load());
548
+ let shipped = 0;
549
+ let measured = 0;
550
+ const nowMs = this.now().getTime();
551
+ for (const e of all.experiments) {
552
+ try {
553
+ if (e.status === 'planned' && e.ticket && this.deps.ticketShippedAt) {
554
+ const at = await this.deps.ticketShippedAt(e.ticket);
555
+ if (at) {
556
+ const t = Math.min(Date.parse(at) || nowMs, nowMs);
557
+ await this.ship(e.id, `ticket ${ticketLabel(e.ticket)} done`, new Date(t).toISOString());
558
+ shipped += 1;
559
+ }
560
+ continue;
561
+ }
562
+ if (e.status === 'running') {
563
+ if (!e.baseline)
564
+ await this.captureBaseline(e.id);
565
+ if (e.dueAt && Date.parse(e.dueAt) <= nowMs) {
566
+ const after = await this.get(e.id);
567
+ if (after?.baseline) {
568
+ await this.measure(e.id);
569
+ if ((await this.get(e.id))?.status === 'done')
570
+ measured += 1;
571
+ }
572
+ }
573
+ }
574
+ if (e.status === 'done' || (await this.get(e.id))?.status === 'done')
575
+ await this.followUp(e.id);
576
+ }
577
+ catch (err) {
578
+ this.logger.warn('Experiment step failed (non-fatal)', { id: e.id, error: errText(err) });
579
+ }
580
+ }
581
+ return { shipped, measured };
582
+ }
583
+ // ---------------------------------------------------------------------------
584
+ // Steps
585
+ // ---------------------------------------------------------------------------
586
+ /**
587
+ * Record the creating agent's prediction that the hypothesis holds.
588
+ *
589
+ * @param id - EXP-n
590
+ */
591
+ async recordPrediction(id) {
592
+ const e = await this.get(id);
593
+ if (!e || !this.deps.predictions || e.predictionId || e.createdBy === 'owner' || !e.dueAt)
594
+ return;
595
+ try {
596
+ const p = await this.deps.predictions.make(e.createdBy, `${e.id}: ${e.hypothesis}`, e.confidence, e.dueAt.slice(0, 10));
597
+ await this.mutate(id, (x) => {
598
+ x.predictionId = p.id;
599
+ this.record(x, 'prediction_recorded', `${p.id} for ${x.createdBy} at ${Math.round(x.confidence * 100)}%`);
600
+ });
601
+ }
602
+ catch (err) {
603
+ this.logger.debug('Could not record the experiment prediction (non-fatal)', { id, error: errText(err) });
604
+ }
605
+ }
606
+ /**
607
+ * A fetch failed: count it, and tell the owner once when it keeps failing.
608
+ *
609
+ * @param id - EXP-n
610
+ * @param step - baseline / result
611
+ * @param err - The error
612
+ */
613
+ async fetchFailed(id, step, err) {
614
+ let tell = null;
615
+ await this.mutate(id, (e) => {
616
+ e.fetchAttempts = (e.fetchAttempts ?? 0) + 1;
617
+ e.lastError = errText(err).slice(0, 500);
618
+ this.record(e, 'fetch_failed', `${step}: ${e.lastError}`);
619
+ if (e.fetchAttempts >= EXPERIMENT_CONSTANTS.MAX_FETCH_ATTEMPTS && !e.stuckReported) {
620
+ e.stuckReported = true;
621
+ tell = { ...e };
622
+ }
623
+ });
624
+ if (tell && this.deps.notifyOwner) {
625
+ const e = tell;
626
+ await this.deps
627
+ .notifyOwner({
628
+ title: `Experiment ${e.id} can't fetch its ${step}`,
629
+ message: `${e.title}\nMetric: ${metricLabel(e.metric)}\nThe last ${EXPERIMENT_CONSTANTS.MAX_FETCH_ATTEMPTS} tries failed: ${e.lastError}\nIt keeps retrying; fix the seo-ops config or credentials (${e.metric.config}).`,
630
+ urgent: false,
631
+ })
632
+ .catch(() => false);
633
+ }
634
+ }
635
+ /**
636
+ * Fetch the baseline window.
637
+ *
638
+ * @param id - EXP-n
639
+ */
640
+ async captureBaseline(id) {
641
+ const e = await this.get(id);
642
+ if (!e || e.status !== 'running' || e.baseline || !e.shippedAt)
643
+ return;
644
+ const w = experimentWindows(e.shippedAt, e.windowDays, e.metric.source);
645
+ let m;
646
+ try {
647
+ m = await this.deps.fetchMetric(e.metric, w.baseline);
648
+ }
649
+ catch (err) {
650
+ await this.fetchFailed(id, 'baseline', err);
651
+ return;
652
+ }
653
+ await this.mutate(id, (x) => {
654
+ x.baseline = m;
655
+ x.fetchAttempts = 0;
656
+ x.stuckReported = false;
657
+ delete x.lastError;
658
+ this.record(x, 'baseline_captured', `${formatValue(m.total, x.metric.measure)} over ${m.start}..${m.end} (volume ${m.volume})`);
659
+ });
660
+ }
661
+ /**
662
+ * Fetch the observation window and label the result.
663
+ *
664
+ * @param id - EXP-n
665
+ */
666
+ async measure(id) {
667
+ const e = await this.get(id);
668
+ if (!e || e.status !== 'running' || !e.baseline || !e.shippedAt)
669
+ return;
670
+ const w = experimentWindows(e.shippedAt, e.windowDays, e.metric.source);
671
+ let m;
672
+ try {
673
+ m = await this.deps.fetchMetric(e.metric, w.observation);
674
+ }
675
+ catch (err) {
676
+ await this.fetchFailed(id, 'result', err);
677
+ return;
678
+ }
679
+ await this.mutate(id, (x) => {
680
+ if (!x.baseline)
681
+ return;
682
+ const v = decideVerdict(x.metric.measure, x.direction, x.baseline, m);
683
+ x.result = m;
684
+ x.verdict = v.verdict;
685
+ x.verdictReason = v.reason;
686
+ x.status = 'done';
687
+ x.fetchAttempts = 0;
688
+ delete x.lastError;
689
+ this.record(x, 'measured', `${v.verdict}: ${v.reason}`);
690
+ });
691
+ this.logger.info('Experiment measured', { id });
692
+ }
693
+ /**
694
+ * After a result: resolve the prediction, write the wiki log, tell the
695
+ * owner. Each step is retried by later ticks until it lands.
696
+ *
697
+ * @param id - EXP-n
698
+ */
699
+ async followUp(id) {
700
+ const e = await this.get(id);
701
+ if (!e || e.status !== 'done' || !e.verdict)
702
+ return;
703
+ const summary = resultSummary(e);
704
+ if (e.predictionId && this.deps.predictions && !e.timeline.some((t) => t.event === 'prediction_resolved') && e.verdict !== 'inconclusive') {
705
+ try {
706
+ await this.deps.predictions.resolve(e.createdBy, e.predictionId, `${verdictWord(e)}: ${e.verdictReason ?? ''}`, e.verdict === 'worked');
707
+ await this.mutate(id, (x) => this.record(x, 'prediction_resolved', `${x.predictionId}: ${x.verdict === 'worked' ? 'accurate' : 'inaccurate'}`));
708
+ }
709
+ catch (err) {
710
+ this.logger.debug('Could not resolve the experiment prediction (non-fatal)', { id, error: errText(err) });
711
+ }
712
+ }
713
+ if (!e.loggedAt && this.deps.writeLog) {
714
+ const ok = await this.deps.writeLog(e, summary).catch((err) => {
715
+ this.logger.debug('Wiki experiment log write failed (non-fatal)', { id, error: errText(err) });
716
+ return false;
717
+ });
718
+ if (ok)
719
+ await this.mutate(id, (x) => {
720
+ x.loggedAt = this.now().toISOString();
721
+ this.record(x, 'logged', EXPERIMENT_CONSTANTS.WIKI_LOG_PATH);
722
+ });
723
+ }
724
+ if (!e.reportedAt && this.deps.notifyOwner) {
725
+ const sent = await this.deps
726
+ .notifyOwner({ title: `Experiment ${e.id} ${verdictWord(e)}`, message: summary, urgent: false })
727
+ .catch(() => false);
728
+ if (sent)
729
+ await this.mutate(id, (x) => {
730
+ x.reportedAt = this.now().toISOString();
731
+ this.record(x, 'reported', 'owner');
732
+ });
733
+ }
734
+ if (e.ticket && this.deps.noteOnTicket && !e.timeline.some((t) => t.event === 'ticket_noted')) {
735
+ try {
736
+ await this.deps.noteOnTicket(e.ticket, `Experiment ${e.id} ${verdictWord(e)}: ${e.verdictReason ?? ''}`);
737
+ await this.mutate(id, (x) => this.record(x, 'ticket_noted', ticketLabel(e.ticket)));
738
+ }
739
+ catch (err) {
740
+ this.logger.debug('Could not note the result on the ticket (non-fatal)', { id, error: errText(err) });
741
+ }
742
+ }
743
+ }
744
+ }
745
+ /**
746
+ * Is this a status filter value?
747
+ *
748
+ * @param v - Query value
749
+ * @returns The status or undefined
750
+ */
751
+ export function statusFilter(v) {
752
+ return isExperimentStatus(v) ? v : undefined;
753
+ }
754
+ //# sourceMappingURL=experiment.service.js.map