@danypops/vehicle-core 0.17.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} +33 -23
- 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} +10 -9
- 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/{vehicle-idempotency.d.ts → idempotency/receipt.d.ts} +1 -1
- package/dist/index.d.ts +26 -8
- package/dist/index.js +26 -8
- 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} +6 -8
- 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} +27 -24
- 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} +10 -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/{vehicle-idempotency.ts → idempotency/receipt.ts} +1 -1
- package/src/index.ts +26 -8
- 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} +6 -8
- 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 -353
- package/dist/vehicle-contract.js +0 -253
- package/dist/vehicle-jobs.d.ts +0 -159
- package/dist/vehicle-jobs.js +0 -182
- package/src/vehicle-contract.ts +0 -597
- package/src/vehicle-jobs.ts +0 -305
- /package/dist/{vehicle-errors.js → errors/error.js} +0 -0
- /package/dist/{vehicle-idempotency.js → idempotency/receipt.js} +0 -0
- /package/dist/{vehicle-scheduler.js → schedules/schedule.js} +0 -0
package/dist/vehicle-contract.js
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
/** Shared defense-in-depth vocabulary for credential-shaped fields at every Vehicle projection boundary. */
|
|
2
|
-
export const VEHICLE_CREDENTIAL_FIELD_NAMES = Object.freeze([
|
|
3
|
-
"password",
|
|
4
|
-
"token",
|
|
5
|
-
"accessToken",
|
|
6
|
-
"refreshToken",
|
|
7
|
-
"apiKey",
|
|
8
|
-
"secret",
|
|
9
|
-
"authorization",
|
|
10
|
-
"credential",
|
|
11
|
-
]);
|
|
12
|
-
const NORMALIZED_VEHICLE_CREDENTIAL_FIELD_NAMES = new Set(VEHICLE_CREDENTIAL_FIELD_NAMES.map((name) => name.replace(/[^a-z0-9]/gi, "").toLowerCase()));
|
|
13
|
-
/** Case/separator-insensitive credential-name check used as a fallback when a schema annotation is missing. */
|
|
14
|
-
export function isVehicleCredentialFieldName(name) {
|
|
15
|
-
return NORMALIZED_VEHICLE_CREDENTIAL_FIELD_NAMES.has(name.replace(/[^a-z0-9]/gi, "").toLowerCase());
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* JSON Schema property annotation consumed by human-facing Vehicle adapters.
|
|
19
|
-
* `omit` hides the field; `summarize` may show shape/size but never its value.
|
|
20
|
-
* Standard `writeOnly: true` and `format: "password"` always imply omission.
|
|
21
|
-
*/
|
|
22
|
-
export const VEHICLE_SCHEMA_PRESENTATION_EXTENSION = "x-vehicle-presentation";
|
|
23
|
-
export function defineVehicleSchema(codec) {
|
|
24
|
-
return Object.freeze({
|
|
25
|
-
jsonSchema: cloneJson(codec.jsonSchema),
|
|
26
|
-
safeParse: codec.safeParse,
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
/** JSON Schema's own `type` keyword vocabulary -- checked for real below so a declared `type: "number"` (say) can't silently accept a string forever. `"integer"` additionally requires no fractional part, matching JSON Schema's own distinction from plain `"number"`. An unrecognized type name is treated as "anything goes" (matches passthroughVehicleSchema's own precedent for a shape not worth strictly enforcing) rather than rejecting every input outright for what would otherwise be a schema-authoring typo. */
|
|
30
|
-
function matchesLooseObjectPropertyType(type, value) {
|
|
31
|
-
switch (type) {
|
|
32
|
-
case "string":
|
|
33
|
-
return typeof value === "string";
|
|
34
|
-
case "number":
|
|
35
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
36
|
-
case "integer":
|
|
37
|
-
return typeof value === "number" && Number.isInteger(value);
|
|
38
|
-
case "boolean":
|
|
39
|
-
return typeof value === "boolean";
|
|
40
|
-
case "object":
|
|
41
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
-
case "array":
|
|
43
|
-
return Array.isArray(value);
|
|
44
|
-
case "null":
|
|
45
|
-
return value === null;
|
|
46
|
-
default:
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
52
|
-
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
53
|
-
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
54
|
-
* has to be checked here for real, or it's a documentation gesture, not an
|
|
55
|
-
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
56
|
-
* had before: it always advertised `additionalProperties: false` and a
|
|
57
|
-
* per-property `type`, while safeParse only ever checked `required` and
|
|
58
|
-
* `enum`). Every consumer projecting a plain-object input onto a
|
|
59
|
-
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
60
|
-
* is that check written once.
|
|
61
|
-
*/
|
|
62
|
-
export function defineLooseObjectSchema(properties, required = []) {
|
|
63
|
-
return defineVehicleSchema({
|
|
64
|
-
// LooseObjectProperty's named fields (type, enum) are all JSON-value-shaped
|
|
65
|
-
// at runtime, but TypeScript's structural check against the recursive
|
|
66
|
-
// JsonValue union doesn't see that through a plain interface -- the cast
|
|
67
|
-
// is a type-system limitation, not a runtime concern.
|
|
68
|
-
jsonSchema: { type: "object", properties: properties, required: [...required], additionalProperties: false },
|
|
69
|
-
safeParse(value) {
|
|
70
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
71
|
-
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
72
|
-
}
|
|
73
|
-
const input = value;
|
|
74
|
-
for (const key of required) {
|
|
75
|
-
if (!(key in input))
|
|
76
|
-
return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
77
|
-
}
|
|
78
|
-
for (const key of Object.keys(input)) {
|
|
79
|
-
if (!(key in properties))
|
|
80
|
-
return { success: false, issues: [{ path: [key], message: `${key} is not a recognized property` }] };
|
|
81
|
-
}
|
|
82
|
-
for (const [key, schema] of Object.entries(properties)) {
|
|
83
|
-
if (!(key in input))
|
|
84
|
-
continue;
|
|
85
|
-
if (!matchesLooseObjectPropertyType(schema.type, input[key])) {
|
|
86
|
-
return { success: false, issues: [{ path: [key], message: `${key} must be of type ${schema.type}` }] };
|
|
87
|
-
}
|
|
88
|
-
if (schema.enum && !schema.enum.includes(input[key])) {
|
|
89
|
-
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return { success: true, value: input };
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
/** Accepts any value unvalidated -- for an operation whose output shape isn't worth a dedicated schema (an internal/low-stakes result, or one already validated upstream by the domain logic it wraps). */
|
|
97
|
-
export const passthroughVehicleSchema = defineVehicleSchema({
|
|
98
|
-
jsonSchema: { type: "object" },
|
|
99
|
-
safeParse: (value) => ({ success: true, value }),
|
|
100
|
-
});
|
|
101
|
-
/**
|
|
102
|
-
* Reads an operation's own `content` blocks off its output when present and
|
|
103
|
-
* well-formed, so a generic Vehicle client can prefer them over dumping raw
|
|
104
|
-
* JSON at the model -- without knowing anything about the operation's own
|
|
105
|
-
* domain shape. Returns undefined for a malformed or absent `content` field;
|
|
106
|
-
* the caller falls back to its own default (formatted JSON) rather than
|
|
107
|
-
* risk forwarding partial/garbled blocks.
|
|
108
|
-
*/
|
|
109
|
-
export function extractVehicleContent(output) {
|
|
110
|
-
if (typeof output !== "object" || output === null || Array.isArray(output))
|
|
111
|
-
return undefined;
|
|
112
|
-
const content = output.content;
|
|
113
|
-
if (!Array.isArray(content) || content.length === 0)
|
|
114
|
-
return undefined;
|
|
115
|
-
const blocks = [];
|
|
116
|
-
for (const block of content) {
|
|
117
|
-
if (typeof block !== "object" || block === null)
|
|
118
|
-
return undefined;
|
|
119
|
-
const { type, text } = block;
|
|
120
|
-
if (type !== "text" || typeof text !== "string")
|
|
121
|
-
return undefined;
|
|
122
|
-
blocks.push({ type: "text", text });
|
|
123
|
-
}
|
|
124
|
-
return blocks;
|
|
125
|
-
}
|
|
126
|
-
/** Canonical list VehicleEffect is itself derived from, so a runtime discriminator check (e.g. a persisted/wire VehicleApprovalRequest's own `effect` field) has one real source to check against instead of a second, driftable hardcoded list. */
|
|
127
|
-
export const VEHICLE_EFFECTS = ["read", "local-write", "external-write", "destructive", "open-world"];
|
|
128
|
-
function validateEventMetadata(options) {
|
|
129
|
-
if (!options.name.trim())
|
|
130
|
-
throw new Error("Vehicle event name must not be empty");
|
|
131
|
-
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
132
|
-
throw new Error("Vehicle event version must be a positive integer");
|
|
133
|
-
}
|
|
134
|
-
if (!options.description.trim())
|
|
135
|
-
throw new Error("Vehicle event description must not be empty");
|
|
136
|
-
if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes < 1) {
|
|
137
|
-
throw new Error("Vehicle event maxPayloadBytes must be a positive integer");
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
export function defineVehicleEvent(options) {
|
|
141
|
-
validateEventMetadata(options);
|
|
142
|
-
const descriptor = Object.freeze({
|
|
143
|
-
name: options.name,
|
|
144
|
-
version: options.version,
|
|
145
|
-
description: options.description,
|
|
146
|
-
payloadSchema: cloneJson(options.payload.jsonSchema),
|
|
147
|
-
maxPayloadBytes: options.maxPayloadBytes,
|
|
148
|
-
});
|
|
149
|
-
return Object.freeze({ descriptor, payload: options.payload });
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
|
|
153
|
-
* vehicle-server) publishes an event under, and a subscriber
|
|
154
|
-
* (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
|
|
155
|
-
* function in vehicle-core so both sides can never drift apart on the
|
|
156
|
-
* convention, the same failure mode this primitive exists to prevent
|
|
157
|
-
* providers from reinventing per-project.
|
|
158
|
-
*/
|
|
159
|
-
export function vehicleEventTopic(name, version) {
|
|
160
|
-
return `vehicle-event:${name}@${version}`;
|
|
161
|
-
}
|
|
162
|
-
export function defineVehicleOperation(options) {
|
|
163
|
-
validateOperationMetadata(options);
|
|
164
|
-
const descriptor = Object.freeze({
|
|
165
|
-
name: options.name,
|
|
166
|
-
version: options.version,
|
|
167
|
-
description: options.description,
|
|
168
|
-
inputSchema: cloneJson(options.input.jsonSchema),
|
|
169
|
-
outputSchema: cloneJson(options.output.jsonSchema),
|
|
170
|
-
permissions: Object.freeze([...(options.permissions ?? [])]),
|
|
171
|
-
effect: options.effect,
|
|
172
|
-
idempotency: Object.freeze({ ...options.idempotency }),
|
|
173
|
-
streaming: options.streaming ?? false,
|
|
174
|
-
longRunning: options.longRunning ?? false,
|
|
175
|
-
limits: Object.freeze({ ...options.limits }),
|
|
176
|
-
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
177
|
-
...(options.requiresApproval !== undefined ? { requiresApproval: options.requiresApproval } : {}),
|
|
178
|
-
...(options.background
|
|
179
|
-
? {
|
|
180
|
-
background: Object.freeze({
|
|
181
|
-
supported: true,
|
|
182
|
-
defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
|
|
183
|
-
maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
|
|
184
|
-
}),
|
|
185
|
-
}
|
|
186
|
-
: {}),
|
|
187
|
-
});
|
|
188
|
-
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
189
|
-
}
|
|
190
|
-
export function bindVehicleOperation(operation, bind) {
|
|
191
|
-
return Object.freeze({ operation, bind });
|
|
192
|
-
}
|
|
193
|
-
function validateOperationMetadata(options) {
|
|
194
|
-
if (!options.name.trim())
|
|
195
|
-
throw new Error("Vehicle operation name must not be empty");
|
|
196
|
-
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
197
|
-
throw new Error("Vehicle operation version must be a positive integer");
|
|
198
|
-
}
|
|
199
|
-
if (!options.description.trim())
|
|
200
|
-
throw new Error("Vehicle operation description must not be empty");
|
|
201
|
-
for (const permission of options.permissions ?? []) {
|
|
202
|
-
if (!permission.trim())
|
|
203
|
-
throw new Error("Vehicle operation permissions must not contain an empty value");
|
|
204
|
-
}
|
|
205
|
-
const limits = options.limits;
|
|
206
|
-
for (const [name, value] of Object.entries(limits)) {
|
|
207
|
-
if (!Number.isSafeInteger(value) || value < 1)
|
|
208
|
-
throw new Error(`Vehicle operation ${name} must be a positive integer`);
|
|
209
|
-
}
|
|
210
|
-
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
211
|
-
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
212
|
-
}
|
|
213
|
-
if (options.idempotency.mode === "keyed" &&
|
|
214
|
-
(!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
|
|
215
|
-
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
216
|
-
}
|
|
217
|
-
if (options.background) {
|
|
218
|
-
if (!options.longRunning) {
|
|
219
|
-
throw new Error("Vehicle operation with a background capability must also set longRunning: true");
|
|
220
|
-
}
|
|
221
|
-
for (const [budgetName, budget] of [
|
|
222
|
-
["defaultWakeBudget", options.background.defaultWakeBudget],
|
|
223
|
-
["maxWakeBudget", options.background.maxWakeBudget],
|
|
224
|
-
]) {
|
|
225
|
-
if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
|
|
226
|
-
throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
|
|
227
|
-
}
|
|
228
|
-
if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
|
|
229
|
-
throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
|
|
233
|
-
throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
|
|
234
|
-
}
|
|
235
|
-
if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
|
|
236
|
-
throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
function cloneJson(value) {
|
|
241
|
-
const serialized = JSON.stringify(value);
|
|
242
|
-
if (serialized === undefined)
|
|
243
|
-
throw new Error("Vehicle JSON metadata must be serializable");
|
|
244
|
-
return freezeJson(JSON.parse(serialized));
|
|
245
|
-
}
|
|
246
|
-
function freezeJson(value) {
|
|
247
|
-
if (Array.isArray(value))
|
|
248
|
-
return Object.freeze(value.map(freezeJson));
|
|
249
|
-
if (value !== null && typeof value === "object") {
|
|
250
|
-
return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child]) => [key, freezeJson(child)])));
|
|
251
|
-
}
|
|
252
|
-
return value;
|
|
253
|
-
}
|
package/dist/vehicle-jobs.d.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
-
import type { VehiclePrincipal } from "./vehicle-contract.js";
|
|
3
|
-
import type { VehicleFailure } from "./vehicle-errors.js";
|
|
4
|
-
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
5
|
-
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. "orphaned" is a restart-reconciliation outcome: a job that was still "running" when its process died, so nothing ever really failed or succeeded -- the record's own status just goes stale. */
|
|
6
|
-
export declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "orphaned", "failed", "succeeded"];
|
|
7
|
-
export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
|
|
8
|
-
export declare function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason;
|
|
9
|
-
/** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
|
|
10
|
-
export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
|
|
11
|
-
export interface VehicleJobWakeBudget {
|
|
12
|
-
readonly maxCount: number;
|
|
13
|
-
readonly maxBytes: number;
|
|
14
|
-
}
|
|
15
|
-
export type VehicleJobWakeDropReason = "count-budget-exhausted" | "byte-budget-exhausted" | "deduplicated-transition" | "superseded-by-first-only";
|
|
16
|
-
export interface VehicleJobWakeEntry {
|
|
17
|
-
readonly seq: number;
|
|
18
|
-
readonly at: number;
|
|
19
|
-
readonly progress: unknown;
|
|
20
|
-
}
|
|
21
|
-
export interface VehicleJobWakeAppendResult {
|
|
22
|
-
readonly accepted: boolean;
|
|
23
|
-
readonly entry?: VehicleJobWakeEntry;
|
|
24
|
-
readonly dropReason?: VehicleJobWakeDropReason;
|
|
25
|
-
}
|
|
26
|
-
export interface VehicleJobWakeLogOptions {
|
|
27
|
-
readonly notifyMode: VehicleJobNotifyMode;
|
|
28
|
-
readonly budget: VehicleJobWakeBudget;
|
|
29
|
-
/** Defaults to Date.now. */
|
|
30
|
-
readonly now?: () => number;
|
|
31
|
-
}
|
|
32
|
-
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
33
|
-
export declare class VehicleJobWakeLog {
|
|
34
|
-
private readonly options;
|
|
35
|
-
private readonly entries;
|
|
36
|
-
private usedBytes;
|
|
37
|
-
private nextSeq;
|
|
38
|
-
private lastHash;
|
|
39
|
-
private acceptedFirst;
|
|
40
|
-
private readonly now;
|
|
41
|
-
constructor(options: VehicleJobWakeLogOptions);
|
|
42
|
-
append(progress: unknown): VehicleJobWakeAppendResult;
|
|
43
|
-
/** Entries with seq strictly greater than `cursor`. */
|
|
44
|
-
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
45
|
-
/** Highest seq issued so far (0 if none accepted yet). */
|
|
46
|
-
get cursor(): number;
|
|
47
|
-
}
|
|
48
|
-
/** 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. */
|
|
49
|
-
export interface VehicleJobWakeLogReader {
|
|
50
|
-
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
51
|
-
readonly cursor: number;
|
|
52
|
-
}
|
|
53
|
-
/** 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. */
|
|
54
|
-
export declare function createStaticVehicleJobWakeLog(entries: readonly VehicleJobWakeEntry[]): VehicleJobWakeLogReader;
|
|
55
|
-
/**
|
|
56
|
-
* A job's mid-flight input channel -- the "steer" primitive. Bounded FIFO:
|
|
57
|
-
* push() while a handler isn't yet reading buffers up to maxQueueSize, then
|
|
58
|
-
* refuses further input rather than growing unboundedly or silently
|
|
59
|
-
* overwriting an unread entry. A handler consumes it via `for await (const
|
|
60
|
-
* input of context.steerInputs)`, which ends cleanly once close() is
|
|
61
|
-
* called (VehicleJobStore does this at job finalization).
|
|
62
|
-
*/
|
|
63
|
-
export interface VehicleJobSteerPushResult {
|
|
64
|
-
readonly accepted: boolean;
|
|
65
|
-
readonly dropReason?: "queue-full" | "channel-closed";
|
|
66
|
-
}
|
|
67
|
-
export declare class VehicleJobSteerChannel implements AsyncIterable<unknown> {
|
|
68
|
-
private readonly maxQueueSize;
|
|
69
|
-
private readonly buffer;
|
|
70
|
-
private readonly waiters;
|
|
71
|
-
private closed;
|
|
72
|
-
constructor(maxQueueSize?: number);
|
|
73
|
-
push(value: unknown): VehicleJobSteerPushResult;
|
|
74
|
-
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
75
|
-
close(): void;
|
|
76
|
-
[Symbol.asyncIterator](): AsyncIterator<unknown>;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Vehicle Jobs run as in-process promises, not child processes -- there is
|
|
80
|
-
* no PID to reuse, but the same identity-confusion risk vstack's
|
|
81
|
-
* {pid, startToken, comm} design guards against still applies in a
|
|
82
|
-
* generalized form: a persisted job record written by one process
|
|
83
|
-
* instance must never be mistaken for one this (possibly restarted)
|
|
84
|
-
* instance can still resolve. Each VehicleJobStore construction gets a
|
|
85
|
-
* fresh random instanceToken; a persisted record's own stamped token only
|
|
86
|
-
* ever matches the instance that wrote it. A mismatch means "the original
|
|
87
|
-
* run is gone", the same conclusion vstack's identityMatches() reaches by
|
|
88
|
-
* comparing a live process's actual pid/start-time/command against a
|
|
89
|
-
* stored snapshot -- this is that same check with no process to inspect.
|
|
90
|
-
*/
|
|
91
|
-
export declare function vehicleJobIdentityMatches(recordInstanceToken: string, currentInstanceToken: string): boolean;
|
|
92
|
-
/** 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". */
|
|
93
|
-
export interface VehicleJobEvictionCandidate {
|
|
94
|
-
readonly jobId: string;
|
|
95
|
-
readonly status: VehicleJobStatus;
|
|
96
|
-
readonly delivered: boolean;
|
|
97
|
-
readonly updatedAt: number;
|
|
98
|
-
}
|
|
99
|
-
export interface VehicleJobRetentionOptions {
|
|
100
|
-
/** Hard cap on total retained job records (of any status). A running job is never evicted regardless of this cap. */
|
|
101
|
-
readonly maxRetainedJobs: number;
|
|
102
|
-
/** A delivered terminal job becomes eligible for eviction once this many ms have passed since it was delivered (== updatedAt at delivery time). */
|
|
103
|
-
readonly deliveredRetentionMs: number;
|
|
104
|
-
readonly now: number;
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* The client-facing wire shapes for Vehicle Jobs -- submit/poll/tail options and results, shared by
|
|
108
|
-
* vehicle-server's VehicleJobStore (the orchestration side) and vehicle-client's job-capable clients
|
|
109
|
-
* (the calling side), so both halves of the wire agree on one definition instead of two structurally
|
|
110
|
-
*-identical copies drifting apart. Every field type referenced here already lives in vehicle-core
|
|
111
|
-
* (VehiclePrincipal, VehicleFailure, VehicleJobStatus, ...), which is what makes it safe for these
|
|
112
|
-
* shapes to live here too, alongside the rest of Vehicle Jobs' pure pieces.
|
|
113
|
-
*/
|
|
114
|
-
export interface VehicleJobSubmitOptions {
|
|
115
|
-
readonly permissions?: readonly string[];
|
|
116
|
-
readonly principal?: VehiclePrincipal;
|
|
117
|
-
readonly idempotencyKey?: string;
|
|
118
|
-
readonly expectedRevision?: string | number;
|
|
119
|
-
readonly approvalCapability?: string;
|
|
120
|
-
readonly correlationId?: string;
|
|
121
|
-
readonly callerSessionId?: string;
|
|
122
|
-
readonly callerProjectRoot?: string;
|
|
123
|
-
/** Defaults to "transition". */
|
|
124
|
-
readonly notifyMode?: VehicleJobNotifyMode;
|
|
125
|
-
/** Defaults to background.defaultWakeBudget; clamped to background.maxWakeBudget either way. */
|
|
126
|
-
readonly wakeBudget?: VehicleJobWakeBudget;
|
|
127
|
-
/** No default -- unset means the job runs until it settles or is canceled. */
|
|
128
|
-
readonly maxLifetimeMs?: number;
|
|
129
|
-
}
|
|
130
|
-
export interface VehicleJobSubmitResult {
|
|
131
|
-
readonly jobId: string;
|
|
132
|
-
}
|
|
133
|
-
export interface VehicleJobSnapshot {
|
|
134
|
-
readonly jobId: string;
|
|
135
|
-
readonly operationName: string;
|
|
136
|
-
readonly operationVersion: number;
|
|
137
|
-
readonly status: VehicleJobStatus;
|
|
138
|
-
readonly createdAt: number;
|
|
139
|
-
readonly updatedAt: number;
|
|
140
|
-
readonly delivered: boolean;
|
|
141
|
-
readonly terminationReason?: VehicleJobTerminationReason;
|
|
142
|
-
readonly output?: unknown;
|
|
143
|
-
readonly error?: VehicleFailure;
|
|
144
|
-
}
|
|
145
|
-
export interface VehicleJobTailResult {
|
|
146
|
-
readonly entries: readonly VehicleJobWakeEntry[];
|
|
147
|
-
readonly cursor: number;
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
151
|
-
* bookkeeping so the bounded-retention rule is independently testable.
|
|
152
|
-
* Preference order: (1) delivered and past deliveredRetentionMs, oldest
|
|
153
|
-
* first; (2) once still over maxRetainedJobs, any delivered terminal job,
|
|
154
|
-
* oldest first; (3) only as a last resort, an undelivered terminal job,
|
|
155
|
-
* oldest first -- a real loss (a caller may still want that result), but
|
|
156
|
-
* an unbounded store is a worse failure mode. A running job is never a
|
|
157
|
-
* candidate.
|
|
158
|
-
*/
|
|
159
|
-
export declare function selectVehicleJobsForEviction(candidates: readonly VehicleJobEvictionCandidate[], options: VehicleJobRetentionOptions): readonly string[];
|
package/dist/vehicle-jobs.js
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
-
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. "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. */
|
|
3
|
-
export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "orphaned", "failed", "succeeded"];
|
|
4
|
-
export function resolveVehicleJobTerminationReason(candidates) {
|
|
5
|
-
if (candidates.length === 0)
|
|
6
|
-
throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
|
|
7
|
-
for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
|
|
8
|
-
if (candidates.includes(reason))
|
|
9
|
-
return reason;
|
|
10
|
-
}
|
|
11
|
-
throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
|
|
12
|
-
}
|
|
13
|
-
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
14
|
-
export class VehicleJobWakeLog {
|
|
15
|
-
options;
|
|
16
|
-
entries = [];
|
|
17
|
-
usedBytes = 0;
|
|
18
|
-
nextSeq = 1;
|
|
19
|
-
lastHash;
|
|
20
|
-
acceptedFirst = false;
|
|
21
|
-
now;
|
|
22
|
-
constructor(options) {
|
|
23
|
-
this.options = options;
|
|
24
|
-
this.now = options.now ?? Date.now;
|
|
25
|
-
}
|
|
26
|
-
append(progress) {
|
|
27
|
-
if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
|
|
28
|
-
return { accepted: false, dropReason: "superseded-by-first-only" };
|
|
29
|
-
}
|
|
30
|
-
const serialized = safeJsonStringify(progress);
|
|
31
|
-
if (this.options.notifyMode === "transition") {
|
|
32
|
-
const hash = fnv1aHash(serialized);
|
|
33
|
-
if (hash === this.lastHash)
|
|
34
|
-
return { accepted: false, dropReason: "deduplicated-transition" };
|
|
35
|
-
this.lastHash = hash;
|
|
36
|
-
}
|
|
37
|
-
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
38
|
-
if (this.entries.length >= this.options.budget.maxCount)
|
|
39
|
-
return { accepted: false, dropReason: "count-budget-exhausted" };
|
|
40
|
-
if (this.usedBytes + bytes > this.options.budget.maxBytes)
|
|
41
|
-
return { accepted: false, dropReason: "byte-budget-exhausted" };
|
|
42
|
-
const entry = { seq: this.nextSeq++, at: this.now(), progress };
|
|
43
|
-
this.entries.push(entry);
|
|
44
|
-
this.usedBytes += bytes;
|
|
45
|
-
this.acceptedFirst = true;
|
|
46
|
-
return { accepted: true, entry };
|
|
47
|
-
}
|
|
48
|
-
/** Entries with seq strictly greater than `cursor`. */
|
|
49
|
-
since(cursor) {
|
|
50
|
-
return this.entries.filter((entry) => entry.seq > cursor);
|
|
51
|
-
}
|
|
52
|
-
/** Highest seq issued so far (0 if none accepted yet). */
|
|
53
|
-
get cursor() {
|
|
54
|
-
return this.nextSeq - 1;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
function safeJsonStringify(value) {
|
|
58
|
-
let serialized;
|
|
59
|
-
try {
|
|
60
|
-
serialized = JSON.stringify(value);
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
|
|
64
|
-
}
|
|
65
|
-
if (serialized === undefined)
|
|
66
|
-
throw new Error("Vehicle job progress value is not JSON-serializable");
|
|
67
|
-
return serialized;
|
|
68
|
-
}
|
|
69
|
-
/** Non-cryptographic (FNV-1a) -- dedup only. */
|
|
70
|
-
function fnv1aHash(value) {
|
|
71
|
-
let hash = 0x811c9dc5;
|
|
72
|
-
for (let i = 0; i < value.length; i++) {
|
|
73
|
-
hash ^= value.charCodeAt(i);
|
|
74
|
-
hash = Math.imul(hash, 0x01000193);
|
|
75
|
-
}
|
|
76
|
-
return (hash >>> 0).toString(16);
|
|
77
|
-
}
|
|
78
|
-
/** 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. */
|
|
79
|
-
export function createStaticVehicleJobWakeLog(entries) {
|
|
80
|
-
const sorted = [...entries].sort((a, b) => a.seq - b.seq);
|
|
81
|
-
const cursor = sorted.length > 0 ? sorted[sorted.length - 1].seq : 0;
|
|
82
|
-
return {
|
|
83
|
-
since: (cursorArg) => sorted.filter((entry) => entry.seq > cursorArg),
|
|
84
|
-
cursor,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
export class VehicleJobSteerChannel {
|
|
88
|
-
maxQueueSize;
|
|
89
|
-
buffer = [];
|
|
90
|
-
waiters = [];
|
|
91
|
-
closed = false;
|
|
92
|
-
constructor(maxQueueSize = 64) {
|
|
93
|
-
this.maxQueueSize = maxQueueSize;
|
|
94
|
-
}
|
|
95
|
-
push(value) {
|
|
96
|
-
if (this.closed)
|
|
97
|
-
return { accepted: false, dropReason: "channel-closed" };
|
|
98
|
-
const waiter = this.waiters.shift();
|
|
99
|
-
if (waiter) {
|
|
100
|
-
waiter({ value, done: false });
|
|
101
|
-
return { accepted: true };
|
|
102
|
-
}
|
|
103
|
-
if (this.buffer.length >= this.maxQueueSize)
|
|
104
|
-
return { accepted: false, dropReason: "queue-full" };
|
|
105
|
-
this.buffer.push(value);
|
|
106
|
-
return { accepted: true };
|
|
107
|
-
}
|
|
108
|
-
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
109
|
-
close() {
|
|
110
|
-
if (this.closed)
|
|
111
|
-
return;
|
|
112
|
-
this.closed = true;
|
|
113
|
-
for (const waiter of this.waiters.splice(0))
|
|
114
|
-
waiter({ value: undefined, done: true });
|
|
115
|
-
}
|
|
116
|
-
[Symbol.asyncIterator]() {
|
|
117
|
-
return {
|
|
118
|
-
next: () => {
|
|
119
|
-
if (this.buffer.length > 0)
|
|
120
|
-
return Promise.resolve({ value: this.buffer.shift(), done: false });
|
|
121
|
-
if (this.closed)
|
|
122
|
-
return Promise.resolve({ value: undefined, done: true });
|
|
123
|
-
return new Promise((resolve) => this.waiters.push(resolve));
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Vehicle Jobs run as in-process promises, not child processes -- there is
|
|
130
|
-
* no PID to reuse, but the same identity-confusion risk vstack's
|
|
131
|
-
* {pid, startToken, comm} design guards against still applies in a
|
|
132
|
-
* generalized form: a persisted job record written by one process
|
|
133
|
-
* instance must never be mistaken for one this (possibly restarted)
|
|
134
|
-
* instance can still resolve. Each VehicleJobStore construction gets a
|
|
135
|
-
* fresh random instanceToken; a persisted record's own stamped token only
|
|
136
|
-
* ever matches the instance that wrote it. A mismatch means "the original
|
|
137
|
-
* run is gone", the same conclusion vstack's identityMatches() reaches by
|
|
138
|
-
* comparing a live process's actual pid/start-time/command against a
|
|
139
|
-
* stored snapshot -- this is that same check with no process to inspect.
|
|
140
|
-
*/
|
|
141
|
-
export function vehicleJobIdentityMatches(recordInstanceToken, currentInstanceToken) {
|
|
142
|
-
return recordInstanceToken === currentInstanceToken;
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
146
|
-
* bookkeeping so the bounded-retention rule is independently testable.
|
|
147
|
-
* Preference order: (1) delivered and past deliveredRetentionMs, oldest
|
|
148
|
-
* first; (2) once still over maxRetainedJobs, any delivered terminal job,
|
|
149
|
-
* oldest first; (3) only as a last resort, an undelivered terminal job,
|
|
150
|
-
* oldest first -- a real loss (a caller may still want that result), but
|
|
151
|
-
* an unbounded store is a worse failure mode. A running job is never a
|
|
152
|
-
* candidate.
|
|
153
|
-
*/
|
|
154
|
-
export function selectVehicleJobsForEviction(candidates, options) {
|
|
155
|
-
const terminal = candidates.filter((candidate) => candidate.status !== "running");
|
|
156
|
-
const byAgeAscending = (a, b) => a.updatedAt - b.updatedAt;
|
|
157
|
-
const evicted = new Set();
|
|
158
|
-
for (const candidate of terminal) {
|
|
159
|
-
if (candidate.delivered && options.now - candidate.updatedAt >= options.deliveredRetentionMs)
|
|
160
|
-
evicted.add(candidate.jobId);
|
|
161
|
-
}
|
|
162
|
-
const remainingCount = () => candidates.length - evicted.size;
|
|
163
|
-
if (remainingCount() > options.maxRetainedJobs) {
|
|
164
|
-
const deliveredOldestFirst = terminal.filter((candidate) => candidate.delivered && !evicted.has(candidate.jobId)).sort(byAgeAscending);
|
|
165
|
-
for (const candidate of deliveredOldestFirst) {
|
|
166
|
-
if (remainingCount() <= options.maxRetainedJobs)
|
|
167
|
-
break;
|
|
168
|
-
evicted.add(candidate.jobId);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (remainingCount() > options.maxRetainedJobs) {
|
|
172
|
-
const undeliveredOldestFirst = terminal
|
|
173
|
-
.filter((candidate) => !candidate.delivered && !evicted.has(candidate.jobId))
|
|
174
|
-
.sort(byAgeAscending);
|
|
175
|
-
for (const candidate of undeliveredOldestFirst) {
|
|
176
|
-
if (remainingCount() <= options.maxRetainedJobs)
|
|
177
|
-
break;
|
|
178
|
-
evicted.add(candidate.jobId);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return [...evicted];
|
|
182
|
-
}
|