@danypops/vehicle-core 0.15.0 → 0.17.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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/vehicle-approvals.js +36 -5
- package/dist/vehicle-contract.d.ts +30 -6
- package/dist/vehicle-contract.js +40 -6
- package/dist/vehicle-errors.d.ts +12 -1
- package/dist/vehicle-errors.js +20 -0
- package/dist/vehicle-idempotency.d.ts +66 -0
- package/dist/vehicle-idempotency.js +42 -0
- package/dist/vehicle-jobs.d.ts +45 -0
- package/dist/vehicle-scheduler.d.ts +17 -1
- package/dist/vehicle-scheduler.js +29 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/vehicle-approvals.ts +36 -5
- package/src/vehicle-contract.ts +66 -8
- package/src/vehicle-errors.ts +22 -0
- package/src/vehicle-idempotency.ts +97 -0
- package/src/vehicle-jobs.ts +50 -0
- package/src/vehicle-scheduler.ts +26 -1
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./atomic-json.js";
|
|
|
2
2
|
export * from "./vehicle-approvals.js";
|
|
3
3
|
export * from "./vehicle-contract.js";
|
|
4
4
|
export * from "./vehicle-errors.js";
|
|
5
|
+
export * from "./vehicle-idempotency.js";
|
|
5
6
|
export * from "./vehicle-jobs.js";
|
|
6
7
|
export * from "./vehicle-scheduler.js";
|
|
7
8
|
export * from "./vehicle-watchers.js";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./atomic-json.js";
|
|
|
2
2
|
export * from "./vehicle-approvals.js";
|
|
3
3
|
export * from "./vehicle-contract.js";
|
|
4
4
|
export * from "./vehicle-errors.js";
|
|
5
|
+
export * from "./vehicle-idempotency.js";
|
|
5
6
|
export * from "./vehicle-jobs.js";
|
|
6
7
|
export * from "./vehicle-scheduler.js";
|
|
7
8
|
export * from "./vehicle-watchers.js";
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
1
|
+
import { defineVehicleEvent, defineVehicleSchema, VEHICLE_EFFECTS } from "./vehicle-contract.js";
|
|
2
|
+
/** A sha256 hex digest: exactly 64 lowercase hex characters -- matches hashApprovalInput's own (vehicle-server) output shape. */
|
|
3
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
4
|
+
function isVehicleEffect(value) {
|
|
5
|
+
return typeof value === "string" && VEHICLE_EFFECTS.includes(value);
|
|
6
|
+
}
|
|
7
|
+
function isVehiclePrincipal(value) {
|
|
8
|
+
if (typeof value !== "object" || value === null)
|
|
9
|
+
return false;
|
|
10
|
+
const candidate = value;
|
|
11
|
+
if (typeof candidate.id !== "string")
|
|
12
|
+
return false;
|
|
13
|
+
if (candidate.claims === undefined)
|
|
14
|
+
return true;
|
|
15
|
+
return typeof candidate.claims === "object" && candidate.claims !== null && !Array.isArray(candidate.claims);
|
|
16
|
+
}
|
|
2
17
|
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
3
18
|
export const DEFAULT_APPROVAL_EFFECTS = ["destructive", "open-world"];
|
|
4
19
|
/**
|
|
@@ -21,7 +36,8 @@ const requestedPayloadSchema = defineVehicleSchema({
|
|
|
21
36
|
requestId: { type: "string" },
|
|
22
37
|
operationName: { type: "string" },
|
|
23
38
|
operationVersion: { type: "number" },
|
|
24
|
-
effect: { type: "string" },
|
|
39
|
+
effect: { type: "string", enum: [...VEHICLE_EFFECTS] },
|
|
40
|
+
principal: { type: "object" },
|
|
25
41
|
requestedAt: { type: "number" },
|
|
26
42
|
expiresAt: { type: "number" },
|
|
27
43
|
inputHash: { type: "string" },
|
|
@@ -36,12 +52,20 @@ const requestedPayloadSchema = defineVehicleSchema({
|
|
|
36
52
|
if (typeof row.requestId !== "string" ||
|
|
37
53
|
typeof row.operationName !== "string" ||
|
|
38
54
|
typeof row.operationVersion !== "number" ||
|
|
39
|
-
|
|
55
|
+
!Number.isInteger(row.operationVersion) ||
|
|
56
|
+
row.operationVersion < 1 ||
|
|
57
|
+
!isVehicleEffect(row.effect) ||
|
|
40
58
|
typeof row.requestedAt !== "number" ||
|
|
59
|
+
!Number.isFinite(row.requestedAt) ||
|
|
41
60
|
typeof row.expiresAt !== "number" ||
|
|
42
|
-
|
|
61
|
+
!Number.isFinite(row.expiresAt) ||
|
|
62
|
+
typeof row.inputHash !== "string" ||
|
|
63
|
+
!SHA256_HEX_PATTERN.test(row.inputHash)) {
|
|
43
64
|
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
44
65
|
}
|
|
66
|
+
if (row.principal !== undefined && !isVehiclePrincipal(row.principal)) {
|
|
67
|
+
return { success: false, issues: [{ path: ["principal"], message: "invalid approval request principal" }] };
|
|
68
|
+
}
|
|
45
69
|
return { success: true, value: row };
|
|
46
70
|
},
|
|
47
71
|
});
|
|
@@ -64,9 +88,16 @@ const resolvedPayloadSchema = defineVehicleSchema({
|
|
|
64
88
|
const row = value;
|
|
65
89
|
if (typeof row.requestId !== "string" ||
|
|
66
90
|
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
67
|
-
typeof row.decidedAt !== "number"
|
|
91
|
+
typeof row.decidedAt !== "number" ||
|
|
92
|
+
!Number.isFinite(row.decidedAt)) {
|
|
68
93
|
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
69
94
|
}
|
|
95
|
+
if (row.decidedBy !== undefined && typeof row.decidedBy !== "string") {
|
|
96
|
+
return { success: false, issues: [{ path: ["decidedBy"], message: "decidedBy must be a string" }] };
|
|
97
|
+
}
|
|
98
|
+
if (row.comment !== undefined && typeof row.comment !== "string") {
|
|
99
|
+
return { success: false, issues: [{ path: ["comment"], message: "comment must be a string" }] };
|
|
100
|
+
}
|
|
70
101
|
return { success: true, value: row };
|
|
71
102
|
},
|
|
72
103
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { VehicleJobWakeBudget } from "./vehicle-jobs.js";
|
|
1
|
+
import type { VehicleJobSnapshot, VehicleJobSubmitOptions, VehicleJobSubmitResult, VehicleJobTailResult, VehicleJobWakeBudget } from "./vehicle-jobs.js";
|
|
2
2
|
export type JsonPrimitive = string | number | boolean | null;
|
|
3
3
|
export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
|
|
4
4
|
readonly [key: string]: JsonValue;
|
|
@@ -46,10 +46,14 @@ export interface LooseObjectProperty {
|
|
|
46
46
|
/**
|
|
47
47
|
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
48
48
|
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
49
|
-
* enforced at runtime -- so a declared `enum`
|
|
50
|
-
* real, or it's a documentation gesture, not an
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
50
|
+
* has to be checked here for real, or it's a documentation gesture, not an
|
|
51
|
+
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
52
|
+
* had before: it always advertised `additionalProperties: false` and a
|
|
53
|
+
* per-property `type`, while safeParse only ever checked `required` and
|
|
54
|
+
* `enum`). Every consumer projecting a plain-object input onto a
|
|
55
|
+
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
56
|
+
* is that check written once.
|
|
53
57
|
*/
|
|
54
58
|
export declare function defineLooseObjectSchema(properties: Record<string, LooseObjectProperty>, required?: readonly string[]): VehicleSchemaCodec<Record<string, unknown>>;
|
|
55
59
|
/** Accepts any value unvalidated -- for an operation whose output shape isn't worth a dedicated schema (an internal/low-stakes result, or one already validated upstream by the domain logic it wraps). */
|
|
@@ -86,7 +90,9 @@ export interface WithVehicleContent {
|
|
|
86
90
|
* risk forwarding partial/garbled blocks.
|
|
87
91
|
*/
|
|
88
92
|
export declare function extractVehicleContent(output: unknown): readonly VehicleContentBlock[] | undefined;
|
|
89
|
-
|
|
93
|
+
/** Canonical list VehicleEffect is itself derived from, so a runtime discriminator check (e.g. a persisted/wire VehicleApprovalRequest's own `effect` field) has one real source to check against instead of a second, driftable hardcoded list. */
|
|
94
|
+
export declare const VEHICLE_EFFECTS: readonly ["read", "local-write", "external-write", "destructive", "open-world"];
|
|
95
|
+
export type VehicleEffect = (typeof VEHICLE_EFFECTS)[number];
|
|
90
96
|
export type VehicleIdempotency = {
|
|
91
97
|
readonly mode: "safe";
|
|
92
98
|
} | {
|
|
@@ -324,6 +330,24 @@ export interface VehicleClient {
|
|
|
324
330
|
manifest(): Promise<VehicleManifest>;
|
|
325
331
|
invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
|
|
326
332
|
close(): Promise<void>;
|
|
333
|
+
/**
|
|
334
|
+
* Vehicle Jobs -- submit a background-capable operation (one whose descriptor declares
|
|
335
|
+
* `background`, see {@link VehicleBackgroundCapability}) and get its jobId back immediately,
|
|
336
|
+
* without waiting for the operation itself to make any progress. Optional: a client that
|
|
337
|
+
* never talks to a job-capable Vehicle (or a hand-rolled test double) simply omits these five
|
|
338
|
+
* methods, exactly like this interface's own long-standing `subscribe()`-shaped extras --
|
|
339
|
+
* present on both LocalVehicleClient and RemoteVehicleClient, absent elsewhere. Feature-detect
|
|
340
|
+
* via the operation's own manifest `background` capability, not by probing for these methods.
|
|
341
|
+
*/
|
|
342
|
+
submitJob?(name: string, version: number, input: unknown, options?: VehicleJobSubmitOptions): Promise<VehicleJobSubmitResult>;
|
|
343
|
+
/** Never blocks -- current status, plus output/error once terminal. */
|
|
344
|
+
pollJob?(jobId: string): Promise<VehicleJobSnapshot>;
|
|
345
|
+
/** Progress entries strictly after `cursor` (0 for everything so far), plus the next cursor. Never blocks. */
|
|
346
|
+
tailJob?(jobId: string, cursor?: number): Promise<VehicleJobTailResult>;
|
|
347
|
+
/** Pushes new input to an already-running job's handler, if it opted in via context.steerInputs. */
|
|
348
|
+
steerJob?(jobId: string, input: unknown): Promise<void>;
|
|
349
|
+
/** Best-effort cancellation of a still-running job -- a no-op against an already-terminal one. */
|
|
350
|
+
cancelJob?(jobId: string): Promise<void>;
|
|
327
351
|
}
|
|
328
352
|
export declare function defineVehicleOperation<Input, Output>(options: DefineVehicleOperationOptions<Input, Output>): VehicleOperation<Input, Output>;
|
|
329
353
|
export declare function bindVehicleOperation<Input, Output>(operation: VehicleOperation<Input, Output>, bind: () => VehicleOperationHandler<Input, Output>): VehicleOperationBinding<Input, Output>;
|
package/dist/vehicle-contract.js
CHANGED
|
@@ -26,13 +26,38 @@ export function defineVehicleSchema(codec) {
|
|
|
26
26
|
safeParse: codec.safeParse,
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
+
/** JSON Schema's own `type` keyword vocabulary -- checked for real below so a declared `type: "number"` (say) can't silently accept a string forever. `"integer"` additionally requires no fractional part, matching JSON Schema's own distinction from plain `"number"`. An unrecognized type name is treated as "anything goes" (matches passthroughVehicleSchema's own precedent for a shape not worth strictly enforcing) rather than rejecting every input outright for what would otherwise be a schema-authoring typo. */
|
|
30
|
+
function matchesLooseObjectPropertyType(type, value) {
|
|
31
|
+
switch (type) {
|
|
32
|
+
case "string":
|
|
33
|
+
return typeof value === "string";
|
|
34
|
+
case "number":
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
36
|
+
case "integer":
|
|
37
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
38
|
+
case "boolean":
|
|
39
|
+
return typeof value === "boolean";
|
|
40
|
+
case "object":
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
case "array":
|
|
43
|
+
return Array.isArray(value);
|
|
44
|
+
case "null":
|
|
45
|
+
return value === null;
|
|
46
|
+
default:
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
29
50
|
/**
|
|
30
51
|
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
31
52
|
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
32
|
-
* enforced at runtime -- so a declared `enum`
|
|
33
|
-
* real, or it's a documentation gesture, not an
|
|
34
|
-
*
|
|
35
|
-
*
|
|
53
|
+
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
54
|
+
* has to be checked here for real, or it's a documentation gesture, not an
|
|
55
|
+
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
56
|
+
* had before: it always advertised `additionalProperties: false` and a
|
|
57
|
+
* per-property `type`, while safeParse only ever checked `required` and
|
|
58
|
+
* `enum`). Every consumer projecting a plain-object input onto a
|
|
59
|
+
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
60
|
+
* is that check written once.
|
|
36
61
|
*/
|
|
37
62
|
export function defineLooseObjectSchema(properties, required = []) {
|
|
38
63
|
return defineVehicleSchema({
|
|
@@ -50,10 +75,17 @@ export function defineLooseObjectSchema(properties, required = []) {
|
|
|
50
75
|
if (!(key in input))
|
|
51
76
|
return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
52
77
|
}
|
|
78
|
+
for (const key of Object.keys(input)) {
|
|
79
|
+
if (!(key in properties))
|
|
80
|
+
return { success: false, issues: [{ path: [key], message: `${key} is not a recognized property` }] };
|
|
81
|
+
}
|
|
53
82
|
for (const [key, schema] of Object.entries(properties)) {
|
|
54
|
-
if (!
|
|
83
|
+
if (!(key in input))
|
|
55
84
|
continue;
|
|
56
|
-
if (!schema.
|
|
85
|
+
if (!matchesLooseObjectPropertyType(schema.type, input[key])) {
|
|
86
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be of type ${schema.type}` }] };
|
|
87
|
+
}
|
|
88
|
+
if (schema.enum && !schema.enum.includes(input[key])) {
|
|
57
89
|
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
58
90
|
}
|
|
59
91
|
}
|
|
@@ -91,6 +123,8 @@ export function extractVehicleContent(output) {
|
|
|
91
123
|
}
|
|
92
124
|
return blocks;
|
|
93
125
|
}
|
|
126
|
+
/** Canonical list VehicleEffect is itself derived from, so a runtime discriminator check (e.g. a persisted/wire VehicleApprovalRequest's own `effect` field) has one real source to check against instead of a second, driftable hardcoded list. */
|
|
127
|
+
export const VEHICLE_EFFECTS = ["read", "local-write", "external-write", "destructive", "open-world"];
|
|
94
128
|
function validateEventMetadata(options) {
|
|
95
129
|
if (!options.name.trim())
|
|
96
130
|
throw new Error("Vehicle event name must not be empty");
|
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" | "background-not-supported" | "job-not-found" | "job-not-steerable" | "job-steer-queue-full";
|
|
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" | "idempotency-conflict" | "client-closed" | "operation-unavailable" | "background-not-supported" | "job-not-found" | "job-not-steerable" | "job-steer-queue-full";
|
|
4
4
|
export interface VehicleRecovery {
|
|
5
5
|
readonly operation?: string;
|
|
6
6
|
readonly message: string;
|
|
@@ -61,6 +61,17 @@ export declare class VehicleError extends Error {
|
|
|
61
61
|
}
|
|
62
62
|
/** Recognizes VehicleError instances across duplicated package installations in one process. */
|
|
63
63
|
export declare function isVehicleError(value: unknown): value is VehicleError;
|
|
64
|
+
/**
|
|
65
|
+
* Reconstructs a throwable VehicleError from a previously-serialized VehicleFailure -- the inverse
|
|
66
|
+
* of VehicleError.prototype.toFailure(), needed anywhere a wire-safe failure gets replayed as a
|
|
67
|
+
* real rejection later (e.g. VehicleIdempotencyPolicy replaying a settled failed receipt to a
|
|
68
|
+
* second caller reusing the same idempotency key). Lossy on purpose: a VehicleFailure never
|
|
69
|
+
* carries the original `cause` (toFailure() already reduced it to an optional bounded
|
|
70
|
+
* causeMessage per the throw site's own exposeCause choice), so the reconstructed error has no
|
|
71
|
+
* cause at all rather than fabricating one -- a replayed failure only needs to match the original
|
|
72
|
+
* code/category/message/details a caller would react to, not its internal cause chain.
|
|
73
|
+
*/
|
|
74
|
+
export declare function vehicleErrorFromFailure(failure: VehicleFailure): VehicleError;
|
|
64
75
|
/** Extracts a bounded, wire-safe message from an unknown cause -- never the full stack trace, never an unbounded payload. */
|
|
65
76
|
export declare function boundedCauseMessage(cause: unknown): string | undefined;
|
|
66
77
|
export declare function boundedValidationDetails(issues: readonly VehicleSchemaIssue[] | undefined): JsonValue | undefined;
|
package/dist/vehicle-errors.js
CHANGED
|
@@ -60,6 +60,26 @@ export class VehicleError extends Error {
|
|
|
60
60
|
export function isVehicleError(value) {
|
|
61
61
|
return value instanceof Error && Reflect.get(value, VEHICLE_ERROR_BRAND) === true;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Reconstructs a throwable VehicleError from a previously-serialized VehicleFailure -- the inverse
|
|
65
|
+
* of VehicleError.prototype.toFailure(), needed anywhere a wire-safe failure gets replayed as a
|
|
66
|
+
* real rejection later (e.g. VehicleIdempotencyPolicy replaying a settled failed receipt to a
|
|
67
|
+
* second caller reusing the same idempotency key). Lossy on purpose: a VehicleFailure never
|
|
68
|
+
* carries the original `cause` (toFailure() already reduced it to an optional bounded
|
|
69
|
+
* causeMessage per the throw site's own exposeCause choice), so the reconstructed error has no
|
|
70
|
+
* cause at all rather than fabricating one -- a replayed failure only needs to match the original
|
|
71
|
+
* code/category/message/details a caller would react to, not its internal cause chain.
|
|
72
|
+
*/
|
|
73
|
+
export function vehicleErrorFromFailure(failure) {
|
|
74
|
+
return new VehicleError(failure.code, failure.message, {
|
|
75
|
+
category: failure.category,
|
|
76
|
+
retryable: failure.retryable,
|
|
77
|
+
retryAfterMs: failure.retryAfterMs,
|
|
78
|
+
recovery: failure.recovery,
|
|
79
|
+
details: failure.details,
|
|
80
|
+
operationId: failure.operationId,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
63
83
|
const MAX_CAUSE_MESSAGE_LENGTH = 500;
|
|
64
84
|
/** Extracts a bounded, wire-safe message from an unknown cause -- never the full stack trace, never an unbounded payload. */
|
|
65
85
|
export function boundedCauseMessage(cause) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pieces of Vehicle's bounded keyed-idempotency replay policy: the settled-receipt shape and
|
|
3
|
+
* its eviction-selection rule. Orchestration (in-flight dedup, persistence, fail-closed conflict
|
|
4
|
+
* detection) lives in vehicle-server's VehicleIdempotencyPolicy -- mirrors the vehicle-jobs.js /
|
|
5
|
+
* VehicleJobStore split (a pure, independently-testable bounded-retention rule here; the stateful
|
|
6
|
+
* store that calls it lives in vehicle-server).
|
|
7
|
+
*/
|
|
8
|
+
import type { VehicleFailure } from "./vehicle-errors.js";
|
|
9
|
+
/** A settled keyed-idempotency outcome -- exactly what gets replayed to a caller reusing the same key. Never carries the original request's raw input (only its hash is ever retained, see VehicleIdempotencyReceipt) and never a credential: `output`/`failure` are already what the operation would hand back to any caller, the same wire-safe boundary VehicleJobPersistedRecord's own `output`/`error` fields already cross. */
|
|
10
|
+
export type VehicleIdempotencyResult = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly output: unknown;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly failure: VehicleFailure;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* One settled keyed-idempotency receipt. Deliberately excludes the original request's raw input --
|
|
19
|
+
* `inputHash` is the only trace of it retained, so a persisted receipt can never leak whatever the
|
|
20
|
+
* caller originally sent (which may carry sensitive arguments the operation's own output does not).
|
|
21
|
+
* A still-in-flight (pending) request has no receipt yet -- that bookkeeping is transient,
|
|
22
|
+
* in-memory-only state in VehicleIdempotencyPolicy, deliberately never a candidate for persistence
|
|
23
|
+
* or eviction (mirrors "a running job is never a candidate" in vehicle-jobs.js's own job eviction).
|
|
24
|
+
*/
|
|
25
|
+
export interface VehicleIdempotencyReceipt {
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly operationName: string;
|
|
28
|
+
readonly operationVersion: number;
|
|
29
|
+
readonly inputHash: string;
|
|
30
|
+
readonly settledAt: number;
|
|
31
|
+
/** settledAt + the descriptor's own keyed retentionMs at the time this receipt settled. A receipt past this is no longer a valid replay -- see selectVehicleIdempotencyReceiptsForEviction. */
|
|
32
|
+
readonly expiresAt: number;
|
|
33
|
+
readonly result: VehicleIdempotencyResult;
|
|
34
|
+
/** Approximate serialized size of `result`, used only to enforce maxTotalBytes -- never exact byte-for-byte, matching every other Vehicle capacity bound's own "good enough to stay bounded" precedent (e.g. enforcePayloadSize). */
|
|
35
|
+
readonly sizeBytes: number;
|
|
36
|
+
}
|
|
37
|
+
/** Minimal shape selectVehicleIdempotencyReceiptsForEviction needs -- kept separate from VehicleIdempotencyReceipt's own `result` so a sweep never has to touch (or risk logging) the actual settled output/failure it's merely deciding whether to keep. */
|
|
38
|
+
export interface VehicleIdempotencyEvictionCandidate {
|
|
39
|
+
readonly key: string;
|
|
40
|
+
readonly settledAt: number;
|
|
41
|
+
readonly expiresAt: number;
|
|
42
|
+
readonly sizeBytes: number;
|
|
43
|
+
}
|
|
44
|
+
export interface VehicleIdempotencyRetentionOptions {
|
|
45
|
+
/** Hard cap on total retained settled receipts. */
|
|
46
|
+
readonly maxEntries: number;
|
|
47
|
+
/** Hard cap on the sum of every retained receipt's own sizeBytes. */
|
|
48
|
+
readonly maxTotalBytes: number;
|
|
49
|
+
readonly now: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pure eviction-selection policy for settled keyed-idempotency receipts, independently testable
|
|
53
|
+
* from VehicleIdempotencyPolicy's own bookkeeping -- mirrors selectVehicleJobsForEviction's own
|
|
54
|
+
* three-phase shape:
|
|
55
|
+
*
|
|
56
|
+
* 1. Any receipt already past its own `expiresAt` (a real per-operation retentionMs elapsed --
|
|
57
|
+
* replaying it would no longer be correct, keeping it around would only be wasted memory).
|
|
58
|
+
* 2. If still over maxEntries once (1) is applied, the oldest remaining receipts by settledAt,
|
|
59
|
+
* until back within budget.
|
|
60
|
+
* 3. If still over maxTotalBytes once (1)+(2) are applied, the oldest remaining receipts by
|
|
61
|
+
* settledAt, until back within budget.
|
|
62
|
+
*
|
|
63
|
+
* A pending (still in-flight) request is never a candidate -- it has no receipt yet, so it can
|
|
64
|
+
* never appear in `candidates` at all; this function only ever sees settled ones.
|
|
65
|
+
*/
|
|
66
|
+
export declare function selectVehicleIdempotencyReceiptsForEviction(candidates: readonly VehicleIdempotencyEvictionCandidate[], options: VehicleIdempotencyRetentionOptions): readonly string[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure eviction-selection policy for settled keyed-idempotency receipts, independently testable
|
|
3
|
+
* from VehicleIdempotencyPolicy's own bookkeeping -- mirrors selectVehicleJobsForEviction's own
|
|
4
|
+
* three-phase shape:
|
|
5
|
+
*
|
|
6
|
+
* 1. Any receipt already past its own `expiresAt` (a real per-operation retentionMs elapsed --
|
|
7
|
+
* replaying it would no longer be correct, keeping it around would only be wasted memory).
|
|
8
|
+
* 2. If still over maxEntries once (1) is applied, the oldest remaining receipts by settledAt,
|
|
9
|
+
* until back within budget.
|
|
10
|
+
* 3. If still over maxTotalBytes once (1)+(2) are applied, the oldest remaining receipts by
|
|
11
|
+
* settledAt, until back within budget.
|
|
12
|
+
*
|
|
13
|
+
* A pending (still in-flight) request is never a candidate -- it has no receipt yet, so it can
|
|
14
|
+
* never appear in `candidates` at all; this function only ever sees settled ones.
|
|
15
|
+
*/
|
|
16
|
+
export function selectVehicleIdempotencyReceiptsForEviction(candidates, options) {
|
|
17
|
+
const byAgeAscending = (a, b) => a.settledAt - b.settledAt;
|
|
18
|
+
const evicted = new Set();
|
|
19
|
+
for (const candidate of candidates) {
|
|
20
|
+
if (options.now >= candidate.expiresAt)
|
|
21
|
+
evicted.add(candidate.key);
|
|
22
|
+
}
|
|
23
|
+
const remaining = () => candidates.filter((candidate) => !evicted.has(candidate.key));
|
|
24
|
+
if (remaining().length > options.maxEntries) {
|
|
25
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
26
|
+
for (const candidate of oldestFirst) {
|
|
27
|
+
if (remaining().length <= options.maxEntries)
|
|
28
|
+
break;
|
|
29
|
+
evicted.add(candidate.key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const totalBytes = () => remaining().reduce((sum, candidate) => sum + candidate.sizeBytes, 0);
|
|
33
|
+
if (totalBytes() > options.maxTotalBytes) {
|
|
34
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
35
|
+
for (const candidate of oldestFirst) {
|
|
36
|
+
if (totalBytes() <= options.maxTotalBytes)
|
|
37
|
+
break;
|
|
38
|
+
evicted.add(candidate.key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...evicted];
|
|
42
|
+
}
|
package/dist/vehicle-jobs.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
+
import type { VehiclePrincipal } from "./vehicle-contract.js";
|
|
3
|
+
import type { VehicleFailure } from "./vehicle-errors.js";
|
|
2
4
|
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
3
5
|
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. "orphaned" is a restart-reconciliation outcome: a job that was still "running" when its process died, so nothing ever really failed or succeeded -- the record's own status just goes stale. */
|
|
4
6
|
export declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "orphaned", "failed", "succeeded"];
|
|
@@ -101,6 +103,49 @@ export interface VehicleJobRetentionOptions {
|
|
|
101
103
|
readonly deliveredRetentionMs: number;
|
|
102
104
|
readonly now: number;
|
|
103
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The client-facing wire shapes for Vehicle Jobs -- submit/poll/tail options and results, shared by
|
|
108
|
+
* vehicle-server's VehicleJobStore (the orchestration side) and vehicle-client's job-capable clients
|
|
109
|
+
* (the calling side), so both halves of the wire agree on one definition instead of two structurally
|
|
110
|
+
*-identical copies drifting apart. Every field type referenced here already lives in vehicle-core
|
|
111
|
+
* (VehiclePrincipal, VehicleFailure, VehicleJobStatus, ...), which is what makes it safe for these
|
|
112
|
+
* shapes to live here too, alongside the rest of Vehicle Jobs' pure pieces.
|
|
113
|
+
*/
|
|
114
|
+
export interface VehicleJobSubmitOptions {
|
|
115
|
+
readonly permissions?: readonly string[];
|
|
116
|
+
readonly principal?: VehiclePrincipal;
|
|
117
|
+
readonly idempotencyKey?: string;
|
|
118
|
+
readonly expectedRevision?: string | number;
|
|
119
|
+
readonly approvalCapability?: string;
|
|
120
|
+
readonly correlationId?: string;
|
|
121
|
+
readonly callerSessionId?: string;
|
|
122
|
+
readonly callerProjectRoot?: string;
|
|
123
|
+
/** Defaults to "transition". */
|
|
124
|
+
readonly notifyMode?: VehicleJobNotifyMode;
|
|
125
|
+
/** Defaults to background.defaultWakeBudget; clamped to background.maxWakeBudget either way. */
|
|
126
|
+
readonly wakeBudget?: VehicleJobWakeBudget;
|
|
127
|
+
/** No default -- unset means the job runs until it settles or is canceled. */
|
|
128
|
+
readonly maxLifetimeMs?: number;
|
|
129
|
+
}
|
|
130
|
+
export interface VehicleJobSubmitResult {
|
|
131
|
+
readonly jobId: string;
|
|
132
|
+
}
|
|
133
|
+
export interface VehicleJobSnapshot {
|
|
134
|
+
readonly jobId: string;
|
|
135
|
+
readonly operationName: string;
|
|
136
|
+
readonly operationVersion: number;
|
|
137
|
+
readonly status: VehicleJobStatus;
|
|
138
|
+
readonly createdAt: number;
|
|
139
|
+
readonly updatedAt: number;
|
|
140
|
+
readonly delivered: boolean;
|
|
141
|
+
readonly terminationReason?: VehicleJobTerminationReason;
|
|
142
|
+
readonly output?: unknown;
|
|
143
|
+
readonly error?: VehicleFailure;
|
|
144
|
+
}
|
|
145
|
+
export interface VehicleJobTailResult {
|
|
146
|
+
readonly entries: readonly VehicleJobWakeEntry[];
|
|
147
|
+
readonly cursor: number;
|
|
148
|
+
}
|
|
104
149
|
/**
|
|
105
150
|
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
106
151
|
* bookkeeping so the bounded-retention rule is independently testable.
|
|
@@ -49,7 +49,23 @@ export declare class VehicleScheduleLimitExceeded extends Error {
|
|
|
49
49
|
readonly max: number;
|
|
50
50
|
constructor(owner: string, max: number);
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
52
|
+
/** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
|
|
53
|
+
export declare class VehicleScheduleInvalidTriggerError extends Error {
|
|
54
|
+
readonly trigger: VehicleScheduleTrigger;
|
|
55
|
+
constructor(trigger: VehicleScheduleTrigger);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
|
|
59
|
+
* plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
|
|
60
|
+
* non-finite or non-positive value silently corrupted every arithmetic function below it:
|
|
61
|
+
* `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
|
|
62
|
+
* schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
|
|
63
|
+
* breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
|
|
64
|
+
* required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
|
|
65
|
+
* negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
|
|
66
|
+
*/
|
|
67
|
+
export declare function isValidVehicleScheduleTrigger(trigger: VehicleScheduleTrigger): boolean;
|
|
68
|
+
/** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
|
|
53
69
|
export declare function initialFireAt(trigger: VehicleScheduleTrigger, now: number): number;
|
|
54
70
|
/** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
|
|
55
71
|
export declare function nextFireAtAfterFire(trigger: VehicleScheduleTrigger, now: number): number | undefined;
|
|
@@ -11,8 +11,36 @@ export class VehicleScheduleLimitExceeded extends Error {
|
|
|
11
11
|
this.name = "VehicleScheduleLimitExceeded";
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
|
|
15
|
+
export class VehicleScheduleInvalidTriggerError extends Error {
|
|
16
|
+
trigger;
|
|
17
|
+
constructor(trigger) {
|
|
18
|
+
super(`Invalid Vehicle schedule trigger: ${JSON.stringify(trigger)}`);
|
|
19
|
+
this.trigger = trigger;
|
|
20
|
+
this.name = "VehicleScheduleInvalidTriggerError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
|
|
25
|
+
* plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
|
|
26
|
+
* non-finite or non-positive value silently corrupted every arithmetic function below it:
|
|
27
|
+
* `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
|
|
28
|
+
* schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
|
|
29
|
+
* breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
|
|
30
|
+
* required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
|
|
31
|
+
* negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
|
|
32
|
+
*/
|
|
33
|
+
export function isValidVehicleScheduleTrigger(trigger) {
|
|
34
|
+
if (trigger.kind === "at")
|
|
35
|
+
return Number.isFinite(trigger.at) && trigger.at > 0;
|
|
36
|
+
if (trigger.kind === "every")
|
|
37
|
+
return Number.isFinite(trigger.intervalMs) && trigger.intervalMs > 0;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
|
|
15
41
|
export function initialFireAt(trigger, now) {
|
|
42
|
+
if (!isValidVehicleScheduleTrigger(trigger))
|
|
43
|
+
throw new VehicleScheduleInvalidTriggerError(trigger);
|
|
16
44
|
return trigger.kind === "at" ? trigger.at : now + trigger.intervalMs;
|
|
17
45
|
}
|
|
18
46
|
/** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./atomic-json.js";
|
|
|
2
2
|
export * from "./vehicle-approvals.js";
|
|
3
3
|
export * from "./vehicle-contract.js";
|
|
4
4
|
export * from "./vehicle-errors.js";
|
|
5
|
+
export * from "./vehicle-idempotency.js";
|
|
5
6
|
export * from "./vehicle-jobs.js";
|
|
6
7
|
export * from "./vehicle-scheduler.js";
|
|
7
8
|
export * from "./vehicle-watchers.js";
|
package/src/vehicle-approvals.ts
CHANGED
|
@@ -7,7 +7,22 @@
|
|
|
7
7
|
* atomic-json.ts already uses for fs access.
|
|
8
8
|
*/
|
|
9
9
|
import type { VehicleEffect, VehiclePrincipal } from "./vehicle-contract.js";
|
|
10
|
-
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
10
|
+
import { defineVehicleEvent, defineVehicleSchema, VEHICLE_EFFECTS } from "./vehicle-contract.js";
|
|
11
|
+
|
|
12
|
+
/** A sha256 hex digest: exactly 64 lowercase hex characters -- matches hashApprovalInput's own (vehicle-server) output shape. */
|
|
13
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
14
|
+
|
|
15
|
+
function isVehicleEffect(value: unknown): value is VehicleEffect {
|
|
16
|
+
return typeof value === "string" && (VEHICLE_EFFECTS as readonly string[]).includes(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isVehiclePrincipal(value: unknown): value is VehiclePrincipal {
|
|
20
|
+
if (typeof value !== "object" || value === null) return false;
|
|
21
|
+
const candidate = value as Record<string, unknown>;
|
|
22
|
+
if (typeof candidate.id !== "string") return false;
|
|
23
|
+
if (candidate.claims === undefined) return true;
|
|
24
|
+
return typeof candidate.claims === "object" && candidate.claims !== null && !Array.isArray(candidate.claims);
|
|
25
|
+
}
|
|
11
26
|
|
|
12
27
|
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
13
28
|
export const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[] = ["destructive", "open-world"];
|
|
@@ -74,7 +89,8 @@ const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
|
|
|
74
89
|
requestId: { type: "string" },
|
|
75
90
|
operationName: { type: "string" },
|
|
76
91
|
operationVersion: { type: "number" },
|
|
77
|
-
effect: { type: "string" },
|
|
92
|
+
effect: { type: "string", enum: [...VEHICLE_EFFECTS] },
|
|
93
|
+
principal: { type: "object" },
|
|
78
94
|
requestedAt: { type: "number" },
|
|
79
95
|
expiresAt: { type: "number" },
|
|
80
96
|
inputHash: { type: "string" },
|
|
@@ -89,13 +105,21 @@ const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
|
|
|
89
105
|
typeof row.requestId !== "string" ||
|
|
90
106
|
typeof row.operationName !== "string" ||
|
|
91
107
|
typeof row.operationVersion !== "number" ||
|
|
92
|
-
|
|
108
|
+
!Number.isInteger(row.operationVersion) ||
|
|
109
|
+
row.operationVersion < 1 ||
|
|
110
|
+
!isVehicleEffect(row.effect) ||
|
|
93
111
|
typeof row.requestedAt !== "number" ||
|
|
112
|
+
!Number.isFinite(row.requestedAt) ||
|
|
94
113
|
typeof row.expiresAt !== "number" ||
|
|
95
|
-
|
|
114
|
+
!Number.isFinite(row.expiresAt) ||
|
|
115
|
+
typeof row.inputHash !== "string" ||
|
|
116
|
+
!SHA256_HEX_PATTERN.test(row.inputHash)
|
|
96
117
|
) {
|
|
97
118
|
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
98
119
|
}
|
|
120
|
+
if (row.principal !== undefined && !isVehiclePrincipal(row.principal)) {
|
|
121
|
+
return { success: false, issues: [{ path: ["principal"], message: "invalid approval request principal" }] };
|
|
122
|
+
}
|
|
99
123
|
return { success: true, value: row as unknown as VehicleApprovalRequest };
|
|
100
124
|
},
|
|
101
125
|
});
|
|
@@ -119,10 +143,17 @@ const resolvedPayloadSchema = defineVehicleSchema<VehicleApprovalOutcome>({
|
|
|
119
143
|
if (
|
|
120
144
|
typeof row.requestId !== "string" ||
|
|
121
145
|
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
122
|
-
typeof row.decidedAt !== "number"
|
|
146
|
+
typeof row.decidedAt !== "number" ||
|
|
147
|
+
!Number.isFinite(row.decidedAt)
|
|
123
148
|
) {
|
|
124
149
|
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
125
150
|
}
|
|
151
|
+
if (row.decidedBy !== undefined && typeof row.decidedBy !== "string") {
|
|
152
|
+
return { success: false, issues: [{ path: ["decidedBy"], message: "decidedBy must be a string" }] };
|
|
153
|
+
}
|
|
154
|
+
if (row.comment !== undefined && typeof row.comment !== "string") {
|
|
155
|
+
return { success: false, issues: [{ path: ["comment"], message: "comment must be a string" }] };
|
|
156
|
+
}
|
|
126
157
|
return { success: true, value: row as unknown as VehicleApprovalOutcome };
|
|
127
158
|
},
|
|
128
159
|
});
|
package/src/vehicle-contract.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
VehicleJobSnapshot,
|
|
3
|
+
VehicleJobSubmitOptions,
|
|
4
|
+
VehicleJobSubmitResult,
|
|
5
|
+
VehicleJobTailResult,
|
|
6
|
+
VehicleJobWakeBudget,
|
|
7
|
+
} from "./vehicle-jobs.js";
|
|
2
8
|
|
|
3
9
|
export type JsonPrimitive = string | number | boolean | null;
|
|
4
10
|
export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
|
|
@@ -67,13 +73,39 @@ export interface LooseObjectProperty {
|
|
|
67
73
|
readonly enum?: readonly string[];
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/** JSON Schema's own `type` keyword vocabulary -- checked for real below so a declared `type: "number"` (say) can't silently accept a string forever. `"integer"` additionally requires no fractional part, matching JSON Schema's own distinction from plain `"number"`. An unrecognized type name is treated as "anything goes" (matches passthroughVehicleSchema's own precedent for a shape not worth strictly enforcing) rather than rejecting every input outright for what would otherwise be a schema-authoring typo. */
|
|
77
|
+
function matchesLooseObjectPropertyType(type: string, value: unknown): boolean {
|
|
78
|
+
switch (type) {
|
|
79
|
+
case "string":
|
|
80
|
+
return typeof value === "string";
|
|
81
|
+
case "number":
|
|
82
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
83
|
+
case "integer":
|
|
84
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
85
|
+
case "boolean":
|
|
86
|
+
return typeof value === "boolean";
|
|
87
|
+
case "object":
|
|
88
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
+
case "array":
|
|
90
|
+
return Array.isArray(value);
|
|
91
|
+
case "null":
|
|
92
|
+
return value === null;
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/**
|
|
71
99
|
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
72
100
|
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
73
|
-
* enforced at runtime -- so a declared `enum`
|
|
74
|
-
* real, or it's a documentation gesture, not an
|
|
75
|
-
*
|
|
76
|
-
*
|
|
101
|
+
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
102
|
+
* has to be checked here for real, or it's a documentation gesture, not an
|
|
103
|
+
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
104
|
+
* had before: it always advertised `additionalProperties: false` and a
|
|
105
|
+
* per-property `type`, while safeParse only ever checked `required` and
|
|
106
|
+
* `enum`). Every consumer projecting a plain-object input onto a
|
|
107
|
+
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
108
|
+
* is that check written once.
|
|
77
109
|
*/
|
|
78
110
|
export function defineLooseObjectSchema(
|
|
79
111
|
properties: Record<string, LooseObjectProperty>,
|
|
@@ -93,9 +125,15 @@ export function defineLooseObjectSchema(
|
|
|
93
125
|
for (const key of required) {
|
|
94
126
|
if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
95
127
|
}
|
|
128
|
+
for (const key of Object.keys(input)) {
|
|
129
|
+
if (!(key in properties)) return { success: false, issues: [{ path: [key], message: `${key} is not a recognized property` }] };
|
|
130
|
+
}
|
|
96
131
|
for (const [key, schema] of Object.entries(properties)) {
|
|
97
|
-
if (!
|
|
98
|
-
if (!schema.
|
|
132
|
+
if (!(key in input)) continue;
|
|
133
|
+
if (!matchesLooseObjectPropertyType(schema.type, input[key])) {
|
|
134
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be of type ${schema.type}` }] };
|
|
135
|
+
}
|
|
136
|
+
if (schema.enum && !schema.enum.includes(input[key] as string)) {
|
|
99
137
|
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
100
138
|
}
|
|
101
139
|
}
|
|
@@ -157,7 +195,9 @@ export function extractVehicleContent(output: unknown): readonly VehicleContentB
|
|
|
157
195
|
return blocks;
|
|
158
196
|
}
|
|
159
197
|
|
|
160
|
-
|
|
198
|
+
/** Canonical list VehicleEffect is itself derived from, so a runtime discriminator check (e.g. a persisted/wire VehicleApprovalRequest's own `effect` field) has one real source to check against instead of a second, driftable hardcoded list. */
|
|
199
|
+
export const VEHICLE_EFFECTS = ["read", "local-write", "external-write", "destructive", "open-world"] as const;
|
|
200
|
+
export type VehicleEffect = (typeof VEHICLE_EFFECTS)[number];
|
|
161
201
|
|
|
162
202
|
export type VehicleIdempotency =
|
|
163
203
|
| { readonly mode: "safe" }
|
|
@@ -438,6 +478,24 @@ export interface VehicleClient {
|
|
|
438
478
|
manifest(): Promise<VehicleManifest>;
|
|
439
479
|
invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
|
|
440
480
|
close(): Promise<void>;
|
|
481
|
+
/**
|
|
482
|
+
* Vehicle Jobs -- submit a background-capable operation (one whose descriptor declares
|
|
483
|
+
* `background`, see {@link VehicleBackgroundCapability}) and get its jobId back immediately,
|
|
484
|
+
* without waiting for the operation itself to make any progress. Optional: a client that
|
|
485
|
+
* never talks to a job-capable Vehicle (or a hand-rolled test double) simply omits these five
|
|
486
|
+
* methods, exactly like this interface's own long-standing `subscribe()`-shaped extras --
|
|
487
|
+
* present on both LocalVehicleClient and RemoteVehicleClient, absent elsewhere. Feature-detect
|
|
488
|
+
* via the operation's own manifest `background` capability, not by probing for these methods.
|
|
489
|
+
*/
|
|
490
|
+
submitJob?(name: string, version: number, input: unknown, options?: VehicleJobSubmitOptions): Promise<VehicleJobSubmitResult>;
|
|
491
|
+
/** Never blocks -- current status, plus output/error once terminal. */
|
|
492
|
+
pollJob?(jobId: string): Promise<VehicleJobSnapshot>;
|
|
493
|
+
/** Progress entries strictly after `cursor` (0 for everything so far), plus the next cursor. Never blocks. */
|
|
494
|
+
tailJob?(jobId: string, cursor?: number): Promise<VehicleJobTailResult>;
|
|
495
|
+
/** Pushes new input to an already-running job's handler, if it opted in via context.steerInputs. */
|
|
496
|
+
steerJob?(jobId: string, input: unknown): Promise<void>;
|
|
497
|
+
/** Best-effort cancellation of a still-running job -- a no-op against an already-terminal one. */
|
|
498
|
+
cancelJob?(jobId: string): Promise<void>;
|
|
441
499
|
}
|
|
442
500
|
|
|
443
501
|
export function defineVehicleOperation<Input, Output>(
|
package/src/vehicle-errors.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type VehicleCoreErrorCode =
|
|
|
26
26
|
| "handler-failed"
|
|
27
27
|
| "policy-failed"
|
|
28
28
|
| "idempotency-key-required"
|
|
29
|
+
| "idempotency-conflict"
|
|
29
30
|
| "client-closed"
|
|
30
31
|
| "operation-unavailable"
|
|
31
32
|
| "background-not-supported"
|
|
@@ -156,6 +157,27 @@ export function isVehicleError(value: unknown): value is VehicleError {
|
|
|
156
157
|
return value instanceof Error && Reflect.get(value, VEHICLE_ERROR_BRAND) === true;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Reconstructs a throwable VehicleError from a previously-serialized VehicleFailure -- the inverse
|
|
162
|
+
* of VehicleError.prototype.toFailure(), needed anywhere a wire-safe failure gets replayed as a
|
|
163
|
+
* real rejection later (e.g. VehicleIdempotencyPolicy replaying a settled failed receipt to a
|
|
164
|
+
* second caller reusing the same idempotency key). Lossy on purpose: a VehicleFailure never
|
|
165
|
+
* carries the original `cause` (toFailure() already reduced it to an optional bounded
|
|
166
|
+
* causeMessage per the throw site's own exposeCause choice), so the reconstructed error has no
|
|
167
|
+
* cause at all rather than fabricating one -- a replayed failure only needs to match the original
|
|
168
|
+
* code/category/message/details a caller would react to, not its internal cause chain.
|
|
169
|
+
*/
|
|
170
|
+
export function vehicleErrorFromFailure(failure: VehicleFailure): VehicleError {
|
|
171
|
+
return new VehicleError(failure.code, failure.message, {
|
|
172
|
+
category: failure.category,
|
|
173
|
+
retryable: failure.retryable,
|
|
174
|
+
retryAfterMs: failure.retryAfterMs,
|
|
175
|
+
recovery: failure.recovery,
|
|
176
|
+
details: failure.details,
|
|
177
|
+
operationId: failure.operationId,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
159
181
|
const MAX_CAUSE_MESSAGE_LENGTH = 500;
|
|
160
182
|
|
|
161
183
|
/** Extracts a bounded, wire-safe message from an unknown cause -- never the full stack trace, never an unbounded payload. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pieces of Vehicle's bounded keyed-idempotency replay policy: the settled-receipt shape and
|
|
3
|
+
* its eviction-selection rule. Orchestration (in-flight dedup, persistence, fail-closed conflict
|
|
4
|
+
* detection) lives in vehicle-server's VehicleIdempotencyPolicy -- mirrors the vehicle-jobs.js /
|
|
5
|
+
* VehicleJobStore split (a pure, independently-testable bounded-retention rule here; the stateful
|
|
6
|
+
* store that calls it lives in vehicle-server).
|
|
7
|
+
*/
|
|
8
|
+
import type { VehicleFailure } from "./vehicle-errors.js";
|
|
9
|
+
|
|
10
|
+
/** A settled keyed-idempotency outcome -- exactly what gets replayed to a caller reusing the same key. Never carries the original request's raw input (only its hash is ever retained, see VehicleIdempotencyReceipt) and never a credential: `output`/`failure` are already what the operation would hand back to any caller, the same wire-safe boundary VehicleJobPersistedRecord's own `output`/`error` fields already cross. */
|
|
11
|
+
export type VehicleIdempotencyResult =
|
|
12
|
+
| { readonly ok: true; readonly output: unknown }
|
|
13
|
+
| { readonly ok: false; readonly failure: VehicleFailure };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One settled keyed-idempotency receipt. Deliberately excludes the original request's raw input --
|
|
17
|
+
* `inputHash` is the only trace of it retained, so a persisted receipt can never leak whatever the
|
|
18
|
+
* caller originally sent (which may carry sensitive arguments the operation's own output does not).
|
|
19
|
+
* A still-in-flight (pending) request has no receipt yet -- that bookkeeping is transient,
|
|
20
|
+
* in-memory-only state in VehicleIdempotencyPolicy, deliberately never a candidate for persistence
|
|
21
|
+
* or eviction (mirrors "a running job is never a candidate" in vehicle-jobs.js's own job eviction).
|
|
22
|
+
*/
|
|
23
|
+
export interface VehicleIdempotencyReceipt {
|
|
24
|
+
readonly key: string;
|
|
25
|
+
readonly operationName: string;
|
|
26
|
+
readonly operationVersion: number;
|
|
27
|
+
readonly inputHash: string;
|
|
28
|
+
readonly settledAt: number;
|
|
29
|
+
/** settledAt + the descriptor's own keyed retentionMs at the time this receipt settled. A receipt past this is no longer a valid replay -- see selectVehicleIdempotencyReceiptsForEviction. */
|
|
30
|
+
readonly expiresAt: number;
|
|
31
|
+
readonly result: VehicleIdempotencyResult;
|
|
32
|
+
/** Approximate serialized size of `result`, used only to enforce maxTotalBytes -- never exact byte-for-byte, matching every other Vehicle capacity bound's own "good enough to stay bounded" precedent (e.g. enforcePayloadSize). */
|
|
33
|
+
readonly sizeBytes: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Minimal shape selectVehicleIdempotencyReceiptsForEviction needs -- kept separate from VehicleIdempotencyReceipt's own `result` so a sweep never has to touch (or risk logging) the actual settled output/failure it's merely deciding whether to keep. */
|
|
37
|
+
export interface VehicleIdempotencyEvictionCandidate {
|
|
38
|
+
readonly key: string;
|
|
39
|
+
readonly settledAt: number;
|
|
40
|
+
readonly expiresAt: number;
|
|
41
|
+
readonly sizeBytes: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface VehicleIdempotencyRetentionOptions {
|
|
45
|
+
/** Hard cap on total retained settled receipts. */
|
|
46
|
+
readonly maxEntries: number;
|
|
47
|
+
/** Hard cap on the sum of every retained receipt's own sizeBytes. */
|
|
48
|
+
readonly maxTotalBytes: number;
|
|
49
|
+
readonly now: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure eviction-selection policy for settled keyed-idempotency receipts, independently testable
|
|
54
|
+
* from VehicleIdempotencyPolicy's own bookkeeping -- mirrors selectVehicleJobsForEviction's own
|
|
55
|
+
* three-phase shape:
|
|
56
|
+
*
|
|
57
|
+
* 1. Any receipt already past its own `expiresAt` (a real per-operation retentionMs elapsed --
|
|
58
|
+
* replaying it would no longer be correct, keeping it around would only be wasted memory).
|
|
59
|
+
* 2. If still over maxEntries once (1) is applied, the oldest remaining receipts by settledAt,
|
|
60
|
+
* until back within budget.
|
|
61
|
+
* 3. If still over maxTotalBytes once (1)+(2) are applied, the oldest remaining receipts by
|
|
62
|
+
* settledAt, until back within budget.
|
|
63
|
+
*
|
|
64
|
+
* A pending (still in-flight) request is never a candidate -- it has no receipt yet, so it can
|
|
65
|
+
* never appear in `candidates` at all; this function only ever sees settled ones.
|
|
66
|
+
*/
|
|
67
|
+
export function selectVehicleIdempotencyReceiptsForEviction(
|
|
68
|
+
candidates: readonly VehicleIdempotencyEvictionCandidate[],
|
|
69
|
+
options: VehicleIdempotencyRetentionOptions,
|
|
70
|
+
): readonly string[] {
|
|
71
|
+
const byAgeAscending = (a: VehicleIdempotencyEvictionCandidate, b: VehicleIdempotencyEvictionCandidate) => a.settledAt - b.settledAt;
|
|
72
|
+
|
|
73
|
+
const evicted = new Set<string>();
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
if (options.now >= candidate.expiresAt) evicted.add(candidate.key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const remaining = () => candidates.filter((candidate) => !evicted.has(candidate.key));
|
|
79
|
+
if (remaining().length > options.maxEntries) {
|
|
80
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
81
|
+
for (const candidate of oldestFirst) {
|
|
82
|
+
if (remaining().length <= options.maxEntries) break;
|
|
83
|
+
evicted.add(candidate.key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const totalBytes = () => remaining().reduce((sum, candidate) => sum + candidate.sizeBytes, 0);
|
|
88
|
+
if (totalBytes() > options.maxTotalBytes) {
|
|
89
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
90
|
+
for (const candidate of oldestFirst) {
|
|
91
|
+
if (totalBytes() <= options.maxTotalBytes) break;
|
|
92
|
+
evicted.add(candidate.key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [...evicted];
|
|
97
|
+
}
|
package/src/vehicle-jobs.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
2
|
|
|
3
|
+
import type { VehiclePrincipal } from "./vehicle-contract.js";
|
|
4
|
+
import type { VehicleFailure } from "./vehicle-errors.js";
|
|
5
|
+
|
|
3
6
|
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
4
7
|
|
|
5
8
|
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. "orphaned" is a restart-reconciliation outcome: a job that was still "running" when its process died, so nothing ever really failed or succeeded -- the record's own status just goes stale. */
|
|
@@ -212,6 +215,53 @@ export interface VehicleJobRetentionOptions {
|
|
|
212
215
|
readonly now: number;
|
|
213
216
|
}
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* The client-facing wire shapes for Vehicle Jobs -- submit/poll/tail options and results, shared by
|
|
220
|
+
* vehicle-server's VehicleJobStore (the orchestration side) and vehicle-client's job-capable clients
|
|
221
|
+
* (the calling side), so both halves of the wire agree on one definition instead of two structurally
|
|
222
|
+
*-identical copies drifting apart. Every field type referenced here already lives in vehicle-core
|
|
223
|
+
* (VehiclePrincipal, VehicleFailure, VehicleJobStatus, ...), which is what makes it safe for these
|
|
224
|
+
* shapes to live here too, alongside the rest of Vehicle Jobs' pure pieces.
|
|
225
|
+
*/
|
|
226
|
+
export interface VehicleJobSubmitOptions {
|
|
227
|
+
readonly permissions?: readonly string[];
|
|
228
|
+
readonly principal?: VehiclePrincipal;
|
|
229
|
+
readonly idempotencyKey?: string;
|
|
230
|
+
readonly expectedRevision?: string | number;
|
|
231
|
+
readonly approvalCapability?: string;
|
|
232
|
+
readonly correlationId?: string;
|
|
233
|
+
readonly callerSessionId?: string;
|
|
234
|
+
readonly callerProjectRoot?: string;
|
|
235
|
+
/** Defaults to "transition". */
|
|
236
|
+
readonly notifyMode?: VehicleJobNotifyMode;
|
|
237
|
+
/** Defaults to background.defaultWakeBudget; clamped to background.maxWakeBudget either way. */
|
|
238
|
+
readonly wakeBudget?: VehicleJobWakeBudget;
|
|
239
|
+
/** No default -- unset means the job runs until it settles or is canceled. */
|
|
240
|
+
readonly maxLifetimeMs?: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface VehicleJobSubmitResult {
|
|
244
|
+
readonly jobId: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface VehicleJobSnapshot {
|
|
248
|
+
readonly jobId: string;
|
|
249
|
+
readonly operationName: string;
|
|
250
|
+
readonly operationVersion: number;
|
|
251
|
+
readonly status: VehicleJobStatus;
|
|
252
|
+
readonly createdAt: number;
|
|
253
|
+
readonly updatedAt: number;
|
|
254
|
+
readonly delivered: boolean;
|
|
255
|
+
readonly terminationReason?: VehicleJobTerminationReason;
|
|
256
|
+
readonly output?: unknown;
|
|
257
|
+
readonly error?: VehicleFailure;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface VehicleJobTailResult {
|
|
261
|
+
readonly entries: readonly VehicleJobWakeEntry[];
|
|
262
|
+
readonly cursor: number;
|
|
263
|
+
}
|
|
264
|
+
|
|
215
265
|
/**
|
|
216
266
|
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
217
267
|
* bookkeeping so the bounded-retention rule is independently testable.
|
package/src/vehicle-scheduler.ts
CHANGED
|
@@ -50,8 +50,33 @@ export class VehicleScheduleLimitExceeded extends Error {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
|
|
54
|
+
export class VehicleScheduleInvalidTriggerError extends Error {
|
|
55
|
+
constructor(readonly trigger: VehicleScheduleTrigger) {
|
|
56
|
+
super(`Invalid Vehicle schedule trigger: ${JSON.stringify(trigger)}`);
|
|
57
|
+
this.name = "VehicleScheduleInvalidTriggerError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
|
|
63
|
+
* plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
|
|
64
|
+
* non-finite or non-positive value silently corrupted every arithmetic function below it:
|
|
65
|
+
* `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
|
|
66
|
+
* schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
|
|
67
|
+
* breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
|
|
68
|
+
* required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
|
|
69
|
+
* negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
|
|
70
|
+
*/
|
|
71
|
+
export function isValidVehicleScheduleTrigger(trigger: VehicleScheduleTrigger): boolean {
|
|
72
|
+
if (trigger.kind === "at") return Number.isFinite(trigger.at) && trigger.at > 0;
|
|
73
|
+
if (trigger.kind === "every") return Number.isFinite(trigger.intervalMs) && trigger.intervalMs > 0;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
|
|
54
78
|
export function initialFireAt(trigger: VehicleScheduleTrigger, now: number): number {
|
|
79
|
+
if (!isValidVehicleScheduleTrigger(trigger)) throw new VehicleScheduleInvalidTriggerError(trigger);
|
|
55
80
|
return trigger.kind === "at" ? trigger.at : now + trigger.intervalMs;
|
|
56
81
|
}
|
|
57
82
|
|