querysub 0.490.0 → 0.492.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,8 @@
28
28
  "Bash(yarn ssh-a-claude *)",
29
29
  "WebSearch",
30
30
  "WebFetch(domain:blog.cloudflare.com)",
31
- "WebFetch(domain:developers.cloudflare.com)"
31
+ "WebFetch(domain:developers.cloudflare.com)",
32
+ "PowerShell(yarn type *)"
32
33
  ]
33
34
  }
34
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.490.0",
3
+ "version": "0.492.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -1,5 +1,6 @@
1
1
  import acme from "acme-client";
2
- import { generateRSAKeyPair, parseCert, privateKeyToPem } from "sliftutils/misc/https/certs";
2
+ import crypto from "crypto";
3
+ import { parseCert } from "sliftutils/misc/https/certs";
3
4
  import { cache, lazy } from "socket-function/src/caching";
4
5
  import { hasDNSWritePermissions, setRecord } from "../-b-authorities/dnsAuthority";
5
6
  import { magenta } from "socket-function/src/formatting/logColors";
@@ -13,6 +14,8 @@ import { timeInMinute } from "socket-function/src/misc";
13
14
  const archives = lazy(() => getArchives(`https_certs_3/`));
14
15
  // Expire EXPIRATION_THRESHOLD% of the way through the certificate's lifetime
15
16
  const EXPIRATION_THRESHOLD = 0.4;
17
+ // Let's Encrypt only accepts RSA or ECDSA account keys (not Ed25519, which is all sliftutils/certs generates).
18
+ const ACCOUNT_KEY_MODULUS_BITS = 2048;
16
19
 
