@atolis-hq/wake 0.4.35 → 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 (41) hide show
  1. package/dist/src/bootstrap/composition-root.d.ts +1 -0
  2. package/dist/src/bootstrap/composition-root.js +1 -1
  3. package/dist/src/bootstrap/integration-runtime.d.ts +3 -0
  4. package/dist/src/bootstrap/integration-runtime.js +22 -0
  5. package/dist/src/bootstrap/surface-api-applications.d.ts +3 -3
  6. package/dist/src/bootstrap/surface-api-applications.js +12 -7
  7. package/dist/src/bootstrap/surface-cli-applications.d.ts +1 -0
  8. package/dist/src/bootstrap/surface-cli-applications.js +18 -0
  9. package/dist/src/bootstrap/version.js +1 -1
  10. package/dist/src/control-plane/application/control-plane-projection.d.ts +2 -2
  11. package/dist/src/control-plane/application/control-plane-projection.js +5 -5
  12. package/dist/src/control-plane/application/control-plane-service.d.ts +3 -3
  13. package/dist/src/control-plane/application/control-plane-service.js +10 -7
  14. package/dist/src/integrations/application/poll-service.d.ts +3 -1
  15. package/dist/src/integrations/application/poll-service.js +2 -2
  16. package/dist/src/integrations/contracts/intake.d.ts +3 -1
  17. package/dist/src/integrations/contracts/provider.d.ts +7 -0
  18. package/dist/src/integrations/github/contracts/config.d.ts +3 -0
  19. package/dist/src/integrations/github/contracts/config.js +4 -0
  20. package/dist/src/integrations/github/index.d.ts +2 -0
  21. package/dist/src/integrations/github/index.js +2 -0
  22. package/dist/src/integrations/github/infrastructure/adapter-health-registry.d.ts +1 -1
  23. package/dist/src/integrations/github/infrastructure/adapter-health-registry.js +0 -0
  24. package/dist/src/integrations/github/infrastructure/client.d.ts +16 -0
  25. package/dist/src/integrations/github/infrastructure/client.js +29 -0
  26. package/dist/src/integrations/github/infrastructure/source.js +4 -2
  27. package/dist/src/integrations/github/infrastructure/webhook-state.d.ts +16 -0
  28. package/dist/src/integrations/github/infrastructure/webhook-state.js +70 -0
  29. package/dist/src/integrations/github/infrastructure/webhook.d.ts +24 -0
  30. package/dist/src/integrations/github/infrastructure/webhook.js +148 -0
  31. package/dist/src/integrations/github/provider.js +2 -0
  32. package/dist/src/surfaces/api/contracts/control-plane.d.ts +4 -4
  33. package/dist/src/surfaces/api/http-server.d.ts +1 -0
  34. package/dist/src/surfaces/api/http-server.js +11 -0
  35. package/dist/src/surfaces/api/routes/applications.d.ts +2 -2
  36. package/dist/src/surfaces/api/routes/commands.js +7 -1
  37. package/dist/src/surfaces/api/routes/control-plane.d.ts +1 -1
  38. package/dist/src/surfaces/api/routes/control-plane.js +2 -2
  39. package/dist/src/surfaces/web-assets/assets/{index-Cs3ckFXD.js → index-aL1g5gVQ.js} +1 -1
  40. package/dist/src/surfaces/web-assets/index.html +1 -1
  41. 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>;
@@ -103,7 +103,7 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
103
103
  },
104
104
  }, activities, config.execution, orchestration);
105
105
  const controlPlane = createControlPlaneService({ journal, clock, ids });
106
- const isRuntimePaused = async () => (await controlPlane.isPaused()) || (await maintenance.read()) !== null;
106
+ const isRuntimePaused = async () => (await controlPlane.isDispatchPaused()) || (await maintenance.read()) !== null;
107
107
  const runnerControls = createRunnerControlService({
108
108
  journal,
109
109
  clock,
@@ -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) {
@@ -11,9 +11,9 @@ export declare function readControlPlaneStatus(root: CompositionRoot, now: () =>
11
11
  startedAt: string;
12
12
  };
13
13
  updatedAt: string;
14
- reason?: string;
15
- pausedUntil?: string;
16
- paused: boolean;
14
+ dispatchPauseReason?: string;
15
+ dispatchPausedUntil?: string;
16
+ dispatchPaused: boolean;
17
17
  };
18
18
  meta: import("../surfaces/index.js").ResponseMeta;
19
19
  }>;
