@devicerail/recorder 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/README.md +111 -0
- package/dist/bundle-cli.d.ts +13 -0
- package/dist/bundle-cli.js +311 -0
- package/dist/canonical.d.ts +22 -0
- package/dist/canonical.js +174 -0
- package/dist/checkpoint.d.ts +28 -0
- package/dist/checkpoint.js +993 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +10 -0
- package/dist/event-log.d.ts +71 -0
- package/dist/event-log.js +860 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/recorder.d.ts +72 -0
- package/dist/recorder.js +607 -0
- package/dist/source-file.d.ts +25 -0
- package/dist/source-file.js +291 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.js +2 -0
- package/package.json +53 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { link, lstat, open, unlink } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { CanonicalJsonError, fromCanonicalJson, toCanonicalJson, } from "./canonical.js";
|
|
6
|
+
import { validateRecorderCheckpoint } from "./checkpoint.js";
|
|
7
|
+
import { RecorderError } from "./errors.js";
|
|
8
|
+
import { RECORDER_CHECKPOINT_FORMAT, RECORDER_CHECKPOINT_VERSION, } from "./types.js";
|
|
9
|
+
export const BUNDLE_SOURCE_MAX_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const SOURCE_KEYS = ["eventProtocolVersion", "sessionExport"];
|
|
11
|
+
const EXPORT_KEYS = ["events", "session"];
|
|
12
|
+
function isNodeError(error, code) {
|
|
13
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
14
|
+
}
|
|
15
|
+
function record(value, location) {
|
|
16
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
17
|
+
throw new RecorderError("source_corrupt", `${location} must be an object`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function exactKeys(value, required, location) {
|
|
22
|
+
const allowed = new Set(required);
|
|
23
|
+
for (const key of required) {
|
|
24
|
+
if (!Object.hasOwn(value, key)) {
|
|
25
|
+
throw new RecorderError("source_corrupt", `${location} is missing ${key}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const key of Object.keys(value)) {
|
|
29
|
+
if (!allowed.has(key)) {
|
|
30
|
+
throw new RecorderError("source_corrupt", `${location} contains unknown field ${key}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function byteLimit(options) {
|
|
35
|
+
const limit = options.maxBytes ?? BUNDLE_SOURCE_MAX_BYTES;
|
|
36
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
37
|
+
throw new RecorderError("source_too_large", "source maxBytes must be a positive safe integer");
|
|
38
|
+
}
|
|
39
|
+
if (limit > BUNDLE_SOURCE_MAX_BYTES) {
|
|
40
|
+
throw new RecorderError("source_too_large", `source maxBytes cannot exceed ${BUNDLE_SOURCE_MAX_BYTES}`);
|
|
41
|
+
}
|
|
42
|
+
return limit;
|
|
43
|
+
}
|
|
44
|
+
function requireNotCancelled(signal) {
|
|
45
|
+
if (signal?.aborted) {
|
|
46
|
+
throw new RecorderError("operation_cancelled", "BundleSource publication was cancelled", {
|
|
47
|
+
details: { reason: signal.reason instanceof Error ? signal.reason.name : "aborted" },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Strictly validate and detach one ended BundleSource. */
|
|
52
|
+
export function validateBundleSourceFile(value) {
|
|
53
|
+
try {
|
|
54
|
+
const source = record(value, "BundleSource");
|
|
55
|
+
exactKeys(source, SOURCE_KEYS, "BundleSource");
|
|
56
|
+
const exported = record(source.sessionExport, "BundleSource.sessionExport");
|
|
57
|
+
exactKeys(exported, EXPORT_KEYS, "BundleSource.sessionExport");
|
|
58
|
+
const sessionCandidate = record(exported.session, "BundleSource.sessionExport.session");
|
|
59
|
+
if (typeof sessionCandidate.id !== "string") {
|
|
60
|
+
throw new RecorderError("source_corrupt", "BundleSource Session id must be a string");
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(exported.events)) {
|
|
63
|
+
throw new RecorderError("source_corrupt", "BundleSource events must be an array");
|
|
64
|
+
}
|
|
65
|
+
const checkpoint = validateRecorderCheckpoint({
|
|
66
|
+
format: RECORDER_CHECKPOINT_FORMAT,
|
|
67
|
+
version: RECORDER_CHECKPOINT_VERSION,
|
|
68
|
+
revision: 1,
|
|
69
|
+
phase: "sealed",
|
|
70
|
+
sessionId: sessionCandidate.id,
|
|
71
|
+
eventProtocolVersion: source.eventProtocolVersion,
|
|
72
|
+
events: exported.events,
|
|
73
|
+
session: exported.session,
|
|
74
|
+
});
|
|
75
|
+
if (checkpoint.phase !== "sealed") {
|
|
76
|
+
throw new RecorderError("source_corrupt", "BundleSource did not produce a sealed Session");
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
eventProtocolVersion: checkpoint.eventProtocolVersion,
|
|
80
|
+
sessionExport: {
|
|
81
|
+
session: checkpoint.session,
|
|
82
|
+
events: [...checkpoint.events],
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (cause) {
|
|
87
|
+
if (cause instanceof RecorderError && cause.code === "source_corrupt") {
|
|
88
|
+
throw cause;
|
|
89
|
+
}
|
|
90
|
+
throw new RecorderError("source_corrupt", "BundleSource is invalid", { cause });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Validate that a strict BundleSource also fits its publication contract. */
|
|
94
|
+
export function validateBundleSourceBounds(value) {
|
|
95
|
+
const normalized = validateBundleSourceFile(value);
|
|
96
|
+
try {
|
|
97
|
+
toCanonicalJson(normalized, { maxBytes: BUNDLE_SOURCE_MAX_BYTES });
|
|
98
|
+
}
|
|
99
|
+
catch (cause) {
|
|
100
|
+
if (cause instanceof CanonicalJsonError) {
|
|
101
|
+
throw new RecorderError("source_too_large", `BundleSource exceeds its ${BUNDLE_SOURCE_MAX_BYTES}-byte limit`, { cause });
|
|
102
|
+
}
|
|
103
|
+
throw cause;
|
|
104
|
+
}
|
|
105
|
+
return normalized;
|
|
106
|
+
}
|
|
107
|
+
function requireOwnerOnly(metadata) {
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if ((metadata.mode & 0o077) !== 0) {
|
|
112
|
+
throw new RecorderError("source_corrupt", "BundleSource must be owner-only");
|
|
113
|
+
}
|
|
114
|
+
const effectiveUser = process.geteuid?.();
|
|
115
|
+
if (effectiveUser !== undefined && metadata.uid !== effectiveUser) {
|
|
116
|
+
throw new RecorderError("source_corrupt", "BundleSource must be owned by this user");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function requireRealParent(path) {
|
|
120
|
+
const parent = dirname(path);
|
|
121
|
+
const name = basename(path);
|
|
122
|
+
if (name.length === 0 || name === "." || name === "..") {
|
|
123
|
+
throw new RecorderError("source_corrupt", "BundleSource path has no safe file name");
|
|
124
|
+
}
|
|
125
|
+
let metadata;
|
|
126
|
+
try {
|
|
127
|
+
metadata = await lstat(parent);
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
throw new RecorderError("source_corrupt", "BundleSource parent does not exist", { cause });
|
|
131
|
+
}
|
|
132
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
133
|
+
throw new RecorderError("source_corrupt", "BundleSource parent must be a real directory");
|
|
134
|
+
}
|
|
135
|
+
return parent;
|
|
136
|
+
}
|
|
137
|
+
async function readBoundedSource(path, limit) {
|
|
138
|
+
let pathMetadata;
|
|
139
|
+
try {
|
|
140
|
+
pathMetadata = await lstat(path);
|
|
141
|
+
}
|
|
142
|
+
catch (cause) {
|
|
143
|
+
throw new RecorderError("source_corrupt", "BundleSource metadata could not be read", {
|
|
144
|
+
cause,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (!pathMetadata.isFile() || pathMetadata.isSymbolicLink()) {
|
|
148
|
+
throw new RecorderError("source_corrupt", "BundleSource must be a regular file");
|
|
149
|
+
}
|
|
150
|
+
requireOwnerOnly(pathMetadata);
|
|
151
|
+
if (pathMetadata.size > limit) {
|
|
152
|
+
throw new RecorderError("source_too_large", `BundleSource exceeds its ${limit}-byte limit`);
|
|
153
|
+
}
|
|
154
|
+
let handle;
|
|
155
|
+
try {
|
|
156
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
throw new RecorderError("source_corrupt", "BundleSource could not be opened safely", {
|
|
160
|
+
cause,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const opened = await handle.stat();
|
|
165
|
+
if (!opened.isFile() ||
|
|
166
|
+
opened.dev !== pathMetadata.dev ||
|
|
167
|
+
opened.ino !== pathMetadata.ino) {
|
|
168
|
+
throw new RecorderError("source_corrupt", "BundleSource changed while it was opened");
|
|
169
|
+
}
|
|
170
|
+
requireOwnerOnly(opened);
|
|
171
|
+
const bytes = Buffer.allocUnsafe(limit + 1);
|
|
172
|
+
let offset = 0;
|
|
173
|
+
while (offset < bytes.length) {
|
|
174
|
+
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null);
|
|
175
|
+
if (bytesRead === 0) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
offset += bytesRead;
|
|
179
|
+
}
|
|
180
|
+
if (offset > limit) {
|
|
181
|
+
throw new RecorderError("source_too_large", `BundleSource exceeds its ${limit}-byte limit`);
|
|
182
|
+
}
|
|
183
|
+
return bytes.subarray(0, offset);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await handle.close().catch(() => { });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function syncParent(parent) {
|
|
190
|
+
// Node does not expose a portable Windows directory fsync. Publication is
|
|
191
|
+
// still no-clobber atomic, but power-loss durability is filesystem-defined.
|
|
192
|
+
if (process.platform === "win32") {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const handle = await open(parent, "r");
|
|
196
|
+
try {
|
|
197
|
+
await handle.sync();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await handle.close().catch(() => { });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Read a strict canonical BundleSource; Unix additionally enforces owner-only metadata. */
|
|
204
|
+
export async function readBundleSource(path, options = {}) {
|
|
205
|
+
const limit = byteLimit(options);
|
|
206
|
+
const bytes = await readBoundedSource(path, limit);
|
|
207
|
+
try {
|
|
208
|
+
return validateBundleSourceFile(fromCanonicalJson(bytes, { maxBytes: limit }));
|
|
209
|
+
}
|
|
210
|
+
catch (cause) {
|
|
211
|
+
if (cause instanceof RecorderError && cause.code === "source_too_large") {
|
|
212
|
+
throw cause;
|
|
213
|
+
}
|
|
214
|
+
throw new RecorderError("source_corrupt", "BundleSource is not strict canonical JSON", {
|
|
215
|
+
cause,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Atomically publish a BundleSource without replacing any existing path.
|
|
221
|
+
* Unix creates it owner-only; Windows relies on the caller's parent ACL. The
|
|
222
|
+
* hard-link operation is the publication linearization point.
|
|
223
|
+
*/
|
|
224
|
+
export async function publishBundleSource(path, source, options = {}) {
|
|
225
|
+
const limit = byteLimit(options);
|
|
226
|
+
const normalized = validateBundleSourceFile(source);
|
|
227
|
+
let bytes;
|
|
228
|
+
try {
|
|
229
|
+
bytes = toCanonicalJson(normalized, { maxBytes: limit });
|
|
230
|
+
}
|
|
231
|
+
catch (cause) {
|
|
232
|
+
if (cause instanceof CanonicalJsonError) {
|
|
233
|
+
throw new RecorderError("source_too_large", "BundleSource exceeds its encoding limit", {
|
|
234
|
+
cause,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
throw cause;
|
|
238
|
+
}
|
|
239
|
+
const parent = await requireRealParent(path);
|
|
240
|
+
const temporary = join(parent, `.${basename(path)}.${randomUUID()}.tmp`);
|
|
241
|
+
let published = false;
|
|
242
|
+
let operationError;
|
|
243
|
+
try {
|
|
244
|
+
requireNotCancelled(options.signal);
|
|
245
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
246
|
+
try {
|
|
247
|
+
await handle.writeFile(bytes);
|
|
248
|
+
await handle.sync();
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await handle.close().catch(() => { });
|
|
252
|
+
}
|
|
253
|
+
requireNotCancelled(options.signal);
|
|
254
|
+
try {
|
|
255
|
+
await link(temporary, path);
|
|
256
|
+
}
|
|
257
|
+
catch (cause) {
|
|
258
|
+
if (isNodeError(cause, "EEXIST")) {
|
|
259
|
+
throw new RecorderError("source_conflict", "BundleSource target already exists");
|
|
260
|
+
}
|
|
261
|
+
throw cause;
|
|
262
|
+
}
|
|
263
|
+
published = true;
|
|
264
|
+
await unlink(temporary);
|
|
265
|
+
await syncParent(parent);
|
|
266
|
+
}
|
|
267
|
+
catch (cause) {
|
|
268
|
+
operationError =
|
|
269
|
+
cause instanceof RecorderError
|
|
270
|
+
? cause
|
|
271
|
+
: new RecorderError(published ? "source_durability_unknown" : "source_corrupt", published
|
|
272
|
+
? "BundleSource was published but final durability is unknown"
|
|
273
|
+
: "BundleSource publication failed before its linearization point", {
|
|
274
|
+
cause,
|
|
275
|
+
...(published ? { details: { published: true } } : {}),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (!published) {
|
|
279
|
+
await unlink(temporary).catch(() => { });
|
|
280
|
+
}
|
|
281
|
+
if (operationError !== undefined) {
|
|
282
|
+
throw operationError;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Construct the exact local source expected by the Bundle CLI. */
|
|
286
|
+
export function bundleSourceFromEndedSession(eventProtocolVersion, session, events) {
|
|
287
|
+
return validateBundleSourceFile({
|
|
288
|
+
eventProtocolVersion,
|
|
289
|
+
sessionExport: { session, events },
|
|
290
|
+
});
|
|
291
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ProtocolVersion, SessionInfo, TestEvent } from "@devicerail/protocol";
|
|
2
|
+
export declare const RECORDER_CHECKPOINT_FORMAT: "devicerail.execution-recorder-checkpoint";
|
|
3
|
+
export declare const RECORDER_CHECKPOINT_VERSION: 1;
|
|
4
|
+
export type RecorderPhase = "recording" | "sealed" | "completed";
|
|
5
|
+
/** Path-free receipt retained only after export and independent validation agree. */
|
|
6
|
+
export interface RecorderBundleReceipt {
|
|
7
|
+
readonly sessionId: string;
|
|
8
|
+
readonly eventCount: number;
|
|
9
|
+
readonly assetCount: number;
|
|
10
|
+
readonly assetBytes: number;
|
|
11
|
+
}
|
|
12
|
+
interface RecorderCheckpointBase {
|
|
13
|
+
readonly format: typeof RECORDER_CHECKPOINT_FORMAT;
|
|
14
|
+
readonly version: typeof RECORDER_CHECKPOINT_VERSION;
|
|
15
|
+
readonly revision: number;
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
readonly eventProtocolVersion: ProtocolVersion;
|
|
18
|
+
readonly events: readonly TestEvent[];
|
|
19
|
+
}
|
|
20
|
+
export interface RecordingCheckpoint extends RecorderCheckpointBase {
|
|
21
|
+
readonly phase: "recording";
|
|
22
|
+
readonly session?: never;
|
|
23
|
+
readonly bundle?: never;
|
|
24
|
+
}
|
|
25
|
+
export interface SealedCheckpoint extends RecorderCheckpointBase {
|
|
26
|
+
readonly phase: "sealed";
|
|
27
|
+
readonly session: SessionInfo;
|
|
28
|
+
readonly bundle?: never;
|
|
29
|
+
}
|
|
30
|
+
export interface CompletedCheckpoint extends RecorderCheckpointBase {
|
|
31
|
+
readonly phase: "completed";
|
|
32
|
+
readonly session: SessionInfo;
|
|
33
|
+
readonly bundle: RecorderBundleReceipt;
|
|
34
|
+
}
|
|
35
|
+
export type RecorderCheckpoint = RecordingCheckpoint | SealedCheckpoint | CompletedCheckpoint;
|
|
36
|
+
export type EventAcceptance = "accepted" | "duplicate";
|
|
37
|
+
export interface EventBatchResult {
|
|
38
|
+
readonly accepted: number;
|
|
39
|
+
readonly duplicates: number;
|
|
40
|
+
readonly lastSequence: number | null;
|
|
41
|
+
readonly terminal: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface EventLogSnapshot {
|
|
44
|
+
readonly sessionId: string;
|
|
45
|
+
readonly events: readonly TestEvent[];
|
|
46
|
+
readonly lastSequence: number | null;
|
|
47
|
+
readonly terminal: boolean;
|
|
48
|
+
readonly openActionCount: number;
|
|
49
|
+
}
|
|
50
|
+
export {};
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devicerail/recorder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Durable execution recorder and session-bundle handoff for DeviceRail",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"device-automation",
|
|
8
|
+
"test-recorder",
|
|
9
|
+
"test-evidence",
|
|
10
|
+
"testing"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@types/node": "26.1.1",
|
|
33
|
+
"@devicerail/client": "0.1.0",
|
|
34
|
+
"@devicerail/protocol": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "7.0.2"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/wangweiwei/device-rail.git",
|
|
42
|
+
"directory": "packages/recorder"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/wangweiwei/device-rail#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/wangweiwei/device-rail/issues"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
50
|
+
"build": "node scripts/clean.mjs dist && tsc -p tsconfig.build.json && tsc -p tsconfig.package-exports.json && node scripts/check-package-exports.mjs",
|
|
51
|
+
"test": "node scripts/clean.mjs .test-dist && tsc -p tsconfig.test.json && node scripts/run-tests.mjs"
|
|
52
|
+
}
|
|
53
|
+
}
|