@atolis-hq/wake 0.4.36 → 0.4.37

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 (27) hide show
  1. package/dist/src/bootstrap/composition-root.d.ts +1 -0
  2. package/dist/src/bootstrap/integration-runtime.d.ts +3 -0
  3. package/dist/src/bootstrap/integration-runtime.js +22 -0
  4. package/dist/src/bootstrap/surface-cli-applications.d.ts +1 -0
  5. package/dist/src/bootstrap/surface-cli-applications.js +18 -0
  6. package/dist/src/bootstrap/version.js +1 -1
  7. package/dist/src/integrations/application/poll-service.d.ts +3 -1
  8. package/dist/src/integrations/application/poll-service.js +2 -2
  9. package/dist/src/integrations/contracts/intake.d.ts +3 -1
  10. package/dist/src/integrations/contracts/provider.d.ts +7 -0
  11. package/dist/src/integrations/github/contracts/config.d.ts +3 -0
  12. package/dist/src/integrations/github/contracts/config.js +4 -0
  13. package/dist/src/integrations/github/index.d.ts +2 -0
  14. package/dist/src/integrations/github/index.js +2 -0
  15. package/dist/src/integrations/github/infrastructure/adapter-health-registry.d.ts +1 -1
  16. package/dist/src/integrations/github/infrastructure/adapter-health-registry.js +0 -0
  17. package/dist/src/integrations/github/infrastructure/client.d.ts +16 -0
  18. package/dist/src/integrations/github/infrastructure/client.js +29 -0
  19. package/dist/src/integrations/github/infrastructure/source.js +4 -2
  20. package/dist/src/integrations/github/infrastructure/webhook-state.d.ts +16 -0
  21. package/dist/src/integrations/github/infrastructure/webhook-state.js +70 -0
  22. package/dist/src/integrations/github/infrastructure/webhook.d.ts +24 -0
  23. package/dist/src/integrations/github/infrastructure/webhook.js +148 -0
  24. package/dist/src/integrations/github/provider.js +2 -0
  25. package/dist/src/surfaces/api/http-server.d.ts +1 -0
  26. package/dist/src/surfaces/api/http-server.js +11 -0
  27. package/package.json +1 -1
@@ -71,6 +71,7 @@ export interface CompositionRoot {
71
71
  readonly delivery: DeliveryService;
72
72
  readonly intakePipeline: IntakePipeline;
73
73
  readonly runnerPipeline: RunnerPipeline;
74
+ readonly requestImmediatePoll: (adapter: string) => void;
74
75
  readonly resolveResourceLink: ResourceLinkResolver;
75
76
  }
76
77
  export declare function createCompositionRoot(wakeRoot: string, options?: CompositionRootOptions): Promise<CompositionRoot>;
@@ -19,6 +19,7 @@ export interface IntegrationRuntime {
19
19
  readonly delivery: DeliveryService;
20
20
  readonly intakePipeline: IntakePipeline;
21
21
  readonly runnerPipeline: RunnerPipeline;
22
+ readonly requestImmediatePoll: (adapter: string) => void;
22
23
  }