17
20
  export const getHTTPSKeyCert = cache(async (domain: string): Promise<{ key: string; cert: string }> => {
18
21
  if (!await hasDNSWritePermissions()) {
@@ -112,13 +115,20 @@ let getAccountKey: () => string = lazy(() => {
112
115
  let keyCache = accountKeyCache.value;
113
116
  if (!keyCache) {
114
117
  console.log(`Generating new letsencrypt account key`);
115
- const keyPair = generateRSAKeyPair();
116
- keyCache = { key: privateKeyToPem(keyPair.privateKey) };
118
+ keyCache = { key: generateRSAPrivateKeyPem() };
117
119
  accountKeyCache.value = keyCache;
118
120
  }
119
121
  return keyCache.key;
120
122
  });
121
123
 
124
+ function generateRSAPrivateKeyPem(): string {
125
+ return crypto.generateKeyPairSync("rsa", {
126
+ modulusLength: ACCOUNT_KEY_MODULUS_BITS,
127
+ publicKeyEncoding: { type: "spki", format: "pem" },
128
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
129
+ }).privateKey;
130
+ }
131
+
122
132
  async function generateCert(config: {
123
133
  accountKey: string;
124
134
  domain: string;
@@ -43,9 +43,31 @@ import { isClient } from "../config2";
43
43
 
44
44
  const DEFAULT_MAX_LOCKS = 1000;
45
45
 
46
- // After this time we allow proxies to be reordered, even if there's flags that tell them not to be.
46
+ // After this time we allow proxies to be reordered, even if there's flags that tell them not to be.
47
47
  const MAX_PROXY_REORDER_BLOCK_TIME = timeInSecond * 10;
48
48
 
49
+ // The proxy watcher reads all of its synced data through this indirection instead of importing
50
+ // authorityStorage directly, so the data source can be swapped out. This is what lets us replay
51
+ // captured function runs against an in-memory dataset in complete isolation (see functionReplay.ts).
52
+ export interface PathValueReadSource {
53
+ getValueAtOrBeforeTime(pathStr: string, time: Time | undefined): PathValue | undefined;
54
+ isSynced(pathStr: string): boolean;
55
+ isParentSynced(pathStr: string): boolean;
56
+ getPathsFromParent(pathStr: string): Set<string> | undefined;
57
+ temporaryOverride<T>(overrides: PathValue[] | undefined, code: () => T): T;
58
+ DEBUG_hasAnyValues(pathStr: string): boolean;
59
+ }
60
+ let currentReadSource: PathValueReadSource = authorityStorage;
61
+ export function runWithPathValueReadSource<T>(source: PathValueReadSource, code: () => T): T {
62
+ let prev = currentReadSource;
63
+ currentReadSource = source;
64
+ try {
65
+ return code();
66
+ } finally {
67
+ currentReadSource = prev;
68
+ }
69
+ }
70
+
49
71
  let nextSeqNum = 1;
50
72
  let nextOrderSeqNum = 1;
51
73
 
@@ -566,7 +588,7 @@ export class PathValueProxyWatcher {
566
588
  if (!watcher.permissionsChecker.checkPermissions(pathStr).allowed) {
567
589
  if (
568
590
  !watcher.hasAnyUnsyncedAccesses()
569
- && authorityStorage.DEBUG_hasAnyValues(pathStr)
591
+ && currentReadSource.DEBUG_hasAnyValues(pathStr)
570
592
  // HACK: Don't show warnings for some framework paths, because they are filling up the console logs
571
593
  // and don't really matter. We could just not request them, but at depth 3 is valid,
572
594
  // so we kind of have to request that. And at depth 2, it would require special case code,
@@ -589,14 +611,14 @@ export class PathValueProxyWatcher {
589
611
 
590
612
  const currentReadTime = watcher.options.forceReadLatest ? undefined : watcher.currentReadTime;
591
613
 
592
- let pathValue = authorityStorage.getValueAtOrBeforeTime(pathStr, currentReadTime);
614
+ let pathValue = currentReadSource.getValueAtOrBeforeTime(pathStr, currentReadTime);
593
615
  if (!watcher.options.noSyncing) {
594
616
  // NOTE: If we have any value, we are always synced (that's what synced means, as if we aren't syncing,
595
617
  // we delete any values, to prevent stale values from being used).
596
618
  if (!pathValue) {
597
619
  // NOTE: We might not have a value simply due to reading too far back in time,
598
620
  // so we have to call isSynced just to be sure it is actually unsynced
599
- if (!authorityStorage.isSynced(pathStr)) {
621
+ if (!currentReadSource.isSynced(pathStr)) {
600
622
  for (let checker of this.isUnsyncCheckers) {
601
623
  checker.value++;
602
624
  }
@@ -604,7 +626,7 @@ export class PathValueProxyWatcher {
604
626
  }
605
627
  }
606
628
  if (syncParentKeys) {
607
- if (!authorityStorage.isParentSynced(pathStr)) {
629
+ if (!currentReadSource.isParentSynced(pathStr)) {
608
630
  watcher.pendingUnsyncedParentAccesses.add(pathStr);
609
631
  }
610
632
  }
@@ -940,7 +962,7 @@ export class PathValueProxyWatcher {
940
962
  return getKeys(pathValue?.value) as string[];
941
963
  }
942
964
 
943
- let childPaths = authorityStorage.getPathsFromParent(pathStr);
965
+ let childPaths = currentReadSource.getPathsFromParent(pathStr);
944
966
 
945
967
  // We need to also get keys from pendingWrites
946
968
  {
@@ -996,7 +1018,7 @@ export class PathValueProxyWatcher {
996
1018
  }
997
1019
 
998
1020
  if (symbol === syncedSymbol) {
999
- return { value: authorityStorage.isSynced(pathStr) };
1021
+ return { value: currentReadSource.isSynced(pathStr) };
1000
1022
  }
1001
1023
 
1002
1024
  // Proxies should be considered atomic, at least for the purpose of other proxies!
@@ -1066,7 +1088,7 @@ export class PathValueProxyWatcher {
1066
1088
  let { watchFunction, ...mostOptions } = options;
1067
1089
  doProxyOptions(mostOptions, () => {
1068
1090
  try {
1069
- let result = authorityStorage.temporaryOverride(options.overrides, () =>
1091
+ let result = currentReadSource.temporaryOverride(options.overrides, () =>
1070
1092
  options.watchFunction()
1071
1093
  );
1072
1094
  // Clone, otherwise proxies get out of the watcher, which can result in accesses outside
@@ -1353,7 +1375,7 @@ export class PathValueProxyWatcher {
1353
1375
  }
1354
1376
  },
1355
1377
  code() {
1356
- return authorityStorage.temporaryOverride(options.overrides, () =>
1378
+ return currentReadSource.temporaryOverride(options.overrides, () =>
1357
1379
  runCodeWithDatabase(proxy, baseFunction)
1358
1380
  );
1359
1381
  },
@@ -1457,8 +1479,8 @@ export class PathValueProxyWatcher {
1457
1479
  setTimeout(() => {
1458
1480
  if (watcher.syncRunCount !== syncRunCount) return;
1459
1481
  if (watcher.disposed) return;
1460
- let remainingUnsynced = Array.from(watcher.lastUnsyncedAccesses).filter(x => !authorityStorage.isSynced(x));
1461
- let remainingUnsyncedParent = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !authorityStorage.isParentSynced(x));
1482
+ let remainingUnsynced = Array.from(watcher.lastUnsyncedAccesses).filter(x => !currentReadSource.isSynced(x));
1483
+ let remainingUnsyncedParent = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !currentReadSource.isParentSynced(x));
1462
1484
  console.warn(red(`Did not sync ${watcher.debugName} after 30 seconds. Either we had a lot synchronous block, or we will never received the values we are waiting for.`), { remainingUnsynced, remainingUnsyncedParent }, watcher.options.watchFunction);
1463
1485
  }, 30 * 1000);
1464
1486
  setTimeout(() => {
@@ -1472,8 +1494,8 @@ export class PathValueProxyWatcher {
1472
1494
  // we have had since this watcher last synced. If it is > 50% of the time...
1473
1495
  // then synchronous lag is the issue.
1474
1496
 
1475
- let reallyUnsyncedAccesses = Array.from(watcher.lastUnsyncedAccesses).filter(x => !authorityStorage.isSynced(x));
1476
- let reallyUnsyncedParentAccesses = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !authorityStorage.isParentSynced(x));
1497
+ let reallyUnsyncedAccesses = Array.from(watcher.lastUnsyncedAccesses).filter(x => !currentReadSource.isSynced(x));
1498
+ let reallyUnsyncedParentAccesses = Array.from(watcher.lastUnsyncedParentAccesses).filter(x => !currentReadSource.isParentSynced(x));
1477
1499
 
1478
1500
  if (reallyUnsyncedAccesses.length !== 0 || reallyUnsyncedParentAccesses.length !== 0) {
1479
1501
  let notWatchingUnsynced = reallyUnsyncedAccesses.filter(x => !remoteWatcher.debugIsWatchingPath(x));
@@ -1705,12 +1727,12 @@ export class PathValueProxyWatcher {
1705
1727
  if (watcher.options.commitAllRuns) return false;
1706
1728
  // NOTE: We COULD remove any synced values from lastUnsyncedAccesses, however... we will generally sync all values at once, so we don't really need to optimize the cascading case here. Also... deleting values requires cloning while we iterate, as well as mutating the set, which probably makes the non-cascading case slower.
1707
1729
  for (let path of watcher.lastUnsyncedAccesses) {
1708
- if (!authorityStorage.isSynced(path)) {
1730
+ if (!currentReadSource.isSynced(path)) {
1709
1731
  return true;
1710
1732
  }
1711
1733
  }
1712
1734
  for (let path of watcher.lastUnsyncedParentAccesses) {
1713
- if (!authorityStorage.isParentSynced(path)) {
1735
+ if (!currentReadSource.isParentSynced(path)) {
1714
1736
  return true;
1715
1737
  }
1716
1738
  }
@@ -1723,13 +1745,13 @@ export class PathValueProxyWatcher {
1723
1745
  function logUnsynced() {
1724
1746
  let anyLogged = false;
1725
1747
  for (let path of watcher.lastUnsyncedAccesses) {
1726
- if (!authorityStorage.isSynced(path)) {
1748
+ if (!currentReadSource.isSynced(path)) {
1727
1749
  console.log(yellow(` Waiting for ${path}`));
1728
1750
  anyLogged = true;
1729
1751
  }
1730
1752
  }
1731
1753
  for (let path of watcher.lastUnsyncedParentAccesses) {
1732
- if (!authorityStorage.isParentSynced(path)) {
1754
+ if (!currentReadSource.isParentSynced(path)) {
1733
1755
  console.log(yellow(` Waiting for parent ${path}`));
1734
1756
  anyLogged = true;
1735
1757
  }
@@ -1997,7 +2019,7 @@ export class PathValueProxyWatcher {
1997
2019
  public async commitFunction<Result = void>(
1998
2020
  options: Omit<WatcherOptions<Result>, "onResultUpdated" | "onWriteCommitted">,
1999
2021
  config?: {
2000
- onWritesCommitted?: (writes: PathValue[]) => void;
2022
+ onWritesCommitted?: (writes: PathValue[], watcher: SyncWatcher) => void;
2001
2023
  }
2002
2024
  ): Promise<Result> {
2003
2025
  options = {
@@ -2029,7 +2051,7 @@ export class PathValueProxyWatcher {
2029
2051
  onResult(result.result);
2030
2052
  }
2031
2053
  if (writes && config?.onWritesCommitted) {
2032
- config.onWritesCommitted(writes);
2054
+ config.onWritesCommitted(writes, watcher);
2033
2055
  }
2034
2056
  }
2035
2057
  });
@@ -2131,12 +2153,12 @@ export class PathValueProxyWatcher {
2131
2153
  public reuseLastWatches() {
2132
2154
  let watcher = this.getTriggeredWatcher();
2133
2155
  for (let path of watcher.lastUnsyncedAccesses) {
2134
- if (!authorityStorage.isSynced(path)) {
2156
+ if (!currentReadSource.isSynced(path)) {
2135
2157
  watcher.pendingUnsyncedAccesses.add(path);
2136
2158
  }
2137
2159
  }
2138
2160
  for (let path of watcher.lastUnsyncedParentAccesses) {
2139
- if (!authorityStorage.isParentSynced(path)) {
2161
+ if (!currentReadSource.isParentSynced(path)) {
2140
2162
  watcher.pendingUnsyncedParentAccesses.add(path);
2141
2163
  }
2142
2164
  }
@@ -24,6 +24,7 @@ import { getGitRefSync, getGitURLSync } from "../4-deploy/git";
24
24
  import type { DeployProgress } from "../4-deploy/deployFunctions";
25
25
  import { getRoutingOverride, getRoutingOverridePart } from "../0-path-value-core/PathRouterRouteOverride";
26
26
  import { PathRouter } from "../0-path-value-core/PathRouter";
27
+ import { FunctionCaptureController, isCapturing, recordCapturedCall } from "./functionCapture";
27
28
  setImmediate(() => import("../4-querysub/Querysub"));
28
29
 
29
30
  let functionCallOuterCount = 0;
@@ -229,6 +230,7 @@ export class PathFunctionRunner {
229
230
  filterSelector?: FilterSelector;
230
231
  }) {
231
232
  SocketFunction.expose(FunctionPreloadController);
233
+ SocketFunction.expose(FunctionCaptureController);
232
234
  debugFunctionRunnerShards.push({
233
235
  domainName: config.domainName,
234
236
  shardRange: config.shardRange,
@@ -777,8 +779,26 @@ export class PathFunctionRunner {
777
779
  }
778
780
  },
779
781
  }, {
780
- onWritesCommitted(writes) {
782
+ onWritesCommitted(writes, watcher) {
781
783
  finalWrites = writes;
784
+ // Capture only runs that actually authored the Result (not nooped/partial runs),
785
+ // so a replayed call maps to exactly one recorded output state.
786
+ if (isCapturing()) {
787
+ let resultsPathStr = getProxyPath(() => functionSchema()[callSpec.DomainName].PathFunctionRunner[callSpec.ModuleId].Results[callSpec.CallId]);
788
+ let resultWrite = writes.find(w => w.path === resultsPathStr);
789
+ if (resultWrite) {
790
+ recordCapturedCall({
791
+ callSpec,
792
+ functionSpec,
793
+ writes,
794
+ watcher,
795
+ resultsPathStr,
796
+ resultWriteTime: resultWrite.time,
797
+ timeTaken: Date.now() - startTime,
798
+ evalTime,
799
+ });
800
+ }
801
+ }
782
802
  },
783
803
  });
784
804
  } catch (e: any) {
@@ -0,0 +1,213 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { runInfinitePoll } from "socket-function/src/batching";
3
+ import { timeInSecond } from "socket-function/src/misc";
4
+ import { authorityStorage, compareTime, PathValue, Time } from "../0-path-value-core/pathValueCore";
5
+ import { pathValueSerializer } from "../-h-path-value-serialize/PathValueSerializer";
6
+ import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
7
+ import { getDomain } from "../config";
8
+ import { CaptureFileHeader, CaptureRecord, CAPTURE_FORMAT_VERSION, encodeCaptureFile, encodeCaptureRecord } from "./functionCaptureFormat";
9
+ import type { SyncWatcher } from "../2-proxy/PathValueProxyWatcher";
10
+ import type { CallSpec, FunctionSpec } from "./PathFunctionRunner";
11
+
12
+ // How long to wait after a run commits its Result before treating that run as final and flushing
13
+ // it into the capture buffer. Long enough to catch a fast rejection (which re-runs the call and
14
+ // supersedes this Result), but well under the Result path's event expiry, so the re-check below
15
+ // can still read the value to confirm it's still ours.
16
+ export const CAPTURE_FLUSH_DELAY = timeInSecond * 5;
17
+ const CAPTURE_FLUSH_POLL_INTERVAL = timeInSecond;
18
+
19
+ export interface CaptureStatus {
20
+ capturing: boolean;
21
+ // Records confirmed final + still awaiting the flush delay.
22
+ callCount: number;
23
+ finalizedCount: number;
24
+ pendingCount: number;
25
+ // Bytes of the finalized record frames (excludes header + pending).
26
+ byteCount: number;
27
+ startedAt: number;
28
+ }
29
+
30
+ interface PendingCapture {
31
+ record: CaptureRecord;
32
+ resultsPathStr: string;
33
+ resultWriteTime: Time;
34
+ }
35
+
36
+ let capturing = false;
37
+ let captureStartedAt = 0;
38
+ let finalizedFrames: Buffer[] = [];
39
+ let finalizedCount = 0;
40
+ let finalizedBytes = 0;
41
+ let pendingCaptures: PendingCapture[] = [];
42
+
43
+ export function isCapturing() {
44
+ return capturing;
45
+ }
46
+
47
+ function resetCaptureBuffer() {
48
+ finalizedFrames = [];
49
+ finalizedCount = 0;
50
+ finalizedBytes = 0;
51
+ pendingCaptures = [];
52
+ }
53
+
54
+ function readValue(pathValue: PathValue): unknown {
55
+ return pathValueSerializer.getPathValue(pathValue, "noMutate");
56
+ }
57
+
58
+ export function recordCapturedCall(config: {
59
+ callSpec: CallSpec;
60
+ functionSpec: FunctionSpec;
61
+ writes: PathValue[];
62
+ watcher: SyncWatcher;
63
+ resultsPathStr: string;
64
+ resultWriteTime: Time;
65
+ timeTaken: number;
66
+ evalTime: number;
67
+ }): void {
68
+ if (!capturing) return;
69
+
70
+ let { callSpec, functionSpec, writes, watcher } = config;
71
+
72
+ let reads: CaptureRecord["reads"] = [];
73
+ for (let values of watcher.pendingAccesses.values()) {
74
+ for (let { pathValue } of values.values()) {
75
+ reads.push({ path: pathValue.path, value: readValue(pathValue), time: pathValue.time });
76
+ }
77
+ }
78
+
79
+ let undefinedReads: CaptureRecord["undefinedReads"] = [];
80
+ for (let [readTime, paths] of watcher.pendingEpochAccesses) {
81
+ for (let path of paths) {
82
+ undefinedReads.push({ path, readTime });
83
+ }
84
+ }
85
+
86
+ let parentKeyReads = Array.from(watcher.lastWatches.parentPaths);
87
+
88
+ let writeRecords: CaptureRecord["writes"] = writes.map(w => ({
89
+ path: w.path,
90
+ value: readValue(w),
91
+ time: w.time,
92
+ isTransparent: w.isTransparent,
93
+ event: w.event,
94
+ }));
95
+
96
+ let record: CaptureRecord = {
97
+ callId: callSpec.CallId,
98
+ functionSpec,
99
+ argsEncoded: callSpec.argsEncoded,
100
+ runAtTime: callSpec.runAtTime,
101
+ callerMachineId: callSpec.callerMachineId,
102
+ callerIP: callSpec.callerIP,
103
+ reads,
104
+ undefinedReads,
105
+ parentKeyReads,
106
+ writes: writeRecords,
107
+ timeTaken: config.timeTaken,
108
+ evalTime: config.evalTime,
109
+ capturedAt: Date.now(),
110
+ };
111
+
112
+ pendingCaptures.push({
113
+ record,
114
+ resultsPathStr: config.resultsPathStr,
115
+ resultWriteTime: config.resultWriteTime,
116
+ });
117
+ }
118
+
119
+ // A record is only kept if the Result path still holds the exact write from this run. If it was
120
+ // rejected or clobbered by another runner, the current value's time won't match, and we drop it.
121
+ function isStillOurResult(pending: PendingCapture): boolean {
122
+ let current = authorityStorage.getValueAtOrBeforeTime(pending.resultsPathStr, undefined);
123
+ if (!current) return false;
124
+ return compareTime(current.time, pending.resultWriteTime) === 0;
125
+ }
126
+
127
+ function finalizePending(force: boolean) {
128
+ if (pendingCaptures.length === 0) return;
129
+ let now = Date.now();
130
+ let stillPending: PendingCapture[] = [];
131
+ for (let pending of pendingCaptures) {
132
+ if (!force && now - pending.record.capturedAt < CAPTURE_FLUSH_DELAY) {
133
+ stillPending.push(pending);
134
+ continue;
135
+ }
136
+ if (!isStillOurResult(pending)) continue;
137
+ let frame = encodeCaptureRecord(pending.record);
138
+ finalizedFrames.push(frame);
139
+ finalizedCount++;
140
+ finalizedBytes += frame.length;
141
+ }
142
+ pendingCaptures = stillPending;
143
+ }
144
+
145
+ let flushPollStarted = false;
146
+ function ensureFlushPoll() {
147
+ if (flushPollStarted) return;
148
+ flushPollStarted = true;
149
+ runInfinitePoll(CAPTURE_FLUSH_POLL_INTERVAL, () => finalizePending(false));
150
+ }
151
+
152
+ function getStatus(): CaptureStatus {
153
+ return {
154
+ capturing,
155
+ callCount: finalizedCount + pendingCaptures.length,
156
+ finalizedCount,
157
+ pendingCount: pendingCaptures.length,
158
+ byteCount: finalizedBytes,
159
+ startedAt: captureStartedAt,
160
+ };
161
+ }
162
+
163
+ class FunctionCaptureControllerBase {
164
+ public async startCapture(): Promise<CaptureStatus> {
165
+ resetCaptureBuffer();
166
+ captureStartedAt = Date.now();
167
+ capturing = true;
168
+ ensureFlushPoll();
169
+ return getStatus();
170
+ }
171
+
172
+ public async getStatus(): Promise<CaptureStatus> {
173
+ return getStatus();
174
+ }
175
+
176
+ // Stops capturing, force-finalizes anything still within the flush delay, and returns the
177
+ // encoded capture file. The buffer is retained until clearCapture so it can be re-downloaded.
178
+ public async stopCapture(): Promise<Buffer> {
179
+ capturing = false;
180
+ finalizePending(true);
181
+ let header: CaptureFileHeader = {
182
+ version: CAPTURE_FORMAT_VERSION,
183
+ createdAt: Date.now(),
184
+ domainName: getDomain(),
185
+ recordCount: finalizedCount,
186
+ };
187
+ return encodeCaptureFile(header, finalizedFrames);
188
+ }
189
+
190
+ public async clearCapture(): Promise<CaptureStatus> {
191
+ capturing = false;
192
+ resetCaptureBuffer();
193
+ captureStartedAt = 0;
194
+ return getStatus();
195
+ }
196
+ }
197
+
198
+ export const FunctionCaptureController = SocketFunction.register(
199
+ "FunctionCaptureController-2f1b8c4a-6e9d-4a71-9b2e-8c5f0d3a1e77",
200
+ new FunctionCaptureControllerBase(),
201
+ () => ({
202
+ startCapture: {},
203
+ getStatus: {},
204
+ stopCapture: {},
205
+ clearCapture: {},
206
+ }),
207
+ () => ({
208
+ hooks: [requiresNetworkTrustHook],
209
+ }),
210
+ {
211
+ noAutoExpose: true,
212
+ }
213
+ );
@@ -0,0 +1,103 @@
1
+ import { encodeCborx, decodeCborx } from "../misc/cloneHelpers";
2
+ import type { Time } from "../0-path-value-core/pathValueCore";
3
+ import type { FunctionSpec } from "./PathFunctionRunner";
4
+
5
+ // Custom binary container for captured function runs. Layout:
6
+ // [MAGIC (8 bytes)]
7
+ // [uint32 LE headerLength][header JSON (utf8)]
8
+ // repeated: [uint32 LE recordLength][record (CBOR)]
9
+ // The header is JSON (not CBOR) so future readers can pull version + metadata without
10
+ // understanding the record encoding, which lets us evolve the record format while
11
+ // staying backwards compatible.
12
+ export const CAPTURE_MAGIC = Buffer.from("QSFNCAP\0", "latin1");
13
+ export const CAPTURE_FORMAT_VERSION = 1;
14
+
15
+ export interface CaptureFileHeader {
16
+ version: number;
17
+ createdAt: number;
18
+ domainName?: string;
19
+ nodeId?: string;
20
+ entryPoint?: string;
21
+ recordCount: number;
22
+ // Free-form metadata, so we can add fields without a version bump.
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ // One captured, fully-resolved function run. Enough to replay it in isolation and compare
27
+ // the output state.
28
+ export interface CaptureRecord {
29
+ callId: string;
30
+ functionSpec: FunctionSpec;
31
+ argsEncoded: string;
32
+ // The explicit timestamp the run executed at. Drives all determinism on replay
33
+ // (Querysub.time()/Querysub.nextId() are derived from it).
34
+ runAtTime: Time;
35
+ callerMachineId: string;
36
+ callerIP: string;
37
+
38
+ // Values the proxy resolved for each read, keyed by the value's own write time so the
39
+ // read can be reconstructed via getValueAtOrBeforeTime on replay.
40
+ reads: { path: string; value: unknown; time: Time }[];
41
+ // Reads that resolved to no value (undefined) at the given read time.
42
+ undefinedReads: { path: string; readTime?: Time }[];
43
+ // Paths whose child keys were enumerated (getKeys / Object.keys), needed so lookup
44
+ // enumeration replays identically.
45
+ parentKeyReads: string[];
46
+
47
+ // Committed writes (the output state to compare against on replay).
48
+ writes: { path: string; value: unknown; time: Time; isTransparent?: boolean; event?: boolean }[];
49
+
50
+ timeTaken: number;
51
+ evalTime: number;
52
+ capturedAt: number;
53
+ }
54
+
55
+ function writeUInt32(value: number): Buffer {
56
+ let buffer = Buffer.alloc(4);
57
+ buffer.writeUInt32LE(value, 0);
58
+ return buffer;
59
+ }
60
+
61
+ export function encodeCaptureRecord(record: CaptureRecord): Buffer {
62
+ let body = encodeCborx(record);
63
+ return Buffer.concat([writeUInt32(body.length), body]);
64
+ }
65
+
66
+ export function encodeCaptureFile(header: CaptureFileHeader, recordFrames: Buffer[]): Buffer {
67
+ let headerBuffer = Buffer.from(JSON.stringify(header), "utf8");
68
+ return Buffer.concat([CAPTURE_MAGIC, writeUInt32(headerBuffer.length), headerBuffer, ...recordFrames]);
69
+ }
70
+
71
+ export function decodeCaptureFile(buffer: Buffer): { header: CaptureFileHeader; records: CaptureRecord[] } {
72
+ if (buffer.length < CAPTURE_MAGIC.length + 4) {
73
+ throw new Error(`Capture file too small to be valid (${buffer.length} bytes)`);
74
+ }
75
+ let magic = buffer.subarray(0, CAPTURE_MAGIC.length);
76
+ if (!magic.equals(CAPTURE_MAGIC)) {
77
+ throw new Error(`Capture file has invalid magic. Expected ${JSON.stringify(CAPTURE_MAGIC.toString("latin1"))}, was ${JSON.stringify(magic.toString("latin1"))}`);
78
+ }
79
+ let offset = CAPTURE_MAGIC.length;
80
+ let headerLength = buffer.readUInt32LE(offset);
81
+ offset += 4;
82
+ if (offset + headerLength > buffer.length) {
83
+ throw new Error(`Capture file header length (${headerLength}) exceeds file size (${buffer.length})`);
84
+ }
85
+ let header = JSON.parse(buffer.subarray(offset, offset + headerLength).toString("utf8")) as CaptureFileHeader;
86
+ offset += headerLength;
87
+
88
+ let records: CaptureRecord[] = [];
89
+ while (offset < buffer.length) {
90
+ if (offset + 4 > buffer.length) {
91
+ throw new Error(`Capture file truncated reading record length at offset ${offset}`);
92
+ }
93
+ let recordLength = buffer.readUInt32LE(offset);
94
+ offset += 4;
95
+ if (offset + recordLength > buffer.length) {
96
+ throw new Error(`Capture file truncated reading record body at offset ${offset} (length ${recordLength}, file size ${buffer.length})`);
97
+ }
98
+ let record = decodeCborx<CaptureRecord>(buffer.subarray(offset, offset + recordLength));
99
+ records.push(record);
100
+ offset += recordLength;
101
+ }
102
+ return { header, records };
103
+ }