@krishnadobhal/rewind-sdk-js 0.1.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.
- package/LICENSE +201 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +30 -0
- package/dist/emitter.d.ts +44 -0
- package/dist/emitter.js +99 -0
- package/dist/env.d.ts +12 -0
- package/dist/env.js +78 -0
- package/dist/http.d.ts +9 -0
- package/dist/http.js +27 -0
- package/dist/middleware.d.ts +26 -0
- package/dist/middleware.js +186 -0
- package/dist/recorder.d.ts +20 -0
- package/dist/recorder.js +120 -0
- package/dist/redact.d.ts +9 -0
- package/dist/redact.js +101 -0
- package/dist/store.d.ts +19 -0
- package/dist/store.js +57 -0
- package/dist/types/recorder.d.ts +23 -0
- package/dist/types/recorder.js +1 -0
- package/dist/types/redact.d.ts +9 -0
- package/dist/types/redact.js +1 -0
- package/dist/types/store.d.ts +7 -0
- package/dist/types/store.js +1 -0
- package/dist/ulid.d.ts +5 -0
- package/dist/ulid.js +18 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "{}"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Krishna Dobhal
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RedactConfig } from './redact.ts';
|
|
2
|
+
export type RewindConfig = {
|
|
3
|
+
/** Cassette store root. A directory; REWIND_SERVER switches the store instead. */
|
|
4
|
+
dir?: string;
|
|
5
|
+
redact?: RedactConfig;
|
|
6
|
+
};
|
|
7
|
+
/** Identity function giving config files type inference. */
|
|
8
|
+
export declare function defineConfig(config: RewindConfig): RewindConfig;
|
|
9
|
+
/** Store root used when nothing else says otherwise. */
|
|
10
|
+
export declare const DEFAULT_DIR = ".rewind";
|
|
11
|
+
/** Imports rewind.config.ts if the user wrote one. */
|
|
12
|
+
export declare function loadConfig(path?: string): Promise<RewindConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* One config surface: `rewind.config.ts`, overridden by the environment.
|
|
11
|
+
* Only what is read lives here — a knob nothing consumes is a promise the code breaks.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
/** Identity function giving config files type inference. */
|
|
17
|
+
export function defineConfig(config) {
|
|
18
|
+
return config; // no validation yet, TypeScript is the check
|
|
19
|
+
}
|
|
20
|
+
/** Store root used when nothing else says otherwise. */
|
|
21
|
+
export const DEFAULT_DIR = '.rewind';
|
|
22
|
+
/** Imports rewind.config.ts if the user wrote one. */
|
|
23
|
+
export async function loadConfig(path) {
|
|
24
|
+
const file = resolve(path ?? 'rewind.config.ts'); // absolute, import() needs it
|
|
25
|
+
if (!existsSync(file))
|
|
26
|
+
return {}; // unconfigured is valid, not an error
|
|
27
|
+
// Node strips types on import, so a .ts config needs no build step.
|
|
28
|
+
const module = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
|
|
29
|
+
return module.default ?? {}; // a config with no default export is empty
|
|
30
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Cassette, Run, Step } from '@krishnadobhal/rewind-core/schema';
|
|
2
|
+
import type { Store } from './store.ts';
|
|
3
|
+
/** One queued write, in the shape the wire carries it. */
|
|
4
|
+
export type BatchWrite = {
|
|
5
|
+
kind: 'run';
|
|
6
|
+
run: Run;
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'step';
|
|
9
|
+
step: Step;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'cassette';
|
|
12
|
+
cassette: Cassette;
|
|
13
|
+
};
|
|
14
|
+
/** A store that writes somewhere slow — Postgres, an ingest API, S3. */
|
|
15
|
+
export type AsyncStore = {
|
|
16
|
+
putRun(run: Run): Promise<void>;
|
|
17
|
+
appendStep(step: Step): Promise<void>;
|
|
18
|
+
putCassette(cassette: Cassette): Promise<void>;
|
|
19
|
+
putBatch?(writes: BatchWrite[]): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export type EmitterOptions = {
|
|
22
|
+
target: AsyncStore;
|
|
23
|
+
/** Writes held before the newest are dropped. */
|
|
24
|
+
maxQueue?: number;
|
|
25
|
+
/** Writes sent in one call, when the target can take a batch. */
|
|
26
|
+
maxBatch?: number;
|
|
27
|
+
/** How long a write waits for company before the batch goes. */
|
|
28
|
+
batchMs?: number;
|
|
29
|
+
log?: (message: string, error: unknown) => void;
|
|
30
|
+
};
|
|
31
|
+
export type FlushResult = {
|
|
32
|
+
written: number;
|
|
33
|
+
dropped: number;
|
|
34
|
+
pending: number;
|
|
35
|
+
};
|
|
36
|
+
/** A Store that queues writes and drains them to a slow target. */
|
|
37
|
+
export declare function bufferedStore(options: EmitterOptions): Store & {
|
|
38
|
+
flush: () => Promise<FlushResult>;
|
|
39
|
+
stats: {
|
|
40
|
+
written: number;
|
|
41
|
+
dropped: number;
|
|
42
|
+
queued: number;
|
|
43
|
+
};
|
|
44
|
+
};
|
package/dist/emitter.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Sends one write through the target's individual methods. */
|
|
2
|
+
function sendOne(target, write) {
|
|
3
|
+
if (write.kind === 'run')
|
|
4
|
+
return target.putRun(write.run);
|
|
5
|
+
if (write.kind === 'step')
|
|
6
|
+
return target.appendStep(write.step);
|
|
7
|
+
return target.putCassette(write.cassette);
|
|
8
|
+
}
|
|
9
|
+
/** A Store that queues writes and drains them to a slow target. */
|
|
10
|
+
export function bufferedStore(options) {
|
|
11
|
+
const { target, maxQueue = 1000, maxBatch = 100, batchMs = 250 } = options;
|
|
12
|
+
const log = options.log ?? ((m, e) => console.warn(`[rewind] ${m}`, e));
|
|
13
|
+
const queue = [];
|
|
14
|
+
const stats = { written: 0, dropped: 0, queued: 0 };
|
|
15
|
+
let draining = null;
|
|
16
|
+
let timer = null;
|
|
17
|
+
let lastLoggedAt = 0;
|
|
18
|
+
/** Counts losses and says so at most once a minute. */
|
|
19
|
+
const drop = (count, why, error) => {
|
|
20
|
+
stats.dropped += count;
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (now - lastLoggedAt < 60_000)
|
|
23
|
+
return;
|
|
24
|
+
lastLoggedAt = now;
|
|
25
|
+
log(`${why} (${stats.dropped} dropped)`, error);
|
|
26
|
+
};
|
|
27
|
+
/** Sends queued writes in order, batching when the target allows. */
|
|
28
|
+
const drain = async () => {
|
|
29
|
+
while (queue.length > 0) {
|
|
30
|
+
// Splice, not slice: a batch that fails is gone, not retried forever.
|
|
31
|
+
const batch = queue.splice(0, maxBatch);
|
|
32
|
+
stats.queued = queue.length;
|
|
33
|
+
if (target.putBatch) {
|
|
34
|
+
try {
|
|
35
|
+
await target.putBatch(batch);
|
|
36
|
+
stats.written += batch.length;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// One request carried all of them, so all of them were lost (I1).
|
|
40
|
+
drop(batch.length, `${batch.length} writes failed`, error);
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// No batch support: one at a time, in order, each guarded on its own. A single
|
|
45
|
+
// bad write must not take the rest of the batch with it — that is a property
|
|
46
|
+
// the local store had before batching existed.
|
|
47
|
+
for (const write of batch) {
|
|
48
|
+
try {
|
|
49
|
+
await sendOne(target, write);
|
|
50
|
+
stats.written++;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
drop(1, `${write.kind} write failed`, error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/** Starts a drain if one is not already running. */
|
|
59
|
+
const schedule = () => {
|
|
60
|
+
if (timer !== null || draining !== null)
|
|
61
|
+
return;
|
|
62
|
+
// Steps arrive in bursts; a short wait lets a whole node's worth queue up.
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
timer = null;
|
|
65
|
+
draining = drain().finally(() => {
|
|
66
|
+
draining = null;
|
|
67
|
+
if (queue.length > 0)
|
|
68
|
+
schedule(); // more arrived while we were away
|
|
69
|
+
});
|
|
70
|
+
}, batchMs);
|
|
71
|
+
timer.unref?.(); // never hold the process open on our account
|
|
72
|
+
};
|
|
73
|
+
/** Queues a write, or drops it if the queue is full. */
|
|
74
|
+
const push = (write) => {
|
|
75
|
+
if (queue.length >= maxQueue) {
|
|
76
|
+
// Drop the newest, not the oldest: the start of a run explains more than its end.
|
|
77
|
+
drop(1, `queue full at ${maxQueue}`, new Error('emitter backpressure'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
queue.push(write);
|
|
81
|
+
stats.queued = queue.length;
|
|
82
|
+
schedule();
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
stats,
|
|
86
|
+
putRun: (run) => push({ kind: 'run', run }),
|
|
87
|
+
appendStep: (step) => push({ kind: 'step', step }),
|
|
88
|
+
putCassette: (cassette) => push({ kind: 'cassette', cassette }),
|
|
89
|
+
async flush() {
|
|
90
|
+
if (timer !== null) {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
timer = null;
|
|
93
|
+
}
|
|
94
|
+
await draining; // let an in-flight drain finish before starting another
|
|
95
|
+
await drain();
|
|
96
|
+
return { written: stats.written, dropped: stats.dropped, pending: queue.length };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Run } from '@krishnadobhal/rewind-core/schema';
|
|
2
|
+
import { Recorder } from './recorder.ts';
|
|
3
|
+
export declare const ENV_ENABLED = "REWIND_ENABLED";
|
|
4
|
+
export declare const ENV_DIR = "REWIND_DIR";
|
|
5
|
+
export declare const ENV_CONFIG = "REWIND_CONFIG";
|
|
6
|
+
export declare const ENV_SERVER = "REWIND_SERVER";
|
|
7
|
+
export declare const ENV_TOKEN = "REWIND_TOKEN";
|
|
8
|
+
export declare const ENV_FILE = "REWIND_ENV_FILE";
|
|
9
|
+
export declare function loadEnvFile(path?: string): void;
|
|
10
|
+
/** Returns this process's recorder, opening it once. */
|
|
11
|
+
export declare function recorderFromEnv(run?: Partial<Run>): Promise<Recorder | null>;
|
|
12
|
+
export declare function resetRecorder(): void;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DEFAULT_DIR, loadConfig } from './config.js';
|
|
2
|
+
import { bufferedStore } from './emitter.js';
|
|
3
|
+
import { httpStore } from './http.js';
|
|
4
|
+
import { Recorder } from './recorder.js';
|
|
5
|
+
import { fileStore } from './store.js';
|
|
6
|
+
export const ENV_ENABLED = 'REWIND_ENABLED';
|
|
7
|
+
export const ENV_DIR = 'REWIND_DIR';
|
|
8
|
+
export const ENV_CONFIG = 'REWIND_CONFIG';
|
|
9
|
+
export const ENV_SERVER = 'REWIND_SERVER';
|
|
10
|
+
export const ENV_TOKEN = 'REWIND_TOKEN';
|
|
11
|
+
export const ENV_FILE = 'REWIND_ENV_FILE';
|
|
12
|
+
// Loads a .env into process.env, once per process.
|
|
13
|
+
let envFileLoaded = false;
|
|
14
|
+
export function loadEnvFile(path) {
|
|
15
|
+
if (envFileLoaded)
|
|
16
|
+
return;
|
|
17
|
+
envFileLoaded = true;
|
|
18
|
+
const file = path ?? process.env[ENV_FILE] ?? '.env';
|
|
19
|
+
if (file === '0')
|
|
20
|
+
return; // explicitly opted out
|
|
21
|
+
try {
|
|
22
|
+
process.loadEnvFile(file);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// No .env is the normal case for a library consumer, not an error.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Where the store lives for this process. */
|
|
29
|
+
async function storeRoot() {
|
|
30
|
+
const config = await loadConfig(process.env[ENV_CONFIG]); // may not exist
|
|
31
|
+
return process.env[ENV_DIR] ?? config.dir ?? DEFAULT_DIR; // flag beats config
|
|
32
|
+
}
|
|
33
|
+
let current = null;
|
|
34
|
+
/** Returns this process's recorder, opening it once. */
|
|
35
|
+
export async function recorderFromEnv(run) {
|
|
36
|
+
current ??= build(run); // first caller's metadata wins; later callers join the run
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
// Drops the memo so the next call opens a new run.
|
|
40
|
+
export function resetRecorder() {
|
|
41
|
+
current = null;
|
|
42
|
+
}
|
|
43
|
+
function chooseStore(root) {
|
|
44
|
+
const url = process.env[ENV_SERVER];
|
|
45
|
+
if (url === undefined || url === '')
|
|
46
|
+
return { store: fileStore(root) };
|
|
47
|
+
const buffered = bufferedStore({
|
|
48
|
+
target: httpStore({ url, token: process.env[ENV_TOKEN] }),
|
|
49
|
+
});
|
|
50
|
+
return { store: buffered, flush: buffered.flush };
|
|
51
|
+
}
|
|
52
|
+
/** Opens the run: reads config, picks a store, starts recording. */
|
|
53
|
+
async function build(run) {
|
|
54
|
+
loadEnvFile(); // before the check, so a .env can switch recording on
|
|
55
|
+
if (process.env[ENV_ENABLED] !== '1')
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
const config = await loadConfig(process.env[ENV_CONFIG]); // may not exist
|
|
59
|
+
const { store, flush } = chooseStore(await storeRoot());
|
|
60
|
+
if (flush)
|
|
61
|
+
flushOnExit(flush);
|
|
62
|
+
return new Recorder({ store, redact: config.redact, run });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
// Fails open (I1): a broken config must not take the agent down.
|
|
66
|
+
console.warn('[rewind] recorder disabled:', error);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function flushOnExit(flush) {
|
|
71
|
+
let done = false;
|
|
72
|
+
process.on('beforeExit', () => {
|
|
73
|
+
if (done)
|
|
74
|
+
return; // beforeExit can fire more than once
|
|
75
|
+
done = true;
|
|
76
|
+
void flush().catch(() => { }); // failing to flush must not become an exception
|
|
77
|
+
});
|
|
78
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AsyncStore } from './emitter.ts';
|
|
2
|
+
export type HttpStoreOptions = {
|
|
3
|
+
url: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
fetchImpl?: typeof fetch;
|
|
7
|
+
};
|
|
8
|
+
/** An AsyncStore that POSTs batches to an ingest server. */
|
|
9
|
+
export declare function httpStore(options: HttpStoreOptions): AsyncStore;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** An AsyncStore that POSTs batches to an ingest server. */
|
|
2
|
+
export function httpStore(options) {
|
|
3
|
+
const { url, token, timeoutMs = 10_000 } = options;
|
|
4
|
+
const send = options.fetchImpl ?? globalThis.fetch;
|
|
5
|
+
const endpoint = `${url.replace(/\/$/, '')}/v1/ingest`;
|
|
6
|
+
/** POSTs one batch, throwing on anything but success. */
|
|
7
|
+
const post = async (writes) => {
|
|
8
|
+
// A hung server would otherwise hold the queue open until the process ends.
|
|
9
|
+
const abort = AbortSignal.timeout(timeoutMs);
|
|
10
|
+
const response = await send(endpoint, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
13
|
+
body: JSON.stringify({ writes }),
|
|
14
|
+
signal: abort,
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok)
|
|
17
|
+
throw new Error(`ingest ${response.status} ${response.statusText}`);
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
// The batch path is the one that runs; these three exist so the store still
|
|
21
|
+
// satisfies AsyncStore for a caller that bypasses the emitter.
|
|
22
|
+
putRun: (run) => post([{ kind: 'run', run }]),
|
|
23
|
+
appendStep: (step) => post([{ kind: 'step', step }]),
|
|
24
|
+
putCassette: (cassette) => post([{ kind: 'cassette', cassette }]),
|
|
25
|
+
putBatch: post,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Recorder } from './recorder.ts';
|
|
2
|
+
/** Anything with an invoke method — a model, a tool, a runnable. */
|
|
3
|
+
type Invokable = {
|
|
4
|
+
invoke: (input: never, config?: never) => unknown;
|
|
5
|
+
};
|
|
6
|
+
export type WithRewindOptions<M, T> = {
|
|
7
|
+
model?: M;
|
|
8
|
+
tools?: T[];
|
|
9
|
+
/** Supplied by tests; otherwise from REWIND_ENABLED and friends. */
|
|
10
|
+
recorder?: Recorder | null;
|
|
11
|
+
/**
|
|
12
|
+
* Turns a recorded response back into the object the graph expects.
|
|
13
|
+
* Kept for the reader that hands cassettes back to a graph; recording ignores it.
|
|
14
|
+
*/
|
|
15
|
+
revive?: (response: unknown) => unknown;
|
|
16
|
+
};
|
|
17
|
+
export type Rewind<M, T> = {
|
|
18
|
+
model: M;
|
|
19
|
+
tools: T[];
|
|
20
|
+
recorder: Recorder | null;
|
|
21
|
+
/** Wraps one more model or tool — `model`/`tools` are sugar over this. */
|
|
22
|
+
wrap: <X extends Invokable>(target: X) => X;
|
|
23
|
+
};
|
|
24
|
+
/** Wraps a model and tools so every call is recorded. */
|
|
25
|
+
export declare function withRewind<M extends Invokable, T extends Invokable>(options?: WithRewindOptions<M, T>): Promise<Rewind<M, T>>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { recorderFromEnv } from './env.js';
|
|
2
|
+
/** Methods returning a new runnable that must stay wrapped. */
|
|
3
|
+
const REWRAPPING = new Set(['bindTools', 'bind', 'withConfig', 'withRetry']);
|
|
4
|
+
/** Wraps a model and tools so every call is recorded. */
|
|
5
|
+
export async function withRewind(options = {}) {
|
|
6
|
+
const recorder = options.recorder ?? (await recorderFromEnv());
|
|
7
|
+
const tools = options.tools ?? [];
|
|
8
|
+
const model = options.model;
|
|
9
|
+
const session = { recorder, revive: options.revive ?? ((r) => r) };
|
|
10
|
+
// Not recording: hand back the originals, so an un-instrumented run costs nothing.
|
|
11
|
+
if (recorder === null) {
|
|
12
|
+
return { model, tools, recorder: null, wrap: (target) => target };
|
|
13
|
+
}
|
|
14
|
+
const wrap = (target) => wrapAny(target, session);
|
|
15
|
+
return { model: model && wrap(model), tools: tools.map(wrap), recorder, wrap };
|
|
16
|
+
}
|
|
17
|
+
/** Dispatches to the model or tool wrapper by shape. */
|
|
18
|
+
function wrapAny(target, session) {
|
|
19
|
+
const t = unwrapModel(target); // a binding carries neither marker itself
|
|
20
|
+
// Models have _llmType/bindTools, tools have neither. Guessing wrong shows up as a
|
|
21
|
+
// `tool` step where a `model` step belongs.
|
|
22
|
+
const isModel = typeof t['_llmType'] === 'function' || typeof t['bindTools'] === 'function';
|
|
23
|
+
return isModel ? wrapModel(target, session) : wrapTool(target, session);
|
|
24
|
+
}
|
|
25
|
+
/** Proxies a chat model, turning each invoke into a step. */
|
|
26
|
+
function wrapModel(model, session, bound = []) {
|
|
27
|
+
return new Proxy(model, {
|
|
28
|
+
get(target, prop) {
|
|
29
|
+
const value = Reflect.get(target, prop, target); // target, not the proxy — getters recurse
|
|
30
|
+
if (prop === 'invoke') {
|
|
31
|
+
return (input, config) => observe(session, 'model', config, () => modelRequest(target, input, bound), () => value.call(target, input, config));
|
|
32
|
+
}
|
|
33
|
+
// bindTools returns a *new* runnable; unwrapped, the graph calls it directly.
|
|
34
|
+
if (REWRAPPING.has(prop) && typeof value === 'function') {
|
|
35
|
+
return (...args) => {
|
|
36
|
+
const next = value.apply(target, args);
|
|
37
|
+
const schemas = prop === 'bindTools' ? (providerTools(next) ?? args[0] ?? []) : bound;
|
|
38
|
+
return wrapModel(next, session, schemas); // schemas are part of the identity
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Proxies a tool, turning each invoke into a step. */
|
|
46
|
+
function wrapTool(tool, session) {
|
|
47
|
+
return new Proxy(tool, {
|
|
48
|
+
get(target, prop) {
|
|
49
|
+
const value = Reflect.get(target, prop, target);
|
|
50
|
+
if (prop !== 'invoke')
|
|
51
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
52
|
+
const name = String(Reflect.get(target, 'name', target) ?? 'tool');
|
|
53
|
+
return (input, config) => observe(session, 'tool', config, () => ({ kind: 'tool', tool_name: name, args: toolArgs(input) }), // name + args, nothing else
|
|
54
|
+
() => value.call(target, input, config));
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Answers a boundary crossing, and records what happened. */
|
|
59
|
+
async function observe(session, kind, config, request, call) {
|
|
60
|
+
const node = nodeName(config);
|
|
61
|
+
const { recorder } = session;
|
|
62
|
+
const started = Date.now(); // real clock; the virtual one arrives with B3
|
|
63
|
+
try {
|
|
64
|
+
const response = await call();
|
|
65
|
+
recorder?.record({
|
|
66
|
+
node,
|
|
67
|
+
kind,
|
|
68
|
+
request: request(),
|
|
69
|
+
response: plain(response),
|
|
70
|
+
latency_ms: Date.now() - started,
|
|
71
|
+
...usage(response),
|
|
72
|
+
});
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// A provider error is a recorded outcome, not a gap (B9).
|
|
77
|
+
recorder?.record({
|
|
78
|
+
node,
|
|
79
|
+
kind,
|
|
80
|
+
request: request(),
|
|
81
|
+
response: null,
|
|
82
|
+
latency_ms: Date.now() - started,
|
|
83
|
+
error: { message: String(error) },
|
|
84
|
+
});
|
|
85
|
+
throw error; // the graph's error handling is the graph's business
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Reads the LangGraph node name out of the config. */
|
|
89
|
+
function nodeName(config) {
|
|
90
|
+
const metadata = config?.metadata;
|
|
91
|
+
const fromGraph = metadata?.['langgraph_node']; // LangGraph sets this per node
|
|
92
|
+
if (typeof fromGraph === 'string')
|
|
93
|
+
return fromGraph;
|
|
94
|
+
const runName = config?.runName;
|
|
95
|
+
return runName ?? 'unknown'; // ponytail: a step with no node is still a step
|
|
96
|
+
}
|
|
97
|
+
/** Digs past Runnable bindings to the model underneath. */
|
|
98
|
+
function unwrapModel(model) {
|
|
99
|
+
let m = model;
|
|
100
|
+
// Identity read off a binding gives model_id "unknown" — every model looks alike.
|
|
101
|
+
for (let depth = 0; depth < 8 && m && typeof m['_llmType'] !== 'function'; depth++) {
|
|
102
|
+
const inner = m['bound'];
|
|
103
|
+
if (inner === undefined || inner === null)
|
|
104
|
+
break;
|
|
105
|
+
m = inner;
|
|
106
|
+
}
|
|
107
|
+
return m;
|
|
108
|
+
}
|
|
109
|
+
/** Builds the canonical model request from an invoke. */
|
|
110
|
+
function modelRequest(model, input, boundTools) {
|
|
111
|
+
const m = unwrapModel(model);
|
|
112
|
+
const llmType = m['_llmType']; // LangChain's provider discriminator
|
|
113
|
+
return {
|
|
114
|
+
kind: 'model',
|
|
115
|
+
provider: typeof llmType === 'function' ? String(llmType.call(m)) : 'unknown',
|
|
116
|
+
model_id: String(m['model'] ?? m['modelName'] ?? 'unknown'),
|
|
117
|
+
messages: messages(input),
|
|
118
|
+
tool_schemas: boundTools.map((tool, i) => toolSchema(tool, i)), // canonical() sorts these
|
|
119
|
+
...pick(m, ['temperature', 'topP', 'topK', 'maxTokens']),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** Flattens LangChain messages to plain role/content records. */
|
|
123
|
+
function messages(input) {
|
|
124
|
+
const list = Array.isArray(input) ? input : [input]; // a bare message is a list of one
|
|
125
|
+
return list.map((message) => {
|
|
126
|
+
const m = message;
|
|
127
|
+
const getType = m['_getType']; // BaseMessage hides its role behind this
|
|
128
|
+
return {
|
|
129
|
+
role: typeof getType === 'function' ? String(getType.call(message)) : String(m['role'] ?? 'user'),
|
|
130
|
+
content: m['content'] ?? null,
|
|
131
|
+
...pick(m, ['tool_calls', 'tool_call_id', 'name', 'id']), // ids become ordinals later
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Unwraps a tool call's args from its envelope. */
|
|
136
|
+
function toolArgs(input) {
|
|
137
|
+
const i = input;
|
|
138
|
+
// LangGraph passes the whole ToolCall; a direct caller passes bare args.
|
|
139
|
+
return i !== null && typeof i === 'object' && 'args' in i ? i['args'] : input;
|
|
140
|
+
}
|
|
141
|
+
/** Prefers the JSON-Schema tool dicts a binding carries. */
|
|
142
|
+
function providerTools(bound) {
|
|
143
|
+
// What the model actually sees, and plain JSON unlike a raw Zod object.
|
|
144
|
+
const b = bound;
|
|
145
|
+
for (const tools of [b?.kwargs?.tools, b?.config?.tools]) {
|
|
146
|
+
if (Array.isArray(tools))
|
|
147
|
+
return tools;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/** Reduces a tool object to its identity fields. */
|
|
152
|
+
function toolSchema(tool, index) {
|
|
153
|
+
const t = tool;
|
|
154
|
+
const fn = (t['function'] ?? t); // OpenAI nests, others are flat
|
|
155
|
+
return {
|
|
156
|
+
name: String(fn['name'] ?? `tool_${index}`),
|
|
157
|
+
description: fn['description'] ?? null,
|
|
158
|
+
// ponytail: a raw Zod object hashes as its internal _def, which a Zod upgrade
|
|
159
|
+
// invalidates. Models that convert on bindTools hit providerTools() instead.
|
|
160
|
+
schema: stable(fn['parameters'] ?? fn['input_schema'] ?? fn['schema'] ?? null),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Drops memoized fields that appear only after first use. */
|
|
164
|
+
function stable(schema) {
|
|
165
|
+
if (schema === null || typeof schema !== 'object')
|
|
166
|
+
return schema;
|
|
167
|
+
// Zod fills `_cached` lazily: identity must not depend on how often it was used.
|
|
168
|
+
return JSON.parse(JSON.stringify(schema, (key, value) => (key === '_cached' ? undefined : value)));
|
|
169
|
+
}
|
|
170
|
+
/** Copies the keys that exist and are not null. */
|
|
171
|
+
function pick(source, keys) {
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const key of keys)
|
|
174
|
+
if (source[key] !== undefined && source[key] !== null)
|
|
175
|
+
out[key] = source[key];
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/** Pulls token counts off a model response, if present. */
|
|
179
|
+
function usage(response) {
|
|
180
|
+
const meta = response?.usage_metadata;
|
|
181
|
+
return typeof meta?.total_tokens === 'number' ? { tokens: meta.total_tokens } : {};
|
|
182
|
+
}
|
|
183
|
+
/** Strips class identity so the response serializes as data. */
|
|
184
|
+
function plain(value) {
|
|
185
|
+
return value === undefined ? null : JSON.parse(JSON.stringify(value));
|
|
186
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Run } from '@krishnadobhal/rewind-core/schema';
|
|
2
|
+
import type { Observation, RecorderOptions } from './types/recorder.ts';
|
|
3
|
+
export type { Observation, RecorderOptions } from './types/recorder.ts';
|
|
4
|
+
export declare class Recorder {
|
|
5
|
+
#private;
|
|
6
|
+
readonly run: Run;
|
|
7
|
+
readonly stats: {
|
|
8
|
+
dropped: number;
|
|
9
|
+
steps: number;
|
|
10
|
+
};
|
|
11
|
+
/** Opens a run and writes its row immediately. */
|
|
12
|
+
constructor(options: RecorderOptions);
|
|
13
|
+
/**
|
|
14
|
+
* Record one boundary crossing. Never throws.
|
|
15
|
+
* hash → redact → write cassette → append step → roll up totals.
|
|
16
|
+
*/
|
|
17
|
+
record(observation: Observation): void;
|
|
18
|
+
/** Close the run out. Never throws. */
|
|
19
|
+
finish(outcome: Run['outcome'], status?: Run['status']): void;
|
|
20
|
+
}
|
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs inside the user's production agent and fails open : a store outage, a full
|
|
3
|
+
* disk, a serialization bug — none of them throw into the graph. The run goes `partial`.
|
|
4
|
+
*/
|
|
5
|
+
import { HASH_VERSION, reqHash } from '@krishnadobhal/rewind-core/hash';
|
|
6
|
+
import { redact } from './redact.js';
|
|
7
|
+
import { ulid } from './ulid.js';
|
|
8
|
+
const LOG_INTERVAL_MS = 60_000;
|
|
9
|
+
export class Recorder {
|
|
10
|
+
run;
|
|
11
|
+
stats = { dropped: 0, steps: 0 };
|
|
12
|
+
#store;
|
|
13
|
+
#redact;
|
|
14
|
+
#log;
|
|
15
|
+
#lastLoggedAt = 0;
|
|
16
|
+
#seq = 0;
|
|
17
|
+
/** Opens a run and writes its row immediately. */
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.#store = options.store;
|
|
20
|
+
this.#redact = options.redact ?? {};
|
|
21
|
+
this.#log = options.log ?? ((m, e) => console.warn(`[rewind] ${m}`, e));
|
|
22
|
+
this.run = {
|
|
23
|
+
run_id: ulid(),
|
|
24
|
+
thread_id: '',
|
|
25
|
+
graph_sha: '',
|
|
26
|
+
code_sha: '',
|
|
27
|
+
prompt_sha: '',
|
|
28
|
+
model_cfg: {},
|
|
29
|
+
seed: Math.floor(Math.random() * 2 ** 48), // under 2^53, Run.seed is a number
|
|
30
|
+
flags_snapshot: {},
|
|
31
|
+
hash_version: HASH_VERSION,
|
|
32
|
+
started_at: new Date().toISOString(),
|
|
33
|
+
ended_at: null,
|
|
34
|
+
status: 'complete',
|
|
35
|
+
outcome: null,
|
|
36
|
+
tokens: 0,
|
|
37
|
+
cost_usd: 0,
|
|
38
|
+
latency_ms: 0,
|
|
39
|
+
...options.run, // caller overrides any default above
|
|
40
|
+
};
|
|
41
|
+
// Written before the first step, so a process that dies mid-run still leaves an
|
|
42
|
+
// inspectable trace instead of orphaned steps.
|
|
43
|
+
// Written up front so a crashed run still leaves a row behind.
|
|
44
|
+
this.#guard('putRun', () => this.#store.putRun(this.run));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Record one boundary crossing. Never throws.
|
|
48
|
+
* hash → redact → write cassette → append step → roll up totals.
|
|
49
|
+
*/
|
|
50
|
+
record(observation) {
|
|
51
|
+
this.#guard('record', () => {
|
|
52
|
+
// Hashed before redaction: a digest of a secret is not the secret, and it keeps
|
|
53
|
+
// req_hash stable when the redaction config changes.
|
|
54
|
+
const hash = reqHash(observation.request);
|
|
55
|
+
const seq = this.#seq++; // claimed before any write can fail
|
|
56
|
+
// Request and response redact together, so a value in both gets one token.
|
|
57
|
+
const safe = redact({ request: observation.request, response: observation.response }, this.#redact);
|
|
58
|
+
const body = safe.value;
|
|
59
|
+
const cassette = {
|
|
60
|
+
hash,
|
|
61
|
+
hash_version: HASH_VERSION,
|
|
62
|
+
kind: observation.kind,
|
|
63
|
+
// Inline, not an s3:// ref — externalize when these outgrow a single file read.
|
|
64
|
+
request: JSON.stringify(body.request),
|
|
65
|
+
response: JSON.stringify(body.response),
|
|
66
|
+
chunks: null,
|
|
67
|
+
provider: observation.provider ?? null,
|
|
68
|
+
model_version: observation.model_version ?? null,
|
|
69
|
+
redaction_map: safe.map,
|
|
70
|
+
refcount: 1,
|
|
71
|
+
recorded_at: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
this.#store.putCassette(cassette); // no-op if this hash exists already
|
|
74
|
+
const step = {
|
|
75
|
+
run_id: this.run.run_id,
|
|
76
|
+
// Arrival order until the deterministic scheduler owns `seq` (B6).
|
|
77
|
+
seq,
|
|
78
|
+
node: observation.node,
|
|
79
|
+
kind: observation.kind,
|
|
80
|
+
req_hash: hash,
|
|
81
|
+
cassette_ref: hash,
|
|
82
|
+
match_tier: 'recorded',
|
|
83
|
+
latency_ms: observation.latency_ms,
|
|
84
|
+
tokens: observation.tokens ?? null,
|
|
85
|
+
cost_usd: observation.cost_usd ?? null,
|
|
86
|
+
error: observation.error ?? null,
|
|
87
|
+
};
|
|
88
|
+
this.#store.appendStep(step);
|
|
89
|
+
// Rolled up here so finish() has totals without re-reading the steps.
|
|
90
|
+
this.stats.steps++;
|
|
91
|
+
this.run.tokens += observation.tokens ?? 0;
|
|
92
|
+
this.run.cost_usd += observation.cost_usd ?? 0;
|
|
93
|
+
this.run.latency_ms += observation.latency_ms;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Close the run out. Never throws. */
|
|
97
|
+
finish(outcome, status = 'complete') {
|
|
98
|
+
this.run.ended_at = new Date().toISOString();
|
|
99
|
+
this.run.outcome = outcome;
|
|
100
|
+
// An incomplete recording is never a replay source; the caller cannot override that.
|
|
101
|
+
this.run.status = this.stats.dropped > 0 ? 'partial' : status;
|
|
102
|
+
this.#guard('finish', () => this.#store.putRun(this.run));
|
|
103
|
+
}
|
|
104
|
+
/** The one place a store failure may land: count it, degrade the run, carry on. */
|
|
105
|
+
/** Runs fn, swallowing and counting any failure (I1). */
|
|
106
|
+
#guard(what, fn) {
|
|
107
|
+
try {
|
|
108
|
+
fn();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
this.stats.dropped++;
|
|
112
|
+
this.run.status = 'partial';
|
|
113
|
+
const now = Date.now(); // rate-limit the log, not the counter
|
|
114
|
+
if (now - this.#lastLoggedAt >= LOG_INTERVAL_MS) {
|
|
115
|
+
this.#lastLoggedAt = now;
|
|
116
|
+
this.#log(`${what} failed; run ${this.run.run_id} is partial (${this.stats.dropped} dropped)`, error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs in the user's process, before anything is written. There is no read-time path.
|
|
3
|
+
* `redaction_map` holds token → matcher name, never the original value.
|
|
4
|
+
*/
|
|
5
|
+
import type { RedactConfig, RedactResult } from './types/redact.ts';
|
|
6
|
+
export type { RedactConfig, RedactResult } from './types/redact.ts';
|
|
7
|
+
/** Assemble the matcher list, then walk once. Returns a new tree; the input is untouched. */
|
|
8
|
+
/** Redacts a value, returning it with a token map. */
|
|
9
|
+
export declare function redact(value: unknown, config?: RedactConfig): RedactResult;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs in the user's process, before anything is written. There is no read-time path.
|
|
3
|
+
* `redaction_map` holds token → matcher name, never the original value.
|
|
4
|
+
*/
|
|
5
|
+
/** Issuer prefixes: Visa, Mastercard, Amex, Discover, UnionPay, JCB. */
|
|
6
|
+
const CARD_PREFIX = /^(?:4|5[1-5]|2[2-7]|3[47]|6(?:011|5)|62|35)/;
|
|
7
|
+
/**
|
|
8
|
+
* Is this digit run actually a card number?
|
|
9
|
+
*
|
|
10
|
+
* A 13–19 digit run is card-*shaped*; a PRNG seed or an order number is too. Luhn alone
|
|
11
|
+
* lets one in ten through, so the issuer prefix has to agree as well. Over-redacting
|
|
12
|
+
* corrupts data the agent needs while protecting nothing.
|
|
13
|
+
*/
|
|
14
|
+
function card(digits) {
|
|
15
|
+
const only = digits.replace(/\D/g, '');
|
|
16
|
+
if (only.length < 13 || !CARD_PREFIX.test(only))
|
|
17
|
+
return false;
|
|
18
|
+
let sum = 0;
|
|
19
|
+
for (let i = 0; i < only.length; i++) {
|
|
20
|
+
// Double every second digit from the right; 10 or more folds to its digit sum.
|
|
21
|
+
let d = Number(only[only.length - 1 - i]);
|
|
22
|
+
if (i % 2 === 1)
|
|
23
|
+
d = d * 2 > 9 ? d * 2 - 9 : d * 2;
|
|
24
|
+
sum += d;
|
|
25
|
+
}
|
|
26
|
+
return sum % 10 === 0;
|
|
27
|
+
}
|
|
28
|
+
const PRESET = [
|
|
29
|
+
// Bounded per RFC 5321 (local part <= 64, each domain label <= 63) for speed, not
|
|
30
|
+
// correctness: an unbounded [\w.+-]+ backtracks across the whole string from every
|
|
31
|
+
// start position, so a long token in the same character class and no @ — base64,
|
|
32
|
+
// a JWT, a data URI — costs O(n^2). 20 KB of base64 took 324ms before, 5.6ms after.
|
|
33
|
+
['email', /[\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63})+/g],
|
|
34
|
+
['token', /\b(?:Bearer\s+[\w.\-~+/]+=*|(?:sk|pk|rk)[-_](?:live|test)?[-_]?[A-Za-z0-9]{12,}|xox[baprs]-[\w-]{10,}|AKIA[0-9A-Z]{16}|eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,})/g],
|
|
35
|
+
['card', /\b(?:\d[ -]?){13,19}\b/g, card],
|
|
36
|
+
// Numbers are not all +1 555, so this takes grouped digits with an optional country
|
|
37
|
+
// code — covering UK `020 7946 0958` and most of Europe. The boundaries are the load
|
|
38
|
+
// -bearing part: without them it eats the digit groups inside a UUID, which corrupts
|
|
39
|
+
// ids the agent needs while protecting nothing.
|
|
40
|
+
['phone', /(?<![\w-])(?:\+\d{1,3}[ .-]?)?(?:\(\d{2,4}\)|\d{2,4})[ .-]\d{3,4}[ .-]?\d{3,4}(?![\w-])/g],
|
|
41
|
+
];
|
|
42
|
+
class Tokens {
|
|
43
|
+
map = {}; // token → matcher name, no plaintext
|
|
44
|
+
#seen = new Map(); // original → token, for repeat values
|
|
45
|
+
#counts = new Map(); // per-matcher counter, gives the suffix
|
|
46
|
+
/** Same original → same token, so redacted text keeps its structure. */
|
|
47
|
+
for(original, matcher) {
|
|
48
|
+
let token = this.#seen.get(original);
|
|
49
|
+
if (token === undefined) {
|
|
50
|
+
const n = this.#counts.get(matcher) ?? 0;
|
|
51
|
+
this.#counts.set(matcher, n + 1);
|
|
52
|
+
token = `[redacted:${matcher}:${n}]`;
|
|
53
|
+
this.#seen.set(original, token);
|
|
54
|
+
this.map[token] = matcher;
|
|
55
|
+
}
|
|
56
|
+
return token;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Runs every matcher over one string, in order. */
|
|
60
|
+
function redactString(input, matchers, tokens) {
|
|
61
|
+
let out = input; // rewritten once per matcher
|
|
62
|
+
for (const [name, pattern, accept] of matchers) {
|
|
63
|
+
// Fresh RegExp per call: a shared /g pattern carries lastIndex between strings.
|
|
64
|
+
out = out.replace(new RegExp(pattern.source, pattern.flags), (m) =>
|
|
65
|
+
// A refused match is left exactly as it was found.
|
|
66
|
+
accept && !accept(m) ? m : tokens.for(m, name));
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Depth-first over the tree, building a dotted path as it descends. A configured
|
|
72
|
+
* `fields` path wins outright — the whole value goes, whatever shape it is — before
|
|
73
|
+
* any pattern gets a chance to run against it.
|
|
74
|
+
*/
|
|
75
|
+
/** Recurses the value, redacting strings and named fields. */
|
|
76
|
+
function walk(value, path, ctx) {
|
|
77
|
+
if (ctx.fields.has(path)) { // whole value goes, whatever type it is
|
|
78
|
+
return value === undefined ? value : ctx.tokens.for(`${path}:${JSON.stringify(value)}`, 'field');
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === 'string')
|
|
81
|
+
return redactString(value, ctx.matchers, ctx.tokens);
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return value.map((v, i) => walk(v, `${path}[${i}]`, ctx));
|
|
84
|
+
if (value !== null && typeof value === 'object') {
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const [k, v] of Object.entries(value)) {
|
|
87
|
+
out[k] = walk(v, path === '' ? k : `${path}.${k}`, ctx);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
/** Assemble the matcher list, then walk once. Returns a new tree; the input is untouched. */
|
|
94
|
+
/** Redacts a value, returning it with a token map. */
|
|
95
|
+
export function redact(value, config = {}) {
|
|
96
|
+
const matchers = config.preset === 'none' ? [] : [...PRESET]; // copy, PRESET is shared
|
|
97
|
+
(config.custom ?? []).forEach((re, i) => matchers.push([`custom:${i}`, re]));
|
|
98
|
+
const tokens = new Tokens(); // one map per call, numbering restarts
|
|
99
|
+
const redacted = walk(value, '', { matchers, fields: new Set(config.fields ?? []), tokens });
|
|
100
|
+
return { value: redacted, map: tokens.map };
|
|
101
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Cassette, Run, Step } from '@krishnadobhal/rewind-core/schema';
|
|
2
|
+
export type Store = {
|
|
3
|
+
putRun(run: Run): void;
|
|
4
|
+
appendStep(step: Step): void;
|
|
5
|
+
putCassette(cassette: Cassette): void;
|
|
6
|
+
};
|
|
7
|
+
/** One recorded run: its metadata plus its ordered steps. */
|
|
8
|
+
export type Trace = {
|
|
9
|
+
run: Run;
|
|
10
|
+
steps: Step[];
|
|
11
|
+
};
|
|
12
|
+
/** Creates a Store backed by a local directory. */
|
|
13
|
+
export declare function fileStore(root: string): Store;
|
|
14
|
+
/** Reads one run and its steps back off disk. */
|
|
15
|
+
export declare function readTrace(root: string, runId: string): Trace | null;
|
|
16
|
+
/** Lists run ids, oldest first, ULIDs sort by time. */
|
|
17
|
+
export declare function listRuns(root: string): string[];
|
|
18
|
+
/** Reads one cassette by its content hash. */
|
|
19
|
+
export declare function readCassette(root: string, hash: string): Cassette | null;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/** Creates a Store backed by a local directory. */
|
|
4
|
+
export function fileStore(root) {
|
|
5
|
+
// Shared by putRun and putCassette, both whole-file writes.
|
|
6
|
+
const write = (path, body) => {
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true }); // parents may not exist yet
|
|
8
|
+
writeFileSync(path, body);
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
putRun(run) {
|
|
12
|
+
// Rewritten on finish, so the last write wins.
|
|
13
|
+
write(join(root, 'runs', run.run_id, 'run.json'), JSON.stringify(run, null, 2) + '\n');
|
|
14
|
+
},
|
|
15
|
+
appendStep(step) {
|
|
16
|
+
const path = join(root, 'runs', step.run_id, 'steps.jsonl');
|
|
17
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
18
|
+
appendFileSync(path, JSON.stringify(step) + '\n'); // append-only, one step per line
|
|
19
|
+
},
|
|
20
|
+
putCassette(cassette) {
|
|
21
|
+
const path = join(root, 'cassettes', `${cassette.hash}.json`);
|
|
22
|
+
// Content-addressed and immutable: a corrected recording is a new
|
|
23
|
+
// cassette, never an overwrite of this one.
|
|
24
|
+
if (existsSync(path))
|
|
25
|
+
return;
|
|
26
|
+
write(path, JSON.stringify(cassette) + '\n');
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function orNull(read) {
|
|
31
|
+
try {
|
|
32
|
+
return read();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null; // unreadable, corrupt, or not the shape we expected
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Reads one run and its steps back off disk. */
|
|
39
|
+
export function readTrace(root, runId) {
|
|
40
|
+
const dir = join(root, 'runs', runId);
|
|
41
|
+
return orNull(() => {
|
|
42
|
+
const run = JSON.parse(readFileSync(join(dir, 'run.json'), 'utf8'));
|
|
43
|
+
const jsonl = existsSync(join(dir, 'steps.jsonl')) ? readFileSync(join(dir, 'steps.jsonl'), 'utf8') : '';
|
|
44
|
+
// Trim first: a trailing newline would split into one empty line.
|
|
45
|
+
const steps = jsonl.trim() === '' ? [] : jsonl.trim().split('\n').map((line) => JSON.parse(line));
|
|
46
|
+
return { run, steps };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Lists run ids, oldest first, ULIDs sort by time. */
|
|
50
|
+
export function listRuns(root) {
|
|
51
|
+
// Not a directory is as good as empty: nothing has been recorded here.
|
|
52
|
+
return orNull(() => readdirSync(join(root, 'runs')).sort()) ?? [];
|
|
53
|
+
}
|
|
54
|
+
/** Reads one cassette by its content hash. */
|
|
55
|
+
export function readCassette(root, hash) {
|
|
56
|
+
return orNull(() => JSON.parse(readFileSync(join(root, 'cassettes', `${hash}.json`), 'utf8')));
|
|
57
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RewindRequest } from '@krishnadobhal/rewind-core/request';
|
|
2
|
+
import type { Run, StepKind } from '@krishnadobhal/rewind-core/schema';
|
|
3
|
+
import type { RedactConfig } from './redact.ts';
|
|
4
|
+
import type { Store } from './store.ts';
|
|
5
|
+
/** One boundary crossing, as the SDK observes it. */
|
|
6
|
+
export type Observation = {
|
|
7
|
+
node: string;
|
|
8
|
+
kind: StepKind;
|
|
9
|
+
request: RewindRequest;
|
|
10
|
+
response: unknown;
|
|
11
|
+
latency_ms: number;
|
|
12
|
+
tokens?: number;
|
|
13
|
+
cost_usd?: number;
|
|
14
|
+
error?: unknown;
|
|
15
|
+
provider?: string;
|
|
16
|
+
model_version?: string;
|
|
17
|
+
};
|
|
18
|
+
export type RecorderOptions = {
|
|
19
|
+
store: Store;
|
|
20
|
+
redact?: RedactConfig;
|
|
21
|
+
run?: Partial<Run>;
|
|
22
|
+
log?: (message: string, error: unknown) => void;
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Cassette, Run, Step } from '@krishnadobhal/rewind-core/schema';
|
|
2
|
+
/** Two lifetimes: a run and its steps belong to one run, cassettes are shared. */
|
|
3
|
+
export type Store = {
|
|
4
|
+
putRun(run: Run): void;
|
|
5
|
+
appendStep(step: Step): void;
|
|
6
|
+
putCassette(cassette: Cassette): void;
|
|
7
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ulid.d.ts
ADDED
package/dist/ulid.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/** Crockford base32 — no I, L, O or U, so ids survive being read aloud. */
|
|
3
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
4
|
+
/**
|
|
5
|
+
* ULID: 48-bit millisecond timestamp + 80 bits of randomness, base32.
|
|
6
|
+
* Lexicographic order is creation order, which is why DATA_MODEL.md picks it over UUIDv4.
|
|
7
|
+
*/
|
|
8
|
+
export function ulid(now = Date.now()) {
|
|
9
|
+
// Least-significant symbol first, each one prepended, so the result reads big-endian.
|
|
10
|
+
let time = ''; // 10 symbols, most significant first
|
|
11
|
+
for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32))
|
|
12
|
+
time = ALPHABET[t % 32] + time;
|
|
13
|
+
// 256 / 32 is exact, so a uniform byte gives a uniform symbol.
|
|
14
|
+
let rand = ''; // 16 symbols, 80 bits
|
|
15
|
+
for (const byte of randomBytes(16))
|
|
16
|
+
rand += ALPHABET[byte % 32];
|
|
17
|
+
return time + rand;
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@krishnadobhal/rewind-sdk-js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Record every boundary an LLM agent crosses: withRewind, the recorder, the redactor, and the stores.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Krishna Dobhal",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/krishnadobhal/Rewind.git",
|
|
10
|
+
"directory": "packages/sdk-js"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/krishnadobhal/Rewind#readme",
|
|
13
|
+
"bugs": "https://github.com/krishnadobhal/Rewind/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"rewind",
|
|
16
|
+
"langgraph",
|
|
17
|
+
"langchain",
|
|
18
|
+
"agents",
|
|
19
|
+
"record",
|
|
20
|
+
"observability"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
"./recorder": "./dist/recorder.js",
|
|
28
|
+
"./redact": "./dist/redact.js",
|
|
29
|
+
"./store": "./dist/store.js",
|
|
30
|
+
"./config": "./dist/config.js",
|
|
31
|
+
"./env": "./dist/env.js",
|
|
32
|
+
"./ulid": "./dist/ulid.js",
|
|
33
|
+
"./middleware": "./dist/middleware.js",
|
|
34
|
+
"./emitter": "./dist/emitter.js",
|
|
35
|
+
"./http": "./dist/http.js"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@krishnadobhal/rewind-core": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^24.13.3"
|
|
45
|
+
}
|
|
46
|
+
}
|