@blogic-cz/agent-tools 0.14.62 → 0.15.2

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.
@@ -14,6 +14,41 @@ const NETWORK_ERROR_RE =
14
14
  const AUTH_401_RE = /HTTP 401|Bad credentials/i;
15
15
  const MAX_GH_RETRIES = 2;
16
16
 
17
+ // A bare `gh` stderr tells an agent what broke but never what to do next. `hint`/`nextCommand`
18
+ // already exist on GitHubCommandError; these fill them for the failures agents actually hit.
19
+ const KNOWN_STDERR_HINTS: ReadonlyArray<{
20
+ readonly re: RegExp;
21
+ readonly hint: string;
22
+ readonly nextCommand?: string;
23
+ }> = [
24
+ {
25
+ re: /no checks reported/i,
26
+ hint: "The head commit carries no check runs yet. Wait for CI to register, or trigger the workflow when the push event was dropped.",
27
+ nextCommand: "agent-tools-gh pr trigger-checks --pr <number> --workflow <file.yml>",
28
+ },
29
+ {
30
+ re: /no commits between/i,
31
+ hint: "Head and base point at the same commit. Push a commit before opening or updating the PR.",
32
+ },
33
+ {
34
+ re: /already exists/i,
35
+ hint: "The resource already exists. Fetch the existing one and update it instead of creating another.",
36
+ },
37
+ {
38
+ re: /pending review/i,
39
+ hint: "A pending (unsubmitted) review blocks this mutation. Inspect its contents and submit or discard it before retrying.",
40
+ nextCommand: "agent-tools-gh pr reviews --pr <number>",
41
+ },
42
+ {
43
+ re: /workflow does not have 'workflow_dispatch'/i,
44
+ hint: "This workflow cannot be dispatched manually. Re-run an existing run instead.",
45
+ nextCommand: "agent-tools-gh workflow rerun --run <run-id>",
46
+ },
47
+ ];
48
+
49
+ const resolveStderrHint = (stderr: string) =>
50
+ KNOWN_STDERR_HINTS.find((entry) => entry.re.test(stderr));
51
+
17
52
  // Only retry verbs that are unambiguously idempotent reads — never replay a mutation on a timeout.
18
53
  const READ_VERBS = new Set(["view", "list", "checks", "status", "diff"]);
19
54
  const MUTATION_TOKENS =
@@ -204,11 +239,14 @@ export class GitHubService extends Context.Service<
204
239
  });
205
240
  }
206
241
 
242
+ const known = resolveStderrHint(result.stderr);
207
243
  return yield* new GitHubCommandError({
208
244
  message: result.stderr,
209
245
  command: `gh ${args.join(" ")}`,
210
246
  exitCode: result.exitCode,
211
247
  stderr: result.stderr,
248
+ ...(known === undefined ? {} : { hint: known.hint }),
249
+ ...(known?.nextCommand === undefined ? {} : { nextCommand: known.nextCommand }),
212
250
  });
213
251
  }
214
252
 
@@ -1,5 +1,5 @@
1
1
  import { Command, Flag, Param } from "effect/unstable/cli";
2
- import { Console, Effect, Option } from "effect";
2
+ import { Console, Duration, Effect, Option } from "effect";
3
3
 
4
4
  import { formatOption, logFormatted } from "#shared";
5
5
  import { CI_CHECK_WATCH_TIMEOUT_MS } from "#gh/config";
@@ -241,6 +241,65 @@ export const dispatchWorkflow = Effect.fn("workflow.dispatchWorkflow")(function*
241
241
  };
242
242
  });
243
243
 
