@q-agent/agent 0.1.6 → 0.1.8
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 +49 -7
- package/dist/src/cli.js +7 -1
- package/dist/src/runner.js +28 -4
- package/dist/src/version.js +61 -0
- package/dist/test/api.test.js +33 -2
- package/dist/test/version.test.js +21 -0
- package/package.json +1 -1
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
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
166
|
-
|
|
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
|
|
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")
|
package/dist/src/runner.js
CHANGED
|
@@ -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
|
-
//
|
|
350
|
-
//
|
|
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,
|
|
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
|
+
}
|
package/dist/test/api.test.js
CHANGED
|
@@ -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
|
|
139
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.8",
|
|
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": {
|