@osolmaz/pi-workflows 0.7.0 → 0.8.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.
@@ -0,0 +1,304 @@
1
+ import {
2
+ estimateProgress,
3
+ type ProgressSample,
4
+ type ProgressTrackState,
5
+ } from "../workflows/index.js";
6
+ import {
7
+ JOB_PROGRESS_SCHEMA,
8
+ type JobProgressEstimate,
9
+ type JobProgressPublishResult,
10
+ type JobProgressReporterOptions,
11
+ type JobProgressSnapshot,
12
+ type JobProgressTrack,
13
+ type JobProgressUpdate,
14
+ } from "./types.js";
15
+ import { isTerminalJobProgressState, validateJobProgressSnapshot } from "./validation.js";
16
+
17
+ const DEFAULT_MINIMUM_INTERVAL_MS = 30_000;
18
+ const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000;
19
+
20
+ export type JobProgressReporter = {
21
+ report(update: JobProgressUpdate): Promise<JobProgressPublishResult>;
22
+ flush(): Promise<JobProgressPublishResult>;
23
+ snapshot(): JobProgressSnapshot;
24
+ };
25
+
26
+ export function createJobProgressReporter(
27
+ options: JobProgressReporterOptions,
28
+ ): JobProgressReporter {
29
+ const now = options.now ?? (() => new Date());
30
+ const minimumIntervalMs = nonNegativeInteger(
31
+ options.minimumIntervalMs ?? DEFAULT_MINIMUM_INTERVAL_MS,
32
+ "minimumIntervalMs",
33
+ );
34
+ const publishTimeoutMs = positiveInteger(
35
+ options.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS,
36
+ "publishTimeoutMs",
37
+ );
38
+ let current =
39
+ options.previousSnapshot === undefined
40
+ ? validateJobProgressSnapshot({
41
+ schema: JOB_PROGRESS_SCHEMA,
42
+ application: options.application,
43
+ component: options.component,
44
+ jobId: options.jobId,
45
+ sourceRevision: options.sourceRevision,
46
+ contractHash: options.contractHash,
47
+ sequence: 0,
48
+ state: options.initialState ?? "running",
49
+ phase: options.initialPhase ?? "starting",
50
+ startedAt: options.startedAt,
51
+ updatedAt: options.startedAt,
52
+ ...(options.deadlineAt === undefined ? {} : { deadlineAt: options.deadlineAt }),
53
+ tracks: options.initialTracks,
54
+ })
55
+ : validatePreviousSnapshot(options, options.previousSnapshot);
56
+ const resumed = options.previousSnapshot !== undefined;
57
+ let lastQueuedAtMs = resumed ? Date.parse(current.updatedAt) : Number.NEGATIVE_INFINITY;
58
+ let lastQueuedSequence = -1;
59
+ let lastPublishedSequence = resumed ? current.sequence : -1;
60
+ let queuedOperation: Promise<void> | undefined;
61
+ let tail: Promise<void> = Promise.resolve();
62
+
63
+ const enqueue = (snapshot: JobProgressSnapshot): Promise<void> => {
64
+ if (lastQueuedSequence === snapshot.sequence && queuedOperation !== undefined) {
65
+ return queuedOperation;
66
+ }
67
+ lastQueuedAtMs = Date.parse(snapshot.updatedAt);
68
+ lastQueuedSequence = snapshot.sequence;
69
+ const started = tail.then(() =>
70
+ startPublication(options.publish, cloneSnapshot(snapshot), publishTimeoutMs),
71
+ );
72
+ const operation = started.then(async (publication) => {
73
+ await publication.result;
74
+ lastPublishedSequence = Math.max(lastPublishedSequence, snapshot.sequence);
75
+ });
76
+ queuedOperation = operation;
77
+ tail = started.then((publication) => publication.settled).catch(() => undefined);
78
+ void operation
79
+ .finally(() => {
80
+ if (lastQueuedSequence === snapshot.sequence) {
81
+ lastQueuedSequence = -1;
82
+ queuedOperation = undefined;
83
+ }
84
+ })
85
+ .catch(() => undefined);
86
+ return operation;
87
+ };
88
+
89
+ return {
90
+ async report(update) {
91
+ if (isTerminalJobProgressState(current.state)) {
92
+ throw new Error("job progress cannot change after a terminal state");
93
+ }
94
+ if (current.sequence >= Number.MAX_SAFE_INTEGER) {
95
+ throw new Error("job progress sequence is exhausted");
96
+ }
97
+ const timestamp = now().toISOString();
98
+ const state = update.state ?? current.state;
99
+ const phase = update.phase ?? current.phase;
100
+ const finishedAt = isTerminalJobProgressState(state)
101
+ ? (update.finishedAt ?? timestamp)
102
+ : update.finishedAt;
103
+ const tracks = isTerminalJobProgressState(state)
104
+ ? terminalizeTracks(update.tracks ?? current.tracks, state)
105
+ : (update.tracks ?? current.tracks);
106
+ const next = validateJobProgressSnapshot({
107
+ ...current,
108
+ sequence: current.sequence + 1,
109
+ state,
110
+ phase,
111
+ updatedAt: timestamp,
112
+ tracks,
113
+ ...(update.cost === undefined ? {} : { cost: update.cost }),
114
+ ...(finishedAt === undefined ? {} : { finishedAt }),
115
+ });
116
+ assertMonotonicTracks(current, next);
117
+ const publishNow =
118
+ update.force === true ||
119
+ phase !== current.phase ||
120
+ isTerminalJobProgressState(state) ||
121
+ Date.parse(timestamp) - lastQueuedAtMs >= minimumIntervalMs;
122
+ current = next;
123
+ if (!publishNow) return { snapshot: cloneSnapshot(next), published: false };
124
+ await enqueue(next);
125
+ return { snapshot: cloneSnapshot(next), published: true };
126
+ },
127
+
128
+ async flush() {
129
+ const snapshot = current;
130
+ if (lastPublishedSequence >= snapshot.sequence) {
131
+ await tail;
132
+ return { snapshot: cloneSnapshot(snapshot), published: false };
133
+ }
134
+ await enqueue(snapshot);
135
+ return { snapshot: cloneSnapshot(snapshot), published: true };
136
+ },
137
+
138
+ snapshot() {
139
+ return cloneSnapshot(current);
140
+ },
141
+ };
142
+ }
143
+
144
+ export function estimateJobProgress(
145
+ snapshots: JobProgressSnapshot[],
146
+ now = new Date(),
147
+ ): JobProgressEstimate {
148
+ if (snapshots.length === 0)
149
+ throw new Error("job progress estimation requires at least one snapshot");
150
+ const validated = snapshots
151
+ .map(validateJobProgressSnapshot)
152
+ .sort((left, right) => left.sequence - right.sequence);
153
+ const latest = validated.at(-1) as JobProgressSnapshot;
154
+ for (const snapshot of validated) assertSameIdentity(latest, snapshot);
155
+ for (let index = 1; index < validated.length; index += 1) {
156
+ const previous = validated[index - 1] as JobProgressSnapshot;
157
+ const current = validated[index] as JobProgressSnapshot;
158
+ if (previous.sequence === current.sequence) {
159
+ throw new Error(`job progress sequence ${current.sequence} is duplicated`);
160
+ }
161
+ if (Date.parse(previous.updatedAt) > Date.parse(current.updatedAt)) {
162
+ throw new Error("job progress updatedAt must not decrease as sequence increases");
163
+ }
164
+ }
165
+ const keys = latest.tracks.map((track) => track.key);
166
+ const tracks: ProgressTrackState[] = keys.map((key) => {
167
+ const samples: ProgressSample[] = validated.flatMap((snapshot) => {
168
+ const track = snapshot.tracks.find((candidate) => candidate.key === key);
169
+ return track === undefined ? [] : [{ at: snapshot.updatedAt, data: track.data }];
170
+ });
171
+ return { key, samples, estimate: estimateProgress(key, samples, now) };
172
+ });
173
+ return {
174
+ snapshot: cloneSnapshot(latest),
175
+ tracks,
176
+ estimates: tracks.map((track) => track.estimate),
177
+ };
178
+ }
179
+
180
+ function terminalizeTracks(
181
+ tracks: JobProgressTrack[],
182
+ state: "completed" | "failed" | "cancelled",
183
+ ): JobProgressTrack[] {
184
+ return tracks.map((track) => ({
185
+ key: track.key,
186
+ data: {
187
+ ...track.data,
188
+ status:
189
+ track.data.status === "completed" ||
190
+ track.data.status === "failed" ||
191
+ track.data.status === "cancelled"
192
+ ? track.data.status
193
+ : state,
194
+ },
195
+ }));
196
+ }
197
+
198
+ function assertMonotonicTracks(previous: JobProgressSnapshot, next: JobProgressSnapshot): void {
199
+ if (previous.phase !== next.phase) return;
200
+ for (const track of next.tracks) {
201
+ const prior = previous.tracks.find((candidate) => candidate.key === track.key);
202
+ if (prior === undefined || resetsTrack(prior, track)) continue;
203
+ if (
204
+ prior.data.completed !== undefined &&
205
+ track.data.completed !== undefined &&
206
+ track.data.completed < prior.data.completed
207
+ ) {
208
+ throw new Error(`job progress track ${track.key} completed value must not decrease`);
209
+ }
210
+ }
211
+ }
212
+
213
+ function resetsTrack(previous: JobProgressTrack, next: JobProgressTrack): boolean {
214
+ return (
215
+ previous.data.phase !== next.data.phase ||
216
+ previous.data.unit !== next.data.unit ||
217
+ previous.data.total !== next.data.total
218
+ );
219
+ }
220
+
221
+ function assertSameIdentity(expected: JobProgressSnapshot, actual: JobProgressSnapshot): void {
222
+ for (const field of [
223
+ "application",
224
+ "component",
225
+ "jobId",
226
+ "sourceRevision",
227
+ "contractHash",
228
+ "startedAt",
229
+ ] as const) {
230
+ if (expected[field] !== actual[field]) {
231
+ throw new Error(`job progress sample ${field} does not match`);
232
+ }
233
+ }
234
+ }
235
+
236
+ function startPublication(
237
+ publish: JobProgressReporterOptions["publish"],
238
+ snapshot: JobProgressSnapshot,
239
+ timeoutMs: number,
240
+ ): { result: Promise<void>; settled: Promise<void> } {
241
+ const controller = new AbortController();
242
+ let timeout: NodeJS.Timeout | undefined;
243
+ const write = publish(snapshot, controller.signal);
244
+ const deadline = new Promise<never>((_resolve, reject) => {
245
+ timeout = setTimeout(() => {
246
+ controller.abort();
247
+ reject(new Error(`job progress publication timed out after ${timeoutMs} ms`));
248
+ }, timeoutMs);
249
+ });
250
+ const result = Promise.race([write, deadline]).finally(() => {
251
+ if (timeout !== undefined) clearTimeout(timeout);
252
+ });
253
+ return {
254
+ result,
255
+ settled: write.then(
256
+ () => undefined,
257
+ () => undefined,
258
+ ),
259
+ };
260
+ }
261
+
262
+ function validatePreviousSnapshot(
263
+ identity: JobProgressReporterOptions,
264
+ previous: JobProgressSnapshot,
265
+ ): JobProgressSnapshot {
266
+ const validated = validateJobProgressSnapshot(previous);
267
+ for (const field of [
268
+ "application",
269
+ "component",
270
+ "jobId",
271
+ "sourceRevision",
272
+ "contractHash",
273
+ "startedAt",
274
+ ] as const) {
275
+ if (identity[field] !== validated[field]) {
276
+ throw new Error(`job progress previous snapshot ${field} does not match`);
277
+ }
278
+ }
279
+ if (isTerminalJobProgressState(validated.state)) {
280
+ throw new Error("job progress cannot resume from a terminal snapshot");
281
+ }
282
+ if (identity.deadlineAt !== validated.deadlineAt) {
283
+ throw new Error("job progress previous snapshot deadlineAt does not match");
284
+ }
285
+ return validated;
286
+ }
287
+
288
+ function cloneSnapshot(snapshot: JobProgressSnapshot): JobProgressSnapshot {
289
+ return structuredClone(snapshot);
290
+ }
291
+
292
+ function nonNegativeInteger(value: number, field: string): number {
293
+ if (!Number.isSafeInteger(value) || value < 0) {
294
+ throw new Error(`${field} must be a non-negative safe integer`);
295
+ }
296
+ return value;
297
+ }
298
+
299
+ function positiveInteger(value: number, field: string): number {
300
+ if (!Number.isSafeInteger(value) || value <= 0) {
301
+ throw new Error(`${field} must be a positive safe integer`);
302
+ }
303
+ return value;
304
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ ProgressEstimate,
3
+ ProgressTrackState,
4
+ WorkflowProgressData,
5
+ } from "../workflows/index.js";
6
+
7
+ export const JOB_PROGRESS_SCHEMA = "pi-workflows.job-progress.v1" as const;
8
+
9
+ export type JobProgressState =
10
+ | "queued"
11
+ | "running"
12
+ | "waiting"
13
+ | "blocked"
14
+ | "completed"
15
+ | "failed"
16
+ | "cancelled"
17
+ | "unknown";
18
+
19
+ export type JobProgressTrack = {
20
+ key: string;
21
+ data: WorkflowProgressData;
22
+ };
23
+
24
+ export type JobProgressCost = {
25
+ settledUsd: number;
26
+ reservedUsd: number;
27
+ };
28
+
29
+ export type JobProgressSnapshot = {
30
+ schema: typeof JOB_PROGRESS_SCHEMA;
31
+ application: string;
32
+ component: string;
33
+ jobId: string;
34
+ sourceRevision: string;
35
+ contractHash: string;
36
+ sequence: number;
37
+ state: JobProgressState;
38
+ phase: string;
39
+ startedAt: string;
40
+ updatedAt: string;
41
+ deadlineAt?: string;
42
+ finishedAt?: string;
43
+ tracks: JobProgressTrack[];
44
+ cost?: JobProgressCost;
45
+ };
46
+
47
+ export type JobProgressIdentity = Pick<
48
+ JobProgressSnapshot,
49
+ "application" | "component" | "jobId" | "sourceRevision" | "contractHash" | "startedAt"
50
+ > & {
51
+ deadlineAt?: string;
52
+ };
53
+
54
+ export type JobProgressUpdate = {
55
+ state?: JobProgressState;
56
+ phase?: string;
57
+ tracks?: JobProgressTrack[];
58
+ cost?: JobProgressCost;
59
+ finishedAt?: string;
60
+ force?: boolean;
61
+ };
62
+
63
+ export type JobProgressPublish = (
64
+ snapshot: JobProgressSnapshot,
65
+ signal: AbortSignal,
66
+ ) => Promise<void>;
67
+
68
+ type JobProgressReporterBaseOptions = JobProgressIdentity & {
69
+ initialState?: Exclude<JobProgressState, "completed" | "failed" | "cancelled">;
70
+ initialPhase?: string;
71
+ publish: JobProgressPublish;
72
+ minimumIntervalMs?: number;
73
+ publishTimeoutMs?: number;
74
+ now?: () => Date;
75
+ };
76
+
77
+ export type JobProgressReporterOptions = JobProgressReporterBaseOptions &
78
+ (
79
+ | { initialTracks: JobProgressTrack[]; previousSnapshot?: never }
80
+ | { initialTracks?: JobProgressTrack[]; previousSnapshot: JobProgressSnapshot }
81
+ );
82
+
83
+ export type JobProgressPublishResult = {
84
+ snapshot: JobProgressSnapshot;
85
+ published: boolean;
86
+ };
87
+
88
+ export type JobProgressEstimate = {
89
+ snapshot: JobProgressSnapshot;
90
+ tracks: ProgressTrackState[];
91
+ estimates: ProgressEstimate[];
92
+ };
@@ -0,0 +1,255 @@
1
+ import { validateProgressData } from "../workflows/index.js";
2
+ import {
3
+ JOB_PROGRESS_SCHEMA,
4
+ type JobProgressCost,
5
+ type JobProgressSnapshot,
6
+ type JobProgressState,
7
+ type JobProgressTrack,
8
+ } from "./types.js";
9
+
10
+ export const MAX_JOB_PROGRESS_BYTES = 64 * 1024;
11
+ export const MAX_JOB_PROGRESS_TRACKS = 128;
12
+
13
+ const SNAPSHOT_FIELDS = new Set([
14
+ "schema",
15
+ "application",
16
+ "component",
17
+ "jobId",
18
+ "sourceRevision",
19
+ "contractHash",
20
+ "sequence",
21
+ "state",
22
+ "phase",
23
+ "startedAt",
24
+ "updatedAt",
25
+ "deadlineAt",
26
+ "finishedAt",
27
+ "tracks",
28
+ "cost",
29
+ ]);
30
+ const TRACK_FIELDS = new Set(["key", "data"]);
31
+ const COST_FIELDS = new Set(["settledUsd", "reservedUsd"]);
32
+ const STATES = new Set<JobProgressState>([
33
+ "queued",
34
+ "running",
35
+ "waiting",
36
+ "blocked",
37
+ "completed",
38
+ "failed",
39
+ "cancelled",
40
+ "unknown",
41
+ ]);
42
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
43
+ const RFC_3339_PATTERN =
44
+ /^(\d{4})-(\d{2})-(\d{2})T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
45
+
46
+ export function validateJobProgressSnapshot(value: unknown): JobProgressSnapshot {
47
+ const bytes = jsonBytes(value);
48
+ if (bytes > MAX_JOB_PROGRESS_BYTES) {
49
+ throw new Error(`job progress snapshot must be at most ${MAX_JOB_PROGRESS_BYTES} bytes`);
50
+ }
51
+ if (!isRecord(value)) throw new Error("job progress snapshot must be an object");
52
+ rejectUnknown(value, SNAPSHOT_FIELDS, "job progress");
53
+ if (value.schema !== JOB_PROGRESS_SCHEMA) {
54
+ throw new Error(`job progress.schema must equal ${JOB_PROGRESS_SCHEMA}`);
55
+ }
56
+
57
+ const application = requiredText(value.application, "job progress.application", 100);
58
+ const component = requiredText(value.component, "job progress.component", 100);
59
+ const jobId = requiredText(value.jobId, "job progress.jobId", 200);
60
+ const sourceRevision = requiredText(value.sourceRevision, "job progress.sourceRevision", 200);
61
+ const contractHash = requiredText(value.contractHash, "job progress.contractHash", 200);
62
+ const sequence = requiredInteger(value.sequence, "job progress.sequence");
63
+ const state = requiredState(value.state);
64
+ const phase = requiredText(value.phase, "job progress.phase", 100);
65
+ const startedAt = requiredTimestamp(value.startedAt, "job progress.startedAt");
66
+ const updatedAt = requiredTimestamp(value.updatedAt, "job progress.updatedAt");
67
+ const deadlineAt = optionalTimestamp(value.deadlineAt, "job progress.deadlineAt");
68
+ const finishedAt = optionalTimestamp(value.finishedAt, "job progress.finishedAt");
69
+ const tracks = requiredTracks(value.tracks);
70
+ const cost = optionalCost(value.cost);
71
+
72
+ if (Date.parse(updatedAt) < Date.parse(startedAt)) {
73
+ throw new Error("job progress.updatedAt must not be before startedAt");
74
+ }
75
+ if (deadlineAt !== undefined && Date.parse(deadlineAt) <= Date.parse(startedAt)) {
76
+ throw new Error("job progress.deadlineAt must be after startedAt");
77
+ }
78
+ if (finishedAt !== undefined && Date.parse(finishedAt) < Date.parse(startedAt)) {
79
+ throw new Error("job progress.finishedAt must not be before startedAt");
80
+ }
81
+ if (finishedAt !== undefined && Date.parse(finishedAt) > Date.parse(updatedAt)) {
82
+ throw new Error("job progress.finishedAt must not be after updatedAt");
83
+ }
84
+ if (isTerminal(state) && finishedAt === undefined) {
85
+ throw new Error("job progress.finishedAt is required for a terminal state");
86
+ }
87
+ if (!isTerminal(state) && finishedAt !== undefined) {
88
+ throw new Error("job progress.finishedAt is allowed only for a terminal state");
89
+ }
90
+ if (isTerminal(state) && tracks.some((track) => !isTerminalTrack(track))) {
91
+ throw new Error("job progress tracks must be terminal when the job state is terminal");
92
+ }
93
+
94
+ return {
95
+ schema: JOB_PROGRESS_SCHEMA,
96
+ application,
97
+ component,
98
+ jobId,
99
+ sourceRevision,
100
+ contractHash,
101
+ sequence,
102
+ state,
103
+ phase,
104
+ startedAt,
105
+ updatedAt,
106
+ ...(deadlineAt === undefined ? {} : { deadlineAt }),
107
+ ...(finishedAt === undefined ? {} : { finishedAt }),
108
+ tracks,
109
+ ...(cost === undefined ? {} : { cost }),
110
+ };
111
+ }
112
+
113
+ export function isTerminalJobProgressState(
114
+ state: JobProgressState,
115
+ ): state is "completed" | "failed" | "cancelled" {
116
+ return isTerminal(state);
117
+ }
118
+
119
+ function requiredTracks(value: unknown): JobProgressTrack[] {
120
+ if (!Array.isArray(value)) throw new Error("job progress.tracks must be an array");
121
+ if (value.length < 1) throw new Error("job progress.tracks must contain at least one entry");
122
+ if (value.length > MAX_JOB_PROGRESS_TRACKS) {
123
+ throw new Error(`job progress.tracks must contain at most ${MAX_JOB_PROGRESS_TRACKS} entries`);
124
+ }
125
+ const keys = new Set<string>();
126
+ return value.map((item, index) => {
127
+ if (!isRecord(item)) throw new Error(`job progress.tracks[${index}] must be an object`);
128
+ rejectUnknown(item, TRACK_FIELDS, `job progress.tracks[${index}]`);
129
+ if (typeof item.key !== "string" || !KEY_PATTERN.test(item.key)) {
130
+ throw new Error(
131
+ `job progress.tracks[${index}].key must match [A-Za-z0-9][A-Za-z0-9._:/-]{0,127}`,
132
+ );
133
+ }
134
+ if (keys.has(item.key)) throw new Error(`job progress track key ${item.key} is duplicated`);
135
+ keys.add(item.key);
136
+ if (!isRecord(item.data)) {
137
+ throw new Error(`job progress.tracks[${index}].data must be an object`);
138
+ }
139
+ return { key: item.key, data: structuredClone(validateProgressData(item.data)) };
140
+ });
141
+ }
142
+
143
+ function optionalCost(value: unknown): JobProgressCost | undefined {
144
+ if (value === undefined) return undefined;
145
+ if (!isRecord(value)) throw new Error("job progress.cost must be an object");
146
+ rejectUnknown(value, COST_FIELDS, "job progress.cost");
147
+ return {
148
+ settledUsd: requiredNonNegative(value.settledUsd, "job progress.cost.settledUsd"),
149
+ reservedUsd: requiredNonNegative(value.reservedUsd, "job progress.cost.reservedUsd"),
150
+ };
151
+ }
152
+
153
+ function requiredState(value: unknown): JobProgressState {
154
+ if (typeof value !== "string" || !STATES.has(value as JobProgressState)) {
155
+ throw new Error("job progress.state is invalid");
156
+ }
157
+ return value as JobProgressState;
158
+ }
159
+
160
+ function requiredText(value: unknown, field: string, max: number): string {
161
+ if (typeof value !== "string" || value.trim().length < 1 || value.trim().length > max) {
162
+ throw new Error(`${field} must be 1 to ${max} characters`);
163
+ }
164
+ if ([...value].some(isControlCharacter))
165
+ throw new Error(`${field} must not contain control characters`);
166
+ return value;
167
+ }
168
+
169
+ function requiredInteger(value: unknown, field: string): number {
170
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
171
+ throw new Error(`${field} must be a non-negative safe integer`);
172
+ }
173
+ return value as number;
174
+ }
175
+
176
+ function requiredNonNegative(value: unknown, field: string): number {
177
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
178
+ throw new Error(`${field} must be a finite non-negative number`);
179
+ }
180
+ return value;
181
+ }
182
+
183
+ function requiredTimestamp(value: unknown, field: string): string {
184
+ const parsed = optionalTimestamp(value, field);
185
+ if (parsed === undefined) throw new Error(`${field} is required`);
186
+ return parsed;
187
+ }
188
+
189
+ function optionalTimestamp(value: unknown, field: string): string | undefined {
190
+ if (value === undefined) return undefined;
191
+ if (typeof value !== "string") {
192
+ throw new Error(`${field} must be an RFC 3339 timestamp with an offset`);
193
+ }
194
+ const match = RFC_3339_PATTERN.exec(value);
195
+ if (match === null || !validCalendarDate(match[1], match[2], match[3])) {
196
+ throw new Error(`${field} must be an RFC 3339 timestamp with an offset`);
197
+ }
198
+ return value;
199
+ }
200
+
201
+ function validCalendarDate(
202
+ yearText: string | undefined,
203
+ monthText: string | undefined,
204
+ dayText: string | undefined,
205
+ ): boolean {
206
+ const year = Number(yearText);
207
+ const month = Number(monthText);
208
+ const day = Number(dayText);
209
+ if (!Number.isInteger(year) || year < 1 || month < 1 || month > 12 || day < 1) return false;
210
+ return day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
211
+ }
212
+
213
+ function rejectUnknown(
214
+ value: Record<string, unknown>,
215
+ allowed: ReadonlySet<string>,
216
+ field: string,
217
+ ): void {
218
+ for (const key of Object.keys(value)) {
219
+ if (!allowed.has(key)) throw new Error(`${field}.${key} is not supported`);
220
+ }
221
+ }
222
+
223
+ function isRecord(value: unknown): value is Record<string, unknown> {
224
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
225
+ const prototype = Object.getPrototypeOf(value) as unknown;
226
+ return prototype === Object.prototype || prototype === null;
227
+ }
228
+
229
+ function jsonBytes(value: unknown): number {
230
+ let encoded: string | undefined;
231
+ try {
232
+ encoded = JSON.stringify(value);
233
+ } catch {
234
+ throw new Error("job progress snapshot must be JSON serializable");
235
+ }
236
+ if (encoded === undefined) throw new Error("job progress snapshot must be JSON serializable");
237
+ return Buffer.byteLength(encoded, "utf8");
238
+ }
239
+
240
+ function isControlCharacter(character: string): boolean {
241
+ const code = character.codePointAt(0) ?? 0;
242
+ return code < 32 || (code >= 127 && code <= 159);
243
+ }
244
+
245
+ function isTerminal(state: JobProgressState): boolean {
246
+ return state === "completed" || state === "failed" || state === "cancelled";
247
+ }
248
+
249
+ function isTerminalTrack(track: JobProgressTrack): boolean {
250
+ return (
251
+ track.data.status === "completed" ||
252
+ track.data.status === "failed" ||
253
+ track.data.status === "cancelled"
254
+ );
255
+ }