@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.
Files changed (123) hide show
  1. package/README.md +12 -0
  2. package/dist/{vehicle-approvals.d.ts → approvals/approval.d.ts} +4 -11
  3. package/dist/{vehicle-approvals.js → approvals/approval.js} +53 -12
  4. package/dist/approvals/index.d.ts +1 -0
  5. package/dist/approvals/index.js +1 -0
  6. package/dist/client/client.d.ts +29 -0
  7. package/dist/client/client.js +1 -0
  8. package/dist/client/index.d.ts +1 -0
  9. package/dist/client/index.js +1 -0
  10. package/dist/content/content.d.ts +32 -0
  11. package/dist/content/content.js +25 -0
  12. package/dist/content/index.d.ts +1 -0
  13. package/dist/content/index.js +1 -0
  14. package/dist/{vehicle-errors.d.ts → errors/error.d.ts} +22 -10
  15. package/dist/{vehicle-errors.js → errors/error.js} +20 -0
  16. package/dist/errors/index.d.ts +1 -0
  17. package/dist/errors/index.js +1 -0
  18. package/dist/events/event.d.ts +42 -0
  19. package/dist/events/event.js +35 -0
  20. package/dist/events/index.d.ts +1 -0
  21. package/dist/events/index.js +1 -0
  22. package/dist/idempotency/index.d.ts +1 -0
  23. package/dist/idempotency/index.js +1 -0
  24. package/dist/idempotency/receipt.d.ts +66 -0
  25. package/dist/idempotency/receipt.js +42 -0
  26. package/dist/index.d.ts +26 -7
  27. package/dist/index.js +26 -7
  28. package/dist/jobs/identity.d.ts +11 -0
  29. package/dist/jobs/identity.js +13 -0
  30. package/dist/jobs/index.d.ts +7 -0
  31. package/dist/jobs/index.js +7 -0
  32. package/dist/jobs/replay.d.ts +8 -0
  33. package/dist/jobs/replay.js +9 -0
  34. package/dist/jobs/retention.d.ts +26 -0
  35. package/dist/jobs/retention.js +39 -0
  36. package/dist/jobs/steer.d.ts +23 -0
  37. package/dist/jobs/steer.js +41 -0
  38. package/dist/jobs/termination.d.ts +5 -0
  39. package/dist/jobs/termination.js +11 -0
  40. package/dist/jobs/wake-log.d.ts +39 -0
  41. package/dist/jobs/wake-log.js +65 -0
  42. package/dist/jobs/wire.d.ts +47 -0
  43. package/dist/jobs/wire.js +1 -0
  44. package/dist/manifest/index.d.ts +1 -0
  45. package/dist/manifest/index.js +1 -0
  46. package/dist/manifest/manifest.d.ts +49 -0
  47. package/dist/manifest/manifest.js +1 -0
  48. package/dist/operations/context.d.ts +52 -0
  49. package/dist/operations/context.js +1 -0
  50. package/dist/operations/effect.d.ts +3 -0
  51. package/dist/operations/effect.js +2 -0
  52. package/dist/operations/index.d.ts +3 -0
  53. package/dist/operations/index.js +3 -0
  54. package/dist/operations/operation.d.ts +98 -0
  55. package/dist/operations/operation.js +79 -0
  56. package/dist/{atomic-json.d.ts → persistence/atomic-json.d.ts} +1 -2
  57. package/dist/{atomic-json.js → persistence/atomic-json.js} +1 -2
  58. package/dist/persistence/index.d.ts +1 -0
  59. package/dist/persistence/index.js +1 -0
  60. package/dist/schedules/index.d.ts +1 -0
  61. package/dist/schedules/index.js +1 -0
  62. package/dist/{vehicle-scheduler.d.ts → schedules/schedule.d.ts} +23 -9
  63. package/dist/schedules/schedule.js +61 -0
  64. package/dist/schemas/codec.d.ts +25 -0
  65. package/dist/schemas/codec.js +7 -0
  66. package/dist/schemas/index.d.ts +4 -0
  67. package/dist/schemas/index.js +4 -0
  68. package/dist/schemas/json.d.ts +7 -0
  69. package/dist/schemas/json.js +14 -0
  70. package/dist/schemas/loose-object.d.ts +20 -0
  71. package/dist/schemas/loose-object.js +73 -0
  72. package/dist/schemas/presentation.d.ts +11 -0
  73. package/dist/schemas/presentation.js +22 -0
  74. package/dist/watches/index.d.ts +1 -0
  75. package/dist/watches/index.js +1 -0
  76. package/dist/{vehicle-watchers.d.ts → watches/registry.d.ts} +2 -9
  77. package/dist/{vehicle-watchers.js → watches/registry.js} +2 -9
  78. package/package.json +5 -1
  79. package/src/{vehicle-approvals.ts → approvals/approval.ts} +47 -13
  80. package/src/approvals/index.ts +1 -0
  81. package/src/client/client.ts +31 -0
  82. package/src/client/index.ts +1 -0
  83. package/src/content/content.ts +46 -0
  84. package/src/content/index.ts +1 -0
  85. package/src/{vehicle-errors.ts → errors/error.ts} +32 -9
  86. package/src/errors/index.ts +1 -0
  87. package/src/events/event.ts +72 -0
  88. package/src/events/index.ts +1 -0
  89. package/src/idempotency/index.ts +1 -0
  90. package/src/idempotency/receipt.ts +97 -0
  91. package/src/index.ts +26 -7
  92. package/src/jobs/identity.ts +13 -0
  93. package/src/jobs/index.ts +7 -0
  94. package/src/jobs/replay.ts +17 -0
  95. package/src/jobs/retention.ts +59 -0
  96. package/src/jobs/steer.ts +49 -0
  97. package/src/jobs/termination.ts +13 -0
  98. package/src/jobs/wake-log.ts +98 -0
  99. package/src/jobs/wire.ts +51 -0
  100. package/src/manifest/index.ts +1 -0
  101. package/src/manifest/manifest.ts +52 -0
  102. package/src/operations/context.ts +56 -0
  103. package/src/operations/effect.ts +3 -0
  104. package/src/operations/index.ts +3 -0
  105. package/src/operations/operation.ts +184 -0
  106. package/src/{atomic-json.ts → persistence/atomic-json.ts} +1 -2
  107. package/src/persistence/index.ts +1 -0
  108. package/src/schedules/index.ts +1 -0
  109. package/src/{vehicle-scheduler.ts → schedules/schedule.ts} +32 -9
  110. package/src/schemas/codec.ts +30 -0
  111. package/src/schemas/index.ts +4 -0
  112. package/src/schemas/json.ts +17 -0
  113. package/src/schemas/loose-object.ts +82 -0
  114. package/src/schemas/presentation.ts +28 -0
  115. package/src/watches/index.ts +1 -0
  116. package/src/{vehicle-watchers.ts → watches/registry.ts} +2 -9
  117. package/dist/vehicle-contract.d.ts +0 -347
  118. package/dist/vehicle-contract.js +0 -219
  119. package/dist/vehicle-jobs.d.ts +0 -159
  120. package/dist/vehicle-jobs.js +0 -182
  121. package/dist/vehicle-scheduler.js +0 -33
  122. package/src/vehicle-contract.ts +0 -563
  123. package/src/vehicle-jobs.ts +0 -305
