@danypops/vehicle-core 0.16.0 → 0.17.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/README.md +12 -0
- package/dist/{vehicle-approvals.d.ts → approvals/approval.d.ts} +4 -11
- package/dist/{vehicle-approvals.js → approvals/approval.js} +53 -12
- package/dist/approvals/index.d.ts +1 -0
- package/dist/approvals/index.js +1 -0
- package/dist/client/client.d.ts +29 -0
- package/dist/client/client.js +1 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/content/content.d.ts +32 -0
- package/dist/content/content.js +25 -0
- package/dist/content/index.d.ts +1 -0
- package/dist/content/index.js +1 -0
- package/dist/{vehicle-errors.d.ts → errors/error.d.ts} +22 -10
- package/dist/{vehicle-errors.js → errors/error.js} +20 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/events/event.d.ts +42 -0
- package/dist/events/event.js +35 -0
- package/dist/events/index.d.ts +1 -0
- package/dist/events/index.js +1 -0
- package/dist/idempotency/index.d.ts +1 -0
- package/dist/idempotency/index.js +1 -0
- package/dist/idempotency/receipt.d.ts +66 -0
- package/dist/idempotency/receipt.js +42 -0
- package/dist/index.d.ts +26 -7
- package/dist/index.js +26 -7
- package/dist/jobs/identity.d.ts +11 -0
- package/dist/jobs/identity.js +13 -0
- package/dist/jobs/index.d.ts +7 -0
- package/dist/jobs/index.js +7 -0
- package/dist/jobs/replay.d.ts +8 -0
- package/dist/jobs/replay.js +9 -0
- package/dist/jobs/retention.d.ts +26 -0
- package/dist/jobs/retention.js +39 -0
- package/dist/jobs/steer.d.ts +23 -0
- package/dist/jobs/steer.js +41 -0
- package/dist/jobs/termination.d.ts +5 -0
- package/dist/jobs/termination.js +11 -0
- package/dist/jobs/wake-log.d.ts +39 -0
- package/dist/jobs/wake-log.js +65 -0
- package/dist/jobs/wire.d.ts +47 -0
- package/dist/jobs/wire.js +1 -0
- package/dist/manifest/index.d.ts +1 -0
- package/dist/manifest/index.js +1 -0
- package/dist/manifest/manifest.d.ts +49 -0
- package/dist/manifest/manifest.js +1 -0
- package/dist/operations/context.d.ts +52 -0
- package/dist/operations/context.js +1 -0
- package/dist/operations/effect.d.ts +3 -0
- package/dist/operations/effect.js +2 -0
- package/dist/operations/index.d.ts +3 -0
- package/dist/operations/index.js +3 -0
- package/dist/operations/operation.d.ts +98 -0
- package/dist/operations/operation.js +79 -0
- package/dist/{atomic-json.d.ts → persistence/atomic-json.d.ts} +1 -2
- package/dist/{atomic-json.js → persistence/atomic-json.js} +1 -2
- package/dist/persistence/index.d.ts +1 -0
- package/dist/persistence/index.js +1 -0
- package/dist/schedules/index.d.ts +1 -0
- package/dist/schedules/index.js +1 -0
- package/dist/{vehicle-scheduler.d.ts → schedules/schedule.d.ts} +23 -9
- package/dist/schedules/schedule.js +61 -0
- package/dist/schemas/codec.d.ts +25 -0
- package/dist/schemas/codec.js +7 -0
- package/dist/schemas/index.d.ts +4 -0
- package/dist/schemas/index.js +4 -0
- package/dist/schemas/json.d.ts +7 -0
- package/dist/schemas/json.js +14 -0
- package/dist/schemas/loose-object.d.ts +20 -0
- package/dist/schemas/loose-object.js +73 -0
- package/dist/schemas/presentation.d.ts +11 -0
- package/dist/schemas/presentation.js +22 -0
- package/dist/watches/index.d.ts +1 -0
- package/dist/watches/index.js +1 -0
- package/dist/{vehicle-watchers.d.ts → watches/registry.d.ts} +2 -9
- package/dist/{vehicle-watchers.js → watches/registry.js} +2 -9
- package/package.json +5 -1
- package/src/{vehicle-approvals.ts → approvals/approval.ts} +47 -13
- package/src/approvals/index.ts +1 -0
- package/src/client/client.ts +31 -0
- package/src/client/index.ts +1 -0
- package/src/content/content.ts +46 -0
- package/src/content/index.ts +1 -0
- package/src/{vehicle-errors.ts → errors/error.ts} +32 -9
- package/src/errors/index.ts +1 -0
- package/src/events/event.ts +72 -0
- package/src/events/index.ts +1 -0
- package/src/idempotency/index.ts +1 -0
- package/src/idempotency/receipt.ts +97 -0
- package/src/index.ts +26 -7
- package/src/jobs/identity.ts +13 -0
- package/src/jobs/index.ts +7 -0
- package/src/jobs/replay.ts +17 -0
- package/src/jobs/retention.ts +59 -0
- package/src/jobs/steer.ts +49 -0
- package/src/jobs/termination.ts +13 -0
- package/src/jobs/wake-log.ts +98 -0
- package/src/jobs/wire.ts +51 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.ts +52 -0
- package/src/operations/context.ts +56 -0
- package/src/operations/effect.ts +3 -0
- package/src/operations/index.ts +3 -0
- package/src/operations/operation.ts +184 -0
- package/src/{atomic-json.ts → persistence/atomic-json.ts} +1 -2
- package/src/persistence/index.ts +1 -0
- package/src/schedules/index.ts +1 -0
- package/src/{vehicle-scheduler.ts → schedules/schedule.ts} +32 -9
- package/src/schemas/codec.ts +30 -0
- package/src/schemas/index.ts +4 -0
- package/src/schemas/json.ts +17 -0
- package/src/schemas/loose-object.ts +82 -0
- package/src/schemas/presentation.ts +28 -0
- package/src/watches/index.ts +1 -0
- package/src/{vehicle-watchers.ts → watches/registry.ts} +2 -9
- package/dist/vehicle-contract.d.ts +0 -347
- package/dist/vehicle-contract.js +0 -219
- package/dist/vehicle-jobs.d.ts +0 -159
- package/dist/vehicle-jobs.js +0 -182
- package/dist/vehicle-scheduler.js +0 -33
- package/src/vehicle-contract.ts +0 -563
- package/src/vehicle-jobs.ts +0 -305
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A block of narrative text meant to be read by the model, not parsed as
|
|
3
|
+
* data -- same field name and shape MCP's own CallToolResult.content and
|
|
4
|
+
* Pi's own ToolDefinition.execute() return already use, so a Vehicle
|
|
5
|
+
* operation adopting this needs no translation layer at either boundary.
|
|
6
|
+
* Only the "text" variant exists here; there's no Vehicle use case yet for
|
|
7
|
+
* MCP's image/audio/resource-link block kinds.
|
|
8
|
+
*/
|
|
9
|
+
export interface VehicleContentBlock {
|
|
10
|
+
readonly type: "text";
|
|
11
|
+
readonly text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An operation's Output type can intersect this to carry its own
|
|
16
|
+
* model-facing narrative alongside its structured data, e.g.
|
|
17
|
+
* `type RunOutput = { runId: string; created: Task[] } & WithVehicleContent`.
|
|
18
|
+
* The operation itself builds `content` since it's the only code that
|
|
19
|
+
* actually knows how to describe what it computed -- never a per-consumer
|
|
20
|
+
* override bolted on wherever the operation happens to get registered.
|
|
21
|
+
*/
|
|
22
|
+
export interface WithVehicleContent {
|
|
23
|
+
readonly content?: readonly VehicleContentBlock[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reads an operation's own `content` blocks off its output when present and
|
|
28
|
+
* well-formed, so a generic Vehicle client can prefer them over dumping raw
|
|
29
|
+
* JSON at the model -- without knowing anything about the operation's own
|
|
30
|
+
* domain shape. Returns undefined for a malformed or absent `content` field;
|
|
31
|
+
* the caller falls back to its own default (formatted JSON) rather than
|
|
32
|
+
* risk forwarding partial/garbled blocks.
|
|
33
|
+
*/
|
|
34
|
+
export function extractVehicleContent(output: unknown): readonly VehicleContentBlock[] | undefined {
|
|
35
|
+
if (typeof output !== "object" || output === null || Array.isArray(output)) return undefined;
|
|
36
|
+
const content = (output as { readonly content?: unknown }).content;
|
|
37
|
+
if (!Array.isArray(content) || content.length === 0) return undefined;
|
|
38
|
+
const blocks: VehicleContentBlock[] = [];
|
|
39
|
+
for (const block of content) {
|
|
40
|
+
if (typeof block !== "object" || block === null) return undefined;
|
|
41
|
+
const { type, text } = block as { readonly type?: unknown; readonly text?: unknown };
|
|
42
|
+
if (type !== "text" || typeof text !== "string") return undefined;
|
|
43
|
+
blocks.push({ type: "text", text });
|
|
44
|
+
}
|
|
45
|
+
return blocks;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./content.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { VehicleSchemaIssue } from "../schemas/codec.js";
|
|
2
|
+
import type { JsonValue } from "../schemas/json.js";
|
|
2
3
|
|
|
3
4
|
const VEHICLE_ERROR_BRAND = Symbol.for("@danypops/vehicle-core/VehicleError");
|
|
4
5
|
|
|
@@ -26,6 +27,7 @@ export type VehicleCoreErrorCode =
|
|
|
26
27
|
| "handler-failed"
|
|
27
28
|
| "policy-failed"
|
|
28
29
|
| "idempotency-key-required"
|
|
30
|
+
| "idempotency-conflict"
|
|
29
31
|
| "client-closed"
|
|
30
32
|
| "operation-unavailable"
|
|
31
33
|
| "background-not-supported"
|
|
@@ -54,10 +56,10 @@ export interface VehicleFailure {
|
|
|
54
56
|
export interface VehicleErrorOptions {
|
|
55
57
|
readonly category: VehicleFailureCategory;
|
|
56
58
|
readonly retryable?: boolean;
|
|
57
|
-
readonly retryAfterMs?: number;
|
|
58
|
-
readonly recovery?: VehicleRecovery;
|
|
59
|
-
readonly details?: JsonValue;
|
|
60
|
-
readonly operationId?: string;
|
|
59
|
+
readonly retryAfterMs?: number | undefined;
|
|
60
|
+
readonly recovery?: VehicleRecovery | undefined;
|
|
61
|
+
readonly details?: JsonValue | undefined;
|
|
62
|
+
readonly operationId?: string | undefined;
|
|
61
63
|
readonly cause?: unknown;
|
|
62
64
|
/** Includes cause's message in toFailure().causeMessage. Default false -- an arbitrary cause could carry a credential or internal detail. */
|
|
63
65
|
readonly exposeCause?: boolean;
|
|
@@ -112,10 +114,10 @@ export function defineErrorMapping(
|
|
|
112
114
|
export class VehicleError extends Error {
|
|
113
115
|
readonly category: VehicleFailureCategory;
|
|
114
116
|
readonly retryable: boolean;
|
|
115
|
-
readonly retryAfterMs?: number;
|
|
116
|
-
readonly recovery?: VehicleRecovery;
|
|
117
|
-
readonly details?: JsonValue;
|
|
118
|
-
readonly operationId?: string;
|
|
117
|
+
readonly retryAfterMs?: number | undefined;
|
|
118
|
+
readonly recovery?: VehicleRecovery | undefined;
|
|
119
|
+
readonly details?: JsonValue | undefined;
|
|
120
|
+
readonly operationId?: string | undefined;
|
|
119
121
|
private readonly exposeCause: boolean;
|
|
120
122
|
|
|
121
123
|
constructor(
|
|
@@ -156,6 +158,27 @@ export function isVehicleError(value: unknown): value is VehicleError {
|
|
|
156
158
|
return value instanceof Error && Reflect.get(value, VEHICLE_ERROR_BRAND) === true;
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Reconstructs a throwable VehicleError from a previously-serialized VehicleFailure -- the inverse
|
|
163
|
+
* of VehicleError.prototype.toFailure(), needed anywhere a wire-safe failure gets replayed as a
|
|
164
|
+
* real rejection later (e.g. VehicleIdempotencyPolicy replaying a settled failed receipt to a
|
|
165
|
+
* second caller reusing the same idempotency key). Lossy on purpose: a VehicleFailure never
|
|
166
|
+
* carries the original `cause` (toFailure() already reduced it to an optional bounded
|
|
167
|
+
* causeMessage per the throw site's own exposeCause choice), so the reconstructed error has no
|
|
168
|
+
* cause at all rather than fabricating one -- a replayed failure only needs to match the original
|
|
169
|
+
* code/category/message/details a caller would react to, not its internal cause chain.
|
|
170
|
+
*/
|
|
171
|
+
export function vehicleErrorFromFailure(failure: VehicleFailure): VehicleError {
|
|
172
|
+
return new VehicleError(failure.code, failure.message, {
|
|
173
|
+
category: failure.category,
|
|
174
|
+
retryable: failure.retryable,
|
|
175
|
+
retryAfterMs: failure.retryAfterMs,
|
|
176
|
+
recovery: failure.recovery,
|
|
177
|
+
details: failure.details,
|
|
178
|
+
operationId: failure.operationId,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
const MAX_CAUSE_MESSAGE_LENGTH = 500;
|
|
160
183
|
|
|
161
184
|
/** Extracts a bounded, wire-safe message from an unknown cause -- never the full stack trace, never an unbounded payload. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./error.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { VehicleSchemaCodec } from "../schemas/codec.js";
|
|
2
|
+
import { cloneJson, type JsonSchema } from "../schemas/json.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A named, schema'd event type a provider declares as part of its
|
|
6
|
+
* manifest -- the typed alternative to a raw PushChannel.publish(topic,
|
|
7
|
+
* payload) call with a hand-invented topic string. No `available` flag the
|
|
8
|
+
* way an operation has one: an event type, once declared, is always
|
|
9
|
+
* emittable -- there's no credential-gated "this event is currently
|
|
10
|
+
* unavailable" concept the way a live external-service-backed operation
|
|
11
|
+
* can have.
|
|
12
|
+
*/
|
|
13
|
+
export interface VehicleEventDescriptor {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly version: number;
|
|
16
|
+
readonly description: string;
|
|
17
|
+
readonly payloadSchema: JsonSchema;
|
|
18
|
+
/** Same bounded-resource discipline as an operation's own maxRequestBytes/maxResponseBytes -- required, never silently defaulted. */
|
|
19
|
+
readonly maxPayloadBytes: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VehicleEvent<Payload> {
|
|
23
|
+
readonly descriptor: VehicleEventDescriptor;
|
|
24
|
+
readonly payload: VehicleSchemaCodec<Payload>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DefineVehicleEventOptions<Payload> {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly version: number;
|
|
30
|
+
readonly description: string;
|
|
31
|
+
readonly payload: VehicleSchemaCodec<Payload>;
|
|
32
|
+
readonly maxPayloadBytes: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateEventMetadata<Payload>(options: DefineVehicleEventOptions<Payload>): void {
|
|
36
|
+
if (!options.name.trim()) throw new Error("Vehicle event name must not be empty");
|
|
37
|
+
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
38
|
+
throw new Error("Vehicle event version must be a positive integer");
|
|
39
|
+
}
|
|
40
|
+
if (!options.description.trim()) throw new Error("Vehicle event description must not be empty");
|
|
41
|
+
if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes < 1) {
|
|
42
|
+
throw new Error("Vehicle event maxPayloadBytes must be a positive integer");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function defineVehicleEvent<Payload>(options: DefineVehicleEventOptions<Payload>): VehicleEvent<Payload> {
|
|
47
|
+
validateEventMetadata(options);
|
|
48
|
+
const descriptor: VehicleEventDescriptor = Object.freeze({
|
|
49
|
+
name: options.name,
|
|
50
|
+
version: options.version,
|
|
51
|
+
description: options.description,
|
|
52
|
+
payloadSchema: cloneJson(options.payload.jsonSchema),
|
|
53
|
+
maxPayloadBytes: options.maxPayloadBytes,
|
|
54
|
+
});
|
|
55
|
+
return Object.freeze({ descriptor, payload: options.payload });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VehicleManifestEvent = VehicleEventDescriptor;
|
|
59
|
+
|
|
60
|
+
export type VehicleEventHandler<Payload> = (payload: Payload) => void;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
|
|
64
|
+
* vehicle-server) publishes an event under, and a subscriber
|
|
65
|
+
* (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
|
|
66
|
+
* function in vehicle-core so both sides can never drift apart on the
|
|
67
|
+
* convention, the same failure mode this primitive exists to prevent
|
|
68
|
+
* providers from reinventing per-project.
|
|
69
|
+
*/
|
|
70
|
+
export function vehicleEventTopic(name: string, version: number): string {
|
|
71
|
+
return `vehicle-event:${name}@${version}`;
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./event.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./receipt.js";
|
|
@@ -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 "../errors/error.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/index.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Vehicle's runtime-neutral wire contract, organized by capability (see each
|
|
3
|
+
* subfolder's own index.ts) rather than one flat forest of vehicle-*.ts files:
|
|
4
|
+
* schemas (codecs, loose-object validation, credential presentation), content
|
|
5
|
+
* (model-facing narrative blocks), operations (descriptors, effect
|
|
6
|
+
* classification, invocation context), events, manifest, client (the port a
|
|
7
|
+
* caller programs against), approvals (the Approval Gate's wire shapes), jobs
|
|
8
|
+
* (Vehicle Jobs' pure pieces), schedules, watches, and persistence (atomic
|
|
9
|
+
* JSON -- a technical utility, not a Vehicle protocol capability, kept
|
|
10
|
+
* distinct for that reason). Every symbol below is re-exported unchanged
|
|
11
|
+
* from its historical flat-file home, so root-level `import { X } from
|
|
12
|
+
* "@danypops/vehicle-core"` usage is completely unaffected by this layout.
|
|
13
|
+
*/
|
|
14
|
+
export * from "./approvals/index.js";
|
|
15
|
+
export * from "./client/index.js";
|
|
16
|
+
export * from "./content/index.js";
|
|
17
|
+
export * from "./errors/index.js";
|
|
18
|
+
export * from "./events/index.js";
|
|
19
|
+
export * from "./idempotency/index.js";
|
|
20
|
+
export * from "./jobs/index.js";
|
|
21
|
+
export * from "./manifest/index.js";
|
|
22
|
+
export * from "./operations/index.js";
|
|
23
|
+
export * from "./persistence/index.js";
|
|
24
|
+
export * from "./schedules/index.js";
|
|
25
|
+
export * from "./schemas/index.js";
|
|
26
|
+
export * from "./watches/index.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vehicle Jobs run as in-process promises, not child processes -- there is
|
|
3
|
+
* no PID to reuse, but a persisted job record written by one process
|
|
4
|
+
* instance must still never be mistaken for one this (possibly restarted)
|
|
5
|
+
* instance can still resolve. Each VehicleJobStore construction gets a
|
|
6
|
+
* fresh random instanceToken; a persisted record's own stamped token only
|
|
7
|
+
* ever matches the instance that wrote it. A mismatch means "the original
|
|
8
|
+
* run is gone" -- this instance should treat the record as orphaned rather
|
|
9
|
+
* than resolve it.
|
|
10
|
+
*/
|
|
11
|
+
export function vehicleJobIdentityMatches(recordInstanceToken: string, currentInstanceToken: string): boolean {
|
|
12
|
+
return recordInstanceToken === currentInstanceToken;
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { VehicleJobWakeEntry } from "./wake-log.js";
|
|
2
|
+
|
|
3
|
+
/** Read-only replay side of a wake log -- both a live VehicleJobWakeLog and a restored (no-longer-appendable) job satisfy this with the same tail() semantics. */
|
|
4
|
+
export interface VehicleJobWakeLogReader {
|
|
5
|
+
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
6
|
+
readonly cursor: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Wraps a fixed, already-finalized list of entries (e.g. restored from disk) in the same reader shape a live VehicleJobWakeLog exposes, so VehicleJobStore.tail() doesn't need to special-case a restored job. */
|
|
10
|
+
export function createStaticVehicleJobWakeLog(entries: readonly VehicleJobWakeEntry[]): VehicleJobWakeLogReader {
|
|
11
|
+
const sorted = [...entries].sort((a, b) => a.seq - b.seq);
|
|
12
|
+
const cursor = sorted.length > 0 ? sorted[sorted.length - 1]!.seq : 0;
|
|
13
|
+
return {
|
|
14
|
+
since: (cursorArg) => sorted.filter((entry) => entry.seq > cursorArg),
|
|
15
|
+
cursor,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { VehicleJobStatus } from "./termination.js";
|
|
2
|
+
|
|
3
|
+
/** Minimal shape selectVehicleJobsForEviction needs from a job record -- kept separate from VehicleJobSnapshot so vehicle-server doesn't have to construct a full snapshot just to ask "should this be swept". */
|
|
4
|
+
export interface VehicleJobEvictionCandidate {
|
|
5
|
+
readonly jobId: string;
|
|
6
|
+
readonly status: VehicleJobStatus;
|
|
7
|
+
readonly delivered: boolean;
|
|
8
|
+
readonly updatedAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VehicleJobRetentionOptions {
|
|
12
|
+
/** Hard cap on total retained job records (of any status). A running job is never evicted regardless of this cap. */
|
|
13
|
+
readonly maxRetainedJobs: number;
|
|
14
|
+
/** A delivered terminal job becomes eligible for eviction once this many ms have passed since it was delivered (== updatedAt at delivery time). */
|
|
15
|
+
readonly deliveredRetentionMs: number;
|
|
16
|
+
readonly now: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
21
|
+
* bookkeeping so the bounded-retention rule is independently testable.
|
|
22
|
+
* Preference order: (1) delivered and past deliveredRetentionMs, oldest
|
|
23
|
+
* first; (2) once still over maxRetainedJobs, any delivered terminal job,
|
|
24
|
+
* oldest first; (3) only as a last resort, an undelivered terminal job,
|
|
25
|
+
* oldest first -- a real loss (a caller may still want that result), but
|
|
26
|
+
* an unbounded store is a worse failure mode. A running job is never a
|
|
27
|
+
* candidate.
|
|
28
|
+
*/
|
|
29
|
+
export function selectVehicleJobsForEviction(
|
|
30
|
+
candidates: readonly VehicleJobEvictionCandidate[],
|
|
31
|
+
options: VehicleJobRetentionOptions,
|
|
32
|
+
): readonly string[] {
|
|
33
|
+
const terminal = candidates.filter((candidate) => candidate.status !== "running");
|
|
34
|
+
const byAgeAscending = (a: VehicleJobEvictionCandidate, b: VehicleJobEvictionCandidate) => a.updatedAt - b.updatedAt;
|
|
35
|
+
|
|
36
|
+
const evicted = new Set<string>();
|
|
37
|
+
for (const candidate of terminal) {
|
|
38
|
+
if (candidate.delivered && options.now - candidate.updatedAt >= options.deliveredRetentionMs) evicted.add(candidate.jobId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const remainingCount = () => candidates.length - evicted.size;
|
|
42
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
43
|
+
const deliveredOldestFirst = terminal.filter((candidate) => candidate.delivered && !evicted.has(candidate.jobId)).sort(byAgeAscending);
|
|
44
|
+
for (const candidate of deliveredOldestFirst) {
|
|
45
|
+
if (remainingCount() <= options.maxRetainedJobs) break;
|
|
46
|
+
evicted.add(candidate.jobId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
50
|
+
const undeliveredOldestFirst = terminal
|
|
51
|
+
.filter((candidate) => !candidate.delivered && !evicted.has(candidate.jobId))
|
|
52
|
+
.sort(byAgeAscending);
|
|
53
|
+
for (const candidate of undeliveredOldestFirst) {
|
|
54
|
+
if (remainingCount() <= options.maxRetainedJobs) break;
|
|
55
|
+
evicted.add(candidate.jobId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...evicted];
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A job's mid-flight input channel -- the "steer" primitive. Bounded FIFO:
|
|
3
|
+
* push() while a handler isn't yet reading buffers up to maxQueueSize, then
|
|
4
|
+
* refuses further input rather than growing unboundedly or silently
|
|
5
|
+
* overwriting an unread entry. A handler consumes it via `for await (const
|
|
6
|
+
* input of context.steerInputs)`, which ends cleanly once close() is
|
|
7
|
+
* called (VehicleJobStore does this at job finalization).
|
|
8
|
+
*/
|
|
9
|
+
export interface VehicleJobSteerPushResult {
|
|
10
|
+
readonly accepted: boolean;
|
|
11
|
+
readonly dropReason?: "queue-full" | "channel-closed";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class VehicleJobSteerChannel implements AsyncIterable<unknown> {
|
|
15
|
+
private readonly buffer: unknown[] = [];
|
|
16
|
+
private readonly waiters: ((result: IteratorResult<unknown>) => void)[] = [];
|
|
17
|
+
private closed = false;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly maxQueueSize: number = 64) {}
|
|
20
|
+
|
|
21
|
+
push(value: unknown): VehicleJobSteerPushResult {
|
|
22
|
+
if (this.closed) return { accepted: false, dropReason: "channel-closed" };
|
|
23
|
+
const waiter = this.waiters.shift();
|
|
24
|
+
if (waiter) {
|
|
25
|
+
waiter({ value, done: false });
|
|
26
|
+
return { accepted: true };
|
|
27
|
+
}
|
|
28
|
+
if (this.buffer.length >= this.maxQueueSize) return { accepted: false, dropReason: "queue-full" };
|
|
29
|
+
this.buffer.push(value);
|
|
30
|
+
return { accepted: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
34
|
+
close(): void {
|
|
35
|
+
if (this.closed) return;
|
|
36
|
+
this.closed = true;
|
|
37
|
+
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
|
41
|
+
return {
|
|
42
|
+
next: (): Promise<IteratorResult<unknown>> => {
|
|
43
|
+
if (this.buffer.length > 0) return Promise.resolve({ value: this.buffer.shift(), done: false });
|
|
44
|
+
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
|
45
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
2
|
+
|
|
3
|
+
/** 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
|
+
export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "orphaned", "failed", "succeeded"] as const;
|
|
5
|
+
export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
|
|
6
|
+
|
|
7
|
+
export function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason {
|
|
8
|
+
if (candidates.length === 0) throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
|
|
9
|
+
for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
|
|
10
|
+
if (candidates.includes(reason)) return reason;
|
|
11
|
+
}
|
|
12
|
+
throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
|
|
2
|
+
export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
|
|
3
|
+
|
|
4
|
+
export interface VehicleJobWakeBudget {
|
|
5
|
+
readonly maxCount: number;
|
|
6
|
+
readonly maxBytes: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type VehicleJobWakeDropReason =
|
|
10
|
+
| "count-budget-exhausted"
|
|
11
|
+
| "byte-budget-exhausted"
|
|
12
|
+
| "deduplicated-transition"
|
|
13
|
+
| "superseded-by-first-only";
|
|
14
|
+
|
|
15
|
+
export interface VehicleJobWakeEntry {
|
|
16
|
+
readonly seq: number;
|
|
17
|
+
readonly at: number;
|
|
18
|
+
readonly progress: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VehicleJobWakeAppendResult {
|
|
22
|
+
readonly accepted: boolean;
|
|
23
|
+
readonly entry?: VehicleJobWakeEntry;
|
|
24
|
+
readonly dropReason?: VehicleJobWakeDropReason;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VehicleJobWakeLogOptions {
|
|
28
|
+
readonly notifyMode: VehicleJobNotifyMode;
|
|
29
|
+
readonly budget: VehicleJobWakeBudget;
|
|
30
|
+
/** Defaults to Date.now. */
|
|
31
|
+
readonly now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeJsonStringify(value: unknown): string {
|
|
35
|
+
let serialized: string | undefined;
|
|
36
|
+
try {
|
|
37
|
+
serialized = JSON.stringify(value);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
|
|
40
|
+
}
|
|
41
|
+
if (serialized === undefined) throw new Error("Vehicle job progress value is not JSON-serializable");
|
|
42
|
+
return serialized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Non-cryptographic (FNV-1a) -- dedup only. */
|
|
46
|
+
function fnv1aHash(value: string): string {
|
|
47
|
+
let hash = 0x811c9dc5;
|
|
48
|
+
for (let i = 0; i < value.length; i++) {
|
|
49
|
+
hash ^= value.charCodeAt(i);
|
|
50
|
+
hash = Math.imul(hash, 0x01000193);
|
|
51
|
+
}
|
|
52
|
+
return (hash >>> 0).toString(16);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
56
|
+
export class VehicleJobWakeLog {
|
|
57
|
+
private readonly entries: VehicleJobWakeEntry[] = [];
|
|
58
|
+
private usedBytes = 0;
|
|
59
|
+
private nextSeq = 1;
|
|
60
|
+
private lastHash: string | undefined;
|
|
61
|
+
private acceptedFirst = false;
|
|
62
|
+
private readonly now: () => number;
|
|
63
|
+
|
|
64
|
+
constructor(private readonly options: VehicleJobWakeLogOptions) {
|
|
65
|
+
this.now = options.now ?? Date.now;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
append(progress: unknown): VehicleJobWakeAppendResult {
|
|
69
|
+
if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
|
|
70
|
+
return { accepted: false, dropReason: "superseded-by-first-only" };
|
|
71
|
+
}
|
|
72
|
+
const serialized = safeJsonStringify(progress);
|
|
73
|
+
if (this.options.notifyMode === "transition") {
|
|
74
|
+
const hash = fnv1aHash(serialized);
|
|
75
|
+
if (hash === this.lastHash) return { accepted: false, dropReason: "deduplicated-transition" };
|
|
76
|
+
this.lastHash = hash;
|
|
77
|
+
}
|
|
78
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
79
|
+
if (this.entries.length >= this.options.budget.maxCount) return { accepted: false, dropReason: "count-budget-exhausted" };
|
|
80
|
+
if (this.usedBytes + bytes > this.options.budget.maxBytes) return { accepted: false, dropReason: "byte-budget-exhausted" };
|
|
81
|
+
|
|
82
|
+
const entry: VehicleJobWakeEntry = { seq: this.nextSeq++, at: this.now(), progress };
|
|
83
|
+
this.entries.push(entry);
|
|
84
|
+
this.usedBytes += bytes;
|
|
85
|
+
this.acceptedFirst = true;
|
|
86
|
+
return { accepted: true, entry };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
90
|
+
since(cursor: number): readonly VehicleJobWakeEntry[] {
|
|
91
|
+
return this.entries.filter((entry) => entry.seq > cursor);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
95
|
+
get cursor(): number {
|
|
96
|
+
return this.nextSeq - 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/jobs/wire.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { VehicleFailure } from "../errors/error.js";
|
|
2
|
+
import type { VehiclePrincipal } from "../operations/context.js";
|
|
3
|
+
import type { VehicleJobStatus, VehicleJobTerminationReason } from "./termination.js";
|
|
4
|
+
import type { VehicleJobNotifyMode, VehicleJobWakeBudget, VehicleJobWakeEntry } from "./wake-log.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The client-facing wire shapes for Vehicle Jobs -- submit/poll/tail options and results, shared by
|
|
8
|
+
* vehicle-server's VehicleJobStore (the orchestration side) and vehicle-client's job-capable clients
|
|
9
|
+
* (the calling side), so both halves of the wire agree on one definition instead of two structurally
|
|
10
|
+
* -identical copies drifting apart. Every field type referenced here already lives in vehicle-core
|
|
11
|
+
* (VehiclePrincipal, VehicleFailure, VehicleJobStatus, ...), which is what makes it safe for these
|
|
12
|
+
* shapes to live here too, alongside the rest of Vehicle Jobs' pure pieces.
|
|
13
|
+
*/
|
|
14
|
+
export interface VehicleJobSubmitOptions {
|
|
15
|
+
readonly permissions?: readonly string[];
|
|
16
|
+
readonly principal?: VehiclePrincipal;
|
|
17
|
+
readonly idempotencyKey?: string;
|
|
18
|
+
readonly expectedRevision?: string | number;
|
|
19
|
+
readonly approvalCapability?: string;
|
|
20
|
+
readonly correlationId?: string;
|
|
21
|
+
readonly callerSessionId?: string;
|
|
22
|
+
readonly callerProjectRoot?: string;
|
|
23
|
+
/** Defaults to "transition". */
|
|
24
|
+
readonly notifyMode?: VehicleJobNotifyMode;
|
|
25
|
+
/** Defaults to background.defaultWakeBudget; clamped to background.maxWakeBudget either way. */
|
|
26
|
+
readonly wakeBudget?: VehicleJobWakeBudget;
|
|
27
|
+
/** No default -- unset means the job runs until it settles or is canceled. */
|
|
28
|
+
readonly maxLifetimeMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface VehicleJobSubmitResult {
|
|
32
|
+
readonly jobId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface VehicleJobSnapshot {
|
|
36
|
+
readonly jobId: string;
|
|
37
|
+
readonly operationName: string;
|
|
38
|
+
readonly operationVersion: number;
|
|
39
|
+
readonly status: VehicleJobStatus;
|
|
40
|
+
readonly createdAt: number;
|
|
41
|
+
readonly updatedAt: number;
|
|
42
|
+
readonly delivered: boolean;
|
|
43
|
+
readonly terminationReason?: VehicleJobTerminationReason;
|
|
44
|
+
readonly output?: unknown;
|
|
45
|
+
readonly error?: VehicleFailure;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface VehicleJobTailResult {
|
|
49
|
+
readonly entries: readonly VehicleJobWakeEntry[];
|
|
50
|
+
readonly cursor: number;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./manifest.js";
|