aglio-worker 0.1.0 → 0.6.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 (2) hide show
  1. package/agent.js +152 -65
  2. package/package.json +1 -1
package/agent.js CHANGED
@@ -7345,8 +7345,10 @@ var NOTIFICATION_TYPES = [
7345
7345
  "run_finished",
7346
7346
  "run_failed",
7347
7347
  "review_feedback",
7348
+ "pr_ready",
7348
7349
  "pr_merged",
7349
- "mention"
7350
+ "mention",
7351
+ "comment"
7350
7352
  ];
7351
7353
  var CREDENTIAL_SCOPES = ["workspace", "user"];
7352
7354
  var CREDENTIAL_AUTH_TYPES = ["api_key", "oauth_token", "session_bundle"];
@@ -27267,45 +27269,58 @@ var repoConnectionReportSchema = external_exports.object({
27267
27269
  repos: external_exports.array(repoConnectionCheckSchema)
27268
27270
  });
27269
27271
 
27272
+ // ../../packages/shared/src/notifications.ts
27273
+ var DEFAULT_NOTIFICATION_PREFERENCES = {
27274
+ emailDigest: true,
27275
+ emailQuestions: true,
27276
+ emailFailures: true,
27277
+ emailPrReady: true
27278
+ };
27279
+ var notificationPreferencesSchema = external_exports.object({
27280
+ emailDigest: external_exports.boolean().catch(DEFAULT_NOTIFICATION_PREFERENCES.emailDigest),
27281
+ emailQuestions: external_exports.boolean().catch(DEFAULT_NOTIFICATION_PREFERENCES.emailQuestions),
27282
+ emailFailures: external_exports.boolean().catch(DEFAULT_NOTIFICATION_PREFERENCES.emailFailures),
27283
+ emailPrReady: external_exports.boolean().catch(DEFAULT_NOTIFICATION_PREFERENCES.emailPrReady)
27284
+ });
27285
+ function isSlackWebhookUrl(value) {
27286
+ if (typeof value !== "string" || !value.trim()) return false;
27287
+ try {
27288
+ const url2 = new URL(value.trim());
27289
+ return url2.protocol === "https:";
27290
+ } catch {
27291
+ return false;
27292
+ }
27293
+ }
27294
+
27270
27295
  // ../../packages/shared/src/routing.ts
27271
- var ROUTING_LABEL_KEYS = ["os", "mode", "tools", "size"];
27272
27296
  var ROUTING_OS_VALUES = ["linux", "macos"];
27273
27297
  var ROUTING_MODE_VALUES = ["docker", "host"];
27274
- var ROUTING_TOOL_VALUES = ["ios", "xcode", "android", "node"];
27275
- var ROUTING_SIZE_VALUES = ["small", "large"];
27276
- var ROUTING_LABEL_VALUES = {
27298
+ var ROUTING_TOOL_VALUES = ["ios", "xcode", "android", "gpu"];
27299
+ var ROUTING_SIZE_VALUES = ["small", "medium", "large"];
27300
+ var ROUTING_GPU_VALUES = ["t4", "a100", "h100"];
27301
+ var ROUTING_ARCH_VALUES = ["x64", "arm64"];
27302
+ var ROUTING_REGION_VALUES = ["us-east", "us-west", "eu-west", "ap-southeast"];
27303
+ var ROUTING_LABEL_SUGGESTIONS = {
27277
27304
  os: ROUTING_OS_VALUES,
27278
27305
  mode: ROUTING_MODE_VALUES,
27279
27306
  tools: ROUTING_TOOL_VALUES,
27280
- size: ROUTING_SIZE_VALUES
27281
- };
27282
- var LABEL_PATTERN = /^([a-z]+):([a-z0-9][a-z0-9._-]*)$/;
27283
- function parseOne(value) {
27284
- const match = LABEL_PATTERN.exec(value);
27285
- if (!match) {
27286
- throw new Error(`Invalid routing label "${value}". Use key:value, for example mode:host.`);
27287
- }
27288
- const key = match[1];
27289
- if (!ROUTING_LABEL_KEYS.includes(key)) {
27290
- throw new Error(
27291
- `Unknown routing label "${value}". Use os, mode, tools or size.`
27292
- );
27293
- }
27294
- const allowed = ROUTING_LABEL_VALUES[key];
27295
- if (!allowed.includes(match[2])) {
27296
- throw new Error(
27297
- `Unknown routing label "${value}". Use ${allowed.map((entry) => `${key}:${entry}`).join(", ")}.`
27298
- );
27299
- }
27300
- return { key, value: match[2] };
27301
- }
27307
+ size: ROUTING_SIZE_VALUES,
27308
+ gpu: ROUTING_GPU_VALUES,
27309
+ arch: ROUTING_ARCH_VALUES,
27310
+ region: ROUTING_REGION_VALUES
27311
+ };
27312
+ var ROUTING_LABEL_SUGGESTION_LIST = Object.entries(
27313
+ ROUTING_LABEL_SUGGESTIONS
27314
+ ).flatMap(([key, values]) => values.map((value) => `${key}:${value}`));
27315
+ var ROUTING_LABEL_MAX_LENGTH = 64;
27316
+ var LABEL_PATTERN = /^([a-z][a-z0-9_-]*):([a-z0-9][a-z0-9._-]*)$/;
27302
27317
  function parseRoutingLabels(input2) {
27303
27318
  const values = Array.isArray(input2) ? input2 : typeof input2 === "string" ? input2.split(/[\s,]+/) : [];
27304
27319
  const seen = /* @__PURE__ */ new Set();
27305
27320
  const labels = [];
27306
27321
  for (const value of values) {
27307
27322
  if (typeof value !== "string") continue;
27308
- const label = value.trim().toLowerCase();
27323
+ const label = value.trim();
27309
27324
  if (!label || seen.has(label)) continue;
27310
27325
  seen.add(label);
27311
27326
  labels.push(label);
@@ -27314,24 +27329,16 @@ function parseRoutingLabels(input2) {
27314
27329
  }
27315
27330
  function validateRoutingLabels(input2) {
27316
27331
  const labels = parseRoutingLabels(input2);
27317
- const seenValues = /* @__PURE__ */ new Map();
27318
- const tools = /* @__PURE__ */ new Set();
27319
27332
  for (const label of labels) {
27320
- const { key, value } = parseOne(label);
27321
- if (key === "os" || key === "mode" || key === "size") {
27322
- const previous = seenValues.get(key);
27323
- if (previous && previous !== value) {
27324
- throw new Error(`Conflicting routing labels "${key}:${previous}" and "${label}".`);
27325
- }
27333
+ if (label.length > ROUTING_LABEL_MAX_LENGTH) {
27334
+ throw new Error(
27335
+ `Invalid routing label "${label}". Labels are at most ${ROUTING_LABEL_MAX_LENGTH} characters.`
27336
+ );
27326
27337
  }
27327
- seenValues.set(key, value);
27328
- if (key === "tools") tools.add(value);
27329
- }
27330
- const os = seenValues.get("os");
27331
- const mode = seenValues.get("mode");
27332
- if (tools.has("ios")) {
27333
- if (os && os !== "macos" || mode && mode !== "host") {
27334
- throw new Error("tools:ios requires os:macos and mode:host.");
27338
+ if (!LABEL_PATTERN.test(label)) {
27339
+ throw new Error(
27340
+ `Invalid routing label "${label}". Use lowercase key:value, for example size:medium.`
27341
+ );
27335
27342
  }
27336
27343
  }
27337
27344
  return labels;
@@ -27395,6 +27402,8 @@ var projectSettingsSchema = external_exports.object({
27395
27402
  runRetryBackoffSeconds: external_exports.number().int().min(10).max(600).default(60),
27396
27403
  runRetryMaxBackoffSeconds: external_exports.number().int().min(60).max(3600).default(600),
27397
27404
  notificationSound: external_exports.boolean().default(true),
27405
+ /** Slack incoming webhook for question, failure and PR-ready alerts. Null disables Slack. */
27406
+ slackWebhookUrl: external_exports.string().trim().max(500).refine(isSlackWebhookUrl, "Slack webhook must be an https URL").nullable().default(null),
27398
27407
  /** Rendered column strip: built-in columns and custom list ids. */
27399
27408
  boardColumnOrder: external_exports.array(external_exports.string()).optional(),
27400
27409
  /** Last repository connectivity check from Project settings or the worker gate. */
@@ -27594,11 +27603,12 @@ function isHostLocalSigningArtifact(name) {
27594
27603
  var WORKER_HEARTBEAT_SECONDS = 15;
27595
27604
  var WORKER_OFFLINE_AFTER_MISSES = 3;
27596
27605
  var WORKER_OFFLINE_AFTER_SECONDS = WORKER_HEARTBEAT_SECONDS * WORKER_OFFLINE_AFTER_MISSES;
27606
+ var WORKER_CLAIM_LONG_POLL_SECONDS = 25;
27597
27607
  var WORKER_MIN_VERSION = "0.1.0";
27598
27608
  var WORKER_VERSION_HEADER = "x-aglio-worker-version";
27599
27609
  var WORKER_MODES = ["docker", "host"];
27600
27610
  var WORKER_OSES = ["linux", "macos"];
27601
- var WORKER_LABEL_MAX_LENGTH = 64;
27611
+ var WORKER_LABEL_MAX_LENGTH = ROUTING_LABEL_MAX_LENGTH;
27602
27612
  var WORKER_IOS_LABEL = "tools:ios";
27603
27613
  var WORKER_ENROLLMENT_TOKEN_PREFIX = "aglio_enr_";
27604
27614
  var WORKER_TOKEN_PREFIX = "aglio_wkr_";
@@ -30748,15 +30758,38 @@ function parseClaimedJob(value) {
30748
30758
  if (!lease || typeof lease.id !== "string" || typeof lease.token !== "string") return null;
30749
30759
  return value;
30750
30760
  }
30761
+ function claimHoldSeconds(config2) {
30762
+ const requested = config2.claimTimeoutSeconds;
30763
+ if (typeof requested !== "number" || !Number.isFinite(requested)) {
30764
+ return WORKER_CLAIM_LONG_POLL_SECONDS;
30765
+ }
30766
+ return Math.min(Math.max(Math.round(requested), 5), 120);
30767
+ }
30768
+ var CLAIM_REQUEST_GRACE_MS = 2e4;
30751
30769
  async function claim2(config2, token, fetchImpl, signal) {
30752
- const response = await request(
30753
- config2,
30754
- fetchImpl,
30755
- "/api/workers/claim",
30756
- token,
30757
- { version: config2.version, capacity: config2.capacity },
30758
- signal
30770
+ const holdSeconds = claimHoldSeconds(config2);
30771
+ const controller = new AbortController();
30772
+ const onAbort = () => controller.abort(signal?.reason);
30773
+ signal?.addEventListener("abort", onAbort, { once: true });
30774
+ const timer = setTimeout(
30775
+ () => controller.abort(new Error("Claim request did not answer in time.")),
30776
+ holdSeconds * 1e3 + CLAIM_REQUEST_GRACE_MS
30759
30777
  );
30778
+ timer.unref?.();
30779
+ let response;
30780
+ try {
30781
+ response = await request(
30782
+ config2,
30783
+ fetchImpl,
30784
+ "/api/workers/claim",
30785
+ token,
30786
+ { version: config2.version, capacity: config2.capacity, claimTimeoutSeconds: holdSeconds },
30787
+ controller.signal
30788
+ );
30789
+ } finally {
30790
+ clearTimeout(timer);
30791
+ signal?.removeEventListener("abort", onAbort);
30792
+ }
30760
30793
  if (response.status === 204) return { status: 204, job: null, reason: null, message: null };
30761
30794
  if (!response.ok) throw await errorFrom(response);
30762
30795
  const body = await responseBody(response);
@@ -30767,6 +30800,15 @@ async function claim2(config2, token, fetchImpl, signal) {
30767
30800
  message: typeof body.message === "string" ? body.message : null
30768
30801
  };
30769
30802
  }
30803
+ function isTransientControlPlaneError(error62) {
30804
+ if (error62 instanceof WorkerHttpError) return error62.status >= 500 || error62.status === 429;
30805
+ return true;
30806
+ }
30807
+ var CLAIM_BACKOFF_MS = [5e3, 1e4, 2e4, 3e4];
30808
+ function claimBackoffMs(attempt) {
30809
+ const index = Math.min(Math.max(Math.floor(attempt), 1), CLAIM_BACKOFF_MS.length) - 1;
30810
+ return CLAIM_BACKOFF_MS[index];
30811
+ }
30770
30812
  async function jobTokenRequest(config2, fetchImpl, path, token, init) {
30771
30813
  return fetchImpl(joinUrl(config2.apiUrl, path), {
30772
30814
  method: init.method,
@@ -30805,18 +30847,23 @@ function workerCardApi(config2, fetchImpl, signal) {
30805
30847
  return new Uint8Array(await response.arrayBuffer());
30806
30848
  },
30807
30849
  async updateRunStatus(input2) {
30808
- const response = await jobTokenRequest(
30809
- config2,
30810
- fetchImpl,
30811
- `/api/workers/jobs/${input2.jobId}/run/status`,
30812
- input2.token,
30813
- {
30814
- method: "POST",
30815
- body: JSON.stringify({ leaseId: input2.leaseId, ...input2.patch }),
30816
- signal
30817
- }
30850
+ await retryTransient(
30851
+ async () => {
30852
+ const response = await jobTokenRequest(
30853
+ config2,
30854
+ fetchImpl,
30855
+ `/api/workers/jobs/${input2.jobId}/run/status`,
30856
+ input2.token,
30857
+ {
30858
+ method: "POST",
30859
+ body: JSON.stringify({ leaseId: input2.leaseId, ...input2.patch }),
30860
+ signal
30861
+ }
30862
+ );
30863
+ if (!response.ok) throw await errorFrom(response);
30864
+ },
30865
+ { signal }
30818
30866
  );
30819
- if (!response.ok) throw await errorFrom(response);
30820
30867
  },
30821
30868
  async appendRunEvents(input2) {
30822
30869
  const response = await jobTokenRequest(
@@ -31004,6 +31051,24 @@ function jobApi(config2, fetchImpl, signal) {
31004
31051
  }
31005
31052
  };
31006
31053
  }
31054
+ var TRANSIENT_RETRY_DELAYS_MS = [500, 1500];
31055
+ async function retryTransient(call, options = {}) {
31056
+ const sleep2 = options.sleep ?? ((ms) => new Promise((resolve6) => setTimeout(resolve6, ms)));
31057
+ let lastError;
31058
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
31059
+ try {
31060
+ return await call();
31061
+ } catch (error62) {
31062
+ lastError = error62;
31063
+ if (!isTransientControlPlaneError(error62) || options.signal?.aborted) throw error62;
31064
+ const delay = TRANSIENT_RETRY_DELAYS_MS[attempt];
31065
+ if (delay === void 0) break;
31066
+ options.onRetry?.(error62, attempt + 1);
31067
+ await sleep2(delay);
31068
+ }
31069
+ }
31070
+ throw lastError;
31071
+ }
31007
31072
  function fatalExitCode(error62) {
31008
31073
  if (error62.status === 401 && error62.code === "worker_revoked") return 0;
31009
31074
  if (error62.status === 426 || error62.code === "incompatible_worker_version") return 2;
@@ -31121,10 +31186,27 @@ async function runRemoteWorker(config2, deps = {}) {
31121
31186
  1,
31122
31187
  Math.min(workerCapacity(config2.capacity, resolved.capacity), workerCapacity(resolved.capacity))
31123
31188
  );
31189
+ let claimAttempt = 0;
31190
+ const backOffClaim = async (error62) => {
31191
+ claimAttempt += 1;
31192
+ const delay = claimBackoffMs(claimAttempt);
31193
+ if (claimAttempt === 1) {
31194
+ log("claim unavailable, backing off", {
31195
+ ...error62 instanceof WorkerHttpError ? { status: error62.status, code: error62.code } : {},
31196
+ error: error62 instanceof Error ? error62.message : String(error62),
31197
+ retryInSeconds: Math.round(delay / 1e3)
31198
+ });
31199
+ }
31200
+ await sleep2(delay);
31201
+ };
31124
31202
  const runSlot = async () => {
31125
31203
  while (!controller.signal.aborted) {
31126
31204
  try {
31127
31205
  const result = await claim2(config2, resolved.token, fetchImpl, controller.signal);
31206
+ if (claimAttempt > 0) {
31207
+ log("claim recovered", { attempts: claimAttempt });
31208
+ claimAttempt = 0;
31209
+ }
31128
31210
  if (result.status === 204) {
31129
31211
  lastReason = null;
31130
31212
  continue;
@@ -31194,11 +31276,13 @@ async function runRemoteWorker(config2, deps = {}) {
31194
31276
  }
31195
31277
  } catch (error62) {
31196
31278
  if (controller.signal.aborted) break;
31279
+ if (isTransientControlPlaneError(error62)) {
31280
+ await backOffClaim(error62);
31281
+ continue;
31282
+ }
31197
31283
  if (error62 instanceof WorkerHttpError) {
31198
31284
  fail2(error62, "claim");
31199
31285
  if (controller.signal.aborted) break;
31200
- } else {
31201
- log("claim failed", { error: error62 instanceof Error ? error62.message : String(error62) });
31202
31286
  }
31203
31287
  await sleep2(5e3);
31204
31288
  }
@@ -43330,6 +43414,8 @@ async function main() {
43330
43414
  const runnerImage = defaultRunnerImage();
43331
43415
  const dockerSocket = process.env.AGLIO_WORKER_DOCKER_SOCKET?.trim() || "/var/run/docker.sock";
43332
43416
  const mcpPort = Number(process.env.AGLIO_WORKER_MCP_PORT ?? 4310);
43417
+ const claimTimeoutRaw = Number(process.env.AGLIO_WORKER_CLAIM_TIMEOUT_SECONDS);
43418
+ const claimTimeoutSeconds = Number.isFinite(claimTimeoutRaw) && claimTimeoutRaw > 0 ? claimTimeoutRaw : void 0;
43333
43419
  const agentContainerId = process.env.HOSTNAME?.trim() || hostname3();
43334
43420
  const runnerNetwork = mode === "docker" ? resolveRunnerNetworkMode({
43335
43421
  override: process.env.AGLIO_WORKER_RUNNER_NETWORK,
@@ -43362,6 +43448,7 @@ async function main() {
43362
43448
  stateFile,
43363
43449
  // Simulators and Xcode share one signing context on a Mac host.
43364
43450
  capacity: workerCapacityForMode(process.env.AGLIO_WORKER_CAPACITY ?? 1, mode),
43451
+ claimTimeoutSeconds,
43365
43452
  workspaceRoot,
43366
43453
  hostWorkspaceRoot,
43367
43454
  runnerImage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aglio-worker",
3
- "version": "0.1.0",
3
+ "version": "0.6.0",
4
4
  "description": "Host machine agent for Aglio: claims jobs over the API and starts aglio-runner containers.",
5
5
  "type": "module",
6
6
  "bin": {