23
24
  export interface IntegrationRuntimeInput {
24
25
  readonly config: ResolvedWakeModulesConfig;
@@ -46,3 +47,5 @@ export interface IntegrationRuntimeInput {
46
47
  readonly providerDefinitions: readonly ProviderDefinition[];
47
48
  }
48
49
  export declare function composeIntegrationRuntime(input: IntegrationRuntimeInput): Promise<IntegrationRuntime>;
50
+ /** Coalesces provider webhook requests while retaining the source's durable poll semantics. */
51
+ export declare function createImmediatePollRequester(providers: readonly ProviderInstance[], journal: EventJournal, wakeRoot: string): (adapter: string) => void;
@@ -23,6 +23,7 @@ export async function composeIntegrationRuntime(input) {
23
23
  registry.register(definition);
24
24
  const { instances, failures: providerFailures } = registry.compose(await hydrateFakeProviderEvidence(input.wakeRoot, input.config.integrations), {
25
25
  publicUiUrl: input.config.surfaces.web.publicUrl,
26
+ providerStateRoot: join(input.wakeRoot, '.wake'),
26
27
  work: input.work,
27
28
  conversations: input.conversations,
28
29
  resources: input.resources,
@@ -155,6 +156,7 @@ export async function composeIntegrationRuntime(input) {
155
156
  await delivery.deliverNext(signal);
156
157
  }),
157
158
  });
159
+ const requestImmediatePoll = createImmediatePollRequester(providers, input.journal, input.wakeRoot);
158
160
  return {
159
161
  projectionSubscriptions,
160
162
  processors: [
@@ -172,6 +174,26 @@ export async function composeIntegrationRuntime(input) {
172
174
  delivery,
173
175
  intakePipeline,
174
176
  runnerPipeline,
177
+ requestImmediatePoll,
178
+ };
179
+ }
180
+ /** Coalesces provider webhook requests while retaining the source's durable poll semantics. */
181
+ export function createImmediatePollRequester(providers, journal, wakeRoot) {
182
+ const immediatePolls = new Map();
183
+ return (adapter) => {
184
+ if (immediatePolls.has(adapter))
185
+ return;
186
+ const provider = providers.find((candidate) => candidate.adapter === adapter);
187
+ if (provider === undefined)
188
+ return;
189
+ const run = withFileLock(join(wakeRoot, '.wake', 'locks', `poll-${provider.adapter}.lock`), async () => {
190
+ await new PollService(journal, provider).pollOnce(new AbortController().signal, {
191
+ bypassInterval: true,
192
+ });
193
+ })
194
+ .catch((error) => console.error(`Webhook polling ${adapter} failed`, error))
195
+ .finally(() => immediatePolls.delete(adapter));
196
+ immediatePolls.set(adapter, run);
175
197
  };
176
198
  }
177
199
  async function observeMemory(profile, phase, operation) {
@@ -28,6 +28,7 @@ interface ExecutionShutdown {
28
28
  shutdown(): Promise<void>;
29
29
  }
30
30
  export declare function closeResidentRuntime(execution: ExecutionShutdown, closeResources: () => Promise<void>): Promise<void>;
31
+ export declare function createProviderWebhookReceiver(providers: readonly CompositionRoot['providers'][number][], requestImmediatePoll: (adapter: string) => void): (body: Buffer, headers: Readonly<Record<string, string | string[] | undefined>>) => Promise<401 | 404 | 400 | 202>;
31
32
  /** The production maintenance boundary shared by the CLI update application. */
32
33
  export declare function createSelfUpdateQuiescePort(root: CompositionRoot): SelfUpdateQuiescePort;
33
34
  export {};
@@ -55,10 +55,13 @@ export function createSurfaceCliApplications(root, api, now) {
55
55
  },
56
56
  start: {
57
57
  async run(signal, budget) {
58
+ const listening = root.config.surfaces.web.enabled || root.config.surfaces.api.enabled;
58
59
  if (root.config.surfaces.web.enabled)
59
60
  await startHttp({}, true);
60
61
  else if (root.config.surfaces.api.enabled)
61
62
  await startHttp({}, false);
63
+ if (listening)
64
+ await Promise.all(root.providers.flatMap((provider) => provider.webhook === undefined ? [] : [provider.webhook.provision()]));
62
65
  return runResidentLifecycle({
63
66
  signal,
64
67
  budget,
@@ -289,6 +292,7 @@ function createHttpStarter(root, api, servers) {
289
292
  disabled: root.config.surfaces.web.auth.disabled,
290
293
  redeemGrant: (grant) => redeemPairingGrant(root.paths.wakeRoot, grant, undefined, serialiseCredentialMutation),
291
294
  },
295
+ webhookReceiver: createProviderWebhookReceiver(root.providers, root.requestImmediatePoll),
292
296
  ...(assets === undefined ? {} : { assets }),
293
297
  });
294
298
  servers.add(server);
@@ -304,6 +308,20 @@ function createHttpStarter(root, api, servers) {
304
308
  }
305
309
  };
306
310
  }
311
+ export function createProviderWebhookReceiver(providers, requestImmediatePoll) {
312
+ return async (body, headers) => {
313
+ const statuses = await Promise.all(providers.flatMap((provider) => provider.webhook === undefined
314
+ ? []
315
+ : [provider.webhook.receive(body, headers, () => requestImmediatePoll(provider.adapter))]));
316
+ if (statuses.includes(202))
317
+ return 202;
318
+ if (statuses.includes(401))
319
+ return 401;
320
+ if (statuses.includes(400))
321
+ return 400;
322
+ return 404;
323
+ };
324
+ }
307
325
  function serialiseCredentialMutation(lockPath, operation) {
308
326
  return withFileLock(lockPath, operation, { waitMs: 5_000, retryIntervalMs: 10 });
309
327
  }
@@ -124,6 +124,6 @@ export function resolveWakeVersion(options = {}) {
124
124
  return `g${headHash.slice(0, 7)}`;
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "v0.4.36";
127
+ export const wakeVersion = "v0.4.37";
128
128
  /** The registry-installable version used by packaged sandbox images. */
129
129
  export const wakePackageVersion = packageVersion(defaultRepoRoot) ?? wakeVersion;
@@ -9,5 +9,7 @@ export declare class PollService {
9
9
  private readonly instance;
10
10
  constructor(journal: EventJournal, instance: ProviderInstance);
11
11
  /** Persists every independently durable draft and reports partial progress. */
12
- pollOnce(signal: AbortSignal): Promise<PollResult>;
12
+ pollOnce(signal: AbortSignal, options?: {
13
+ readonly bypassInterval?: boolean;
14
+ }): Promise<PollResult>;
13
15
  }
@@ -7,9 +7,9 @@ export class PollService {
7
7
  this.instance = instance;
8
8
  }
9
9
  /** Persists every independently durable draft and reports partial progress. */
10
- async pollOnce(signal) {
10
+ async pollOnce(signal, options) {
11
11
  const adapter = this.instance.adapter;
12
- const drafts = await this.instance.source.poll(signal);
12
+ const drafts = await this.instance.source.poll(signal, options);
13
13
  let appended = 0;
14
14
  let failed = 0;
15
15
  for (const draft of drafts) {
@@ -1,7 +1,9 @@
1
1
  import type { EventData, EventProcessor } from '@atolis-hq/eventing';
2
2
  export type ProviderEventData = EventData<string, unknown>;
3
3
  export interface ExternalEventSource {
4
- poll(signal: AbortSignal): Promise<readonly ProviderEventData[]>;
4
+ poll(signal: AbortSignal, options?: {
5
+ readonly bypassInterval?: boolean;
6
+ }): Promise<readonly ProviderEventData[]>;
5
7
  /** Commits provider cursor state after every draft from the poll is durable. */
6
8
  markPollPersisted?(): Promise<void>;
7
9
  }
@@ -22,6 +22,8 @@ export interface WorkConclusion {
22
22
  export interface ProviderServices {
23
23
  /** Stable operator-configured UI address included in provider notifications when present. */
24
24
  readonly publicUiUrl?: string | undefined;
25
+ /** Durable provider-owned operational state, rooted at .wake/. */
26
+ readonly providerStateRoot: string;
25
27
  readonly work: WorkService;
26
28
  readonly conversations: ConversationService;
27
29
  readonly resources: ResourceService;
@@ -73,6 +75,11 @@ export interface ProviderInstance {
73
75
  readonly health?: () => readonly AdapterHealthCheck[];
74
76
  readonly commands?: () => readonly AdapterCommand[];
75
77
  readonly replyPublication?: ReplyPublicationConfig | undefined;
78
+ readonly webhook?: ProviderWebhook | undefined;
79
+ }
80
+ export interface ProviderWebhook {
81
+ provision(): Promise<void>;
82
+ receive(body: Buffer, headers: Readonly<Record<string, string | string[] | undefined>>, trigger: () => void): Promise<404 | 400 | 401 | 202>;
76
83
  }
77
84
  export type ComposedProviderInstance = Omit<ProviderInstance, 'provider'>;
78
85
  export interface ProviderDefinition<Config = unknown> {
@@ -13,6 +13,9 @@ export declare const gitHubConfigSchema: z.ZodObject<{
13
13
  lookbackMs: z.ZodDefault<z.ZodNumber>;
14
14
  intervalMs: z.ZodDefault<z.ZodNumber>;
15
15
  }, z.core.$strict>>;
16
+ webhooks: z.ZodDefault<z.ZodObject<{
17
+ enabled: z.ZodDefault<z.ZodBoolean>;
18
+ }, z.core.$strict>>;
16
19
  intake: z.ZodDefault<z.ZodArray<z.ZodObject<{
17
20
  where: z.ZodObject<{
18
21
  kind: z.ZodOptional<z.ZodEnum<{
@@ -88,6 +88,10 @@ export const gitHubConfigSchema = z
88
88
  lookbackMs: 60_000,
89
89
  intervalMs: 30_000,
90
90
  }),
91
+ webhooks: z
92
+ .object({ enabled: z.boolean().default(false) })
93
+ .strict()
94
+ .default({ enabled: false }),
91
95
  intake: z.array(intakeRuleSchema).default([]),
92
96
  publication: z
93
97
  .object({
@@ -31,4 +31,6 @@ export * from './infrastructure/issue-source.js';
31
31
  export * from './infrastructure/pr-source.js';
32
32
  export * from './infrastructure/review-source.js';
33
33
  export * from './infrastructure/source.js';
34
+ export * from './infrastructure/webhook.js';
35
+ export * from './infrastructure/webhook-state.js';
34
36
  export * from './provider.js';
@@ -31,4 +31,6 @@ export * from './infrastructure/issue-source.js';
31
31
  export * from './infrastructure/pr-source.js';
32
32
  export * from './infrastructure/review-source.js';
33
33
  export * from './infrastructure/source.js';
34
+ export * from './infrastructure/webhook.js';
35
+ export * from './infrastructure/webhook-state.js';
34
36
  export * from './provider.js';
@@ -1,5 +1,5 @@
1
1
  import type { AdapterHealthCheck } from '../../contracts/provider.js';
2
- export type GitHubHealthChannel = 'poll' | 'deliver';
2
+ export type GitHubHealthChannel = 'poll' | 'deliver' | 'webhook';
3
3
  export interface GitHubAdapterHealthRegistry {
4
4
  recordSuccess(repository: string, channel: GitHubHealthChannel): void;
5
5
  recordFailure(repository: string, channel: GitHubHealthChannel, error: unknown): void;
@@ -3,6 +3,22 @@ import type { GitHubIssueQueryFilters } from '../contracts/issue-query.js';
3
3
  import { GitHubOutboundAction } from '../contracts/vocabulary.js';
4
4
  export declare function redactGitHubRequestMessage(message: unknown): string;
5
5
  export declare function createGitHubClient(token: string): {
6
+ getHook: (owner: string, repo: string, hookId: number) => Promise<void>;
7
+ createHook: (input: {
8
+ owner: string;
9
+ repo: string;
10
+ url: string;
11
+ secret: string;
12
+ events: readonly string[];
13
+ }) => Promise<number>;
14
+ updateHook: (input: {
15
+ owner: string;
16
+ repo: string;
17
+ hookId: number;
18
+ url: string;
19
+ secret: string;
20
+ events: readonly string[];
21
+ }) => Promise<void>;
6
22
  authenticatedLogin: () => Promise<string>;
7
23
  collaboratorPermission: (owner: string, repo: string, login: string) => Promise<ProviderPermission>;
8
24
  getIssueLabelsFresh: (owner: string, repo: string, issueNumber: number) => Promise<string[]>;
@@ -62,6 +62,35 @@ export function createGitHubClient(token) {
62
62
  });
63
63
  },
64
64
  deliver: (command) => deliver(octokit, command),
65
+ ...createGitHubWebhookClient(octokit),
66
+ };
67
+ }
68
+ function createGitHubWebhookClient(octokit) {
69
+ return {
70
+ getHook: async (owner, repo, hookId) => {
71
+ await octokit.rest.repos.getWebhook({ owner, repo, hook_id: hookId });
72
+ },
73
+ createHook: async (input) => {
74
+ const response = await octokit.rest.repos.createWebhook({
75
+ owner: input.owner,
76
+ repo: input.repo,
77
+ name: 'web',
78
+ active: true,
79
+ events: [...input.events],
80
+ config: { url: input.url, content_type: 'json', secret: input.secret },
81
+ });
82
+ return response.data.id;
83
+ },
84
+ updateHook: async (input) => {
85
+ await octokit.rest.repos.updateWebhook({
86
+ owner: input.owner,
87
+ repo: input.repo,
88
+ hook_id: input.hookId,
89
+ active: true,
90
+ events: [...input.events],
91
+ config: { url: input.url, content_type: 'json', secret: input.secret },
92
+ });
93
+ },
65
94
  };
66
95
  }
67
96
  function createGitHubReadClient(octokit, cache) {
@@ -20,9 +20,11 @@ export function createGitHubSource(config, client, adapter, requests = createGit
20
20
  // the same tradeoff already made for lastEventIds above.
21
21
  let lastPolledAt;
22
22
  return {
23
- async poll(signal) {
23
+ async poll(signal, options) {
24
24
  const currentTime = now();
25
- if (lastPolledAt !== undefined && currentTime - lastPolledAt < config.polling.intervalMs) {
25
+ if (options?.bypassInterval !== true &&
26
+ lastPolledAt !== undefined &&
27
+ currentTime - lastPolledAt < config.polling.intervalMs) {
26
28
  return [];
27
29
  }
28
30
  lastPolledAt = currentTime;
@@ -0,0 +1,16 @@
1
+ import type { AdapterId } from '../../contracts/identifiers.js';
2
+ export interface GitHubWebhookState {
3
+ readonly secret: string;
4
+ readonly hookId?: number;
5
+ }
6
+ /** Provider operational state: intentionally outside configuration and projections. */
7
+ export declare class GitHubWebhookStateStore {
8
+ private readonly root;
9
+ private readonly adapter;
10
+ constructor(root: string, adapter: AdapterId);
11
+ load(owner: string, repo: string): Promise<GitHubWebhookState>;
12
+ save(owner: string, repo: string, state: GitHubWebhookState): Promise<void>;
13
+ serialise<Value>(owner: string, repo: string, operation: () => Promise<Value>): Promise<Value>;
14
+ private path;
15
+ private lockPath;
16
+ }
@@ -0,0 +1,70 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ /** Provider operational state: intentionally outside configuration and projections. */
5
+ export class GitHubWebhookStateStore {
6
+ root;
7
+ adapter;
8
+ constructor(root, adapter) {
9
+ this.root = root;
10
+ this.adapter = adapter;
11
+ }
12
+ async load(owner, repo) {
13
+ const path = this.path(owner, repo);
14
+ try {
15
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
16
+ if (typeof parsed === 'object' &&
17
+ parsed !== null &&
18
+ 'secret' in parsed &&
19
+ typeof parsed.secret === 'string' &&
20
+ ('hookId' in parsed ? typeof parsed.hookId === 'number' : true))
21
+ return parsed;
22
+ }
23
+ catch (error) {
24
+ if (error.code !== 'ENOENT')
25
+ throw error;
26
+ }
27
+ const state = { secret: randomBytes(32).toString('hex') };
28
+ await this.save(owner, repo, state);
29
+ return state;
30
+ }
31
+ async save(owner, repo, state) {
32
+ const path = this.path(owner, repo);
33
+ await mkdir(join(this.root, 'github-webhooks', this.adapter), { recursive: true });
34
+ const temporary = `${path}.${process.pid}.tmp`;
35
+ await writeFile(temporary, JSON.stringify(state), { encoding: 'utf8', mode: 0o600 });
36
+ await rename(temporary, path);
37
+ }
38
+ async serialise(owner, repo, operation) {
39
+ await mkdir(join(this.root, 'locks'), { recursive: true });
40
+ const lock = this.lockPath(owner, repo);
41
+ await acquireLock(lock);
42
+ try {
43
+ return await operation();
44
+ }
45
+ finally {
46
+ await unlink(lock);
47
+ }
48
+ }
49
+ path(owner, repo) {
50
+ return join(this.root, 'github-webhooks', this.adapter, `${encodeURIComponent(owner)}--${encodeURIComponent(repo)}.json`);
51
+ }
52
+ lockPath(owner, repo) {
53
+ return join(this.root, 'locks', `github-webhook-${this.adapter}-${encodeURIComponent(owner)}--${encodeURIComponent(repo)}.lock`);
54
+ }
55
+ }
56
+ async function acquireLock(path) {
57
+ const deadline = Date.now() + 5_000;
58
+ while (true) {
59
+ try {
60
+ const handle = await open(path, 'wx', 0o600);
61
+ await handle.close();
62
+ return;
63
+ }
64
+ catch (error) {
65
+ if (error.code !== 'EEXIST' || Date.now() >= deadline)
66
+ throw error;
67
+ await new Promise((resolve) => setTimeout(resolve, 10));
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,24 @@
1
+ import type { AdapterId } from '../../contracts/identifiers.js';
2
+ import type { ProviderWebhook } from '../../contracts/provider.js';
3
+ import type { GitHubConfig } from '../contracts/config.js';
4
+ import type { GitHubAdapterHealthRegistry } from './adapter-health-registry.js';
5
+ interface HookClient {
6
+ getHook(owner: string, repo: string, hookId: number): Promise<void>;
7
+ createHook(input: {
8
+ owner: string;
9
+ repo: string;
10
+ url: string;
11
+ secret: string;
12
+ events: readonly string[];
13
+ }): Promise<number>;
14
+ updateHook(input: {
15
+ owner: string;
16
+ repo: string;
17
+ hookId: number;
18
+ url: string;
19
+ secret: string;
20
+ events: readonly string[];
21
+ }): Promise<void>;
22
+ }
23
+ export declare function createGitHubWebhook(adapter: AdapterId, config: GitHubConfig, publicUrl: string | undefined, root: string, client: HookClient, health: GitHubAdapterHealthRegistry): ProviderWebhook;
24
+ export {};
@@ -0,0 +1,148 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { GitHubWebhookStateStore } from './webhook-state.js';
3
+ const events = new Set([
4
+ 'issues',
5
+ 'issue_comment',
6
+ 'pull_request',
7
+ 'pull_request_review',
8
+ 'pull_request_review_comment',
9
+ 'check_run',
10
+ 'status',
11
+ ]);
12
+ export function createGitHubWebhook(adapter, config, publicUrl, root, client, health) {
13
+ const state = new GitHubWebhookStateStore(root, adapter);
14
+ const endpoint = publicUrl === undefined ? undefined : webhookEndpoint(publicUrl);
15
+ return {
16
+ async provision() {
17
+ for (const repository of config.repositories) {
18
+ if (!config.webhooks.enabled)
19
+ continue;
20
+ if (endpoint === undefined) {
21
+ health.recordFailure(`${repository.owner}/${repository.repo}`, 'webhook', new Error('surfaces.web.publicUrl is not configured'));
22
+ continue;
23
+ }
24
+ try {
25
+ await state.serialise(repository.owner, repository.repo, () => provisionRepository(repository, endpoint, state, client, health));
26
+ }
27
+ catch (error) {
28
+ health.recordFailure(`${repository.owner}/${repository.repo}`, 'webhook', error);
29
+ }
30
+ }
31
+ },
32
+ async receive(body, headers, trigger) {
33
+ if (!config.webhooks.enabled)
34
+ return 404;
35
+ const signature = header(headers, 'x-hub-signature-256');
36
+ if (signature === undefined)
37
+ return 401;
38
+ const event = header(headers, 'x-github-event');
39
+ if (event === undefined)
40
+ return 400;
41
+ const fullName = parseRepository(body);
42
+ if (fullName === null)
43
+ return 400;
44
+ if (fullName === undefined)
45
+ return 404;
46
+ const [owner, repo] = fullName.split('/');
47
+ if (owner === undefined ||
48
+ repo === undefined ||
49
+ !config.repositories.some((value) => value.owner === owner && value.repo === repo))
50
+ return 404;
51
+ return state.serialise(owner, repo, async () => {
52
+ const saved = await state.load(owner, repo);
53
+ const expected = `sha256=${createHmac('sha256', saved.secret).update(body).digest('hex')}`;
54
+ if (!safeEqual(signature, expected))
55
+ return 401;
56
+ if (!events.has(event))
57
+ return 404;
58
+ trigger();
59
+ return 202;
60
+ });
61
+ },
62
+ };
63
+ }
64
+ async function provisionRepository(repository, endpoint, state, client, health) {
65
+ const scope = `${repository.owner}/${repository.repo}`;
66
+ try {
67
+ const saved = await state.load(repository.owner, repository.repo);
68
+ if (saved.hookId !== undefined &&
69
+ (await updateManagedHook(repository, endpoint, saved, client))) {
70
+ health.recordSuccess(scope, 'webhook');
71
+ return;
72
+ }
73
+ const hookId = await client.createHook({
74
+ ...repository,
75
+ url: endpoint,
76
+ secret: saved.secret,
77
+ events: [...events],
78
+ });
79
+ await state.save(repository.owner, repository.repo, { ...saved, hookId });
80
+ health.recordSuccess(scope, 'webhook');
81
+ }
82
+ catch (error) {
83
+ health.recordFailure(scope, 'webhook', error);
84
+ }
85
+ }
86
+ async function updateManagedHook(repository, endpoint, saved, client) {
87
+ if (saved.hookId === undefined)
88
+ return false;
89
+ try {
90
+ await client.getHook(repository.owner, repository.repo, saved.hookId);
91
+ await client.updateHook({
92
+ ...repository,
93
+ hookId: saved.hookId,
94
+ url: endpoint,
95
+ secret: saved.secret,
96
+ events: [...events],
97
+ });
98
+ return true;
99
+ }
100
+ catch (error) {
101
+ if (statusOf(error) === 404)
102
+ return false;
103
+ throw error;
104
+ }
105
+ }
106
+ function header(headers, name) {
107
+ const value = headers[name] ?? headers[name.toLowerCase()];
108
+ return Array.isArray(value) ? value[0] : value;
109
+ }
110
+ function repositoryName(value) {
111
+ if (typeof value !== 'object' ||
112
+ value === null ||
113
+ !('full_name' in value) ||
114
+ typeof value.full_name !== 'string')
115
+ return undefined;
116
+ return value.full_name;
117
+ }
118
+ function parseRepository(body) {
119
+ try {
120
+ const payload = JSON.parse(body.toString('utf8'));
121
+ if (typeof payload !== 'object' || payload === null || !('repository' in payload))
122
+ return null;
123
+ return repositoryName(payload.repository);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ function safeEqual(left, right) {
130
+ const a = Buffer.from(left);
131
+ const b = Buffer.from(right);
132
+ return a.length === b.length && timingSafeEqual(a, b);
133
+ }
134
+ function statusOf(error) {
135
+ return typeof error === 'object' &&
136
+ error !== null &&
137
+ 'status' in error &&
138
+ typeof error.status === 'number'
139
+ ? error.status
140
+ : undefined;
141
+ }
142
+ function webhookEndpoint(publicUrl) {
143
+ const base = new URL(publicUrl);
144
+ base.pathname = `${base.pathname.replace(/\/$/, '')}/webhooks/github`;
145
+ base.search = '';
146
+ base.hash = '';
147
+ return base.href;
148
+ }
@@ -14,6 +14,7 @@ import { createGitHubDelivery } from './infrastructure/delivery.js';
14
14
  import { resolveGitHubCliToken } from './infrastructure/gh-auth.js';
15
15
  import { createGitHubRequestCoordinator } from './infrastructure/request-coordinator.js';
16
16
  import { createGitHubSource } from './infrastructure/source.js';
17
+ import { createGitHubWebhook } from './infrastructure/webhook.js';
17
18
  export const gitHubProviderDefinition = {
18
19
  provider: 'github',
19
20
  eventTypes: Object.values(GitHubEventType),
@@ -59,6 +60,7 @@ export const gitHubProviderDefinition = {
59
60
  requests,
60
61
  }),
61
62
  health: () => health.snapshotAll(),
63
+ webhook: createGitHubWebhook(adapter, config, services.publicUiUrl, services.providerStateRoot, client, health),
62
64
  commands: () => [
63
65
  ...Object.values(GitHubBuiltInCommand).map((syntax) => ({ syntax })),
64
66
  ...config.commands.map((syntax) => ({ syntax })),
@@ -24,6 +24,7 @@ export interface SurfaceHttpServerOptions {
24
24
  readonly disabled: boolean;
25
25
  readonly redeemGrant: (grant: string) => Promise<boolean>;
26
26
  };
27
+ readonly webhookReceiver?: (body: Buffer, headers: Readonly<Record<string, string | string[] | undefined>>) => Promise<number>;
27
28
  }
28
29
  /**
29
30
  * The production HTTP surface. Public login routes are declared separately;
@@ -41,6 +41,17 @@ export function createSurfaceHttpServer(options) {
41
41
  return sendFastifyJson(reply, errorResult(new MalformedJsonError()), false);
42
42
  return sendFastifyJson(reply, errorResult(error), false);
43
43
  });
44
+ if (options.webhookReceiver !== undefined) {
45
+ app.register((webhooks, _pluginOptions, done) => {
46
+ webhooks.removeAllContentTypeParsers();
47
+ webhooks.addContentTypeParser('*', { parseAs: 'buffer' }, (_request, body, callback) => callback(null, body));
48
+ webhooks.post('/github', { bodyLimit: 64 * 1024 }, async (request, reply) => {
49
+ const status = await options.webhookReceiver(request.body, request.headers);
50
+ return reply.code(status).send();
51
+ });
52
+ done();
53
+ }, { prefix: '/webhooks' });
54
+ }
44
55
  app.get('/api/v1/auth/session', async (request, reply) => {
45
56
  if (authDisabled)
46
57
  return { authenticated: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.4.36",
3
+ "version": "0.4.37",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {