@blogic-cz/agent-tools 1.2.2 → 1.4.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.
Files changed (47) hide show
  1. package/README.md +40 -0
  2. package/dist/gh-tool/api.d.ts +16 -0
  3. package/dist/gh-tool/api.d.ts.map +1 -0
  4. package/dist/gh-tool/errors.d.ts +1 -0
  5. package/dist/gh-tool/errors.d.ts.map +1 -1
  6. package/dist/gh-tool/pr/commands.d.ts +2 -1
  7. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  8. package/dist/gh-tool/pr/core.d.ts +1 -0
  9. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  10. package/dist/gh-tool/pr/index.d.ts +1 -1
  11. package/dist/gh-tool/pr/index.d.ts.map +1 -1
  12. package/dist/gh-tool/pr/stack-read.d.ts +7 -0
  13. package/dist/gh-tool/pr/stack-read.d.ts.map +1 -0
  14. package/dist/gh-tool/pr/stack.d.ts +53 -0
  15. package/dist/gh-tool/pr/stack.d.ts.map +1 -0
  16. package/dist/gh-tool/service.d.ts +2 -0
  17. package/dist/gh-tool/service.d.ts.map +1 -1
  18. package/dist/gh-tool/types.d.ts +38 -0
  19. package/dist/gh-tool/types.d.ts.map +1 -1
  20. package/dist/observability-tool/shared.d.ts.map +1 -1
  21. package/dist/observability-tool/trace.d.ts +5 -0
  22. package/dist/observability-tool/trace.d.ts.map +1 -1
  23. package/dist/observability-tool/types.d.ts +8 -0
  24. package/dist/observability-tool/types.d.ts.map +1 -1
  25. package/dist/shared/index.d.ts +2 -0
  26. package/dist/shared/index.d.ts.map +1 -1
  27. package/dist/shared/poll-until-resolved.d.ts +9 -0
  28. package/dist/shared/poll-until-resolved.d.ts.map +1 -0
  29. package/dist/shared/retry-transient.d.ts +7 -0
  30. package/dist/shared/retry-transient.d.ts.map +1 -0
  31. package/package.json +1 -1
  32. package/src/gh-tool/api.ts +166 -0
  33. package/src/gh-tool/errors.ts +2 -0
  34. package/src/gh-tool/index.ts +2 -0
  35. package/src/gh-tool/pr/commands.ts +96 -0
  36. package/src/gh-tool/pr/core.ts +79 -21
  37. package/src/gh-tool/pr/index.ts +1 -0
  38. package/src/gh-tool/pr/stack-read.ts +73 -0
  39. package/src/gh-tool/pr/stack.ts +284 -0
  40. package/src/gh-tool/service.ts +22 -16
  41. package/src/gh-tool/types.ts +38 -0
  42. package/src/observability-tool/shared.ts +5 -0
  43. package/src/observability-tool/trace.ts +158 -1
  44. package/src/observability-tool/types.ts +9 -0
  45. package/src/shared/index.ts +2 -0
  46. package/src/shared/poll-until-resolved.ts +39 -0
  47. package/src/shared/retry-transient.ts +24 -0