@@ -0,0 +1,79 @@
1
+ import { cloneJson } from "../schemas/json.js";
2
+ export function defineVehicleOperation(options) {
3
+ validateOperationMetadata(options);
4
+ const descriptor = Object.freeze({
5
+ name: options.name,
6
+ version: options.version,
7
+ description: options.description,
8
+ inputSchema: cloneJson(options.input.jsonSchema),
9
+ outputSchema: cloneJson(options.output.jsonSchema),
10
+ permissions: Object.freeze([...(options.permissions ?? [])]),
11
+ effect: options.effect,
12
+ idempotency: Object.freeze({ ...options.idempotency }),
13
+ streaming: options.streaming ?? false,
14
+ longRunning: options.longRunning ?? false,
15
+ limits: Object.freeze({ ...options.limits }),
16
+ errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
17
+ ...(options.requiresApproval !== undefined ? { requiresApproval: options.requiresApproval } : {}),
18
+ ...(options.background
19
+ ? {
20
+ background: Object.freeze({
21
+ supported: true,
22
+ defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
23
+ maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
24
+ }),
25
+ }
26
+ : {}),
27
+ });
28
+ return Object.freeze({ descriptor, input: options.input, output: options.output });
29
+ }
30
+ export function bindVehicleOperation(operation, bind) {
31
+ return Object.freeze({ operation, bind });
32
+ }
33
+ function validateOperationMetadata(options) {
34
+ if (!options.name.trim())
35
+ throw new Error("Vehicle operation name must not be empty");
36
+ if (!Number.isInteger(options.version) || options.version < 1) {
37
+ throw new Error("Vehicle operation version must be a positive integer");
38
+ }
39
+ if (!options.description.trim())
40
+ throw new Error("Vehicle operation description must not be empty");
41
+ for (const permission of options.permissions ?? []) {
42
+ if (!permission.trim())
43
+ throw new Error("Vehicle operation permissions must not contain an empty value");
44
+ }
45
+ const limits = options.limits;
46
+ for (const [name, value] of Object.entries(limits)) {
47
+ if (!Number.isSafeInteger(value) || value < 1)
48
+ throw new Error(`Vehicle operation ${name} must be a positive integer`);
49
+ }
50
+ if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
51
+ throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
52
+ }
53
+ if (options.idempotency.mode === "keyed" &&
54
+ (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
55
+ throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
56
+ }
57
+ if (options.background) {
58
+ if (!options.longRunning) {
59
+ throw new Error("Vehicle operation with a background capability must also set longRunning: true");
60
+ }
61
+ for (const [budgetName, budget] of [
62
+ ["defaultWakeBudget", options.background.defaultWakeBudget],
63
+ ["maxWakeBudget", options.background.maxWakeBudget],
64
+ ]) {
65
+ if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
66
+ throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
67
+ }
68
+ if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
69
+ throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
70
+ }
71
+ }
72
+ if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
73
+ throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
74
+ }
75
+ if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
76
+ throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
77
+ }
78
+ }
79
+ }
@@ -8,8 +8,7 @@
8
8
  * the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
