@akshatmittal/invoker 0.2.0 → 0.3.1
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 -13
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +44 -44
- package/dist/slack.mjs +123 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,10 +104,10 @@ import { defineTask } from "@akshatmittal/invoker";
|
|
|
104
104
|
|
|
105
105
|
export const evaluateModels = defineTask({
|
|
106
106
|
name: "evaluate-models",
|
|
107
|
-
matrix: {
|
|
107
|
+
matrix: async () => ({
|
|
108
108
|
model: ["gpt-5", "gpt-5-mini"],
|
|
109
109
|
dataset: ["support", "sales"],
|
|
110
|
-
},
|
|
110
|
+
}),
|
|
111
111
|
setup: async ({ cases }) => loadFixtures(cases),
|
|
112
112
|
run: async ({ matrix, setup, vitest }) => {
|
|
113
113
|
vitest.expect(setup.has(matrix.dataset)).toBe(true);
|
|
@@ -139,7 +139,8 @@ defineWorkflow({
|
|
|
139
139
|
});
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
Matrix
|
|
142
|
+
The Matrix function runs during collection and its returned literal determines the exact
|
|
143
|
+
`matrix` type. `setup` determines the exact
|
|
143
144
|
shared setup type, and the exact JSON return type is retained on the Task.
|
|
144
145
|
Axis names must be non-empty, enumerable strings that are not array indexes.
|
|
145
146
|
Omitting `matrix` creates one Case with `{}`. Setup runs once per Task, Cases
|
|
@@ -195,16 +196,14 @@ retries.
|
|
|
195
196
|
## Notify Slack
|
|
196
197
|
|
|
197
198
|
Invoker's optional Slack reporter posts one `Invoker Report` parent message per
|
|
198
|
-
Vitest run
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
failures are
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
an explicit Slack rate-limit rejection is reattempted only after its required
|
|
207
|
-
delay.
|
|
199
|
+
Vitest run containing every Workflow card. Each card includes aggregate results,
|
|
200
|
+
Workflow metadata, and a table of Task counts and durations. A shared footer
|
|
201
|
+
contains the elapsed span from the first Case start to the final Case completion,
|
|
202
|
+
a localized timestamp, and the optional run link. Final failures, successful
|
|
203
|
+
retry details, skipped Case reasons, and unhandled run errors are posted in the
|
|
204
|
+
same thread. Delivery failures are isolated to the affected reply. Ambiguous
|
|
205
|
+
transport failures are not retried; an explicit Slack rate-limit rejection is
|
|
206
|
+
reattempted only after its required delay.
|
|
208
207
|
|
|
209
208
|
```ts
|
|
210
209
|
import { slackReporter } from "@akshatmittal/invoker/slack";
|
package/dist/index.d.mts
CHANGED
|
@@ -34,7 +34,7 @@ type TeardownContext<M extends Matrix, Setup> = SetupContext<M> & {
|
|
|
34
34
|
declare const taskDefinitionBrand: unique symbol;
|
|
35
35
|
interface TaskDefinition<Name extends string = string, M extends Matrix = Matrix, Setup = unknown, Output extends JsonValue = JsonValue> {
|
|
36
36
|
readonly name: Name;
|
|
37
|
-
readonly matrix: M
|
|
37
|
+
readonly matrix: () => Promise<M>;
|
|
38
38
|
readonly [taskDefinitionBrand]: true;
|
|
39
39
|
readonly setup?: (context: SetupContext<M>) => Awaitable<Setup>;
|
|
40
40
|
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
|
|
@@ -42,14 +42,14 @@ interface TaskDefinition<Name extends string = string, M extends Matrix = Matrix
|
|
|
42
42
|
}
|
|
43
43
|
type TaskWithSetup<Name extends string, M extends Matrix, Setup, Output extends JsonValue> = {
|
|
44
44
|
readonly name: Name;
|
|
45
|
-
readonly matrix?: M
|
|
45
|
+
readonly matrix?: () => Promise<M>;
|
|
46
46
|
readonly setup: (context: SetupContext<M>) => Awaitable<Setup>;
|
|
47
47
|
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
|
|
48
48
|
readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
|
|
49
49
|
};
|
|
50
50
|
type TaskWithoutSetup<Name extends string, M extends Matrix, Output extends JsonValue> = {
|
|
51
51
|
readonly name: Name;
|
|
52
|
-
readonly matrix?: M
|
|
52
|
+
readonly matrix?: () => Promise<M>;
|
|
53
53
|
readonly setup?: never;
|
|
54
54
|
readonly run: (context: TaskContext<CaseCoordinates<M>, undefined>) => Awaitable<Output>;
|
|
55
55
|
readonly teardown?: never;
|
|
@@ -58,7 +58,7 @@ declare function defineTask<const Name extends string, const M extends Matrix =
|
|
|
58
58
|
declare function defineTask<const Name extends string, const M extends Matrix = Record<never, never>, const Output extends JsonValue = JsonValue>(definition: TaskWithoutSetup<Name, M, Output>): TaskDefinition<Name, M, undefined, Output>;
|
|
59
59
|
type AnyTaskDefinition = {
|
|
60
60
|
readonly name: string;
|
|
61
|
-
readonly matrix: Matrix
|
|
61
|
+
readonly matrix: () => Promise<Matrix>;
|
|
62
62
|
readonly [taskDefinitionBrand]: true;
|
|
63
63
|
};
|
|
64
64
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ const taskDefinitionBrand = Symbol("invoker.task");
|
|
|
5
5
|
function defineTask(definition) {
|
|
6
6
|
return {
|
|
7
7
|
...definition,
|
|
8
|
-
matrix: definition.matrix ?? {},
|
|
8
|
+
matrix: definition.matrix ?? (async () => ({})),
|
|
9
9
|
[taskDefinitionBrand]: true
|
|
10
10
|
};
|
|
11
11
|
}
|
|
@@ -71,7 +71,6 @@ function isJsonObject(value) {
|
|
|
71
71
|
//#region src/matrix.ts
|
|
72
72
|
const axisSchema = z.string();
|
|
73
73
|
function expandMatrix(matrix, owner) {
|
|
74
|
-
if (matrix === void 0) return [{}];
|
|
75
74
|
assertPlainObject(matrix, owner, ".matrix");
|
|
76
75
|
let cases = [{}];
|
|
77
76
|
for (const candidate of Reflect.ownKeys(matrix)) {
|
|
@@ -105,9 +104,9 @@ function caseName(matrix, index) {
|
|
|
105
104
|
//#endregion
|
|
106
105
|
//#region src/workflow.ts
|
|
107
106
|
function defineWorkflow(definition) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
for (const prepared of
|
|
107
|
+
describe(definition.name, { concurrent: false }, async () => {
|
|
108
|
+
const tasks = await prepareWorkflow(definition);
|
|
109
|
+
for (const prepared of tasks) describe(prepared.task.name, { concurrent: false }, () => {
|
|
111
110
|
let setup;
|
|
112
111
|
const setupTask = prepared.task.setup;
|
|
113
112
|
if (setupTask) beforeAll(async () => {
|
|
@@ -135,7 +134,7 @@ function defineWorkflow(definition) {
|
|
|
135
134
|
});
|
|
136
135
|
});
|
|
137
136
|
}
|
|
138
|
-
function prepareWorkflow(definition) {
|
|
137
|
+
async function prepareWorkflow(definition) {
|
|
139
138
|
assertPlainObject(definition, "Workflow", "");
|
|
140
139
|
assertOnlyKeys(definition, [
|
|
141
140
|
"name",
|
|
@@ -148,44 +147,45 @@ function prepareWorkflow(definition) {
|
|
|
148
147
|
if (metadata !== void 0) assertPlainObject(metadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
|
|
149
148
|
if (!Array.isArray(taskDefinitions) || taskDefinitions.length === 0) fail("Workflow", ".tasks", "expected a non-empty Task tuple");
|
|
150
149
|
const names = /* @__PURE__ */ new Set();
|
|
151
|
-
|
|
152
|
-
name
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
150
|
+
const tasks = taskDefinitions.map((value, index) => {
|
|
151
|
+
const owner = `Workflow ${JSON.stringify(name)} Task ${index + 1}`;
|
|
152
|
+
assertPlainObject(value, owner, "");
|
|
153
|
+
if (value[taskDefinitionBrand] !== true) fail(owner, "", "expected a Task created by defineTask");
|
|
154
|
+
const task = value;
|
|
155
|
+
assertOnlyKeys(task, [
|
|
156
|
+
"name",
|
|
157
|
+
"matrix",
|
|
158
|
+
"setup",
|
|
159
|
+
"run",
|
|
160
|
+
"teardown"
|
|
161
|
+
], owner);
|
|
162
|
+
const taskSnapshot = { ...task };
|
|
163
|
+
assertName(taskSnapshot.name, owner, ".name");
|
|
164
|
+
if (names.has(taskSnapshot.name)) fail(owner, ".name", `duplicate Task name ${JSON.stringify(taskSnapshot.name)}`);
|
|
165
|
+
names.add(taskSnapshot.name);
|
|
166
|
+
if (!z.function().safeParse(taskSnapshot.run).success) fail(owner, ".run", "expected a function");
|
|
167
|
+
if (!z.function().safeParse(taskSnapshot.matrix).success) fail(owner, ".matrix", "expected a function");
|
|
168
|
+
if (taskSnapshot.setup !== void 0 && !z.function().safeParse(taskSnapshot.setup).success) fail(owner, ".setup", "expected a function");
|
|
169
|
+
if (taskSnapshot.teardown !== void 0 && !z.function().safeParse(taskSnapshot.teardown).success) fail(owner, ".teardown", "expected a function");
|
|
170
|
+
if (taskSnapshot.teardown && !taskSnapshot.setup) fail(owner, ".teardown", "requires setup");
|
|
171
|
+
return taskSnapshot;
|
|
172
|
+
});
|
|
173
|
+
return await Promise.all(tasks.map(async (task) => {
|
|
174
|
+
const cases = expandMatrix(await task.matrix(), `Task ${JSON.stringify(task.name)}`);
|
|
175
|
+
return {
|
|
176
|
+
task,
|
|
177
|
+
cases,
|
|
178
|
+
names: cases.map(caseName),
|
|
179
|
+
metadata: cases.map((matrix) => metadata === void 0 ? {
|
|
180
|
+
schema: 1,
|
|
181
|
+
matrix
|
|
182
|
+
} : {
|
|
183
|
+
schema: 1,
|
|
184
|
+
matrix,
|
|
185
|
+
metadata
|
|
186
|
+
})
|
|
187
|
+
};
|
|
188
|
+
}));
|
|
189
189
|
}
|
|
190
190
|
//#endregion
|
|
191
191
|
export { defineTask, defineWorkflow };
|
package/dist/slack.mjs
CHANGED
|
@@ -48,7 +48,9 @@ function collectWorkflowReports(modules) {
|
|
|
48
48
|
failed: 0,
|
|
49
49
|
skipped: 0,
|
|
50
50
|
incomplete: 0,
|
|
51
|
-
failures: []
|
|
51
|
+
failures: [],
|
|
52
|
+
retries: [],
|
|
53
|
+
skips: []
|
|
52
54
|
};
|
|
53
55
|
workflow.tasks.set(taskSuite, task);
|
|
54
56
|
}
|
|
@@ -56,7 +58,17 @@ function collectWorkflowReports(modules) {
|
|
|
56
58
|
const result = testCase.result();
|
|
57
59
|
const diagnostic = testCase.diagnostic();
|
|
58
60
|
task[result.state === "pending" ? "incomplete" : result.state] += 1;
|
|
59
|
-
|
|
61
|
+
const retryCount = diagnostic?.retryCount ?? 0;
|
|
62
|
+
if (retryCount > 0) {
|
|
63
|
+
task.retried += 1;
|
|
64
|
+
if (result.state === "passed") task.retries.push({
|
|
65
|
+
task: task.name,
|
|
66
|
+
caseName: testCase.name,
|
|
67
|
+
matrix: invoker.matrix,
|
|
68
|
+
count: retryCount,
|
|
69
|
+
messages: (result.errors ?? []).map(errorMessage)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
60
72
|
if (diagnostic) {
|
|
61
73
|
task.startedAt = Math.min(task.startedAt ?? diagnostic.startTime, diagnostic.startTime);
|
|
62
74
|
task.endedAt = Math.max(task.endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
|
|
@@ -67,31 +79,43 @@ function collectWorkflowReports(modules) {
|
|
|
67
79
|
matrix: invoker.matrix,
|
|
68
80
|
messages: result.errors.map(errorMessage)
|
|
69
81
|
});
|
|
82
|
+
else if (result.state === "skipped") task.skips.push({
|
|
83
|
+
task: task.name,
|
|
84
|
+
caseName: testCase.name,
|
|
85
|
+
matrix: invoker.matrix,
|
|
86
|
+
reason: result.note || "No reason provided"
|
|
87
|
+
});
|
|
70
88
|
}
|
|
71
89
|
return [...workflows.values()].map((workflow) => {
|
|
72
90
|
const failures = [];
|
|
91
|
+
const retries = [];
|
|
92
|
+
const skips = [];
|
|
73
93
|
addErrors(failures, workflow.suite.errors());
|
|
74
94
|
addErrors(failures, workflow.module.errors());
|
|
75
95
|
const collectedTasks = [...workflow.tasks.values()];
|
|
76
96
|
for (const task of collectedTasks) {
|
|
77
97
|
failures.push(...task.failures);
|
|
98
|
+
retries.push(...task.retries);
|
|
99
|
+
skips.push(...task.skips);
|
|
78
100
|
addErrors(failures, task.suite.errors(), task.name);
|
|
79
101
|
}
|
|
80
102
|
const { startedAt, endedAt } = timeSpan(collectedTasks);
|
|
81
103
|
return {
|
|
82
104
|
name: workflow.name,
|
|
83
105
|
metadata: workflow.metadata,
|
|
84
|
-
tasks: collectedTasks.map(({ suite: _suite, failures: _failures, startedAt, endedAt, ...task }) => ({
|
|
106
|
+
tasks: collectedTasks.map(({ suite: _suite, failures: _failures, retries: _retries, skips: _skips, startedAt, endedAt, ...task }) => ({
|
|
85
107
|
...task,
|
|
86
108
|
duration: startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt
|
|
87
109
|
})),
|
|
88
110
|
failures: deduplicateFailures(failures),
|
|
111
|
+
retries,
|
|
112
|
+
skips,
|
|
89
113
|
startedAt,
|
|
90
114
|
endedAt
|
|
91
115
|
};
|
|
92
116
|
});
|
|
93
117
|
}
|
|
94
|
-
function
|
|
118
|
+
function summaryMessage(reports, runUrl) {
|
|
95
119
|
const timestamp = Math.floor(Date.now() / 1e3);
|
|
96
120
|
const { startedAt, endedAt } = timeSpan(reports);
|
|
97
121
|
const footer = [
|
|
@@ -99,16 +123,16 @@ function summaryMessages(reports, runUrl) {
|
|
|
99
123
|
`<!date^${timestamp}^{date_short_pretty} at {time}|${(/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString()}>`,
|
|
100
124
|
...runUrl ? [`<${escapeSlackControl(runUrl)}|View run>`] : []
|
|
101
125
|
].join(" • ");
|
|
102
|
-
return
|
|
103
|
-
text:
|
|
104
|
-
attachments: [
|
|
126
|
+
return {
|
|
127
|
+
text: "Invoker Report",
|
|
128
|
+
attachments: [...reports.flatMap(workflowAttachments), { blocks: [{
|
|
105
129
|
type: "context",
|
|
106
130
|
elements: [{
|
|
107
131
|
type: "mrkdwn",
|
|
108
132
|
text: footer
|
|
109
133
|
}]
|
|
110
|
-
}] }]
|
|
111
|
-
}
|
|
134
|
+
}] }]
|
|
135
|
+
};
|
|
112
136
|
}
|
|
113
137
|
function workflowAttachments(report) {
|
|
114
138
|
const tables = taskTables(report.tasks);
|
|
@@ -194,6 +218,93 @@ function failureMessages(report) {
|
|
|
194
218
|
});
|
|
195
219
|
});
|
|
196
220
|
}
|
|
221
|
+
function retryMessages(report) {
|
|
222
|
+
return [...Map.groupBy(report.retries, (retry) => retry.task)].flatMap(([task, retries]) => {
|
|
223
|
+
const title = escapeSlack(truncate(task, NAME_CHARACTER_LIMIT));
|
|
224
|
+
const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
|
|
225
|
+
const metadata = metadataText(report.metadata);
|
|
226
|
+
const context = metadata ? `${workflow} • ${metadata}` : workflow;
|
|
227
|
+
return chunk(retries.map((retry) => {
|
|
228
|
+
const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(retry.matrix))}`;
|
|
229
|
+
const messages = retry.messages.map((message) => `\n• ${escapeSlack(message)}`).join("");
|
|
230
|
+
return `*${escapeSlack(retry.caseName)}*\nRetries: ${retry.count}${matrix}${messages}`;
|
|
231
|
+
}), SECTION_CHARACTER_LIMIT).map((details, index, messages) => {
|
|
232
|
+
const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
|
|
233
|
+
return {
|
|
234
|
+
text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task, NAME_CHARACTER_LIMIT)} — ${retries.length} retried${part}`,
|
|
235
|
+
attachments: [{
|
|
236
|
+
color: "warning",
|
|
237
|
+
blocks: [
|
|
238
|
+
{
|
|
239
|
+
type: "section",
|
|
240
|
+
text: {
|
|
241
|
+
type: "mrkdwn",
|
|
242
|
+
text: `🟡 *${title} — ${retries.length} retried${part}*`
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
type: "context",
|
|
247
|
+
elements: [{
|
|
248
|
+
type: "mrkdwn",
|
|
249
|
+
text: context
|
|
250
|
+
}]
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
type: "section",
|
|
254
|
+
text: {
|
|
255
|
+
type: "mrkdwn",
|
|
256
|
+
text: details
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
]
|
|
260
|
+
}]
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
function skipMessages(report) {
|
|
266
|
+
return [...Map.groupBy(report.skips, (skip) => skip.task)].flatMap(([task, skips]) => {
|
|
267
|
+
const title = escapeSlack(truncate(task, NAME_CHARACTER_LIMIT));
|
|
268
|
+
const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
|
|
269
|
+
const metadata = metadataText(report.metadata);
|
|
270
|
+
const context = metadata ? `${workflow} • ${metadata}` : workflow;
|
|
271
|
+
return chunk(skips.map((skip) => {
|
|
272
|
+
const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(skip.matrix))}`;
|
|
273
|
+
return `*${escapeSlack(skip.caseName)}*${matrix}\nReason: ${escapeSlack(skip.reason)}`;
|
|
274
|
+
}), SECTION_CHARACTER_LIMIT).map((details, index, messages) => {
|
|
275
|
+
const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
|
|
276
|
+
return {
|
|
277
|
+
text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task, NAME_CHARACTER_LIMIT)} — ${skips.length} skipped${part}`,
|
|
278
|
+
attachments: [{
|
|
279
|
+
color: "warning",
|
|
280
|
+
blocks: [
|
|
281
|
+
{
|
|
282
|
+
type: "section",
|
|
283
|
+
text: {
|
|
284
|
+
type: "mrkdwn",
|
|
285
|
+
text: `🟡 *${title} — ${skips.length} skipped${part}*`
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
type: "context",
|
|
290
|
+
elements: [{
|
|
291
|
+
type: "mrkdwn",
|
|
292
|
+
text: context
|
|
293
|
+
}]
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
type: "section",
|
|
297
|
+
text: {
|
|
298
|
+
type: "mrkdwn",
|
|
299
|
+
text: details
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
]
|
|
303
|
+
}]
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
197
308
|
function unhandledErrorMessages(errors) {
|
|
198
309
|
const messages = [...new Set(errors.map(errorMessage))];
|
|
199
310
|
return chunk(messages.map((message) => `• ${escapeSlack(message)}`), SECTION_CHARACTER_LIMIT).map((details, index, chunks) => {
|
|
@@ -382,8 +493,7 @@ function slackReporter(options) {
|
|
|
382
493
|
return { async onTestRunEnd(modules, unhandledErrors) {
|
|
383
494
|
const reports = collectWorkflowReports(modules);
|
|
384
495
|
if (reports.length === 0) return;
|
|
385
|
-
const
|
|
386
|
-
if (!parentMessage) return;
|
|
496
|
+
const parentMessage = summaryMessage(reports, options.runUrl);
|
|
387
497
|
let parentTimestamp;
|
|
388
498
|
try {
|
|
389
499
|
const parentArguments = {
|
|
@@ -398,8 +508,9 @@ function slackReporter(options) {
|
|
|
398
508
|
return;
|
|
399
509
|
}
|
|
400
510
|
const replies = [
|
|
401
|
-
...continuations,
|
|
402
511
|
...reports.flatMap(failureMessages),
|
|
512
|
+
...reports.flatMap(retryMessages),
|
|
513
|
+
...reports.flatMap(skipMessages),
|
|
403
514
|
...unhandledErrorMessages(unhandledErrors)
|
|
404
515
|
];
|
|
405
516
|
if (replies.length > 0 && !parentTimestamp) {
|