@@ -0,0 +1,284 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { pollUntilResolved } from "#shared/poll-until-resolved";
4
+ import { GitHubService } from "#gh/service";
5
+ import { GitHubMergeError } from "#gh/errors";
6
+
7
+ import type { MergeStrategy, StackMember, StackMergeBlocker, StackMergeResult } from "#gh/types";
8
+
9
+ import { fetchCheckResults, fetchPRView } from "./core";
10
+ import { readStack } from "./stack-read";
11
+
12
+ export { readStack };
13
+
14
+ type AsyncMergeDetails = {
15
+ message?: string;
16
+ uuid?: string;
17
+ sha?: string;
18
+ merge_method?: MergeStrategy;
19
+ };
20
+
21
+ type AsyncMergeResult = {
22
+ status: "pending" | "merged" | "enqueued" | "failed";
23
+ details?: AsyncMergeDetails;
24
+ };
25
+
26
+ // One request merges every open member, so the server-side work scales with the stack;
27
+ // the single-PR budget in core.ts is deliberately shorter.
28
+ const POLL_INTERVAL_MS = 2000;
29
+ const MAX_WAIT_SECONDS = 300;
30
+
31
+ const stackMergeFailure = (opts: {
32
+ stackNumber: number;
33
+ pr: number;
34
+ message: string;
35
+ hint: string;
36
+ reason?: GitHubMergeError["reason"];
37
+ }) =>
38
+ new GitHubMergeError({
39
+ message: `Failed to merge stack #${opts.stackNumber}: ${opts.message}`,
40
+ reason: opts.reason ?? "unknown",
41
+ hint: opts.hint,
42
+ nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
43
+ });
44
+
45
+ const collectBlockers = Effect.fn("pr.collectStackBlockers")(function* (members: StackMember[]) {
46
+ const blockers: StackMergeBlocker[] = [];
47
+
48
+ for (const member of members) {
49
+ if (member.isDraft) {
50
+ blockers.push({ number: member.number, reason: "draft", detail: "PR is a draft" });
51
+ continue;
52
+ }
53
+
54
+ const info = yield* fetchPRView(member.number);
55
+ if (info.mergeable === "CONFLICTING") {
56
+ blockers.push({
57
+ number: member.number,
58
+ reason: "not_mergeable",
59
+ detail: "PR has merge conflicts",
60
+ });
61
+ continue;
62
+ }
63
+
64
+ if (info.mergeable !== "MERGEABLE") {
65
+ blockers.push({
66
+ number: member.number,
67
+ reason: "mergeability_unknown",
68
+ detail: `GitHub has not settled mergeability yet (${info.mergeable})`,
69
+ });
70
+ continue;
71
+ }
72
+
73
+ const checks = yield* fetchCheckResults(member.number);
74
+ const failing = checks.filter((check) => check.bucket === "fail");
75
+ if (failing.length > 0) {
76
+ blockers.push({
77
+ number: member.number,
78
+ reason: "checks_failing",
79
+ detail: `${failing.length} failing check(s): ${failing.map((c) => c.name).join(", ")}`,
80
+ });
81
+ continue;
82
+ }
83
+
84
+ const pending = checks.filter((check) => check.bucket === "pending");
85
+ if (pending.length > 0) {
86
+ blockers.push({
87
+ number: member.number,
88
+ reason: "checks_pending",
89
+ detail: `${pending.length} check(s) still running`,
90
+ });
91
+ }
92
+ }
93
+
94
+ return blockers;
95
+ });
96
+
97
+ export const mergeStack = Effect.fn("pr.mergeStack")(function* (opts: {
98
+ pr: number;
99
+ strategy: MergeStrategy;
100
+ confirm: boolean;
101
+ }) {
102
+ const gh = yield* GitHubService;
103
+ const repo = yield* gh.getRepoInfo();
104
+ const view = yield* readStack({ pr: opts.pr });
105
+
106
+ if (!view.isStacked || view.stackNumber === null || view.baseRef === null) {
107
+ return yield* new GitHubMergeError({
108
+ message: `PR #${opts.pr} is not part of a GitHub stack`,
109
+ reason: "unknown",
110
+ hint: "Use 'pr merge' for an unstacked PR. A chain of PRs based on each other is only a stack when GitHub has registered it as one.",
111
+ nextCommand: `agent-tools-gh pr merge --pr ${opts.pr}`,
112
+ });
113
+ }
114
+
115
+ const unmerged = view.members.filter((member) => member.state === "open");
116
+ if (unmerged.length === 0) {
117
+ return yield* new GitHubMergeError({
118
+ message: `Stack #${view.stackNumber} has no open pull requests left`,
119
+ reason: "unknown",
120
+ hint: "Every member is already merged or closed.",
121
+ });
122
+ }
123
+
124
+ // merge-async merges every unmerged PR up to and including the requested one, so the
125
+ // top open member is the request that lands the whole stack.
126
+ const target = unmerged[unmerged.length - 1] as StackMember;
127
+
128
+ const blockers = yield* collectBlockers(unmerged);
129
+
130
+ const plan = unmerged.map((member) => ({
131
+ position: member.position,
132
+ number: member.number,
133
+ headRefName: member.headRefName,
134
+ }));
135
+
136
+ const base = {
137
+ stackNumber: view.stackNumber,
138
+ baseRef: view.baseRef,
139
+ target: target.number,
140
+ plan,
141
+ blockers,
142
+ };
143
+
144
+ if (!opts.confirm) {
145
+ return {
146
+ ...base,
147
+ strategy: opts.strategy,
148
+ merged: false,
149
+ dryRun: true,
150
+ sha: null,
151
+ adoptedExistingRequest: false,
152
+ } satisfies StackMergeResult;
153
+ }
154
+
155
+ if (blockers.length > 0) {
156
+ return yield* new GitHubMergeError({
157
+ message:
158
+ `Stack #${view.stackNumber} is not ready: ` +
159
+ blockers.map((b) => `#${b.number} ${b.detail}`).join("; "),
160
+ reason: "unknown",
161
+ hint: "A partial stack merge leaves a parent on the trunk and a broken child, so nothing was attempted.",
162
+ });
163
+ }
164
+
165
+ const asyncPath = `repos/${repo.owner}/${repo.name}/pulls/${target.number}/merge-async`;
166
+
167
+ const requested = yield* gh.apiRequest<AsyncMergeResult>({
168
+ path: asyncPath,
169
+ method: "PUT",
170
+ body: { merge_method: opts.strategy, merge_action: "direct_merge" },
171
+ alsoAcceptStatus: [202, 409],
172
+ });
173
+
174
+ // A 409 hands back an existing request whose options may differ from the ones asked
175
+ // for, so the result reports the strategy GitHub is actually applying.
176
+ const adoptedExistingRequest = requested.status === 409;
177
+ let latest = requested.body;
178
+ const effectiveStrategy = latest.details?.merge_method ?? opts.strategy;
179
+
180
+ const uuid = latest.details?.uuid;
181
+ if (latest.status === "pending" && (uuid === undefined || uuid.length === 0)) {
182
+ return yield* stackMergeFailure({
183
+ stackNumber: view.stackNumber,
184
+ pr: target.number,
185
+ message: "GitHub reported a pending merge without a request id",
186
+ hint: "The merge may or may not be running. Re-read the stack before retrying so the merge is not requested twice.",
187
+ });
188
+ }
189
+
190
+ if (uuid !== undefined) {
191
+ latest = yield* pollUntilResolved({
192
+ initial: latest,
193
+ isPending: (value) => value.status === "pending",
194
+ fetchLatest: () =>
195
+ gh
196
+ .apiRequest<AsyncMergeResult>({ path: `${asyncPath}/${uuid}` })
197
+ .pipe(Effect.map((response) => response.body)),
198
+ intervalMs: POLL_INTERVAL_MS,
199
+ budgetSeconds: MAX_WAIT_SECONDS,
200
+ });
201
+ }
202
+
203
+ if (latest.status === "merged") {
204
+ return {
205
+ ...base,
206
+ strategy: effectiveStrategy,
207
+ merged: true,
208
+ dryRun: false,
209
+ sha: latest.details?.sha ?? null,
210
+ adoptedExistingRequest,
211
+ } satisfies StackMergeResult;
212
+ }
213
+
214
+ if (latest.status === "enqueued") {
215
+ return yield* stackMergeFailure({
216
+ stackNumber: view.stackNumber,
217
+ pr: target.number,
218
+ reason: "merge_queue",
219
+ message: latest.details?.message ?? "the stack entered a merge queue",
220
+ hint: "The merge queue owns the merge from here; it is not merged yet. Watch the PRs until the queue drains.",
221
+ });
222
+ }
223
+
224
+ if (latest.status === "pending") {
225
+ return yield* stackMergeFailure({
226
+ stackNumber: view.stackNumber,
227
+ pr: target.number,
228
+ message: `still pending after ${MAX_WAIT_SECONDS}s`,
229
+ hint: "The asynchronous merge is still running. Re-check the stack before retrying so the merge is not requested twice.",
230
+ });
231
+ }
232
+
233
+ return yield* stackMergeFailure({
234
+ stackNumber: view.stackNumber,
235
+ pr: target.number,
236
+ message: latest.details?.message ?? "the merge request failed",
237
+ hint: "Inspect the stack state and branch protections, then retry.",
238
+ });
239
+ });
240
+
241
+ export const unstackStack = Effect.fn("pr.unstackStack")(function* (opts: {
242
+ pr: number;
243
+ confirm: boolean;
244
+ }) {
245
+ const gh = yield* GitHubService;
246
+ const repo = yield* gh.getRepoInfo();
247
+ const view = yield* readStack({ pr: opts.pr });
248
+
249
+ if (!view.isStacked || view.stackNumber === null) {
250
+ return yield* new GitHubMergeError({
251
+ message: `PR #${opts.pr} is not part of a GitHub stack`,
252
+ reason: "unknown",
253
+ hint: "There is no stack to dissolve.",
254
+ nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
255
+ });
256
+ }
257
+
258
+ const unmerged = view.members.filter((member) => member.state === "open");
259
+
260
+ const plan = {
261
+ stackNumber: view.stackNumber,
262
+ pr: opts.pr,
263
+ removes: unmerged.map((member) => member.number),
264
+ };
265
+
266
+ if (!opts.confirm) {
267
+ return { ...plan, dissolved: false, unstacked: false, dryRun: true };
268
+ }
269
+
270
+ // The endpoint removes every unmerged member at once; it has no per-PR form. A 204 means
271
+ // nothing was left and the stack is gone, a 200 means some members could not be removed.
272
+ const result = yield* gh.apiRequest<unknown>({
273
+ path: `repos/${repo.owner}/${repo.name}/stacks/${view.stackNumber}/unstack`,
274
+ method: "POST",
275
+ alsoAcceptStatus: [204],
276
+ });
277
+
278
+ return {
279
+ ...plan,
280
+ dissolved: result.status === 204,
281
+ unstacked: true,
282
+ dryRun: false,
283
+ };
284
+ });
@@ -1,11 +1,14 @@
1
1
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
- import { Context, Duration, Effect, Layer, Stream } from "effect";
2
+ import { Context, Effect, Layer, Stream } from "effect";
3
3
 
