@elench/testkit 0.1.100 → 0.1.102

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.
Files changed (38) hide show
  1. package/README.md +6 -3
  2. package/lib/cli/args.mjs +0 -19
  3. package/lib/cli/assistant/app.mjs +6 -0
  4. package/lib/cli/assistant/command-observer.mjs +75 -44
  5. package/lib/cli/assistant/command-results.mjs +29 -2
  6. package/lib/cli/assistant/context-pack.mjs +21 -1
  7. package/lib/cli/assistant/providers/claude.mjs +42 -7
  8. package/lib/cli/assistant/providers/codex.mjs +87 -9
  9. package/lib/cli/assistant/providers/events.mjs +71 -0
  10. package/lib/cli/assistant/providers/index.mjs +5 -4
  11. package/lib/cli/assistant/providers/shared.mjs +40 -21
  12. package/lib/cli/assistant/session.mjs +46 -8
  13. package/lib/cli/assistant/settings.mjs +29 -6
  14. package/lib/cli/assistant/state.mjs +181 -6
  15. package/lib/cli/assistant/transcript-text.mjs +35 -0
  16. package/lib/cli/assistant/view-model.mjs +11 -0
  17. package/lib/cli/command-flags.mjs +0 -3
  18. package/lib/cli/entrypoint.mjs +0 -2
  19. package/lib/cli/operations/run/operation.mjs +0 -3
  20. package/lib/runner/live-run.mjs +5 -1
  21. package/lib/runner/orchestrator.mjs +26 -26
  22. package/lib/runner/planning.mjs +0 -75
  23. package/lib/runner/provenance.mjs +20 -0
  24. package/lib/runner/reporting.mjs +14 -9
  25. package/lib/runner/run-finalization.mjs +5 -2
  26. package/lib/runner/run-guards.mjs +0 -1
  27. package/lib/runner/scheduler/estimates.mjs +61 -0
  28. package/lib/runner/scheduler/identity.mjs +31 -0
  29. package/lib/runner/scheduler/index.mjs +126 -0
  30. package/lib/runner/scheduler/observations.mjs +27 -0
  31. package/lib/runner/selection.mjs +1 -2
  32. package/lib/runner/worker-loop.mjs +3 -4
  33. package/lib/timing/index.mjs +33 -33
  34. package/node_modules/@elench/next-analysis/package.json +1 -1
  35. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  36. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  37. package/node_modules/@elench/ts-analysis/package.json +1 -1
  38. package/package.json +14 -8
@@ -1,11 +1,10 @@
1
1
  import {
2
- applyShard,
3
2
  buildRuntimeGraphs,
4
- buildTaskQueue,
5
3
  claimNextTask,
6
4
  collectSuites,
7
5
  resolveRuntimeConfigs,
8
6
  } from "./planning.mjs";
