@atolis-hq/wake 0.3.81 → 0.3.83

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 (28) hide show
  1. package/dist/src/bootstrap/index.js +3 -3
  2. package/dist/src/bootstrap/integration-runtime.js +1 -1
  3. package/dist/src/bootstrap/persistence-composition.js +1 -0
  4. package/dist/src/bootstrap/surface-api-applications.js +15 -0
  5. package/dist/src/bootstrap/surface-cli-applications.js +12 -8
  6. package/dist/src/bootstrap/version.js +1 -1
  7. package/dist/src/control-plane/application/control-plane-service.js +23 -9
  8. package/dist/src/control-plane/contracts/config.js +6 -3
  9. package/dist/src/integrations/delivery/application/delivery-outcome-reactor.js +52 -10
  10. package/dist/src/integrations/github/application/inbound-comment-syntax.js +9 -7
  11. package/dist/src/integrations/github/application/inbound-review-signals.js +9 -5
  12. package/dist/src/integrations/github/application/review-command-translator.js +5 -2
  13. package/dist/src/integrations/github/contracts/config.js +5 -0
  14. package/dist/src/integrations/github/contracts/vocabulary.js +12 -0
  15. package/dist/src/integrations/github/infrastructure/comment-source.js +2 -1
  16. package/dist/src/integrations/github/infrastructure/review-source.js +3 -3
  17. package/dist/src/integrations/github/provider.js +5 -0
  18. package/dist/src/kernel/contracts/journal-change-signal.js +1 -0
  19. package/dist/src/kernel/index.js +2 -0
  20. package/dist/src/kernel/infrastructure/journal-change-signal.js +30 -0
  21. package/dist/src/persistence/filesystem/file-event-journal.js +6 -1
  22. package/dist/src/persistence/memory/in-memory-event-journal.js +9 -1
  23. package/dist/src/surfaces/api/routes/read.js +2 -0
  24. package/dist/src/surfaces/api/routes/system.js +5 -1
  25. package/dist/src/surfaces/web-assets/assets/index-DPstcTxm.js +1090 -0
  26. package/dist/src/surfaces/web-assets/index.html +1 -1
  27. package/package.json +1 -1
  28. package/dist/src/surfaces/web-assets/assets/index-CRUtOkcs.js +0 -1090
@@ -4,8 +4,8 @@ import { createRuntimeProjectionRunner } from './projection-runtime.js';
4
4
  export function composeDeliveryService(dependencies) {
5
5
  return new DeliveryService(dependencies);
6
6
  }