4
4
  import type { GitHubRepoConfig } from "#config";
5
5
  import type { RepoInfo } from "./types";
6
6
 
7
7
  import { GH_BINARY } from "./config";
8
8
  import { GitHubAuthError, GitHubCommandError, GitHubNotFoundError } from "./errors";
9
+ import { retryTransient } from "#shared/retry-transient";
10
+ import { githubApi } from "./api";
11
+ import type { GitHubApiRequest, GitHubApiResponse } from "./api";
9
12
  import { ConfigService, getGitHubConfig, resolveGitHubRepoTarget } from "#config";
10
13
 
11
14
  // Transient GitHub-side failures worth a silent retry (vs. a hard error the agent must act on).
@@ -82,6 +85,9 @@ export class GitHubService extends Context.Service<
82
85
  query: string,
83
86
  variables: Record<string, string | number | null>,
84
87
  ) => Effect.Effect<unknown, GhError>;
88
+ readonly apiRequest: <T>(
89
+ opts: GitHubApiRequest,
90
+ ) => Effect.Effect<GitHubApiResponse<T>, GhError>;
85
91
  readonly getRepoConfig: () => Effect.Effect<GitHubRepoConfig | undefined, never>;
86
92
  readonly getRepoInfo: () => Effect.Effect<RepoInfo, GhError>;
