@atolis-hq/wake 0.3.45 → 0.3.47

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.
@@ -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 = "g8f84f8d";
111
+ export const wakeVersion = "gfc95a01";
@@ -20,6 +20,7 @@ export class PollService {
20
20
  await this.journal.append(stream, existing.length, [{ ...draft, stream }]);
21
21
  appended += 1;
22
22
  }
23
+ await this.instance.source.markPollPersisted?.();
23
24
  return appended;
24
25
  }
25
26
  }
@@ -2,10 +2,10 @@
2
2
  import { PullRequestState } from '../../../activities/index.js';
3
3
  import { GitHubListState } from '../contracts/vocabulary.js';
4
4
  import { fetchPaginatedWithEtag, fetchWithEtag } from './etag-cache.js';
5
- export function listIssues(octokit, cache, owner, repo, maxResults) {
5
+ export function listIssues(octokit, cache, owner, repo, maxResults, since) {
6
6
  return fetchPaginatedWithEtag({
7
7
  cache,
8
- key: `issues:${owner}/${repo}`,
8
+ key: `issues:${owner}/${repo}:since:${since ?? 'bootstrap'}`,
9
9
  maxResults,
10
10
  pages: (headers) => octokit.paginate.iterator(octokit.rest.issues.listForRepo, {
11
11
  owner,
@@ -14,6 +14,7 @@ export function listIssues(octokit, cache, owner, repo, maxResults) {
14
14
  sort: 'updated',
15
15
  direction: 'desc',
16
16
  per_page: Math.min(maxResults, 100),
17
+ ...(since === undefined ? {} : { since }),
17
18
  ...(headers === undefined ? {} : { headers }),
18
19
  }),
19
20
  }).then((items) => items.map(normalizeIssue));
@@ -41,6 +42,8 @@ export async function listPullRequests(octokit, cache, owner, repo, maxResults)
41
42
  owner,
42
43
  repo,
43
44
  state: GitHubListState.All,
45
+ sort: 'updated',
46
+ direction: 'desc',
44
47
  per_page: Math.min(maxResults, 100),
45
48
  ...(headers === undefined ? {} : { headers }),
46
49
  }),
@@ -208,15 +211,16 @@ function normalizePullRequestState(pullRequest) {
208
211
  : PullRequestState.Open,
209
212
  };
210
213
  }
