@osolmaz/pi-workflows 0.6.1 → 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.
- package/README.md +26 -2
- package/dist/job-progress/index.d.ts +3 -0
- package/dist/job-progress/index.js +4 -0
- package/dist/job-progress/index.js.map +1 -0
- package/dist/job-progress/reporter.d.ts +8 -0
- package/dist/job-progress/reporter.js +243 -0
- package/dist/job-progress/reporter.js.map +1 -0
- package/dist/job-progress/types.d.ts +65 -0
- package/dist/job-progress/types.js +2 -0
- package/dist/job-progress/types.js.map +1 -0
- package/dist/job-progress/validation.d.ts +5 -0
- package/dist/job-progress/validation.js +227 -0
- package/dist/job-progress/validation.js.map +1 -0
- package/docs/JOB_PROGRESS.md +119 -0
- package/docs/MONITOR.md +5 -0
- package/docs/plans/2026-08-17-bundled-skills-plan.md +104 -0
- package/docs/plans/2026-08-17-durable-job-progress-plan.md +176 -0
- package/docs/workflows.md +10 -0
- package/package.json +9 -1
- package/skills/monitor/SKILL.md +170 -0
- package/skills/pi-workflows/SKILL.md +71 -0
- package/src/job-progress/index.ts +24 -0
- package/src/job-progress/reporter.ts +304 -0
- package/src/job-progress/types.ts +92 -0
- package/src/job-progress/validation.ts +255 -0
|
@@ -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
|
+
}
|