@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.
- package/dist/src/bootstrap/composition-root.d.ts +1 -0
- package/dist/src/bootstrap/composition-root.js +1 -1
- package/dist/src/bootstrap/integration-runtime.d.ts +3 -0
- package/dist/src/bootstrap/integration-runtime.js +22 -0
- package/dist/src/bootstrap/surface-api-applications.d.ts +3 -3
- package/dist/src/bootstrap/surface-api-applications.js +12 -7
- package/dist/src/bootstrap/surface-cli-applications.d.ts +1 -0
- package/dist/src/bootstrap/surface-cli-applications.js +18 -0
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/control-plane/application/control-plane-projection.d.ts +2 -2
- package/dist/src/control-plane/application/control-plane-projection.js +5 -5
- package/dist/src/control-plane/application/control-plane-service.d.ts +3 -3
- package/dist/src/control-plane/application/control-plane-service.js +10 -7
- package/dist/src/integrations/application/poll-service.d.ts +3 -1
- package/dist/src/integrations/application/poll-service.js +2 -2
- package/dist/src/integrations/contracts/intake.d.ts +3 -1
- package/dist/src/integrations/contracts/provider.d.ts +7 -0
- package/dist/src/integrations/github/contracts/config.d.ts +3 -0
- package/dist/src/integrations/github/contracts/config.js +4 -0
- package/dist/src/integrations/github/index.d.ts +2 -0
- package/dist/src/integrations/github/index.js +2 -0
- package/dist/src/integrations/github/infrastructure/adapter-health-registry.d.ts +1 -1
- package/dist/src/integrations/github/infrastructure/adapter-health-registry.js +0 -0
- package/dist/src/integrations/github/infrastructure/client.d.ts +16 -0
- package/dist/src/integrations/github/infrastructure/client.js +29 -0
- package/dist/src/integrations/github/infrastructure/source.js +4 -2
- package/dist/src/integrations/github/infrastructure/webhook-state.d.ts +16 -0
- package/dist/src/integrations/github/infrastructure/webhook-state.js +70 -0
- package/dist/src/integrations/github/infrastructure/webhook.d.ts +24 -0
- package/dist/src/integrations/github/infrastructure/webhook.js +148 -0
- package/dist/src/integrations/github/provider.js +2 -0
- package/dist/src/surfaces/api/contracts/control-plane.d.ts +4 -4
- package/dist/src/surfaces/api/http-server.d.ts +1 -0
- package/dist/src/surfaces/api/http-server.js +11 -0
- package/dist/src/surfaces/api/routes/applications.d.ts +2 -2
- package/dist/src/surfaces/api/routes/commands.js +7 -1
- package/dist/src/surfaces/api/routes/control-plane.d.ts +1 -1
- package/dist/src/surfaces/api/routes/control-plane.js +2 -2
- package/dist/src/surfaces/web-assets/assets/{index-Cs3ckFXD.js → index-aL1g5gVQ.js} +1 -1
- package/dist/src/surfaces/web-assets/index.html +1 -1
- 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.
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
363
|
-
await root.controlPlane.
|
|
362
|
+
async pauseDispatch(command) {
|
|
363
|
+
await root.controlPlane.pauseDispatch(command.idempotencyKey);
|
|
364
364
|
return commandAccepted(command, now());
|
|
365
365
|
},
|
|
366
|
-
async
|
|
367
|
-
await root.controlPlane.
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
...(stored?.value.
|
|
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.
|
|
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
|
|
5
|
-
readonly
|
|
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: () => ({
|
|
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
|
-
|
|
39
|
-
|
|
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 {
|
|
60
|
-
return { ...rest,
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
async
|
|
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'
|
|
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) ||
|
|
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
|
|
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
|
|
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;
|
|
Binary file
|
|
@@ -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 (
|
|
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 {};
|