@atolis-hq/wake 0.3.74 → 0.3.76

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.
@@ -203,12 +203,21 @@ function createSystemApplications(root, now) {
203
203
  detail: `rolled back from ${selfUpdateFailure.tag} at ${selfUpdateFailure.occurredAt}: ${selfUpdateFailure.message}`,
204
204
  },
205
205
  ];
206
+ const adapters = root.providers.flatMap((instance) => (instance.health?.() ?? []).map((check) => ({
207
+ adapter: instance.adapter,
208
+ provider: instance.provider,
209
+ ...check,
210
+ })));
206
211
  return {
207
212
  data: {
208
- status: checks.some((check) => check.status === 'degraded') ? 'degraded' : 'ok',
213
+ status: checks.some((check) => check.status === 'degraded') ||
214
+ adapters.some((check) => check.status === 'degraded')
215
+ ? 'degraded'
216
+ : 'ok',
209
217
  version: wakeVersion,
210
218
  checkedAt,
211
219
  checks,
220
+ adapters,
212
221
  },
213
222
  meta: sampledMeta(checkedAt),
214
223
  };
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "gb6b6a2c";
111
+ export const wakeVersion = "gad83503";
@@ -0,0 +1,43 @@
1
+ const DEFINITIVE_STATUSES = new Set([401, 403, 429]);
2
+ export function createAdapterHealthTracker(options = {}) {
3
+ const now = options.now ?? Date.now;
4
+ const threshold = options.degradeAfterConsecutiveFailures ?? 3;
5
+ let successCount = 0;
6
+ let failureCount = 0;
7
+ let consecutiveFailures = 0;
8
+ let degradedDetail;
9
+ return {
10
+ recordSuccess() {
11
+ successCount += 1;
12
+ consecutiveFailures = 0;
13
+ degradedDetail = undefined;
14
+ },
15
+ recordFailure(error) {
16
+ failureCount += 1;
17
+ consecutiveFailures += 1;
18
+ const status = statusOf(error);
19
+ const occurredAt = new Date(now()).toISOString();
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ if (status !== undefined && DEFINITIVE_STATUSES.has(status)) {
22
+ degradedDetail = `${status} at ${occurredAt}: ${message}`;
23
+ return;
24
+ }
25
+ if (consecutiveFailures >= threshold) {
26
+ degradedDetail = `${consecutiveFailures} consecutive failures, last: ${status ?? 'error'} at ${occurredAt}: ${message}`;
27
+ }
28
+ },
29
+ snapshot() {
30
+ return {
31
+ status: degradedDetail === undefined ? 'ok' : 'degraded',
32
+ ...(degradedDetail === undefined ? {} : { detail: degradedDetail }),
33
+ successCount,
34
+ failureCount,
35
+ };
36
+ },
37
+ };
38
+ }
39
+ function statusOf(error) {
40
+ if (typeof error !== 'object' || error === null || !('status' in error))
41
+ return undefined;
42
+ return typeof error.status === 'number' ? error.status : undefined;
43
+ }
@@ -18,11 +18,14 @@ export class ProviderRegistry {
18
18
  throw new Error(`Provider ${provider} is not registered`);
19
19
  const adapter = adapterId(name);
20
20
  try {
21
- instances.push(definition.create({
22
- adapter,
23
- config: definition.parseConfig(entry),
24
- ...(services === undefined ? {} : { services }),
25
- }));
21
+ instances.push({
22
+ ...definition.create({
23
+ adapter,
24
+ config: definition.parseConfig(entry),
25
+ ...(services === undefined ? {} : { services }),
26
+ }),
27
+ provider,
28
+ });
26
29
  }
27
30
  catch (error) {
28
31
  failures.push({
@@ -1,4 +1,5 @@
1
1
  const defaultMaximumGitHubResponseBytes = 8 * 1024 * 1024;
2
+ const defaultGitHubRequestTimeoutMs = 30_000;
2
3
  export class GitHubResponseTooLargeError extends Error {
3
4
  maximumBytes;
4
5
  observedBytes;
@@ -9,9 +10,39 @@ export class GitHubResponseTooLargeError extends Error {
9
10
  this.name = 'GitHubResponseTooLargeError';
10
11
  }
11
12
  }
12
- export function createBoundedGitHubFetch(baseFetch = globalThis.fetch, maximumResponseBytes = defaultMaximumGitHubResponseBytes) {
13
+ // Neither Octokit nor the underlying fetch implementation bounds how long a
14
+ // single request may hang: a connection that never responds (rather than
15
+ // returning a clean error status) blocks forever. Every caller of the GitHub
16
+ // client boundary — reads and mutations alike — currently awaits that call
17
+ // inline within the runner's single serialized tick, so one hung request
18
+ // stalls Advancement for every other, unrelated work item too.
19
+ export class GitHubRequestTimeoutError extends Error {
20
+ timeoutMs;
21
+ constructor(timeoutMs) {
22
+ super(`GitHub request did not complete within ${timeoutMs}ms`);
23
+ this.timeoutMs = timeoutMs;
24
+ this.name = 'GitHubRequestTimeoutError';
25
+ }
26
+ }
27
+ export function createBoundedGitHubFetch(baseFetch = globalThis.fetch, maximumResponseBytes = defaultMaximumGitHubResponseBytes, timeoutMs = defaultGitHubRequestTimeoutMs) {
13
28
  return async (input, init) => {
14
- const response = await baseFetch(input, init);
29
+ const timeoutController = new AbortController();
30
+ const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
31
+ const signal = init?.signal
32
+ ? AbortSignal.any([init.signal, timeoutController.signal])
33
+ : timeoutController.signal;
34
+ let response;
35
+ try {
36
+ response = await baseFetch(input, { ...init, signal });
37
+ }
38
+ catch (error) {
39
+ if (timeoutController.signal.aborted)
40
+ throw new GitHubRequestTimeoutError(timeoutMs);
41
+ throw error;
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
15
46
  const declaredBytes = contentLength(response);
16
47
  if (declaredBytes !== undefined && declaredBytes > maximumResponseBytes) {
17
48
  await response.body?.cancel();
@@ -1,4 +1,5 @@
1
1
  import { GitHubEventType } from '../contracts/events.js';
2
+ import { createGitHubAdapterHealthRegistry, } from './adapter-health-registry.js';
2
3
  import { issueCommentEventsFor, reviewCommentEventsFor, reviewEventsFor, } from './comment-source.js';
3
4
  import { issueObservation } from './issue-source.js';
4
5
  import { loadWatermark, overlapSince, reportPartialPollFailure, watermarkCheckpoint, } from './poll-watermark.js';
@@ -7,6 +8,7 @@ import { createGitHubRequestCoordinator, } from './request-coordinator.js';
7
8
  export function createGitHubSource(config, client, adapter, requests = createGitHubRequestCoordinator({
8
9
  maxConcurrent: config.polling.maxConcurrent,
9
10
  }), state) {
11
+ const health = state?.health ?? createGitHubAdapterHealthRegistry(config.repositories);
10
12
  // Draft eventIds are already content fingerprints (see issue-source.ts/pr-source.ts),
11
13
  // so the journal itself is idempotent per item. This cache only avoids re-appending
12
14
  // (and re-triggering downstream translation) an unchanged item on every poll within
@@ -25,6 +27,7 @@ export function createGitHubSource(config, client, adapter, requests = createGit
25
27
  repo,
26
28
  watermark: await loadWatermark(state?.checkpoints, adapter, owner, repo),
27
29
  now: state?.now ?? Date.now,
30
+ health,
28
31
  })));
29
32
  for (const result of perRepository)
30
33
  if (result.succeeded)
@@ -40,10 +43,11 @@ export function createGitHubSource(config, client, adapter, requests = createGit
40
43
  });
41
44
  },
42
45
  async markPollPersisted() {
43
- if (state === undefined)
46
+ if (state?.checkpoints === undefined)
44
47
  return;
48
+ const checkpoints = state.checkpoints;
45
49
  for (const [repository, watermark] of pendingWatermarks) {
46
- await state.checkpoints.save(watermarkCheckpoint(adapter, repository), watermark);
50
+ await checkpoints.save(watermarkCheckpoint(adapter, repository), watermark);
47
51
  pendingWatermarks.delete(repository);
48
52
  }
49
53
  },
@@ -72,7 +76,7 @@ function limitGitHubSourceClient(client, requests) {
72
76
  };
73
77
  }
74
78
  async function pollRepository(input) {
75
- const { client, config, adapter, signal, owner, repo } = input;
79
+ const { client, config, adapter, signal, owner, repo, health } = input;
76
80
  const queriedAt = input.now();
77
81
  const context = {
78
82
  client,
@@ -87,10 +91,18 @@ async function pollRepository(input) {
87
91
  client.listIssues(owner, repo, config.polling.maxPerRepo, since),
88
92
  client.listPullRequests(owner, repo, config.polling.maxPerRepo),
89
93
  ]);
90
- if (!isFulfilled(issuesResult))
94
+ if (isFulfilled(issuesResult))
95
+ health.recordSuccess(context.repository, 'poll');
96
+ else {
91
97
  reportPartialPollFailure(context.repository, 'issues');
92
- if (!isFulfilled(pullRequestsResult))
98
+ health.recordFailure(context.repository, 'poll', issuesResult.reason);
99
+ }
100
+ if (isFulfilled(pullRequestsResult))
101
+ health.recordSuccess(context.repository, 'poll');
102
+ else {
93
103
  reportPartialPollFailure(context.repository, 'pull requests');
104
+ health.recordFailure(context.repository, 'poll', pullRequestsResult.reason);
105
+ }
94
106
  const issues = isFulfilled(issuesResult) ? issuesResult.value : [];
95
107
  const pullRequestPayloads = isFulfilled(pullRequestsResult)
96
108
  ? pullRequestsResult.value.filter((pullRequest) => since === undefined || pullRequest.updated_at >= since)
@@ -6,6 +6,7 @@ import { translateGitHubOutbound } from './application/outbound-translator.js';
6
6
  import { createGitHubWakeLabelReconciler } from './application/wake-labels.js';
7
7
  import { gitHubConfigSchema } from './contracts/config.js';
8
8
  import { GitHubEventType } from './contracts/events.js';
9
+ import { createGitHubAdapterHealthRegistry } from './infrastructure/adapter-health-registry.js';
9
10
  import { createGitHubClient } from './infrastructure/client.js';
10
11
  import { createGitHubDelivery } from './infrastructure/delivery.js';
11
12
  import { resolveGitHubCliToken } from './infrastructure/gh-auth.js';
@@ -24,12 +25,14 @@ export const gitHubProviderDefinition = {
24
25
  const requests = createGitHubRequestCoordinator({
25
26
  maxConcurrent: config.polling.maxConcurrent,
26
27
  });
28
+ const health = createGitHubAdapterHealthRegistry(config.repositories);
27
29
  return {
28
30
  adapter,
29
31
  eventTypes: Object.values(GitHubEventType),
30
32
  source: createGitHubSource(config, client, adapter, requests, {
31
33
  checkpoints: services.checkpoints,
32
34
  now: () => services.clock.now().getTime(),
35
+ health,
33
36
  }),
34
37
  maintenance: createGitHubWakeLabelReconciler({
35
38
  orchestration: services.orchestration,
@@ -39,16 +42,29 @@ export const gitHubProviderDefinition = {
39
42
  setLabels: client.setIssueLabels,
40
43
  requests,
41
44
  }),
45
+ health: () => health.snapshotAll(),
42
46
  delivery: createGitHubDelivery(async (intent, idempotencyKey) => {
43
47
  const resource = await services.resources.get(resourceId(intent.resourceId));
44
48
  if (resource === null)
45
49
  throw new Error(`GitHub resource ${intent.resourceId} is unavailable`);
46
- return client.deliver({
47
- ...translateGitHubOutbound(resource, intent, {
48
- publicUiUrl: services.publicUiUrl,
49
- }),
50
- idempotencyKey,
51
- });
50
+ const parsedKey = parsePullRequestKey(resource.externalKey.key);
51
+ const repository = parsedKey === null ? null : `${parsedKey.owner}/${parsedKey.repo}`;
52
+ try {
53
+ const externalId = await client.deliver({
54
+ ...translateGitHubOutbound(resource, intent, {
55
+ publicUiUrl: services.publicUiUrl,
56
+ }),
57
+ idempotencyKey,
58
+ });
59
+ if (repository !== null)
60
+ health.recordSuccess(repository, 'deliver');
61
+ return externalId;
62
+ }
63
+ catch (error) {
64
+ if (repository !== null)
65
+ health.recordFailure(repository, 'deliver', error);
66
+ throw error;
67
+ }
52
68
  }, async (intent) => {
53
69
  if (intent.kind !== BuiltInActivityName.IssueComplete)
54
70
  return null;