@danypops/vehicle-core 0.17.0 → 0.18.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.
- 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/grant.d.ts +36 -0
- package/dist/jobs/grant.js +31 -0
- package/dist/jobs/identity.d.ts +11 -0
- package/dist/jobs/identity.js +13 -0
- package/dist/jobs/index.d.ts +8 -0
- package/dist/jobs/index.js +8 -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/grant.ts +53 -0
- package/src/jobs/identity.ts +13 -0
- package/src/jobs/index.ts +8 -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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
3
|
+
* bookkeeping so the bounded-retention rule is independently testable.
|
|
4
|
+
* Preference order: (1) delivered and past deliveredRetentionMs, oldest
|
|
5
|
+
* first; (2) once still over maxRetainedJobs, any delivered terminal job,
|
|
6
|
+
* oldest first; (3) only as a last resort, an undelivered terminal job,
|
|
7
|
+
* oldest first -- a real loss (a caller may still want that result), but
|
|
8
|
+
* an unbounded store is a worse failure mode. A running job is never a
|
|
9
|
+
* candidate.
|
|
10
|
+
*/
|
|
11
|
+
export function selectVehicleJobsForEviction(candidates, options) {
|
|
12
|
+
const terminal = candidates.filter((candidate) => candidate.status !== "running");
|
|
13
|
+
const byAgeAscending = (a, b) => a.updatedAt - b.updatedAt;
|
|
14
|
+
const evicted = new Set();
|
|
15
|
+
for (const candidate of terminal) {
|
|
16
|
+
if (candidate.delivered && options.now - candidate.updatedAt >= options.deliveredRetentionMs)
|
|
17
|
+
evicted.add(candidate.jobId);
|
|
18
|
+
}
|
|
19
|
+
const remainingCount = () => candidates.length - evicted.size;
|
|
20
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
21
|
+
const deliveredOldestFirst = terminal.filter((candidate) => candidate.delivered && !evicted.has(candidate.jobId)).sort(byAgeAscending);
|
|
22
|
+
for (const candidate of deliveredOldestFirst) {
|
|
23
|
+
if (remainingCount() <= options.maxRetainedJobs)
|
|
24
|
+
break;
|
|
25
|
+
evicted.add(candidate.jobId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
29
|
+
const undeliveredOldestFirst = terminal
|
|
30
|
+
.filter((candidate) => !candidate.delivered && !evicted.has(candidate.jobId))
|
|
31
|
+
.sort(byAgeAscending);
|
|
32
|
+
for (const candidate of undeliveredOldestFirst) {
|
|
33
|
+
if (remainingCount() <= options.maxRetainedJobs)
|
|
34
|
+
break;
|
|
35
|
+
evicted.add(candidate.jobId);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...evicted];
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A job's mid-flight input channel -- the "steer" primitive. Bounded FIFO:
|
|
3
|
+
* push() while a handler isn't yet reading buffers up to maxQueueSize, then
|
|
4
|
+
* refuses further input rather than growing unboundedly or silently
|
|
5
|
+
* overwriting an unread entry. A handler consumes it via `for await (const
|
|
6
|
+
* input of context.steerInputs)`, which ends cleanly once close() is
|
|
7
|
+
* called (VehicleJobStore does this at job finalization).
|
|
8
|
+
*/
|
|
9
|
+
export interface VehicleJobSteerPushResult {
|
|
10
|
+
readonly accepted: boolean;
|
|
11
|
+
readonly dropReason?: "queue-full" | "channel-closed";
|
|
12
|
+
}
|
|
13
|
+
export declare class VehicleJobSteerChannel implements AsyncIterable<unknown> {
|
|
14
|
+
private readonly maxQueueSize;
|
|
15
|
+
private readonly buffer;
|
|
16
|
+
private readonly waiters;
|
|
17
|
+
private closed;
|
|
18
|
+
constructor(maxQueueSize?: number);
|
|
19
|
+
push(value: unknown): VehicleJobSteerPushResult;
|
|
20
|
+
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
21
|
+
close(): void;
|
|
22
|
+
[Symbol.asyncIterator](): AsyncIterator<unknown>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class VehicleJobSteerChannel {
|
|
2
|
+
maxQueueSize;
|
|
3
|
+
buffer = [];
|
|
4
|
+
waiters = [];
|
|
5
|
+
closed = false;
|
|
6
|
+
constructor(maxQueueSize = 64) {
|
|
7
|
+
this.maxQueueSize = maxQueueSize;
|
|
8
|
+
}
|
|
9
|
+
push(value) {
|
|
10
|
+
if (this.closed)
|
|
11
|
+
return { accepted: false, dropReason: "channel-closed" };
|
|
12
|
+
const waiter = this.waiters.shift();
|
|
13
|
+
if (waiter) {
|
|
14
|
+
waiter({ value, done: false });
|
|
15
|
+
return { accepted: true };
|
|
16
|
+
}
|
|
17
|
+
if (this.buffer.length >= this.maxQueueSize)
|
|
18
|
+
return { accepted: false, dropReason: "queue-full" };
|
|
19
|
+
this.buffer.push(value);
|
|
20
|
+
return { accepted: true };
|
|
21
|
+
}
|
|
22
|
+
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
23
|
+
close() {
|
|
24
|
+
if (this.closed)
|
|
25
|
+
return;
|
|
26
|
+
this.closed = true;
|
|
27
|
+
for (const waiter of this.waiters.splice(0))
|
|
28
|
+
waiter({ value: undefined, done: true });
|
|
29
|
+
}
|
|
30
|
+
[Symbol.asyncIterator]() {
|
|
31
|
+
return {
|
|
32
|
+
next: () => {
|
|
33
|
+
if (this.buffer.length > 0)
|
|
34
|
+
return Promise.resolve({ value: this.buffer.shift(), done: false });
|
|
35
|
+
if (this.closed)
|
|
36
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
37
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
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 declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "orphaned", "failed", "succeeded"];
|
|
4
|
+
export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
|
|
5
|
+
export declare function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** 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. */
|
|
2
|
+
export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "orphaned", "failed", "succeeded"];
|
|
3
|
+
export function resolveVehicleJobTerminationReason(candidates) {
|
|
4
|
+
if (candidates.length === 0)
|
|
5
|
+
throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
|
|
6
|
+
for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
|
|
7
|
+
if (candidates.includes(reason))
|
|
8
|
+
return reason;
|
|
9
|
+
}
|
|
10
|
+
throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
|
|
11
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
|
|
2
|
+
export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
|
|
3
|
+
export interface VehicleJobWakeBudget {
|
|
4
|
+
readonly maxCount: number;
|
|
5
|
+
readonly maxBytes: number;
|
|
6
|
+
}
|
|
7
|
+
export type VehicleJobWakeDropReason = "count-budget-exhausted" | "byte-budget-exhausted" | "deduplicated-transition" | "superseded-by-first-only";
|
|
8
|
+
export interface VehicleJobWakeEntry {
|
|
9
|
+
readonly seq: number;
|
|
10
|
+
readonly at: number;
|
|
11
|
+
readonly progress: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface VehicleJobWakeAppendResult {
|
|
14
|
+
readonly accepted: boolean;
|
|
15
|
+
readonly entry?: VehicleJobWakeEntry;
|
|
16
|
+
readonly dropReason?: VehicleJobWakeDropReason;
|
|
17
|
+
}
|
|
18
|
+
export interface VehicleJobWakeLogOptions {
|
|
19
|
+
readonly notifyMode: VehicleJobNotifyMode;
|
|
20
|
+
readonly budget: VehicleJobWakeBudget;
|
|
21
|
+
/** Defaults to Date.now. */
|
|
22
|
+
readonly now?: () => number;
|
|
23
|
+
}
|
|
24
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
25
|
+
export declare class VehicleJobWakeLog {
|
|
26
|
+
private readonly options;
|
|
27
|
+
private readonly entries;
|
|
28
|
+
private usedBytes;
|
|
29
|
+
private nextSeq;
|
|
30
|
+
private lastHash;
|
|
31
|
+
private acceptedFirst;
|
|
32
|
+
private readonly now;
|
|
33
|
+
constructor(options: VehicleJobWakeLogOptions);
|
|
34
|
+
append(progress: unknown): VehicleJobWakeAppendResult;
|
|
35
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
36
|
+
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
37
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
38
|
+
get cursor(): number;
|
|
39
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function safeJsonStringify(value) {
|
|
2
|
+
let serialized;
|
|
3
|
+
try {
|
|
4
|
+
serialized = JSON.stringify(value);
|
|
5
|
+
}
|
|
6
|
+
catch (error) {
|
|
7
|
+
throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
|
|
8
|
+
}
|
|
9
|
+
if (serialized === undefined)
|
|
10
|
+
throw new Error("Vehicle job progress value is not JSON-serializable");
|
|
11
|
+
return serialized;
|
|
12
|
+
}
|
|
13
|
+
/** Non-cryptographic (FNV-1a) -- dedup only. */
|
|
14
|
+
function fnv1aHash(value) {
|
|
15
|
+
let hash = 0x811c9dc5;
|
|
16
|
+
for (let i = 0; i < value.length; i++) {
|
|
17
|
+
hash ^= value.charCodeAt(i);
|
|
18
|
+
hash = Math.imul(hash, 0x01000193);
|
|
19
|
+
}
|
|
20
|
+
return (hash >>> 0).toString(16);
|
|
21
|
+
}
|
|
22
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
23
|
+
export class VehicleJobWakeLog {
|
|
24
|
+
options;
|
|
25
|
+
entries = [];
|
|
26
|
+
usedBytes = 0;
|
|
27
|
+
nextSeq = 1;
|
|
28
|
+
lastHash;
|
|
29
|
+
acceptedFirst = false;
|
|
30
|
+
now;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.now = options.now ?? Date.now;
|
|
34
|
+
}
|
|
35
|
+
append(progress) {
|
|
36
|
+
if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
|
|
37
|
+
return { accepted: false, dropReason: "superseded-by-first-only" };
|
|
38
|
+
}
|
|
39
|
+
const serialized = safeJsonStringify(progress);
|
|
40
|
+
if (this.options.notifyMode === "transition") {
|
|
41
|
+
const hash = fnv1aHash(serialized);
|
|
42
|
+
if (hash === this.lastHash)
|
|
43
|
+
return { accepted: false, dropReason: "deduplicated-transition" };
|
|
44
|
+
this.lastHash = hash;
|
|
45
|
+
}
|
|
46
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
47
|
+
if (this.entries.length >= this.options.budget.maxCount)
|
|
48
|
+
return { accepted: false, dropReason: "count-budget-exhausted" };
|
|
49
|
+
if (this.usedBytes + bytes > this.options.budget.maxBytes)
|
|
50
|
+
return { accepted: false, dropReason: "byte-budget-exhausted" };
|
|
51
|
+
const entry = { seq: this.nextSeq++, at: this.now(), progress };
|
|
52
|
+
this.entries.push(entry);
|
|
53
|
+
this.usedBytes += bytes;
|
|
54
|
+
this.acceptedFirst = true;
|
|
55
|
+
return { accepted: true, entry };
|
|
56
|
+
}
|
|
57
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
58
|
+
since(cursor) {
|
|
59
|
+
return this.entries.filter((entry) => entry.seq > cursor);
|
|
60
|
+
}
|
|
61
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
62
|
+
get cursor() {
|
|
63
|
+
return this.nextSeq - 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { VehicleFailure } from "../errors/error.js";
|
|
2
|
+
import type { VehiclePrincipal } from "../operations/context.js";
|
|
3
|
+
import type { VehicleJobStatus, VehicleJobTerminationReason } from "./termination.js";
|
|
4
|
+
import type { VehicleJobNotifyMode, VehicleJobWakeBudget, VehicleJobWakeEntry } from "./wake-log.js";
|
|
5
|
+
/**
|
|
6
|
+
* The client-facing wire shapes for Vehicle Jobs -- submit/poll/tail options and results, shared by
|
|
7
|
+
* vehicle-server's VehicleJobStore (the orchestration side) and vehicle-client's job-capable clients
|
|
8
|
+
* (the calling side), so both halves of the wire agree on one definition instead of two structurally
|
|
9
|
+
* -identical copies drifting apart. Every field type referenced here already lives in vehicle-core
|
|
10
|
+
* (VehiclePrincipal, VehicleFailure, VehicleJobStatus, ...), which is what makes it safe for these
|
|
11
|
+
* shapes to live here too, alongside the rest of Vehicle Jobs' pure pieces.
|
|
12
|
+
*/
|
|
13
|
+
export interface VehicleJobSubmitOptions {
|
|
14
|
+
readonly permissions?: readonly string[];
|
|
15
|
+
readonly principal?: VehiclePrincipal;
|
|
16
|
+
readonly idempotencyKey?: string;
|
|
17
|
+
readonly expectedRevision?: string | number;
|
|
18
|
+
readonly approvalCapability?: string;
|
|
19
|
+
readonly correlationId?: string;
|
|
20
|
+
readonly callerSessionId?: string;
|
|
21
|
+
readonly callerProjectRoot?: string;
|
|
22
|
+
/** Defaults to "transition". */
|
|
23
|
+
readonly notifyMode?: VehicleJobNotifyMode;
|
|
24
|
+
/** Defaults to background.defaultWakeBudget; clamped to background.maxWakeBudget either way. */
|
|
25
|
+
readonly wakeBudget?: VehicleJobWakeBudget;
|
|
26
|
+
/** No default -- unset means the job runs until it settles or is canceled. */
|
|
27
|
+
readonly maxLifetimeMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface VehicleJobSubmitResult {
|
|
30
|
+
readonly jobId: string;
|
|
31
|
+
}
|
|
32
|
+
export interface VehicleJobSnapshot {
|
|
33
|
+
readonly jobId: string;
|
|
34
|
+
readonly operationName: string;
|
|
35
|
+
readonly operationVersion: number;
|
|
36
|
+
readonly status: VehicleJobStatus;
|
|
37
|
+
readonly createdAt: number;
|
|
38
|
+
readonly updatedAt: number;
|
|
39
|
+
readonly delivered: boolean;
|
|
40
|
+
readonly terminationReason?: VehicleJobTerminationReason;
|
|
41
|
+
readonly output?: unknown;
|
|
42
|
+
readonly error?: VehicleFailure;
|
|
43
|
+
}
|
|
44
|
+
export interface VehicleJobTailResult {
|
|
45
|
+
readonly entries: readonly VehicleJobWakeEntry[];
|
|
46
|
+
readonly cursor: number;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./manifest.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./manifest.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { VehicleManifestEvent } from "../events/event.js";
|
|
2
|
+
import type { VehicleOperationDescriptor } from "../operations/operation.js";
|
|
3
|
+
export interface VehicleManifestIdentity {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly version: string;
|
|
6
|
+
readonly description: string;
|
|
7
|
+
readonly guidance?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A manifest's own view of an operation: the static descriptor plus
|
|
11
|
+
* whether it's currently usable on this particular server instance right
|
|
12
|
+
* now. Availability is a runtime property of a live registry (a
|
|
13
|
+
* credential got configured or removed), never baked into the static
|
|
14
|
+
* descriptor defineVehicleOperation() produces -- two manifest() calls
|
|
15
|
+
* against the same registry can report different availability for the
|
|
16
|
+
* exact same descriptor.
|
|
17
|
+
*/
|
|
18
|
+
export interface VehicleManifestOperation extends VehicleOperationDescriptor {
|
|
19
|
+
readonly available: boolean;
|
|
20
|
+
readonly unavailableReason?: string;
|
|
21
|
+
/**
|
|
22
|
+
* The registry's own, live, fully-resolved answer to "does invoking this operation right
|
|
23
|
+
* now require approval" -- accounts for the registry's current approval policy being
|
|
24
|
+
* enabled/disabled, this operation's own `requiresApproval` override when set, and the
|
|
25
|
+
* effect-derived default otherwise. A real VehicleRegistry.manifest() always sets this
|
|
26
|
+
* (false when the registry never called configureApprovals() at all) -- unlike
|
|
27
|
+
* `requiresApproval` (the static, author-declared override on the descriptor itself),
|
|
28
|
+
* this always reflects the current instant, so a client re-fetching the manifest after a
|
|
29
|
+
* live policy change (VehicleRegistry.updateApprovalPolicy) sees the new answer with no
|
|
30
|
+
* separate sync mechanism needed.
|
|
31
|
+
*
|
|
32
|
+
* Optional purely for backward compatibility with every hand-authored VehicleManifest
|
|
33
|
+
* test fixture across the ecosystem that predates this field (the same reason
|
|
34
|
+
* VehicleManifest.events is optional) -- a consumer reading it should treat undefined the
|
|
35
|
+
* same as a caller of classifyVehicleOperationSafety does: fall back to the effect-level
|
|
36
|
+
* default, never assume false.
|
|
37
|
+
*/
|
|
38
|
+
readonly approvalRequired?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `events` is optional purely for backward compatibility with every
|
|
42
|
+
* hand-authored VehicleManifest test fixture across the ecosystem that
|
|
43
|
+
* predates this field -- a real VehicleRegistry.manifest() always
|
|
44
|
+
* populates it (as [] when no events are declared), never omits it.
|
|
45
|
+
*/
|
|
46
|
+
export interface VehicleManifest extends VehicleManifestIdentity {
|
|
47
|
+
readonly operations: readonly VehicleManifestOperation[];
|
|
48
|
+
readonly events?: readonly VehicleManifestEvent[];
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { JsonValue } from "../schemas/json.js";
|
|
2
|
+
export interface VehiclePrincipal {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly claims?: Readonly<Record<string, JsonValue>>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* callerSessionId/callerProjectRoot identify the real host session (e.g. one Pi TUI process) that
|
|
8
|
+
* originated this call, and its working directory at call time -- a generic ownership/attribution
|
|
9
|
+
* hook any operation handler can read (e.g. scoping a background subscription to the session or
|
|
10
|
+
* project that created it), distinct from both:
|
|
11
|
+
* - correlationId: a caller-CHOSEN id deliberately meant to span several separate invoke() calls
|
|
12
|
+
* (a batch/business-transaction id), not an automatically-derived caller identity.
|
|
13
|
+
* - principal: broader identity/claims used for permission and approval decisions, usually a
|
|
14
|
+
* fixed per-extension value (e.g. {id: "pi-pipes"}), not a distinguishing per-session id.
|
|
15
|
+
* A Pi projection layer (see vehicle-client-pi's invokeVehicleOperation) auto-derives both from
|
|
16
|
+
* context.sessionManager.getSessionId()/context.cwd on every call, the same way it already
|
|
17
|
+
* auto-derives correlationId -- a handler that never reads them pays nothing extra.
|
|
18
|
+
*/
|
|
19
|
+
export interface VehicleInvocationOptions {
|
|
20
|
+
readonly operationId?: string;
|
|
21
|
+
readonly correlationId?: string;
|
|
22
|
+
readonly callerSessionId?: string;
|
|
23
|
+
readonly callerProjectRoot?: string;
|
|
24
|
+
readonly signal?: AbortSignal;
|
|
25
|
+
readonly deadline?: number;
|
|
26
|
+
readonly permissions?: readonly string[];
|
|
27
|
+
readonly principal?: VehiclePrincipal;
|
|
28
|
+
readonly idempotencyKey?: string;
|
|
29
|
+
readonly expectedRevision?: string | number;
|
|
30
|
+
readonly approvalCapability?: string;
|
|
31
|
+
readonly onProgress?: (progress: unknown) => void;
|
|
32
|
+
}
|
|
33
|
+
export interface VehicleOperationContext<Input> {
|
|
34
|
+
readonly input: Input;
|
|
35
|
+
readonly operationId: string;
|
|
36
|
+
readonly correlationId?: string;
|
|
37
|
+
/** See VehicleInvocationOptions's own doc comment. */
|
|
38
|
+
readonly callerSessionId?: string;
|
|
39
|
+
/** See VehicleInvocationOptions's own doc comment. */
|
|
40
|
+
readonly callerProjectRoot?: string;
|
|
41
|
+
readonly signal: AbortSignal;
|
|
42
|
+
readonly deadline: number;
|
|
43
|
+
readonly permissions: readonly string[];
|
|
44
|
+
readonly principal?: VehiclePrincipal;
|
|
45
|
+
readonly idempotencyKey?: string;
|
|
46
|
+
readonly expectedRevision?: string | number;
|
|
47
|
+
readonly approvalCapability?: string;
|
|
48
|
+
/** 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 ?? [])`. */
|
|
49
|
+
readonly steerInputs?: AsyncIterable<unknown>;
|
|
50
|
+
reportProgress(progress: unknown): void;
|
|
51
|
+
}
|
|
52
|
+
export type VehicleOperationHandler<Input, Output> = (context: VehicleOperationContext<Input>) => Promise<Output>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** 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. */
|
|
2
|
+
export declare const VEHICLE_EFFECTS: readonly ["read", "local-write", "external-write", "destructive", "open-world"];
|
|
3
|
+
export type VehicleEffect = (typeof VEHICLE_EFFECTS)[number];
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** 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. */
|
|
2
|
+
export const VEHICLE_EFFECTS = ["read", "local-write", "external-write", "destructive", "open-world"];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { VehicleJobWakeBudget } from "../jobs/wake-log.js";
|
|
2
|
+
import type { VehicleSchemaCodec } from "../schemas/codec.js";
|
|
3
|
+
import { type JsonSchema } from "../schemas/json.js";
|
|
4
|
+
import type { VehicleOperationHandler } from "./context.js";
|
|
5
|
+
import type { VehicleEffect } from "./effect.js";
|
|
6
|
+
export type VehicleIdempotency = {
|
|
7
|
+
readonly mode: "safe";
|
|
8
|
+
} | {
|
|
9
|
+
readonly mode: "keyed";
|
|
10
|
+
readonly retentionMs: number;
|
|
11
|
+
} | {
|
|
12
|
+
readonly mode: "unsafe";
|
|
13
|
+
};
|
|
14
|
+
export interface VehicleLimits {
|
|
15
|
+
readonly defaultTimeoutMs: number;
|
|
16
|
+
readonly maxTimeoutMs: number;
|
|
17
|
+
readonly maxRequestBytes: number;
|
|
18
|
+
readonly maxResponseBytes: number;
|
|
19
|
+
}
|
|
20
|
+
/** One structured, documented failure mode a {@link VehicleOperationDescriptor} declares up front -- part of the operation's own serializable contract, not an ad hoc thrown Error a caller has to reverse-engineer from a message string. */
|
|
21
|
+
export interface VehicleFailureDescriptor {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly description: string;
|
|
24
|
+
}
|
|
25
|
+
/** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
|
|
26
|
+
export interface VehicleBackgroundCapability {
|
|
27
|
+
readonly supported: true;
|
|
28
|
+
readonly defaultWakeBudget: VehicleJobWakeBudget;
|
|
29
|
+
readonly maxWakeBudget: VehicleJobWakeBudget;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The serializable half of a Vehicle operation -- name, version, schemas,
|
|
33
|
+
* ownership-implying permissions, effect classification, idempotency,
|
|
34
|
+
* streaming/long-running capability, request/response limits, and declared
|
|
35
|
+
* {@link VehicleFailureDescriptor} failure modes. Kept separate from the
|
|
36
|
+
* executable {@link VehicleOperationHandler} on purpose: a manifest, a Pi
|
|
37
|
+
* tool projection, or a client's own capability check can all inspect this
|
|
38
|
+
* shape without ever touching (or needing to trust) the implementation
|
|
39
|
+
* behind it.
|
|
40
|
+
*/
|
|
41
|
+
export interface VehicleOperationDescriptor {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly version: number;
|
|
44
|
+
readonly description: string;
|
|
45
|
+
readonly inputSchema: JsonSchema;
|
|
46
|
+
readonly outputSchema: JsonSchema;
|
|
47
|
+
readonly permissions: readonly string[];
|
|
48
|
+
readonly effect: VehicleEffect;
|
|
49
|
+
readonly idempotency: VehicleIdempotency;
|
|
50
|
+
readonly streaming: boolean;
|
|
51
|
+
readonly longRunning: boolean;
|
|
52
|
+
readonly limits: VehicleLimits;
|
|
53
|
+
readonly errors: readonly VehicleFailureDescriptor[];
|
|
54
|
+
readonly background?: VehicleBackgroundCapability;
|
|
55
|
+
/**
|
|
56
|
+
* Owner-declared override for whether this specific operation is ever a candidate for
|
|
57
|
+
* approval gating, independent of its `effect`. Undefined (the default) means "derive it
|
|
58
|
+
* from `effect` against the registry's own requireApprovalForEffects set instead" --
|
|
59
|
+
* VehicleRegistry.manifest()'s own resolution rule, unchanged for every existing
|
|
60
|
+
* operation that never sets this.
|
|
61
|
+
*
|
|
62
|
+
* Exists because VehicleEffect's five values are coarse enough that two operations a
|
|
63
|
+
* real owner classifies very differently (e.g. "restart an already-installed,
|
|
64
|
+
* already-vetted service" vs. "sync a read-only catalog mirror") can land in the same
|
|
65
|
+
* effect bucket (both external-write) -- no single requireApprovalForEffects set can
|
|
66
|
+
* gate one without also gating the other. The owner who registers the operation knows
|
|
67
|
+
* its real risk far better than a 5-value enum can; this lets them say so directly.
|
|
68
|
+
*/
|
|
69
|
+
readonly requiresApproval?: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface VehicleOperation<Input, Output> {
|
|
72
|
+
readonly descriptor: VehicleOperationDescriptor;
|
|
73
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
74
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
75
|
+
}
|
|
76
|
+
export interface DefineVehicleOperationOptions<Input, Output> {
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly version: number;
|
|
79
|
+
readonly description: string;
|
|
80
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
81
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
82
|
+
readonly permissions?: readonly string[];
|
|
83
|
+
readonly effect: VehicleEffect;
|
|
84
|
+
readonly idempotency: VehicleIdempotency;
|
|
85
|
+
readonly streaming?: boolean;
|
|
86
|
+
readonly longRunning?: boolean;
|
|
87
|
+
readonly limits: VehicleLimits;
|
|
88
|
+
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
89
|
+
readonly background?: VehicleBackgroundCapability;
|
|
90
|
+
/** See {@link VehicleOperationDescriptor.requiresApproval}. */
|
|
91
|
+
readonly requiresApproval?: boolean;
|
|
92
|
+
}
|
|
93
|
+
export interface VehicleOperationBinding<Input, Output> {
|
|
94
|
+
readonly operation: VehicleOperation<Input, Output>;
|
|
95
|
+
bind(): VehicleOperationHandler<Input, Output>;
|
|
96
|
+
}
|
|
97
|
+
export declare function defineVehicleOperation<Input, Output>(options: DefineVehicleOperationOptions<Input, Output>): VehicleOperation<Input, Output>;
|
|
98
|
+
export declare function bindVehicleOperation<Input, Output>(operation: VehicleOperation<Input, Output>, bind: () => VehicleOperationHandler<Input, Output>): VehicleOperationBinding<Input, Output>;
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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";
|