211
- export async function listIssueComments(octokit, cache, owner, repo, issueNumber, pageSize) {
214
+ export async function listIssueComments(octokit, cache, owner, repo, issueNumber, options) {
212
215
  const comments = await fetchPaginatedWithEtag({
213
216
  cache,
214
- key: `issue-comments:${owner}/${repo}#${issueNumber}`,
217
+ key: `issue-comments:${owner}/${repo}#${issueNumber}:since:${options.since ?? 'bootstrap'}`,
215
218
  pages: (headers) => octokit.paginate.iterator(octokit.rest.issues.listComments, {
216
219
  owner,
217
220
  repo,
218
221
  issue_number: issueNumber,
219
- per_page: Math.min(pageSize, 100),
222
+ per_page: Math.min(options.pageSize, 100),
223
+ ...(options.since === undefined ? {} : { since: options.since }),
220
224
  ...(headers === undefined ? {} : { headers }),
221
225
  }),
222
226
  });
@@ -34,9 +34,12 @@ export function createGitHubClient(token) {
34
34
  const cache = createEtagCache();
35
35
  return {
36
36
  authenticatedLogin: async () => (await octokit.rest.users.getAuthenticated()).data.login,
37
- listIssues: (owner, repo, maxResults) => listIssues(octokit, cache, owner, repo, maxResults),
37
+ listIssues: (owner, repo, maxResults, since) => listIssues(octokit, cache, owner, repo, maxResults, since),
38
38
  listPullRequests: (owner, repo, maxResults) => listPullRequests(octokit, cache, owner, repo, maxResults),
39
- listIssueComments: (owner, repo, issueNumber, pageSize) => listIssueComments(octokit, cache, owner, repo, issueNumber, pageSize),
39
+ listIssueComments: (owner, repo, issueNumber, pageSize, since) => listIssueComments(octokit, cache, owner, repo, issueNumber, {
40
+ pageSize,
41
+ ...(since === undefined ? {} : { since }),
42
+ }),
40
43
  listReviewComments: (owner, repo, pullNumber, pageSize) => listReviewComments(octokit, cache, owner, repo, pullNumber, pageSize),
41
44
  collaboratorPermission: async (owner, repo, login) => {
42
45
  const { data } = await octokit.rest.repos.getCollaboratorPermissionLevel({
@@ -0,0 +1,86 @@
1
+ import { ReviewerAuthorizationSource, } from '../../../activities/index.js';
2
+ import { issueCommentObservation } from './issue-source.js';
3
+ import { mergeBatches, reportPartialPollFailure } from './poll-watermark.js';
4
+ import { githubReviewObservation } from './review-source.js';
5
+ export async function reviewCommentEventsFor(context, pullRequests) {
6
+ if (context.client.listReviewComments === undefined)
7
+ return { drafts: [], succeeded: true };
8
+ const items = await Promise.all(pullRequests.map(async (pullRequest) => {
9
+ try {
10
+ const comments = await context.client.listReviewComments(context.owner, context.repo, pullRequest.number, context.config.polling.commentPageSize);
11
+ return {
12
+ succeeded: true,
13
+ drafts: (await Promise.all(comments.map((comment) => issueCommentEventsForComment(context, pullRequest, comment)))).flat(),
14
+ };
15
+ }
16
+ catch {
17
+ reportPartialPollFailure(context.repository, `pull-request review comments #${pullRequest.number}`);
18
+ return { drafts: [], succeeded: false };
19
+ }
20
+ }));
21
+ return mergeBatches(items);
22
+ }
23
+ export async function reviewEventsFor(context, pullRequests) {
24
+ const items = await Promise.all(pullRequests.map(async (pullRequest) => {
25
+ try {
26
+ const reviews = await context.client.listReviews(context.owner, context.repo, pullRequest.number, context.config.polling.commentPageSize);
27
+ return {
28
+ succeeded: true,
29
+ drafts: reviews.flatMap((review) => githubReviewObservation({
30
+ repository: context.repository,
31
+ pullRequest,
32
+ review,
33
+ authorizedReviewers: [],
34
+ })),
35
+ };
36
+ }
37
+ catch {
38
+ reportPartialPollFailure(context.repository, `pull-request reviews #${pullRequest.number}`);
39
+ return { drafts: [], succeeded: false };
40
+ }
41
+ }));
42
+ return mergeBatches(items);
43
+ }
44
+ export async function issueCommentEventsFor(context, issues, since) {
45
+ const items = await Promise.all(issues.map(async (issue) => {
46
+ try {
47
+ const comments = await context.client.listIssueComments(context.owner, context.repo, issue.number, context.config.polling.commentPageSize, since);
48
+ return {
49
+ succeeded: true,
50
+ drafts: (await Promise.all(comments.map((comment) => issueCommentEventsForComment(context, issue, comment)))).flat(),
51
+ };
52
+ }
53
+ catch {
54
+ reportPartialPollFailure(context.repository, `issue comments #${issue.number}`);
55
+ return { drafts: [], succeeded: false };
56
+ }
57
+ }));
58
+ return mergeBatches(items);
59
+ }
60
+ async function issueCommentEventsForComment(context, issue, comment) {
61
+ const authorization = await retryAuthorization(context, comment);
62
+ const event = issueCommentObservation({
63
+ repository: context.repository,
64
+ issue,
65
+ comment,
66
+ ...(authorization === undefined ? {} : { authorization }),
67
+ ...(context.adapter === undefined ? {} : { adapter: context.adapter }),
68
+ });
69
+ return event === null ? [] : [event];
70
+ }
71
+ async function retryAuthorization(context, comment) {
72
+ if (comment.body?.trim().toLowerCase() !== '/retry')
73
+ return undefined;
74
+ const login = comment.user?.login;
75
+ if (login === undefined || context.client.collaboratorPermission === undefined)
76
+ return { source: ReviewerAuthorizationSource.None };
77
+ try {
78
+ return {
79
+ source: ReviewerAuthorizationSource.ProviderPermission,
80
+ permission: await context.client.collaboratorPermission(context.owner, context.repo, login),
81
+ };
82
+ }
83
+ catch {
84
+ return { source: ReviewerAuthorizationSource.None };
85
+ }
86
+ }
@@ -0,0 +1,22 @@
1
+ export function mergeBatches(batches) {
2
+ return {
3
+ drafts: batches.flatMap((batch) => batch.drafts),
4
+ succeeded: batches.every((batch) => batch.succeeded),
5
+ };
6
+ }
7
+ export function watermarkCheckpoint(adapter, repository) {
8
+ return `source:github:${adapter ?? 'github'}:${encodeURIComponent(repository)}`;
9
+ }
10
+ export async function loadWatermark(checkpoints, adapter, owner, repo) {
11
+ if (checkpoints === undefined)
12
+ return 0;
13
+ return checkpoints.load(watermarkCheckpoint(adapter, `${owner}/${repo}`));
14
+ }
15
+ export function overlapSince(watermark, overlapMs) {
16
+ if (!Number.isFinite(watermark) || watermark <= 0)
17
+ return undefined;
18
+ return new Date(Math.max(0, watermark - overlapMs)).toISOString();
19
+ }
20
+ export function reportPartialPollFailure(repository, query) {
21
+ process.stderr.write(`GitHub poll partial failure for ${repository}: ${query}; preserving watermark for replay\n`);
22
+ }
@@ -1,33 +1,37 @@
1
- import { ReviewerAuthorizationSource, } from '../../../activities/index.js';
2
1
  import { GitHubEventType } from '../contracts/events.js';
3
- import { issueCommentObservation, issueObservation } from './issue-source.js';
2
+ import { issueCommentEventsFor, reviewCommentEventsFor, reviewEventsFor, } from './comment-source.js';
3
+ import { issueObservation } from './issue-source.js';
4
+ import { loadWatermark, overlapSince, reportPartialPollFailure, watermarkCheckpoint, } from './poll-watermark.js';
4
5
  import { createGitHubPullRequestSource } from './pr-source.js';
5
6
  import { createGitHubRequestCoordinator, } from './request-coordinator.js';
6
- import { githubReviewObservation } from './review-source.js';
7
7
  export function createGitHubSource(config, client, adapter, requests = createGitHubRequestCoordinator({
8
8
  maxConcurrent: config.polling.maxConcurrent,
9
- })) {
10
- let nextPollAt = 0;
9
+ }), state) {
11
10
  // Draft eventIds are already content fingerprints (see issue-source.ts/pr-source.ts),
12
11
  // so the journal itself is idempotent per item. This cache only avoids re-appending
13
12
  // (and re-triggering downstream translation) an unchanged item on every poll within
14
13
  // a single process lifetime — it's a perf optimization, not a correctness dependency,
15
14
  // so it's safe for it to reset on restart.
16
15
  const lastEventIds = new Map();
16
+ const pendingWatermarks = new Map();
17
17
  return {
18
18
  async poll(signal) {
19
- if (Date.now() < nextPollAt)
20
- return [];
21
- nextPollAt = Date.now() + config.polling.lookbackMs;
22
- const perRepository = await Promise.all(config.repositories.map(({ owner, repo }) => pollRepository({
19
+ const perRepository = await Promise.all(config.repositories.map(async ({ owner, repo }) => pollRepository({
23
20
  client: limitGitHubSourceClient(client, requests),
24
21
  config,
25
22
  adapter,
26
23
  signal,
27
24
  owner,
28
25
  repo,
26
+ watermark: await loadWatermark(state?.checkpoints, adapter, owner, repo),
27
+ now: state?.now ?? Date.now,
29
28
  })));
30
- return perRepository.flat().filter((draft) => {
29
+ for (const result of perRepository)
30
+ if (result.succeeded)
31
+ pendingWatermarks.set(result.repository, result.completedAt);
32
+ return perRepository
33
+ .flatMap((result) => result.drafts)
34
+ .filter((draft) => {
31
35
  if (draft.eventType !== GitHubEventType.WorkObserved)
32
36
  return true;
33
37
  const prior = lastEventIds.get(draft.payload.externalKey);
@@ -35,17 +39,25 @@ export function createGitHubSource(config, client, adapter, requests = createGit
35
39
  return prior !== draft.eventId;
36
40
  });
37
41
  },
42
+ async markPollPersisted() {
43
+ if (state === undefined)
44
+ return;
45
+ for (const [repository, watermark] of pendingWatermarks) {
46
+ await state.checkpoints.save(watermarkCheckpoint(adapter, repository), watermark);
47
+ pendingWatermarks.delete(repository);
48
+ }
49
+ },
38
50
  };
39
51
  }
40
52
  function limitGitHubSourceClient(client, requests) {
41
53
  return {
42
54
  ...client,
43
- listIssues: (owner, repo, maxResults) => requests.run(() => client.listIssues(owner, repo, maxResults)),
55
+ listIssues: (owner, repo, maxResults, since) => requests.run(() => client.listIssues(owner, repo, maxResults, since)),
44
56
  listPullRequests: (owner, repo, maxResults) => requests.run(() => client.listPullRequests(owner, repo, maxResults)),
45
57
  listCheckRunsForRef: (owner, repo, ref) => requests.run(() => client.listCheckRunsForRef(owner, repo, ref)),
46
58
  getCombinedStatusForRef: (owner, repo, ref) => requests.run(() => client.getCombinedStatusForRef(owner, repo, ref)),
47
59
  listPullRequestFiles: (owner, repo, pullNumber) => requests.run(() => client.listPullRequestFiles(owner, repo, pullNumber)),
48
- listIssueComments: (owner, repo, issueNumber, pageSize) => requests.run(() => client.listIssueComments(owner, repo, issueNumber, pageSize)),
60
+ listIssueComments: (owner, repo, issueNumber, pageSize, since) => requests.run(() => client.listIssueComments(owner, repo, issueNumber, pageSize, since)),
49
61
  listReviews: (owner, repo, pullNumber, pageSize) => requests.run(() => client.listReviews(owner, repo, pullNumber, pageSize)),
50
62
  ...(client.listReviewComments === undefined
51
63
  ? {}
@@ -69,21 +81,39 @@ async function pollRepository(input) {
69
81
  repo,
70
82
  repository: `${owner}/${repo}`,
71
83
  };
72
- try {
73
- const pullRequestPayloads = await client.listPullRequests(owner, repo, config.polling.maxPerRepo);
74
- const [issues, pullRequests, reviews, reviewComments] = await Promise.all([
75
- client.listIssues(owner, repo, config.polling.maxPerRepo),
76
- createGitHubPullRequestSource({
77
- client: { ...client, listPullRequests: async () => pullRequestPayloads },
78
- repository: context.repository,
79
- maxResults: config.polling.maxPerRepo,
80
- ...(adapter === undefined ? {} : { adapter }),
81
- }).poll(signal),
82
- reviewEventsFor(context, pullRequestPayloads).catch(() => []),
83
- reviewCommentEventsFor(context, pullRequestPayloads).catch(() => []),
84
- ]);
85
- const issueComments = await issueCommentEventsFor(context, issues);
86
- return [
84
+ const since = overlapSince(input.watermark, config.polling.lookbackMs);
85
+ const [issuesResult, pullRequestsResult] = await Promise.allSettled([
86
+ client.listIssues(owner, repo, config.polling.maxPerRepo, since),
87
+ client.listPullRequests(owner, repo, config.polling.maxPerRepo),
88
+ ]);
89
+ if (!isFulfilled(issuesResult))
90
+ reportPartialPollFailure(context.repository, 'issues');
91
+ if (!isFulfilled(pullRequestsResult))
92
+ reportPartialPollFailure(context.repository, 'pull requests');
93
+ const issues = isFulfilled(issuesResult) ? issuesResult.value : [];
94
+ const pullRequestPayloads = isFulfilled(pullRequestsResult)
95
+ ? pullRequestsResult.value.filter((pullRequest) => since === undefined || pullRequest.updated_at >= since)
96
+ : [];
97
+ const [pullRequests, reviews, reviewComments, issueComments] = await Promise.all([
98
+ createGitHubPullRequestSource({
99
+ client: { ...client, listPullRequests: async () => pullRequestPayloads },
100
+ repository: context.repository,
101
+ maxResults: config.polling.maxPerRepo,
102
+ ...(adapter === undefined ? {} : { adapter }),
103
+ }).poll(signal),
104
+ reviewEventsFor(context, pullRequestPayloads),
105
+ reviewCommentEventsFor(context, pullRequestPayloads),
106
+ issueCommentEventsFor(context, issues, since),
107
+ ]);
108
+ return {
109
+ repository: context.repository,
110
+ completedAt: input.now(),
111
+ succeeded: isFulfilled(issuesResult) &&
112
+ isFulfilled(pullRequestsResult) &&
113
+ reviews.succeeded &&
114
+ reviewComments.succeeded &&
115
+ issueComments.succeeded,
116
+ drafts: [
87
117
  ...issues
88
118
  .filter((issue) => issue.pull_request === undefined)
89
119
  .map((issue) => issueObservation({
@@ -92,67 +122,12 @@ async function pollRepository(input) {
92
122
  ...(adapter === undefined ? {} : { adapter }),
93
123
  })),
94
124
  ...pullRequests,
95
- ...reviews,
96
- ...reviewComments,
97
- ...issueComments,
98
- ];
99
- }
100
- catch {
101
- return [];
102
- }
103
- }
104
- async function reviewCommentEventsFor(context, pullRequests) {
105
- if (context.client.listReviewComments === undefined)
106
- return [];
107
- const items = await Promise.all(pullRequests.map(async (pullRequest) => (async () => {
108
- const comments = await context.client.listReviewComments(context.owner, context.repo, pullRequest.number, context.config.polling.commentPageSize);
109
- return (await Promise.all(comments.map((comment) => issueCommentEventsForComment(context, pullRequest, comment)))).flat();
110
- })()));
111
- return items.flat();
112
- }
113
- async function reviewEventsFor(context, pullRequestPayloads) {
114
- const items = await Promise.all(pullRequestPayloads.map(async (pullRequest) => {
115
- const reviewEvents = await context.client.listReviews(context.owner, context.repo, pullRequest.number, context.config.polling.commentPageSize);
116
- return reviewEvents.flatMap((review) => githubReviewObservation({
117
- repository: context.repository,
118
- pullRequest,
119
- review,
120
- authorizedReviewers: [],
121
- }));
122
- }));
123
- return items.flat();
124
- }
125
- async function issueCommentEventsFor(context, issues) {
126
- const items = await Promise.all(issues.map(async (issue) => (async () => {
127
- const comments = await context.client.listIssueComments(context.owner, context.repo, issue.number, context.config.polling.commentPageSize);
128
- return (await Promise.all(comments.map((comment) => issueCommentEventsForComment(context, issue, comment)))).flat();
129
- })()));
130
- return items.flat();
131
- }
132
- async function issueCommentEventsForComment(context, issue, comment) {
133
- const authorization = await retryAuthorization(context, comment);
134
- const event = issueCommentObservation({
135
- repository: context.repository,
136
- issue,
137
- comment,
138
- ...(authorization === undefined ? {} : { authorization }),
139
- ...(context.adapter === undefined ? {} : { adapter: context.adapter }),
140
- });
141
- return event === null ? [] : [event];
125
+ ...reviews.drafts,
126
+ ...reviewComments.drafts,
127
+ ...issueComments.drafts,
128
+ ],
129
+ };
142
130
  }
143
- async function retryAuthorization(context, comment) {
144
- if (comment.body?.trim().toLowerCase() !== '/retry')
145
- return undefined;
146
- const login = comment.user?.login;
147
- if (login === undefined || context.client.collaboratorPermission === undefined)
148
- return { source: ReviewerAuthorizationSource.None };
149
- try {
150
- return {
151
- source: ReviewerAuthorizationSource.ProviderPermission,
152
- permission: await context.client.collaboratorPermission(context.owner, context.repo, login),
153
- };
154
- }
155
- catch {
156
- return { source: ReviewerAuthorizationSource.None };
157
- }
131
+ function isFulfilled(result) {
132
+ return 'value' in result;
158
133
  }
@@ -27,7 +27,10 @@ export const gitHubProviderDefinition = {
27
27
  return {
28
28
  adapter,
29
29
  eventTypes: Object.values(GitHubEventType),
30
- source: createGitHubSource(config, client, adapter, requests),
30
+ source: createGitHubSource(config, client, adapter, requests, {
31
+ checkpoints: services.checkpoints,
32
+ now: () => services.clock.now().getTime(),
33
+ }),
31
34
  maintenance: createGitHubWakeLabelReconciler({
32
35
  orchestration: services.orchestration,
33
36
  resources: services.resources,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {