@danypops/vehicle-core 0.18.6 → 0.19.1
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/approvals/approval.d.ts +14 -0
- package/dist/approvals/approval.js +14 -0
- package/dist/client/client.d.ts +3 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/invocation-outcome.d.ts +29 -0
- package/dist/client/invocation-outcome.js +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/manifest/manifest.d.ts +3 -0
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.js +1 -0
- package/dist/protocol/negotiation.d.ts +36 -0
- package/dist/protocol/negotiation.js +71 -0
- package/package.json +1 -1
- package/src/approvals/approval.ts +15 -0
- package/src/client/client.ts +3 -0
- package/src/client/index.ts +1 -0
- package/src/client/invocation-outcome.ts +39 -0
- package/src/index.ts +1 -0
- package/src/manifest/manifest.ts +3 -0
- package/src/protocol/index.ts +1 -0
- package/src/protocol/negotiation.ts +110 -0
|
@@ -13,6 +13,20 @@ export declare const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[];
|
|
|
13
13
|
* loop point of the gate entirely.
|
|
14
14
|
*/
|
|
15
15
|
export declare const VEHICLE_APPROVAL_RESOLVE_OPERATION_NAME = "vehicle.approval.resolve";
|
|
16
|
+
/**
|
|
17
|
+
* The name VehicleRegistry.configureApprovals() registers its own read-only status-lookup
|
|
18
|
+
* operation under. Unlike vehicle.approval.resolve, this one is deliberately left projectable
|
|
19
|
+
* as an ordinary Pi/model-callable tool: reading a requestId's own already-made decision (who
|
|
20
|
+
* decided, when, and any comment) can never let a caller grant its own pending request -- it
|
|
21
|
+
* only ever surfaces a decision a human (or other authority) already recorded. Exists because a
|
|
22
|
+
* gated invoke()'s own local HITL prompt and the eventual human decision can genuinely happen on
|
|
23
|
+
* two different calls (a caller-side timeout, a UI presented asynchronously outside the
|
|
24
|
+
* originating call's own lifetime, a session restart in between) -- without this, a decision
|
|
25
|
+
* (and any comment explaining it) made after the originating call already gave up is
|
|
26
|
+
* unrecoverable, since vehicle.approval.resolve's own vehicleApprovalResolvedEvent is transient
|
|
27
|
+
* pub/sub with no replay for a caller that wasn't subscribed at that exact moment.
|
|
28
|
+
*/
|
|
29
|
+
export declare const VEHICLE_APPROVAL_STATUS_OPERATION_NAME = "vehicle.approval.status";
|
|
16
30
|
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
17
31
|
export declare const DEFAULT_APPROVAL_TIMEOUT_MS: number;
|
|
18
32
|
/** Emitted (as a Vehicle Event) the moment a gated-effect invoke() has no valid capability -- durable-first, before any interactive prompt is attempted. */
|
|
@@ -37,6 +37,20 @@ export const DEFAULT_APPROVAL_EFFECTS = ["destructive", "open-world"];
|
|
|
37
37
|
* loop point of the gate entirely.
|
|
38
38
|
*/
|
|
39
39
|
export const VEHICLE_APPROVAL_RESOLVE_OPERATION_NAME = "vehicle.approval.resolve";
|
|
40
|
+
/**
|
|
41
|
+
* The name VehicleRegistry.configureApprovals() registers its own read-only status-lookup
|
|
42
|
+
* operation under. Unlike vehicle.approval.resolve, this one is deliberately left projectable
|
|
43
|
+
* as an ordinary Pi/model-callable tool: reading a requestId's own already-made decision (who
|
|
44
|
+
* decided, when, and any comment) can never let a caller grant its own pending request -- it
|
|
45
|
+
* only ever surfaces a decision a human (or other authority) already recorded. Exists because a
|
|
46
|
+
* gated invoke()'s own local HITL prompt and the eventual human decision can genuinely happen on
|
|
47
|
+
* two different calls (a caller-side timeout, a UI presented asynchronously outside the
|
|
48
|
+
* originating call's own lifetime, a session restart in between) -- without this, a decision
|
|
49
|
+
* (and any comment explaining it) made after the originating call already gave up is
|
|
50
|
+
* unrecoverable, since vehicle.approval.resolve's own vehicleApprovalResolvedEvent is transient
|
|
51
|
+
* pub/sub with no replay for a caller that wasn't subscribed at that exact moment.
|
|
52
|
+
*/
|
|
53
|
+
export const VEHICLE_APPROVAL_STATUS_OPERATION_NAME = "vehicle.approval.status";
|
|
40
54
|
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
41
55
|
export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
42
56
|
const requestedPayloadSchema = defineVehicleSchema({
|
package/dist/client/client.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { VehicleJobSnapshot, VehicleJobSubmitOptions, VehicleJobSubmitResult, VehicleJobTailResult } from "../jobs/wire.js";
|
|
2
2
|
import type { VehicleManifest } from "../manifest/manifest.js";
|
|
3
3
|
import type { VehicleInvocationOptions } from "../operations/context.js";
|
|
4
|
+
import type { VehicleProtocolAgreement, VehicleProtocolOffer } from "../protocol/negotiation.js";
|
|
4
5
|
export interface VehicleSubscription {
|
|
5
6
|
close(): void;
|
|
6
7
|
}
|
|
7
8
|
export interface VehicleClient {
|
|
8
9
|
manifest(): Promise<VehicleManifest>;
|
|
10
|
+
/** Optional for compatibility with clients authored before explicit wire negotiation. */
|
|
11
|
+
negotiate?(offer: VehicleProtocolOffer): Promise<VehicleProtocolAgreement>;
|
|
9
12
|
invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
|
|
10
13
|
close(): Promise<void>;
|
|
11
14
|
/**
|
package/dist/client/index.d.ts
CHANGED
package/dist/client/index.js
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type VehicleFailure } from "../errors/error.js";
|
|
2
|
+
import type { VehicleInvocationOptions } from "../operations/context.js";
|
|
3
|
+
import type { VehicleClient } from "./client.js";
|
|
4
|
+
export type VehicleInvocationOutcome<Output> = {
|
|
5
|
+
readonly ok: true;
|
|
6
|
+
readonly value: Output;
|
|
7
|
+
} | {
|
|
8
|
+
readonly ok: false;
|
|
9
|
+
readonly kind: "vehicle-failure";
|
|
10
|
+
readonly failure: VehicleFailure;
|
|
11
|
+
} | {
|
|
12
|
+
readonly ok: false;
|
|
13
|
+
readonly kind: "cancelled";
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly operationId?: string;
|
|
16
|
+
} | {
|
|
17
|
+
readonly ok: false;
|
|
18
|
+
readonly kind: "transport-failure";
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly retryable: true;
|
|
21
|
+
readonly operationId?: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly ok: false;
|
|
24
|
+
readonly kind: "unexpected-failure";
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly operationId?: string;
|
|
27
|
+
};
|
|
28
|
+
/** Invokes one Vehicle operation while representing expected and boundary failures as typed values. */
|
|
29
|
+
export declare function invokeVehicleOutcome<Output = unknown>(client: VehicleClient, name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<VehicleInvocationOutcome<Output>>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isVehicleError } from "../errors/error.js";
|
|
2
|
+
/** Invokes one Vehicle operation while representing expected and boundary failures as typed values. */
|
|
3
|
+
export async function invokeVehicleOutcome(client, name, version, input, options = {}) {
|
|
4
|
+
try {
|
|
5
|
+
return { ok: true, value: await client.invoke(name, version, input, options) };
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (isVehicleError(error))
|
|
9
|
+
return { ok: false, kind: "vehicle-failure", failure: error.toFailure() };
|
|
10
|
+
const operation = options.operationId === undefined ? {} : { operationId: options.operationId };
|
|
11
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
12
|
+
return { ok: false, kind: "cancelled", message: "Vehicle invocation was cancelled", ...operation };
|
|
13
|
+
}
|
|
14
|
+
if (error instanceof TypeError) {
|
|
15
|
+
return { ok: false, kind: "transport-failure", message: "Vehicle transport failed", retryable: true, ...operation };
|
|
16
|
+
}
|
|
17
|
+
return { ok: false, kind: "unexpected-failure", message: "Vehicle invocation failed unexpectedly", ...operation };
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export * from "./jobs/index.js";
|
|
|
26
26
|
export * from "./manifest/index.js";
|
|
27
27
|
export * from "./operations/index.js";
|
|
28
28
|
export * from "./persistence/index.js";
|
|
29
|
+
export * from "./protocol/index.js";
|
|
29
30
|
export * from "./resource-pool/index.js";
|
|
30
31
|
export * from "./schedules/index.js";
|
|
31
32
|
export * from "./schemas/index.js";
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ export * from "./jobs/index.js";
|
|
|
26
26
|
export * from "./manifest/index.js";
|
|
27
27
|
export * from "./operations/index.js";
|
|
28
28
|
export * from "./persistence/index.js";
|
|
29
|
+
export * from "./protocol/index.js";
|
|
29
30
|
export * from "./resource-pool/index.js";
|
|
30
31
|
export * from "./schedules/index.js";
|
|
31
32
|
export * from "./schemas/index.js";
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { VehicleManifestEvent } from "../events/event.js";
|
|
2
2
|
import type { VehicleOperationDescriptor } from "../operations/operation.js";
|
|
3
|
+
import type { VehicleProtocolSupport } from "../protocol/negotiation.js";
|
|
3
4
|
export interface VehicleManifestIdentity {
|
|
4
5
|
readonly name: string;
|
|
5
6
|
readonly version: string;
|
|
6
7
|
readonly description: string;
|
|
7
8
|
readonly guidance?: readonly string[];
|
|
9
|
+
/** Wire compatibility served independently from this package's version and each operation's version. */
|
|
10
|
+
readonly protocol?: VehicleProtocolSupport;
|
|
8
11
|
}
|
|
9
12
|
/**
|
|
10
13
|
* A manifest's own view of an operation: the static descriptor plus
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./negotiation.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./negotiation.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const VEHICLE_PROTOCOL_VERSION = 1;
|
|
2
|
+
export declare const MAX_VEHICLE_PROTOCOL_CAPABILITIES = 64;
|
|
3
|
+
export declare const MAX_VEHICLE_PROTOCOL_CAPABILITY_LENGTH = 128;
|
|
4
|
+
export declare const MAX_VEHICLE_PROTOCOL_OFFER_BYTES: number;
|
|
5
|
+
/** Describes the wire versions and optional features one Vehicle server can serve. */
|
|
6
|
+
export interface VehicleProtocolSupport {
|
|
7
|
+
readonly minimumVersion: number;
|
|
8
|
+
readonly maximumVersion: number;
|
|
9
|
+
readonly capabilities: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
/** Describes the compatibility range and features one client requests before invoking operations. */
|
|
12
|
+
export interface VehicleProtocolOffer {
|
|
13
|
+
readonly minimumVersion: number;
|
|
14
|
+
readonly maximumVersion: number;
|
|
15
|
+
readonly requiredCapabilities: readonly string[];
|
|
16
|
+
readonly optionalCapabilities: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
/** Records the highest shared wire version and capabilities accepted by both peers. */
|
|
19
|
+
export interface VehicleProtocolAgreement {
|
|
20
|
+
readonly version: number;
|
|
21
|
+
readonly capabilities: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
export type VehicleProtocolNegotiationFailureCode = "protocol-offer-invalid" | "protocol-support-invalid" | "protocol-version-incompatible" | "protocol-capability-unsupported";
|
|
24
|
+
export type VehicleProtocolNegotiationResult = {
|
|
25
|
+
readonly ok: true;
|
|
26
|
+
readonly value: VehicleProtocolAgreement;
|
|
27
|
+
} | {
|
|
28
|
+
readonly ok: false;
|
|
29
|
+
readonly code: VehicleProtocolNegotiationFailureCode;
|
|
30
|
+
readonly message: string;
|
|
31
|
+
};
|
|
32
|
+
export declare const DEFAULT_VEHICLE_PROTOCOL_SUPPORT: VehicleProtocolSupport;
|
|
33
|
+
/** Validates a protocol agreement received across an untrusted wire boundary. */
|
|
34
|
+
export declare function isVehicleProtocolAgreement(value: unknown): value is VehicleProtocolAgreement;
|
|
35
|
+
/** Negotiates one bounded protocol agreement without performing transport I/O. */
|
|
36
|
+
export declare function negotiateVehicleProtocol(support: VehicleProtocolSupport, offer: VehicleProtocolOffer): VehicleProtocolNegotiationResult;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export const VEHICLE_PROTOCOL_VERSION = 1;
|
|
2
|
+
export const MAX_VEHICLE_PROTOCOL_CAPABILITIES = 64;
|
|
3
|
+
export const MAX_VEHICLE_PROTOCOL_CAPABILITY_LENGTH = 128;
|
|
4
|
+
export const MAX_VEHICLE_PROTOCOL_OFFER_BYTES = 16 * 1024;
|
|
5
|
+
export const DEFAULT_VEHICLE_PROTOCOL_SUPPORT = Object.freeze({
|
|
6
|
+
minimumVersion: VEHICLE_PROTOCOL_VERSION,
|
|
7
|
+
maximumVersion: VEHICLE_PROTOCOL_VERSION,
|
|
8
|
+
capabilities: Object.freeze([]),
|
|
9
|
+
});
|
|
10
|
+
function validVersionRange(minimumVersion, maximumVersion) {
|
|
11
|
+
return (Number.isSafeInteger(minimumVersion) && minimumVersion > 0 && Number.isSafeInteger(maximumVersion) && maximumVersion >= minimumVersion);
|
|
12
|
+
}
|
|
13
|
+
function validCapabilities(capabilities) {
|
|
14
|
+
if (!Array.isArray(capabilities) || capabilities.length > MAX_VEHICLE_PROTOCOL_CAPABILITIES)
|
|
15
|
+
return false;
|
|
16
|
+
const unique = new Set();
|
|
17
|
+
for (const capability of capabilities) {
|
|
18
|
+
if (!capability.trim() || capability.length > MAX_VEHICLE_PROTOCOL_CAPABILITY_LENGTH || unique.has(capability))
|
|
19
|
+
return false;
|
|
20
|
+
unique.add(capability);
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** Validates a protocol agreement received across an untrusted wire boundary. */
|
|
25
|
+
export function isVehicleProtocolAgreement(value) {
|
|
26
|
+
if (typeof value !== "object" || value === null)
|
|
27
|
+
return false;
|
|
28
|
+
const agreement = value;
|
|
29
|
+
return Number.isSafeInteger(agreement.version) && agreement.version > 0 && validCapabilities(agreement.capabilities);
|
|
30
|
+
}
|
|
31
|
+
/** Negotiates one bounded protocol agreement without performing transport I/O. */
|
|
32
|
+
export function negotiateVehicleProtocol(support, offer) {
|
|
33
|
+
if (!validVersionRange(support.minimumVersion, support.maximumVersion) || !validCapabilities(support.capabilities)) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
code: "protocol-support-invalid",
|
|
37
|
+
message: "Vehicle protocol support is malformed or exceeds its capability bound",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!validVersionRange(offer.minimumVersion, offer.maximumVersion) ||
|
|
41
|
+
!validCapabilities(offer.requiredCapabilities) ||
|
|
42
|
+
!validCapabilities(offer.optionalCapabilities) ||
|
|
43
|
+
offer.requiredCapabilities.length + offer.optionalCapabilities.length > MAX_VEHICLE_PROTOCOL_CAPABILITIES) {
|
|
44
|
+
return { ok: false, code: "protocol-offer-invalid", message: "Vehicle protocol offer is malformed or exceeds its capability bound" };
|
|
45
|
+
}
|
|
46
|
+
const minimumSharedVersion = Math.max(support.minimumVersion, offer.minimumVersion);
|
|
47
|
+
const maximumSharedVersion = Math.min(support.maximumVersion, offer.maximumVersion);
|
|
48
|
+
if (minimumSharedVersion > maximumSharedVersion) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
code: "protocol-version-incompatible",
|
|
52
|
+
message: `Vehicle protocol versions do not overlap: server ${support.minimumVersion}-${support.maximumVersion}, client ${offer.minimumVersion}-${offer.maximumVersion}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const supported = new Set(support.capabilities);
|
|
56
|
+
for (const capability of offer.requiredCapabilities) {
|
|
57
|
+
if (!supported.has(capability)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: "protocol-capability-unsupported",
|
|
61
|
+
message: `Vehicle protocol requires unsupported capability "${capability}"`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const capabilities = [...offer.requiredCapabilities];
|
|
66
|
+
for (const capability of offer.optionalCapabilities) {
|
|
67
|
+
if (supported.has(capability) && !capabilities.includes(capability))
|
|
68
|
+
capabilities.push(capability);
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, value: Object.freeze({ version: maximumSharedVersion, capabilities: Object.freeze(capabilities) }) };
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
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",
|
|
@@ -42,6 +42,21 @@ export const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[] = ["destructive"
|
|
|
42
42
|
*/
|
|
43
43
|
export const VEHICLE_APPROVAL_RESOLVE_OPERATION_NAME = "vehicle.approval.resolve";
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The name VehicleRegistry.configureApprovals() registers its own read-only status-lookup
|
|
47
|
+
* operation under. Unlike vehicle.approval.resolve, this one is deliberately left projectable
|
|
48
|
+
* as an ordinary Pi/model-callable tool: reading a requestId's own already-made decision (who
|
|
49
|
+
* decided, when, and any comment) can never let a caller grant its own pending request -- it
|
|
50
|
+
* only ever surfaces a decision a human (or other authority) already recorded. Exists because a
|
|
51
|
+
* gated invoke()'s own local HITL prompt and the eventual human decision can genuinely happen on
|
|
52
|
+
* two different calls (a caller-side timeout, a UI presented asynchronously outside the
|
|
53
|
+
* originating call's own lifetime, a session restart in between) -- without this, a decision
|
|
54
|
+
* (and any comment explaining it) made after the originating call already gave up is
|
|
55
|
+
* unrecoverable, since vehicle.approval.resolve's own vehicleApprovalResolvedEvent is transient
|
|
56
|
+
* pub/sub with no replay for a caller that wasn't subscribed at that exact moment.
|
|
57
|
+
*/
|
|
58
|
+
export const VEHICLE_APPROVAL_STATUS_OPERATION_NAME = "vehicle.approval.status";
|
|
59
|
+
|
|
45
60
|
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
46
61
|
export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
47
62
|
|
package/src/client/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { VehicleJobSnapshot, VehicleJobSubmitOptions, VehicleJobSubmitResult, VehicleJobTailResult } from "../jobs/wire.js";
|
|
2
2
|
import type { VehicleManifest } from "../manifest/manifest.js";
|
|
3
3
|
import type { VehicleInvocationOptions } from "../operations/context.js";
|
|
4
|
+
import type { VehicleProtocolAgreement, VehicleProtocolOffer } from "../protocol/negotiation.js";
|
|
4
5
|
|
|
5
6
|
export interface VehicleSubscription {
|
|
6
7
|
close(): void;
|
|
@@ -8,6 +9,8 @@ export interface VehicleSubscription {
|
|
|
8
9
|
|
|
9
10
|
export interface VehicleClient {
|
|
10
11
|
manifest(): Promise<VehicleManifest>;
|
|
12
|
+
/** Optional for compatibility with clients authored before explicit wire negotiation. */
|
|
13
|
+
negotiate?(offer: VehicleProtocolOffer): Promise<VehicleProtocolAgreement>;
|
|
11
14
|
invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
|
|
12
15
|
close(): Promise<void>;
|
|
13
16
|
/**
|
package/src/client/index.ts
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { isVehicleError, type VehicleFailure } from "../errors/error.js";
|
|
2
|
+
import type { VehicleInvocationOptions } from "../operations/context.js";
|
|
3
|
+
import type { VehicleClient } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export type VehicleInvocationOutcome<Output> =
|
|
6
|
+
| { readonly ok: true; readonly value: Output }
|
|
7
|
+
| { readonly ok: false; readonly kind: "vehicle-failure"; readonly failure: VehicleFailure }
|
|
8
|
+
| { readonly ok: false; readonly kind: "cancelled"; readonly message: string; readonly operationId?: string }
|
|
9
|
+
| {
|
|
10
|
+
readonly ok: false;
|
|
11
|
+
readonly kind: "transport-failure";
|
|
12
|
+
readonly message: string;
|
|
13
|
+
readonly retryable: true;
|
|
14
|
+
readonly operationId?: string;
|
|
15
|
+
}
|
|
16
|
+
| { readonly ok: false; readonly kind: "unexpected-failure"; readonly message: string; readonly operationId?: string };
|
|
17
|
+
|
|
18
|
+
/** Invokes one Vehicle operation while representing expected and boundary failures as typed values. */
|
|
19
|
+
export async function invokeVehicleOutcome<Output = unknown>(
|
|
20
|
+
client: VehicleClient,
|
|
21
|
+
name: string,
|
|
22
|
+
version: number,
|
|
23
|
+
input: unknown,
|
|
24
|
+
options: VehicleInvocationOptions = {},
|
|
25
|
+
): Promise<VehicleInvocationOutcome<Output>> {
|
|
26
|
+
try {
|
|
27
|
+
return { ok: true, value: await client.invoke<Output>(name, version, input, options) };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (isVehicleError(error)) return { ok: false, kind: "vehicle-failure", failure: error.toFailure() };
|
|
30
|
+
const operation = options.operationId === undefined ? {} : { operationId: options.operationId };
|
|
31
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
32
|
+
return { ok: false, kind: "cancelled", message: "Vehicle invocation was cancelled", ...operation };
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof TypeError) {
|
|
35
|
+
return { ok: false, kind: "transport-failure", message: "Vehicle transport failed", retryable: true, ...operation };
|
|
36
|
+
}
|
|
37
|
+
return { ok: false, kind: "unexpected-failure", message: "Vehicle invocation failed unexpectedly", ...operation };
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export * from "./jobs/index.js";
|
|
|
26
26
|
export * from "./manifest/index.js";
|
|
27
27
|
export * from "./operations/index.js";
|
|
28
28
|
export * from "./persistence/index.js";
|
|
29
|
+
export * from "./protocol/index.js";
|
|
29
30
|
export * from "./resource-pool/index.js";
|
|
30
31
|
export * from "./schedules/index.js";
|
|
31
32
|
export * from "./schemas/index.js";
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { VehicleManifestEvent } from "../events/event.js";
|
|
2
2
|
import type { VehicleOperationDescriptor } from "../operations/operation.js";
|
|
3
|
+
import type { VehicleProtocolSupport } from "../protocol/negotiation.js";
|
|
3
4
|
|
|
4
5
|
export interface VehicleManifestIdentity {
|
|
5
6
|
readonly name: string;
|
|
6
7
|
readonly version: string;
|
|
7
8
|
readonly description: string;
|
|
8
9
|
readonly guidance?: readonly string[];
|
|
10
|
+
/** Wire compatibility served independently from this package's version and each operation's version. */
|
|
11
|
+
readonly protocol?: VehicleProtocolSupport;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./negotiation.js";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export const VEHICLE_PROTOCOL_VERSION = 1;
|
|
2
|
+
export const MAX_VEHICLE_PROTOCOL_CAPABILITIES = 64;
|
|
3
|
+
export const MAX_VEHICLE_PROTOCOL_CAPABILITY_LENGTH = 128;
|
|
4
|
+
export const MAX_VEHICLE_PROTOCOL_OFFER_BYTES = 16 * 1024;
|
|
5
|
+
|
|
6
|
+
/** Describes the wire versions and optional features one Vehicle server can serve. */
|
|
7
|
+
export interface VehicleProtocolSupport {
|
|
8
|
+
readonly minimumVersion: number;
|
|
9
|
+
readonly maximumVersion: number;
|
|
10
|
+
readonly capabilities: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Describes the compatibility range and features one client requests before invoking operations. */
|
|
14
|
+
export interface VehicleProtocolOffer {
|
|
15
|
+
readonly minimumVersion: number;
|
|
16
|
+
readonly maximumVersion: number;
|
|
17
|
+
readonly requiredCapabilities: readonly string[];
|
|
18
|
+
readonly optionalCapabilities: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Records the highest shared wire version and capabilities accepted by both peers. */
|
|
22
|
+
export interface VehicleProtocolAgreement {
|
|
23
|
+
readonly version: number;
|
|
24
|
+
readonly capabilities: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type VehicleProtocolNegotiationFailureCode =
|
|
28
|
+
| "protocol-offer-invalid"
|
|
29
|
+
| "protocol-support-invalid"
|
|
30
|
+
| "protocol-version-incompatible"
|
|
31
|
+
| "protocol-capability-unsupported";
|
|
32
|
+
|
|
33
|
+
export type VehicleProtocolNegotiationResult =
|
|
34
|
+
| { readonly ok: true; readonly value: VehicleProtocolAgreement }
|
|
35
|
+
| { readonly ok: false; readonly code: VehicleProtocolNegotiationFailureCode; readonly message: string };
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_VEHICLE_PROTOCOL_SUPPORT: VehicleProtocolSupport = Object.freeze({
|
|
38
|
+
minimumVersion: VEHICLE_PROTOCOL_VERSION,
|
|
39
|
+
maximumVersion: VEHICLE_PROTOCOL_VERSION,
|
|
40
|
+
capabilities: Object.freeze([]),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function validVersionRange(minimumVersion: number, maximumVersion: number): boolean {
|
|
44
|
+
return (
|
|
45
|
+
Number.isSafeInteger(minimumVersion) && minimumVersion > 0 && Number.isSafeInteger(maximumVersion) && maximumVersion >= minimumVersion
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validCapabilities(capabilities: unknown): capabilities is readonly string[] {
|
|
50
|
+
if (!Array.isArray(capabilities) || capabilities.length > MAX_VEHICLE_PROTOCOL_CAPABILITIES) return false;
|
|
51
|
+
const unique = new Set<string>();
|
|
52
|
+
for (const capability of capabilities) {
|
|
53
|
+
if (!capability.trim() || capability.length > MAX_VEHICLE_PROTOCOL_CAPABILITY_LENGTH || unique.has(capability)) return false;
|
|
54
|
+
unique.add(capability);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validates a protocol agreement received across an untrusted wire boundary. */
|
|
60
|
+
export function isVehicleProtocolAgreement(value: unknown): value is VehicleProtocolAgreement {
|
|
61
|
+
if (typeof value !== "object" || value === null) return false;
|
|
62
|
+
const agreement = value as { version?: unknown; capabilities?: unknown };
|
|
63
|
+
return Number.isSafeInteger(agreement.version) && (agreement.version as number) > 0 && validCapabilities(agreement.capabilities);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Negotiates one bounded protocol agreement without performing transport I/O. */
|
|
67
|
+
export function negotiateVehicleProtocol(support: VehicleProtocolSupport, offer: VehicleProtocolOffer): VehicleProtocolNegotiationResult {
|
|
68
|
+
if (!validVersionRange(support.minimumVersion, support.maximumVersion) || !validCapabilities(support.capabilities)) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
code: "protocol-support-invalid",
|
|
72
|
+
message: "Vehicle protocol support is malformed or exceeds its capability bound",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
!validVersionRange(offer.minimumVersion, offer.maximumVersion) ||
|
|
77
|
+
!validCapabilities(offer.requiredCapabilities) ||
|
|
78
|
+
!validCapabilities(offer.optionalCapabilities) ||
|
|
79
|
+
offer.requiredCapabilities.length + offer.optionalCapabilities.length > MAX_VEHICLE_PROTOCOL_CAPABILITIES
|
|
80
|
+
) {
|
|
81
|
+
return { ok: false, code: "protocol-offer-invalid", message: "Vehicle protocol offer is malformed or exceeds its capability bound" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const minimumSharedVersion = Math.max(support.minimumVersion, offer.minimumVersion);
|
|
85
|
+
const maximumSharedVersion = Math.min(support.maximumVersion, offer.maximumVersion);
|
|
86
|
+
if (minimumSharedVersion > maximumSharedVersion) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
code: "protocol-version-incompatible",
|
|
90
|
+
message: `Vehicle protocol versions do not overlap: server ${support.minimumVersion}-${support.maximumVersion}, client ${offer.minimumVersion}-${offer.maximumVersion}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const supported = new Set(support.capabilities);
|
|
95
|
+
for (const capability of offer.requiredCapabilities) {
|
|
96
|
+
if (!supported.has(capability)) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
code: "protocol-capability-unsupported",
|
|
100
|
+
message: `Vehicle protocol requires unsupported capability "${capability}"`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const capabilities = [...offer.requiredCapabilities];
|
|
106
|
+
for (const capability of offer.optionalCapabilities) {
|
|
107
|
+
if (supported.has(capability) && !capabilities.includes(capability)) capabilities.push(capability);
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, value: Object.freeze({ version: maximumSharedVersion, capabilities: Object.freeze(capabilities) }) };
|
|
110
|
+
}
|