87
93
  readonly withRepoTarget: <A, E, R>(
@@ -261,20 +267,12 @@ export class GitHubService extends Context.Service<
261
267
  // Auto-retry transient failures, but only for idempotent reads (never replay a mutation).
262
268
  const runGh = (args: string[]): Effect.Effect<GhResult, GhError> => {
263
269
  const canRetry = isSafeRetryRead(args);
264
- const loop = (attempt: number): Effect.Effect<GhResult, GhError> =>
265
- runGhAttempt(args).pipe(
266
- Effect.catch((err) => {
267
- const retryable =
268
- err instanceof GitHubCommandError && err.retryable === true && canRetry;
269
- if (retryable && attempt < MAX_GH_RETRIES) {
270
- return Effect.sleep(Duration.millis(500 * 2 ** attempt)).pipe(
271
- Effect.flatMap(() => loop(attempt + 1)),
272
- );
273
- }
274
- return Effect.fail(err);
275
- }),
276
- );
277
- return loop(0);
270
+ return retryTransient({
271
+ attempt: () => runGhAttempt(args),
272
+ isTransient: (err) =>
273
+ err instanceof GitHubCommandError && err.retryable === true && canRetry,
274
+ maxRetries: MAX_GH_RETRIES,
275
+ });
278
276
  };
279
277
 
280
278
  const runGhJson = <T>(args: string[]) =>
@@ -365,7 +363,15 @@ export class GitHubService extends Context.Service<
365
363
  return repoInfo;
366
364
  });
367
365
 
368
- return { runGh, runGhJson, runGraphQL, getRepoConfig, getRepoInfo, withRepoTarget };
366
+ return {
367
+ runGh,
368
+ runGhJson,
369
+ runGraphQL,
370
+ apiRequest: githubApi,
371
+ getRepoConfig,
372
+ getRepoInfo,
373
+ withRepoTarget,
374
+ };
369
375
  }),
