@danypops/vehicle-core 0.2.0 → 0.4.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/dist/atomic-json.d.ts +53 -0
- package/dist/atomic-json.js +102 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/vehicle-contract.d.ts +9 -0
- package/dist/vehicle-contract.js +33 -1
- package/dist/vehicle-errors.d.ts +1 -1
- package/dist/vehicle-jobs.d.ts +45 -0
- package/dist/vehicle-jobs.js +77 -0
- package/package.json +2 -2
- package/src/atomic-json.ts +137 -0
- package/src/index.ts +2 -0
- package/src/vehicle-contract.ts +51 -8
- package/src/vehicle-errors.ts +3 -1
- package/src/vehicle-jobs.ts +114 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform atomic JSON persistence -- shared by every Vehicle
|
|
3
|
+
* primitive that needs durable state (Jobs' status file, Watchers'
|
|
4
|
+
* registry) so a crash or concurrent read never observes a half-written
|
|
5
|
+
* file. Lives in vehicle-core (not vehicle-server) but stays fs-free
|
|
6
|
+
* itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
|
|
7
|
+
* matching vehicle-core's own "zero runtime dependencies" invariant --
|
|
8
|
+
* the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
|
|
9
|
+
* functions, this module only sequences them.
|
|
10
|
+
*
|
|
11
|
+
* Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
|
|
12
|
+
* a collision-safe temp filename, injectable fs/now/pid/random for
|
|
13
|
+
* deterministic tests, and explicit Windows-aware rename retry (a plain
|
|
14
|
+
* `fs.rename` onto an existing path can transiently fail on Windows if
|
|
15
|
+
* another process -- antivirus, search indexing -- has the destination
|
|
16
|
+
* briefly open; POSIX rename() has no such failure mode, so retrying by
|
|
17
|
+
* default there would only add latency for a class of error that never
|
|
18
|
+
* happens).
|
|
19
|
+
*/
|
|
20
|
+
export interface AtomicJsonFsAdapter {
|
|
21
|
+
writeFile(path: string, data: string): Promise<void>;
|
|
22
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
23
|
+
unlink(path: string): Promise<void>;
|
|
24
|
+
readFile(path: string): Promise<string>;
|
|
25
|
+
}
|
|
26
|
+
export interface AtomicJsonWriterOptions {
|
|
27
|
+
readonly fs: AtomicJsonFsAdapter;
|
|
28
|
+
/** Defaults to Date.now. */
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
/** Defaults to the current process's pid, or 0 outside Node/Bun. */
|
|
31
|
+
readonly pid?: () => number;
|
|
32
|
+
/** Defaults to a short random hex string. */
|
|
33
|
+
readonly random?: () => string;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a failed rename onto the destination is retried at all.
|
|
36
|
+
* Defaults to `process.platform === "win32"` -- off on Linux/macOS,
|
|
37
|
+
* where a transient rename failure isn't a real failure mode.
|
|
38
|
+
*/
|
|
39
|
+
readonly retryRename?: boolean;
|
|
40
|
+
/** Error codes on `rename` worth retrying. Defaults to ["EPERM", "EBUSY", "EACCES"] (the documented Windows file-lock codes). */
|
|
41
|
+
readonly retryRenameErrors?: readonly string[];
|
|
42
|
+
/** Delay before each retry attempt, in order. Defaults to [50, 100, 200]. */
|
|
43
|
+
readonly retryDelaysMs?: readonly number[];
|
|
44
|
+
/** Injectable so a test doesn't have to sleep for real. Defaults to setTimeout. */
|
|
45
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export interface AtomicJsonWriter {
|
|
48
|
+
/** Serializes `value` to JSON and writes it to `filePath` atomically (temp file + rename). */
|
|
49
|
+
write(filePath: string, value: unknown): Promise<void>;
|
|
50
|
+
/** Reads and JSON.parses `filePath`. Returns undefined if the file doesn't exist (fs.readFile throws ENOENT); rethrows any other error. */
|
|
51
|
+
read(filePath: string): Promise<unknown | undefined>;
|
|
52
|
+
}
|
|
53
|
+
export declare function createAtomicJsonWriter(options: AtomicJsonWriterOptions): AtomicJsonWriter;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform atomic JSON persistence -- shared by every Vehicle
|
|
3
|
+
* primitive that needs durable state (Jobs' status file, Watchers'
|
|
4
|
+
* registry) so a crash or concurrent read never observes a half-written
|
|
5
|
+
* file. Lives in vehicle-core (not vehicle-server) but stays fs-free
|
|
6
|
+
* itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
|
|
7
|
+
* matching vehicle-core's own "zero runtime dependencies" invariant --
|
|
8
|
+
* the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
|
|
9
|
+
* functions, this module only sequences them.
|
|
10
|
+
*
|
|
11
|
+
* Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
|
|
12
|
+
* a collision-safe temp filename, injectable fs/now/pid/random for
|
|
13
|
+
* deterministic tests, and explicit Windows-aware rename retry (a plain
|
|
14
|
+
* `fs.rename` onto an existing path can transiently fail on Windows if
|
|
15
|
+
* another process -- antivirus, search indexing -- has the destination
|
|
16
|
+
* briefly open; POSIX rename() has no such failure mode, so retrying by
|
|
17
|
+
* default there would only add latency for a class of error that never
|
|
18
|
+
* happens).
|
|
19
|
+
*/
|
|
20
|
+
function defaultPlatformIsWindows() {
|
|
21
|
+
return typeof process !== "undefined" && process.platform === "win32";
|
|
22
|
+
}
|
|
23
|
+
function defaultPid() {
|
|
24
|
+
return typeof process !== "undefined" ? process.pid : 0;
|
|
25
|
+
}
|
|
26
|
+
function defaultRandom() {
|
|
27
|
+
return Math.random().toString(36).slice(2, 10);
|
|
28
|
+
}
|
|
29
|
+
function isErrnoException(error) {
|
|
30
|
+
return error instanceof Error && "code" in error;
|
|
31
|
+
}
|
|
32
|
+
function dirAndBase(filePath) {
|
|
33
|
+
const separator = filePath.lastIndexOf("/");
|
|
34
|
+
if (separator === -1)
|
|
35
|
+
return { dir: ".", base: filePath };
|
|
36
|
+
return { dir: filePath.slice(0, separator) || "/", base: filePath.slice(separator + 1) };
|
|
37
|
+
}
|
|
38
|
+
export function createAtomicJsonWriter(options) {
|
|
39
|
+
const fsAdapter = options.fs;
|
|
40
|
+
const now = options.now ?? Date.now;
|
|
41
|
+
const pid = options.pid ?? defaultPid;
|
|
42
|
+
const random = options.random ?? defaultRandom;
|
|
43
|
+
const retryRename = options.retryRename ?? defaultPlatformIsWindows();
|
|
44
|
+
const retryRenameErrors = options.retryRenameErrors ?? ["EPERM", "EBUSY", "EACCES"];
|
|
45
|
+
const retryDelaysMs = options.retryDelaysMs ?? [50, 100, 200];
|
|
46
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
47
|
+
async function renameWithRetry(tempPath, filePath) {
|
|
48
|
+
let attempt = 0;
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
await fsAdapter.rename(tempPath, filePath);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const retryable = retryRename && isErrnoException(error) && !!error.code && retryRenameErrors.includes(error.code);
|
|
56
|
+
if (!retryable || attempt >= retryDelaysMs.length)
|
|
57
|
+
throw error;
|
|
58
|
+
await sleep(retryDelaysMs[attempt] ?? 0);
|
|
59
|
+
attempt++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
async write(filePath, value) {
|
|
65
|
+
let serialized;
|
|
66
|
+
try {
|
|
67
|
+
serialized = JSON.stringify(value);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`, { cause: error });
|
|
71
|
+
}
|
|
72
|
+
if (serialized === undefined)
|
|
73
|
+
throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
|
|
74
|
+
const { dir, base } = dirAndBase(filePath);
|
|
75
|
+
const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
|
|
76
|
+
await fsAdapter.writeFile(tempPath, serialized);
|
|
77
|
+
try {
|
|
78
|
+
await renameWithRetry(tempPath, filePath);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
try {
|
|
82
|
+
await fsAdapter.unlink(tempPath);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Best-effort cleanup -- the rename failure itself is the real error to surface.
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async read(filePath) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = await fsAdapter.readFile(filePath);
|
|
93
|
+
return JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (isErrnoException(error) && error.code === "ENOENT")
|
|
97
|
+
return undefined;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VehicleJobWakeBudget } from "./vehicle-jobs.js";
|
|
1
2
|
export type JsonPrimitive = string | number | boolean | null;
|
|
2
3
|
export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
|
|
3
4
|
readonly [key: string]: JsonValue;
|
|
@@ -85,6 +86,12 @@ export interface VehicleFailureDescriptor {
|
|
|
85
86
|
readonly code: string;
|
|
86
87
|
readonly description: string;
|
|
87
88
|
}
|
|
89
|
+
/** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
|
|
90
|
+
export interface VehicleBackgroundCapability {
|
|
91
|
+
readonly supported: true;
|
|
92
|
+
readonly defaultWakeBudget: VehicleJobWakeBudget;
|
|
93
|
+
readonly maxWakeBudget: VehicleJobWakeBudget;
|
|
94
|
+
}
|
|
88
95
|
export interface VehicleOperationDescriptor {
|
|
89
96
|
readonly name: string;
|
|
90
97
|
readonly version: number;
|
|
@@ -98,6 +105,7 @@ export interface VehicleOperationDescriptor {
|
|
|
98
105
|
readonly longRunning: boolean;
|
|
99
106
|
readonly limits: VehicleLimits;
|
|
100
107
|
readonly errors: readonly VehicleFailureDescriptor[];
|
|
108
|
+
readonly background?: VehicleBackgroundCapability;
|
|
101
109
|
}
|
|
102
110
|
export interface VehicleOperation<Input, Output> {
|
|
103
111
|
readonly descriptor: VehicleOperationDescriptor;
|
|
@@ -117,6 +125,7 @@ export interface DefineVehicleOperationOptions<Input, Output> {
|
|
|
117
125
|
readonly longRunning?: boolean;
|
|
118
126
|
readonly limits: VehicleLimits;
|
|
119
127
|
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
128
|
+
readonly background?: VehicleBackgroundCapability;
|
|
120
129
|
}
|
|
121
130
|
export interface VehiclePrincipal {
|
|
122
131
|
readonly id: string;
|
package/dist/vehicle-contract.js
CHANGED
|
@@ -84,6 +84,15 @@ export function defineVehicleOperation(options) {
|
|
|
84
84
|
longRunning: options.longRunning ?? false,
|
|
85
85
|
limits: Object.freeze({ ...options.limits }),
|
|
86
86
|
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
87
|
+
...(options.background
|
|
88
|
+
? {
|
|
89
|
+
background: Object.freeze({
|
|
90
|
+
supported: true,
|
|
91
|
+
defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
|
|
92
|
+
maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
|
|
93
|
+
}),
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
87
96
|
});
|
|
88
97
|
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
89
98
|
}
|
|
@@ -110,9 +119,32 @@ function validateOperationMetadata(options) {
|
|
|
110
119
|
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
111
120
|
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
112
121
|
}
|
|
113
|
-
if (options.idempotency.mode === "keyed" &&
|
|
122
|
+
if (options.idempotency.mode === "keyed" &&
|
|
123
|
+
(!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
|
|
114
124
|
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
115
125
|
}
|
|
126
|
+
if (options.background) {
|
|
127
|
+
if (!options.longRunning) {
|
|
128
|
+
throw new Error("Vehicle operation with a background capability must also set longRunning: true");
|
|
129
|
+
}
|
|
130
|
+
for (const [budgetName, budget] of [
|
|
131
|
+
["defaultWakeBudget", options.background.defaultWakeBudget],
|
|
132
|
+
["maxWakeBudget", options.background.maxWakeBudget],
|
|
133
|
+
]) {
|
|
134
|
+
if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
|
|
135
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
|
|
138
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
|
|
142
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
|
|
143
|
+
}
|
|
144
|
+
if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
|
|
145
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
116
148
|
}
|
|
117
149
|
function cloneJson(value) {
|
|
118
150
|
const serialized = JSON.stringify(value);
|
package/dist/vehicle-errors.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JsonValue, VehicleSchemaIssue } from "./vehicle-contract.js";
|
|
2
2
|
export type VehicleFailureCategory = "validation" | "not_found" | "conflict" | "authorization" | "capacity" | "timeout" | "cancelled" | "unavailable" | "internal";
|
|
3
|
-
export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable";
|
|
3
|
+
export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable" | "background-not-supported" | "job-not-found";
|
|
4
4
|
export interface VehicleRecovery {
|
|
5
5
|
readonly operation?: string;
|
|
6
6
|
readonly message: string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
+
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
3
|
+
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
|
|
4
|
+
export declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "failed", "succeeded"];
|
|
5
|
+
export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
|
|
6
|
+
export declare function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason;
|
|
7
|
+
/** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
|
|
8
|
+
export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
|
|
9
|
+
export interface VehicleJobWakeBudget {
|
|
10
|
+
readonly maxCount: number;
|
|
11
|
+
readonly maxBytes: number;
|
|
12
|
+
}
|
|
13
|
+
export type VehicleJobWakeDropReason = "count-budget-exhausted" | "byte-budget-exhausted" | "deduplicated-transition" | "superseded-by-first-only";
|
|
14
|
+
export interface VehicleJobWakeEntry {
|
|
15
|
+
readonly seq: number;
|
|
16
|
+
readonly at: number;
|
|
17
|
+
readonly progress: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface VehicleJobWakeAppendResult {
|
|
20
|
+
readonly accepted: boolean;
|
|
21
|
+
readonly entry?: VehicleJobWakeEntry;
|
|
22
|
+
readonly dropReason?: VehicleJobWakeDropReason;
|
|
23
|
+
}
|
|
24
|
+
export interface VehicleJobWakeLogOptions {
|
|
25
|
+
readonly notifyMode: VehicleJobNotifyMode;
|
|
26
|
+
readonly budget: VehicleJobWakeBudget;
|
|
27
|
+
/** Defaults to Date.now. */
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
31
|
+
export declare class VehicleJobWakeLog {
|
|
32
|
+
private readonly options;
|
|
33
|
+
private readonly entries;
|
|
34
|
+
private usedBytes;
|
|
35
|
+
private nextSeq;
|
|
36
|
+
private lastHash;
|
|
37
|
+
private acceptedFirst;
|
|
38
|
+
private readonly now;
|
|
39
|
+
constructor(options: VehicleJobWakeLogOptions);
|
|
40
|
+
append(progress: unknown): VehicleJobWakeAppendResult;
|
|
41
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
42
|
+
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
43
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
44
|
+
get cursor(): number;
|
|
45
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
+
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
|
|
3
|
+
export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "failed", "succeeded"];
|
|
4
|
+
export function resolveVehicleJobTerminationReason(candidates) {
|
|
5
|
+
if (candidates.length === 0)
|
|
6
|
+
throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
|
|
7
|
+
for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
|
|
8
|
+
if (candidates.includes(reason))
|
|
9
|
+
return reason;
|
|
10
|
+
}
|
|
11
|
+
throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
|
|
12
|
+
}
|
|
13
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
14
|
+
export class VehicleJobWakeLog {
|
|
15
|
+
options;
|
|
16
|
+
entries = [];
|
|
17
|
+
usedBytes = 0;
|
|
18
|
+
nextSeq = 1;
|
|
19
|
+
lastHash;
|
|
20
|
+
acceptedFirst = false;
|
|
21
|
+
now;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.now = options.now ?? Date.now;
|
|
25
|
+
}
|
|
26
|
+
append(progress) {
|
|
27
|
+
if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
|
|
28
|
+
return { accepted: false, dropReason: "superseded-by-first-only" };
|
|
29
|
+
}
|
|
30
|
+
const serialized = safeJsonStringify(progress);
|
|
31
|
+
if (this.options.notifyMode === "transition") {
|
|
32
|
+
const hash = fnv1aHash(serialized);
|
|
33
|
+
if (hash === this.lastHash)
|
|
34
|
+
return { accepted: false, dropReason: "deduplicated-transition" };
|
|
35
|
+
this.lastHash = hash;
|
|
36
|
+
}
|
|
37
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
38
|
+
if (this.entries.length >= this.options.budget.maxCount)
|
|
39
|
+
return { accepted: false, dropReason: "count-budget-exhausted" };
|
|
40
|
+
if (this.usedBytes + bytes > this.options.budget.maxBytes)
|
|
41
|
+
return { accepted: false, dropReason: "byte-budget-exhausted" };
|
|
42
|
+
const entry = { seq: this.nextSeq++, at: this.now(), progress };
|
|
43
|
+
this.entries.push(entry);
|
|
44
|
+
this.usedBytes += bytes;
|
|
45
|
+
this.acceptedFirst = true;
|
|
46
|
+
return { accepted: true, entry };
|
|
47
|
+
}
|
|
48
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
49
|
+
since(cursor) {
|
|
50
|
+
return this.entries.filter((entry) => entry.seq > cursor);
|
|
51
|
+
}
|
|
52
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
53
|
+
get cursor() {
|
|
54
|
+
return this.nextSeq - 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function safeJsonStringify(value) {
|
|
58
|
+
let serialized;
|
|
59
|
+
try {
|
|
60
|
+
serialized = JSON.stringify(value);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
|
|
64
|
+
}
|
|
65
|
+
if (serialized === undefined)
|
|
66
|
+
throw new Error("Vehicle job progress value is not JSON-serializable");
|
|
67
|
+
return serialized;
|
|
68
|
+
}
|
|
69
|
+
/** Non-cryptographic (FNV-1a) -- dedup only. */
|
|
70
|
+
function fnv1aHash(value) {
|
|
71
|
+
let hash = 0x811c9dc5;
|
|
72
|
+
for (let i = 0; i < value.length; i++) {
|
|
73
|
+
hash ^= value.charCodeAt(i);
|
|
74
|
+
hash = Math.imul(hash, 0x01000193);
|
|
75
|
+
}
|
|
76
|
+
return (hash >>> 0).toString(16);
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Vehicle's runtime-neutral wire contract: operation descriptors, schema codecs, failure shapes. Zero runtime dependencies, zero Bun-specific code -- the one thing every Vehicle client and server package depends on.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/node": "^22.0.0",
|
|
22
|
-
"typescript": "
|
|
22
|
+
"typescript": "^5.9.2"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform atomic JSON persistence -- shared by every Vehicle
|
|
3
|
+
* primitive that needs durable state (Jobs' status file, Watchers'
|
|
4
|
+
* registry) so a crash or concurrent read never observes a half-written
|
|
5
|
+
* file. Lives in vehicle-core (not vehicle-server) but stays fs-free
|
|
6
|
+
* itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
|
|
7
|
+
* matching vehicle-core's own "zero runtime dependencies" invariant --
|
|
8
|
+
* the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
|
|
9
|
+
* functions, this module only sequences them.
|
|
10
|
+
*
|
|
11
|
+
* Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
|
|
12
|
+
* a collision-safe temp filename, injectable fs/now/pid/random for
|
|
13
|
+
* deterministic tests, and explicit Windows-aware rename retry (a plain
|
|
14
|
+
* `fs.rename` onto an existing path can transiently fail on Windows if
|
|
15
|
+
* another process -- antivirus, search indexing -- has the destination
|
|
16
|
+
* briefly open; POSIX rename() has no such failure mode, so retrying by
|
|
17
|
+
* default there would only add latency for a class of error that never
|
|
18
|
+
* happens).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface AtomicJsonFsAdapter {
|
|
22
|
+
writeFile(path: string, data: string): Promise<void>;
|
|
23
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
24
|
+
unlink(path: string): Promise<void>;
|
|
25
|
+
readFile(path: string): Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AtomicJsonWriterOptions {
|
|
29
|
+
readonly fs: AtomicJsonFsAdapter;
|
|
30
|
+
/** Defaults to Date.now. */
|
|
31
|
+
readonly now?: () => number;
|
|
32
|
+
/** Defaults to the current process's pid, or 0 outside Node/Bun. */
|
|
33
|
+
readonly pid?: () => number;
|
|
34
|
+
/** Defaults to a short random hex string. */
|
|
35
|
+
readonly random?: () => string;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a failed rename onto the destination is retried at all.
|
|
38
|
+
* Defaults to `process.platform === "win32"` -- off on Linux/macOS,
|
|
39
|
+
* where a transient rename failure isn't a real failure mode.
|
|
40
|
+
*/
|
|
41
|
+
readonly retryRename?: boolean;
|
|
42
|
+
/** Error codes on `rename` worth retrying. Defaults to ["EPERM", "EBUSY", "EACCES"] (the documented Windows file-lock codes). */
|
|
43
|
+
readonly retryRenameErrors?: readonly string[];
|
|
44
|
+
/** Delay before each retry attempt, in order. Defaults to [50, 100, 200]. */
|
|
45
|
+
readonly retryDelaysMs?: readonly number[];
|
|
46
|
+
/** Injectable so a test doesn't have to sleep for real. Defaults to setTimeout. */
|
|
47
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AtomicJsonWriter {
|
|
51
|
+
/** Serializes `value` to JSON and writes it to `filePath` atomically (temp file + rename). */
|
|
52
|
+
write(filePath: string, value: unknown): Promise<void>;
|
|
53
|
+
/** Reads and JSON.parses `filePath`. Returns undefined if the file doesn't exist (fs.readFile throws ENOENT); rethrows any other error. */
|
|
54
|
+
read(filePath: string): Promise<unknown | undefined>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultPlatformIsWindows(): boolean {
|
|
58
|
+
return typeof process !== "undefined" && process.platform === "win32";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultPid(): number {
|
|
62
|
+
return typeof process !== "undefined" ? process.pid : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultRandom(): string {
|
|
66
|
+
return Math.random().toString(36).slice(2, 10);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
70
|
+
return error instanceof Error && "code" in error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dirAndBase(filePath: string): { readonly dir: string; readonly base: string } {
|
|
74
|
+
const separator = filePath.lastIndexOf("/");
|
|
75
|
+
if (separator === -1) return { dir: ".", base: filePath };
|
|
76
|
+
return { dir: filePath.slice(0, separator) || "/", base: filePath.slice(separator + 1) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createAtomicJsonWriter(options: AtomicJsonWriterOptions): AtomicJsonWriter {
|
|
80
|
+
const fsAdapter = options.fs;
|
|
81
|
+
const now = options.now ?? Date.now;
|
|
82
|
+
const pid = options.pid ?? defaultPid;
|
|
83
|
+
const random = options.random ?? defaultRandom;
|
|
84
|
+
const retryRename = options.retryRename ?? defaultPlatformIsWindows();
|
|
85
|
+
const retryRenameErrors = options.retryRenameErrors ?? ["EPERM", "EBUSY", "EACCES"];
|
|
86
|
+
const retryDelaysMs = options.retryDelaysMs ?? [50, 100, 200];
|
|
87
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
88
|
+
|
|
89
|
+
async function renameWithRetry(tempPath: string, filePath: string): Promise<void> {
|
|
90
|
+
let attempt = 0;
|
|
91
|
+
for (;;) {
|
|
92
|
+
try {
|
|
93
|
+
await fsAdapter.rename(tempPath, filePath);
|
|
94
|
+
return;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const retryable = retryRename && isErrnoException(error) && !!error.code && retryRenameErrors.includes(error.code);
|
|
97
|
+
if (!retryable || attempt >= retryDelaysMs.length) throw error;
|
|
98
|
+
await sleep(retryDelaysMs[attempt] ?? 0);
|
|
99
|
+
attempt++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
async write(filePath, value) {
|
|
106
|
+
let serialized: string | undefined;
|
|
107
|
+
try {
|
|
108
|
+
serialized = JSON.stringify(value);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`, { cause: error });
|
|
111
|
+
}
|
|
112
|
+
if (serialized === undefined) throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
|
|
113
|
+
const { dir, base } = dirAndBase(filePath);
|
|
114
|
+
const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
|
|
115
|
+
await fsAdapter.writeFile(tempPath, serialized);
|
|
116
|
+
try {
|
|
117
|
+
await renameWithRetry(tempPath, filePath);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
try {
|
|
120
|
+
await fsAdapter.unlink(tempPath);
|
|
121
|
+
} catch {
|
|
122
|
+
// Best-effort cleanup -- the rename failure itself is the real error to surface.
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
async read(filePath) {
|
|
128
|
+
try {
|
|
129
|
+
const raw = await fsAdapter.readFile(filePath);
|
|
130
|
+
return JSON.parse(raw) as unknown;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isErrnoException(error) && error.code === "ENOENT") return undefined;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/index.ts
CHANGED
package/src/vehicle-contract.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { VehicleJobWakeBudget } from "./vehicle-jobs.js";
|
|
2
|
+
|
|
1
3
|
export type JsonPrimitive = string | number | boolean | null;
|
|
2
4
|
export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
|
|
3
5
|
export type JsonSchema = Readonly<Record<string, JsonValue>>;
|
|
@@ -36,7 +38,10 @@ export interface LooseObjectProperty {
|
|
|
36
38
|
* consumer projecting a plain-object input onto a VehicleOperation needs the
|
|
37
39
|
* same required/enum checks; this is that check written once.
|
|
38
40
|
*/
|
|
39
|
-
export function defineLooseObjectSchema(
|
|
41
|
+
export function defineLooseObjectSchema(
|
|
42
|
+
properties: Record<string, LooseObjectProperty>,
|
|
43
|
+
required: readonly string[] = [],
|
|
44
|
+
): VehicleSchemaCodec<Record<string, unknown>> {
|
|
40
45
|
return defineVehicleSchema<Record<string, unknown>>({
|
|
41
46
|
// LooseObjectProperty's named fields (type, enum) are all JSON-value-shaped
|
|
42
47
|
// at runtime, but TypeScript's structural check against the recursive
|
|
@@ -134,6 +139,13 @@ export interface VehicleFailureDescriptor {
|
|
|
134
139
|
readonly description: string;
|
|
135
140
|
}
|
|
136
141
|
|
|
142
|
+
/** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
|
|
143
|
+
export interface VehicleBackgroundCapability {
|
|
144
|
+
readonly supported: true;
|
|
145
|
+
readonly defaultWakeBudget: VehicleJobWakeBudget;
|
|
146
|
+
readonly maxWakeBudget: VehicleJobWakeBudget;
|
|
147
|
+
}
|
|
148
|
+
|
|
137
149
|
export interface VehicleOperationDescriptor {
|
|
138
150
|
readonly name: string;
|
|
139
151
|
readonly version: number;
|
|
@@ -147,6 +159,7 @@ export interface VehicleOperationDescriptor {
|
|
|
147
159
|
readonly longRunning: boolean;
|
|
148
160
|
readonly limits: VehicleLimits;
|
|
149
161
|
readonly errors: readonly VehicleFailureDescriptor[];
|
|
162
|
+
readonly background?: VehicleBackgroundCapability;
|
|
150
163
|
}
|
|
151
164
|
|
|
152
165
|
export interface VehicleOperation<Input, Output> {
|
|
@@ -168,6 +181,7 @@ export interface DefineVehicleOperationOptions<Input, Output> {
|
|
|
168
181
|
readonly longRunning?: boolean;
|
|
169
182
|
readonly limits: VehicleLimits;
|
|
170
183
|
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
184
|
+
readonly background?: VehicleBackgroundCapability;
|
|
171
185
|
}
|
|
172
186
|
|
|
173
187
|
export interface VehiclePrincipal {
|
|
@@ -236,12 +250,7 @@ export interface VehicleManifest extends VehicleManifestIdentity {
|
|
|
236
250
|
|
|
237
251
|
export interface VehicleClient {
|
|
238
252
|
manifest(): Promise<VehicleManifest>;
|
|
239
|
-
invoke<Output = unknown>(
|
|
240
|
-
name: string,
|
|
241
|
-
version: number,
|
|
242
|
-
input: unknown,
|
|
243
|
-
options?: VehicleInvocationOptions,
|
|
244
|
-
): Promise<Output>;
|
|
253
|
+
invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
|
|
245
254
|
close(): Promise<void>;
|
|
246
255
|
}
|
|
247
256
|
|
|
@@ -262,6 +271,15 @@ export function defineVehicleOperation<Input, Output>(
|
|
|
262
271
|
longRunning: options.longRunning ?? false,
|
|
263
272
|
limits: Object.freeze({ ...options.limits }),
|
|
264
273
|
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
274
|
+
...(options.background
|
|
275
|
+
? {
|
|
276
|
+
background: Object.freeze({
|
|
277
|
+
supported: true as const,
|
|
278
|
+
defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
|
|
279
|
+
maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
|
|
280
|
+
}),
|
|
281
|
+
}
|
|
282
|
+
: {}),
|
|
265
283
|
});
|
|
266
284
|
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
267
285
|
}
|
|
@@ -289,9 +307,34 @@ function validateOperationMetadata<Input, Output>(options: DefineVehicleOperatio
|
|
|
289
307
|
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
290
308
|
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
291
309
|
}
|
|
292
|
-
if (
|
|
310
|
+
if (
|
|
311
|
+
options.idempotency.mode === "keyed" &&
|
|
312
|
+
(!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)
|
|
313
|
+
) {
|
|
293
314
|
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
294
315
|
}
|
|
316
|
+
if (options.background) {
|
|
317
|
+
if (!options.longRunning) {
|
|
318
|
+
throw new Error("Vehicle operation with a background capability must also set longRunning: true");
|
|
319
|
+
}
|
|
320
|
+
for (const [budgetName, budget] of [
|
|
321
|
+
["defaultWakeBudget", options.background.defaultWakeBudget],
|
|
322
|
+
["maxWakeBudget", options.background.maxWakeBudget],
|
|
323
|
+
] as const) {
|
|
324
|
+
if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
|
|
325
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
|
|
326
|
+
}
|
|
327
|
+
if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
|
|
328
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
|
|
332
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
|
|
333
|
+
}
|
|
334
|
+
if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
|
|
335
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
295
338
|
}
|
|
296
339
|
|
|
297
340
|
function cloneJson<T extends JsonValue>(value: T): T {
|
package/src/vehicle-errors.ts
CHANGED
|
@@ -25,7 +25,9 @@ export type VehicleCoreErrorCode =
|
|
|
25
25
|
| "policy-failed"
|
|
26
26
|
| "idempotency-key-required"
|
|
27
27
|
| "client-closed"
|
|
28
|
-
| "operation-unavailable"
|
|
28
|
+
| "operation-unavailable"
|
|
29
|
+
| "background-not-supported"
|
|
30
|
+
| "job-not-found";
|
|
29
31
|
|
|
30
32
|
export interface VehicleRecovery {
|
|
31
33
|
readonly operation?: string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
+
|
|
3
|
+
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
4
|
+
|
|
5
|
+
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
|
|
6
|
+
export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "failed", "succeeded"] as const;
|
|
7
|
+
export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
|
|
8
|
+
|
|
9
|
+
export function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason {
|
|
10
|
+
if (candidates.length === 0) throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
|
|
11
|
+
for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
|
|
12
|
+
if (candidates.includes(reason)) return reason;
|
|
13
|
+
}
|
|
14
|
+
throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
|
|
18
|
+
export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
|
|
19
|
+
|
|
20
|
+
export interface VehicleJobWakeBudget {
|
|
21
|
+
readonly maxCount: number;
|
|
22
|
+
readonly maxBytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type VehicleJobWakeDropReason =
|
|
26
|
+
| "count-budget-exhausted"
|
|
27
|
+
| "byte-budget-exhausted"
|
|
28
|
+
| "deduplicated-transition"
|
|
29
|
+
| "superseded-by-first-only";
|
|
30
|
+
|
|
31
|
+
export interface VehicleJobWakeEntry {
|
|
32
|
+
readonly seq: number;
|
|
33
|
+
readonly at: number;
|
|
34
|
+
readonly progress: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VehicleJobWakeAppendResult {
|
|
38
|
+
readonly accepted: boolean;
|
|
39
|
+
readonly entry?: VehicleJobWakeEntry;
|
|
40
|
+
readonly dropReason?: VehicleJobWakeDropReason;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface VehicleJobWakeLogOptions {
|
|
44
|
+
readonly notifyMode: VehicleJobNotifyMode;
|
|
45
|
+
readonly budget: VehicleJobWakeBudget;
|
|
46
|
+
/** Defaults to Date.now. */
|
|
47
|
+
readonly now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
51
|
+
export class VehicleJobWakeLog {
|
|
52
|
+
private readonly entries: VehicleJobWakeEntry[] = [];
|
|
53
|
+
private usedBytes = 0;
|
|
54
|
+
private nextSeq = 1;
|
|
55
|
+
private lastHash: string | undefined;
|
|
56
|
+
private acceptedFirst = false;
|
|
57
|
+
private readonly now: () => number;
|
|
58
|
+
|
|
59
|
+
constructor(private readonly options: VehicleJobWakeLogOptions) {
|
|
60
|
+
this.now = options.now ?? Date.now;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
append(progress: unknown): VehicleJobWakeAppendResult {
|
|
64
|
+
if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
|
|
65
|
+
return { accepted: false, dropReason: "superseded-by-first-only" };
|
|
66
|
+
}
|
|
67
|
+
const serialized = safeJsonStringify(progress);
|
|
68
|
+
if (this.options.notifyMode === "transition") {
|
|
69
|
+
const hash = fnv1aHash(serialized);
|
|
70
|
+
if (hash === this.lastHash) return { accepted: false, dropReason: "deduplicated-transition" };
|
|
71
|
+
this.lastHash = hash;
|
|
72
|
+
}
|
|
73
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
74
|
+
if (this.entries.length >= this.options.budget.maxCount) return { accepted: false, dropReason: "count-budget-exhausted" };
|
|
75
|
+
if (this.usedBytes + bytes > this.options.budget.maxBytes) return { accepted: false, dropReason: "byte-budget-exhausted" };
|
|
76
|
+
|
|
77
|
+
const entry: VehicleJobWakeEntry = { seq: this.nextSeq++, at: this.now(), progress };
|
|
78
|
+
this.entries.push(entry);
|
|
79
|
+
this.usedBytes += bytes;
|
|
80
|
+
this.acceptedFirst = true;
|
|
81
|
+
return { accepted: true, entry };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
85
|
+
since(cursor: number): readonly VehicleJobWakeEntry[] {
|
|
86
|
+
return this.entries.filter((entry) => entry.seq > cursor);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
90
|
+
get cursor(): number {
|
|
91
|
+
return this.nextSeq - 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeJsonStringify(value: unknown): string {
|
|
96
|
+
let serialized: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
serialized = JSON.stringify(value);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
|
|
101
|
+
}
|
|
102
|
+
if (serialized === undefined) throw new Error("Vehicle job progress value is not JSON-serializable");
|
|
103
|
+
return serialized;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Non-cryptographic (FNV-1a) -- dedup only. */
|
|
107
|
+
function fnv1aHash(value: string): string {
|
|
108
|
+
let hash = 0x811c9dc5;
|
|
109
|
+
for (let i = 0; i < value.length; i++) {
|
|
110
|
+
hash ^= value.charCodeAt(i);
|
|
111
|
+
hash = Math.imul(hash, 0x01000193);
|
|
112
|
+
}
|
|
113
|
+
return (hash >>> 0).toString(16);
|
|
114
|
+
}
|