@replayci/replay 0.1.1 → 0.1.3

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/dist/index.cjs CHANGED
@@ -50,6 +50,7 @@ var CaptureBuffer = class {
50
50
  apiKey;
51
51
  endpoint;
52
52
  diagnostics;
53
+ onStateChange;
53
54
  fetchImpl;
54
55
  now;
55
56
  queue = [];
@@ -59,6 +60,11 @@ var CaptureBuffer = class {
59
60
  circuitOpenUntil = 0;
60
61
  remoteDisabled = false;
61
62
  closed = false;
63
+ droppedOverflowTotal = 0;
64
+ lastFlushAttemptMs = 0;
65
+ lastFlushSuccessMs = 0;
66
+ lastFlushErrorMs = 0;
67
+ lastFlushErrorMsg = null;
62
68
  constructor(opts) {
63
69
  this.apiKey = opts.apiKey;
64
70
  this.endpoint = normalizeEndpoint(opts.endpoint);
@@ -69,6 +75,7 @@ var CaptureBuffer = class {
69
75
  MAX_SEND_TIMEOUT
70
76
  );
71
77
  this.diagnostics = opts.diagnostics;
78
+ this.onStateChange = opts.onStateChange;
72
79
  this.fetchImpl = opts.fetchImpl ?? fetch;
73
80
  this.now = opts.now ?? Date.now;
74
81
  this.scheduleNextDrain();
@@ -85,6 +92,21 @@ var CaptureBuffer = class {
85
92
  get isRemoteDisabled() {
86
93
  return this.remoteDisabled;
87
94
  }
95
+ get droppedOverflow() {
96
+ return this.droppedOverflowTotal;
97
+ }
98
+ get lastFlushAttemptAt() {
99
+ return this.lastFlushAttemptMs;
100
+ }
101
+ get lastFlushSuccessAt() {
102
+ return this.lastFlushSuccessMs;
103
+ }
104
+ get lastFlushErrorAt() {
105
+ return this.lastFlushErrorMs;
106
+ }
107
+ get lastFlushError() {
108
+ return this.lastFlushErrorMsg;
109
+ }
88
110
  push(item) {
89
111
  if (this.closed || this.remoteDisabled) {
90
112
  return;
@@ -94,10 +116,12 @@ var CaptureBuffer = class {
94
116
  }
95
117
  if (this.queue.length >= this.maxBuffer) {
96
118
  this.queue.shift();
119
+ this.droppedOverflowTotal += 1;
97
120
  emitDiagnostics(this.diagnostics, {
98
121
  type: "buffer_overflow",
99
122
  dropped: 1
100
123
  });
124
+ emitStateChange(this.onStateChange, { type: "buffer_overflow", dropped: 1 });
101
125
  }
102
126
  this.queue.push(item);
103
127
  }
@@ -155,11 +179,13 @@ var CaptureBuffer = class {
155
179
  if (batch.length === 0) {
156
180
  return;
157
181
  }
182
+ this.lastFlushAttemptMs = this.now();
183
+ emitStateChange(this.onStateChange, { type: "flush_attempt" });
158
184
  let payload = "";
159
185
  try {
160
186
  payload = JSON.stringify({ captures: batch });
161
187
  } catch {
162
- this.handleFailure();
188
+ this.handleFailure("JSON serialization failed");
163
189
  return;
164
190
  }
165
191
  const controller = new AbortController();
@@ -181,22 +207,35 @@ var CaptureBuffer = class {
181
207
  this.clearTimer();
182
208
  this.failureCount = 0;
183
209
  this.circuitOpenUntil = Number.MAX_SAFE_INTEGER;
210
+ emitDiagnostics(this.diagnostics, { type: "remote_disabled" });
211
+ emitStateChange(this.onStateChange, { type: "remote_disabled" });
184
212
  return;
185
213
  }
186
214
  if (!response.ok) {
187
- this.handleFailure();
215
+ this.handleFailure(`HTTP ${response.status}`);
188
216
  return;
189
217
  }
190
218
  this.failureCount = 0;
191
219
  this.circuitOpenUntil = 0;
192
- } catch {
193
- this.handleFailure();
220
+ this.lastFlushSuccessMs = this.now();
221
+ this.lastFlushErrorMsg = null;
222
+ emitStateChange(this.onStateChange, { type: "flush_success", batch_size: batch.length });
223
+ } catch (err) {
224
+ this.handleFailure(err instanceof Error ? err.message : String(err));
194
225
  } finally {
195
226
  clearTimeout(timeout);
196
227
  }
197
228
  }
198
- handleFailure() {
229
+ handleFailure(errorMsg) {
199
230
  this.failureCount += 1;
231
+ const errorStr = errorMsg ?? "unknown error";
232
+ this.lastFlushErrorMs = this.now();
233
+ this.lastFlushErrorMsg = errorStr;
234
+ emitDiagnostics(this.diagnostics, {
235
+ type: "flush_error",
236
+ error: errorStr
237
+ });
238
+ emitStateChange(this.onStateChange, { type: "flush_error", error: errorStr });
200
239
  if (this.failureCount >= CIRCUIT_BREAKER_FAILURE_LIMIT) {
201
240
  this.circuitOpenUntil = this.now() + CIRCUIT_BREAKER_MS;
202
241
  emitDiagnostics(this.diagnostics, {
@@ -204,6 +243,12 @@ var CaptureBuffer = class {
204
243
  failures: this.failureCount,
205
244
  backoffMs: CIRCUIT_BREAKER_MS
206
245
  });
246
+ emitStateChange(this.onStateChange, {
247
+ type: "circuit_open",
248
+ failures: this.failureCount,
249
+ backoffMs: CIRCUIT_BREAKER_MS,
250
+ openUntil: this.circuitOpenUntil
251
+ });
207
252
  try {
208
253
  console.warn(
209
254
  `[replayci] Capture buffer circuit breaker open after ${this.failureCount} consecutive failures. Captures will be dropped for ${CIRCUIT_BREAKER_MS / 6e4} minutes.`
@@ -283,6 +328,12 @@ function emitDiagnostics(diagnostics, event) {
283
328
  } catch {
284
329
  }
285
330
  }
331
+ function emitStateChange(listener, event) {
332
+ try {
333
+ listener?.(event);
334
+ } catch {
335
+ }
336
+ }
286
337
  function isRemoteDisable(response) {
287
338
  return response.headers.get("x-replayci-disable")?.toLowerCase() === "true";
288
339
  }
@@ -933,7 +984,8 @@ function parseNodeMajorVersion(nodeVersion) {
933
984
 
934
985
  // src/captureSchema.ts
935
986
  var CAPTURE_SCHEMA_VERSION_LEGACY = "2026-03-04";
936
- var CAPTURE_SCHEMA_VERSION_CURRENT = "2026-03-06";
987
+ var CAPTURE_SCHEMA_VERSION_V2 = "2026-03-06";
988
+ var CAPTURE_SCHEMA_VERSION_CURRENT = "2026-03-09";
937
989
  function isRecord(value) {
938
990
  return value !== null && typeof value === "object" && !Array.isArray(value);
939
991
  }
@@ -1097,9 +1149,19 @@ function validateUsage(value, path) {
1097
1149
  total_tokens: requireNonNegativeInt(usage.total_tokens, `${path}.total_tokens`)
1098
1150
  };
1099
1151
  }
1152
+ function optionalString(value, path) {
1153
+ if (value === void 0 || value === null) {
1154
+ return void 0;
1155
+ }
1156
+ if (typeof value !== "string") {
1157
+ throw new Error(`${path} must be a string when provided`);
1158
+ }
1159
+ return value;
1160
+ }
1100
1161
  function parseCommonCapture(capture, index, modelId, schemaVersion) {
1101
1162
  const toolNames = requireStringArray(capture.tool_names, `captures[${index}].tool_names`);
1102
1163
  const primaryToolName = capture.primary_tool_name === void 0 ? toolNames[0] ?? null : nullableString(capture.primary_tool_name, `captures[${index}].primary_tool_name`);
1164
+ const sdkSessionId = optionalString(capture.sdk_session_id, `captures[${index}].sdk_session_id`);
1103
1165
  return {
1104
1166
  schema_version: schemaVersion,
1105
1167
  agent: requireString(capture.agent, `captures[${index}].agent`),
@@ -1112,7 +1174,8 @@ function parseCommonCapture(capture, index, modelId, schemaVersion) {
1112
1174
  response: validateResponse(capture.response, `captures[${index}].response`),
1113
1175
  ...capture.validation !== void 0 ? { validation: validateValidation(capture.validation, `captures[${index}].validation`) } : {},
1114
1176
  ...capture.usage !== void 0 ? { usage: validateUsage(capture.usage, `captures[${index}].usage`) } : {},
1115
- latency_ms: requireNonNegativeInt(capture.latency_ms, `captures[${index}].latency_ms`)
1177
+ latency_ms: requireNonNegativeInt(capture.latency_ms, `captures[${index}].latency_ms`),
1178
+ ...sdkSessionId !== void 0 ? { sdk_session_id: sdkSessionId } : {}
1116
1179
  };
1117
1180
  }
1118
1181
  function parseLegacyCapturedCall(capture, index) {
@@ -1123,6 +1186,14 @@ function parseLegacyCapturedCall(capture, index) {
1123
1186
  CAPTURE_SCHEMA_VERSION_LEGACY
1124
1187
  );
1125
1188
  }
1189
+ function parseV2CapturedCall(capture, index) {
1190
+ return parseCommonCapture(
1191
+ capture,
1192
+ index,
1193
+ requireString(capture.model_id, `captures[${index}].model_id`),
1194
+ CAPTURE_SCHEMA_VERSION_V2
1195
+ );
1196
+ }
1126
1197
  function parseCurrentCapturedCall(capture, index) {
1127
1198
  return parseCommonCapture(
1128
1199
  capture,
@@ -1133,25 +1204,254 @@ function parseCurrentCapturedCall(capture, index) {
1133
1204
  }
1134
1205
  var CAPTURE_SCHEMA_PARSERS = {
1135
1206
  [CAPTURE_SCHEMA_VERSION_LEGACY]: parseLegacyCapturedCall,
1207
+ [CAPTURE_SCHEMA_VERSION_V2]: parseV2CapturedCall,
1136
1208
  [CAPTURE_SCHEMA_VERSION_CURRENT]: parseCurrentCapturedCall
1137
1209
  };
1138
1210
 
1211
+ // src/healthStore.ts
1212
+ var import_node_crypto = require("crypto");
1213
+ var import_node_fs = require("fs");
1214
+ var import_promises = require("fs/promises");
1215
+ var import_node_path = require("path");
1216
+ var import_meta = {};
1217
+ var SESSIONS_DIR = "observe-sessions";
1218
+ var SCHEMA_VERSION = "1.0";
1219
+ var JANITOR_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
1220
+ function generateSessionId() {
1221
+ return `obs_${(0, import_node_crypto.randomBytes)(12).toString("hex")}`;
1222
+ }
1223
+ function resolveStateDir(opts) {
1224
+ let explicit;
1225
+ let workDir;
1226
+ if (typeof opts === "string") {
1227
+ workDir = opts;
1228
+ } else if (opts != null) {
1229
+ explicit = opts.stateDir;
1230
+ workDir = opts.cwd;
1231
+ }
1232
+ if (typeof explicit === "string" && explicit.length > 0) {
1233
+ return explicit;
1234
+ }
1235
+ const envValue = typeof process !== "undefined" ? process.env.REPLAYCI_STATE_DIR : void 0;
1236
+ if (typeof envValue === "string" && envValue.length > 0) {
1237
+ return envValue;
1238
+ }
1239
+ const base = workDir ?? process.cwd();
1240
+ return (0, import_node_path.join)(base, ".replayci", "runtime");
1241
+ }
1242
+ function resolveSessionsDir(stateDir) {
1243
+ return (0, import_node_path.join)(stateDir, SESSIONS_DIR);
1244
+ }
1245
+ function sessionFilePath(sessionsDir, sessionId) {
1246
+ return (0, import_node_path.join)(sessionsDir, `${sessionId}.json`);
1247
+ }
1248
+ function writeHealthSnapshot(sessionsDir, sessionId, snapshot) {
1249
+ ensureDir(sessionsDir);
1250
+ const destPath = sessionFilePath(sessionsDir, sessionId);
1251
+ const tmpPath = (0, import_node_path.join)(sessionsDir, `.tmp_${sessionId}_${(0, import_node_crypto.randomBytes)(4).toString("hex")}.json`);
1252
+ const content = JSON.stringify(snapshot, null, 2);
1253
+ const fd = (0, import_node_fs.openSync)(tmpPath, "w", 384);
1254
+ try {
1255
+ (0, import_node_fs.writeFileSync)(fd, content);
1256
+ (0, import_node_fs.fsyncSync)(fd);
1257
+ } finally {
1258
+ (0, import_node_fs.closeSync)(fd);
1259
+ }
1260
+ (0, import_node_fs.renameSync)(tmpPath, destPath);
1261
+ }
1262
+ function parseHealthFile(raw) {
1263
+ let parsed;
1264
+ try {
1265
+ parsed = JSON.parse(raw);
1266
+ } catch {
1267
+ return null;
1268
+ }
1269
+ if (!isValidHealthSnapshot(parsed)) {
1270
+ return null;
1271
+ }
1272
+ return parsed;
1273
+ }
1274
+ function isValidHealthSnapshot(value) {
1275
+ if (value === null || typeof value !== "object") {
1276
+ return false;
1277
+ }
1278
+ const obj = value;
1279
+ if (obj.schema_version !== SCHEMA_VERSION) {
1280
+ return false;
1281
+ }
1282
+ if (typeof obj.session_id !== "string" || obj.session_id.length === 0) {
1283
+ return false;
1284
+ }
1285
+ if (typeof obj.agent !== "string") {
1286
+ return false;
1287
+ }
1288
+ const validProviders = ["openai", "anthropic", null];
1289
+ if (!validProviders.includes(obj.provider)) {
1290
+ return false;
1291
+ }
1292
+ const validStates = ["active", "inactive", "stopped", "stale"];
1293
+ if (typeof obj.state !== "string" || !validStates.includes(obj.state)) {
1294
+ return false;
1295
+ }
1296
+ if (!isValidActivation(obj.activation)) {
1297
+ return false;
1298
+ }
1299
+ if (!isValidProcess(obj.process)) {
1300
+ return false;
1301
+ }
1302
+ if (!isValidRuntime(obj.runtime)) {
1303
+ return false;
1304
+ }
1305
+ if (typeof obj.last_heartbeat_at !== "string") {
1306
+ return false;
1307
+ }
1308
+ return true;
1309
+ }
1310
+ function isValidActivation(value) {
1311
+ if (value === null || typeof value !== "object") {
1312
+ return false;
1313
+ }
1314
+ const act = value;
1315
+ if (typeof act.active !== "boolean") {
1316
+ return false;
1317
+ }
1318
+ const validReasons = [
1319
+ "active",
1320
+ "disabled",
1321
+ "missing_api_key",
1322
+ "unsupported_client",
1323
+ "double_wrap",
1324
+ "patch_target_unwritable",
1325
+ "internal_error"
1326
+ ];
1327
+ if (typeof act.reason_code !== "string" || !validReasons.includes(act.reason_code)) {
1328
+ return false;
1329
+ }
1330
+ if (typeof act.activated_at !== "string") {
1331
+ return false;
1332
+ }
1333
+ return true;
1334
+ }
1335
+ function isValidProcess(value) {
1336
+ if (value === null || typeof value !== "object") {
1337
+ return false;
1338
+ }
1339
+ const proc = value;
1340
+ if (typeof proc.cwd !== "string") {
1341
+ return false;
1342
+ }
1343
+ if (typeof proc.node_version !== "string") {
1344
+ return false;
1345
+ }
1346
+ if (typeof proc.sdk_version !== "string") {
1347
+ return false;
1348
+ }
1349
+ return true;
1350
+ }
1351
+ function isValidRuntime(value) {
1352
+ if (value === null || typeof value !== "object") {
1353
+ return false;
1354
+ }
1355
+ const rt = value;
1356
+ if (typeof rt.captures_seen !== "number") {
1357
+ return false;
1358
+ }
1359
+ if (typeof rt.dropped_overflow !== "number") {
1360
+ return false;
1361
+ }
1362
+ if (typeof rt.queue_size !== "number") {
1363
+ return false;
1364
+ }
1365
+ if (typeof rt.consecutive_failures !== "number") {
1366
+ return false;
1367
+ }
1368
+ if (typeof rt.remote_disabled !== "boolean") {
1369
+ return false;
1370
+ }
1371
+ return true;
1372
+ }
1373
+ function runJanitor(sessionsDir, currentSessionId, now) {
1374
+ try {
1375
+ if (!(0, import_node_fs.existsSync)(sessionsDir)) {
1376
+ return;
1377
+ }
1378
+ const nowMs = now ?? Date.now();
1379
+ const entries = (0, import_node_fs.readdirSync)(sessionsDir);
1380
+ for (const entry of entries) {
1381
+ if (!entry.endsWith(".json") || entry.startsWith(".tmp_")) {
1382
+ continue;
1383
+ }
1384
+ const sessionId = entry.replace(/\.json$/, "");
1385
+ if (sessionId === currentSessionId) {
1386
+ continue;
1387
+ }
1388
+ const filePath = (0, import_node_path.join)(sessionsDir, entry);
1389
+ let snapshot = null;
1390
+ try {
1391
+ const raw = (0, import_node_fs.readFileSync)(filePath, "utf-8");
1392
+ snapshot = parseHealthFile(raw);
1393
+ } catch {
1394
+ continue;
1395
+ }
1396
+ if (!snapshot) {
1397
+ continue;
1398
+ }
1399
+ const isTerminal = snapshot.state === "stopped" || snapshot.state === "stale";
1400
+ if (!isTerminal) {
1401
+ continue;
1402
+ }
1403
+ const heartbeatAge = nowMs - new Date(snapshot.last_heartbeat_at).getTime();
1404
+ if (heartbeatAge < JANITOR_TTL_MS) {
1405
+ continue;
1406
+ }
1407
+ try {
1408
+ (0, import_node_fs.unlinkSync)(filePath);
1409
+ } catch {
1410
+ }
1411
+ }
1412
+ } catch {
1413
+ }
1414
+ }
1415
+ function getSdkVersion() {
1416
+ try {
1417
+ const url = new URL("../package.json", import_meta.url);
1418
+ const raw = (0, import_node_fs.readFileSync)(url, "utf-8");
1419
+ const pkg = JSON.parse(raw);
1420
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
1421
+ } catch {
1422
+ return "0.0.0";
1423
+ }
1424
+ }
1425
+ function ensureDir(dir) {
1426
+ try {
1427
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
1428
+ } catch (err) {
1429
+ if (err.code !== "EEXIST") {
1430
+ throw err;
1431
+ }
1432
+ }
1433
+ }
1434
+
1139
1435
  // src/observe.ts
1140
1436
  var REPLAY_WRAPPED = /* @__PURE__ */ Symbol.for("replayci.wrapped");
1141
1437
  var DEFAULT_AGENT = "default";
1438
+ var IDLE_HEARTBEAT_MS = 3e4;
1142
1439
  function observe(client, opts = {}) {
1143
1440
  assertSupportedNodeRuntime();
1441
+ const sessionId = generateSessionId();
1442
+ const agent = typeof opts.agent === "string" && opts.agent.length > 0 ? opts.agent : DEFAULT_AGENT;
1443
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1144
1444
  try {
1145
1445
  if (isDisabled(opts)) {
1146
- return createNoopHandle(client);
1446
+ return createInactiveHandle(client, sessionId, agent, "disabled", void 0, now, opts.diagnostics, opts.stateDir);
1147
1447
  }
1148
1448
  const apiKey = resolveApiKey(opts);
1149
1449
  if (!apiKey) {
1150
- return createNoopHandle(client);
1450
+ return createInactiveHandle(client, sessionId, agent, "missing_api_key", void 0, now, opts.diagnostics, opts.stateDir);
1151
1451
  }
1152
1452
  const provider = detectProviderSafely(client, opts.diagnostics);
1153
1453
  if (!provider) {
1154
- return createNoopHandle(client);
1454
+ return createInactiveHandle(client, sessionId, agent, "unsupported_client", "Could not detect provider.", now, opts.diagnostics, opts.stateDir);
1155
1455
  }
1156
1456
  const patchTarget = resolvePatchTarget(client, provider);
1157
1457
  if (!patchTarget) {
@@ -1160,14 +1460,14 @@ function observe(client, opts = {}) {
1160
1460
  mode: "observe",
1161
1461
  detail: `Unsupported ${provider} client shape.`
1162
1462
  });
1163
- return createNoopHandle(client);
1463
+ return createInactiveHandle(client, sessionId, agent, "unsupported_client", `Unsupported ${provider} client shape.`, now, opts.diagnostics, opts.stateDir);
1164
1464
  }
1165
1465
  if (isWrapped(client, patchTarget.target)) {
1166
1466
  emitDiagnostic(opts.diagnostics, {
1167
1467
  type: "double_wrap",
1168
1468
  mode: "observe"
1169
1469
  });
1170
- return createNoopHandle(client);
1470
+ return createInactiveHandle(client, sessionId, agent, "double_wrap", void 0, now, opts.diagnostics, opts.stateDir);
1171
1471
  }
1172
1472
  const patchabilityError = getPatchabilityError(patchTarget.target, patchTarget.methodName);
1173
1473
  if (patchabilityError) {
@@ -1176,19 +1476,171 @@ function observe(client, opts = {}) {
1176
1476
  mode: "observe",
1177
1477
  detail: patchabilityError
1178
1478
  });
1179
- return createNoopHandle(client);
1479
+ return createInactiveHandle(client, sessionId, agent, "patch_target_unwritable", patchabilityError, now, opts.diagnostics, opts.stateDir);
1180
1480
  }
1181
1481
  const captureLevel = normalizeCaptureLevel(opts.captureLevel);
1182
- const agent = typeof opts.agent === "string" && opts.agent.length > 0 ? opts.agent : DEFAULT_AGENT;
1482
+ const patchTargetName = `${provider}.${provider === "openai" ? "chat.completions.create" : "messages.create"}`;
1483
+ const runtimeState = {
1484
+ captures_seen: 0,
1485
+ dropped_overflow: 0,
1486
+ last_capture_at: null,
1487
+ last_flush_attempt_at: null,
1488
+ last_flush_success_at: null,
1489
+ last_flush_error_at: null,
1490
+ last_flush_error: null,
1491
+ queue_size: 0,
1492
+ consecutive_failures: 0,
1493
+ circuit_open_until: null,
1494
+ remote_disabled: false
1495
+ };
1496
+ let lastHealthStoreErrorAt = null;
1497
+ let lastHealthStoreError = null;
1498
+ const sessionsDir = resolveSessionsDir(
1499
+ resolveStateDir(opts.stateDir ? { stateDir: opts.stateDir } : void 0)
1500
+ );
1501
+ const buildCurrentSnapshot = () => ({
1502
+ schema_version: "1.0",
1503
+ session_id: sessionId,
1504
+ agent,
1505
+ provider,
1506
+ patch_target: patchTargetName,
1507
+ state: "active",
1508
+ activation: {
1509
+ active: true,
1510
+ reason_code: "active",
1511
+ activated_at: now
1512
+ },
1513
+ process: {
1514
+ pid: typeof process !== "undefined" ? process.pid : null,
1515
+ cwd: typeof process !== "undefined" ? process.cwd() : "",
1516
+ node_version: typeof process !== "undefined" ? process.versions?.node ?? "" : "",
1517
+ sdk_version: getSdkVersion()
1518
+ },
1519
+ runtime: { ...runtimeState },
1520
+ stopped_at: null,
1521
+ last_heartbeat_at: (/* @__PURE__ */ new Date()).toISOString(),
1522
+ last_health_store_error_at: lastHealthStoreErrorAt,
1523
+ last_health_store_error: lastHealthStoreError
1524
+ });
1525
+ const persistHealth = (snapshot) => {
1526
+ const s = snapshot ?? buildCurrentSnapshot();
1527
+ try {
1528
+ writeHealthSnapshot(sessionsDir, sessionId, s);
1529
+ if (lastHealthStoreErrorAt !== null) {
1530
+ lastHealthStoreErrorAt = null;
1531
+ lastHealthStoreError = null;
1532
+ }
1533
+ } catch (err) {
1534
+ const detail = err instanceof Error ? err.message : "Failed to write health snapshot";
1535
+ lastHealthStoreErrorAt = (/* @__PURE__ */ new Date()).toISOString();
1536
+ lastHealthStoreError = detail;
1537
+ emitDiagnostic(opts.diagnostics, {
1538
+ type: "health_store_error",
1539
+ detail
1540
+ });
1541
+ }
1542
+ };
1543
+ let lastEventWriteAt = Date.now();
1544
+ let heartbeatTimer;
1545
+ const persistHealthEvent = () => {
1546
+ lastEventWriteAt = Date.now();
1547
+ persistHealth();
1548
+ };
1549
+ const startHeartbeat = () => {
1550
+ heartbeatTimer = setInterval(() => {
1551
+ const sinceLast = Date.now() - lastEventWriteAt;
1552
+ if (sinceLast >= IDLE_HEARTBEAT_MS) {
1553
+ persistHealth();
1554
+ }
1555
+ }, IDLE_HEARTBEAT_MS);
1556
+ unrefTimerHandle(heartbeatTimer);
1557
+ };
1558
+ const stopHeartbeat = () => {
1559
+ if (heartbeatTimer !== void 0) {
1560
+ clearInterval(heartbeatTimer);
1561
+ heartbeatTimer = void 0;
1562
+ }
1563
+ };
1564
+ const onBufferStateChange = (event) => {
1565
+ if (restored) return;
1566
+ switch (event.type) {
1567
+ case "flush_attempt":
1568
+ runtimeState.last_flush_attempt_at = (/* @__PURE__ */ new Date()).toISOString();
1569
+ persistHealthEvent();
1570
+ break;
1571
+ case "flush_success":
1572
+ runtimeState.last_flush_success_at = (/* @__PURE__ */ new Date()).toISOString();
1573
+ runtimeState.last_flush_error = null;
1574
+ runtimeState.consecutive_failures = 0;
1575
+ runtimeState.circuit_open_until = null;
1576
+ runtimeState.queue_size = buffer.size;
1577
+ emitDiagnostic(opts.diagnostics, {
1578
+ type: "flush_succeeded",
1579
+ batch_size: event.batch_size
1580
+ });
1581
+ persistHealthEvent();
1582
+ break;
1583
+ case "flush_error":
1584
+ runtimeState.last_flush_error_at = (/* @__PURE__ */ new Date()).toISOString();
1585
+ runtimeState.last_flush_error = event.error;
1586
+ runtimeState.consecutive_failures = buffer.consecutiveFailures;
1587
+ runtimeState.queue_size = buffer.size;
1588
+ persistHealthEvent();
1589
+ break;
1590
+ case "buffer_overflow":
1591
+ runtimeState.dropped_overflow = buffer.droppedOverflow;
1592
+ runtimeState.queue_size = buffer.size;
1593
+ persistHealthEvent();
1594
+ break;
1595
+ case "circuit_open":
1596
+ runtimeState.circuit_open_until = new Date(event.openUntil).toISOString();
1597
+ runtimeState.consecutive_failures = event.failures;
1598
+ persistHealthEvent();
1599
+ break;
1600
+ case "remote_disabled":
1601
+ runtimeState.remote_disabled = true;
1602
+ runtimeState.queue_size = 0;
1603
+ persistHealthEvent();
1604
+ break;
1605
+ }
1606
+ };
1183
1607
  const buffer = new CaptureBuffer({
1184
1608
  apiKey,
1185
1609
  endpoint: opts.endpoint,
1186
1610
  maxBuffer: opts.maxBuffer,
1187
1611
  flushMs: opts.flushMs,
1188
1612
  timeoutMs: opts.timeoutMs,
1189
- diagnostics: opts.diagnostics
1613
+ diagnostics: opts.diagnostics,
1614
+ onStateChange: onBufferStateChange
1190
1615
  });
1191
1616
  registerBeforeExit(buffer);
1617
+ let exitSnapshotWritten = false;
1618
+ const beforeExitHandler = () => {
1619
+ if (exitSnapshotWritten || restored) return;
1620
+ exitSnapshotWritten = true;
1621
+ try {
1622
+ stopHeartbeat();
1623
+ const stoppedSnapshot = buildCurrentSnapshot();
1624
+ stoppedSnapshot.state = "stopped";
1625
+ stoppedSnapshot.stopped_at = (/* @__PURE__ */ new Date()).toISOString();
1626
+ stoppedSnapshot.last_heartbeat_at = (/* @__PURE__ */ new Date()).toISOString();
1627
+ persistHealth(stoppedSnapshot);
1628
+ } catch {
1629
+ }
1630
+ };
1631
+ if (typeof process !== "undefined" && typeof process.on === "function") {
1632
+ process.on("beforeExit", beforeExitHandler);
1633
+ }
1634
+ persistHealthEvent();
1635
+ safeRunJanitor(sessionsDir, sessionId);
1636
+ startHeartbeat();
1637
+ emitDiagnostic(opts.diagnostics, {
1638
+ type: "observe_activated",
1639
+ session_id: sessionId,
1640
+ provider,
1641
+ agent,
1642
+ patch_target: patchTargetName
1643
+ });
1192
1644
  const wrappedCreate = function observeWrappedCreate(...args) {
1193
1645
  const requestSnapshot = snapshotRequest(args[0], captureLevel);
1194
1646
  const startedAt = Date.now();
@@ -1201,7 +1653,11 @@ function observe(client, opts = {}) {
1201
1653
  provider,
1202
1654
  requestSnapshot,
1203
1655
  response,
1204
- startedAt
1656
+ startedAt,
1657
+ sessionId,
1658
+ runtimeState,
1659
+ persistHealthEvent,
1660
+ diagnostics: opts.diagnostics
1205
1661
  });
1206
1662
  return response;
1207
1663
  });
@@ -1211,11 +1667,18 @@ function observe(client, opts = {}) {
1211
1667
  let restored = false;
1212
1668
  return {
1213
1669
  client,
1670
+ flush() {
1671
+ return buffer.flush();
1672
+ },
1214
1673
  restore() {
1215
1674
  if (restored) {
1216
1675
  return;
1217
1676
  }
1218
1677
  restored = true;
1678
+ stopHeartbeat();
1679
+ if (typeof process !== "undefined" && typeof process.removeListener === "function") {
1680
+ process.removeListener("beforeExit", beforeExitHandler);
1681
+ }
1219
1682
  if (patchTarget.target[patchTarget.methodName] === wrappedCreate) {
1220
1683
  if (patchTarget.hadOwnMethod) {
1221
1684
  patchTarget.target[patchTarget.methodName] = patchTarget.originalCreate;
@@ -1224,11 +1687,124 @@ function observe(client, opts = {}) {
1224
1687
  }
1225
1688
  }
1226
1689
  clearWrapped(client, patchTarget.target);
1690
+ const stoppedSnapshot = buildCurrentSnapshot();
1691
+ stoppedSnapshot.state = "stopped";
1692
+ stoppedSnapshot.stopped_at = (/* @__PURE__ */ new Date()).toISOString();
1693
+ stoppedSnapshot.last_heartbeat_at = (/* @__PURE__ */ new Date()).toISOString();
1694
+ persistHealth(stoppedSnapshot);
1227
1695
  buffer.close();
1696
+ },
1697
+ getHealth() {
1698
+ if (restored) {
1699
+ const snapshot = buildCurrentSnapshot();
1700
+ snapshot.state = "stopped";
1701
+ snapshot.stopped_at = snapshot.stopped_at ?? (/* @__PURE__ */ new Date()).toISOString();
1702
+ return snapshot;
1703
+ }
1704
+ runtimeState.queue_size = buffer.size;
1705
+ return buildCurrentSnapshot();
1228
1706
  }
1229
1707
  };
1708
+ } catch (err) {
1709
+ const detail = err instanceof Error ? err.message : "Unknown internal error";
1710
+ return createInactiveHandle(client, sessionId, agent, "internal_error", detail, now, opts.diagnostics, opts.stateDir);
1711
+ }
1712
+ }
1713
+ function createInactiveHandle(client, sessionId, agent, reasonCode, detail, activatedAt, diagnostics, stateDir) {
1714
+ const healthSnapshot = buildHealthSnapshot({
1715
+ sessionId,
1716
+ agent,
1717
+ provider: null,
1718
+ patchTarget: null,
1719
+ state: "inactive",
1720
+ reasonCode,
1721
+ active: false,
1722
+ activatedAt,
1723
+ detail
1724
+ });
1725
+ const sessionsDir = resolveSessionsDir(
1726
+ resolveStateDir(stateDir ? { stateDir } : void 0)
1727
+ );
1728
+ safeWriteHealth(sessionsDir, sessionId, healthSnapshot, diagnostics);
1729
+ emitDiagnostic(diagnostics, {
1730
+ type: "observe_inactive",
1731
+ reason_code: reasonCode,
1732
+ ...detail ? { detail } : {}
1733
+ });
1734
+ return {
1735
+ client,
1736
+ flush() {
1737
+ return Promise.resolve();
1738
+ },
1739
+ restore() {
1740
+ },
1741
+ getHealth() {
1742
+ return { ...healthSnapshot };
1743
+ }
1744
+ };
1745
+ }
1746
+ function unrefTimerHandle(timer) {
1747
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
1748
+ try {
1749
+ timer.unref?.call(timer);
1750
+ } catch {
1751
+ }
1752
+ }
1753
+ }
1754
+ function buildHealthSnapshot(input) {
1755
+ const snapshot = {
1756
+ schema_version: "1.0",
1757
+ session_id: input.sessionId,
1758
+ agent: input.agent,
1759
+ provider: input.provider,
1760
+ patch_target: input.patchTarget,
1761
+ state: input.state,
1762
+ activation: {
1763
+ active: input.active,
1764
+ reason_code: input.reasonCode,
1765
+ activated_at: input.activatedAt,
1766
+ ...input.detail ? { detail: input.detail } : {}
1767
+ },
1768
+ process: {
1769
+ pid: typeof process !== "undefined" ? process.pid : null,
1770
+ cwd: typeof process !== "undefined" ? process.cwd() : "",
1771
+ node_version: typeof process !== "undefined" ? process.versions?.node ?? "" : "",
1772
+ sdk_version: getSdkVersion()
1773
+ },
1774
+ runtime: {
1775
+ captures_seen: 0,
1776
+ dropped_overflow: 0,
1777
+ last_capture_at: null,
1778
+ last_flush_attempt_at: null,
1779
+ last_flush_success_at: null,
1780
+ last_flush_error_at: null,
1781
+ last_flush_error: null,
1782
+ queue_size: 0,
1783
+ consecutive_failures: 0,
1784
+ circuit_open_until: null,
1785
+ remote_disabled: false
1786
+ },
1787
+ stopped_at: null,
1788
+ last_heartbeat_at: input.activatedAt,
1789
+ last_health_store_error_at: null,
1790
+ last_health_store_error: null
1791
+ };
1792
+ return snapshot;
1793
+ }
1794
+ function safeWriteHealth(sessionsDir, sessionId, snapshot, diagnostics) {
1795
+ try {
1796
+ writeHealthSnapshot(sessionsDir, sessionId, snapshot);
1797
+ } catch (err) {
1798
+ emitDiagnostic(diagnostics, {
1799
+ type: "health_store_error",
1800
+ detail: err instanceof Error ? err.message : "Failed to write health snapshot"
1801
+ });
1802
+ }
1803
+ }
1804
+ function safeRunJanitor(sessionsDir, currentSessionId) {
1805
+ try {
1806
+ runJanitor(sessionsDir, currentSessionId);
1230
1807
  } catch {
1231
- return createNoopHandle(client);
1232
1808
  }
1233
1809
  }
1234
1810
  function safelyCaptureResponse(input) {
@@ -1258,10 +1834,19 @@ function safelyCaptureResponse(input) {
1258
1834
  usage: extractUsage(input.response, input.provider)
1259
1835
  },
1260
1836
  endedAt: Date.now(),
1261
- startedAt: input.startedAt
1837
+ startedAt: input.startedAt,
1838
+ sessionId: input.sessionId
1262
1839
  });
1263
1840
  if (capture) {
1264
1841
  input.buffer.push(capture);
1842
+ input.runtimeState.captures_seen += 1;
1843
+ input.runtimeState.last_capture_at = (/* @__PURE__ */ new Date()).toISOString();
1844
+ input.runtimeState.queue_size = input.buffer.size;
1845
+ emitDiagnostic(input.diagnostics, {
1846
+ type: "capture_seen",
1847
+ session_id: input.sessionId
1848
+ });
1849
+ input.persistHealthEvent();
1265
1850
  }
1266
1851
  } catch {
1267
1852
  }
@@ -1278,10 +1863,19 @@ function safelyPushStreamCapture(input) {
1278
1863
  requestSnapshot: input.requestSnapshot,
1279
1864
  responseData: input.summary,
1280
1865
  startedAt: input.startedAt,
1281
- endedAt: Date.now()
1866
+ endedAt: Date.now(),
1867
+ sessionId: input.sessionId
1282
1868
  });
1283
1869
  if (capture) {
1284
1870
  input.buffer.push(capture);
1871
+ input.runtimeState.captures_seen += 1;
1872
+ input.runtimeState.last_capture_at = (/* @__PURE__ */ new Date()).toISOString();
1873
+ input.runtimeState.queue_size = input.buffer.size;
1874
+ emitDiagnostic(input.diagnostics, {
1875
+ type: "capture_seen",
1876
+ session_id: input.sessionId
1877
+ });
1878
+ input.persistHealthEvent();
1285
1879
  }
1286
1880
  } catch {
1287
1881
  }
@@ -1307,7 +1901,8 @@ function buildCapturedCall(input) {
1307
1901
  ...input.captureLevel === "full" && input.responseData.textBlocks && input.responseData.textBlocks.length > 0 ? { text_blocks: input.responseData.textBlocks } : {}
1308
1902
  },
1309
1903
  ...input.responseData.usage ? { usage: input.responseData.usage } : {},
1310
- latency_ms: Math.max(0, input.endedAt - input.startedAt)
1904
+ latency_ms: Math.max(0, input.endedAt - input.startedAt),
1905
+ ...input.sessionId ? { sdk_session_id: input.sessionId } : {}
1311
1906
  };
1312
1907
  } catch {
1313
1908
  return null;
@@ -1486,13 +2081,6 @@ function clearWrapped(client, target) {
1486
2081
  } catch {
1487
2082
  }
1488
2083
  }
1489
- function createNoopHandle(client) {
1490
- return {
1491
- client,
1492
- restore() {
1493
- }
1494
- };
1495
- }
1496
2084
  function resolveApiKey(opts) {
1497
2085
  return typeof opts.apiKey === "string" && opts.apiKey.length > 0 ? opts.apiKey : toNonEmptyString(process.env.REPLAYCI_API_KEY);
1498
2086
  }
@@ -1536,8 +2124,8 @@ var import_contracts_core3 = require("@replayci/contracts-core");
1536
2124
 
1537
2125
  // src/contracts.ts
1538
2126
  var import_contracts_core2 = require("@replayci/contracts-core");
1539
- var import_node_fs = require("fs");
1540
- var import_node_path = require("path");
2127
+ var import_node_fs2 = require("fs");
2128
+ var import_node_path2 = require("path");
1541
2129
  var CONTRACT_EXTENSIONS = /* @__PURE__ */ new Set([".yaml", ".yml"]);
1542
2130
  var MAX_REGEX_BYTES = 1024;
1543
2131
  var NESTED_QUANTIFIER_RE = /\((?:[^()\\]|\\.)*[+*{](?:[^()\\]|\\.)*\)(?:[+*]|\{\d+(?:,\d*)?\})/;
@@ -1586,39 +2174,51 @@ function loadContractsFromPaths(inputs) {
1586
2174
  const repoRoot = process.cwd();
1587
2175
  const contractFiles = inputs.flatMap((input) => {
1588
2176
  try {
1589
- return collectContractFiles((0, import_node_path.resolve)(repoRoot, input));
2177
+ return collectContractFiles((0, import_node_path2.resolve)(repoRoot, input));
1590
2178
  } catch (error) {
1591
2179
  throw new ReplayConfigurationError(
1592
2180
  `Failed to load contracts from "${input}": ${formatErrorMessage(error)}`
1593
2181
  );
1594
2182
  }
1595
2183
  });
1596
- return contractFiles.map((contractFile) => {
1597
- const contractPath = (0, import_node_path.relative)(repoRoot, contractFile);
1598
- const contract = (0, import_contracts_core2.loadContractSync)({
1599
- repoRoot,
1600
- contractPath
1601
- });
1602
- return normalizeInlineContract({
1603
- ...contract,
1604
- contract_file: contractFile
1605
- });
1606
- });
2184
+ const loaded = [];
2185
+ for (const contractFile of contractFiles) {
2186
+ const contractPath = (0, import_node_path2.relative)(repoRoot, contractFile);
2187
+ try {
2188
+ const contract = (0, import_contracts_core2.loadContractSync)({
2189
+ repoRoot,
2190
+ contractPath
2191
+ });
2192
+ loaded.push(normalizeInlineContract({
2193
+ ...contract,
2194
+ contract_file: contractFile
2195
+ }));
2196
+ } catch (error) {
2197
+ const msg = error instanceof Error ? error.message : String(error);
2198
+ if (msg.startsWith("ContractMissingTool:")) {
2199
+ continue;
2200
+ }
2201
+ throw new ReplayConfigurationError(
2202
+ `Failed to parse contract "${contractPath}": ${msg}`
2203
+ );
2204
+ }
2205
+ }
2206
+ return loaded;
1607
2207
  }
1608
2208
  function collectContractFiles(inputPath) {
1609
- const stat = (0, import_node_fs.statSync)(inputPath);
2209
+ const stat = (0, import_node_fs2.statSync)(inputPath);
1610
2210
  if (stat.isFile()) {
1611
2211
  return [inputPath];
1612
2212
  }
1613
2213
  if (!stat.isDirectory()) {
1614
2214
  return [];
1615
2215
  }
1616
- return (0, import_node_fs.readdirSync)(inputPath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
1617
- const fullPath = (0, import_node_path.join)(inputPath, entry.name);
2216
+ return (0, import_node_fs2.readdirSync)(inputPath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
2217
+ const fullPath = (0, import_node_path2.join)(inputPath, entry.name);
1618
2218
  if (entry.isDirectory()) {
1619
2219
  return collectContractFiles(fullPath);
1620
2220
  }
1621
- if (entry.isFile() && CONTRACT_EXTENSIONS.has((0, import_node_path.extname)(entry.name).toLowerCase())) {
2221
+ if (entry.isFile() && CONTRACT_EXTENSIONS.has((0, import_node_path2.extname)(entry.name).toLowerCase())) {
1622
2222
  return [fullPath];
1623
2223
  }
1624
2224
  return [];