@danypops/vehicle-core 0.1.1 → 0.3.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 +9 -0
- package/dist/atomic-json.d.ts +53 -0
- package/dist/atomic-json.js +102 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/vehicle-contract.d.ts +32 -0
- package/dist/vehicle-contract.js +27 -1
- package/package.json +2 -2
- package/src/atomic-json.ts +137 -0
- package/src/index.ts +1 -0
- package/src/vehicle-contract.ts +56 -8
package/README.md
CHANGED
|
@@ -13,3 +13,12 @@ descriptor kept separate from its executable handler. See the
|
|
|
13
13
|
[workspace README](https://github.com/DanyPops/vehicle#readme) for how it
|
|
14
14
|
fits with `@danypops/vehicle-server`, `@danypops/vehicle-client`, and
|
|
15
15
|
`@danypops/vehicle-client-pi`.
|
|
16
|
+
|
|
17
|
+
An operation whose result should be read as a narrative rather than parsed
|
|
18
|
+
as data can intersect its Output type with `WithVehicleContent` and include
|
|
19
|
+
a `content: [{ type: "text", text }]` field alongside its own domain data --
|
|
20
|
+
the same field name and shape MCP's `CallToolResult.content` and Pi's own
|
|
21
|
+
tool-result type use, so no translation layer is needed at either boundary.
|
|
22
|
+
`extractVehicleContent(output)` reads those blocks back out for a generic
|
|
23
|
+
Vehicle client to prefer over raw JSON, returning undefined for absent or
|
|
24
|
+
malformed content so the caller can fall back safely.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform atomic JSON persistence -- shared by every Vehicle
|
|
3
|
+
* primitive that needs durable state (Jobs' status file, Watchers'
|
|
4
|
+
* registry) so a crash or concurrent read never observes a half-written
|
|
5
|
+
* file. Lives in vehicle-core (not vehicle-server) but stays fs-free
|
|
6
|
+
* itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
|
|
7
|
+
* matching vehicle-core's own "zero runtime dependencies" invariant --
|
|
8
|
+
* the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
|
|
9
|
+
* functions, this module only sequences them.
|
|
10
|
+
*
|
|
11
|
+
* Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
|
|
12
|
+
* a collision-safe temp filename, injectable fs/now/pid/random for
|
|
13
|
+
* deterministic tests, and explicit Windows-aware rename retry (a plain
|
|
14
|
+
* `fs.rename` onto an existing path can transiently fail on Windows if
|
|
15
|
+
* another process -- antivirus, search indexing -- has the destination
|
|
16
|
+
* briefly open; POSIX rename() has no such failure mode, so retrying by
|
|
17
|
+
* default there would only add latency for a class of error that never
|
|
18
|
+
* happens).
|
|
19
|
+
*/
|
|
20
|
+
export interface AtomicJsonFsAdapter {
|
|
21
|
+
writeFile(path: string, data: string): Promise<void>;
|
|
22
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
23
|
+
unlink(path: string): Promise<void>;
|
|
24
|
+
readFile(path: string): Promise<string>;
|
|
25
|
+
}
|
|
26
|
+
export interface AtomicJsonWriterOptions {
|
|
27
|
+
readonly fs: AtomicJsonFsAdapter;
|
|
28
|
+
/** Defaults to Date.now. */
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
/** Defaults to the current process's pid, or 0 outside Node/Bun. */
|
|
31
|
+
readonly pid?: () => number;
|
|
32
|
+
/** Defaults to a short random hex string. */
|
|
33
|
+
readonly random?: () => string;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a failed rename onto the destination is retried at all.
|
|
36
|
+
* Defaults to `process.platform === "win32"` -- off on Linux/macOS,
|
|
37
|
+
* where a transient rename failure isn't a real failure mode.
|
|
38
|
+
*/
|
|
39
|
+
readonly retryRename?: boolean;
|
|
40
|
+
/** Error codes on `rename` worth retrying. Defaults to ["EPERM", "EBUSY", "EACCES"] (the documented Windows file-lock codes). */
|
|
41
|
+
readonly retryRenameErrors?: readonly string[];
|
|
42
|
+
/** Delay before each retry attempt, in order. Defaults to [50, 100, 200]. */
|
|
43
|
+
readonly retryDelaysMs?: readonly number[];
|
|
44
|
+
/** Injectable so a test doesn't have to sleep for real. Defaults to setTimeout. */
|
|
45
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export interface AtomicJsonWriter {
|
|
48
|
+
/** Serializes `value` to JSON and writes it to `filePath` atomically (temp file + rename). */
|
|
49
|
+
write(filePath: string, value: unknown): Promise<void>;
|
|
50
|
+
/** Reads and JSON.parses `filePath`. Returns undefined if the file doesn't exist (fs.readFile throws ENOENT); rethrows any other error. */
|
|
51
|
+
read(filePath: string): Promise<unknown | undefined>;
|
|
52
|
+
}
|
|
53
|
+
export declare function createAtomicJsonWriter(options: AtomicJsonWriterOptions): AtomicJsonWriter;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform atomic JSON persistence -- shared by every Vehicle
|
|
3
|
+
* primitive that needs durable state (Jobs' status file, Watchers'
|
|
4
|
+
* registry) so a crash or concurrent read never observes a half-written
|
|
5
|
+
* file. Lives in vehicle-core (not vehicle-server) but stays fs-free
|
|
6
|
+
* itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
|
|
7
|
+
* matching vehicle-core's own "zero runtime dependencies" invariant --
|
|
8
|
+
* the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
|
|
9
|
+
* functions, this module only sequences them.
|
|
10
|
+
*
|
|
11
|
+
* Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
|
|
12
|
+
* a collision-safe temp filename, injectable fs/now/pid/random for
|
|
13
|
+
* deterministic tests, and explicit Windows-aware rename retry (a plain
|
|
14
|
+
* `fs.rename` onto an existing path can transiently fail on Windows if
|
|
15
|
+
* another process -- antivirus, search indexing -- has the destination
|
|
16
|
+
* briefly open; POSIX rename() has no such failure mode, so retrying by
|
|
17
|
+
* default there would only add latency for a class of error that never
|
|
18
|
+
* happens).
|
|
19
|
+
*/
|
|
20
|
+
function defaultPlatformIsWindows() {
|
|
21
|
+
return typeof process !== "undefined" && process.platform === "win32";
|
|
22
|
+
}
|
|
23
|
+
function defaultPid() {
|
|
24
|
+
return typeof process !== "undefined" ? process.pid : 0;
|
|
25
|
+
}
|
|
26
|
+
function defaultRandom() {
|
|
27
|
+
return Math.random().toString(36).slice(2, 10);
|
|
28
|
+
}
|
|
29
|
+
function isErrnoException(error) {
|
|
30
|
+
return error instanceof Error && "code" in error;
|
|
31
|
+
}
|
|
32
|
+
function dirAndBase(filePath) {
|
|
33
|
+
const separator = filePath.lastIndexOf("/");
|
|
34
|
+
if (separator === -1)
|
|
35
|
+
return { dir: ".", base: filePath };
|
|
36
|
+
return { dir: filePath.slice(0, separator) || "/", base: filePath.slice(separator + 1) };
|
|
37
|
+
}
|
|
38
|
+
export function createAtomicJsonWriter(options) {
|
|
39
|
+
const fsAdapter = options.fs;
|
|
40
|
+
const now = options.now ?? Date.now;
|
|
41
|
+
const pid = options.pid ?? defaultPid;
|
|
42
|
+
const random = options.random ?? defaultRandom;
|
|
43
|
+
const retryRename = options.retryRename ?? defaultPlatformIsWindows();
|
|
44
|
+
const retryRenameErrors = options.retryRenameErrors ?? ["EPERM", "EBUSY", "EACCES"];
|
|
45
|
+
const retryDelaysMs = options.retryDelaysMs ?? [50, 100, 200];
|
|
46
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
47
|
+
async function renameWithRetry(tempPath, filePath) {
|
|
48
|
+
let attempt = 0;
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
await fsAdapter.rename(tempPath, filePath);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const retryable = retryRename && isErrnoException(error) && !!error.code && retryRenameErrors.includes(error.code);
|
|
56
|
+
if (!retryable || attempt >= retryDelaysMs.length)
|
|
57
|
+
throw error;
|
|
58
|
+
await sleep(retryDelaysMs[attempt] ?? 0);
|
|
59
|
+
attempt++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
async write(filePath, value) {
|
|
65
|
+
let serialized;
|
|
66
|
+
try {
|
|
67
|
+
serialized = JSON.stringify(value);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`, { cause: error });
|
|
71
|
+
}
|
|
72
|
+
if (serialized === undefined)
|
|
73
|
+
throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
|
|
74
|
+
const { dir, base } = dirAndBase(filePath);
|
|
75
|
+
const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
|
|
76
|
+
await fsAdapter.writeFile(tempPath, serialized);
|
|
77
|
+
try {
|
|
78
|
+
await renameWithRetry(tempPath, filePath);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
try {
|
|
82
|
+
await fsAdapter.unlink(tempPath);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Best-effort cleanup -- the rename failure itself is the real error to surface.
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async read(filePath) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = await fsAdapter.readFile(filePath);
|
|
93
|
+
return JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (isErrnoException(error) && error.code === "ENOENT")
|
|
97
|
+
return undefined;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -34,6 +34,38 @@ export interface LooseObjectProperty {
|
|
|
34
34
|
export declare function defineLooseObjectSchema(properties: Record<string, LooseObjectProperty>, required?: readonly string[]): VehicleSchemaCodec<Record<string, unknown>>;
|
|
35
35
|
/** 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). */
|
|
36
36
|
export declare const passthroughVehicleSchema: VehicleSchemaCodec<unknown>;
|
|
37
|
+
/**
|
|
38
|
+
* A block of narrative text meant to be read by the model, not parsed as
|
|
39
|
+
* data -- same field name and shape MCP's own CallToolResult.content and
|
|
40
|
+
* Pi's own ToolDefinition.execute() return already use, so a Vehicle
|
|
41
|
+
* operation adopting this needs no translation layer at either boundary.
|
|
42
|
+
* Only the "text" variant exists here; there's no Vehicle use case yet for
|
|
43
|
+
* MCP's image/audio/resource-link block kinds.
|
|
44
|
+
*/
|
|
45
|
+
export interface VehicleContentBlock {
|
|
46
|
+
readonly type: "text";
|
|
47
|
+
readonly text: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* An operation's Output type can intersect this to carry its own
|
|
51
|
+
* model-facing narrative alongside its structured data, e.g.
|
|
52
|
+
* `type RunOutput = { runId: string; created: Task[] } & WithVehicleContent`.
|
|
53
|
+
* The operation itself builds `content` since it's the only code that
|
|
54
|
+
* actually knows how to describe what it computed -- never a per-consumer
|
|
55
|
+
* override bolted on wherever the operation happens to get registered.
|
|
56
|
+
*/
|
|
57
|
+
export interface WithVehicleContent {
|
|
58
|
+
readonly content?: readonly VehicleContentBlock[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reads an operation's own `content` blocks off its output when present and
|
|
62
|
+
* well-formed, so a generic Vehicle client can prefer them over dumping raw
|
|
63
|
+
* JSON at the model -- without knowing anything about the operation's own
|
|
64
|
+
* domain shape. Returns undefined for a malformed or absent `content` field;
|
|
65
|
+
* the caller falls back to its own default (formatted JSON) rather than
|
|
66
|
+
* risk forwarding partial/garbled blocks.
|
|
67
|
+
*/
|
|
68
|
+
export declare function extractVehicleContent(output: unknown): readonly VehicleContentBlock[] | undefined;
|
|
37
69
|
export type VehicleEffect = "read" | "local-write" | "external-write" | "destructive" | "open-world";
|
|
38
70
|
export type VehicleIdempotency = {
|
|
39
71
|
readonly mode: "safe";
|
package/dist/vehicle-contract.js
CHANGED
|
@@ -44,6 +44,31 @@ export const passthroughVehicleSchema = defineVehicleSchema({
|
|
|
44
44
|
jsonSchema: { type: "object" },
|
|
45
45
|
safeParse: (value) => ({ success: true, value }),
|
|
46
46
|
});
|
|
47
|
+
/**
|
|
48
|
+
* Reads an operation's own `content` blocks off its output when present and
|
|
49
|
+
* well-formed, so a generic Vehicle client can prefer them over dumping raw
|
|
50
|
+
* JSON at the model -- without knowing anything about the operation's own
|
|
51
|
+
* domain shape. Returns undefined for a malformed or absent `content` field;
|
|
52
|
+
* the caller falls back to its own default (formatted JSON) rather than
|
|
53
|
+
* risk forwarding partial/garbled blocks.
|
|
54
|
+
*/
|
|
55
|
+
export function extractVehicleContent(output) {
|
|
56
|
+
if (typeof output !== "object" || output === null || Array.isArray(output))
|
|
57
|
+
return undefined;
|
|
58
|
+
const content = output.content;
|
|
59
|
+
if (!Array.isArray(content) || content.length === 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
const blocks = [];
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (typeof block !== "object" || block === null)
|
|
64
|
+
return undefined;
|
|
65
|
+
const { type, text } = block;
|
|
66
|
+
if (type !== "text" || typeof text !== "string")
|
|
67
|
+
return undefined;
|
|
68
|
+
blocks.push({ type: "text", text });
|
|
69
|
+
}
|
|
70
|
+
return blocks;
|
|
71
|
+
}
|
|
47
72
|
export function defineVehicleOperation(options) {
|
|
48
73
|
validateOperationMetadata(options);
|
|
49
74
|
const descriptor = Object.freeze({
|
|
@@ -85,7 +110,8 @@ function validateOperationMetadata(options) {
|
|
|
85
110
|
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
86
111
|
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
87
112
|
}
|
|
88
|
-
if (options.idempotency.mode === "keyed" &&
|
|
113
|
+
if (options.idempotency.mode === "keyed" &&
|
|
114
|
+
(!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
|
|
89
115
|
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
90
116
|
}
|
|
91
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/node": "^22.0.0",
|
|
22
|
-
"typescript": "
|
|
22
|
+
"typescript": "^5.9.2"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform atomic JSON persistence -- shared by every Vehicle
|
|
3
|
+
* primitive that needs durable state (Jobs' status file, Watchers'
|
|
4
|
+
* registry) so a crash or concurrent read never observes a half-written
|
|
5
|
+
* file. Lives in vehicle-core (not vehicle-server) but stays fs-free
|
|
6
|
+
* itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
|
|
7
|
+
* matching vehicle-core's own "zero runtime dependencies" invariant --
|
|
8
|
+
* the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
|
|
9
|
+
* functions, this module only sequences them.
|
|
10
|
+
*
|
|
11
|
+
* Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
|
|
12
|
+
* a collision-safe temp filename, injectable fs/now/pid/random for
|
|
13
|
+
* deterministic tests, and explicit Windows-aware rename retry (a plain
|
|
14
|
+
* `fs.rename` onto an existing path can transiently fail on Windows if
|
|
15
|
+
* another process -- antivirus, search indexing -- has the destination
|
|
16
|
+
* briefly open; POSIX rename() has no such failure mode, so retrying by
|
|
17
|
+
* default there would only add latency for a class of error that never
|
|
18
|
+
* happens).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface AtomicJsonFsAdapter {
|
|
22
|
+
writeFile(path: string, data: string): Promise<void>;
|
|
23
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
24
|
+
unlink(path: string): Promise<void>;
|
|
25
|
+
readFile(path: string): Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AtomicJsonWriterOptions {
|
|
29
|
+
readonly fs: AtomicJsonFsAdapter;
|
|
30
|
+
/** Defaults to Date.now. */
|
|
31
|
+
readonly now?: () => number;
|
|
32
|
+
/** Defaults to the current process's pid, or 0 outside Node/Bun. */
|
|
33
|
+
readonly pid?: () => number;
|
|
34
|
+
/** Defaults to a short random hex string. */
|
|
35
|
+
readonly random?: () => string;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a failed rename onto the destination is retried at all.
|
|
38
|
+
* Defaults to `process.platform === "win32"` -- off on Linux/macOS,
|
|
39
|
+
* where a transient rename failure isn't a real failure mode.
|
|
40
|
+
*/
|
|
41
|
+
readonly retryRename?: boolean;
|
|
42
|
+
/** Error codes on `rename` worth retrying. Defaults to ["EPERM", "EBUSY", "EACCES"] (the documented Windows file-lock codes). */
|
|
43
|
+
readonly retryRenameErrors?: readonly string[];
|
|
44
|
+
/** Delay before each retry attempt, in order. Defaults to [50, 100, 200]. */
|
|
45
|
+
readonly retryDelaysMs?: readonly number[];
|
|
46
|
+
/** Injectable so a test doesn't have to sleep for real. Defaults to setTimeout. */
|
|
47
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AtomicJsonWriter {
|
|
51
|
+
/** Serializes `value` to JSON and writes it to `filePath` atomically (temp file + rename). */
|
|
52
|
+
write(filePath: string, value: unknown): Promise<void>;
|
|
53
|
+
/** Reads and JSON.parses `filePath`. Returns undefined if the file doesn't exist (fs.readFile throws ENOENT); rethrows any other error. */
|
|
54
|
+
read(filePath: string): Promise<unknown | undefined>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultPlatformIsWindows(): boolean {
|
|
58
|
+
return typeof process !== "undefined" && process.platform === "win32";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultPid(): number {
|
|
62
|
+
return typeof process !== "undefined" ? process.pid : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultRandom(): string {
|
|
66
|
+
return Math.random().toString(36).slice(2, 10);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
70
|
+
return error instanceof Error && "code" in error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dirAndBase(filePath: string): { readonly dir: string; readonly base: string } {
|
|
74
|
+
const separator = filePath.lastIndexOf("/");
|
|
75
|
+
if (separator === -1) return { dir: ".", base: filePath };
|
|
76
|
+
return { dir: filePath.slice(0, separator) || "/", base: filePath.slice(separator + 1) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createAtomicJsonWriter(options: AtomicJsonWriterOptions): AtomicJsonWriter {
|
|
80
|
+
const fsAdapter = options.fs;
|
|
81
|
+
const now = options.now ?? Date.now;
|
|
82
|
+
const pid = options.pid ?? defaultPid;
|
|
83
|
+
const random = options.random ?? defaultRandom;
|
|
84
|
+
const retryRename = options.retryRename ?? defaultPlatformIsWindows();
|
|
85
|
+
const retryRenameErrors = options.retryRenameErrors ?? ["EPERM", "EBUSY", "EACCES"];
|
|
86
|
+
const retryDelaysMs = options.retryDelaysMs ?? [50, 100, 200];
|
|
87
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
88
|
+
|
|
89
|
+
async function renameWithRetry(tempPath: string, filePath: string): Promise<void> {
|
|
90
|
+
let attempt = 0;
|
|
91
|
+
for (;;) {
|
|
92
|
+
try {
|
|
93
|
+
await fsAdapter.rename(tempPath, filePath);
|
|
94
|
+
return;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const retryable = retryRename && isErrnoException(error) && !!error.code && retryRenameErrors.includes(error.code);
|
|
97
|
+
if (!retryable || attempt >= retryDelaysMs.length) throw error;
|
|
98
|
+
await sleep(retryDelaysMs[attempt] ?? 0);
|
|
99
|
+
attempt++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
async write(filePath, value) {
|
|
106
|
+
let serialized: string | undefined;
|
|
107
|
+
try {
|
|
108
|
+
serialized = JSON.stringify(value);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`, { cause: error });
|
|
111
|
+
}
|
|
112
|
+
if (serialized === undefined) throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
|
|
113
|
+
const { dir, base } = dirAndBase(filePath);
|
|
114
|
+
const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
|
|
115
|
+
await fsAdapter.writeFile(tempPath, serialized);
|
|
116
|
+
try {
|
|
117
|
+
await renameWithRetry(tempPath, filePath);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
try {
|
|
120
|
+
await fsAdapter.unlink(tempPath);
|
|
121
|
+
} catch {
|
|
122
|
+
// Best-effort cleanup -- the rename failure itself is the real error to surface.
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
async read(filePath) {
|
|
128
|
+
try {
|
|
129
|
+
const raw = await fsAdapter.readFile(filePath);
|
|
130
|
+
return JSON.parse(raw) as unknown;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isErrnoException(error) && error.code === "ENOENT") return undefined;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/index.ts
CHANGED
package/src/vehicle-contract.ts
CHANGED
|
@@ -36,7 +36,10 @@ export interface LooseObjectProperty {
|
|
|
36
36
|
* consumer projecting a plain-object input onto a VehicleOperation needs the
|
|
37
37
|
* same required/enum checks; this is that check written once.
|
|
38
38
|
*/
|
|
39
|
-
export function defineLooseObjectSchema(
|
|
39
|
+
export function defineLooseObjectSchema(
|
|
40
|
+
properties: Record<string, LooseObjectProperty>,
|
|
41
|
+
required: readonly string[] = [],
|
|
42
|
+
): VehicleSchemaCodec<Record<string, unknown>> {
|
|
40
43
|
return defineVehicleSchema<Record<string, unknown>>({
|
|
41
44
|
// LooseObjectProperty's named fields (type, enum) are all JSON-value-shaped
|
|
42
45
|
// at runtime, but TypeScript's structural check against the recursive
|
|
@@ -68,6 +71,53 @@ export const passthroughVehicleSchema: VehicleSchemaCodec<unknown> = defineVehic
|
|
|
68
71
|
safeParse: (value) => ({ success: true, value }),
|
|
69
72
|
});
|
|
70
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A block of narrative text meant to be read by the model, not parsed as
|
|
76
|
+
* data -- same field name and shape MCP's own CallToolResult.content and
|
|
77
|
+
* Pi's own ToolDefinition.execute() return already use, so a Vehicle
|
|
78
|
+
* operation adopting this needs no translation layer at either boundary.
|
|
79
|
+
* Only the "text" variant exists here; there's no Vehicle use case yet for
|
|
80
|
+
* MCP's image/audio/resource-link block kinds.
|
|
81
|
+
*/
|
|
82
|
+
export interface VehicleContentBlock {
|
|
83
|
+
readonly type: "text";
|
|
84
|
+
readonly text: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An operation's Output type can intersect this to carry its own
|
|
89
|
+
* model-facing narrative alongside its structured data, e.g.
|
|
90
|
+
* `type RunOutput = { runId: string; created: Task[] } & WithVehicleContent`.
|
|
91
|
+
* The operation itself builds `content` since it's the only code that
|
|
92
|
+
* actually knows how to describe what it computed -- never a per-consumer
|
|
93
|
+
* override bolted on wherever the operation happens to get registered.
|
|
94
|
+
*/
|
|
95
|
+
export interface WithVehicleContent {
|
|
96
|
+
readonly content?: readonly VehicleContentBlock[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reads an operation's own `content` blocks off its output when present and
|
|
101
|
+
* well-formed, so a generic Vehicle client can prefer them over dumping raw
|
|
102
|
+
* JSON at the model -- without knowing anything about the operation's own
|
|
103
|
+
* domain shape. Returns undefined for a malformed or absent `content` field;
|
|
104
|
+
* the caller falls back to its own default (formatted JSON) rather than
|
|
105
|
+
* risk forwarding partial/garbled blocks.
|
|
106
|
+
*/
|
|
107
|
+
export function extractVehicleContent(output: unknown): readonly VehicleContentBlock[] | undefined {
|
|
108
|
+
if (typeof output !== "object" || output === null || Array.isArray(output)) return undefined;
|
|
109
|
+
const content = (output as { readonly content?: unknown }).content;
|
|
110
|
+
if (!Array.isArray(content) || content.length === 0) return undefined;
|
|
111
|
+
const blocks: VehicleContentBlock[] = [];
|
|
112
|
+
for (const block of content) {
|
|
113
|
+
if (typeof block !== "object" || block === null) return undefined;
|
|
114
|
+
const { type, text } = block as { readonly type?: unknown; readonly text?: unknown };
|
|
115
|
+
if (type !== "text" || typeof text !== "string") return undefined;
|
|
116
|
+
blocks.push({ type: "text", text });
|
|
117
|
+
}
|
|
118
|
+
return blocks;
|
|
119
|
+
}
|
|
120
|
+
|
|
71
121
|
export type VehicleEffect = "read" | "local-write" | "external-write" | "destructive" | "open-world";
|
|
72
122
|
|
|
73
123
|
export type VehicleIdempotency =
|
|
@@ -189,12 +239,7 @@ export interface VehicleManifest extends VehicleManifestIdentity {
|
|
|
189
239
|
|
|
190
240
|
export interface VehicleClient {
|
|
191
241
|
manifest(): Promise<VehicleManifest>;
|
|
192
|
-
invoke<Output = unknown>(
|
|
193
|
-
name: string,
|
|
194
|
-
version: number,
|
|
195
|
-
input: unknown,
|
|
196
|
-
options?: VehicleInvocationOptions,
|
|
197
|
-
): Promise<Output>;
|
|
242
|
+
invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
|
|
198
243
|
close(): Promise<void>;
|
|
199
244
|
}
|
|
200
245
|
|
|
@@ -242,7 +287,10 @@ function validateOperationMetadata<Input, Output>(options: DefineVehicleOperatio
|
|
|
242
287
|
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
243
288
|
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
244
289
|
}
|
|
245
|
-
if (
|
|
290
|
+
if (
|
|
291
|
+
options.idempotency.mode === "keyed" &&
|
|
292
|
+
(!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)
|
|
293
|
+
) {
|
|
246
294
|
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
247
295
|
}
|
|
248
296
|
}
|