@danypops/vehicle-core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -154,6 +154,8 @@ export interface VehicleOperationContext<Input> {
154
154
  readonly idempotencyKey?: string;
155
155
  readonly expectedRevision?: string | number;
156
156
  readonly approvalCapability?: string;
157
+ /** Set only for a job execution (VehicleJobStore.submit()); undefined for a plain invoke(). A handler that wants mid-flight input opts in with `for await (const input of context.steerInputs ?? [])`. */
158
+ readonly steerInputs?: AsyncIterable<unknown>;
157
159
  reportProgress(progress: unknown): void;
158
160
  }
159
161
  export type VehicleOperationHandler<Input, Output> = (context: VehicleOperationContext<Input>) => Promise<Output>;
@@ -1,6 +1,6 @@
1
1
  import type { JsonValue, VehicleSchemaIssue } from "./vehicle-contract.js";
2
2
  export type VehicleFailureCategory = "validation" | "not_found" | "conflict" | "authorization" | "capacity" | "timeout" | "cancelled" | "unavailable" | "internal";
3
- export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable" | "background-not-supported" | "job-not-found";
3
+ export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable" | "background-not-supported" | "job-not-found" | "job-not-steerable" | "job-steer-queue-full";
4
4
  export interface VehicleRecovery {
5
5
  readonly operation?: string;
6
6
  readonly message: string;
@@ -1,7 +1,7 @@
1
1
  /** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
2
2
  export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
3
- /** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
4
- export declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "failed", "succeeded"];
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 declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "orphaned", "failed", "succeeded"];
5
5
  export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
6
6
  export declare function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason;
7
7
  /** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
@@ -43,3 +43,72 @@ export declare class VehicleJobWakeLog {
43
43
  /** Highest seq issued so far (0 if none accepted yet). */
44
44
  get cursor(): number;
45
45
  }