9
9
  * functions, this module only sequences them.
10
10
  *
11
- * Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
12
- * a collision-safe temp filename, injectable fs/now/pid/random for
11
+ * A collision-safe temp filename, injectable fs/now/pid/random for
13
12
  * deterministic tests, and explicit Windows-aware rename retry (a plain
14
13
  * `fs.rename` onto an existing path can transiently fail on Windows if
15
14
  * another process -- antivirus, search indexing -- has the destination
@@ -8,8 +8,7 @@
8
8
  * the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
9
9
  * functions, this module only sequences them.
10
10
  *
11
- * Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
12
- * a collision-safe temp filename, injectable fs/now/pid/random for
11
+ * A collision-safe temp filename, injectable fs/now/pid/random for
13
12
  * deterministic tests, and explicit Windows-aware rename retry (a plain
14
13
  * `fs.rename` onto an existing path can transiently fail on Windows if
15
14
  * another process -- antivirus, search indexing -- has the destination
@@ -0,0 +1 @@
1
+ export * from "./atomic-json.js";
@@ -0,0 +1 @@
1
+ export * from "./atomic-json.js";
@@ -0,0 +1 @@
1
+ export * from "./schedule.js";
@@ -0,0 +1 @@
1
+ export * from "./schedule.js";
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * "Trigger something at a future time or on a recurring interval,"
3
3
  * independent of any specific job's own lifecycle -- a distinct shape from
4
- * Vehicle Jobs (submit-then-track a unit of work already running). Modeled
5
- * on ~/Workspace/alef's packages/core/foundry/src/scheduler.ts
6
- * (defer/repeat/cancel/list), generalized so the fired action is a
7
- * declarative Vehicle operation invocation or event emission -- never a
8
- * bespoke callback closure, so it can be persisted and re-armed after a
9
- * restart the way a closure never could.
4
+ * Vehicle Jobs (submit-then-track a unit of work already running). The
5
+ * fired action is a declarative Vehicle operation invocation or event
6
+ * emission -- never a bespoke callback closure, so it can be persisted and
7
+ * re-armed after a restart the way a closure never could.
10
8
  *
11
9
  * Pure pieces only: trigger/action shapes and the fire-time arithmetic.
12
10
  * Real timers, persistence, and registry wiring live in vehicle-server's
13
- * VehicleScheduler, the same core/server split vehicle-jobs.ts uses.
11
+ * VehicleScheduler, the same core/server split Vehicle Jobs uses.
14
12
  */
15
- import type { JsonValue } from "./vehicle-contract.js";
13
+ import type { JsonValue } from "../schemas/json.js";
16
14
  export type VehicleScheduleTrigger = {
17
15
  readonly kind: "at";
18
16
  readonly at: number;
@@ -49,7 +47,23 @@ export declare class VehicleScheduleLimitExceeded extends Error {
49
47
  readonly max: number;
50
48
  constructor(owner: string, max: number);
51
49
  }
52
- /** The first fire time for a freshly created schedule. */
50
+ /** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
51
+ export declare class VehicleScheduleInvalidTriggerError extends Error {
52
+ readonly trigger: VehicleScheduleTrigger;
53
+ constructor(trigger: VehicleScheduleTrigger);
54
+ }
55
+ /**
56
+ * Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
57
+ * plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
58
+ * non-finite or non-positive value silently corrupted every arithmetic function below it:
59
+ * `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
60
+ * schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
61
+ * breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
62
+ * required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
63
+ * negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
64
+ */
65
+ export declare function isValidVehicleScheduleTrigger(trigger: VehicleScheduleTrigger): boolean;
66
+ /** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
53
67
  export declare function initialFireAt(trigger: VehicleScheduleTrigger, now: number): number;
54
68
  /** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
55
69
  export declare function nextFireAtAfterFire(trigger: VehicleScheduleTrigger, now: number): number | undefined;
@@ -0,0 +1,61 @@
1
+ /** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
2
+ export const DEFAULT_MAX_SCHEDULES_PER_OWNER = 32;
3
+ /** Raised when an owner already has its configured maximum of schedules -- fails closed, the same bounded-resource discipline WatchLimitExceeded already applies to Vehicle Watchers. */
4
+ export class VehicleScheduleLimitExceeded extends Error {
5
+ owner;
6
+ max;
7
+ constructor(owner, max) {
8
+ super(`owner "${owner}" already has ${max} active schedules -- cancel one before adding another`);
9
+ this.owner = owner;
10
+ this.max = max;
11
+ this.name = "VehicleScheduleLimitExceeded";
12
+ }
13
+ }
14
+ /** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
15
+ export class VehicleScheduleInvalidTriggerError extends Error {
16
+ trigger;
17
+ constructor(trigger) {
18
+ super(`Invalid Vehicle schedule trigger: ${JSON.stringify(trigger)}`);
19
+ this.trigger = trigger;
20
+ this.name = "VehicleScheduleInvalidTriggerError";
21
+ }
22
+ }
23
+ /**
24
+ * Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
25
+ * plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
26
+ * non-finite or non-positive value silently corrupted every arithmetic function below it:
27
+ * `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
28
+ * schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
29
+ * breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
30
+ * required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
31
+ * negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
32
+ */
33
+ export function isValidVehicleScheduleTrigger(trigger) {
34
+ if (trigger.kind === "at")
35
+ return Number.isFinite(trigger.at) && trigger.at > 0;
36
+ if (trigger.kind === "every")
37
+ return Number.isFinite(trigger.intervalMs) && trigger.intervalMs > 0;
38
+ return false;
39
+ }
40
+ /** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
41
+ export function initialFireAt(trigger, now) {
42
+ if (!isValidVehicleScheduleTrigger(trigger))
43
+ throw new VehicleScheduleInvalidTriggerError(trigger);
44
+ return trigger.kind === "at" ? trigger.at : now + trigger.intervalMs;
45
+ }
46
+ /** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
47
+ export function nextFireAtAfterFire(trigger, now) {
48
+ return trigger.kind === "every" ? now + trigger.intervalMs : undefined;
49
+ }
50
+ /**
51
+ * Where a restored entry should be re-armed to. A one-shot "at" entry keeps
52
+ * its original persisted time (fires as soon as possible if overdue -- the
53
+ * one thing it was supposed to do must not be silently lost). A recurring
54
+ * "every" entry resumes its normal cadence from now if it fell behind while
55
+ * the daemon was down, rather than firing once per missed tick.
56
+ */
57
+ export function nextFireAtAfterRestore(trigger, persistedNextFireAt, now) {
58
+ if (trigger.kind === "at")
59
+ return persistedNextFireAt;
60
+ return persistedNextFireAt > now ? persistedNextFireAt : now + trigger.intervalMs;
61
+ }
@@ -0,0 +1,25 @@
1
+ import { type JsonSchema } from "./json.js";
2
+ export interface VehicleSchemaIssue {
3
+ readonly path: readonly (string | number)[];
4
+ readonly message: string;
5
+ }
6
+ export type VehicleSchemaResult<T> = {
7
+ readonly success: true;
8
+ readonly value: T;
9
+ } | {
10
+ readonly success: false;
11
+ readonly issues?: readonly VehicleSchemaIssue[];
12
+ };
13
+ /**
14
+ * A serializable, descriptive `jsonSchema` (surfaced to a client or Pi tool
15
+ * projection) paired with a real `safeParse` that actually enforces it at
16
+ * runtime -- a Vehicle registry's own `invoke()` only ever calls
17
+ * `safeParse`; `jsonSchema` alone is never itself enforced, so a codec that
18
+ * only sets `jsonSchema` without a matching `safeParse` is a documentation
19
+ * gesture, not an honest contract.
20
+ */
21
+ export interface VehicleSchemaCodec<T> {
22
+ readonly jsonSchema: JsonSchema;
23
+ safeParse(value: unknown): VehicleSchemaResult<T>;
24
+ }
25
+ export declare function defineVehicleSchema<T>(codec: VehicleSchemaCodec<T>): VehicleSchemaCodec<T>;
@@ -0,0 +1,7 @@
1
+ import { cloneJson } from "./json.js";
2
+ export function defineVehicleSchema(codec) {
3
+ return Object.freeze({
4
+ jsonSchema: cloneJson(codec.jsonSchema),
5
+ safeParse: codec.safeParse,
6
+ });
7
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./codec.js";
2
+ export * from "./json.js";
3
+ export * from "./loose-object.js";
4
+ export * from "./presentation.js";
@@ -0,0 +1,4 @@
1
+ export * from "./codec.js";
2
+ export * from "./json.js";
3
+ export * from "./loose-object.js";
4
+ export * from "./presentation.js";
@@ -0,0 +1,7 @@
1
+ export type JsonPrimitive = string | number | boolean | null;
2
+ export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
3
+ readonly [key: string]: JsonValue;
4
+ };
5
+ export type JsonSchema = Readonly<Record<string, JsonValue>>;
6
+ export declare function cloneJson<T extends JsonValue>(value: T): T;
7
+ export declare function freezeJson(value: JsonValue): JsonValue;
@@ -0,0 +1,14 @@
1
+ export function cloneJson(value) {
2
+ const serialized = JSON.stringify(value);
3
+ if (serialized === undefined)
4
+ throw new Error("Vehicle JSON metadata must be serializable");
5
+ return freezeJson(JSON.parse(serialized));
6
+ }
7
+ export function freezeJson(value) {
8
+ if (Array.isArray(value))
9
+ return Object.freeze(value.map(freezeJson));
10
+ if (value !== null && typeof value === "object") {
11
+ return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child]) => [key, freezeJson(child)])));
12
+ }
13
+ return value;
14
+ }
@@ -0,0 +1,20 @@
1
+ import { type VehicleSchemaCodec } from "./codec.js";
2
+ export interface LooseObjectProperty {
3
+ readonly type: string;
4
+ readonly enum?: readonly string[];
5
+ }
6
+ /**
7
+ * A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
8
+ * descriptive metadata surfaced to a client/Pi projection, never itself
9
+ * enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
10
+ * has to be checked here for real, or it's a documentation gesture, not an
11
+ * honest contract (the exact drift this function's own jsonSchema metadata
12
+ * had before: it always advertised `additionalProperties: false` and a
13
+ * per-property `type`, while safeParse only ever checked `required` and
14
+ * `enum`). Every consumer projecting a plain-object input onto a
15
+ * VehicleOperation needs the same required/type/extra-key/enum checks; this
16
+ * is that check written once.
17
+ */
18
+ export declare function defineLooseObjectSchema(properties: Record<string, LooseObjectProperty>, required?: readonly string[]): VehicleSchemaCodec<Record<string, unknown>>;
19
+ /** 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). */
20
+ export declare const passthroughVehicleSchema: VehicleSchemaCodec<unknown>;
@@ -0,0 +1,73 @@
1
+ import { defineVehicleSchema } from "./codec.js";
2
+ /** 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. */
3
+ function matchesLooseObjectPropertyType(type, value) {
4
+ switch (type) {
5
+ case "string":
6
+ return typeof value === "string";
7
+ case "number":
8
+ return typeof value === "number" && Number.isFinite(value);
9
+ case "integer":
10
+ return typeof value === "number" && Number.isInteger(value);
11
+ case "boolean":
12
+ return typeof value === "boolean";
13
+ case "object":
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ case "array":
16
+ return Array.isArray(value);
17
+ case "null":
18
+ return value === null;
19
+ default:
20
+ return true;
21
+ }
22
+ }
23
+ /**
24
+ * A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
25
+ * descriptive metadata surfaced to a client/Pi projection, never itself
26
+ * enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
27
+ * has to be checked here for real, or it's a documentation gesture, not an
28
+ * honest contract (the exact drift this function's own jsonSchema metadata
29
+ * had before: it always advertised `additionalProperties: false` and a
30
+ * per-property `type`, while safeParse only ever checked `required` and
31
+ * `enum`). Every consumer projecting a plain-object input onto a
32
+ * VehicleOperation needs the same required/type/extra-key/enum checks; this
33
+ * is that check written once.
34
+ */
35
+ export function defineLooseObjectSchema(properties, required = []) {
36
+ return defineVehicleSchema({
37
+ // LooseObjectProperty's named fields (type, enum) are all JSON-value-shaped
38
+ // at runtime, but TypeScript's structural check against the recursive
39
+ // JsonValue union doesn't see that through a plain interface -- the cast
40
+ // is a type-system limitation, not a runtime concern.
41
+ jsonSchema: { type: "object", properties: properties, required: [...required], additionalProperties: false },
42
+ safeParse(value) {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
44
+ return { success: false, issues: [{ path: [], message: "input must be an object" }] };
45
+ }
46
+ const input = value;
47
+ for (const key of required) {
48
+ if (!(key in input))
49
+ return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
50
+ }
51
+ for (const key of Object.keys(input)) {
52
+ if (!(key in properties))
53
+ return { success: false, issues: [{ path: [key], message: `${key} is not a recognized property` }] };
54
+ }
55
+ for (const [key, schema] of Object.entries(properties)) {
56
+ if (!(key in input))
57
+ continue;
58
+ if (!matchesLooseObjectPropertyType(schema.type, input[key])) {
59
+ return { success: false, issues: [{ path: [key], message: `${key} must be of type ${schema.type}` }] };
60
+ }
61
+ if (schema.enum && !schema.enum.includes(input[key])) {
62
+ return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
63
+ }
64
+ }
65
+ return { success: true, value: input };
66
+ },
67
+ });
68
+ }
69
+ /** 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). */
70
+ export const passthroughVehicleSchema = defineVehicleSchema({
71
+ jsonSchema: { type: "object" },
72
+ safeParse: (value) => ({ success: true, value }),
73
+ });
@@ -0,0 +1,11 @@
1
+ /** Shared defense-in-depth vocabulary for credential-shaped fields at every Vehicle projection boundary. */
2
+ export declare const VEHICLE_CREDENTIAL_FIELD_NAMES: readonly ["password", "token", "accessToken", "refreshToken", "apiKey", "secret", "authorization", "credential"];
3
+ /** Case/separator-insensitive credential-name check used as a fallback when a schema annotation is missing. */
4
+ export declare function isVehicleCredentialFieldName(name: string): boolean;
5
+ /**
6
+ * JSON Schema property annotation consumed by human-facing Vehicle adapters.
7
+ * `omit` hides the field; `summarize` may show shape/size but never its value.
8
+ * Standard `writeOnly: true` and `format: "password"` always imply omission.
9
+ */
10
+ export declare const VEHICLE_SCHEMA_PRESENTATION_EXTENSION: "x-vehicle-presentation";
11
+ export type VehicleSchemaPresentation = "omit" | "summarize";
@@ -0,0 +1,22 @@
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";
@@ -0,0 +1 @@
1
+ export * from "./registry.js";
@@ -0,0 +1 @@
1
+ export * from "./registry.js";
@@ -1,13 +1,6 @@
1
1
  /**
2
- * "Watch a changing resource, get notified" -- lifted near-verbatim from
3
- * Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
4
- * generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
5
- * -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
6
- * Confirmed independently reinvented three-plus times across this house's
7
- * own ecosystem before this existed: Lector's own registry, Lector's CLI
8
- * (a second, non-resilient reimplementation of the same watch/subscribe
9
- * shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
10
- * ensurePushChannel()+poll dance independently).
2
+ * "Watch a changing resource, get notified" -- a shared Vehicle primitive
3
+ * generalizing a (scope, resource) watch registration into a wire topic.
11
4
  *
12
5
  * Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
13
6
  * I/O here. Matching pattern/resource against a real changed resource is a
@@ -1,13 +1,6 @@
1
1
  /**
2
- * "Watch a changing resource, get notified" -- lifted near-verbatim from
3
- * Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
4
- * generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
5
- * -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
6
- * Confirmed independently reinvented three-plus times across this house's
7
- * own ecosystem before this existed: Lector's own registry, Lector's CLI
8
- * (a second, non-resilient reimplementation of the same watch/subscribe
9
- * shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
10
- * ensurePushChannel()+poll dance independently).
2
+ * "Watch a changing resource, get notified" -- a shared Vehicle primitive
3
+ * generalizing a (scope, resource) watch registration into a wire topic.
11
4
  *
12
5
  * Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
13
6
  * I/O here. Matching pattern/resource against a real changed resource is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-core",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -10,6 +10,10 @@
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
12
  "default": "./dist/index.js"
13
+ },
14
+ "./persistence": {
15
+ "types": "./dist/persistence/index.d.ts",
16
+ "default": "./dist/persistence/index.js"
13
17
  }
14
18
  },
15
19
  "scripts": {
@@ -6,8 +6,26 @@
6
6
  * node:crypto and lives in vehicle-server instead, the same split
7
7
  * atomic-json.ts already uses for fs access.
8
8
  */