7
- export function composeDeliveryOutcomeReactor(journal, checkpoints, orchestration) {
8
- return new DeliveryOutcomeReactor(journal, checkpoints, orchestration);
7
+ export function composeDeliveryOutcomeReactor(journal, checkpoints, orchestration, projections) {
8
+ return new DeliveryOutcomeReactor(journal, checkpoints, orchestration, projections);
9
9
  }
10
10
  export function composeDeliveryRuntime(dependencies) {
11
11
  const projectionRunner = createRuntimeProjectionRunner(dependencies.journal, dependencies.projections, dependencies.checkpoints);
@@ -16,7 +16,7 @@ export function composeDeliveryRuntime(dependencies) {
16
16
  adapter: dependencies.adapter,
17
17
  now: dependencies.now,
18
18
  });
19
- const reactor = composeDeliveryOutcomeReactor(dependencies.journal, dependencies.checkpoints, dependencies.orchestration);
19
+ const reactor = composeDeliveryOutcomeReactor(dependencies.journal, dependencies.checkpoints, dependencies.orchestration, dependencies.projections);
20
20
  return {
21
21
  async runOnce(signal) {
22
22
  await projectionRunner.runRegisteredOnce();
@@ -120,7 +120,7 @@ export async function composeIntegrationRuntime(input) {
120
120
  await resourceTransitions.drain();
121
121
  return operation();
122
122
  });
123
- const outcomes = new DeliveryOutcomeReactor(input.journal, input.checkpoints, input.orchestration);
123
+ const outcomes = new DeliveryOutcomeReactor(input.journal, input.checkpoints, input.orchestration, input.projections);
124
124
  const catchUpProjections = async () => {
125
125
  await projectionRunner.runRegisteredOnce();
126
126
  };
@@ -13,6 +13,7 @@ function serializeJournalAppends(journal) {
13
13
  readStream: (stream) => journal.readStream(stream),
14
14
  readAll: (afterGlobalPosition, limit) => journal.readAll(afterGlobalPosition, limit),
15
15
  latestGlobalPosition: () => journal.latestGlobalPosition(),
16
+ changeSignal: journal.changeSignal,
16
17
  ...(journal.readLatest === undefined
17
18
  ? {}
18
19
  : {
@@ -229,6 +229,21 @@ function createSystemApplications(root, now) {
229
229
  meta: sampledMeta(sampledAt),
230
230
  };
231
231
  },
232
+ async commands() {
233
+ const sampledAt = now();
234
+ return {
235
+ data: {
236
+ adapters: root.providers
237
+ .filter((instance) => instance.commands !== undefined)
238
+ .map((instance) => ({
239
+ adapter: instance.adapter,
240
+ provider: instance.provider,
241
+ commands: instance.commands(),
242
+ })),
243
+ },
244
+ meta: sampledMeta(sampledAt),
245
+ };
246
+ },
232
247
  };
233
248
  }
234
249
  function commandAccepted(command, acceptedAt) {
@@ -39,12 +39,13 @@ export function createSurfaceCliApplications(root, api, now) {
39
39
  // hammered every cycle instead of given a chance to recover.
40
40
  const runnerResident = new ResidentHost(runnerTick, (signal, { consecutiveIdleTicks, consecutiveErrorTicks }) => {
41
41
  if (consecutiveErrorTicks > 0)
42
- return sleepUntilAbort(signal, nextIdleBackoffMs(root.config.controlPlane.resident, consecutiveErrorTicks));
42
+ return sleepUntilAbort(signal, nextPollBackoffMs(root.config.controlPlane.resident, consecutiveErrorTicks));
43
+ // Genuine idle (no errors): wait for the journal to actually change.
43
44
  return consecutiveIdleTicks === 0
44
45
  ? Promise.resolve()
45
- : sleepUntilAbort(signal, root.config.controlPlane.resident?.idleBackoffMs ?? 1000);
46
+ : root.journal.changeSignal.waitForChange(signal, JOURNAL_WAIT_FALLBACK_MS);
46
47
  }, reportResidentError('runner'));
47
- const intakeResident = new ResidentHost(intakeHost, (signal, { consecutiveIdleTicks }) => sleepUntilAbort(signal, nextIdleBackoffMs(root.config.controlPlane.resident, consecutiveIdleTicks)), reportResidentError('intake'));
48
+ const intakeResident = new ResidentHost(intakeHost, (signal, { consecutiveIdleTicks }) => sleepUntilAbort(signal, nextPollBackoffMs(root.config.controlPlane.resident, consecutiveIdleTicks)), reportResidentError('intake'));
48
49
  const servers = new Set();
49
50
  const startHttp = createHttpStarter(root, api, servers);
50
51
  return {
@@ -134,8 +135,11 @@ export function createSurfaceCliApplications(root, api, now) {
134
135
  operational: createOperationalApplications(root),
135
136
  };
136
137
  }
138
+ // Safety net for a missed in-process notify() or a cross-process writer the
139
+ // EventEmitter can't see; a real append wakes waiters within milliseconds
140
+ // regardless, so there's no operational reason to make this configurable.
141
+ const JOURNAL_WAIT_FALLBACK_MS = 30_000;
137
142
  export async function runProjectionPump(root, signal) {
138
- const intervalMs = 1000;
139
143
  while (!signal.aborted) {
140
144
  try {
141
145
  await root.projectionRunner.runRegisteredOnce();
@@ -143,12 +147,12 @@ export async function runProjectionPump(root, signal) {
143
147
  catch (error) {
144
148
  process.stderr.write(`Wake projection pump failed: ${error instanceof Error ? error.message : String(error)}\n`);
145
149
  }
146
- await sleepUntilAbort(signal, intervalMs);
150
+ await root.journal.changeSignal.waitForChange(signal, JOURNAL_WAIT_FALLBACK_MS);
147
151
  }
148
152
  }
149
- function nextIdleBackoffMs(resident, consecutiveIdleTicks) {
150
- const baseMs = resident?.idleBackoffMs ?? 1000;
151
- const maxMs = resident?.maxIdleBackoffMs ?? baseMs * 16;
153
+ function nextPollBackoffMs(resident, consecutiveIdleTicks) {
154
+ const baseMs = resident?.pollBackoffMs ?? 1000;
155
+ const maxMs = resident?.maxPollBackoffMs ?? baseMs * 16;
152
156
  return Math.min(baseMs * 2 ** Math.min(consecutiveIdleTicks, 20), maxMs);
153
157
  }
154
158
  function sleepUntilAbort(signal, milliseconds) {
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "gb8f09ad";
111
+ export const wakeVersion = "gedf016c";
@@ -2,22 +2,36 @@ import { EventActorKind, correlationId, } from '../../kernel/index.js';
2
2
  import { ControlEventType, createControlEventDraft, selectControlEvent, } from '../contracts/events.js';
3
3
  import { controlPlaneStream } from '../contracts/streams.js';
4
4
  export function createControlPlaneService(input) {
5
+ // isPaused() is checked many times per pipeline run, so memoize by
6
+ // journal position to skip the read entirely when nothing has moved.
7
+ let cached;
5
8
  return {
6
9
  pause: (key) => change(input, key, 'pause'),
7
10
  resume: (key) => change(input, key, 'resume'),
8
11
  async isPaused() {
9
- let paused = false;
10
- for (const envelope of await input.journal.readStream(controlPlaneStream())) {
11
- const event = selectControlEvent(envelope);
12
- if (event?.eventType === ControlEventType.DispatchPaused)
13
- paused = true;
14
- if (event?.eventType === ControlEventType.DispatchResumed)
15
- paused = false;
16
- }
12
+ const position = await input.journal.latestGlobalPosition();
13
+ if (cached !== undefined && cached.position === position)
14
+ return cached.paused;
15
+ const paused = await currentIsPaused(input.journal);
16
+ cached = { position, paused };
17
17
  return paused;
18
18
  },
19
19
  };
20
20
  }
21
+ async function currentIsPaused(journal) {
22
+ return isPausedIn(await journal.readStream(controlPlaneStream()));
23
+ }
24
+ function isPausedIn(events) {
25
+ let paused = false;
26
+ for (const envelope of events) {
27
+ const event = selectControlEvent(envelope);
28
+ if (event?.eventType === ControlEventType.DispatchPaused)
29
+ paused = true;
30
+ if (event?.eventType === ControlEventType.DispatchResumed)
31
+ paused = false;
32
+ }
33
+ return paused;
34
+ }
21
35
  async function change(input, idempotencyKey, operation) {
22
36
  const stream = controlPlaneStream();
23
37
  const events = await input.journal.readStream(stream);
@@ -25,7 +39,7 @@ async function change(input, idempotencyKey, operation) {
25
39
  const correlation = correlationId(`control:${operation}:${idempotencyKey}`);
26
40
  if (events.some((event) => event.eventType === eventType && event.correlationId === correlation))
27
41
  return;
28
- const currentlyPaused = await createControlPlaneService(input).isPaused();
42
+ const currentlyPaused = isPausedIn(events);
29
43
  if ((operation === 'pause' && currentlyPaused) || (operation === 'resume' && !currentlyPaused))
30
44
  return;
31
45
  const occurredAt = input.clock.now().toISOString();
@@ -14,10 +14,13 @@ export const controlPlaneConfigSchema = z
14
14
  schedules: z.array(scheduleSchema).default([]),
15
15
  resident: z
16
16
  .object({
17
- idleBackoffMs: z.number().int().positive().default(1000),
18
- maxIdleBackoffMs: z.number().int().positive().optional(),
17
+ // Backoff for the resident loop's own retry cadence when idle or
18
+ // erroring, not a per-adapter rate limit (e.g.
19
+ // integrations.github.polling.intervalMs gates the actual call).
20
+ pollBackoffMs: z.number().int().positive().default(1000),
21
+ maxPollBackoffMs: z.number().int().positive().optional(),
19
22
  })
20
23
  .strict()
21
- .default({ idleBackoffMs: 1000 }),
24
+ .default({ pollBackoffMs: 1000 }),
22
25
  })
23
26
  .strict();
@@ -1,34 +1,63 @@
1
1
  import { ActivityOutcomeKind, activationId } from '../../../activities/index.js';
2
- import { EventActorKind } from '../../../kernel/index.js';
2
+ import { EventActorKind, } from '../../../kernel/index.js';
3
3
  import { ActivityActivationStatus, workflowInstanceId, } from '../../../orchestration/index.js';
4
4
  import { DeliveryEventType, selectDeliveryEvent } from '../contracts/events.js';
5
5
  import { DeliveryResultKind } from '../contracts/vocabulary.js';
6
6
  const deliveryResultSignalKind = 'delivery-result';
7
+ const pendingNamespace = 'reactor:delivery-outcomes:pending';
8
+ const pendingKey = 'pending-confirmations';
7
9
  export class DeliveryOutcomeReactor {
8
10
  journal;
9
11
  checkpoints;
10
12
  orchestration;
11
- constructor(journal, checkpoints, orchestration) {
13
+ projections;
14
+ constructor(journal, checkpoints, orchestration, projections) {
12
15
  this.journal = journal;
13
16
  this.checkpoints = checkpoints;
14
17
  this.orchestration = orchestration;
18
+ this.projections = projections;
15
19
  }
16
20
  async runOnce() {
17
21
  const consumer = 'reactor:delivery-outcomes';
18
22
  const events = await this.journal.readAll(await this.checkpoints.load(consumer));
19
- const resolvedDeliveryEventIds = new Set();
23
+ const resolved = new Set();
24
+ const pending = new Map((await this.loadPending()).map((event) => [event.eventId, event]));
20
25
  for (const event of events) {
21
- await this.reconcile(event, resolvedDeliveryEventIds);
26
+ const matched = await this.reconcile(event, resolved);
27
+ if (matched === false) {
28
+ // reconcile() only ever returns false for a delivery-terminal event
29
+ // (selectDeliveryEvent(event) !== null), so this lookup can't miss.
30
+ const delivery = selectDeliveryEvent(event);
31
+ pending.set(delivery.eventId, event);
32
+ }
22
33
  await this.checkpoints.save(consumer, event.globalPosition);
23
34
  }
24
- for (const event of await this.journal.readAll(0))
25
- await this.reconcile(event, resolvedDeliveryEventIds);
35
+ // Catches a confirmation checkpointed before its workflow reached
36
+ // "waiting for delivery" — re-checked here on every call rather than
37
+ // relying on the incremental pass above, which never revisits a
38
+ // position once checkpointed.
39
+ for (const [id, pendingEvent] of pending) {
40
+ if (resolved.has(id)) {
41
+ pending.delete(id);
42
+ continue;
43
+ }
44
+ const matched = await this.reconcile(pendingEvent, resolved);
45
+ if (matched === true)
46
+ pending.delete(id);
47
+ }
48
+ await this.savePending([...pending.values()]);
26
49
  return events.length;
27
50
  }
51
+ // true: resolved (accepted this call, or already accepted earlier this
52
+ // same call). false: a delivery-terminal event whose workflow isn't
53
+ // waiting on it yet — belongs in the pending set for a later retry. null:
54
+ // not a delivery-terminal event at all.
28
55
  async reconcile(event, seen) {
29
56
  const delivery = selectDeliveryEvent(event);
30
- if (delivery === null || seen.has(delivery.eventId))
31
- return;
57
+ if (delivery === null)
58
+ return null;
59
+ if (seen.has(delivery.eventId))
60
+ return true;
32
61
  const outcome = delivery.eventType === DeliveryEventType.Confirmed ||
33
62
  (delivery.eventType === DeliveryEventType.Reconciled &&
34
63
  delivery.payload.result === DeliveryResultKind.Confirmed)
@@ -37,13 +66,13 @@ export class DeliveryOutcomeReactor {
37
66
  ? { kind: ActivityOutcomeKind.Failed, data: { reason: delivery.payload.code } }
38
67
  : null;
39
68
  if (outcome === null)
40
- return;
69
+ return null;
41
70
  const command = {
42
71
  workflowInstanceId: workflowInstanceId(delivery.payload.workflowInstanceId),
43
72
  activationId: activationId(delivery.payload.activationId),
44
73
  };
45
74
  if (!(await this.isAwaitingThisDelivery(command, delivery.payload.intentEventId)))
46
- return;
75
+ return false;
47
76
  seen.add(delivery.eventId);
48
77
  await this.orchestration.acceptOutcome({ ...command, outcome }, {
49
78
  commandId: delivery.eventId,
@@ -51,6 +80,7 @@ export class DeliveryOutcomeReactor {
51
80
  actor: { kind: EventActorKind.System, id: 'delivery-outcome-reactor' },
52
81
  occurredAt: event.recordedAt,
53
82
  });
83
+ return true;
54
84
  }
55
85
  /**
56
86
  * A delivery's own completion may only resolve the activation that
@@ -66,4 +96,16 @@ export class DeliveryOutcomeReactor {
66
96
  view.waitingFor?.signalKind === deliveryResultSignalKind &&
67
97
  view.waitingFor.intentEventId === intentEventId);
68
98
  }
99
+ async loadPending() {
100
+ const stored = await this.projections.read(pendingNamespace, pendingKey);
101
+ return stored?.value.events ?? [];
102
+ }
103
+ async savePending(events) {
104
+ await this.projections.write({
105
+ namespace: pendingNamespace,
106
+ key: pendingKey,
107
+ lastGlobalPosition: 0,
108
+ value: { events },
109
+ });
110
+ }
69
111
  }
@@ -1,15 +1,17 @@
1
1
  import { ReviewActorKind } from '../../../activities/index.js';
2
+ import { GitHubBuiltInCommand } from '../contracts/vocabulary.js';
2
3
  export function isHumanNonWakeReply(actorKind, body) {
3
4
  return actorKind === ReviewActorKind.Human && !body.includes('<!-- wake:');
4
5
  }
5
6
  export function recognizedCommand(body) {
6
7
  const normalized = body.trim().toLowerCase();
7
- if (normalized === '/approved')
8
- return '/approved';
9
- if (normalized === '/changes' || normalized.startsWith('/changes '))
10
- return '/changes';
11
- if (normalized === '/retry')
12
- return '/retry';
8
+ if (normalized === GitHubBuiltInCommand.Approved)
9
+ return GitHubBuiltInCommand.Approved;
10
+ if (normalized === GitHubBuiltInCommand.Changes ||
11
+ normalized.startsWith(`${GitHubBuiltInCommand.Changes} `))
12
+ return GitHubBuiltInCommand.Changes;
13
+ if (normalized === GitHubBuiltInCommand.Retry)
14
+ return GitHubBuiltInCommand.Retry;
13
15
  return null;
14
16
  }
15
17
  export function isPlainReply(body) {
@@ -17,5 +19,5 @@ export function isPlainReply(body) {
17
19
  return normalized.length > 0 && !normalized.startsWith('/');
18
20
  }
19
21
  export function shouldResumeBlockedStage(command, plainReply) {
20
- return command === '/changes' || plainReply;
22
+ return command === GitHubBuiltInCommand.Changes || plainReply;
21
23
  }
@@ -3,7 +3,7 @@ import { ActivityOutcomeKind, ReviewActorKind, ReviewDecisionKind, ReviewerAutho
3
3
  import { ApprovalAuthorityKind, selectOperatorRetryTarget, } from '../../../orchestration/index.js';
4
4
  import { BuiltInResourceKind, ResourceCorrelationRole, ResourceStreamKind, resourceId, } from '../../../resources/index.js';
5
5
  import { WorkStatus } from '../../../work/index.js';
6
- import { UnknownGitHubIdentity } from '../contracts/vocabulary.js';
6
+ import { GitHubBuiltInCommand, UnknownGitHubIdentity } from '../contracts/vocabulary.js';
7
7
  import { isHumanNonWakeReply, isPlainReply, recognizedCommand, shouldResumeBlockedStage, } from './inbound-comment-syntax.js';
8
8
  import { commandContext } from './inbound-context.js';
9
9
  import { ignoreIneligibleOperatorRetry } from './operator-retry-command.js';
@@ -77,7 +77,7 @@ async function applyIssueReviewSignal(input) {
77
77
  const resource = await resources.get(resourceIdValue);
78
78
  const command = recognizedCommand(event.payload.body);
79
79
  const plainReply = isPlainReply(event.payload.body);
80
- if (command === '/retry') {
80
+ if (command === GitHubBuiltInCommand.Retry) {
81
81
  return applyIssueRetrySignal({
82
82
  event,
83
83
  resources,
@@ -93,7 +93,9 @@ async function applyIssueReviewSignal(input) {
93
93
  work,
94
94
  orchestration,
95
95
  resourceId: resourceIdValue,
96
- outcome: command === '/approved' ? ActivityOutcomeKind.Done : ActivityOutcomeKind.Rejected,
96
+ outcome: command === GitHubBuiltInCommand.Approved
97
+ ? ActivityOutcomeKind.Done
98
+ : ActivityOutcomeKind.Rejected,
97
99
  acceptWaitingSignal: command !== null,
98
100
  resumeBlockedOnChanges: shouldResumeBlockedStage(command, plainReply),
99
101
  });
@@ -153,8 +155,10 @@ async function applyIssueApprovalSignal(input) {
153
155
  work,
154
156
  orchestration,
155
157
  resourceId: resourceIdValue,
156
- outcome: command === '/approved' ? ActivityOutcomeKind.Done : ActivityOutcomeKind.Rejected,
157
- resumeBlockedOnChanges: command === '/changes',
158
+ outcome: command === GitHubBuiltInCommand.Approved
159
+ ? ActivityOutcomeKind.Done
160
+ : ActivityOutcomeKind.Rejected,
161
+ resumeBlockedOnChanges: command === GitHubBuiltInCommand.Changes,
158
162
  });
159
163
  }
160
164
  async function applyPullRequestWorkflowSignal(input) {
@@ -1,7 +1,8 @@
1
1
  import { ReviewDecisionKind, } from '../../../activities/index.js';
2
+ import { GitHubBuiltInCommand } from '../contracts/vocabulary.js';
2
3
  export function translateGitHubReviewCommand(input) {
3
4
  const command = input.body.trim();
4
- if (command !== '/accepted' && command !== '/changes')
5
+ if (command !== GitHubBuiltInCommand.Accepted && command !== GitHubBuiltInCommand.Changes)
5
6
  return null;
6
7
  return {
7
8
  resourceId: input.resourceId,
@@ -11,6 +12,8 @@ export function translateGitHubReviewCommand(input) {
11
12
  resourceAuthorId: input.resourceAuthorId,
12
13
  authorization: input.authorization,
13
14
  providerEventId: input.providerEventId,
14
- kind: command === '/accepted' ? ReviewDecisionKind.Accepted : ReviewDecisionKind.ChangesRequested,
15
+ kind: command === GitHubBuiltInCommand.Accepted
16
+ ? ReviewDecisionKind.Accepted
17
+ : ReviewDecisionKind.ChangesRequested,
15
18
  };
16
19
  }
@@ -52,5 +52,10 @@ export const gitHubConfigSchema = z
52
52
  .object({ postStatusComments: z.boolean().default(true) })
53
53
  .strict()
54
54
  .default({ postStatusComments: true }),
55
+ // Additional command syntax to advertise on the commands/instructions
56
+ // surface alongside the adapter's built-in commands. Purely descriptive:
57
+ // Wake does not recognize these itself, so any behavior they imply must
58
+ // be handled by whatever reads the comment (e.g. a workflow prompt).
59
+ commands: z.array(z.string().trim().min(1)).default([]),
55
60
  })
56
61
  .strict();
@@ -54,3 +54,15 @@ export const GitHubOutboundAction = {
54
54
  Reply: 'reply',
55
55
  Close: 'close',
56
56
  };
57
+ // Comment-channel commands the GitHub adapter recognizes from human replies.
58
+ // /approved and /changes drive issue-review and PR-issue-comment signals;
59
+ // /accepted and /changes drive formal (native) PR review comments; /retry
60
+ // resumes a blocked/failed stage. Kept as the single source of truth for both
61
+ // recognition (inbound-comment-syntax.ts, review-command-translator.ts) and
62
+ // the commands/instructions surface, so the two cannot drift.
63
+ export const GitHubBuiltInCommand = defineClosedVocabulary({
64
+ Approved: '/approved',
65
+ Accepted: '/accepted',
66
+ Changes: '/changes',
67
+ Retry: '/retry',
68
+ });
@@ -1,4 +1,5 @@
1
1
  import { ReviewerAuthorizationSource, } from '../../../activities/index.js';
2
+ import { GitHubBuiltInCommand } from '../contracts/vocabulary.js';
2
3
  import { issueCommentObservation } from './issue-source.js';
3
4
  import { mergeBatches, reportPartialPollFailure } from './poll-watermark.js';
4
5
  import { githubReviewObservation } from './review-source.js';
@@ -69,7 +70,7 @@ async function issueCommentEventsForComment(context, issue, comment) {
69
70
  return event === null ? [] : [event];
70
71
  }
71
72
  async function retryAuthorization(context, comment) {
72
- if (comment.body?.trim().toLowerCase() !== '/retry')
73
+ if (comment.body?.trim().toLowerCase() !== GitHubBuiltInCommand.Retry)
73
74
  return undefined;
74
75
  const login = comment.user?.login;
75
76
  if (login === undefined || context.client.collaboratorPermission === undefined)
@@ -3,7 +3,7 @@ import { createEventDraft, EventActorKind, EventSourceKind } from '../../../kern
3
3
  import { integrationStream } from '../../contracts/streams.js';
4
4
  import { GitHubEventType } from '../contracts/events.js';
5
5
  import { formatGitHubResourceKey } from '../contracts/external-key.js';
6
- import { GitHubAdapter, GitHubReviewState, UnknownGitHubIdentity, } from '../contracts/vocabulary.js';
6
+ import { GitHubAdapter, GitHubBuiltInCommand, GitHubReviewState, UnknownGitHubIdentity, } from '../contracts/vocabulary.js';
7
7
  export function githubReviewObservation(input) {
8
8
  const body = input.review.body?.trim();
9
9
  const command = reviewCommand(input.review.state);
@@ -62,8 +62,8 @@ function configuredAuthorization(actorId, reviewers) {
62
62
  }
63
63
  function reviewCommand(state) {
64
64
  if (state === GitHubReviewState.Approved)
65
- return '/accepted';
66
- return state === GitHubReviewState.ChangesRequested ? '/changes' : null;
65
+ return GitHubBuiltInCommand.Accepted;
66
+ return state === GitHubReviewState.ChangesRequested ? GitHubBuiltInCommand.Changes : null;
67
67
  }
68
68
  function sameIdentity(left, right) {
69
69
  return left.toLowerCase() === right.toLowerCase();
@@ -6,6 +6,7 @@ import { translateGitHubOutbound } from './application/outbound-translator.js';
6
6
  import { createGitHubWakeLabelReconciler } from './application/wake-labels.js';
7
7
  import { gitHubConfigSchema } from './contracts/config.js';
8
8
  import { GitHubEventType } from './contracts/events.js';
9
+ import { GitHubBuiltInCommand } from './contracts/vocabulary.js';
9
10
  import { createGitHubAdapterHealthRegistry } from './infrastructure/adapter-health-registry.js';
10
11
  import { createGitHubClient } from './infrastructure/client.js';
11
12
  import { createGitHubDelivery } from './infrastructure/delivery.js';
@@ -43,6 +44,10 @@ export const gitHubProviderDefinition = {
43
44
  requests,
44
45
  }),
45
46
  health: () => health.snapshotAll(),
47
+ commands: () => [
48
+ ...Object.values(GitHubBuiltInCommand).map((syntax) => ({ syntax })),
49
+ ...config.commands.map((syntax) => ({ syntax })),
50
+ ],
46
51
  delivery: createGitHubDelivery(async (intent, idempotencyKey) => {
47
52
  const resource = await services.resources.get(resourceId(intent.resourceId));
48
53
  if (resource === null)
@@ -0,0 +1 @@
1
+ export {};
@@ -5,6 +5,7 @@ export * from './contracts/event-journal.js';
5
5
  export * from './contracts/event-schema.js';
6
6
  export * from './contracts/events.js';
7
7
  export * from './contracts/id-generator.js';
8
+ export * from './contracts/journal-change-signal.js';
8
9
  export { causationId, correlationId, eventId } from './contracts/identifiers.js';
9
10
  export * from './contracts/projection-store.js';
10
11
  export * from './contracts/relations.js';
@@ -14,5 +15,6 @@ export * from './domain/event-envelope.js';
14
15
  export * from './domain/match-mode.js';
15
16
  export * from './domain/relation.js';
16
17
  export * from './infrastructure/cached-journal-view.js';
18
+ export * from './infrastructure/journal-change-signal.js';
17
19
  export * from './infrastructure/system-clock.js';
18
20
  export * from './infrastructure/ulid-id-generator.js';
@@ -0,0 +1,30 @@
1
+ export class InProcessJournalChangeSignal {
2
+ waiters = [];
3
+ notify() {
4
+ const waiters = this.waiters;
5
+ this.waiters = [];
6
+ for (const resolve of waiters)
7
+ resolve();
8
+ }
9
+ waitForChange(signal, fallbackMs) {
10
+ if (signal.aborted)
11
+ return Promise.resolve();
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const done = () => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ clearTimeout(timer);
19
+ signal.removeEventListener('abort', done);
20
+ // Must drop this waiter here too, not just in notify() — otherwise
21
+ // every timeout/abort leaks an entry into `waiters` forever.
22
+ this.waiters = this.waiters.filter((waiter) => waiter !== done);
23
+ resolve();
24
+ };
25
+ this.waiters.push(done);
26
+ const timer = setTimeout(done, fallbackMs);
27
+ signal.addEventListener('abort', done, { once: true });
28
+ });
29
+ }
30
+ }
@@ -1,7 +1,7 @@
1
1
  import { appendFile, mkdir, readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
- import { decodeEventEnvelope, WrongExpectedSequenceError } from '../../kernel/index.js';
4
+ import { decodeEventEnvelope, InProcessJournalChangeSignal, WrongExpectedSequenceError, } from '../../kernel/index.js';
5
5
  import { withFileLock } from './file-lock.js';
6
6
  export class FileEventJournal {
7
7
  root;
@@ -10,6 +10,10 @@ export class FileEventJournal {
10
10
  this.root = root;
11
11
  this.clock = clock;
12
12
  }
13
+ changeSignalSource = new InProcessJournalChangeSignal();
14
+ get changeSignal() {
15
+ return this.changeSignalSource;
16
+ }
13
17
  cached;
14
18
  async append(stream, expectedSequence, drafts) {
15
19
  return withFileLock(join(this.root, 'locks', 'event-journal.lock'), async () => {
@@ -56,6 +60,7 @@ export class FileEventJournal {
56
60
  const file = `${day}.jsonl`;
57
61
  await appendFile(join(directory, file), newEnvelopes.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
58
62
  await this.extendCache(file, current, newEnvelopes);
63
+ this.changeSignalSource.notify();
59
64
  }
60
65
  return finalizedEnvelopes;
61
66
  });
@@ -1,13 +1,17 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import { WrongExpectedSequenceError } from '../../kernel/index.js';
2
+ import { InProcessJournalChangeSignal, WrongExpectedSequenceError } from '../../kernel/index.js';
3
3
  export class InMemoryEventJournal {
4
4
  clock;
5
5
  streams = new Map();
6
6
  events = [];
7
7
  eventIds = new Map();
8
+ changeSignalSource = new InProcessJournalChangeSignal();
8
9
  constructor(clock) {
9
10
  this.clock = clock;
10
11
  }
12
+ get changeSignal() {
13
+ return this.changeSignalSource;
14
+ }
11
15
  async append(stream, expectedSequence, events) {
12
16
  validateBatch(stream, events);
13
17
  const existingEvents = events.map((draft) => this.eventIds.get(draft.eventId));
@@ -21,6 +25,7 @@ export class InMemoryEventJournal {
21
25
  }
22
26
  const recordedAt = this.clock.now().toISOString();
23
27
  const appended = [];
28
+ let newCount = 0;
24
29
  for (const [index, draft] of events.entries()) {
25
30
  const prior = existingEvents[index];
26
31
  if (prior !== undefined) {
@@ -37,8 +42,11 @@ export class InMemoryEventJournal {
37
42
  this.events.push(envelope);
38
43
  this.eventIds.set(draft.eventId, { draft, envelope });
39
44
  appended.push(envelope);
45
+ newCount += 1;
40
46
  }
41
47
  this.streams.set(streamKey(stream), streamEvents);
48
+ if (newCount > 0)
49
+ this.changeSignalSource.notify();
42
50
  return appended;
43
51
  }
44
52
  async readStream(stream) {
@@ -36,6 +36,8 @@ async function readSingleton(applications, url) {
36
36
  return noQuery(url, async () => ok(await applications.system.health()));
37
37
  case '/api/v1/system/configuration':
38
38
  return noQuery(url, async () => ok(await applications.system.configuration()));
39
+ case '/api/v1/system/commands':
40
+ return noQuery(url, async () => ok(await applications.system.commands()));
39
41
  default:
40
42
  return undefined;
41
43
  }
@@ -1 +1,5 @@
1
- export const systemRoutes = ['/api/v1/system/health', '/api/v1/system/configuration'];
1
+ export const systemRoutes = [
2
+ '/api/v1/system/health',
3
+ '/api/v1/system/configuration',
4
+ '/api/v1/system/commands',
5
+ ];