46
+ /** 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. */
47
+ export interface VehicleJobWakeLogReader {
48
+ since(cursor: number): readonly VehicleJobWakeEntry[];
49
+ readonly cursor: number;
50
+ }
51
+ /** 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. */
52
+ export declare function createStaticVehicleJobWakeLog(entries: readonly VehicleJobWakeEntry[]): VehicleJobWakeLogReader;
53
+ /**
54
+ * A job's mid-flight input channel -- the "steer" primitive. Bounded FIFO:
55
+ * push() while a handler isn't yet reading buffers up to maxQueueSize, then
56
+ * refuses further input rather than growing unboundedly or silently
57
+ * overwriting an unread entry. A handler consumes it via `for await (const
58
+ * input of context.steerInputs)`, which ends cleanly once close() is
59
+ * called (VehicleJobStore does this at job finalization).
60
+ */
61
+ export interface VehicleJobSteerPushResult {
62
+ readonly accepted: boolean;
63
+ readonly dropReason?: "queue-full" | "channel-closed";
64
+ }
65
+ export declare class VehicleJobSteerChannel implements AsyncIterable<unknown> {
66
+ private readonly maxQueueSize;
67
+ private readonly buffer;
68
+ private readonly waiters;
69
+ private closed;
70
+ constructor(maxQueueSize?: number);
71
+ push(value: unknown): VehicleJobSteerPushResult;
72
+ /** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
73
+ close(): void;
74
+ [Symbol.asyncIterator](): AsyncIterator<unknown>;
75
+ }
76
+ /**
77
+ * Vehicle Jobs run as in-process promises, not child processes -- there is
78
+ * no PID to reuse, but the same identity-confusion risk vstack's
79
+ * {pid, startToken, comm} design guards against still applies in a
80
+ * generalized form: a persisted job record written by one process
81
+ * instance must never be mistaken for one this (possibly restarted)
82
+ * instance can still resolve. Each VehicleJobStore construction gets a
83
+ * fresh random instanceToken; a persisted record's own stamped token only
84
+ * ever matches the instance that wrote it. A mismatch means "the original
85
+ * run is gone", the same conclusion vstack's identityMatches() reaches by
86
+ * comparing a live process's actual pid/start-time/command against a
87
+ * stored snapshot -- this is that same check with no process to inspect.
88
+ */
89
+ export declare function vehicleJobIdentityMatches(recordInstanceToken: string, currentInstanceToken: string): boolean;
90
+ /** 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". */
91
+ export interface VehicleJobEvictionCandidate {
92
+ readonly jobId: string;
93
+ readonly status: VehicleJobStatus;
94
+ readonly delivered: boolean;
95
+ readonly updatedAt: number;
96
+ }
97
+ export interface VehicleJobRetentionOptions {
98
+ /** Hard cap on total retained job records (of any status). A running job is never evicted regardless of this cap. */
99
+ readonly maxRetainedJobs: number;
100
+ /** A delivered terminal job becomes eligible for eviction once this many ms have passed since it was delivered (== updatedAt at delivery time). */
101
+ readonly deliveredRetentionMs: number;
102
+ readonly now: number;
103
+ }
104
+ /**
105
+ * Pure eviction-selection policy, kept separate from VehicleJobStore's own
106
+ * bookkeeping so the bounded-retention rule is independently testable.
107
+ * Preference order: (1) delivered and past deliveredRetentionMs, oldest
108
+ * first; (2) once still over maxRetainedJobs, any delivered terminal job,
109
+ * oldest first; (3) only as a last resort, an undelivered terminal job,
110
+ * oldest first -- a real loss (a caller may still want that result), but
111
+ * an unbounded store is a worse failure mode. A running job is never a
112
+ * candidate.
113
+ */
114
+ export declare function selectVehicleJobsForEviction(candidates: readonly VehicleJobEvictionCandidate[], options: VehicleJobRetentionOptions): readonly string[];
@@ -1,6 +1,6 @@
1
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. */
3
- export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "failed", "succeeded"];
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
4
  export function resolveVehicleJobTerminationReason(candidates) {
5
5
  if (candidates.length === 0)
6
6
  throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
@@ -75,3 +75,108 @@ function fnv1aHash(value) {
75
75
  }
76
76
  return (hash >>> 0).toString(16);
77
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",
@@ -213,6 +213,8 @@ export interface VehicleOperationContext<Input> {
213
213
  readonly idempotencyKey?: string;
214
214
  readonly expectedRevision?: string | number;
215
215
  readonly approvalCapability?: string;
216
+ /** Set only for a job execution (VehicleJobStore.submit()); undefined for a plain invoke(). A handler that wants mid-flight input opts in with `for await (const input of context.steerInputs ?? [])`. */
217
+ readonly steerInputs?: AsyncIterable<unknown>;
216
218
  reportProgress(progress: unknown): void;
217
219
  }
218
220
 
@@ -27,7 +27,9 @@ export type VehicleCoreErrorCode =
27
27
  | "client-closed"
28
28
  | "operation-unavailable"
29
29
  | "background-not-supported"
30
- | "job-not-found";
30
+ | "job-not-found"
31
+ | "job-not-steerable"
32
+ | "job-steer-queue-full";
31
33
 
32
34
  export interface VehicleRecovery {
33
35
  readonly operation?: string;
@@ -2,8 +2,8 @@
2
2
 
3
3
  export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
4
4
 
5
- /** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
6
- export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "failed", "succeeded"] as const;
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 const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "orphaned", "failed", "succeeded"] as const;
7
7
  export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
8
8
 
9
9
  export function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason {
@@ -112,3 +112,144 @@ function fnv1aHash(value: string): string {
112
112
  }
113
113
  return (hash >>> 0).toString(16);
114
114
  }
115
+
116
+ /** 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. */
117
+ export interface VehicleJobWakeLogReader {
118
+ since(cursor: number): readonly VehicleJobWakeEntry[];
119
+ readonly cursor: number;
120
+ }
121
+
122
+ /** 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. */
123
+ export function createStaticVehicleJobWakeLog(entries: readonly VehicleJobWakeEntry[]): VehicleJobWakeLogReader {
124
+ const sorted = [...entries].sort((a, b) => a.seq - b.seq);
125
+ const cursor = sorted.length > 0 ? sorted[sorted.length - 1]!.seq : 0;
126
+ return {
127
+ since: (cursorArg) => sorted.filter((entry) => entry.seq > cursorArg),
128
+ cursor,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * A job's mid-flight input channel -- the "steer" primitive. Bounded FIFO:
134
+ * push() while a handler isn't yet reading buffers up to maxQueueSize, then
135
+ * refuses further input rather than growing unboundedly or silently
136
+ * overwriting an unread entry. A handler consumes it via `for await (const
137
+ * input of context.steerInputs)`, which ends cleanly once close() is
138
+ * called (VehicleJobStore does this at job finalization).
139
+ */
140
+ export interface VehicleJobSteerPushResult {
141
+ readonly accepted: boolean;
142
+ readonly dropReason?: "queue-full" | "channel-closed";
143
+ }
144
+
145
+ export class VehicleJobSteerChannel implements AsyncIterable<unknown> {
146
+ private readonly buffer: unknown[] = [];
147
+ private readonly waiters: ((result: IteratorResult<unknown>) => void)[] = [];
148
+ private closed = false;
149
+
150
+ constructor(private readonly maxQueueSize: number = 64) {}
151
+
152
+ push(value: unknown): VehicleJobSteerPushResult {
153
+ if (this.closed) return { accepted: false, dropReason: "channel-closed" };
154
+ const waiter = this.waiters.shift();
155
+ if (waiter) {
156
+ waiter({ value, done: false });
157
+ return { accepted: true };
158
+ }
159
+ if (this.buffer.length >= this.maxQueueSize) return { accepted: false, dropReason: "queue-full" };
160
+ this.buffer.push(value);
161
+ return { accepted: true };
162
+ }
163
+
164
+ /** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
165
+ close(): void {
166
+ if (this.closed) return;
167
+ this.closed = true;
168
+ for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true });
169
+ }
170
+
171
+ [Symbol.asyncIterator](): AsyncIterator<unknown> {
172
+ return {
173
+ next: (): Promise<IteratorResult<unknown>> => {
174
+ if (this.buffer.length > 0) return Promise.resolve({ value: this.buffer.shift(), done: false });
175
+ if (this.closed) return Promise.resolve({ value: undefined, done: true });
176
+ return new Promise((resolve) => this.waiters.push(resolve));
177
+ },
178
+ };
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Vehicle Jobs run as in-process promises, not child processes -- there is
184
+ * no PID to reuse, but the same identity-confusion risk vstack's
185
+ * {pid, startToken, comm} design guards against still applies in a
186
+ * generalized form: a persisted job record written by one process
187
+ * instance must never be mistaken for one this (possibly restarted)
188
+ * instance can still resolve. Each VehicleJobStore construction gets a
189
+ * fresh random instanceToken; a persisted record's own stamped token only
190
+ * ever matches the instance that wrote it. A mismatch means "the original
191
+ * run is gone", the same conclusion vstack's identityMatches() reaches by
192
+ * comparing a live process's actual pid/start-time/command against a
193
+ * stored snapshot -- this is that same check with no process to inspect.
194
+ */
195
+ export function vehicleJobIdentityMatches(recordInstanceToken: string, currentInstanceToken: string): boolean {
196
+ return recordInstanceToken === currentInstanceToken;
197
+ }
198
+
199
+ /** 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". */
200
+ export interface VehicleJobEvictionCandidate {
201
+ readonly jobId: string;
202
+ readonly status: VehicleJobStatus;
203
+ readonly delivered: boolean;
204
+ readonly updatedAt: number;
205
+ }
206
+
207
+ export interface VehicleJobRetentionOptions {
208
+ /** Hard cap on total retained job records (of any status). A running job is never evicted regardless of this cap. */
209
+ readonly maxRetainedJobs: number;
210
+ /** A delivered terminal job becomes eligible for eviction once this many ms have passed since it was delivered (== updatedAt at delivery time). */
211
+ readonly deliveredRetentionMs: number;
212
+ readonly now: number;
213
+ }
214
+
215
+ /**
216
+ * Pure eviction-selection policy, kept separate from VehicleJobStore's own
217
+ * bookkeeping so the bounded-retention rule is independently testable.
218
+ * Preference order: (1) delivered and past deliveredRetentionMs, oldest
219
+ * first; (2) once still over maxRetainedJobs, any delivered terminal job,
220
+ * oldest first; (3) only as a last resort, an undelivered terminal job,
221
+ * oldest first -- a real loss (a caller may still want that result), but
222
+ * an unbounded store is a worse failure mode. A running job is never a
223
+ * candidate.
224
+ */
225
+ export function selectVehicleJobsForEviction(
226
+ candidates: readonly VehicleJobEvictionCandidate[],
227
+ options: VehicleJobRetentionOptions,
228
+ ): readonly string[] {
229
+ const terminal = candidates.filter((candidate) => candidate.status !== "running");
230
+ const byAgeAscending = (a: VehicleJobEvictionCandidate, b: VehicleJobEvictionCandidate) => a.updatedAt - b.updatedAt;
231
+
232
+ const evicted = new Set<string>();
233
+ for (const candidate of terminal) {
234
+ if (candidate.delivered && options.now - candidate.updatedAt >= options.deliveredRetentionMs) evicted.add(candidate.jobId);
235
+ }
236
+
237
+ const remainingCount = () => candidates.length - evicted.size;
238
+ if (remainingCount() > options.maxRetainedJobs) {
239
+ const deliveredOldestFirst = terminal.filter((candidate) => candidate.delivered && !evicted.has(candidate.jobId)).sort(byAgeAscending);
240
+ for (const candidate of deliveredOldestFirst) {
241
+ if (remainingCount() <= options.maxRetainedJobs) break;
242
+ evicted.add(candidate.jobId);
243
+ }
244
+ }
245
+ if (remainingCount() > options.maxRetainedJobs) {
246
+ const undeliveredOldestFirst = terminal
247
+ .filter((candidate) => !candidate.delivered && !evicted.has(candidate.jobId))
248
+ .sort(byAgeAscending);
249
+ for (const candidate of undeliveredOldestFirst) {
250
+ if (remainingCount() <= options.maxRetainedJobs) break;
251
+ evicted.add(candidate.jobId);
252
+ }
253
+ }
254
+ return [...evicted];
255
+ }