244
+ export type DispatchedRun = {
245
+ databaseId: number;
246
+ headSha: string;
247
+ status: string;
248
+ conclusion: string | null;
249
+ url: string;
250
+ };
251
+
252
+ const DISPATCH_DISCOVERY_ATTEMPTS = 8;
253
+ const DISPATCH_DISCOVERY_INTERVAL_MS = 2000;
254
+
255
+ export const listDispatchedRuns = Effect.fn("workflow.listDispatchedRuns")(function* (opts: {
256
+ workflow: string;
257
+ ref: string;
258
+ repo: string | null;
259
+ }) {
260
+ const gh = yield* GitHubService;
261
+ const args = [
262
+ "run",
263
+ "list",
264
+ "--json",
265
+ "databaseId,headSha,status,conclusion,url",
266
+ "--limit",
267
+ "20",
268
+ "--workflow",
269
+ opts.workflow,
270
+ "--branch",
271
+ opts.ref,
272
+ "--event",
273
+ "workflow_dispatch",
274
+ ];
275
+
276
+ if (opts.repo !== null) {
277
+ args.push("--repo", opts.repo);
278
+ }
279
+
280
+ return yield* gh
281
+ .runGhJson<DispatchedRun[]>(args)
282
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed<DispatchedRun[]>([])));
283
+ });
284
+
285
+ // `gh workflow run` and the REST dispatch endpoint both return an empty body — there is no
286
+ // dispatch-to-run mapping. Polling the run list for an id absent before the dispatch is the only
287
+ // way to name the run we just created, and an unnamed run cannot be watched or verified.
288
+ export const discoverDispatchedRun = Effect.fn("workflow.discoverDispatchedRun")(function* (opts: {
289
+ workflow: string;
290
+ ref: string;
291
+ repo: string | null;
292
+ knownRunIds: ReadonlySet<number>;
293
+ }) {
294
+ for (let attempt = 0; attempt < DISPATCH_DISCOVERY_ATTEMPTS; attempt += 1) {
295
+ const runs = yield* listDispatchedRuns(opts);
296
+ const created = runs.find((run) => !opts.knownRunIds.has(run.databaseId));
297
+ if (created !== undefined) return created;
298
+ yield* Effect.sleep(Duration.millis(DISPATCH_DISCOVERY_INTERVAL_MS));
299
+ }
300
+ return null;
301
+ });
302
+
244
303
  // `gh run watch` has no native timeout (observed hanging 36 min). Block for the caller's --timeout,
245
304
  // then fall back to a one-shot snapshot so a timeout never returns nothing.
246
305
  const DEFAULT_WATCH_RUN_TIMEOUT_SECONDS = CI_CHECK_WATCH_TIMEOUT_MS / 1000;
@@ -781,13 +840,32 @@ export const workflowRunCommand = Command.make(
781
840
  ({ field, format, ref, repo, workflow }) =>
782
841
  Effect.gen(function* () {
783
842
  const resolvedRepo = yield* resolveRepoArg(repo);
843
+ const before = yield* listDispatchedRuns({ workflow, ref, repo: resolvedRepo });
784
844
  const result = yield* dispatchWorkflow({
785
845
  workflow,
786
846
  ref,
787
847
  fields: field,
788
848
  repo: resolvedRepo,
789
849
  });
790
- yield* logFormatted(result, format);
850
+ const created = yield* discoverDispatchedRun({
851
+ workflow,
852
+ ref,
853
+ repo: resolvedRepo,
854
+ knownRunIds: new Set(before.map((run) => run.databaseId)),
855
+ });
856
+ yield* logFormatted(
857
+ {
858
+ ...result,
859
+ runId: created?.databaseId ?? null,
860
+ headSha: created?.headSha ?? null,
861
+ url: created?.url ?? null,
862
+ nextCommand:
863
+ created === null
864
+ ? `agent-tools-gh workflow list --workflow ${workflow} --branch ${ref}`
865
+ : `agent-tools-gh workflow watch --run ${created.databaseId}`,
866
+ },
867
+ format,
868
+ );
791
869
  }),
792
870
  ).pipe(Command.withDescription("Dispatch a workflow_dispatch workflow run"));
793
871
 
