@akshatmittal/invoker 0.3.1 → 0.4.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 +79 -30
- package/dist/errors-CMczwLz9.mjs +16 -0
- package/dist/index.d.mts +43 -36
- package/dist/index.mjs +156 -84
- package/dist/slack.mjs +185 -243
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,23 +98,29 @@ scheduler intentionally has no HTTP health endpoint.
|
|
|
98
98
|
|
|
99
99
|
## Define a Workflow
|
|
100
100
|
|
|
101
|
+
Declare a Task's required parameters once, then bind them from each Workflow.
|
|
102
|
+
TypeScript checks the bindings while inferring the Task's Matrix, setup, and Output:
|
|
103
|
+
|
|
101
104
|
```ts
|
|
102
105
|
// regressions/model/tasks/evaluate-models.ts
|
|
103
106
|
import { defineTask } from "@akshatmittal/invoker";
|
|
104
107
|
|
|
105
|
-
|
|
108
|
+
type EvaluateParams = {
|
|
109
|
+
environment: "staging" | "production";
|
|
110
|
+
baseline: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const evaluateModels = defineTask<EvaluateParams>()({
|
|
106
114
|
name: "evaluate-models",
|
|
107
|
-
matrix: async () => ({
|
|
108
|
-
model:
|
|
115
|
+
matrix: async ({ params }) => ({
|
|
116
|
+
model: await discoverModels(params.environment),
|
|
109
117
|
dataset: ["support", "sales"],
|
|
110
118
|
}),
|
|
111
|
-
setup: async ({ cases }) => loadFixtures(cases),
|
|
112
|
-
run: async ({ matrix, setup, vitest }) => {
|
|
119
|
+
setup: async ({ params, cases }) => loadFixtures(params.environment, cases),
|
|
120
|
+
run: async ({ params, matrix, setup, vitest }) => {
|
|
113
121
|
vitest.expect(setup.has(matrix.dataset)).toBe(true);
|
|
114
|
-
|
|
115
122
|
return {
|
|
116
|
-
|
|
117
|
-
dataset: matrix.dataset,
|
|
123
|
+
baseline: params.baseline,
|
|
118
124
|
score: await evaluate(matrix, setup),
|
|
119
125
|
};
|
|
120
126
|
},
|
|
@@ -131,21 +137,51 @@ import { evaluateModels } from "../tasks/evaluate-models.js";
|
|
|
131
137
|
|
|
132
138
|
defineWorkflow({
|
|
133
139
|
name: "model-regressions",
|
|
134
|
-
metadata: {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
metadata: { commit: process.env.GITHUB_SHA ?? "local" },
|
|
141
|
+
matrix: async () => ({ environment: ["staging", "production"] }),
|
|
142
|
+
tasks: ({ matrix }) => [
|
|
143
|
+
evaluateModels({
|
|
144
|
+
environment: matrix.environment,
|
|
145
|
+
baseline: "2026-09-01",
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
139
148
|
});
|
|
140
149
|
```
|
|
141
150
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
Calling `evaluateModels(...)` binds inputs; it does not execute the Task. All
|
|
152
|
+
parameter properties are required and JSON-compatible. Missing fields, optional
|
|
153
|
+
parameter declarations, and incompatible bindings fail typechecking. There is no
|
|
154
|
+
SDK defaults mechanism. Use setup for clients and other non-JSON resources.
|
|
155
|
+
|
|
156
|
+
For a parameterless Task, use `defineTask()({ name, run })` and bind it with no
|
|
157
|
+
argument. Workflow `tasks` is always a synchronous callback returning a nonempty
|
|
158
|
+
Task list. It can select different Tasks per Workflow coordinate; names must be
|
|
159
|
+
unique within each coordinate. Reuse the same Task in other Workflows with new
|
|
160
|
+
bindings.
|
|
161
|
+
|
|
162
|
+
Both Matrix functions run during collection. The Workflow Matrix resolves once;
|
|
163
|
+
Task binding runs once per Workflow coordinate, and each bound Task discovers its
|
|
164
|
+
own Matrix once. Task discovery can run concurrently across coordinates. All
|
|
165
|
+
bindings and matrices are validated before executable suites are registered; a
|
|
166
|
+
discovery failure prevents that Workflow's execution.
|
|
167
|
+
|
|
168
|
+
Workflow axes multiply Task axes. For two environments and three Task models,
|
|
169
|
+
there are six Cases. Execution follows the complete Task sequence for staging,
|
|
170
|
+
then the complete sequence for production. Each bound Task gets its own setup and
|
|
171
|
+
teardown, and its Cases run concurrently under Vitest's limits. Execution failures
|
|
172
|
+
normally allow later Tasks to continue; Vitest's bail configuration controls
|
|
173
|
+
stopping early.
|
|
174
|
+
|
|
175
|
+
Parameters are validated and snapshotted during collection and passed readonly to
|
|
176
|
+
Matrix discovery, setup, run, and teardown. Task `matrix` and setup's `cases`
|
|
177
|
+
contain only Task coordinates; Workflow coordinates enter through explicit
|
|
178
|
+
parameter binding. Retries share that binding's parameter snapshot and setup
|
|
179
|
+
result. Teardown runs once after successful setup.
|
|
180
|
+
|
|
181
|
+
Omitting either Matrix creates one coordinate, `{}`. Axis names must be non-empty,
|
|
182
|
+
enumerable strings that are not array indexes. Empty axes and duplicate values
|
|
183
|
+
are errors. Vitest shows Workflow → Workflow coordinate → Task → Case, with `[1]`
|
|
184
|
+
for empty coordinates and numbered axis values otherwise.
|
|
149
185
|
|
|
150
186
|
## Configure Vitest
|
|
151
187
|
|
|
@@ -178,32 +214,45 @@ Define additional Workflows in separate `*.test.ts` files. Vitest discovers
|
|
|
178
214
|
them automatically; Invoker does not scan directories or require a central
|
|
179
215
|
index.
|
|
180
216
|
|
|
181
|
-
|
|
217
|
+
Schema 2 keeps both coordinate scopes and all bound parameters in the JSON report:
|
|
182
218
|
|
|
183
219
|
```json
|
|
184
220
|
{
|
|
185
|
-
"schema":
|
|
186
|
-
"matrix": {
|
|
221
|
+
"schema": 2,
|
|
222
|
+
"matrix": {
|
|
223
|
+
"workflow": { "environment": "staging" },
|
|
224
|
+
"task": { "model": "model-a", "dataset": "support" }
|
|
225
|
+
},
|
|
226
|
+
"params": { "environment": "staging", "baseline": "2026-09-01" },
|
|
187
227
|
"metadata": { "commit": "abc123" },
|
|
188
|
-
"output": { "
|
|
228
|
+
"output": { "baseline": "2026-09-01", "score": 0.92 }
|
|
189
229
|
}
|
|
190
230
|
```
|
|
191
231
|
|
|
192
|
-
`
|
|
193
|
-
|
|
232
|
+
`matrix.workflow`, `matrix.task`, and `params` are always present, with `{}` for
|
|
233
|
+
empty scopes. Parameters are persisted in full, including fixed configuration;
|
|
234
|
+
there is no automatic redaction. Static coordinates and parameters survive setup
|
|
235
|
+
failures, skips, and retries. `output` is present only after a successful,
|
|
236
|
+
JSON-valid Task return and is cleared before every retry. Vitest's report remains authoritative for status, failures, timing, hierarchy, and
|
|
194
237
|
retries.
|
|
195
238
|
|
|
196
239
|
## Notify Slack
|
|
197
240
|
|
|
198
241
|
Invoker's optional Slack reporter posts one `Invoker Report` parent message per
|
|
199
242
|
Vitest run containing every Workflow card. Each card includes aggregate results,
|
|
200
|
-
Workflow metadata, and a table
|
|
243
|
+
Workflow metadata, and a table with counts and durations per Workflow
|
|
244
|
+
coordinate/Task pair. Failures, retries, and skips identify both Matrix scopes.
|
|
245
|
+
Collection failures produce a failed Workflow card even without collected Cases.
|
|
246
|
+
Full parameters are persisted in JSON rather than printed automatically in Slack.
|
|
247
|
+
A shared footer
|
|
201
248
|
contains the elapsed span from the first Case start to the final Case completion,
|
|
202
249
|
a localized timestamp, and the optional run link. Final failures, successful
|
|
203
250
|
retry details, skipped Case reasons, and unhandled run errors are posted in the
|
|
204
|
-
same thread.
|
|
205
|
-
|
|
206
|
-
|
|
251
|
+
same thread. The thread is limited to ten replies per run across all detail
|
|
252
|
+
types. When details are omitted, the final reply directs readers to the run
|
|
253
|
+
logs. Delivery failures are isolated to the affected reply. Ambiguous transport
|
|
254
|
+
failures are not retried; an explicit Slack rate-limit rejection is reattempted
|
|
255
|
+
only after its required delay.
|
|
207
256
|
|
|
208
257
|
```ts
|
|
209
258
|
import { slackReporter } from "@akshatmittal/invoker/slack";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/errors.ts
|
|
3
|
+
const errorMessageSchema = z.object({ message: z.string() });
|
|
4
|
+
const errorStackSchema = z.object({ stack: z.string() });
|
|
5
|
+
function errorMessage(cause) {
|
|
6
|
+
const message = errorMessageSchema.safeParse(cause);
|
|
7
|
+
if (message.success) return message.data.message;
|
|
8
|
+
const stack = errorStackSchema.safeParse(cause);
|
|
9
|
+
if (stack.success) return stack.data.stack.split("\n", 1)[0];
|
|
10
|
+
return String(cause);
|
|
11
|
+
}
|
|
12
|
+
function errorWithContext(context, cause) {
|
|
13
|
+
return new Error(`${context}: ${errorMessage(cause)}`, { cause });
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { errorWithContext as n, errorMessage as t };
|
package/dist/index.d.mts
CHANGED
|
@@ -11,63 +11,70 @@ type Matrix = {
|
|
|
11
11
|
readonly [axis: string]: readonly JsonValue[];
|
|
12
12
|
};
|
|
13
13
|
type CaseCoordinates<M extends Matrix> = { readonly [K in keyof M]: M[K][number]; };
|
|
14
|
-
interface InvokerMeta<Coordinates extends JsonObject, Output extends JsonValue, Metadata extends JsonObject = JsonObject> {
|
|
15
|
-
schema:
|
|
16
|
-
matrix:
|
|
14
|
+
interface InvokerMeta<Coordinates extends JsonObject, Output extends JsonValue, Metadata extends JsonObject = JsonObject, WorkflowCoordinates extends JsonObject = JsonObject, Params extends JsonObject = JsonObject> {
|
|
15
|
+
schema: 2;
|
|
16
|
+
matrix: {
|
|
17
|
+
workflow: WorkflowCoordinates;
|
|
18
|
+
task: Coordinates;
|
|
19
|
+
};
|
|
20
|
+
params: Params;
|
|
17
21
|
metadata?: Metadata;
|
|
18
22
|
output?: Output;
|
|
19
23
|
}
|
|
20
|
-
interface TaskContext<Coordinates, Setup> {
|
|
24
|
+
interface TaskContext<Coordinates, Setup, Params = Record<never, never>> extends ParamsContext<Params> {
|
|
21
25
|
readonly matrix: Coordinates;
|
|
22
26
|
readonly setup: Setup;
|
|
23
27
|
readonly vitest: TestContext;
|
|
24
28
|
}
|
|
25
29
|
type Awaitable<Value> = Value | PromiseLike<Value>;
|
|
26
|
-
type
|
|
30
|
+
type DeepReadonly<Value> = JsonValue extends Value ? Value : { readonly [Key in keyof Value]: DeepReadonly<Value[Key]>; };
|
|
31
|
+
type ParamsContext<Params> = {
|
|
32
|
+
readonly params: DeepReadonly<Params>;
|
|
33
|
+
};
|
|
34
|
+
type SetupContext<M extends Matrix, Params = Record<never, never>> = ParamsContext<Params> & {
|
|
27
35
|
readonly cases: readonly CaseCoordinates<M>[];
|
|
28
36
|
};
|
|
29
|
-
type TeardownContext<M extends Matrix, Setup
|
|
37
|
+
type TeardownContext<M extends Matrix, Setup, Params = Record<never, never>> = SetupContext<M, Params> & {
|
|
30
38
|
readonly setup: Setup;
|
|
31
39
|
};
|
|
32
40
|
//#endregion
|
|
33
41
|
//#region src/task.d.ts
|
|
34
|
-
declare const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
readonly setup?: (context: SetupContext<M>) => Awaitable<Setup>;
|
|
40
|
-
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
|
|
41
|
-
readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
|
|
42
|
-
}
|
|
43
|
-
type TaskWithSetup<Name extends string, M extends Matrix, Setup, Output extends JsonValue> = {
|
|
44
|
-
readonly name: Name;
|
|
45
|
-
readonly matrix?: () => Promise<M>;
|
|
46
|
-
readonly setup: (context: SetupContext<M>) => Awaitable<Setup>;
|
|
47
|
-
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
|
|
48
|
-
readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
|
|
49
|
-
};
|
|
50
|
-
type TaskWithoutSetup<Name extends string, M extends Matrix, Output extends JsonValue> = {
|
|
42
|
+
declare const taskBindingBrand: unique symbol;
|
|
43
|
+
type ParameterArguments<Params> = [Params] extends [Record<string, never>] ? [] : [params: Params];
|
|
44
|
+
type RequiredParameters<Params> = { [Key in keyof Params]-?: Params[Key] extends JsonValue ? RequiredParameters<Params[Key]> : never; };
|
|
45
|
+
type TaskDefinition<Name extends string = string, M extends Matrix = Matrix, Setup = unknown, Output extends JsonValue = JsonValue, Params extends JsonObject = Record<never, never>> = (...args: ParameterArguments<Params>) => BoundTask<Name, M, Setup, Output, Params>;
|
|
46
|
+
type TaskOptions<Name extends string, M extends Matrix, Setup, Output extends JsonValue, Params> = {
|
|
51
47
|
readonly name: Name;
|
|
52
|
-
readonly matrix?: () => Promise<M>;
|
|
53
|
-
readonly setup?:
|
|
54
|
-
readonly run: (context: TaskContext<CaseCoordinates<M>,
|
|
55
|
-
readonly teardown?:
|
|
48
|
+
readonly matrix?: (context: ParamsContext<Params>) => Promise<M>;
|
|
49
|
+
readonly setup?: (context: SetupContext<M, Params>) => Awaitable<Setup>;
|
|
50
|
+
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup, Params>) => Awaitable<Output>;
|
|
51
|
+
readonly teardown?: (context: TeardownContext<M, Setup, Params>) => Awaitable<void>;
|
|
56
52
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
interface BoundTask<Name extends string, M extends Matrix, Setup, Output extends JsonValue, Params extends JsonObject> extends TaskOptions<Name, M, Setup, Output, Params>, ParamsContext<Params> {
|
|
54
|
+
readonly matrix: (context: ParamsContext<Params>) => Promise<M>;
|
|
55
|
+
readonly [taskBindingBrand]: true;
|
|
56
|
+
}
|
|
57
|
+
declare function defineTask<Params extends Record<keyof Params, JsonValue> & RequiredParameters<Params> = Record<never, never>>(): {
|
|
58
|
+
<const Name extends string, const M extends Matrix = Record<never, never>, Setup = unknown, const Output extends JsonValue = JsonValue>(definition: TaskOptions<Name, M, Setup, Output, Params> & {
|
|
59
|
+
readonly setup: (context: SetupContext<M, Params>) => Awaitable<Setup>;
|
|
60
|
+
}): TaskDefinition<Name, M, Setup, Output, Params>;
|
|
61
|
+
<const Name extends string, const M extends Matrix = Record<never, never>, const Output extends JsonValue = JsonValue>(definition: TaskOptions<Name, M, undefined, Output, Params> & {
|
|
62
|
+
readonly setup?: never;
|
|
63
|
+
readonly teardown?: never;
|
|
64
|
+
}): TaskDefinition<Name, M, undefined, Output, Params>;
|
|
63
65
|
};
|
|
66
|
+
type AnyTaskBinding = Pick<RuntimeTask, "name" | "params" | typeof taskBindingBrand>;
|
|
67
|
+
type RuntimeTask = BoundTask<string, Matrix, unknown, JsonValue, JsonObject>;
|
|
64
68
|
//#endregion
|
|
65
69
|
//#region src/workflow.d.ts
|
|
66
|
-
type WorkflowDefinition<
|
|
70
|
+
type WorkflowDefinition<M extends Matrix, Metadata extends JsonObject> = {
|
|
67
71
|
readonly name: string;
|
|
68
72
|
readonly metadata?: Metadata;
|
|
69
|
-
readonly
|
|
73
|
+
readonly matrix?: () => Promise<M>;
|
|
74
|
+
readonly tasks: (context: {
|
|
75
|
+
readonly matrix: CaseCoordinates<M>;
|
|
76
|
+
}) => readonly [AnyTaskBinding, ...AnyTaskBinding[]];
|
|
70
77
|
};
|
|
71
|
-
declare function defineWorkflow<const
|
|
78
|
+
declare function defineWorkflow<const M extends Matrix = Record<never, never>, const Metadata extends JsonObject = JsonObject>(definition: WorkflowDefinition<M, Metadata>): void;
|
|
72
79
|
//#endregion
|
|
73
80
|
export { type CaseCoordinates, type InvokerMeta, type JsonObject, type JsonPrimitive, type JsonValue, type Matrix, type TaskContext, type TaskDefinition, defineTask, defineWorkflow };
|
package/dist/index.mjs
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as errorWithContext } from "./errors-CMczwLz9.mjs";
|
|
2
|
+
import { TestRunner, beforeAll, describe, test } from "vitest";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
//#region src/task.ts
|
|
4
|
-
const
|
|
5
|
-
function defineTask(
|
|
6
|
-
|
|
7
|
-
...
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
const taskBindingBrand = Symbol("invoker.task-binding");
|
|
6
|
+
function defineTask() {
|
|
7
|
+
function define(definition) {
|
|
8
|
+
return (...args) => {
|
|
9
|
+
return {
|
|
10
|
+
...definition,
|
|
11
|
+
params: args.length === 0 ? {} : args[0],
|
|
12
|
+
matrix: definition.matrix ?? (async () => ({})),
|
|
13
|
+
[taskBindingBrand]: true
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return define;
|
|
11
18
|
}
|
|
12
19
|
//#endregion
|
|
13
20
|
//#region src/json.ts
|
|
@@ -18,8 +25,8 @@ const scalarSchema = z.union([
|
|
|
18
25
|
z.number()
|
|
19
26
|
]);
|
|
20
27
|
const nameSchema = z.string().trim().min(1);
|
|
21
|
-
function snapshotJson(value, owner, path
|
|
22
|
-
return cloneJson(value, owner, path,
|
|
28
|
+
function snapshotJson(value, owner, path) {
|
|
29
|
+
return cloneJson(value, owner, path, /* @__PURE__ */ new Set());
|
|
23
30
|
}
|
|
24
31
|
function cloneJson(value, owner, path, ancestors) {
|
|
25
32
|
if (scalarSchema.safeParse(value).success) return value;
|
|
@@ -34,13 +41,12 @@ function cloneJson(value, owner, path, ancestors) {
|
|
|
34
41
|
}
|
|
35
42
|
ancestors.delete(value);
|
|
36
43
|
return snapshot;
|
|
37
|
-
} else {
|
|
38
|
-
assertPlainObject(value, owner, path);
|
|
39
|
-
if (Object.getOwnPropertySymbols(value).length > 0) fail(owner, path, "JSON objects cannot have symbol keys");
|
|
40
|
-
const snapshot = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child, owner, `${path}.${key}`, ancestors)]));
|
|
41
|
-
ancestors.delete(value);
|
|
42
|
-
return snapshot;
|
|
43
44
|
}
|
|
45
|
+
assertPlainObject(value, owner, path);
|
|
46
|
+
if (Object.getOwnPropertySymbols(value).length > 0) fail(owner, path, "JSON objects cannot have symbol keys");
|
|
47
|
+
const snapshot = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child, owner, `${path}.${key}`, ancestors)]));
|
|
48
|
+
ancestors.delete(value);
|
|
49
|
+
return snapshot;
|
|
44
50
|
}
|
|
45
51
|
function assertPlainObject(value, owner, path) {
|
|
46
52
|
if (Object(value) !== value || Array.isArray(value)) fail(owner, path, "expected a plain object");
|
|
@@ -103,89 +109,155 @@ function caseName(matrix, index) {
|
|
|
103
109
|
}
|
|
104
110
|
//#endregion
|
|
105
111
|
//#region src/workflow.ts
|
|
112
|
+
const functionSchema = z.function();
|
|
106
113
|
function defineWorkflow(definition) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
const info = { name: definition.name };
|
|
115
|
+
const meta = { invokerWorkflow: info };
|
|
116
|
+
describe(definition.name, {
|
|
117
|
+
concurrent: false,
|
|
118
|
+
shuffle: false,
|
|
119
|
+
meta
|
|
120
|
+
}, async () => {
|
|
121
|
+
const file = TestRunner.getCurrentSuite().file;
|
|
122
|
+
try {
|
|
123
|
+
const coordinates = await prepareWorkflow(definition, info);
|
|
124
|
+
for (const [index, coordinate] of coordinates.entries()) describe(caseName(coordinate.matrix, index), {
|
|
125
|
+
concurrent: false,
|
|
126
|
+
meta: { invokerWorkflow: null }
|
|
127
|
+
}, () => {
|
|
128
|
+
for (const prepared of coordinate.tasks) registerTask(prepared, coordinate.matrix, info.metadata);
|
|
129
|
+
});
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
Object.assign(file.meta, { invokerCollectionError: info });
|
|
132
|
+
throw errorWithContext(`Workflow ${JSON.stringify(info.name)} collection failed`, cause);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function registerTask(prepared, workflow, metadata) {
|
|
137
|
+
const { task, cases } = prepared;
|
|
138
|
+
const { params } = task;
|
|
139
|
+
describe(task.name, { concurrent: false }, () => {
|
|
140
|
+
let setup;
|
|
141
|
+
const setupTask = task.setup;
|
|
142
|
+
if (setupTask) beforeAll(async () => {
|
|
143
|
+
setup = await setupTask({
|
|
144
|
+
params,
|
|
145
|
+
cases
|
|
146
|
+
});
|
|
147
|
+
const teardownTask = task.teardown;
|
|
148
|
+
if (teardownTask) return () => teardownTask({
|
|
149
|
+
params,
|
|
150
|
+
cases,
|
|
151
|
+
setup
|
|
119
152
|
});
|
|
120
|
-
for (const [index, matrix] of prepared.cases.entries()) {
|
|
121
|
-
const name = prepared.names[index];
|
|
122
|
-
const invoker = prepared.metadata[index];
|
|
123
|
-
test.concurrent(name, { meta: { invoker } }, async (vitest) => {
|
|
124
|
-
const meta = vitest.task.meta;
|
|
125
|
-
delete meta.invoker.output;
|
|
126
|
-
const output = await prepared.task.run({
|
|
127
|
-
matrix,
|
|
128
|
-
setup,
|
|
129
|
-
vitest
|
|
130
|
-
});
|
|
131
|
-
meta.invoker.output = snapshotJson(output, `Task ${JSON.stringify(prepared.task.name)}`, ".output");
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
153
|
});
|
|
154
|
+
for (const [index, matrix] of cases.entries()) {
|
|
155
|
+
const invoker = {
|
|
156
|
+
schema: 2,
|
|
157
|
+
matrix: {
|
|
158
|
+
workflow,
|
|
159
|
+
task: matrix
|
|
160
|
+
},
|
|
161
|
+
params
|
|
162
|
+
};
|
|
163
|
+
if (metadata !== void 0) invoker.metadata = metadata;
|
|
164
|
+
test.concurrent(caseName(matrix, index), { meta: { invoker } }, async (vitest) => {
|
|
165
|
+
const meta = vitest.task.meta;
|
|
166
|
+
delete meta.invoker.output;
|
|
167
|
+
const output = await task.run({
|
|
168
|
+
params,
|
|
169
|
+
matrix,
|
|
170
|
+
setup,
|
|
171
|
+
vitest
|
|
172
|
+
});
|
|
173
|
+
meta.invoker.output = snapshotJson(output, `Task ${JSON.stringify(task.name)}`, ".output");
|
|
174
|
+
});
|
|
175
|
+
}
|
|
135
176
|
});
|
|
136
177
|
}
|
|
137
|
-
async function prepareWorkflow(definition) {
|
|
178
|
+
async function prepareWorkflow(definition, info) {
|
|
138
179
|
assertPlainObject(definition, "Workflow", "");
|
|
139
180
|
assertOnlyKeys(definition, [
|
|
140
181
|
"name",
|
|
141
182
|
"metadata",
|
|
183
|
+
"matrix",
|
|
142
184
|
"tasks"
|
|
143
185
|
], "Workflow");
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
186
|
+
assertName(definition.name, "Workflow", ".name");
|
|
187
|
+
const owner = `Workflow ${JSON.stringify(definition.name)}`;
|
|
188
|
+
if (definition.metadata !== void 0) {
|
|
189
|
+
const metadata = snapshotJson(definition.metadata, owner, ".metadata");
|
|
190
|
+
assertPlainObject(metadata, owner, ".metadata");
|
|
191
|
+
info.metadata = metadata;
|
|
192
|
+
}
|
|
193
|
+
if (!functionSchema.safeParse(definition.tasks).success) fail(owner, ".tasks", "expected a synchronous function");
|
|
194
|
+
if (definition.matrix !== void 0 && !functionSchema.safeParse(definition.matrix).success) fail(owner, ".matrix", "expected a function");
|
|
195
|
+
const coordinates = expandMatrix(definition.matrix ? await definition.matrix() : {}, owner).map((coordinate, index) => {
|
|
196
|
+
const coordinateOwner = `${owner} coordinate ${caseName(coordinate, index)}`;
|
|
197
|
+
return {
|
|
198
|
+
matrix: coordinate,
|
|
199
|
+
owner: coordinateOwner,
|
|
200
|
+
tasks: bindTasks(definition, coordinate, coordinateOwner)
|
|
201
|
+
};
|
|
172
202
|
});
|
|
173
|
-
return
|
|
174
|
-
|
|
203
|
+
return Promise.all(coordinates.map(async (coordinate) => ({
|
|
204
|
+
matrix: coordinate.matrix,
|
|
205
|
+
tasks: await Promise.all(coordinate.tasks.map((task) => discoverTask(task, coordinate.owner)))
|
|
206
|
+
})));
|
|
207
|
+
}
|
|
208
|
+
function bindTasks(definition, matrix, owner) {
|
|
209
|
+
try {
|
|
210
|
+
const bindings = definition.tasks({ matrix });
|
|
211
|
+
if (!Array.isArray(bindings) || bindings.length === 0) fail(owner, ".tasks", "expected a non-empty Task tuple");
|
|
212
|
+
const names = /* @__PURE__ */ new Set();
|
|
213
|
+
return bindings.map((binding) => prepareBinding(binding, owner, names));
|
|
214
|
+
} catch (cause) {
|
|
215
|
+
throw errorWithContext(`${owner} Task binding failed`, cause);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function discoverTask(task, owner) {
|
|
219
|
+
const taskOwner = `${owner} Task ${JSON.stringify(task.name)}`;
|
|
220
|
+
try {
|
|
175
221
|
return {
|
|
176
222
|
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
|
-
})
|
|
223
|
+
cases: expandMatrix(await task.matrix({ params: task.params }), taskOwner)
|
|
187
224
|
};
|
|
188
|
-
})
|
|
225
|
+
} catch (cause) {
|
|
226
|
+
throw errorWithContext(`${taskOwner} Matrix discovery failed`, cause);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function prepareBinding(binding, owner, names) {
|
|
230
|
+
assertPlainObject(binding, owner, ".tasks");
|
|
231
|
+
if (binding[taskBindingBrand] !== true) fail(owner, ".tasks", "expected a bound Task created by defineTask");
|
|
232
|
+
const task = binding;
|
|
233
|
+
assertOnlyKeys(task, [
|
|
234
|
+
"name",
|
|
235
|
+
"params",
|
|
236
|
+
"matrix",
|
|
237
|
+
"setup",
|
|
238
|
+
"run",
|
|
239
|
+
"teardown"
|
|
240
|
+
], owner);
|
|
241
|
+
assertName(task.name, owner, ".tasks.name");
|
|
242
|
+
if (names.has(task.name)) fail(owner, ".tasks", `duplicate Task name ${JSON.stringify(task.name)}`);
|
|
243
|
+
names.add(task.name);
|
|
244
|
+
const taskOwner = `${owner} Task ${JSON.stringify(task.name)}`;
|
|
245
|
+
for (const key of [
|
|
246
|
+
"matrix",
|
|
247
|
+
"run",
|
|
248
|
+
"setup",
|
|
249
|
+
"teardown"
|
|
250
|
+
]) {
|
|
251
|
+
if ((key === "setup" || key === "teardown") && task[key] === void 0) continue;
|
|
252
|
+
if (!functionSchema.safeParse(task[key]).success) fail(taskOwner, `.${key}`, "expected a function");
|
|
253
|
+
}
|
|
254
|
+
if (task.teardown && !task.setup) fail(taskOwner, ".teardown", "requires setup");
|
|
255
|
+
const params = snapshotJson(task.params, taskOwner, ".params");
|
|
256
|
+
assertPlainObject(params, taskOwner, ".params");
|
|
257
|
+
return {
|
|
258
|
+
...task,
|
|
259
|
+
params
|
|
260
|
+
};
|
|
189
261
|
}
|
|
190
262
|
//#endregion
|
|
191
263
|
export { defineTask, defineWorkflow };
|
package/dist/slack.mjs
CHANGED
|
@@ -1,68 +1,89 @@
|
|
|
1
|
+
import { t as errorMessage } from "./errors-CMczwLz9.mjs";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { WebAPIRateLimitedError, WebClient } from "@slack/web-api";
|
|
3
4
|
import { setTimeout } from "node:timers/promises";
|
|
4
|
-
//#region src/slack/
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
//#region src/slack/collect.ts
|
|
6
|
+
const jsonObjectSchema = z.record(z.string(), z.json());
|
|
7
|
+
const workflowInfoSchema = z.object({
|
|
8
|
+
name: z.string(),
|
|
9
|
+
metadata: jsonObjectSchema.optional()
|
|
10
|
+
});
|
|
11
|
+
const workflowMetaSchema = z.object({ invokerWorkflow: workflowInfoSchema });
|
|
12
|
+
const collectionErrorSchema = z.object({ invokerCollectionError: workflowInfoSchema });
|
|
13
|
+
const testMetaSchema = z.object({ invoker: z.strictObject({
|
|
14
|
+
schema: z.literal(2),
|
|
15
|
+
matrix: z.strictObject({
|
|
16
|
+
workflow: jsonObjectSchema,
|
|
17
|
+
task: jsonObjectSchema
|
|
18
|
+
}),
|
|
19
|
+
params: jsonObjectSchema,
|
|
10
20
|
metadata: jsonObjectSchema.optional(),
|
|
11
21
|
output: z.json().optional()
|
|
12
|
-
});
|
|
13
|
-
const testMetaSchema = z.object({ invoker: invokerMetaSchema.optional() });
|
|
14
|
-
const errorMessageSchema = z.object({ message: stringSchema });
|
|
15
|
-
const errorStackSchema = z.object({ stack: stringSchema });
|
|
16
|
-
const TABLE_ROW_LIMIT = 100;
|
|
17
|
-
const TABLE_CHARACTER_LIMIT = 1e4;
|
|
18
|
-
const SECTION_CHARACTER_LIMIT = 3e3;
|
|
19
|
-
const NAME_CHARACTER_LIMIT = 200;
|
|
20
|
-
const METADATA_CHARACTER_LIMIT = 1800;
|
|
22
|
+
}) });
|
|
21
23
|
function collectWorkflowReports(modules) {
|
|
22
|
-
const workflows =
|
|
23
|
-
for (const module of modules)
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
const workflows = [];
|
|
25
|
+
for (const module of modules) {
|
|
26
|
+
for (const suite of module.children.allSuites()) {
|
|
27
|
+
const meta = workflowMetaSchema.safeParse(suite.meta());
|
|
28
|
+
if (!meta.success) continue;
|
|
29
|
+
const tasks = [...suite.children.suites()].flatMap((coordinate) => [...coordinate.children.suites()].map((task) => collectTask(task, coordinate.name)));
|
|
30
|
+
const failures = [];
|
|
31
|
+
addErrors(failures, suite.errors());
|
|
32
|
+
for (const coordinate of suite.children.suites()) addErrors(failures, coordinate.errors(), coordinate.name);
|
|
33
|
+
workflows.push({
|
|
34
|
+
...meta.data.invokerWorkflow,
|
|
35
|
+
tasks: tasks.map((task) => task.report),
|
|
36
|
+
failures: deduplicateFailures([...failures, ...tasks.flatMap((task) => task.failures)]),
|
|
37
|
+
retries: tasks.flatMap((task) => task.retries),
|
|
38
|
+
skips: tasks.flatMap((task) => task.skips),
|
|
39
|
+
...timeSpan(tasks)
|
|
40
|
+
});
|
|
39
41
|
}
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
failed: 0,
|
|
49
|
-
skipped: 0,
|
|
50
|
-
incomplete: 0,
|
|
51
|
-
failures: [],
|
|
42
|
+
const collection = collectionErrorSchema.safeParse(module.meta());
|
|
43
|
+
if (collection.success) {
|
|
44
|
+
const failures = [];
|
|
45
|
+
addErrors(failures, module.errors());
|
|
46
|
+
workflows.push({
|
|
47
|
+
...collection.data.invokerCollectionError,
|
|
48
|
+
tasks: [],
|
|
49
|
+
failures,
|
|
52
50
|
retries: [],
|
|
53
51
|
skips: []
|
|
54
|
-
};
|
|
55
|
-
workflow.tasks.set(taskSuite, task);
|
|
52
|
+
});
|
|
56
53
|
}
|
|
57
|
-
|
|
54
|
+
}
|
|
55
|
+
return workflows;
|
|
56
|
+
}
|
|
57
|
+
function collectTask(suite, coordinateName) {
|
|
58
|
+
const name = `${coordinateName} · ${suite.name}`;
|
|
59
|
+
const report = {
|
|
60
|
+
name,
|
|
61
|
+
total: 0,
|
|
62
|
+
passed: 0,
|
|
63
|
+
retried: 0,
|
|
64
|
+
failed: 0,
|
|
65
|
+
skipped: 0,
|
|
66
|
+
incomplete: 0,
|
|
67
|
+
duration: 0
|
|
68
|
+
};
|
|
69
|
+
const failures = [];
|
|
70
|
+
const retries = [];
|
|
71
|
+
const skips = [];
|
|
72
|
+
let startedAt;
|
|
73
|
+
let endedAt;
|
|
74
|
+
for (const testCase of suite.children.tests()) {
|
|
75
|
+
const meta = testMetaSchema.safeParse(testCase.meta());
|
|
76
|
+
if (!meta.success) continue;
|
|
77
|
+
const { invoker } = meta.data;
|
|
58
78
|
const result = testCase.result();
|
|
59
79
|
const diagnostic = testCase.diagnostic();
|
|
60
|
-
|
|
80
|
+
report.total += 1;
|
|
81
|
+
report[result.state === "pending" ? "incomplete" : result.state] += 1;
|
|
61
82
|
const retryCount = diagnostic?.retryCount ?? 0;
|
|
62
83
|
if (retryCount > 0) {
|
|
63
|
-
|
|
64
|
-
if (result.state === "passed")
|
|
65
|
-
task:
|
|
84
|
+
report.retried += 1;
|
|
85
|
+
if (result.state === "passed") retries.push({
|
|
86
|
+
task: name,
|
|
66
87
|
caseName: testCase.name,
|
|
67
88
|
matrix: invoker.matrix,
|
|
68
89
|
count: retryCount,
|
|
@@ -70,51 +91,80 @@ function collectWorkflowReports(modules) {
|
|
|
70
91
|
});
|
|
71
92
|
}
|
|
72
93
|
if (diagnostic) {
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
startedAt = Math.min(startedAt ?? diagnostic.startTime, diagnostic.startTime);
|
|
95
|
+
endedAt = Math.max(endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
|
|
75
96
|
}
|
|
76
|
-
if (result.state === "failed")
|
|
77
|
-
task:
|
|
97
|
+
if (result.state === "failed") failures.push({
|
|
98
|
+
task: name,
|
|
78
99
|
caseName: testCase.name,
|
|
79
100
|
matrix: invoker.matrix,
|
|
80
101
|
messages: result.errors.map(errorMessage)
|
|
81
102
|
});
|
|
82
|
-
else if (result.state === "skipped")
|
|
83
|
-
task:
|
|
103
|
+
else if (result.state === "skipped") skips.push({
|
|
104
|
+
task: name,
|
|
84
105
|
caseName: testCase.name,
|
|
85
106
|
matrix: invoker.matrix,
|
|
86
107
|
reason: result.note || "No reason provided"
|
|
87
108
|
});
|
|
88
109
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
metadata: workflow.metadata,
|
|
106
|
-
tasks: collectedTasks.map(({ suite: _suite, failures: _failures, retries: _retries, skips: _skips, startedAt, endedAt, ...task }) => ({
|
|
107
|
-
...task,
|
|
108
|
-
duration: startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt
|
|
109
|
-
})),
|
|
110
|
-
failures: deduplicateFailures(failures),
|
|
111
|
-
retries,
|
|
112
|
-
skips,
|
|
113
|
-
startedAt,
|
|
114
|
-
endedAt
|
|
115
|
-
};
|
|
110
|
+
addErrors(failures, suite.errors(), name);
|
|
111
|
+
report.duration = startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt;
|
|
112
|
+
return {
|
|
113
|
+
report,
|
|
114
|
+
failures,
|
|
115
|
+
retries,
|
|
116
|
+
skips,
|
|
117
|
+
startedAt,
|
|
118
|
+
endedAt
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function addErrors(failures, errors, task) {
|
|
122
|
+
const messages = errors.map(errorMessage);
|
|
123
|
+
if (messages.length > 0) failures.push({
|
|
124
|
+
task,
|
|
125
|
+
messages
|
|
116
126
|
});
|
|
117
127
|
}
|
|
128
|
+
function deduplicateFailures(failures) {
|
|
129
|
+
const seen = /* @__PURE__ */ new Set();
|
|
130
|
+
return failures.flatMap((failure) => {
|
|
131
|
+
const messages = failure.messages.filter((message) => {
|
|
132
|
+
const key = JSON.stringify([
|
|
133
|
+
failure.task,
|
|
134
|
+
failure.caseName,
|
|
135
|
+
failure.matrix,
|
|
136
|
+
message
|
|
137
|
+
]);
|
|
138
|
+
if (seen.has(key)) return false;
|
|
139
|
+
seen.add(key);
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
return messages.length > 0 ? [{
|
|
143
|
+
...failure,
|
|
144
|
+
messages
|
|
145
|
+
}] : [];
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function timeSpan(values) {
|
|
149
|
+
let startedAt;
|
|
150
|
+
let endedAt;
|
|
151
|
+
for (const value of values) {
|
|
152
|
+
if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
|
|
153
|
+
if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
startedAt,
|
|
157
|
+
endedAt
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/slack/report.ts
|
|
162
|
+
const stringSchema = z.string();
|
|
163
|
+
const TABLE_ROW_LIMIT = 100;
|
|
164
|
+
const TABLE_CHARACTER_LIMIT = 1e4;
|
|
165
|
+
const SECTION_CHARACTER_LIMIT = 3e3;
|
|
166
|
+
const NAME_CHARACTER_LIMIT = 200;
|
|
167
|
+
const METADATA_CHARACTER_LIMIT = 1800;
|
|
118
168
|
function summaryMessage(reports, runUrl) {
|
|
119
169
|
const timestamp = Math.floor(Date.now() / 1e3);
|
|
120
170
|
const { startedAt, endedAt } = timeSpan(reports);
|
|
@@ -143,7 +193,8 @@ function workflowAttachment(report, rows, index, pages) {
|
|
|
143
193
|
const status = workflowStatus(report);
|
|
144
194
|
const metadata = metadataText(report.metadata);
|
|
145
195
|
const page = pages > 1 ? ` (${index + 1}/${pages})` : "";
|
|
146
|
-
const
|
|
196
|
+
const result = report.tasks.length === 0 ? "Collection failed" : `${totals.passed}/${totals.total} passed`;
|
|
197
|
+
const headline = `${status.emoji} *${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))} — ${result}${page}*`;
|
|
147
198
|
return {
|
|
148
199
|
color: status.color,
|
|
149
200
|
blocks: [
|
|
@@ -178,131 +229,66 @@ function workflowAttachment(report, rows, index, pages) {
|
|
|
178
229
|
}
|
|
179
230
|
function failureMessages(report) {
|
|
180
231
|
return [...Map.groupBy(report.failures, (failure) => failure.task)].flatMap(([task, failures]) => {
|
|
181
|
-
|
|
182
|
-
const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
|
|
183
|
-
const metadata = metadataText(report.metadata);
|
|
184
|
-
const context = metadata ? `${workflow} • ${metadata}` : workflow;
|
|
185
|
-
return chunk(failures.map((failure) => {
|
|
232
|
+
return detailMessages(report, task, "failed", failures.map((failure) => {
|
|
186
233
|
return `${failure.caseName ? `*${escapeSlack(failure.caseName)}*` : task ? "*Task error*" : "*Workflow error*"}${failure.matrix ? `\nMatrix: ${escapeSlack(JSON.stringify(failure.matrix))}` : ""}${failure.messages.map((message) => `\n• ${escapeSlack(message)}`).join("")}`;
|
|
187
|
-
})
|
|
188
|
-
const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
|
|
189
|
-
return {
|
|
190
|
-
text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${failures.length} failed${part}`,
|
|
191
|
-
attachments: [{
|
|
192
|
-
color: "danger",
|
|
193
|
-
blocks: [
|
|
194
|
-
{
|
|
195
|
-
type: "section",
|
|
196
|
-
text: {
|
|
197
|
-
type: "mrkdwn",
|
|
198
|
-
text: `🔴 *${title} — ${failures.length} failed${part}*`
|
|
199
|
-
}
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
type: "context",
|
|
203
|
-
elements: [{
|
|
204
|
-
type: "mrkdwn",
|
|
205
|
-
text: context
|
|
206
|
-
}]
|
|
207
|
-
},
|
|
208
|
-
{
|
|
209
|
-
type: "section",
|
|
210
|
-
text: {
|
|
211
|
-
type: "mrkdwn",
|
|
212
|
-
text: details
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
]
|
|
216
|
-
}]
|
|
217
|
-
};
|
|
218
|
-
});
|
|
234
|
+
}));
|
|
219
235
|
});
|
|
220
236
|
}
|
|
221
237
|
function retryMessages(report) {
|
|
222
238
|
return [...Map.groupBy(report.retries, (retry) => retry.task)].flatMap(([task, retries]) => {
|
|
223
|
-
|
|
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) => {
|
|
239
|
+
return detailMessages(report, task, "retried", retries.map((retry) => {
|
|
228
240
|
const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(retry.matrix))}`;
|
|
229
241
|
const messages = retry.messages.map((message) => `\n• ${escapeSlack(message)}`).join("");
|
|
230
242
|
return `*${escapeSlack(retry.caseName)}*\nRetries: ${retry.count}${matrix}${messages}`;
|
|
231
|
-
})
|
|
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
|
-
});
|
|
243
|
+
}));
|
|
263
244
|
});
|
|
264
245
|
}
|
|
265
246
|
function skipMessages(report) {
|
|
266
247
|
return [...Map.groupBy(report.skips, (skip) => skip.task)].flatMap(([task, skips]) => {
|
|
267
|
-
|
|
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) => {
|
|
248
|
+
return detailMessages(report, task, "skipped", skips.map((skip) => {
|
|
272
249
|
const matrix = `\nMatrix: ${escapeSlack(JSON.stringify(skip.matrix))}`;
|
|
273
250
|
return `*${escapeSlack(skip.caseName)}*${matrix}\nReason: ${escapeSlack(skip.reason)}`;
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
type: "section",
|
|
297
|
-
text: {
|
|
298
|
-
type: "mrkdwn",
|
|
299
|
-
text: details
|
|
300
|
-
}
|
|
251
|
+
}));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function detailMessages(report, task, outcome, entries) {
|
|
255
|
+
const title = escapeSlack(truncate(task ?? "Workflow errors", NAME_CHARACTER_LIMIT));
|
|
256
|
+
const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
|
|
257
|
+
const metadata = metadataText(report.metadata);
|
|
258
|
+
const context = metadata ? `${workflow} • ${metadata}` : workflow;
|
|
259
|
+
const emoji = outcome === "failed" ? "🔴" : "🟡";
|
|
260
|
+
return chunk(entries, SECTION_CHARACTER_LIMIT).map((details, index, messages) => {
|
|
261
|
+
const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
|
|
262
|
+
const count = `${entries.length} ${outcome}${part}`;
|
|
263
|
+
return {
|
|
264
|
+
text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${count}`,
|
|
265
|
+
attachments: [{
|
|
266
|
+
color: outcome === "failed" ? "danger" : "warning",
|
|
267
|
+
blocks: [
|
|
268
|
+
{
|
|
269
|
+
type: "section",
|
|
270
|
+
text: {
|
|
271
|
+
type: "mrkdwn",
|
|
272
|
+
text: `${emoji} *${title} — ${count}*`
|
|
301
273
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
type: "context",
|
|
277
|
+
elements: [{
|
|
278
|
+
type: "mrkdwn",
|
|
279
|
+
text: context
|
|
280
|
+
}]
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
type: "section",
|
|
284
|
+
text: {
|
|
285
|
+
type: "mrkdwn",
|
|
286
|
+
text: details
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
}]
|
|
291
|
+
};
|
|
306
292
|
});
|
|
307
293
|
}
|
|
308
294
|
function unhandledErrorMessages(errors) {
|
|
@@ -330,40 +316,6 @@ function unhandledErrorMessages(errors) {
|
|
|
330
316
|
};
|
|
331
317
|
});
|
|
332
318
|
}
|
|
333
|
-
function addErrors(failures, errors, task) {
|
|
334
|
-
const messages = errors.map(errorMessage);
|
|
335
|
-
if (messages.length > 0) failures.push({
|
|
336
|
-
task,
|
|
337
|
-
messages
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
function errorMessage(cause) {
|
|
341
|
-
const message = errorMessageSchema.safeParse(cause);
|
|
342
|
-
if (message.success) return message.data.message;
|
|
343
|
-
const stack = errorStackSchema.safeParse(cause);
|
|
344
|
-
if (stack.success) return stack.data.stack.split("\n", 1)[0];
|
|
345
|
-
return String(cause);
|
|
346
|
-
}
|
|
347
|
-
function deduplicateFailures(failures) {
|
|
348
|
-
const seen = /* @__PURE__ */ new Set();
|
|
349
|
-
return failures.flatMap((failure) => {
|
|
350
|
-
const messages = failure.messages.filter((message) => {
|
|
351
|
-
const key = JSON.stringify([
|
|
352
|
-
failure.task,
|
|
353
|
-
failure.caseName,
|
|
354
|
-
failure.matrix,
|
|
355
|
-
message
|
|
356
|
-
]);
|
|
357
|
-
if (seen.has(key)) return false;
|
|
358
|
-
seen.add(key);
|
|
359
|
-
return true;
|
|
360
|
-
});
|
|
361
|
-
return messages.length > 0 ? [{
|
|
362
|
-
...failure,
|
|
363
|
-
messages
|
|
364
|
-
}] : [];
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
319
|
function escapeSlack(value) {
|
|
368
320
|
return escapeSlackControl(value).replaceAll("*", "∗").replaceAll("_", "_").replaceAll("~", "∼").replaceAll("`", "ˋ");
|
|
369
321
|
}
|
|
@@ -451,7 +403,7 @@ function formatDuration(milliseconds) {
|
|
|
451
403
|
function chunk(entries, limit) {
|
|
452
404
|
const chunks = [];
|
|
453
405
|
for (const entry of entries) {
|
|
454
|
-
const value = entry
|
|
406
|
+
const value = truncate(entry, limit);
|
|
455
407
|
const previous = chunks.at(-1);
|
|
456
408
|
if (previous && previous.length + value.length + 2 <= limit) chunks[chunks.length - 1] = `${previous}\n\n${value}`;
|
|
457
409
|
else chunks.push(value);
|
|
@@ -461,20 +413,9 @@ function chunk(entries, limit) {
|
|
|
461
413
|
function truncate(value, limit) {
|
|
462
414
|
return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
|
|
463
415
|
}
|
|
464
|
-
function timeSpan(values) {
|
|
465
|
-
let startedAt;
|
|
466
|
-
let endedAt;
|
|
467
|
-
for (const value of values) {
|
|
468
|
-
if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
|
|
469
|
-
if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
|
|
470
|
-
}
|
|
471
|
-
return {
|
|
472
|
-
startedAt,
|
|
473
|
-
endedAt
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
416
|
//#endregion
|
|
477
417
|
//#region src/slack/reporter.ts
|
|
418
|
+
const THREAD_REPLY_LIMIT = 10;
|
|
478
419
|
function slackReporter(options) {
|
|
479
420
|
const client = new WebClient(options.token, {
|
|
480
421
|
rejectRateLimitedCalls: true,
|
|
@@ -507,12 +448,13 @@ function slackReporter(options) {
|
|
|
507
448
|
console.warn("[invoker] Could not post the Slack report.");
|
|
508
449
|
return;
|
|
509
450
|
}
|
|
510
|
-
const
|
|
451
|
+
const details = [
|
|
511
452
|
...reports.flatMap(failureMessages),
|
|
512
453
|
...reports.flatMap(retryMessages),
|
|
513
454
|
...reports.flatMap(skipMessages),
|
|
514
455
|
...unhandledErrorMessages(unhandledErrors)
|
|
515
456
|
];
|
|
457
|
+
const replies = details.length > THREAD_REPLY_LIMIT ? [...details.slice(0, 9), { text: "Too many report details to display. See the run logs for more information." }] : details;
|
|
516
458
|
if (replies.length > 0 && !parentTimestamp) {
|
|
517
459
|
console.warn("[invoker] Slack did not return a timestamp for the report thread.");
|
|
518
460
|
return;
|