7
+ import { buildRunPlanningMetadata, buildScheduledQueue } from "./scheduler/index.mjs";
9
8
  import {
10
9
  addTrackerError,
11
10
  buildServiceTrackers,
@@ -20,6 +19,7 @@ import {
20
19
  resetResultArtifacts,
21
20
  saveTimings,
22
21
  } from "./artifacts.mjs";
22
+ import { loadHistory } from "../history/index.mjs";
23
23
  import { createRunLogRegistry } from "./logs.mjs";
24
24
  import { createSetupOperationRegistry } from "./setup-operations.mjs";
25
25
  import {
@@ -40,12 +40,14 @@ import { createWorker, runWorker } from "./worker-loop.mjs";
40
40
  import { ensureRequestedFilesMatch, ensureStatusWriteAllowed } from "./run-guards.mjs";
41
41
  import { createLiveSnapshotWriter } from "./live-run.mjs";
42
42
  import { finalizeRunArtifacts } from "./run-finalization.mjs";
43
+ import { buildRunProvenance } from "./provenance.mjs";
43
44
 
44
45
  export async function runAll(configs, typeValues, suiteSelectors, opts, allConfigs = configs) {
45
46
  const configMap = new Map(allConfigs.map((config) => [config.name, config]));
46
47
  const startedAt = Date.now();
47
48
  const telemetry = configs[0]?.telemetry || null;
48
49
  const productDir = configs[0]?.productDir || process.cwd();
50
+ const provenance = buildRunProvenance(opts.env || process.env);
49
51
  await cleanupStaleRuns(productDir);
50
52
  resetResultArtifacts(productDir);
51
53
  const metadata = {
@@ -87,30 +89,33 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
87
89
  );
88
90
  reporter?.setServicePlans?.(servicePlans);
89
91
  const trackers = buildServiceTrackers(servicePlans, startedAt);
92
+ const runSelection = {
93
+ typeValues,
94
+ suiteSelectors,
95
+ fileNames: requestedFiles,
96
+ serviceFilter: opts.serviceFilter || null,
97
+ scenarioSeed: opts.scenarioSeed || null,
98
+ planning: null,
99
+ };
90
100
  let writeLiveSnapshot = () => {};
91
101
  const setupRegistry = createSetupOperationRegistry({ logRegistry, onChange: () => writeLiveSnapshot() });
102
+ const executedPlans = servicePlans.filter((plan) => !plan.skipped);
103
+ let exitCode = 0;
104
+ const lifecycle = createRunLifecycle(productDir);
92
105
  writeLiveSnapshot = createLiveSnapshotWriter({
93
106
  productDir,
107
+ runId: lifecycle.runId,
94
108
  configs,
95
109
  trackers,
96
110
  startedAt,
97
111
  execution,
98
112
  workerState,
99
- selection: {
100
- typeValues,
101
- suiteSelectors,
102
- fileNames: requestedFiles,
103
- shard: opts.shard || null,
104
- serviceFilter: opts.serviceFilter || null,
105
- scenarioSeed: opts.scenarioSeed || null,
106
- },
113
+ selection: runSelection,
114
+ provenance,
107
115
  metadata,
108
116
  logRegistry,
109
117
  setupRegistry,
110
118
  });
111
- const executedPlans = servicePlans.filter((plan) => !plan.skipped);
112
- let exitCode = 0;
113
- const lifecycle = createRunLifecycle(productDir);
114
119
  lifecycle.markRunning();
115
120
  lifecycle.installSignalHandlers();
116
121
  let results = [];
@@ -120,8 +125,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
120
125
  try {
121
126
  if (executedPlans.length > 0) {
122
127
  const timings = loadTimings(productDir);
128
+ const history = loadHistory(productDir);
123
129
  const graphs = buildRuntimeGraphs(executedPlans);
124
- const queue = buildTaskQueue(executedPlans, graphs, timings);
130
+ const queue = buildScheduledQueue(executedPlans, graphs, { timings, history });
131
+ runSelection.planning = buildRunPlanningMetadata(queue);
132
+ writeLiveSnapshot();
125
133
  reporter?.setTotalFileCount?.(queue.length);
126
134
  for (const task of queue) {
127
135
  task.scenarioSeed = opts.scenarioSeed || null;
@@ -191,6 +199,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
191
199
  );
192
200
  const finalized = await finalizeRunArtifacts({
193
201
  productDir,
202
+ runId: lifecycle.runId,
194
203
  results,
195
204
  startedAt,
196
205
  finishedAt,
@@ -198,14 +207,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
198
207
  workerCount: workerState.workerCount,
199
208
  runtimeInstanceCount: workerState.runtimeInstanceCount,
200
209
  runtimeStats: workerState.runtimeStats,
201
- selection: {
202
- typeValues,
203
- suiteSelectors,
204
- fileNames: requestedFiles,
205
- shard: opts.shard || null,
206
- serviceFilter: opts.serviceFilter || null,
207
- scenarioSeed: opts.scenarioSeed || null,
208
- },
210
+ selection: runSelection,
211
+ provenance,
209
212
  metadata,
210
213
  logRegistry,
211
214
  setupRegistry,
@@ -245,10 +248,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
245
248
 
246
249
  function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts, execution, reporter) {
247
250
  return configs.map((config) => {
248
- const suites = applyShard(
249
- collectSuites(config, typeValues, suiteSelectors, opts.fileNames || [], opts),
250
- opts.shard
251
- );
251
+ const suites = collectSuites(config, typeValues, suiteSelectors, opts.fileNames || [], opts);
252
252
 
253
253
  if (suites.length === 0) {
254
254
  reporter?.serviceSkipped?.(
@@ -1,4 +1,3 @@
1
- import { buildTimingKey, estimateTaskDuration } from "../timing/index.mjs";
2
1
  import {
3
2
  matchesSelectedTypes,
4
3
  matchesSuiteSelectors,
@@ -86,11 +85,6 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
86
85
  return suites;
87
86
  }
88
87
 
89
- export function applyShard(suites, shard) {
90
- if (!shard) return suites;
91
- return suites.filter((_unused, index) => index % shard.total === shard.index - 1);
92
- }
93
-
94
88
  export function orderedTypes(types) {
95
89
  const ordered = [];
96
90
  for (const known of TYPE_ORDER) {
@@ -143,52 +137,6 @@ export function buildRuntimeGraphs(servicePlans) {
143
137
  }));
144
138
  }
145
139
 
146
- export function buildTaskQueue(servicePlans, graphs, timings) {
147
- const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
148
- const tasks = [];
149
- let nextId = 1;
150
-
151
- for (const plan of servicePlans) {
152
- if (plan.skipped) continue;
153
-
154
- const graph = graphByKey.get(plan.assignedGraphKey);
155
- if (!graph) {
156
- throw new Error(`Assigned graph "${plan.assignedGraphKey}" not found for ${plan.config.name}`);
157
- }
158
-
159
- for (const suite of plan.suites) {
160
- for (const file of suite.files) {
161
- const timingKey = buildTimingKey(plan.config.name, suite, file);
162
- tasks.push({
163
- id: nextId,
164
- graphKey: graph.key,
165
- targetName: plan.config.name,
166
- serviceName: plan.config.name,
167
- suiteKey: `${suite.type}:${suite.name}`,
168
- suiteName: suite.name,
169
- type: suite.type,
170
- framework: suite.framework,
171
- orderIndex: suite.orderIndex,
172
- file,
173
- locks: resolveTaskLocks(plan.config, suite, file),
174
- resourceCost: 1,
175
- timingKey,
176
- estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
177
- });
178
- nextId += 1;
179
- }
180
- }
181
- }
182
-
183
- return tasks.sort(
184
- (a, b) =>
185
- b.estimatedDurationMs - a.estimatedDurationMs ||
186
- a.serviceName.localeCompare(b.serviceName) ||
187
- a.suiteKey.localeCompare(b.suiteKey) ||
188
- a.file.localeCompare(b.file)
189
- );
190
- }
191
-
192
140
  export function claimNextTask(queue, preferredGraphKey, isRunnable = () => true) {
193
141
  if (queue.length === 0) return null;
194
142
 
@@ -259,29 +207,6 @@ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
259
207
  };
260
208
  }
261
209
 
262
- function resolveTaskLocks(config, suite, file) {
263
- const locks = new Set();
264
- const matchedSuiteRules = config.testkit.requirements?.suites || [];
265
- for (const rule of matchedSuiteRules) {
266
- if (matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])) {
267
- for (const lockName of rule.locks || []) {
268
- locks.add(lockName);
269
- }
270
- }
271
- }
272
-
273
- const normalizedFile = normalizePathSeparators(file);
274
- const fileMetadata = config.testkit.fileMetadataByPath?.get(normalizedFile) || null;
275
- for (const lockName of config.testkit.requirements?.fileLocksByPath?.get(normalizedFile) || []) {
276
- locks.add(lockName);
277
- }
278
- for (const lockName of fileMetadata?.locks || []) {
279
- locks.add(lockName);
280
- }
281
-
282
- return [...locks].sort();
283
- }
284
-
285
210
  function normalizePathSeparators(filePath) {
286
211
  return String(filePath).split("\\").join("/");
287
212
  }
@@ -0,0 +1,20 @@
1
+ const ASSISTANT_SESSION_ENV = "TESTKIT_ASSISTANT_SESSION_ID";
2
+ const ASSISTANT_COMMAND_ID_ENV = "TESTKIT_ASSISTANT_COMMAND_ID";
3
+
4
+ export function buildRunProvenance(env = process.env) {
5
+ const sessionId = normalizeOptionalString(env?.[ASSISTANT_SESSION_ENV]);
6
+ const commandId = normalizeOptionalString(env?.[ASSISTANT_COMMAND_ID_ENV]);
7
+ if (!sessionId && !commandId) return null;
8
+ return {
9
+ assistant: {
10
+ sessionId,
11
+ commandId,
12
+ },
13
+ };
14
+ }
15
+
16
+ function normalizeOptionalString(value) {
17
+ if (value == null) return null;
18
+ const stringValue = String(value).trim();
19
+ return stringValue || null;
20
+ }
@@ -6,7 +6,6 @@ export function buildStatusArtifact({
6
6
  typeValues,
7
7
  suiteSelectors,
8
8
  fileNames,
9
- shard,
10
9
  serviceFilter,
11
10
  scenarioSeed,
12
11
  metadata,
@@ -67,7 +66,6 @@ export function buildStatusArtifact({
67
66
  types: [...(typeValues || ["all"])].sort(),
68
67
  suiteSelectors: [...(suiteSelectors || [])].map((selector) => selector.raw).sort(),
69
68
  fileNames: [...(fileNames || [])].sort(),
70
- shard: shard || null,
71
69
  serviceFilter: serviceFilter || null,
72
70
  scenarioSeed: scenarioSeed || null,
73
71
  };
@@ -76,11 +74,10 @@ export function buildStatusArtifact({
76
74
  scope.types[0] === "all" &&
77
75
  scope.suiteSelectors.length === 0 &&
78
76
  scope.fileNames.length === 0 &&
79
- scope.shard === null &&
80
77
  scope.serviceFilter === null;
81
78
 
82
79
  return {
83
- schemaVersion: 7,
80
+ schemaVersion: 8,
84
81
  source: "testkit",
85
82
  notice: "Generated file. Do not edit manually.",
86
83
  product: {
@@ -99,6 +96,7 @@ export function buildStatusArtifact({
99
96
 
100
97
  export function buildRunArtifact({
101
98
  productDir,
99
+ runId = null,
102
100
  results,
103
101
  startedAt,
104
102
  finishedAt,
@@ -109,9 +107,10 @@ export function buildRunArtifact({
109
107
  typeValues,
110
108
  suiteSelectors,
111
109
  fileNames,
112
- shard,
113
110
  serviceFilter,
114
111
  scenarioSeed,
112
+ planning = null,
113
+ provenance = null,
115
114
  metadata,
116
115
  summarizeDbBackend,
117
116
  serviceLogs = [],
@@ -134,7 +133,7 @@ export function buildRunArtifact({
134
133
  const dbBackend = summarizeDbBackend(results);
135
134
 
136
135
  return {
137
- schemaVersion: 9,
136
+ schemaVersion: 10,
138
137
  source: "testkit",
139
138
  generatedAt: new Date(finishedAt).toISOString(),
140
139
  product: {
@@ -144,6 +143,7 @@ export function buildRunArtifact({
144
143
  git: metadata.git,
145
144
  host: metadata.host,
146
145
  run: {
146
+ id: runId,
147
147
  status: runStatus || (failedServices.length > 0 ? "failed" : "passed"),
148
148
  startedAt: new Date(startedAt).toISOString(),
149
149
  finishedAt: new Date(finishedAt).toISOString(),
@@ -157,11 +157,12 @@ export function buildRunArtifact({
157
157
  types: typeValues,
158
158
  suiteSelectors: suiteSelectors.map((selector) => selector.raw),
159
159
  fileNames,
160
- shard,
161
160
  serviceFilter,
162
161
  scenarioSeed: scenarioSeed || null,
163
162
  testkitVersion: metadata.testkitVersion,
164
163
  },
164
+ ...(provenance ? { provenance } : {}),
165
+ ...(planning ? { planning } : {}),
165
166
  summary: {
166
167
  services: {
167
168
  total: executed.length,
@@ -216,6 +217,7 @@ export function buildRunArtifact({
216
217
 
217
218
  export function buildLiveRunArtifact({
218
219
  productDir,
220
+ runId = null,
219
221
  results,
220
222
  startedAt,
221
223
  updatedAt,
@@ -226,9 +228,10 @@ export function buildLiveRunArtifact({
226
228
  typeValues,
227
229
  suiteSelectors,
228
230
  fileNames,
229
- shard,
230
231
  serviceFilter,
231
232
  scenarioSeed,
233
+ planning = null,
234
+ provenance = null,
232
235
  metadata,
233
236
  summarizeDbBackend,
234
237
  serviceLogs = [],
@@ -237,6 +240,7 @@ export function buildLiveRunArtifact({
237
240
  }) {
238
241
  return buildRunArtifact({
239
242
  productDir,
243
+ runId,
240
244
  results,
241
245
  startedAt,
242
246
  finishedAt: updatedAt,
@@ -247,9 +251,10 @@ export function buildLiveRunArtifact({
247
251
  typeValues,
248
252
  suiteSelectors,
249
253
  fileNames,
250
- shard,
251
254
  serviceFilter,
252
255
  scenarioSeed,
256
+ planning,
257
+ provenance,
253
258
  metadata,
254
259
  summarizeDbBackend,
255
260
  serviceLogs,
@@ -9,6 +9,7 @@ import { shouldFailRegressionSync, validateRegressionIssues } from "../regressio
9
9
 
10
10
  export async function finalizeRunArtifacts({
11
11
  productDir,
12
+ runId,
12
13
  results,
13
14
  startedAt,
14
15
  finishedAt,
@@ -17,6 +18,7 @@ export async function finalizeRunArtifacts({
17
18
  runtimeInstanceCount,
18
19
  runtimeStats,
19
20
  selection,
21
+ provenance = null,
20
22
  metadata,
21
23
  logRegistry,
22
24
  setupRegistry,
@@ -28,6 +30,7 @@ export async function finalizeRunArtifacts({
28
30
  }) {
29
31
  const runArtifact = buildRunArtifact({
30
32
  productDir,
33
+ runId,
31
34
  results,
32
35
  startedAt,
33
36
  finishedAt,
@@ -38,9 +41,10 @@ export async function finalizeRunArtifacts({
38
41
  typeValues: selection.typeValues,
39
42
  suiteSelectors: selection.suiteSelectors,
40
43
  fileNames: selection.fileNames,
41
- shard: selection.shard,
42
44
  serviceFilter: selection.serviceFilter,
43
45
  scenarioSeed: selection.scenarioSeed,
46
+ planning: selection.planning || null,
47
+ provenance,
44
48
  metadata,
45
49
  summarizeDbBackend,
46
50
  serviceLogs: logRegistry.listServiceLogs(),
@@ -54,7 +58,6 @@ export async function finalizeRunArtifacts({
54
58
  typeValues: selection.typeValues,
55
59
  suiteSelectors: selection.suiteSelectors,
56
60
  fileNames: selection.fileNames,
57
- shard: selection.shard,
58
61
  serviceFilter: selection.serviceFilter,
59
62
  scenarioSeed: selection.scenarioSeed,
60
63
  metadata,
@@ -27,7 +27,6 @@ export function ensureStatusWriteAllowed(opts, typeValues, suiteSelectors, reque
27
27
  typeValues,
28
28
  suiteSelectors,
29
29
  requestedFiles,
30
- opts.shard || null,
31
30
  opts.serviceFilter || null
32
31
  )
33
32
  ) {
@@ -0,0 +1,61 @@
1
+ export const DEFAULT_ESTIMATE_SOURCE = "default";
2
+
3
+ export function estimateTask({ task, suite, timings, history } = {}) {
4
+ const timingEstimate = estimateFromTimings(timings, task);
5
+ if (timingEstimate) return timingEstimate;
6
+
7
+ const historyEstimate = estimateFromHistory(history, task);
8
+ if (historyEstimate) return historyEstimate;
9
+
10
+ return {
11
+ durationMs: estimateDefaultDuration(task, suite),
12
+ source: DEFAULT_ESTIMATE_SOURCE,
13
+ confidence: "low",
14
+ runs: 0,
15
+ key: null,
16
+ };
17
+ }
18
+
19
+ export function estimateDefaultDuration(task = {}, suite = {}) {
20
+ const base =
21
+ task.framework === "playwright" || suite.framework === "playwright"
22
+ ? 20_000
23
+ : task.type === "dal" || suite.type === "dal"
24
+ ? 4_000
25
+ : 8_000;
26
+ const suiteWeight = Number(suite.weight || 1);
27
+ const suiteFileCount = Math.max(1, Number(suite.files?.length || 1));
28
+ return Math.max(1_000, Math.round((base * suiteWeight) / suiteFileCount));
29
+ }
30
+
31
+ function estimateFromTimings(timings, task = {}) {
32
+ const candidates = [task.timingKey, ...(task.legacyTimingKeys || [])].filter(Boolean);
33
+ for (const key of candidates) {
34
+ const entry = timings?.files?.[key];
35
+ const durationMs = Number(entry?.durationMs || entry?.avgDurationMs || 0);
36
+ if (durationMs <= 0) continue;
37
+ const runs = Number(entry.runs || 0);
38
+ return {
39
+ durationMs: Math.max(1, Math.round(durationMs)),
40
+ source: "timings",
41
+ confidence: runs >= 3 ? "high" : "medium",
42
+ runs,
43
+ key,
44
+ };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function estimateFromHistory(history, task = {}) {
50
+ const entry = history?.tests?.[task.historyKey];
51
+ const durationMs = Number(entry?.avgDurationMs || 0);
52
+ if (durationMs <= 0) return null;
53
+ const runs = Number(entry.durationCount || entry.runCount || 0);
54
+ return {
55
+ durationMs: Math.max(1, Math.round(durationMs)),
56
+ source: "history",
57
+ confidence: runs >= 3 ? "medium" : "low",
58
+ runs,
59
+ key: task.historyKey,
60
+ };
61
+ }
@@ -0,0 +1,31 @@
1
+ import path from "path";
2
+
3
+ export function normalizeTaskPath(filePath) {
4
+ return String(filePath || "").split(path.sep).join("/").replace(/^\.\/+/, "");
5
+ }
6
+
7
+ export function buildSchedulerTaskKey({ serviceName, displayType, type, framework, file } = {}) {
8
+ return [
9
+ serviceName || "service",
10
+ displayType || type || "unknown",
11
+ framework || "k6",
12
+ normalizeTaskPath(file),
13
+ ].join("|");
14
+ }
15
+
16
+ export function buildLegacyTimingKey({ serviceName, type, framework, file } = {}) {
17
+ return [
18
+ serviceName || "service",
19
+ framework || "k6",
20
+ type || "unknown",
21
+ normalizeTaskPath(file),
22
+ ].join("|");
23
+ }
24
+
25
+ export function buildSchedulerHistoryKey({ serviceName, displayType, type, file } = {}) {
26
+ return [
27
+ serviceName || "service",
28
+ displayType || type || "unknown",
29
+ normalizeTaskPath(file),
30
+ ].join("|");
31
+ }
@@ -0,0 +1,126 @@
1
+ import {
2
+ buildLegacyTimingKey,
3
+ buildSchedulerHistoryKey,
4
+ buildSchedulerTaskKey,
5
+ } from "./identity.mjs";
6
+ import { estimateTask } from "./estimates.mjs";
7
+ import { matchesSuiteSelectors } from "../suite-selection.mjs";
8
+
9
+ export const SCHEDULER_POLICY = "longest-estimated-duration-first";
10
+ export const SCHEDULER_VERSION = 1;
11
+
12
+ export function buildScheduledQueue(servicePlans, graphs, { timings, history } = {}) {
13
+ const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
14
+ const tasks = [];
15
+ let nextId = 1;
16
+
17
+ for (const plan of servicePlans) {
18
+ if (plan.skipped) continue;
19
+
20
+ const graph = graphByKey.get(plan.assignedGraphKey);
21
+ if (!graph) {
22
+ throw new Error(`Assigned graph "${plan.assignedGraphKey}" not found for ${plan.config.name}`);
23
+ }
24
+
25
+ for (const suite of plan.suites) {
26
+ for (const file of suite.files) {
27
+ const baseTask = {
28
+ id: nextId,
29
+ graphKey: graph.key,
30
+ targetName: plan.config.name,
31
+ serviceName: plan.config.name,
32
+ suiteKey: `${suite.type}:${suite.name}`,
33
+ suiteName: suite.name,
34
+ type: suite.type,
35
+ displayType: suite.displayType || suite.type,
36
+ framework: suite.framework,
37
+ orderIndex: suite.orderIndex,
38
+ file,
39
+ locks: resolveTaskLocks(plan.config, suite, file),
40
+ resourceCost: 1,
41
+ };
42
+ const timingKey = buildSchedulerTaskKey(baseTask);
43
+ const task = {
44
+ ...baseTask,
45
+ timingKey,
46
+ legacyTimingKeys: [buildLegacyTimingKey(baseTask)].filter((key) => key !== timingKey),
47
+ historyKey: buildSchedulerHistoryKey(baseTask),
48
+ };
49
+ const estimate = estimateTask({ task, suite, timings, history });
50
+ tasks.push({
51
+ ...task,
52
+ estimatedDurationMs: estimate.durationMs,
53
+ estimateSource: estimate.source,
54
+ estimateConfidence: estimate.confidence,
55
+ estimateRuns: estimate.runs,
56
+ estimateKey: estimate.key,
57
+ });
58
+ nextId += 1;
59
+ }
60
+ }
61
+ }
62
+
63
+ return tasks
64
+ .sort(compareScheduledTasks)
65
+ .map((task, index) => ({
66
+ ...task,
67
+ schedulerRank: index + 1,
68
+ schedulerPolicy: SCHEDULER_POLICY,
69
+ }));
70
+ }
71
+
72
+ export function buildRunPlanningMetadata(queue) {
73
+ return {
74
+ scheduler: {
75
+ policy: SCHEDULER_POLICY,
76
+ version: SCHEDULER_VERSION,
77
+ },
78
+ tasks: (queue || []).map((task) => ({
79
+ rank: task.schedulerRank || null,
80
+ id: task.id,
81
+ service: task.serviceName,
82
+ suite: task.suiteName,
83
+ type: task.displayType || task.type,
84
+ framework: task.framework,
85
+ file: task.file,
86
+ graphKey: task.graphKey,
87
+ estimatedDurationMs: task.estimatedDurationMs,
88
+ estimateSource: task.estimateSource,
89
+ estimateConfidence: task.estimateConfidence,
90
+ estimateRuns: task.estimateRuns || 0,
91
+ timingKey: task.timingKey,
92
+ historyKey: task.historyKey,
93
+ })),
94
+ };
95
+ }
96
+
97
+ export function compareScheduledTasks(a, b) {
98
+ return (
99
+ b.estimatedDurationMs - a.estimatedDurationMs ||
100
+ a.serviceName.localeCompare(b.serviceName) ||
101
+ a.suiteKey.localeCompare(b.suiteKey) ||
102
+ a.file.localeCompare(b.file)
103
+ );
104
+ }
105
+
106
+ function resolveTaskLocks(config, suite, file) {
107
+ const locks = new Set();
108
+ const matchedSuiteRules = config.testkit.requirements?.suites || [];
109
+ for (const rule of matchedSuiteRules) {
110
+ if (matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])) {
111
+ for (const lockName of rule.locks || []) {
112
+ locks.add(lockName);
113
+ }
114
+ }
115
+ }
116
+
117
+ const normalizedFile = String(file).split("\\").join("/");
118
+ const fileMetadata = config.testkit.fileMetadataByPath?.get(normalizedFile) || null;
119
+ for (const lockName of config.testkit.requirements?.fileLocksByPath?.get(normalizedFile) || []) {
120
+ locks.add(lockName);
121
+ }
122
+ for (const lockName of fileMetadata?.locks || []) {
123
+ locks.add(lockName);
124
+ }
125
+ return [...locks].sort();
126
+ }
@@ -0,0 +1,27 @@
1
+ export function buildTimingObservation(task, outcome = {}) {
2
+ const status = normalizeOutcomeStatus(outcome);
3
+ if (status === "skipped" || status === "not_run") return null;
4
+
5
+ const durationMs = Number(outcome.durationMs || 0);
6
+ if (durationMs <= 0) return null;
7
+
8
+ return {
9
+ key: task.timingKey,
10
+ legacyKeys: task.legacyTimingKeys || [],
11
+ durationMs,
12
+ status,
13
+ startedAt: outcome.startedAt || null,
14
+ finishedAt: outcome.finishedAt || null,
15
+ estimate: {
16
+ durationMs: task.estimatedDurationMs || null,
17
+ source: task.estimateSource || null,
18
+ confidence: task.estimateConfidence || null,
19
+ },
20
+ };
21
+ }
22
+
23
+ function normalizeOutcomeStatus(outcome = {}) {
24
+ if (outcome.status === "skipped") return "skipped";
25
+ if (outcome.status === "not_run") return "not_run";
26
+ return outcome.failed ? "failed" : "passed";
27
+ }
@@ -23,13 +23,12 @@ export function findUnmatchedRequestedFiles(
23
23
  );
24
24
  }
25
25
 
26
- export function isFullRunSelection(typeValues, suiteSelectors, fileNames, shard, serviceFilter) {
26
+ export function isFullRunSelection(typeValues, suiteSelectors, fileNames, serviceFilter) {
27
27
  return (
28
28
  (typeValues || []).length === 1 &&
29
29
  typeValues[0] === "all" &&
30
30
  (suiteSelectors || []).length === 0 &&
31
31
  (fileNames || []).length === 0 &&
32
- (shard || null) === null &&
33
32
  (serviceFilter || null) === null
34
33
  );
35
34
  }