9
- import type { VehicleEffect, VehiclePrincipal } from "./vehicle-contract.js";
10
- import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
9
+ import { defineVehicleEvent } from "../events/event.js";
10
+ import type { VehiclePrincipal } from "../operations/context.js";
11
+ import type { VehicleEffect } from "../operations/effect.js";
12
+ import { VEHICLE_EFFECTS } from "../operations/effect.js";
13
+ import { defineVehicleSchema } from "../schemas/codec.js";
14
+
15
+ /** A sha256 hex digest: exactly 64 lowercase hex characters -- matches hashApprovalInput's own (vehicle-server) output shape. */
16
+ const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
17
+
18
+ function isVehicleEffect(value: unknown): value is VehicleEffect {
19
+ return typeof value === "string" && (VEHICLE_EFFECTS as readonly string[]).includes(value);
20
+ }
21
+
22
+ function isVehiclePrincipal(value: unknown): value is VehiclePrincipal {
23
+ if (typeof value !== "object" || value === null) return false;
24
+ const candidate = value as Record<string, unknown>;
25
+ if (typeof candidate["id"] !== "string") return false;
26
+ if (candidate["claims"] === undefined) return true;
27
+ return typeof candidate["claims"] === "object" && candidate["claims"] !== null && !Array.isArray(candidate["claims"]);
28
+ }
11
29
 
12
30
  /** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
13
31
  export const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[] = ["destructive", "open-world"];
@@ -74,7 +92,8 @@ const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
74
92
  requestId: { type: "string" },
75
93
  operationName: { type: "string" },
76
94
  operationVersion: { type: "number" },
77
- effect: { type: "string" },
95
+ effect: { type: "string", enum: [...VEHICLE_EFFECTS] },
96
+ principal: { type: "object" },
78
97
  requestedAt: { type: "number" },
79
98
  expiresAt: { type: "number" },
80
99
  inputHash: { type: "string" },
@@ -86,16 +105,24 @@ const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
86
105
  if (typeof value !== "object" || value === null) return { success: false, issues: [{ path: [], message: "input must be an object" }] };
87
106
  const row = value as Record<string, unknown>;
88
107
  if (
89
- typeof row.requestId !== "string" ||
90
- typeof row.operationName !== "string" ||
91
- typeof row.operationVersion !== "number" ||
92
- typeof row.effect !== "string" ||
93
- typeof row.requestedAt !== "number" ||
94
- typeof row.expiresAt !== "number" ||
95
- typeof row.inputHash !== "string"
108
+ typeof row["requestId"] !== "string" ||
109
+ typeof row["operationName"] !== "string" ||
110
+ typeof row["operationVersion"] !== "number" ||
111
+ !Number.isInteger(row["operationVersion"]) ||
112
+ row["operationVersion"] < 1 ||
113
+ !isVehicleEffect(row["effect"]) ||
114
+ typeof row["requestedAt"] !== "number" ||
115
+ !Number.isFinite(row["requestedAt"]) ||
116
+ typeof row["expiresAt"] !== "number" ||
117
+ !Number.isFinite(row["expiresAt"]) ||
118
+ typeof row["inputHash"] !== "string" ||
119
+ !SHA256_HEX_PATTERN.test(row["inputHash"])
96
120
  ) {
97
121
  return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
98
122
  }
123
+ if (row["principal"] !== undefined && !isVehiclePrincipal(row["principal"])) {
124
+ return { success: false, issues: [{ path: ["principal"], message: "invalid approval request principal" }] };
125
+ }
99
126
  return { success: true, value: row as unknown as VehicleApprovalRequest };
100
127
  },
101
128
  });
@@ -117,12 +144,19 @@ const resolvedPayloadSchema = defineVehicleSchema<VehicleApprovalOutcome>({
117
144
  if (typeof value !== "object" || value === null) return { success: false, issues: [{ path: [], message: "input must be an object" }] };
118
145
  const row = value as Record<string, unknown>;
119
146
  if (
120
- typeof row.requestId !== "string" ||
121
- (row.decision !== "granted" && row.decision !== "denied") ||
122
- typeof row.decidedAt !== "number"
147
+ typeof row["requestId"] !== "string" ||
148
+ (row["decision"] !== "granted" && row["decision"] !== "denied") ||
149
+ typeof row["decidedAt"] !== "number" ||
150
+ !Number.isFinite(row["decidedAt"])
123
151
  ) {
124
152
  return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
125
153
  }
154
+ if (row["decidedBy"] !== undefined && typeof row["decidedBy"] !== "string") {
155
+ return { success: false, issues: [{ path: ["decidedBy"], message: "decidedBy must be a string" }] };
156
+ }
157
+ if (row["comment"] !== undefined && typeof row["comment"] !== "string") {
158
+ return { success: false, issues: [{ path: ["comment"], message: "comment must be a string" }] };
159
+ }
126
160
  return { success: true, value: row as unknown as VehicleApprovalOutcome };
127
161
  },
128
162
  });
@@ -0,0 +1 @@
1
+ export * from "./approval.js";
@@ -0,0 +1,31 @@
1
+ import type { VehicleJobSnapshot, VehicleJobSubmitOptions, VehicleJobSubmitResult, VehicleJobTailResult } from "../jobs/wire.js";
2
+ import type { VehicleManifest } from "../manifest/manifest.js";
3
+ import type { VehicleInvocationOptions } from "../operations/context.js";
4
+
5
+ export interface VehicleSubscription {
6
+ close(): void;
7
+ }
8
+
9
+ export interface VehicleClient {
10
+ manifest(): Promise<VehicleManifest>;
11
+ invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
12
+ close(): Promise<void>;
13
+ /**
14
+ * Vehicle Jobs -- submit a background-capable operation (one whose descriptor declares
15
+ * `background`, see {@link VehicleBackgroundCapability}) and get its jobId back immediately,
16
+ * without waiting for the operation itself to make any progress. Optional: a client that
17
+ * never talks to a job-capable Vehicle (or a hand-rolled test double) simply omits these five
18
+ * methods, exactly like this interface's own long-standing `subscribe()`-shaped extras --
19
+ * present on both LocalVehicleClient and RemoteVehicleClient, absent elsewhere. Feature-detect
20
+ * via the operation's own manifest `background` capability, not by probing for these methods.
21
+ */
22
+ submitJob?(name: string, version: number, input: unknown, options?: VehicleJobSubmitOptions): Promise<VehicleJobSubmitResult>;
23
+ /** Never blocks -- current status, plus output/error once terminal. */
24
+ pollJob?(jobId: string): Promise<VehicleJobSnapshot>;
25
+ /** Progress entries strictly after `cursor` (0 for everything so far), plus the next cursor. Never blocks. */
26
+ tailJob?(jobId: string, cursor?: number): Promise<VehicleJobTailResult>;
27
+ /** Pushes new input to an already-running job's handler, if it opted in via context.steerInputs. */
28
+ steerJob?(jobId: string, input: unknown): Promise<void>;
29
+ /** Best-effort cancellation of a still-running job -- a no-op against an already-terminal one. */
30
+ cancelJob?(jobId: string): Promise<void>;
31
+ }
@@ -0,0 +1 @@
1
+ export * from "./client.js";