@danypops/vehicle-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/vehicle-contract.d.ts +136 -0
- package/dist/vehicle-contract.js +65 -0
- package/dist/vehicle-errors.d.ts +38 -0
- package/dist/vehicle-errors.js +46 -0
- package/package.json +31 -0
- package/src/index.ts +2 -0
- package/src/vehicle-contract.ts +217 -0
- package/src/vehicle-errors.ts +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @danypops/vehicle-core
|
|
2
|
+
|
|
3
|
+
Vehicle's runtime-neutral wire contract: operation descriptors, schema
|
|
4
|
+
codecs, and failure shapes. Zero runtime dependencies, zero Bun-specific
|
|
5
|
+
code -- the one thing every Vehicle client and server package depends on.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @danypops/vehicle-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`defineVehicleOperation()`/`bindVehicleOperation()` build a serializable
|
|
12
|
+
descriptor kept separate from its executable handler. See the
|
|
13
|
+
[workspace README](https://github.com/DanyPops/daemon-kit#readme) for how it
|
|
14
|
+
fits with `@danypops/vehicle-server`, `@danypops/vehicle-client`, and
|
|
15
|
+
`@danypops/vehicle-client-pi`.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
|
|
3
|
+
readonly [key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
export type JsonSchema = Readonly<Record<string, JsonValue>>;
|
|
6
|
+
export interface VehicleSchemaIssue {
|
|
7
|
+
readonly path: readonly (string | number)[];
|
|
8
|
+
readonly message: string;
|
|
9
|
+
}
|
|
10
|
+
export type VehicleSchemaResult<T> = {
|
|
11
|
+
readonly success: true;
|
|
12
|
+
readonly value: T;
|
|
13
|
+
} | {
|
|
14
|
+
readonly success: false;
|
|
15
|
+
readonly issues?: readonly VehicleSchemaIssue[];
|
|
16
|
+
};
|
|
17
|
+
export interface VehicleSchemaCodec<T> {
|
|
18
|
+
readonly jsonSchema: JsonSchema;
|
|
19
|
+
safeParse(value: unknown): VehicleSchemaResult<T>;
|
|
20
|
+
}
|
|
21
|
+
export declare function defineVehicleSchema<T>(codec: VehicleSchemaCodec<T>): VehicleSchemaCodec<T>;
|
|
22
|
+
export type VehicleEffect = "read" | "local-write" | "external-write" | "destructive" | "open-world";
|
|
23
|
+
export type VehicleIdempotency = {
|
|
24
|
+
readonly mode: "safe";
|
|
25
|
+
} | {
|
|
26
|
+
readonly mode: "keyed";
|
|
27
|
+
readonly retentionMs: number;
|
|
28
|
+
} | {
|
|
29
|
+
readonly mode: "unsafe";
|
|
30
|
+
};
|
|
31
|
+
export interface VehicleLimits {
|
|
32
|
+
readonly defaultTimeoutMs: number;
|
|
33
|
+
readonly maxTimeoutMs: number;
|
|
34
|
+
readonly maxRequestBytes: number;
|
|
35
|
+
readonly maxResponseBytes: number;
|
|
36
|
+
}
|
|
37
|
+
export interface VehicleFailureDescriptor {
|
|
38
|
+
readonly code: string;
|
|
39
|
+
readonly description: string;
|
|
40
|
+
}
|
|
41
|
+
export interface VehicleOperationDescriptor {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly version: number;
|
|
44
|
+
readonly description: string;
|
|
45
|
+
readonly inputSchema: JsonSchema;
|
|
46
|
+
readonly outputSchema: JsonSchema;
|
|
47
|
+
readonly permissions: readonly string[];
|
|
48
|
+
readonly effect: VehicleEffect;
|
|
49
|
+
readonly idempotency: VehicleIdempotency;
|
|
50
|
+
readonly streaming: boolean;
|
|
51
|
+
readonly longRunning: boolean;
|
|
52
|
+
readonly limits: VehicleLimits;
|
|
53
|
+
readonly errors: readonly VehicleFailureDescriptor[];
|
|
54
|
+
}
|
|
55
|
+
export interface VehicleOperation<Input, Output> {
|
|
56
|
+
readonly descriptor: VehicleOperationDescriptor;
|
|
57
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
58
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
59
|
+
}
|
|
60
|
+
export interface DefineVehicleOperationOptions<Input, Output> {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly version: number;
|
|
63
|
+
readonly description: string;
|
|
64
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
65
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
66
|
+
readonly permissions?: readonly string[];
|
|
67
|
+
readonly effect: VehicleEffect;
|
|
68
|
+
readonly idempotency: VehicleIdempotency;
|
|
69
|
+
readonly streaming?: boolean;
|
|
70
|
+
readonly longRunning?: boolean;
|
|
71
|
+
readonly limits: VehicleLimits;
|
|
72
|
+
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
73
|
+
}
|
|
74
|
+
export interface VehiclePrincipal {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly claims?: Readonly<Record<string, JsonValue>>;
|
|
77
|
+
}
|
|
78
|
+
export interface VehicleInvocationOptions {
|
|
79
|
+
readonly operationId?: string;
|
|
80
|
+
readonly correlationId?: string;
|
|
81
|
+
readonly signal?: AbortSignal;
|
|
82
|
+
readonly deadline?: number;
|
|
83
|
+
readonly permissions?: readonly string[];
|
|
84
|
+
readonly principal?: VehiclePrincipal;
|
|
85
|
+
readonly idempotencyKey?: string;
|
|
86
|
+
readonly expectedRevision?: string | number;
|
|
87
|
+
readonly approvalCapability?: string;
|
|
88
|
+
readonly onProgress?: (progress: unknown) => void;
|
|
89
|
+
}
|
|
90
|
+
export interface VehicleOperationContext<Input> {
|
|
91
|
+
readonly input: Input;
|
|
92
|
+
readonly operationId: string;
|
|
93
|
+
readonly correlationId?: string;
|
|
94
|
+
readonly signal: AbortSignal;
|
|
95
|
+
readonly deadline: number;
|
|
96
|
+
readonly permissions: readonly string[];
|
|
97
|
+
readonly principal?: VehiclePrincipal;
|
|
98
|
+
readonly idempotencyKey?: string;
|
|
99
|
+
readonly expectedRevision?: string | number;
|
|
100
|
+
readonly approvalCapability?: string;
|
|
101
|
+
reportProgress(progress: unknown): void;
|
|
102
|
+
}
|
|
103
|
+
export type VehicleOperationHandler<Input, Output> = (context: VehicleOperationContext<Input>) => Promise<Output>;
|
|
104
|
+
export interface VehicleOperationBinding<Input, Output> {
|
|
105
|
+
readonly operation: VehicleOperation<Input, Output>;
|
|
106
|
+
bind(): VehicleOperationHandler<Input, Output>;
|
|
107
|
+
}
|
|
108
|
+
export interface VehicleManifestIdentity {
|
|
109
|
+
readonly name: string;
|
|
110
|
+
readonly version: string;
|
|
111
|
+
readonly description: string;
|
|
112
|
+
readonly guidance?: readonly string[];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A manifest's own view of an operation: the static descriptor plus
|
|
116
|
+
* whether it's currently usable on this particular server instance right
|
|
117
|
+
* now. Availability is a runtime property of a live registry (a
|
|
118
|
+
* credential got configured or removed), never baked into the static
|
|
119
|
+
* descriptor defineVehicleOperation() produces -- two manifest() calls
|
|
120
|
+
* against the same registry can report different availability for the
|
|
121
|
+
* exact same descriptor.
|
|
122
|
+
*/
|
|
123
|
+
export interface VehicleManifestOperation extends VehicleOperationDescriptor {
|
|
124
|
+
readonly available: boolean;
|
|
125
|
+
readonly unavailableReason?: string;
|
|
126
|
+
}
|
|
127
|
+
export interface VehicleManifest extends VehicleManifestIdentity {
|
|
128
|
+
readonly operations: readonly VehicleManifestOperation[];
|
|
129
|
+
}
|
|
130
|
+
export interface VehicleClient {
|
|
131
|
+
manifest(): Promise<VehicleManifest>;
|
|
132
|
+
invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
|
|
133
|
+
close(): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
export declare function defineVehicleOperation<Input, Output>(options: DefineVehicleOperationOptions<Input, Output>): VehicleOperation<Input, Output>;
|
|
136
|
+
export declare function bindVehicleOperation<Input, Output>(operation: VehicleOperation<Input, Output>, bind: () => VehicleOperationHandler<Input, Output>): VehicleOperationBinding<Input, Output>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function defineVehicleSchema(codec) {
|
|
2
|
+
return Object.freeze({
|
|
3
|
+
jsonSchema: cloneJson(codec.jsonSchema),
|
|
4
|
+
safeParse: codec.safeParse,
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
export function defineVehicleOperation(options) {
|
|
8
|
+
validateOperationMetadata(options);
|
|
9
|
+
const descriptor = Object.freeze({
|
|
10
|
+
name: options.name,
|
|
11
|
+
version: options.version,
|
|
12
|
+
description: options.description,
|
|
13
|
+
inputSchema: cloneJson(options.input.jsonSchema),
|
|
14
|
+
outputSchema: cloneJson(options.output.jsonSchema),
|
|
15
|
+
permissions: Object.freeze([...(options.permissions ?? [])]),
|
|
16
|
+
effect: options.effect,
|
|
17
|
+
idempotency: Object.freeze({ ...options.idempotency }),
|
|
18
|
+
streaming: options.streaming ?? false,
|
|
19
|
+
longRunning: options.longRunning ?? false,
|
|
20
|
+
limits: Object.freeze({ ...options.limits }),
|
|
21
|
+
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
22
|
+
});
|
|
23
|
+
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
24
|
+
}
|
|
25
|
+
export function bindVehicleOperation(operation, bind) {
|
|
26
|
+
return Object.freeze({ operation, bind });
|
|
27
|
+
}
|
|
28
|
+
function validateOperationMetadata(options) {
|
|
29
|
+
if (!options.name.trim())
|
|
30
|
+
throw new Error("Vehicle operation name must not be empty");
|
|
31
|
+
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
32
|
+
throw new Error("Vehicle operation version must be a positive integer");
|
|
33
|
+
}
|
|
34
|
+
if (!options.description.trim())
|
|
35
|
+
throw new Error("Vehicle operation description must not be empty");
|
|
36
|
+
for (const permission of options.permissions ?? []) {
|
|
37
|
+
if (!permission.trim())
|
|
38
|
+
throw new Error("Vehicle operation permissions must not contain an empty value");
|
|
39
|
+
}
|
|
40
|
+
const limits = options.limits;
|
|
41
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
42
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
43
|
+
throw new Error(`Vehicle operation ${name} must be a positive integer`);
|
|
44
|
+
}
|
|
45
|
+
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
46
|
+
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
47
|
+
}
|
|
48
|
+
if (options.idempotency.mode === "keyed" && (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
|
|
49
|
+
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function cloneJson(value) {
|
|
53
|
+
const serialized = JSON.stringify(value);
|
|
54
|
+
if (serialized === undefined)
|
|
55
|
+
throw new Error("Vehicle JSON metadata must be serializable");
|
|
56
|
+
return freezeJson(JSON.parse(serialized));
|
|
57
|
+
}
|
|
58
|
+
function freezeJson(value) {
|
|
59
|
+
if (Array.isArray(value))
|
|
60
|
+
return Object.freeze(value.map(freezeJson));
|
|
61
|
+
if (value !== null && typeof value === "object") {
|
|
62
|
+
return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child]) => [key, freezeJson(child)])));
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { JsonValue, VehicleSchemaIssue } from "./vehicle-contract.js";
|
|
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";
|
|
4
|
+
export interface VehicleRecovery {
|
|
5
|
+
readonly operation?: string;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
}
|
|
8
|
+
export interface VehicleFailure {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
readonly category: VehicleFailureCategory;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly retryable: boolean;
|
|
13
|
+
readonly retryAfterMs?: number;
|
|
14
|
+
readonly recovery?: VehicleRecovery;
|
|
15
|
+
readonly details?: JsonValue;
|
|
16
|
+
readonly operationId?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface VehicleErrorOptions {
|
|
19
|
+
readonly category: VehicleFailureCategory;
|
|
20
|
+
readonly retryable?: boolean;
|
|
21
|
+
readonly retryAfterMs?: number;
|
|
22
|
+
readonly recovery?: VehicleRecovery;
|
|
23
|
+
readonly details?: JsonValue;
|
|
24
|
+
readonly operationId?: string;
|
|
25
|
+
readonly cause?: unknown;
|
|
26
|
+
}
|
|
27
|
+
export declare class VehicleError extends Error {
|
|
28
|
+
readonly code: string;
|
|
29
|
+
readonly category: VehicleFailureCategory;
|
|
30
|
+
readonly retryable: boolean;
|
|
31
|
+
readonly retryAfterMs?: number;
|
|
32
|
+
readonly recovery?: VehicleRecovery;
|
|
33
|
+
readonly details?: JsonValue;
|
|
34
|
+
readonly operationId?: string;
|
|
35
|
+
constructor(code: string, message: string, options: VehicleErrorOptions);
|
|
36
|
+
toFailure(): VehicleFailure;
|
|
37
|
+
}
|
|
38
|
+
export declare function boundedValidationDetails(issues: readonly VehicleSchemaIssue[] | undefined): JsonValue | undefined;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export class VehicleError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
category;
|
|
4
|
+
retryable;
|
|
5
|
+
retryAfterMs;
|
|
6
|
+
recovery;
|
|
7
|
+
details;
|
|
8
|
+
operationId;
|
|
9
|
+
constructor(code, message, options) {
|
|
10
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "VehicleError";
|
|
13
|
+
this.category = options.category;
|
|
14
|
+
this.retryable = options.retryable ?? false;
|
|
15
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
16
|
+
this.recovery = options.recovery;
|
|
17
|
+
this.details = options.details;
|
|
18
|
+
this.operationId = options.operationId;
|
|
19
|
+
}
|
|
20
|
+
toFailure() {
|
|
21
|
+
return {
|
|
22
|
+
code: this.code,
|
|
23
|
+
category: this.category,
|
|
24
|
+
message: this.message,
|
|
25
|
+
retryable: this.retryable,
|
|
26
|
+
...(this.retryAfterMs === undefined ? {} : { retryAfterMs: this.retryAfterMs }),
|
|
27
|
+
...(this.recovery === undefined ? {} : { recovery: this.recovery }),
|
|
28
|
+
...(this.details === undefined ? {} : { details: this.details }),
|
|
29
|
+
...(this.operationId === undefined ? {} : { operationId: this.operationId }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const MAX_VALIDATION_ISSUES = 10;
|
|
34
|
+
const MAX_ISSUE_MESSAGE_LENGTH = 500;
|
|
35
|
+
const MAX_ISSUE_PATH_LENGTH = 20;
|
|
36
|
+
export function boundedValidationDetails(issues) {
|
|
37
|
+
if (!issues?.length)
|
|
38
|
+
return undefined;
|
|
39
|
+
return {
|
|
40
|
+
issues: issues.slice(0, MAX_VALIDATION_ISSUES).map((issue) => ({
|
|
41
|
+
path: issue.path.slice(0, MAX_ISSUE_PATH_LENGTH),
|
|
42
|
+
message: issue.message.slice(0, MAX_ISSUE_MESSAGE_LENGTH),
|
|
43
|
+
})),
|
|
44
|
+
...(issues.length > MAX_VALIDATION_ISSUES ? { truncated: true } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danypops/vehicle-core",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
17
|
+
"test": "bun test test",
|
|
18
|
+
"typecheck": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
22
|
+
"typescript": "latest"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/DanyPops/daemon-kit.git",
|
|
27
|
+
"directory": "packages/vehicle-core"
|
|
28
|
+
},
|
|
29
|
+
"keywords": ["vehicle", "agent-tools"],
|
|
30
|
+
"files": ["src", "dist", "README.md"]
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
|
|
3
|
+
export type JsonSchema = Readonly<Record<string, JsonValue>>;
|
|
4
|
+
|
|
5
|
+
export interface VehicleSchemaIssue {
|
|
6
|
+
readonly path: readonly (string | number)[];
|
|
7
|
+
readonly message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type VehicleSchemaResult<T> =
|
|
11
|
+
| { readonly success: true; readonly value: T }
|
|
12
|
+
| { readonly success: false; readonly issues?: readonly VehicleSchemaIssue[] };
|
|
13
|
+
|
|
14
|
+
export interface VehicleSchemaCodec<T> {
|
|
15
|
+
readonly jsonSchema: JsonSchema;
|
|
16
|
+
safeParse(value: unknown): VehicleSchemaResult<T>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function defineVehicleSchema<T>(codec: VehicleSchemaCodec<T>): VehicleSchemaCodec<T> {
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
jsonSchema: cloneJson(codec.jsonSchema),
|
|
22
|
+
safeParse: codec.safeParse,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type VehicleEffect = "read" | "local-write" | "external-write" | "destructive" | "open-world";
|
|
27
|
+
|
|
28
|
+
export type VehicleIdempotency =
|
|
29
|
+
| { readonly mode: "safe" }
|
|
30
|
+
| { readonly mode: "keyed"; readonly retentionMs: number }
|
|
31
|
+
| { readonly mode: "unsafe" };
|
|
32
|
+
|
|
33
|
+
export interface VehicleLimits {
|
|
34
|
+
readonly defaultTimeoutMs: number;
|
|
35
|
+
readonly maxTimeoutMs: number;
|
|
36
|
+
readonly maxRequestBytes: number;
|
|
37
|
+
readonly maxResponseBytes: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VehicleFailureDescriptor {
|
|
41
|
+
readonly code: string;
|
|
42
|
+
readonly description: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VehicleOperationDescriptor {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly version: number;
|
|
48
|
+
readonly description: string;
|
|
49
|
+
readonly inputSchema: JsonSchema;
|
|
50
|
+
readonly outputSchema: JsonSchema;
|
|
51
|
+
readonly permissions: readonly string[];
|
|
52
|
+
readonly effect: VehicleEffect;
|
|
53
|
+
readonly idempotency: VehicleIdempotency;
|
|
54
|
+
readonly streaming: boolean;
|
|
55
|
+
readonly longRunning: boolean;
|
|
56
|
+
readonly limits: VehicleLimits;
|
|
57
|
+
readonly errors: readonly VehicleFailureDescriptor[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface VehicleOperation<Input, Output> {
|
|
61
|
+
readonly descriptor: VehicleOperationDescriptor;
|
|
62
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
63
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DefineVehicleOperationOptions<Input, Output> {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly version: number;
|
|
69
|
+
readonly description: string;
|
|
70
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
71
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
72
|
+
readonly permissions?: readonly string[];
|
|
73
|
+
readonly effect: VehicleEffect;
|
|
74
|
+
readonly idempotency: VehicleIdempotency;
|
|
75
|
+
readonly streaming?: boolean;
|
|
76
|
+
readonly longRunning?: boolean;
|
|
77
|
+
readonly limits: VehicleLimits;
|
|
78
|
+
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VehiclePrincipal {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
readonly claims?: Readonly<Record<string, JsonValue>>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface VehicleInvocationOptions {
|
|
87
|
+
readonly operationId?: string;
|
|
88
|
+
readonly correlationId?: string;
|
|
89
|
+
readonly signal?: AbortSignal;
|
|
90
|
+
readonly deadline?: number;
|
|
91
|
+
readonly permissions?: readonly string[];
|
|
92
|
+
readonly principal?: VehiclePrincipal;
|
|
93
|
+
readonly idempotencyKey?: string;
|
|
94
|
+
readonly expectedRevision?: string | number;
|
|
95
|
+
readonly approvalCapability?: string;
|
|
96
|
+
readonly onProgress?: (progress: unknown) => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface VehicleOperationContext<Input> {
|
|
100
|
+
readonly input: Input;
|
|
101
|
+
readonly operationId: string;
|
|
102
|
+
readonly correlationId?: string;
|
|
103
|
+
readonly signal: AbortSignal;
|
|
104
|
+
readonly deadline: number;
|
|
105
|
+
readonly permissions: readonly string[];
|
|
106
|
+
readonly principal?: VehiclePrincipal;
|
|
107
|
+
readonly idempotencyKey?: string;
|
|
108
|
+
readonly expectedRevision?: string | number;
|
|
109
|
+
readonly approvalCapability?: string;
|
|
110
|
+
reportProgress(progress: unknown): void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type VehicleOperationHandler<Input, Output> = (context: VehicleOperationContext<Input>) => Promise<Output>;
|
|
114
|
+
|
|
115
|
+
export interface VehicleOperationBinding<Input, Output> {
|
|
116
|
+
readonly operation: VehicleOperation<Input, Output>;
|
|
117
|
+
bind(): VehicleOperationHandler<Input, Output>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface VehicleManifestIdentity {
|
|
121
|
+
readonly name: string;
|
|
122
|
+
readonly version: string;
|
|
123
|
+
readonly description: string;
|
|
124
|
+
readonly guidance?: readonly string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A manifest's own view of an operation: the static descriptor plus
|
|
129
|
+
* whether it's currently usable on this particular server instance right
|
|
130
|
+
* now. Availability is a runtime property of a live registry (a
|
|
131
|
+
* credential got configured or removed), never baked into the static
|
|
132
|
+
* descriptor defineVehicleOperation() produces -- two manifest() calls
|
|
133
|
+
* against the same registry can report different availability for the
|
|
134
|
+
* exact same descriptor.
|
|
135
|
+
*/
|
|
136
|
+
export interface VehicleManifestOperation extends VehicleOperationDescriptor {
|
|
137
|
+
readonly available: boolean;
|
|
138
|
+
readonly unavailableReason?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface VehicleManifest extends VehicleManifestIdentity {
|
|
142
|
+
readonly operations: readonly VehicleManifestOperation[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface VehicleClient {
|
|
146
|
+
manifest(): Promise<VehicleManifest>;
|
|
147
|
+
invoke<Output = unknown>(
|
|
148
|
+
name: string,
|
|
149
|
+
version: number,
|
|
150
|
+
input: unknown,
|
|
151
|
+
options?: VehicleInvocationOptions,
|
|
152
|
+
): Promise<Output>;
|
|
153
|
+
close(): Promise<void>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function defineVehicleOperation<Input, Output>(
|
|
157
|
+
options: DefineVehicleOperationOptions<Input, Output>,
|
|
158
|
+
): VehicleOperation<Input, Output> {
|
|
159
|
+
validateOperationMetadata(options);
|
|
160
|
+
const descriptor: VehicleOperationDescriptor = Object.freeze({
|
|
161
|
+
name: options.name,
|
|
162
|
+
version: options.version,
|
|
163
|
+
description: options.description,
|
|
164
|
+
inputSchema: cloneJson(options.input.jsonSchema),
|
|
165
|
+
outputSchema: cloneJson(options.output.jsonSchema),
|
|
166
|
+
permissions: Object.freeze([...(options.permissions ?? [])]),
|
|
167
|
+
effect: options.effect,
|
|
168
|
+
idempotency: Object.freeze({ ...options.idempotency }),
|
|
169
|
+
streaming: options.streaming ?? false,
|
|
170
|
+
longRunning: options.longRunning ?? false,
|
|
171
|
+
limits: Object.freeze({ ...options.limits }),
|
|
172
|
+
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
173
|
+
});
|
|
174
|
+
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function bindVehicleOperation<Input, Output>(
|
|
178
|
+
operation: VehicleOperation<Input, Output>,
|
|
179
|
+
bind: () => VehicleOperationHandler<Input, Output>,
|
|
180
|
+
): VehicleOperationBinding<Input, Output> {
|
|
181
|
+
return Object.freeze({ operation, bind });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validateOperationMetadata<Input, Output>(options: DefineVehicleOperationOptions<Input, Output>): void {
|
|
185
|
+
if (!options.name.trim()) throw new Error("Vehicle operation name must not be empty");
|
|
186
|
+
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
187
|
+
throw new Error("Vehicle operation version must be a positive integer");
|
|
188
|
+
}
|
|
189
|
+
if (!options.description.trim()) throw new Error("Vehicle operation description must not be empty");
|
|
190
|
+
for (const permission of options.permissions ?? []) {
|
|
191
|
+
if (!permission.trim()) throw new Error("Vehicle operation permissions must not contain an empty value");
|
|
192
|
+
}
|
|
193
|
+
const limits = options.limits;
|
|
194
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
195
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`Vehicle operation ${name} must be a positive integer`);
|
|
196
|
+
}
|
|
197
|
+
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
198
|
+
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
199
|
+
}
|
|
200
|
+
if (options.idempotency.mode === "keyed" && (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
|
|
201
|
+
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cloneJson<T extends JsonValue>(value: T): T {
|
|
206
|
+
const serialized = JSON.stringify(value);
|
|
207
|
+
if (serialized === undefined) throw new Error("Vehicle JSON metadata must be serializable");
|
|
208
|
+
return freezeJson(JSON.parse(serialized) as JsonValue) as T;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function freezeJson(value: JsonValue): JsonValue {
|
|
212
|
+
if (Array.isArray(value)) return Object.freeze(value.map(freezeJson));
|
|
213
|
+
if (value !== null && typeof value === "object") {
|
|
214
|
+
return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child]) => [key, freezeJson(child)])));
|
|
215
|
+
}
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { JsonValue, VehicleSchemaIssue } from "./vehicle-contract.js";
|
|
2
|
+
|
|
3
|
+
export type VehicleFailureCategory =
|
|
4
|
+
| "validation"
|
|
5
|
+
| "not_found"
|
|
6
|
+
| "conflict"
|
|
7
|
+
| "authorization"
|
|
8
|
+
| "capacity"
|
|
9
|
+
| "timeout"
|
|
10
|
+
| "cancelled"
|
|
11
|
+
| "unavailable"
|
|
12
|
+
| "internal";
|
|
13
|
+
|
|
14
|
+
export type VehicleCoreErrorCode =
|
|
15
|
+
| "duplicate-owner"
|
|
16
|
+
| "not-found"
|
|
17
|
+
| "invalid-input"
|
|
18
|
+
| "invalid-output"
|
|
19
|
+
| "permission-denied"
|
|
20
|
+
| "request-too-large"
|
|
21
|
+
| "response-too-large"
|
|
22
|
+
| "cancelled"
|
|
23
|
+
| "deadline-exceeded"
|
|
24
|
+
| "handler-failed"
|
|
25
|
+
| "policy-failed"
|
|
26
|
+
| "idempotency-key-required"
|
|
27
|
+
| "client-closed"
|
|
28
|
+
| "operation-unavailable";
|
|
29
|
+
|
|
30
|
+
export interface VehicleRecovery {
|
|
31
|
+
readonly operation?: string;
|
|
32
|
+
readonly message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface VehicleFailure {
|
|
36
|
+
readonly code: string;
|
|
37
|
+
readonly category: VehicleFailureCategory;
|
|
38
|
+
readonly message: string;
|
|
39
|
+
readonly retryable: boolean;
|
|
40
|
+
readonly retryAfterMs?: number;
|
|
41
|
+
readonly recovery?: VehicleRecovery;
|
|
42
|
+
readonly details?: JsonValue;
|
|
43
|
+
readonly operationId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface VehicleErrorOptions {
|
|
47
|
+
readonly category: VehicleFailureCategory;
|
|
48
|
+
readonly retryable?: boolean;
|
|
49
|
+
readonly retryAfterMs?: number;
|
|
50
|
+
readonly recovery?: VehicleRecovery;
|
|
51
|
+
readonly details?: JsonValue;
|
|
52
|
+
readonly operationId?: string;
|
|
53
|
+
readonly cause?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class VehicleError extends Error {
|
|
57
|
+
readonly category: VehicleFailureCategory;
|
|
58
|
+
readonly retryable: boolean;
|
|
59
|
+
readonly retryAfterMs?: number;
|
|
60
|
+
readonly recovery?: VehicleRecovery;
|
|
61
|
+
readonly details?: JsonValue;
|
|
62
|
+
readonly operationId?: string;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
readonly code: string,
|
|
66
|
+
message: string,
|
|
67
|
+
options: VehicleErrorOptions,
|
|
68
|
+
) {
|
|
69
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
70
|
+
this.name = "VehicleError";
|
|
71
|
+
this.category = options.category;
|
|
72
|
+
this.retryable = options.retryable ?? false;
|
|
73
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
74
|
+
this.recovery = options.recovery;
|
|
75
|
+
this.details = options.details;
|
|
76
|
+
this.operationId = options.operationId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
toFailure(): VehicleFailure {
|
|
80
|
+
return {
|
|
81
|
+
code: this.code,
|
|
82
|
+
category: this.category,
|
|
83
|
+
message: this.message,
|
|
84
|
+
retryable: this.retryable,
|
|
85
|
+
...(this.retryAfterMs === undefined ? {} : { retryAfterMs: this.retryAfterMs }),
|
|
86
|
+
...(this.recovery === undefined ? {} : { recovery: this.recovery }),
|
|
87
|
+
...(this.details === undefined ? {} : { details: this.details }),
|
|
88
|
+
...(this.operationId === undefined ? {} : { operationId: this.operationId }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const MAX_VALIDATION_ISSUES = 10;
|
|
94
|
+
const MAX_ISSUE_MESSAGE_LENGTH = 500;
|
|
95
|
+
const MAX_ISSUE_PATH_LENGTH = 20;
|
|
96
|
+
|
|
97
|
+
export function boundedValidationDetails(issues: readonly VehicleSchemaIssue[] | undefined): JsonValue | undefined {
|
|
98
|
+
if (!issues?.length) return undefined;
|
|
99
|
+
return {
|
|
100
|
+
issues: issues.slice(0, MAX_VALIDATION_ISSUES).map((issue) => ({
|
|
101
|
+
path: issue.path.slice(0, MAX_ISSUE_PATH_LENGTH),
|
|
102
|
+
message: issue.message.slice(0, MAX_ISSUE_MESSAGE_LENGTH),
|
|
103
|
+
})),
|
|
104
|
+
...(issues.length > MAX_VALIDATION_ISSUES ? { truncated: true } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|