@atolis-hq/wake 0.4.36 → 0.4.38

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 (34) 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/orchestration/application/orchestration-service.js +1 -1
  26. package/dist/src/orchestration/application/watch-reactor.js +11 -0
  27. package/dist/src/orchestration/contracts/event-decoder.d.ts +1 -0
  28. package/dist/src/orchestration/contracts/event-decoder.js +1 -0
  29. package/dist/src/orchestration/contracts/events.d.ts +2 -0
  30. package/dist/src/orchestration/contracts/views.d.ts +1 -0
  31. package/dist/src/orchestration/domain/workflow-instance-events.js +1 -0
  32. package/dist/src/surfaces/api/http-server.d.ts +1 -0
  33. package/dist/src/surfaces/api/http-server.js +11 -0
  34. 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.38";
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 })),
@@ -154,7 +154,7 @@ export class OrchestrationService {
154
154
  throw new GroupBudgetExtensionIneligibleError('Workflow has no durable gate definition to extend');
155
155
  const group = childOrchestrationGroupStream(parent.orchestrationGroupId, exhausted.watchId);
156
156
  await this.claims.grantBudget(group, exhausted.requestId, context, authority);
157
- await this.waitForSignal(parent.workflowInstanceId, parent.waitingFor, context);
157
+ await this.waitForSignal(parent.workflowInstanceId, { ...parent.waitingFor, suppressWatchDispatch: true }, context);
158
158
  return this.requestChild({
159
159
  ...exhausted,
160
160
  workflowName,
@@ -11,6 +11,8 @@ const reconciliationCheckpoint = 'reconciler:orchestration.watch';
11
11
  const noWatchEventTypes = () => [];
12
12
  export function createWatchReactor(orchestration, watchEventTypes = noWatchEventTypes, runs) {
13
13
  const react = async (event, context) => {
14
+ if (suppressesWatchDispatch(event))
15
+ return;
14
16
  await dispatch(orchestration, runs, event, context, await orchestration.listWatchMatches(event, context));
15
17
  };
16
18
  return {
@@ -45,6 +47,10 @@ export function createWatchReconciler(orchestration, journal, checkpoints, runs)
45
47
  ]));
46
48
  let reconciled = 0;
47
49
  for (const event of events) {
50
+ if (suppressesWatchDispatch(event)) {
51
+ await checkpoints.save(reconciliationCheckpoint, event.globalPosition);
52
+ continue;
53
+ }
48
54
  const context = reactorCommandContext(event, ApprovalAuthorityKind.Watch, 'watch-reactor');
49
55
  const matches = (await Promise.all((await orchestration.listWatchMatches(event, context)).map(async (match) => {
50
56
  const watches = waiting.get(match.parent.workflowInstanceId);
@@ -63,6 +69,11 @@ export function createWatchReconciler(orchestration, journal, checkpoints, runs)
63
69
  },
64
70
  };
65
71
  }
72
+ function suppressesWatchDispatch(event) {
73
+ const owned = selectOrchestrationEvent(event);
74
+ return (owned?.event.eventType === OrchestrationEventType.SignalWaitStarted &&
75
+ owned.event.payload.suppressWatchDispatch === true);
76
+ }
66
77
  async function dispatch(orchestration, runs, event, context, matches) {
67
78
  const causalCycle = orchestrationCausalCycleId(selectOrchestrationEvent(event));
68
79
  const sourceWorkflowInstanceId = await resolveTriggerWorkflowInstanceId(event, runs);
@@ -71,6 +71,7 @@ export declare const activityRequestedSchema: z.ZodObject<{
71
71
  }, z.core.$strict>;
72
72
  export declare const expectationSchema: z.ZodObject<{
73
73
  signalKind: z.ZodPipe<z.ZodString, z.ZodTransform<import("./identifiers.js").SignalName, string>>;
74
+ suppressWatchDispatch: z.ZodOptional<z.ZodLiteral<true>>;
74
75
  resourceId: z.ZodOptional<z.ZodString>;
75
76
  revision: z.ZodOptional<z.ZodString>;
76
77
  from: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -121,6 +121,7 @@ const resourceTransitionSchema = z
121
121
  export const expectationSchema = z
122
122
  .object({
123
123
  signalKind: brandedStringSchema(signalName),
124
+ suppressWatchDispatch: z.literal(true).optional(),
124
125
  resourceId: z.string().optional(),
125
126
  revision: z.string().optional(),
126
127
  from: z.array(approvalAuthoritySchema).min(1).optional(),
@@ -187,6 +187,8 @@ export interface ActivityRequestedPayload {
187
187
  }
188
188
  export interface SignalExpectation {
189
189
  readonly signalKind: SignalName;
190
+ /** Re-arm a blocked gate without treating its wait event as a new watch trigger. */
191
+ readonly suppressWatchDispatch?: true | undefined;
190
192
  readonly resourceId?: string | undefined;
191
193
  readonly revision?: string | undefined;
192
194
  readonly from?: readonly ApprovalAuthority[] | undefined;
@@ -17,6 +17,7 @@ export interface ActivityActivationView {
17
17
  }
18
18
  export interface SignalExpectationView {
19
19
  readonly signalKind: SignalName;
20
+ readonly suppressWatchDispatch?: true;
20
21
  readonly intentEventId?: string;
21
22
  readonly resourceId?: string;
22
23
  readonly revision?: string;
@@ -177,6 +177,7 @@ function applySignalWaitStarted(state, event) {
177
177
  state.status = orchestrationStatusTransitions[OrchestrationEventType.SignalWaitStarted];
178
178
  state.waitingFor = {
179
179
  signalKind: event.payload.signalKind,
180
+ ...(event.payload.suppressWatchDispatch === true ? { suppressWatchDispatch: true } : {}),
180
181
  ...(event.payload.resourceId === undefined ? {} : { resourceId: event.payload.resourceId }),
181
182
  ...(event.payload.revision === undefined ? {} : { revision: event.payload.revision }),
182
183
  ...(event.payload.from === undefined ? {} : { from: event.payload.from }),
@@ -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.38",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {