@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.
- package/dist/src/bootstrap/surface-api-applications.js +10 -1
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/integrations/application/adapter-health-tracker.js +43 -0
- package/dist/src/integrations/contracts/provider.js +8 -5
- package/dist/src/integrations/github/infrastructure/adapter-health-registry.js +0 -0
- package/dist/src/integrations/github/infrastructure/bounded-fetch.js +33 -2
- package/dist/src/integrations/github/infrastructure/source.js +17 -5
- package/dist/src/integrations/github/provider.js +22 -6
- package/dist/src/surfaces/web-assets/assets/{index-DHGUQVg7.js → index-CBE1yusZ.js} +1 -1
- package/dist/src/surfaces/web-assets/index.html +1 -1
- package/package.json +1 -1
|
@@ -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')
|
|
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
|
};
|
|
@@ -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(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
94
|
+
if (isFulfilled(issuesResult))
|
|
95
|
+
health.recordSuccess(context.repository, 'poll');
|
|
96
|
+
else {
|
|
91
97
|
reportPartialPollFailure(context.repository, 'issues');
|
|
92
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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;
|