@@ -359,12 +359,12 @@ function toHealthErrorText(value) {
359
359
  function createControlPlaneApplications(root, now) {
360
360
  return {
361
361
  status: () => readControlPlaneStatus(root, now),
362
- async pause(command) {
363
- await root.controlPlane.pause(command.idempotencyKey);
362
+ async pauseDispatch(command) {
363
+ await root.controlPlane.pauseDispatch(command.idempotencyKey);
364
364
  return commandAccepted(command, now());
365
365
  },
366
- async resume(command) {
367
- await root.controlPlane.resume(command.idempotencyKey);
366
+ async resumeDispatch(command) {
367
+ await root.controlPlane.resumeDispatch(command.idempotencyKey);
368
368
  return commandAccepted(command, now());
369
369
  },
370
370
  async clearMaintenance(command) {
@@ -402,9 +402,14 @@ export async function readControlPlaneStatus(root, now) {
402
402
  const lease = await root.maintenance.read();
403
403
  return {
404
404
  data: {
405
- paused: stored?.value.pausedUntil !== null && stored?.value.pausedUntil !== undefined,
406
- ...(stored?.value.pausedUntil == null ? {} : { pausedUntil: stored.value.pausedUntil }),
407
- ...(stored?.value.reason === undefined ? {} : { reason: stored.value.reason }),
405
+ dispatchPaused: stored?.value.dispatchPausedUntil !== null &&
406
+ stored?.value.dispatchPausedUntil !== undefined,
407
+ ...(stored?.value.dispatchPausedUntil == null
408
+ ? {}
409
+ : { dispatchPausedUntil: stored.value.dispatchPausedUntil }),
410
+ ...(stored?.value.dispatchPauseReason === undefined
411
+ ? {}
412
+ : { dispatchPauseReason: stored.value.dispatchPauseReason }),
408
413
  updatedAt: meta.asOf,
409
414
  ...(lease === null
410
415
  ? {}
@@ -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.35";
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;
@@ -1,8 +1,8 @@
1
1
  import type { EventJournal, ProjectionDefinition } from '@atolis-hq/eventing';
2
2
  import { ControlEventType, type ControlEvent } from '../contracts/events.js';
3
3
  export interface ControlPlaneView {
4
- readonly pausedUntil: string | null;
5
- readonly reason?: string;
4
+ readonly dispatchPausedUntil: string | null;
5
+ readonly dispatchPauseReason?: string;
6
6
  readonly runnerPauses: Readonly<Record<string, {
7
7
  readonly cause: Extract<ControlEvent['event'], {
8
8
  readonly eventType: typeof ControlEventType.RunnerPaused;
@@ -26,7 +26,7 @@ export const controlPlaneProjection = {
26
26
  select(event) {
27
27
  return selectControlEvent(event) === null ? null : { key: 'global' };
28
28
  },
29
- initial: () => ({ pausedUntil: null, runnerPauses: {} }),
29
+ initial: () => ({ dispatchPausedUntil: null, runnerPauses: {} }),
30
30
  project(previous, envelope) {
31
31
  const event = selectControlEvent(envelope);
32
32
  if (event === null)
@@ -35,8 +35,8 @@ export const controlPlaneProjection = {
35
35
  case ControlEventType.DispatchPaused:
36
36
  return {
37
37
  ...previous,
38
- pausedUntil: event.event.payload.resumeAt,
39
- reason: event.event.payload.reason,
38
+ dispatchPausedUntil: event.event.payload.resumeAt,
39
+ dispatchPauseReason: event.event.payload.reason,
40
40
  };
41
41
  case ControlEventType.DispatchResumed:
42
42
  return withoutDispatchPause(previous);
@@ -56,8 +56,8 @@ export const controlPlaneProjection = {
56
56
  },
57
57
  };
58
58
  function withoutDispatchPause(previous) {
59
- const { reason: _, ...rest } = previous;
60
- return { ...rest, pausedUntil: null };
59
+ const { dispatchPauseReason: _, ...rest } = previous;
60
+ return { ...rest, dispatchPausedUntil: null };
61
61
  }
62
62
  export const controlPlaneProjectionDefinitions = [
63
63
  controlPlaneProjection,
@@ -1,9 +1,9 @@
1
1
  import { type EventJournal } from '@atolis-hq/eventing';
2
2
  import { type Clock, type IdGenerator } from '../../kernel/index.js';
3
3
  export interface ControlPlaneService {
4
- pause(idempotencyKey: string): Promise<void>;
5
- resume(idempotencyKey: string): Promise<void>;
6
- isPaused(): Promise<boolean>;
4
+ pauseDispatch(idempotencyKey: string): Promise<void>;
5
+ resumeDispatch(idempotencyKey: string): Promise<void>;
6
+ isDispatchPaused(): Promise<boolean>;
7
7
  }
8
8
  export declare function createControlPlaneService(input: {
9
9
  readonly journal: EventJournal;
@@ -3,13 +3,13 @@ import { createControlPlaneEventData } from '../contracts/event-factory.js';
3
3
  import { ControlEventType, selectControlEvent } from '../contracts/events.js';
4
4
  import { ControlStreamKind, controlPlaneStream } from '../contracts/streams.js';
5
5
  export function createControlPlaneService(input) {
6
- // isPaused() is checked many times per pipeline run, so memoize by
6
+ // isDispatchPaused() is checked many times per pipeline run, so memoize by
7
7
  // journal position to skip the read entirely when nothing has moved.
8
8
  let cached;
9
9
  return {
10
- pause: (key) => change(input, key, 'pause'),
11
- resume: (key) => change(input, key, 'resume'),
12
- async isPaused() {
10
+ pauseDispatch: (key) => change(input, key, 'pause-dispatch'),
11
+ resumeDispatch: (key) => change(input, key, 'resume-dispatch'),
12
+ async isDispatchPaused() {
13
13
  const position = await input.journal.latestGlobalPosition();
14
14
  if (cached !== undefined && cached.position === position)
15
15
  return cached.paused;
@@ -36,12 +36,15 @@ function isPausedIn(events) {
36
36
  async function change(input, idempotencyKey, operation) {
37
37
  const stream = controlPlaneStream();
38
38
  const events = await input.journal.readStream(stream);
39
- const eventType = operation === 'pause' ? ControlEventType.DispatchPaused : ControlEventType.DispatchResumed;
39
+ const eventType = operation === 'pause-dispatch'
40
+ ? ControlEventType.DispatchPaused
41
+ : ControlEventType.DispatchResumed;
40
42
  const correlation = correlationId(`control:${operation}:${idempotencyKey}`);
41
43
  if (events.some((event) => event.event.eventType === eventType && event.event.correlationId === correlation))
42
44
  return;
43
45
  const currentlyPaused = isPausedIn(events);
44
- if ((operation === 'pause' && currentlyPaused) || (operation === 'resume' && !currentlyPaused))
46
+ if ((operation === 'pause-dispatch' && currentlyPaused) ||
47
+ (operation === 'resume-dispatch' && !currentlyPaused))
45
48
  return;
46
49
  const occurredAt = input.clock.now().toISOString();
47
50
  const context = {
@@ -50,7 +53,7 @@ async function change(input, idempotencyKey, operation) {
50
53
  occurredAt,
51
54
  actor: { kind: EventActorKind.Operator, id: 'web' },
52
55
  };
53
- const event = operation === 'pause'
56
+ const event = operation === 'pause-dispatch'
54
57
  ? createControlPlaneEventData({
55
58
  eventId: `${context.commandId}:${ControlEventType.DispatchPaused}`,
56
59
  eventType: ControlEventType.DispatchPaused,
@@ -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 {};