370
376
  ),
371
377
  );
@@ -314,3 +314,41 @@ export type JobAnnotations = {
314
314
  jobName: string;
315
315
  annotations: CheckRunAnnotation[];
316
316
  };
317
+
318
+ export type StackMember = {
319
+ position: number;
320
+ number: number;
321
+ title: string;
322
+ headRefName: string;
323
+ baseRefName: string;
324
+ state: "open" | "closed" | "merged";
325
+ isDraft: boolean;
326
+ url: string;
327
+ };
328
+
329
+ export type StackView = {
330
+ pr: number;
331
+ isStacked: boolean;
332
+ stackNumber: number | null;
333
+ baseRef: string | null;
334
+ members: StackMember[];
335
+ };
336
+
337
+ export type StackMergeBlocker = {
338
+ number: number;
339
+ reason: "draft" | "not_mergeable" | "mergeability_unknown" | "checks_failing" | "checks_pending";
340
+ detail: string;
341
+ };
342
+
343
+ export type StackMergeResult = {
344
+ stackNumber: number;
345
+ baseRef: string;
346
+ target: number;
347
+ strategy: MergeStrategy;
348
+ plan: Array<{ position: number; number: number; headRefName: string }>;
349
+ merged: boolean;
350
+ dryRun: boolean;
351
+ blockers: StackMergeBlocker[];
352
+ sha: string | null;
353
+ adoptedExistingRequest: boolean;
354
+ };
@@ -26,6 +26,11 @@ export function formatObservabilityError(error: unknown): string {
26
26
  return error.message;
27
27
  }
28
28
 
29
+ // Structured causes carry their text in `message`; String() would render "[object Object]".
30
+ if (typeof error === "object" && error !== null && "message" in error) {
31
+ return String((error as { message: unknown }).message);
32
+ }
33
+
29
34
  return String(error);
30
35
  }
31
36
 
@@ -25,6 +25,7 @@ import type {
25
25
  SpanResolution,
26
26
  TempoSearchResponse,
27
27
  TempoTraceResponse,
28
+ TraceSearchHit,
28
29
  TraceSummary,
29
30
  } from "./types";
30
31
 
@@ -355,6 +356,95 @@ function resolveTraceFromId(
355
356
  });
356
357
  }
357
358
 
359
+ /**
360
+ * Strict counterpart of relativeToEpoch for user-supplied windows: an unparseable bound is
361
+ * refused rather than silently collapsed to "now", which would report a zero-width window as
362
+ * "no traces found".
363
+ */
364
+ function strictRelativeToEpoch(value: string, nowEpoch: number): number | undefined {
365
+ const trimmed = value.trim();
366
+ if (trimmed === "now") return nowEpoch;
367
+ return /^now-\d+[smhd]$/.test(trimmed) ? relativeToEpoch(trimmed, nowEpoch) : undefined;
368
+ }
369
+
370
+ /** Tempo rejects a search window wider than this, so the CLI says so before the API does. */
371
+ const MAX_SEARCH_RANGE_SECONDS = 168 * 3600;
372
+
373
+ export function summarizeSearchHits(response: TempoSearchResponse): TraceSearchHit[] {
374
+ return (response.traces ?? [])
375
+ .filter((trace) => trace.traceID !== undefined)
376
+ .map((trace) => {
377
+ const startedNano = nanoToBigInt(trace.startTimeUnixNano);
378
+
379
+ return {
380
+ traceId: trace.traceID as string,
381
+ startedAt:
382
+ startedNano === undefined
383
+ ? undefined
384
+ : new Date(Number(startedNano / 1_000_000n)).toISOString(),
385
+ rootServiceName: trace.rootServiceName,
386
+ rootTraceName: trace.rootTraceName,
387
+ durationMs: trace.durationMs,
388
+ matchedSpans: trace.spanSets?.reduce(
389
+ (total, spanSet) => total + (spanSet.matched ?? spanSet.spans?.length ?? 0),
390
+ 0,
391
+ ),
392
+ };
393
+ })
394
+ .toSorted((left, right) => (right.startedAt ?? "").localeCompare(left.startedAt ?? ""));
395
+ }
396
+
397
+ export function searchTempoByQuery(
398
+ config: ObservabilityEnvConfig,
399
+ query: string,
400
+ window: SearchWindow,
401
+ limit: number,
402
+ ): Effect.Effect<TempoSearchResponse, ObservabilityToolError> {
403
+ return Effect.gen(function* () {
404
+ const tempoUid = yield* requireTempoUid(config);
405
+ const now = Math.floor(Date.now() / 1000);
406
+ const startEpoch = strictRelativeToEpoch(window.start, now);
407
+ const endEpoch = strictRelativeToEpoch(window.end, now);
408
+
409
+ if (startEpoch === undefined || endEpoch === undefined) {
410
+ const rejected = startEpoch === undefined ? window.start : window.end;
411
+ return yield* new ObservabilityToolError({
412
+ cause: {
413
+ message: `Unparseable time "${rejected}" — use "now" or "now-<number><s|m|h|d>", e.g. now-6h`,
414
+ code: "INVALID_TIME_RANGE",
415
+ retryable: false,
416
+ },
417
+ });
418
+ }
419
+
420
+ if (startEpoch >= endEpoch) {
421
+ return yield* new ObservabilityToolError({
422
+ cause: {
423
+ message: `Search window ${window.start} → ${window.end} ends at or before it starts`,
424
+ code: "INVALID_TIME_RANGE",
425
+ retryable: false,
426
+ },
427
+ });
428
+ }
429
+
430
+ if (endEpoch - startEpoch > MAX_SEARCH_RANGE_SECONDS) {
431
+ return yield* new ObservabilityToolError({
432
+ cause: {
433
+ message: `Search window ${window.start} → ${window.end} exceeds the 168h Tempo limit — query a narrower range`,
434
+ code: "SEARCH_RANGE_TOO_WIDE",
435
+ retryable: false,
436
+ },
437
+ });
438
+ }
439
+
440
+ const searchUrl =
441
+ `/api/datasources/proxy/uid/${tempoUid}/api/search` +
442
+ `?q=${encodeURIComponent(query)}&start=${startEpoch}&end=${endEpoch}&limit=${limit}`;
443
+
444
+ return yield* observabilityFetch<TempoSearchResponse>(config, searchUrl);
445
+ });
446
+ }
447
+
358
448
  function handleTraceGet(
359
449
  id: string,
360
450
  format: OutputFormat,
@@ -526,7 +616,74 @@ const findCommand = Command.make(
526
616
  ({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
527
617
  ).pipe(Command.withDescription("Alias for 'trace get' — resolve a trace by trace ID or span ID"));
528
618
 
619
+ const searchCommand = Command.make(
620
+ "search",
621
+ {
622
+ query: Argument.string("query"),
623
+ format: formatOption,
624
+ env: envOption,
625
+ profile: profileOption,
626
+ limit: Flag.integer("limit").pipe(
627
+ Flag.withDescription("Max traces to return (default: 20)"),
628
+ Flag.withDefault(20),
629
+ ),
630
+ start: Flag.string("start").pipe(
631
+ Flag.withDescription("Start time (default: now-1h, max span now-168h)"),
632
+ Flag.withDefault("now-1h"),
633
+ ),
634
+ end: Flag.string("end").pipe(
635
+ Flag.withDescription("End time (default: now)"),
636
+ Flag.withDefault("now"),
637
+ ),
638
+ },
639
+ ({ query, format, env, profile, limit, start, end }) => {
640
+ const startedAt = Date.now();
641
+
642
+ return Effect.gen(function* () {
643
+ const config = yield* resolveConfig(env, profile);
644
+ const response = yield* searchTempoByQuery(config, query, { start, end }, limit);
645
+ const traces = summarizeSearchHits(response);
646
+
647
+ const result = {
648
+ success: true,
649
+ message: `Found ${traces.length} trace(s) matching the TraceQL query`,
650
+ data: {
651
+ environment: env,
652
+ grafanaUrl: config.url,
653
+ tempoDatasourceUid: config.tempoUid ?? null,
654
+ query,
655
+ start,
656
+ end,
657
+ limit,
658
+ traceCount: traces.length,
659
+ traces,
660
+ },
661
+ executionTimeMs: Date.now() - startedAt,
662
+ };
663
+
664
+ yield* logText(formatOutput(result, format));
665
+ }).pipe(
666
+ Effect.catch((error) =>
667
+ Effect.gen(function* () {
668
+ const result = {
669
+ success: false,
670
+ message: "Failed to search Tempo",
671
+ error: formatObservabilityError(error),
672
+ hint: 'Query is TraceQL, e.g. { name = "GraphQL Document Validation" && status = error }. The window may not exceed 168h',
673
+ executionTimeMs: Date.now() - startedAt,
674
+ };
675
+ yield* logText(formatOutput(result, format));
676
+ }),
677
+ ),
678
+ );
679
+ },
680
+ ).pipe(
681
+ Command.withDescription(
682
+ "Search Tempo with a TraceQL query — find traces when no trace or span ID is known yet",
683
+ ),
684
+ );
685
+
529
686
  export const traceCommand = Command.make("trace", {}).pipe(
530
687
  Command.withDescription("Tempo trace operations"),
531
- Command.withSubcommands([getCommand, logsCommand, findCommand]),
688
+ Command.withSubcommands([getCommand, searchCommand, logsCommand, findCommand]),
532
689
  );
@@ -127,6 +127,15 @@ export type TempoSearchResponse = {
127
127
  readonly metrics?: Record<string, unknown>;
128
128
  };
129
129
 
130
+ export type TraceSearchHit = {
131
+ readonly traceId: string;
132
+ readonly startedAt?: string;
133
+ readonly rootServiceName?: string;
134
+ readonly rootTraceName?: string;
135
+ readonly durationMs?: number;
136
+ readonly matchedSpans?: number;
137
+ };
138
+
130
139
  export const IdKind = Schema.Literals(["trace_id", "span_id"]);
131
140
  export type IdKind = typeof IdKind.Type;
132
141
 
@@ -35,3 +35,5 @@ export {
35
35
  } from "./transform";
36
36
 
37
37
  export { transformLogOutput } from "./log-transform";
38
+ export { retryTransient } from "./retry-transient";
39
+ export { pollUntilResolved } from "./poll-until-resolved";
@@ -0,0 +1,39 @@
1
+ import { Clock, Duration, Effect } from "effect";
2
+
3
+ export const pollUntilResolved = <A, E, R>(opts: {
4
+ initial: A;
5
+ isPending: (value: A) => boolean;
6
+ fetchLatest: () => Effect.Effect<A, E, R>;
7
+ intervalMs: number;
8
+ budgetSeconds: number;
9
+ }): Effect.Effect<A, E, R> =>
10
+ Effect.gen(function* () {
11
+ let latest = opts.initial;
12
+
13
+ if (!opts.isPending(latest)) {
14
+ return latest;
15
+ }
16
+
17
+ const start = yield* Clock.currentTimeMillis;
18
+ const deadlineMs = Number(start) + opts.budgetSeconds * 1000;
19
+ let timedOut = false;
20
+
21
+ // Effect.whileLoop (not recursion) so TestClock.adjust can advance Effect.sleep without real waits.
22
+ yield* Effect.whileLoop({
23
+ while: () => opts.isPending(latest) && !timedOut,
24
+ body: () =>
25
+ Effect.gen(function* () {
26
+ const now = yield* Clock.currentTimeMillis;
27
+ if (Number(now) >= deadlineMs) {
28
+ timedOut = true;
29
+ return;
30
+ }
31
+ const remaining = deadlineMs - Number(now);
32
+ yield* Effect.sleep(Duration.millis(Math.min(opts.intervalMs, remaining)));
33
+ latest = yield* opts.fetchLatest();
34
+ }),
35
+ step: () => undefined,
36
+ });
37
+
38
+ return latest;
39
+ });
@@ -0,0 +1,24 @@
1
+ import { Duration, Effect } from "effect";
2
+
3
+ const BASE_DELAY_MS = 500;
4
+
5
+ export const retryTransient = <A, E, R>(opts: {
6
+ attempt: () => Effect.Effect<A, E, R>;
7
+ isTransient: (error: E) => boolean;
8
+ maxRetries: number;
9
+ }): Effect.Effect<A, E, R> => {
10
+ const loop = (attempt: number): Effect.Effect<A, E, R> =>
11
+ opts
12
+ .attempt()
13
+ .pipe(
14
+ Effect.catch((error: E) =>
15
+ opts.isTransient(error) && attempt < opts.maxRetries
16
+ ? Effect.sleep(Duration.millis(BASE_DELAY_MS * 2 ** attempt)).pipe(
17
+ Effect.flatMap(() => loop(attempt + 1)),
18
+ )
19
+ : Effect.fail(error),
20
+ ),
21
+ );
22
+
23
+ return loop(0);
24
+ };