@@ -0,0 +1,88 @@
1
+ import { ChildProcess } from "effect/unstable/process";
2
+
3
+ import type { ResolvedVpnDriver } from "#shared/prerequisites/types";
4
+ import type { SanitizedVpnDriver } from "#shared/prerequisites/store";
5
+
6
+ export type VpnDriverAction = "status" | "start" | "stop";
7
+ export type VpnCommandSpec = { readonly executable: string; readonly args: readonly string[] };
8
+
9
+ export const sanitizeVpnDriver = (driver: ResolvedVpnDriver): SanitizedVpnDriver => {
10
+ if (driver.type === "macos-scutil") {
11
+ return { type: driver.type, platform: driver.platform, serviceName: driver.serviceName };
12
+ }
13
+ if (driver.type === "linux-nmcli") {
14
+ return { type: driver.type, platform: driver.platform, connectionName: driver.connectionName };
15
+ }
16
+ return { type: driver.type, platform: driver.platform, entryName: driver.entryName };
17
+ };
18
+
19
+ export const vpnCommandSpec = (
20
+ driver: SanitizedVpnDriver,
21
+ action: VpnDriverAction,
22
+ ): VpnCommandSpec => {
23
+ if (driver.type === "macos-scutil") {
24
+ return {
25
+ executable: "scutil",
26
+ args:
27
+ action === "status"
28
+ ? ["--nc", "status", driver.serviceName]
29
+ : ["--nc", action, driver.serviceName],
30
+ };
31
+ }
32
+ if (driver.type === "linux-nmcli") {
33
+ return {
34
+ executable: "nmcli",
35
+ args:
36
+ action === "status"
37
+ ? ["-t", "-e", "no", "-f", "NAME", "connection", "show", "--active"]
38
+ : ["connection", action === "start" ? "up" : "down", driver.connectionName],
39
+ };
40
+ }
41
+ return {
42
+ executable: "rasdial",
43
+ args:
44
+ action === "status"
45
+ ? []
46
+ : action === "start"
47
+ ? [driver.entryName]
48
+ : [driver.entryName, "/disconnect"],
49
+ };
50
+ };
51
+
52
+ export const makeParentVpnCommand = (
53
+ driver: ResolvedVpnDriver,
54
+ action: VpnDriverAction,
55
+ secret?: string,
56
+ ) => {
57
+ const spec = vpnCommandSpec(sanitizeVpnDriver(driver), action);
58
+ const secretArgs = action === "start" && secret ? ["--secret", secret] : [];
59
+ const args = [...spec.args, ...secretArgs];
60
+ const labelArgs = [...spec.args, ...(secretArgs.length > 0 ? ["--secret", "<redacted>"] : [])];
61
+ return {
62
+ command: ChildProcess.make(spec.executable, args, { stdout: "pipe", stderr: "pipe" }),
63
+ label: [spec.executable, ...labelArgs].join(" "),
64
+ };
65
+ };
66
+
67
+ export const parseVpnStatus = (
68
+ driver: SanitizedVpnDriver,
69
+ result: { readonly stdout: string; readonly exitCode: number },
70
+ ): boolean | undefined => {
71
+ if (result.exitCode !== 0) return undefined;
72
+ const lines = result.stdout.split(/\r?\n/);
73
+ if (driver.type === "macos-scutil") {
74
+ if (lines.includes("Connected")) return true;
75
+ if (lines.includes("Disconnected")) return false;
76
+ return undefined;
77
+ }
78
+ if (driver.type === "linux-nmcli") {
79
+ return lines.some((line) => line === driver.connectionName);
80
+ }
81
+ const records = lines.map((line) => line.trim()).filter((line) => line.length > 0);
82
+ const successFooter = "Command completed successfully.";
83
+ if (records.at(-1) !== successFooter) return undefined;
84
+ const body = records.slice(0, -1);
85
+ if (body.length === 1 && body[0] === "No connections") return false;
86
+ if (body[0] !== "Connected to" || body.length === 1) return undefined;
87
+ return body.slice(1).includes(driver.entryName);
88
+ };
@@ -0,0 +1,49 @@
1
+ import type {
2
+ GuardianInboundMessage,
3
+ GuardianOutboundMessage,
4
+ } from "#shared/prerequisites/guardian";
5
+ import { runGuardian } from "#shared/prerequisites/guardian";
6
+
7
+ let release: (() => Promise<void>) | undefined;
8
+ let initialized = false;
9
+ let initializedLeaseId: string | undefined;
10
+ let requestedLeaseId: string | undefined;
11
+ let disconnected = false;
12
+ let releaseStarted = false;
13
+
14
+ const send = (message: GuardianOutboundMessage) => process.send?.(message);
15
+ const fail = (error: unknown) => {
16
+ send({ type: "ERROR", message: error instanceof Error ? error.message : String(error) });
17
+ process.exitCode = 1;
18
+ };
19
+ const releaseIfRequested = () => {
20
+ if (releaseStarted || !release || (!disconnected && requestedLeaseId !== initializedLeaseId)) {
21
+ return;
22
+ }
23
+ releaseStarted = true;
24
+ void release().catch(fail);
25
+ };
26
+
27
+ process.on("message", (message: GuardianInboundMessage) => {
28
+ if (message.type === "INIT" && !initialized) {
29
+ initialized = true;
30
+ initializedLeaseId = message.leaseId;
31
+ void runGuardian(message, send)
32
+ .then((guardian) => {
33
+ release = guardian.release;
34
+ send({ type: "READY", leaseId: message.leaseId, guardianId: message.guardianId });
35
+ return releaseIfRequested();
36
+ })
37
+ .catch(fail);
38
+ return;
39
+ }
40
+ if (message.type === "RELEASE") {
41
+ requestedLeaseId = message.leaseId;
42
+ releaseIfRequested();
43
+ }
44
+ });
45
+
46
+ process.on("disconnect", () => {
47
+ disconnected = true;
48
+ releaseIfRequested();
49
+ });
@@ -0,0 +1,157 @@
1
+ import type { SanitizedVpnDriver } from "#shared/prerequisites/store";
2
+ import { VpnStore } from "#shared/prerequisites/store";
3
+ import { parseVpnStatus, vpnCommandSpec } from "#shared/prerequisites/driver-commands";
4
+ import type { VpnCleanupPolicy } from "#shared/prerequisites/types";
5
+
6
+ export type GuardianInitMessage = {
7
+ readonly type: "INIT";
8
+ readonly driver: SanitizedVpnDriver;
9
+ readonly runtimeRoot: string;
10
+ readonly leaseId: string;
11
+ readonly guardianId: string;
12
+ readonly ownerPid: number;
13
+ readonly cleanup: VpnCleanupPolicy;
14
+ readonly idleDisconnectMs: number;
15
+ readonly disconnectTimeoutMs: number;
16
+ };
17
+ export type GuardianReleaseMessage = { readonly type: "RELEASE"; readonly leaseId: string };
18
+ export type GuardianInboundMessage = GuardianInitMessage | GuardianReleaseMessage;
19
+ export type GuardianOutboundMessage =
20
+ | { readonly type: "READY"; readonly leaseId: string; readonly guardianId: string }
21
+ | { readonly type: "RELEASED"; readonly leaseId: string }
22
+ | { readonly type: "ERROR"; readonly message: string };
23
+
24
+ export type GuardianCommandRunner = (
25
+ action: "status" | "stop",
26
+ timeoutMs: number,
27
+ ) => Promise<{ readonly stdout: string; readonly stderr: string; readonly exitCode: number }>;
28
+
29
+ const safeEnvironment = (): Record<string, string> =>
30
+ Object.fromEntries(
31
+ ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT", "WINDIR"].flatMap((name) => {
32
+ const value = process.env[name];
33
+ return value === undefined ? [] : [[name, value]];
34
+ }),
35
+ );
36
+
37
+ export const makeGuardianCommandRunner =
38
+ (driver: SanitizedVpnDriver): GuardianCommandRunner =>
39
+ async (action, timeoutMs) => {
40
+ const spec = vpnCommandSpec(driver, action);
41
+ const child = Bun.spawn([spec.executable, ...spec.args], {
42
+ env: safeEnvironment(),
43
+ stdin: "ignore",
44
+ stdout: "pipe",
45
+ stderr: "pipe",
46
+ timeout: timeoutMs,
47
+ killSignal: "SIGKILL",
48
+ });
49
+ const [exitCode, stdout, stderr] = await Promise.all([
50
+ child.exited,
51
+ new Response(child.stdout).text(),
52
+ new Response(child.stderr).text(),
53
+ ]);
54
+ return { stdout, stderr, exitCode };
55
+ };
56
+
57
+ const sleep = (milliseconds: number) =>
58
+ new Promise<void>((resolve) => {
59
+ setTimeout(resolve, milliseconds);
60
+ });
61
+
62
+ export async function stopWhenIdle(
63
+ store: VpnStore,
64
+ init: GuardianInitMessage,
65
+ runCommand: GuardianCommandRunner,
66
+ now: () => number = Date.now,
67
+ ): Promise<void> {
68
+ const snapshot = store.snapshot();
69
+ if (snapshot.lifecycle !== "IDLE" || snapshot.idleDeadline === null) return;
70
+ const delay = snapshot.idleDeadline - now();
71
+ if (delay > 0) await sleep(delay);
72
+
73
+ const operationId = crypto.randomUUID();
74
+ const token = crypto.randomUUID();
75
+ const guard = store.claimStop(operationId, token, process.pid, now());
76
+ if (!guard) return;
77
+
78
+ const deadline = now() + init.disconnectTimeoutMs;
79
+ let evidence = "VPN stop did not produce confirmed disconnected status.";
80
+ try {
81
+ let remaining = deadline - now();
82
+ if (remaining > 0) {
83
+ const stop = await runCommand("stop", remaining);
84
+ remaining = deadline - now();
85
+ if (stop.exitCode !== 0) {
86
+ evidence = "VPN stop command failed; ownership is unknown and stop will not be retried.";
87
+ } else {
88
+ const confirmDisconnected = async (): Promise<boolean> => {
89
+ const statusRemaining = deadline - now();
90
+ if (statusRemaining <= 0) return false;
91
+ const status = await runCommand("status", statusRemaining);
92
+ remaining = deadline - now();
93
+ const connected = parseVpnStatus(init.driver, status);
94
+ if (connected === false) return true;
95
+ if (connected === undefined || remaining <= 0) return false;
96
+ const sleepRemaining = deadline - now();
97
+ if (sleepRemaining <= 0) return false;
98
+ await sleep(Math.min(250, sleepRemaining));
99
+ remaining = deadline - now();
100
+ return remaining > 0 && confirmDisconnected();
101
+ };
102
+ if (await confirmDisconnected()) {
103
+ store.commitStop(guard, true, "VPN stop confirmed disconnected.", now());
104
+ return;
105
+ }
106
+ evidence = "VPN status failed, stayed connected, or was unparseable after stop.";
107
+ }
108
+ } else {
109
+ evidence = "VPN disconnect deadline expired before a command could safely start.";
110
+ }
111
+ } catch {
112
+ evidence = "VPN stop or confirmation timed out or failed; ownership is unknown.";
113
+ }
114
+ store.commitStop(guard, false, evidence, now());
115
+ }
116
+
117
+ export function runGuardian(
118
+ init: GuardianInitMessage,
119
+ send: (message: GuardianOutboundMessage) => void,
120
+ runCommand: GuardianCommandRunner = makeGuardianCommandRunner(init.driver),
121
+ ): Promise<{ release: () => Promise<void> }> {
122
+ return new Promise((resolve) => {
123
+ const store = VpnStore.open(init.driver, { root: init.runtimeRoot });
124
+ let released = false;
125
+ store.reserveLease({
126
+ leaseId: init.leaseId,
127
+ guardianId: init.guardianId,
128
+ ownerPid: init.ownerPid,
129
+ cleanup: init.cleanup,
130
+ now: Date.now(),
131
+ });
132
+
133
+ const release = async () => {
134
+ if (released) return;
135
+ released = true;
136
+ try {
137
+ const result = store.releaseLease({
138
+ leaseId: init.leaseId,
139
+ guardianId: init.guardianId,
140
+ idleDisconnectMs: init.idleDisconnectMs,
141
+ now: Date.now(),
142
+ });
143
+ const stop =
144
+ result.released && result.deadline !== null
145
+ ? stopWhenIdle(store, init, runCommand)
146
+ : undefined;
147
+ if (stop && init.idleDisconnectMs === 0) await stop;
148
+ send({ type: "RELEASED", leaseId: init.leaseId });
149
+ if (stop && init.idleDisconnectMs > 0) await stop;
150
+ } finally {
151
+ store.close();
152
+ }
153
+ };
154
+
155
+ resolve({ release });
156
+ });
157
+ }