@q-agent/agent 0.1.6 → 0.1.7

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/dist/src/api.js CHANGED
@@ -40,6 +40,7 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.ApiError = void 0;
43
+ exports.fetchWithTimeout = fetchWithTimeout;
43
44
  exports.redeemDevice = redeemDevice;
44
45
  exports.disconnectDevice = disconnectDevice;
45
46
  exports.claimNextJob = claimNextJob;
@@ -71,6 +72,22 @@ async function throwIfNotOk(res) {
71
72
  throw new ApiError(res.status, await res.text().catch(() => ""));
72
73
  }
73
74
  }
75
+ /** Multipart evidence (video/trace) can be multi-MB; cap the upload so a stalled
76
+ * connection aborts instead of hanging the background uploader forever. */
77
+ const EVIDENCE_UPLOAD_TIMEOUT_MS = 300_000;
78
+ /** `fetch()` with a hard timeout via `AbortController`, so a stalled connection
79
+ * can't hang indefinitely (Node's `fetch` has no default timeout). Rejects with
80
+ * an `AbortError` once `timeoutMs` elapses. */
81
+ async function fetchWithTimeout(url, init, timeoutMs) {
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
84
+ try {
85
+ return await fetch(url, { ...init, signal: controller.signal });
86
+ }
87
+ finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
74
91
  /** Redeem a one-time pairing code for a durable device token (the `pair` command). */
75
92
  async function redeemDevice(serverUrl, code, name) {
76
93
  const res = await fetch(`${serverUrl}/agent/devices/redeem`, {
@@ -148,22 +165,47 @@ async function postEvidence(cfg, executionId, ev) {
148
165
  form.set("case_code", ev.caseCode);
149
166
  form.set("kind", ev.kind);
150
167
  form.set("file", new Blob([data]), ev.filename);
151
- const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/evidence`, {
168
+ const res = await fetchWithTimeout(`${cfg.serverUrl}/agent/jobs/${executionId}/evidence`, {
152
169
  method: "POST",
153
170
  headers: authHeaders(cfg.deviceToken),
154
171
  body: form,
155
- });
172
+ }, EVIDENCE_UPLOAD_TIMEOUT_MS);
156
173
  await throwIfNotOk(res);
157
174
  }
158
- /** Ask the server to classify + fix one failed heal attempt (Claude + KB live server-side). */
175
+ /** Heal-fix polling: the server generates the fix (~3 min Claude call) in the
176
+ * background so no single request stays open long enough to hit a fronting
177
+ * proxy's ~100s edge cap (Cloudflare 524). Poll every 3s, capped at 6 min
178
+ * (covers the server's 300s Claude timeout + margin); each request is short. */
179
+ const HEAL_FIX_POLL_INTERVAL_MS = 3_000;
180
+ const HEAL_FIX_TOTAL_TIMEOUT_MS = 360_000;
181
+ const HEAL_FIX_REQUEST_TIMEOUT_MS = 30_000;
182
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
183
+ /** Ask the server to classify + fix one failed heal attempt (Claude + KB live
184
+ * server-side). Starts an async job then polls for the result, so a long fix
185
+ * generation never trips the ~100s proxy timeout that a synchronous call did. */
159
186
  async function postHealFix(cfg, caseId, body) {
160
- const res = await fetch(`${cfg.serverUrl}/agent/heal/${caseId}/fix`, {
187
+ const startRes = await fetchWithTimeout(`${cfg.serverUrl}/agent/heal/${caseId}/fix`, {
161
188
  method: "POST",
162
189
  headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
163
190
  body: JSON.stringify(body),
164
- });
165
- await throwIfNotOk(res);
166
- return (await res.json());
191
+ }, HEAL_FIX_REQUEST_TIMEOUT_MS);
192
+ await throwIfNotOk(startRes);
193
+ const { jobId } = (await startRes.json());
194
+ if (!jobId)
195
+ throw new Error("Heal fix did not return a job id");
196
+ const deadline = Date.now() + HEAL_FIX_TOTAL_TIMEOUT_MS;
197
+ for (;;) {
198
+ const res = await fetchWithTimeout(`${cfg.serverUrl}/agent/heal/${caseId}/fix/${jobId}`, { method: "GET", headers: authHeaders(cfg.deviceToken) }, HEAL_FIX_REQUEST_TIMEOUT_MS);
199
+ await throwIfNotOk(res);
200
+ const data = (await res.json());
201
+ if (data.status === "done" && data.result)
202
+ return data.result;
203
+ if (data.status === "error")
204
+ throw new Error(data.error || "Heal fix failed on the server");
205
+ if (Date.now() > deadline)
206
+ throw new Error("Heal fix timed out waiting for the server");
207
+ await sleep(HEAL_FIX_POLL_INTERVAL_MS);
208
+ }
167
209
  }
168
210
  /** Persist the heal's final outcome + feed a passing DOM-grounded heal into the KB. */
169
211
  async function postHealFinalize(cfg, caseId, body) {
package/dist/src/cli.js CHANGED
@@ -49,13 +49,19 @@ const config_1 = require("./config");
49
49
  const api_1 = require("./api");
50
50
  const runner_1 = require("./runner");
51
51
  const ui_1 = require("./ui");
52
+ const version_1 = require("./version");
52
53
  /** How the user invoked this agent, for accurate "run X" hints: the bare command
53
54
  * inside a packaged bundle, else the `npx` form of the published package. */
54
55
  function invocation() {
55
56
  return process.pkg ? "qagent-agent" : "npx @q-agent/agent";
56
57
  }
57
58
  const program = new commander_1.Command();
58
- program.name("qagent-agent").description("Q-Agent Local Agent — runs Playwright locally for this machine's owner.");
59
+ program
60
+ .name("qagent-agent")
61
+ .description("Q-Agent Local Agent — runs Playwright locally for this machine's owner.")
62
+ // Surfaces the actual installed build (e.g. via `npx @q-agent/agent --version`),
63
+ // so a stale npx cache serving an old agent is easy to spot.
64
+ .version((0, version_1.agentVersion)(), "-v, --version", "print the agent version");
59
65
  program
60
66
  .command("pair")
61
67
  .description("Redeem a one-time pairing code (from the Q-Agent SPA) for a device token")
@@ -54,6 +54,7 @@ const paths_1 = require("./paths");
54
54
  const playwrightConfig_1 = require("./playwrightConfig");
55
55
  const report_1 = require("./report");
56
56
  const session_1 = require("./session");
57
+ const version_1 = require("./version");
57
58
  // Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
58
59
  const EXEC_TIMEOUT_MS = 600_000;
59
60
  const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
@@ -224,6 +225,10 @@ async function processJob(cfg, job) {
224
225
  return;
225
226
  }
226
227
  const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
228
+ // Set once the (detached) evidence uploader takes ownership of workDir — it
229
+ // then removes the dir when its uploads finish. Until then, this function's
230
+ // finally cleans up (error / early-return paths).
231
+ let handedOff = false;
227
232
  try {
228
233
  for (const spec of job.specs) {
229
234
  fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
@@ -346,11 +351,30 @@ async function processJob(cfg, job) {
346
351
  await api.postComplete(cfg, job.executionId, { passed, failed, log: logText });
347
352
  (0, bus_1.emit)("job-complete", { executionId: job.executionId, passed, failed });
348
353
  // Now that the run is marked done, upload the (deferred) evidence artifacts.
349
- // The web already reflects the outcome; these trail in the background and the
350
- // Evidence step picks them up as they land.
354
+ // These run DETACHED from the claim loop: the run's outcome is already
355
+ // reported, so the agent must be free to claim the next run immediately
356
+ // rather than blocking here until every (potentially multi-MB) artifact
357
+ // finishes uploading — that block is what stalled the agent when a new run
358
+ // was started mid-upload. The uploader owns workDir cleanup from here.
359
+ handedOff = true;
360
+ void uploadEvidenceThenCleanup(cfg, job.executionId, pendingEvidence, workDir);
361
+ }
362
+ finally {
363
+ if (!handedOff)
364
+ fs.rmSync(workDir, { recursive: true, force: true });
365
+ }
366
+ }
367
+ /**
368
+ * Upload a completed job's deferred evidence artifacts, then remove its workDir.
369
+ * Runs detached from the claim loop (see {@link processJob}) so uploads never
370
+ * delay claiming the next run. Never throws: each upload's failure is logged and
371
+ * the workDir is always removed, even if some uploads fail or time out.
372
+ */
373
+ async function uploadEvidenceThenCleanup(cfg, executionId, pendingEvidence, workDir) {
374
+ try {
351
375
  for (const ev of pendingEvidence) {
352
376
  await api
353
- .postEvidence(cfg, job.executionId, {
377
+ .postEvidence(cfg, executionId, {
354
378
  ticketExternalId: ev.ticket,
355
379
  caseCode: ev.caseCode,
356
380
  kind: ev.kind,
@@ -592,7 +616,7 @@ async function runAgentLoop(cfg, signal) {
592
616
  console.error("Chromium is required to run tests — aborting.");
593
617
  return;
594
618
  }
595
- console.log(`Local Agent started — polling ${cfg.serverUrl} as device #${cfg.deviceId} (${cfg.deviceName})`);
619
+ console.log(`Local Agent v${(0, version_1.agentVersion)()} started — polling ${cfg.serverUrl} as device #${cfg.deviceId} (${cfg.deviceName})`);
596
620
  while (!signal.aborted) {
597
621
  let job = null;
598
622
  try {
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ /**
3
+ * The running agent's version.
4
+ *
5
+ * Read from the package manifest at runtime (rather than a compile-time import)
6
+ * so both the `npx @q-agent/agent` package and the packaged desktop bundle report
7
+ * the *actual* installed build — the number one signal when diagnosing a stale
8
+ * `npx` cache serving an old agent. Compiled output lives at `dist/src/`, so the
9
+ * manifest is two levels up. Any failure falls back to `"unknown"` — a missing
10
+ * version must never crash the CLI.
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.agentVersion = agentVersion;
47
+ const path = __importStar(require("path"));
48
+ let cached = null;
49
+ /** The agent's semver (e.g. "0.1.6"), or "unknown" if the manifest can't be read. */
50
+ function agentVersion() {
51
+ if (cached !== null)
52
+ return cached;
53
+ try {
54
+ const manifest = require(path.join(__dirname, "..", "..", "package.json"));
55
+ cached = manifest.version || "unknown";
56
+ }
57
+ catch {
58
+ cached = "unknown";
59
+ }
60
+ return cached;
61
+ }
@@ -135,18 +135,37 @@ function mockFetch(handler) {
135
135
  const body = JSON.parse(calls[0].init.body);
136
136
  strict_1.default.deepEqual(body, { passed: 3, failed: 1, log: "tail" });
137
137
  });
138
- (0, node_test_1.test)("postHealFix posts the attempt to /agent/heal/{caseId}/fix and returns the action", async () => {
139
- mockFetch(() => Response.json({ action: "fixed", code: "// fixed", diff: "@@" }));
138
+ (0, node_test_1.test)("postHealFix starts a job then polls /agent/heal/{caseId}/fix/{jobId} for the action", async () => {
139
+ // Async flow (#313): POST starts the job (returns jobId), GET polls until done.
140
+ mockFetch((url, init) => {
141
+ if (init.method === "POST")
142
+ return Response.json({ jobId: "job-1", status: "running" });
143
+ return Response.json({ status: "done", result: { action: "fixed", code: "// fixed", diff: "@@" } });
144
+ });
140
145
  const out = await api.postHealFix(cfg, 99, {
141
146
  currentCode: "// old", error: "boom", output: "tail", domDistilled: { path: "/x" }, attempt: 2,
142
147
  });
148
+ // First call: POST to start with the attempt body.
143
149
  strict_1.default.equal(calls[0].url, "http://127.0.0.1:8787/agent/heal/99/fix");
144
150
  strict_1.default.equal(calls[0].init.method, "POST");
145
151
  const body = JSON.parse(calls[0].init.body);
146
152
  strict_1.default.deepEqual(body, { currentCode: "// old", error: "boom", output: "tail", domDistilled: { path: "/x" }, attempt: 2 });
153
+ // Second call: GET poll on the returned job id.
154
+ strict_1.default.equal(calls[1].url, "http://127.0.0.1:8787/agent/heal/99/fix/job-1");
155
+ strict_1.default.equal(calls[1].init.method, "GET");
147
156
  strict_1.default.equal(out.action, "fixed");
148
157
  strict_1.default.equal(out.code, "// fixed");
149
158
  });
159
+ (0, node_test_1.test)("postHealFix surfaces a server-side error job", async () => {
160
+ mockFetch((url, init) => {
161
+ if (init.method === "POST")
162
+ return Response.json({ jobId: "job-2", status: "running" });
163
+ return Response.json({ status: "error", error: "Claude timed out" });
164
+ });
165
+ await strict_1.default.rejects(() => api.postHealFix(cfg, 99, {
166
+ currentCode: "// old", error: "boom", output: "", domDistilled: null, attempt: 1,
167
+ }), /Claude timed out/);
168
+ });
150
169
  (0, node_test_1.test)("postHealFinalize posts the outcome to /agent/heal/{caseId}/finalize", async () => {
151
170
  mockFetch(() => new Response(null, { status: 200 }));
152
171
  await api.postHealFinalize(cfg, 99, { finalStatus: "pass", finalCode: "// x", attempts: [] });
@@ -163,3 +182,15 @@ function mockFetch(handler) {
163
182
  const body = JSON.parse(calls[0].init.body);
164
183
  strict_1.default.deepEqual(body, { code: "PAIR123", name: "my-laptop" });
165
184
  });
185
+ (0, node_test_1.test)("fetchWithTimeout aborts a stalled request once the timeout elapses", async () => {
186
+ // A server that never responds but honors the abort signal.
187
+ mockFetch((_u, init) => new Promise((_resolve, reject) => {
188
+ init.signal.addEventListener("abort", () => reject(new Error("aborted")));
189
+ }));
190
+ await strict_1.default.rejects(api.fetchWithTimeout("http://127.0.0.1:8787/slow", { method: "POST" }, 20));
191
+ });
192
+ (0, node_test_1.test)("fetchWithTimeout returns the response when it resolves before the timeout", async () => {
193
+ mockFetch(() => new Response(null, { status: 200 }));
194
+ const res = await api.fetchWithTimeout("http://127.0.0.1:8787/ok", {}, 1000);
195
+ strict_1.default.equal(res.status, 200);
196
+ });
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ /**
3
+ * Tests for the runtime version lookup (`src/version.ts`) — the agent must be
4
+ * able to report its actual installed build (for `--version` and the startup
5
+ * banner), which is the key signal when diagnosing a stale npx cache.
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const strict_1 = __importDefault(require("node:assert/strict"));
12
+ const node_test_1 = require("node:test");
13
+ const version_1 = require("../src/version");
14
+ (0, node_test_1.test)("agentVersion reads a real semver from the package manifest (not 'unknown')", () => {
15
+ const v = (0, version_1.agentVersion)();
16
+ strict_1.default.notEqual(v, "unknown", "expected the manifest version to resolve at runtime");
17
+ strict_1.default.match(v, /^\d+\.\d+\.\d+/, `expected semver, got ${v}`);
18
+ });
19
+ (0, node_test_1.test)("agentVersion is stable across calls (cached)", () => {
20
+ strict_1.default.equal((0, version_1.agentVersion)(), (0, version_1.agentVersion)());
21
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q-agent/agent",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Q-Agent Local Agent — claims execution jobs from the Q-Agent server and runs Playwright locally, so manual login/MFA happens on the user's own machine and session credentials never leave it.",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {