@danypops/vehicle-core 0.8.0 → 0.10.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/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/vehicle-approvals.d.ts +50 -0
- package/dist/vehicle-approvals.js +75 -0
- package/dist/vehicle-scheduler.d.ts +63 -0
- package/dist/vehicle-scheduler.js +33 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/vehicle-approvals.ts +130 -0
- package/src/vehicle-scheduler.ts +73 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from "./atomic-json.js";
|
|
2
|
+
export * from "./vehicle-approvals.js";
|
|
2
3
|
export * from "./vehicle-contract.js";
|
|
3
4
|
export * from "./vehicle-errors.js";
|
|
4
5
|
export * from "./vehicle-jobs.js";
|
|
6
|
+
export * from "./vehicle-scheduler.js";
|
|
5
7
|
export * from "./vehicle-watchers.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from "./atomic-json.js";
|
|
2
|
+
export * from "./vehicle-approvals.js";
|
|
2
3
|
export * from "./vehicle-contract.js";
|
|
3
4
|
export * from "./vehicle-errors.js";
|
|
4
5
|
export * from "./vehicle-jobs.js";
|
|
6
|
+
export * from "./vehicle-scheduler.js";
|
|
5
7
|
export * from "./vehicle-watchers.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level shapes for the Approval Gate: a pending human/authority decision
|
|
3
|
+
* that stands between a gated-effect invoke() and its handler actually
|
|
4
|
+
* running. Kept in vehicle-core (runtime-neutral, zero dependencies) --
|
|
5
|
+
* the actual signing/verification authority (HmacApprovalAuthority) needs
|
|
6
|
+
* node:crypto and lives in vehicle-server instead, the same split
|
|
7
|
+
* atomic-json.ts already uses for fs access.
|
|
8
|
+
*/
|
|
9
|
+
import type { VehicleEffect, VehiclePrincipal } from "./vehicle-contract.js";
|
|
10
|
+
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
11
|
+
export declare const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[];
|
|
12
|
+
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
13
|
+
export declare const DEFAULT_APPROVAL_TIMEOUT_MS: number;
|
|
14
|
+
/** Emitted (as a Vehicle Event) the moment a gated-effect invoke() has no valid capability -- durable-first, before any interactive prompt is attempted. */
|
|
15
|
+
export interface VehicleApprovalRequest {
|
|
16
|
+
readonly requestId: string;
|
|
17
|
+
readonly operationName: string;
|
|
18
|
+
readonly operationVersion: number;
|
|
19
|
+
readonly effect: VehicleEffect;
|
|
20
|
+
readonly principal?: VehiclePrincipal;
|
|
21
|
+
readonly requestedAt: number;
|
|
22
|
+
readonly expiresAt: number;
|
|
23
|
+
/** sha256 hex of the exact input the gated invoke() attempted -- a minted capability is scoped to this input, not just the operation. */
|
|
24
|
+
readonly inputHash: string;
|
|
25
|
+
}
|
|
26
|
+
export type VehicleApprovalDecision = "granted" | "denied";
|
|
27
|
+
/** Emitted once vehicle.approval.resolve settles a request, whichever way. */
|
|
28
|
+
export interface VehicleApprovalOutcome {
|
|
29
|
+
readonly requestId: string;
|
|
30
|
+
readonly decision: VehicleApprovalDecision;
|
|
31
|
+
readonly decidedAt: number;
|
|
32
|
+
readonly decidedBy?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The real authority behind an approvalCapability -- replaces today's
|
|
36
|
+
* "any non-empty string satisfies it" rubber stamp. mint() is called only
|
|
37
|
+
* from inside vehicle.approval.resolve once a decision is actually made;
|
|
38
|
+
* verify() is called from invoke() itself against whatever capability the
|
|
39
|
+
* caller presents. A capability is scoped to the exact operation+input it
|
|
40
|
+
* was minted for and expires with its originating request -- presenting a
|
|
41
|
+
* capability minted for a different operation, a different input, or one
|
|
42
|
+
* already consumed (single-use) must fail verify().
|
|
43
|
+
*/
|
|
44
|
+
export interface VehicleApprovalAuthority {
|
|
45
|
+
mint(request: Pick<VehicleApprovalRequest, "requestId" | "operationName" | "operationVersion" | "expiresAt" | "inputHash">): string;
|
|
46
|
+
verify(capability: string, operationName: string, operationVersion: number, inputHash: string): boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Built into every VehicleRegistry once configureApprovals() is called -- never registered unconditionally, so a Vehicle that never opts in has zero manifest/shape change. */
|
|
49
|
+
export declare const vehicleApprovalRequestedEvent: import("./vehicle-contract.js").VehicleEvent<VehicleApprovalRequest>;
|
|
50
|
+
export declare const vehicleApprovalResolvedEvent: import("./vehicle-contract.js").VehicleEvent<VehicleApprovalOutcome>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
2
|
+
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
3
|
+
export const DEFAULT_APPROVAL_EFFECTS = ["destructive", "open-world"];
|
|
4
|
+
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
5
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
6
|
+
const requestedPayloadSchema = defineVehicleSchema({
|
|
7
|
+
jsonSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
requestId: { type: "string" },
|
|
11
|
+
operationName: { type: "string" },
|
|
12
|
+
operationVersion: { type: "number" },
|
|
13
|
+
effect: { type: "string" },
|
|
14
|
+
requestedAt: { type: "number" },
|
|
15
|
+
expiresAt: { type: "number" },
|
|
16
|
+
inputHash: { type: "string" },
|
|
17
|
+
},
|
|
18
|
+
required: ["requestId", "operationName", "operationVersion", "effect", "requestedAt", "expiresAt", "inputHash"],
|
|
19
|
+
additionalProperties: true,
|
|
20
|
+
},
|
|
21
|
+
safeParse(value) {
|
|
22
|
+
if (typeof value !== "object" || value === null)
|
|
23
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
24
|
+
const row = value;
|
|
25
|
+
if (typeof row.requestId !== "string" ||
|
|
26
|
+
typeof row.operationName !== "string" ||
|
|
27
|
+
typeof row.operationVersion !== "number" ||
|
|
28
|
+
typeof row.effect !== "string" ||
|
|
29
|
+
typeof row.requestedAt !== "number" ||
|
|
30
|
+
typeof row.expiresAt !== "number" ||
|
|
31
|
+
typeof row.inputHash !== "string") {
|
|
32
|
+
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
33
|
+
}
|
|
34
|
+
return { success: true, value: row };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const resolvedPayloadSchema = defineVehicleSchema({
|
|
38
|
+
jsonSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
requestId: { type: "string" },
|
|
42
|
+
decision: { type: "string", enum: ["granted", "denied"] },
|
|
43
|
+
decidedAt: { type: "number" },
|
|
44
|
+
decidedBy: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
required: ["requestId", "decision", "decidedAt"],
|
|
47
|
+
additionalProperties: true,
|
|
48
|
+
},
|
|
49
|
+
safeParse(value) {
|
|
50
|
+
if (typeof value !== "object" || value === null)
|
|
51
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
52
|
+
const row = value;
|
|
53
|
+
if (typeof row.requestId !== "string" ||
|
|
54
|
+
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
55
|
+
typeof row.decidedAt !== "number") {
|
|
56
|
+
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
57
|
+
}
|
|
58
|
+
return { success: true, value: row };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
/** Built into every VehicleRegistry once configureApprovals() is called -- never registered unconditionally, so a Vehicle that never opts in has zero manifest/shape change. */
|
|
62
|
+
export const vehicleApprovalRequestedEvent = defineVehicleEvent({
|
|
63
|
+
name: "vehicle.approval.requested",
|
|
64
|
+
version: 1,
|
|
65
|
+
description: "A gated-effect operation was invoked without a valid approval capability.",
|
|
66
|
+
payload: requestedPayloadSchema,
|
|
67
|
+
maxPayloadBytes: 16_384,
|
|
68
|
+
});
|
|
69
|
+
export const vehicleApprovalResolvedEvent = defineVehicleEvent({
|
|
70
|
+
name: "vehicle.approval.resolved",
|
|
71
|
+
version: 1,
|
|
72
|
+
description: "A pending approval request was granted or denied.",
|
|
73
|
+
payload: resolvedPayloadSchema,
|
|
74
|
+
maxPayloadBytes: 4_096,
|
|
75
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Trigger something at a future time or on a recurring interval,"
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Pure pieces only: trigger/action shapes and the fire-time arithmetic.
|
|
12
|
+
* Real timers, persistence, and registry wiring live in vehicle-server's
|
|
13
|
+
* VehicleScheduler, the same core/server split vehicle-jobs.ts uses.
|
|
14
|
+
*/
|
|
15
|
+
import type { JsonValue } from "./vehicle-contract.js";
|
|
16
|
+
export type VehicleScheduleTrigger = {
|
|
17
|
+
readonly kind: "at";
|
|
18
|
+
readonly at: number;
|
|
19
|
+
} | {
|
|
20
|
+
readonly kind: "every";
|
|
21
|
+
readonly intervalMs: number;
|
|
22
|
+
};
|
|
23
|
+
export type VehicleScheduleAction = {
|
|
24
|
+
readonly kind: "operation";
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly version: number;
|
|
27
|
+
readonly input: JsonValue;
|
|
28
|
+
readonly permissions?: readonly string[];
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: "event";
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly version: number;
|
|
33
|
+
readonly payload: JsonValue;
|
|
34
|
+
};
|
|
35
|
+
export interface VehicleScheduledEntry {
|
|
36
|
+
readonly scheduleId: string;
|
|
37
|
+
readonly owner: string;
|
|
38
|
+
readonly trigger: VehicleScheduleTrigger;
|
|
39
|
+
readonly action: VehicleScheduleAction;
|
|
40
|
+
readonly createdAt: number;
|
|
41
|
+
/** For "at": consumed once it fires. For "every": advanced to the next tick after each fire. */
|
|
42
|
+
readonly nextFireAt: number;
|
|
43
|
+
}
|
|
44
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
45
|
+
export declare const DEFAULT_MAX_SCHEDULES_PER_OWNER = 32;
|
|
46
|
+
/** Raised when an owner already has its configured maximum of schedules -- fails closed, the same bounded-resource discipline WatchLimitExceeded already applies to Vehicle Watchers. */
|
|
47
|
+
export declare class VehicleScheduleLimitExceeded extends Error {
|
|
48
|
+
readonly owner: string;
|
|
49
|
+
readonly max: number;
|
|
50
|
+
constructor(owner: string, max: number);
|
|
51
|
+
}
|
|
52
|
+
/** The first fire time for a freshly created schedule. */
|
|
53
|
+
export declare function initialFireAt(trigger: VehicleScheduleTrigger, now: number): number;
|
|
54
|
+
/** 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
|
+
export declare function nextFireAtAfterFire(trigger: VehicleScheduleTrigger, now: number): number | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Where a restored entry should be re-armed to. A one-shot "at" entry keeps
|
|
58
|
+
* its original persisted time (fires as soon as possible if overdue -- the
|
|
59
|
+
* one thing it was supposed to do must not be silently lost). A recurring
|
|
60
|
+
* "every" entry resumes its normal cadence from now if it fell behind while
|
|
61
|
+
* the daemon was down, rather than firing once per missed tick.
|
|
62
|
+
*/
|
|
63
|
+
export declare function nextFireAtAfterRestore(trigger: VehicleScheduleTrigger, persistedNextFireAt: number, now: number): number;
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
/** The first fire time for a freshly created schedule. */
|
|
15
|
+
export function initialFireAt(trigger, now) {
|
|
16
|
+
return trigger.kind === "at" ? trigger.at : now + trigger.intervalMs;
|
|
17
|
+
}
|
|
18
|
+
/** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
|
|
19
|
+
export function nextFireAtAfterFire(trigger, now) {
|
|
20
|
+
return trigger.kind === "every" ? now + trigger.intervalMs : undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Where a restored entry should be re-armed to. A one-shot "at" entry keeps
|
|
24
|
+
* its original persisted time (fires as soon as possible if overdue -- the
|
|
25
|
+
* one thing it was supposed to do must not be silently lost). A recurring
|
|
26
|
+
* "every" entry resumes its normal cadence from now if it fell behind while
|
|
27
|
+
* the daemon was down, rather than firing once per missed tick.
|
|
28
|
+
*/
|
|
29
|
+
export function nextFireAtAfterRestore(trigger, persistedNextFireAt, now) {
|
|
30
|
+
if (trigger.kind === "at")
|
|
31
|
+
return persistedNextFireAt;
|
|
32
|
+
return persistedNextFireAt > now ? persistedNextFireAt : now + trigger.intervalMs;
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from "./atomic-json.js";
|
|
2
|
+
export * from "./vehicle-approvals.js";
|
|
2
3
|
export * from "./vehicle-contract.js";
|
|
3
4
|
export * from "./vehicle-errors.js";
|
|
4
5
|
export * from "./vehicle-jobs.js";
|
|
6
|
+
export * from "./vehicle-scheduler.js";
|
|
5
7
|
export * from "./vehicle-watchers.js";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level shapes for the Approval Gate: a pending human/authority decision
|
|
3
|
+
* that stands between a gated-effect invoke() and its handler actually
|
|
4
|
+
* running. Kept in vehicle-core (runtime-neutral, zero dependencies) --
|
|
5
|
+
* the actual signing/verification authority (HmacApprovalAuthority) needs
|
|
6
|
+
* node:crypto and lives in vehicle-server instead, the same split
|
|
7
|
+
* atomic-json.ts already uses for fs access.
|
|
8
|
+
*/
|
|
9
|
+
import type { VehicleEffect, VehiclePrincipal } from "./vehicle-contract.js";
|
|
10
|
+
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
11
|
+
|
|
12
|
+
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
13
|
+
export const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[] = ["destructive", "open-world"];
|
|
14
|
+
|
|
15
|
+
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
16
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
17
|
+
|
|
18
|
+
/** Emitted (as a Vehicle Event) the moment a gated-effect invoke() has no valid capability -- durable-first, before any interactive prompt is attempted. */
|
|
19
|
+
export interface VehicleApprovalRequest {
|
|
20
|
+
readonly requestId: string;
|
|
21
|
+
readonly operationName: string;
|
|
22
|
+
readonly operationVersion: number;
|
|
23
|
+
readonly effect: VehicleEffect;
|
|
24
|
+
readonly principal?: VehiclePrincipal;
|
|
25
|
+
readonly requestedAt: number;
|
|
26
|
+
readonly expiresAt: number;
|
|
27
|
+
/** sha256 hex of the exact input the gated invoke() attempted -- a minted capability is scoped to this input, not just the operation. */
|
|
28
|
+
readonly inputHash: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type VehicleApprovalDecision = "granted" | "denied";
|
|
32
|
+
|
|
33
|
+
/** Emitted once vehicle.approval.resolve settles a request, whichever way. */
|
|
34
|
+
export interface VehicleApprovalOutcome {
|
|
35
|
+
readonly requestId: string;
|
|
36
|
+
readonly decision: VehicleApprovalDecision;
|
|
37
|
+
readonly decidedAt: number;
|
|
38
|
+
readonly decidedBy?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The real authority behind an approvalCapability -- replaces today's
|
|
43
|
+
* "any non-empty string satisfies it" rubber stamp. mint() is called only
|
|
44
|
+
* from inside vehicle.approval.resolve once a decision is actually made;
|
|
45
|
+
* verify() is called from invoke() itself against whatever capability the
|
|
46
|
+
* caller presents. A capability is scoped to the exact operation+input it
|
|
47
|
+
* was minted for and expires with its originating request -- presenting a
|
|
48
|
+
* capability minted for a different operation, a different input, or one
|
|
49
|
+
* already consumed (single-use) must fail verify().
|
|
50
|
+
*/
|
|
51
|
+
export interface VehicleApprovalAuthority {
|
|
52
|
+
mint(request: Pick<VehicleApprovalRequest, "requestId" | "operationName" | "operationVersion" | "expiresAt" | "inputHash">): string;
|
|
53
|
+
verify(capability: string, operationName: string, operationVersion: number, inputHash: string): boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
|
|
57
|
+
jsonSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
requestId: { type: "string" },
|
|
61
|
+
operationName: { type: "string" },
|
|
62
|
+
operationVersion: { type: "number" },
|
|
63
|
+
effect: { type: "string" },
|
|
64
|
+
requestedAt: { type: "number" },
|
|
65
|
+
expiresAt: { type: "number" },
|
|
66
|
+
inputHash: { type: "string" },
|
|
67
|
+
},
|
|
68
|
+
required: ["requestId", "operationName", "operationVersion", "effect", "requestedAt", "expiresAt", "inputHash"],
|
|
69
|
+
additionalProperties: true,
|
|
70
|
+
},
|
|
71
|
+
safeParse(value) {
|
|
72
|
+
if (typeof value !== "object" || value === null) return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
73
|
+
const row = value as Record<string, unknown>;
|
|
74
|
+
if (
|
|
75
|
+
typeof row.requestId !== "string" ||
|
|
76
|
+
typeof row.operationName !== "string" ||
|
|
77
|
+
typeof row.operationVersion !== "number" ||
|
|
78
|
+
typeof row.effect !== "string" ||
|
|
79
|
+
typeof row.requestedAt !== "number" ||
|
|
80
|
+
typeof row.expiresAt !== "number" ||
|
|
81
|
+
typeof row.inputHash !== "string"
|
|
82
|
+
) {
|
|
83
|
+
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
84
|
+
}
|
|
85
|
+
return { success: true, value: row as unknown as VehicleApprovalRequest };
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const resolvedPayloadSchema = defineVehicleSchema<VehicleApprovalOutcome>({
|
|
90
|
+
jsonSchema: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
requestId: { type: "string" },
|
|
94
|
+
decision: { type: "string", enum: ["granted", "denied"] },
|
|
95
|
+
decidedAt: { type: "number" },
|
|
96
|
+
decidedBy: { type: "string" },
|
|
97
|
+
},
|
|
98
|
+
required: ["requestId", "decision", "decidedAt"],
|
|
99
|
+
additionalProperties: true,
|
|
100
|
+
},
|
|
101
|
+
safeParse(value) {
|
|
102
|
+
if (typeof value !== "object" || value === null) return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
103
|
+
const row = value as Record<string, unknown>;
|
|
104
|
+
if (
|
|
105
|
+
typeof row.requestId !== "string" ||
|
|
106
|
+
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
107
|
+
typeof row.decidedAt !== "number"
|
|
108
|
+
) {
|
|
109
|
+
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
110
|
+
}
|
|
111
|
+
return { success: true, value: row as unknown as VehicleApprovalOutcome };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** Built into every VehicleRegistry once configureApprovals() is called -- never registered unconditionally, so a Vehicle that never opts in has zero manifest/shape change. */
|
|
116
|
+
export const vehicleApprovalRequestedEvent = defineVehicleEvent<VehicleApprovalRequest>({
|
|
117
|
+
name: "vehicle.approval.requested",
|
|
118
|
+
version: 1,
|
|
119
|
+
description: "A gated-effect operation was invoked without a valid approval capability.",
|
|
120
|
+
payload: requestedPayloadSchema,
|
|
121
|
+
maxPayloadBytes: 16_384,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const vehicleApprovalResolvedEvent = defineVehicleEvent<VehicleApprovalOutcome>({
|
|
125
|
+
name: "vehicle.approval.resolved",
|
|
126
|
+
version: 1,
|
|
127
|
+
description: "A pending approval request was granted or denied.",
|
|
128
|
+
payload: resolvedPayloadSchema,
|
|
129
|
+
maxPayloadBytes: 4_096,
|
|
130
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Trigger something at a future time or on a recurring interval,"
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Pure pieces only: trigger/action shapes and the fire-time arithmetic.
|
|
12
|
+
* Real timers, persistence, and registry wiring live in vehicle-server's
|
|
13
|
+
* VehicleScheduler, the same core/server split vehicle-jobs.ts uses.
|
|
14
|
+
*/
|
|
15
|
+
import type { JsonValue } from "./vehicle-contract.js";
|
|
16
|
+
|
|
17
|
+
export type VehicleScheduleTrigger = { readonly kind: "at"; readonly at: number } | { readonly kind: "every"; readonly intervalMs: number };
|
|
18
|
+
|
|
19
|
+
export type VehicleScheduleAction =
|
|
20
|
+
| {
|
|
21
|
+
readonly kind: "operation";
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly version: number;
|
|
24
|
+
readonly input: JsonValue;
|
|
25
|
+
readonly permissions?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
| { readonly kind: "event"; readonly name: string; readonly version: number; readonly payload: JsonValue };
|
|
28
|
+
|
|
29
|
+
export interface VehicleScheduledEntry {
|
|
30
|
+
readonly scheduleId: string;
|
|
31
|
+
readonly owner: string;
|
|
32
|
+
readonly trigger: VehicleScheduleTrigger;
|
|
33
|
+
readonly action: VehicleScheduleAction;
|
|
34
|
+
readonly createdAt: number;
|
|
35
|
+
/** For "at": consumed once it fires. For "every": advanced to the next tick after each fire. */
|
|
36
|
+
readonly nextFireAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
40
|
+
export const DEFAULT_MAX_SCHEDULES_PER_OWNER = 32;
|
|
41
|
+
|
|
42
|
+
/** Raised when an owner already has its configured maximum of schedules -- fails closed, the same bounded-resource discipline WatchLimitExceeded already applies to Vehicle Watchers. */
|
|
43
|
+
export class VehicleScheduleLimitExceeded extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
readonly owner: string,
|
|
46
|
+
readonly max: number,
|
|
47
|
+
) {
|
|
48
|
+
super(`owner "${owner}" already has ${max} active schedules -- cancel one before adding another`);
|
|
49
|
+
this.name = "VehicleScheduleLimitExceeded";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The first fire time for a freshly created schedule. */
|
|
54
|
+
export function initialFireAt(trigger: VehicleScheduleTrigger, now: number): number {
|
|
55
|
+
return trigger.kind === "at" ? trigger.at : now + trigger.intervalMs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
|
|
59
|
+
export function nextFireAtAfterFire(trigger: VehicleScheduleTrigger, now: number): number | undefined {
|
|
60
|
+
return trigger.kind === "every" ? now + trigger.intervalMs : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where a restored entry should be re-armed to. A one-shot "at" entry keeps
|
|
65
|
+
* its original persisted time (fires as soon as possible if overdue -- the
|
|
66
|
+
* one thing it was supposed to do must not be silently lost). A recurring
|
|
67
|
+
* "every" entry resumes its normal cadence from now if it fell behind while
|
|
68
|
+
* the daemon was down, rather than firing once per missed tick.
|
|
69
|
+
*/
|
|
70
|
+
export function nextFireAtAfterRestore(trigger: VehicleScheduleTrigger, persistedNextFireAt: number, now: number): number {
|
|
71
|
+
if (trigger.kind === "at") return persistedNextFireAt;
|
|
72
|
+
return persistedNextFireAt > now ? persistedNextFireAt : now + trigger.intervalMs;
|
|
73
|
+
}
|