@atolis-hq/wake 0.3.75 → 0.3.77

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.
@@ -12,6 +12,7 @@ function serializeJournalAppends(journal) {
12
12
  },
13
13
  readStream: (stream) => journal.readStream(stream),
14
14
  readAll: (afterGlobalPosition, limit) => journal.readAll(afterGlobalPosition, limit),
15
+ latestGlobalPosition: () => journal.latestGlobalPosition(),
15
16
  ...(journal.readLatest === undefined
16
17
  ? {}
17
18
  : {
@@ -203,12 +203,21 @@ function createSystemApplications(root, now) {
203
203
  detail: `rolled back from ${selfUpdateFailure.tag} at ${selfUpdateFailure.occurredAt}: ${selfUpdateFailure.message}`,
204
204
  },
205
205
  ];
206
+ const adapters = root.providers.flatMap((instance) => (instance.health?.() ?? []).map((check) => ({
207
+ adapter: instance.adapter,
208
+ provider: instance.provider,
209
+ ...check,
210
+ })));
206
211
  return {
207
212
  data: {
208
- status: checks.some((check) => check.status === 'degraded') ? 'degraded' : 'ok',
213
+ status: checks.some((check) => check.status === 'degraded') ||
214
+ adapters.some((check) => check.status === 'degraded')
215
+ ? 'degraded'
216
+ : 'ok',
209
217
  version: wakeVersion,
210
218
  checkedAt,
211
219
  checks,
220
+ adapters,
212
221
  },
213
222
  meta: sampledMeta(checkedAt),
214
223
  };
@@ -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 = "g370f0e6";
111
+ export const wakeVersion = "gd101844";
@@ -1,11 +1,14 @@
1
+ import { cachedJournalView, } from '../../kernel/index.js';
1
2
  import { decodeRunExecutionEvent, } from '../contracts/events.js';
2
3
  import { runId } from '../contracts/identifiers.js';
3
4
  import { isRunStream, runStream } from '../contracts/streams.js';
4
5
  import { foldRun } from '../domain/run.js';
5
6
  export class RunRepository {
6
7
  journal;
8
+ runs;
7
9
  constructor(journal) {
8
10
  this.journal = journal;
11
+ this.runs = cachedJournalView(journal, deriveRuns);
9
12
  }
10
13
  async load(runId) {
11
14
  const events = await this.journal.readStream(runStream(runId));
@@ -17,18 +20,21 @@ export class RunRepository {
17
20
  return events.map(decodeRunExecutionEvent);
18
21
  }
19
22
  async list(activationId) {
20
- const grouped = new Map();
21
- for (const event of await this.journal.readAll(0)) {
22
- if (!isRunStream(event.stream))
23
- continue;
24
- const id = runId(event.stream.id);
25
- const events = grouped.get(id) ?? [];
26
- events.push(decodeRunExecutionEvent(event));
27
- grouped.set(id, events);
28
- }
29
- const runs = (await Promise.all([...grouped.values()].map(foldRun))).filter((run) => run !== null);
23
+ const runs = await this.runs.get();
30
24
  return activationId === undefined
31
25
  ? runs
32
26
  : runs.filter((run) => run.activationId === activationId);
33
27
  }
34
28
  }
29
+ function deriveRuns(events) {
30
+ const grouped = new Map();
31
+ for (const event of events) {
32
+ if (!isRunStream(event.stream))
33
+ continue;
34
+ const id = runId(event.stream.id);
35
+ const decoded = grouped.get(id) ?? [];
36
+ decoded.push(decodeRunExecutionEvent(event));
37
+ grouped.set(id, decoded);
38
+ }
39
+ return [...grouped.values()].map(foldRun).filter((run) => run !== null);
40
+ }
@@ -0,0 +1,43 @@
1
+ const DEFINITIVE_STATUSES = new Set([401, 403, 429]);
2
+ export function createAdapterHealthTracker(options = {}) {
3
+ const now = options.now ?? Date.now;
4
+ const threshold = options.degradeAfterConsecutiveFailures ?? 3;
5
+ let successCount = 0;
6
+ let failureCount = 0;
7
+ let consecutiveFailures = 0;
8
+ let degradedDetail;
9
+ return {
10
+ recordSuccess() {
11
+ successCount += 1;
12
+ consecutiveFailures = 0;
13
+ degradedDetail = undefined;
14
+ },
15
+ recordFailure(error) {
16
+ failureCount += 1;
17
+ consecutiveFailures += 1;
18
+ const status = statusOf(error);
19
+ const occurredAt = new Date(now()).toISOString();
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ if (status !== undefined && DEFINITIVE_STATUSES.has(status)) {
22
+ degradedDetail = `${status} at ${occurredAt}: ${message}`;
23
+ return;
24
+ }
25
+ if (consecutiveFailures >= threshold) {
26
+ degradedDetail = `${consecutiveFailures} consecutive failures, last: ${status ?? 'error'} at ${occurredAt}: ${message}`;
27
+ }
28
+ },
29
+ snapshot() {
30
+ return {
31
+ status: degradedDetail === undefined ? 'ok' : 'degraded',
32
+ ...(degradedDetail === undefined ? {} : { detail: degradedDetail }),
33
+ successCount,
34
+ failureCount,
35
+ };
36
+ },
37
+ };
38
+ }
39
+ function statusOf(error) {
40
+ if (typeof error !== 'object' || error === null || !('status' in error))
41
+ return undefined;
42
+ return typeof error.status === 'number' ? error.status : undefined;
43
+ }
@@ -1,5 +1,5 @@
1
1
  import { reportedArtifactSchema } from '../../activities/index.js';
2
- import { createEventDraft, EventActorKind, EventSourceKind, } from '../../kernel/index.js';
2
+ import { cachedJournalView, createEventDraft, EventActorKind, EventSourceKind, } from '../../kernel/index.js';
3
3
  import { OrchestrationEventType, selectWorkflowOrchestrationEvent, workflowInstanceStream, } from '../../orchestration/index.js';
4
4
  import { ResourceCorrelationProvenance, ResourceCorrelationRole, resourceId, resourceKind, ResourceStreamKind, } from '../../resources/index.js';
5
5
  import { ArtifactEventType, decodeArtifactEvent, } from '../contracts/artifact-events.js';
@@ -7,8 +7,10 @@ import { ArtifactVerificationResult, ArtifactVerificationStatus, } from '../cont
7
7
  import { integrationStream } from '../contracts/streams.js';
8
8
  export class ArtifactRegistrationReactor {
9
9
  dependencies;
10
+ latestUnresolved;
10
11
  constructor(dependencies) {
11
12
  this.dependencies = dependencies;
13
+ this.latestUnresolved = cachedJournalView(dependencies.journal, deriveLatestUnresolved);
12
14
  }
13
15
  async runOnce(limit = 100) {
14
16
  const consumer = 'reactor:artifact-registration';
@@ -43,14 +45,7 @@ export class ArtifactRegistrationReactor {
43
45
  }, branch, workItemId, 1);
44
46
  }
45
47
  async reconcileAmbiguous() {
46
- const latest = new Map();
47
- for (const event of await this.dependencies.journal.readAll(0)) {
48
- if (event.eventType !== ArtifactEventType.VerificationUnresolved)
49
- continue;
50
- const decoded = decodeArtifactEvent(event);
51
- const key = `${decoded.causationId}:${decoded.payload.artifact.externalKey.adapter}:${decoded.payload.artifact.externalKey.key}`;
52
- latest.set(key, decoded);
53
- }
48
+ const latest = await this.latestUnresolved.get();
54
49
  for (const event of latest.values()) {
55
50
  if (event.payload.status !== ArtifactVerificationStatus.Ambiguous || event.payload.escalated)
56
51
  continue;
@@ -137,6 +132,17 @@ export class ArtifactRegistrationReactor {
137
132
  return started?.payload.workItemId ?? null;
138
133
  }
139
134
  }
135
+ function deriveLatestUnresolved(events) {
136
+ const latest = new Map();
137
+ for (const event of events) {
138
+ if (event.eventType !== ArtifactEventType.VerificationUnresolved)
139
+ continue;
140
+ const decoded = decodeArtifactEvent(event);
141
+ const key = `${decoded.causationId}:${decoded.payload.artifact.externalKey.adapter}:${decoded.payload.artifact.externalKey.key}`;
142
+ latest.set(key, decoded);
143
+ }
144
+ return latest;
145
+ }
140
146
  function reportedArtifacts(value) {
141
147
  if (typeof value !== 'object' || value === null || !('reportedArtifacts' in value))
142
148
  return [];
@@ -18,11 +18,14 @@ export class ProviderRegistry {
18
18
  throw new Error(`Provider ${provider} is not registered`);
19
19
  const adapter = adapterId(name);
20
20
  try {
21
- instances.push(definition.create({
22
- adapter,
23
- config: definition.parseConfig(entry),
24
- ...(services === undefined ? {} : { services }),
25
- }));
21
+ instances.push({
22
+ ...definition.create({
23
+ adapter,
24
+ config: definition.parseConfig(entry),
25
+ ...(services === undefined ? {} : { services }),
26
+ }),
27
+ provider,
28
+ });
26
29
  }
27
30
  catch (error) {
28
31
  failures.push({
@@ -37,9 +37,16 @@ export const gitHubConfigSchema = z
37
37
  maxConcurrent: z.number().int().positive().default(4),
38
38
  commentPageSize: z.number().int().positive().max(100).default(25),
39
39
  lookbackMs: z.number().int().nonnegative().default(60_000),
40
+ intervalMs: z.number().int().positive().default(30_000),
40
41
  })
41
42
  .strict()
42
- .default({ maxPerRepo: 25, maxConcurrent: 4, commentPageSize: 25, lookbackMs: 60_000 }),
43
+ .default({
44
+ maxPerRepo: 25,
45
+ maxConcurrent: 4,
46
+ commentPageSize: 25,
47
+ lookbackMs: 60_000,
48
+ intervalMs: 30_000,
49
+ }),
43
50
  intake: z.array(intakeRuleSchema).default([]),
44
51
  publication: z
45
52
  .object({ postStatusComments: z.boolean().default(true) })
@@ -1,4 +1,5 @@
1
1
  import { GitHubEventType } from '../contracts/events.js';
2
+ import { createGitHubAdapterHealthRegistry, } from './adapter-health-registry.js';
2
3
  import { issueCommentEventsFor, reviewCommentEventsFor, reviewEventsFor, } from './comment-source.js';
3
4
  import { issueObservation } from './issue-source.js';
4
5
  import { loadWatermark, overlapSince, reportPartialPollFailure, watermarkCheckpoint, } from './poll-watermark.js';
@@ -7,6 +8,7 @@ import { createGitHubRequestCoordinator, } from './request-coordinator.js';
7
8
  export function createGitHubSource(config, client, adapter, requests = createGitHubRequestCoordinator({
8
9
  maxConcurrent: config.polling.maxConcurrent,
9
10
  }), state) {
11
+ const health = state?.health ?? createGitHubAdapterHealthRegistry(config.repositories);
10
12
  // Draft eventIds are already content fingerprints (see issue-source.ts/pr-source.ts),
11
13
  // so the journal itself is idempotent per item. This cache only avoids re-appending
12
14
  // (and re-triggering downstream translation) an unchanged item on every poll within
@@ -14,8 +16,17 @@ export function createGitHubSource(config, client, adapter, requests = createGit
14
16
  // so it's safe for it to reset on restart.
15
17
  const lastEventIds = new Map();
16
18
  const pendingWatermarks = new Map();
19
+ const now = state?.now ?? Date.now;
20
+ // In-memory only: an extra real poll after a process restart is harmless,
21
+ // the same tradeoff already made for lastEventIds above.
22
+ let lastPolledAt;
17
23
  return {
18
24
  async poll(signal) {
25
+ const currentTime = now();
26
+ if (lastPolledAt !== undefined && currentTime - lastPolledAt < config.polling.intervalMs) {
27
+ return [];
28
+ }
29
+ lastPolledAt = currentTime;
19
30
  const perRepository = await Promise.all(config.repositories.map(async ({ owner, repo }) => pollRepository({
20
31
  client: limitGitHubSourceClient(client, requests),
21
32
  config,
@@ -24,7 +35,8 @@ export function createGitHubSource(config, client, adapter, requests = createGit
24
35
  owner,
25
36
  repo,
26
37
  watermark: await loadWatermark(state?.checkpoints, adapter, owner, repo),
27
- now: state?.now ?? Date.now,
38
+ now,
39
+ health,
28
40
  })));
29
41
  for (const result of perRepository)
30
42
  if (result.succeeded)
@@ -40,10 +52,11 @@ export function createGitHubSource(config, client, adapter, requests = createGit
40
52
  });
41
53
  },
42
54
  async markPollPersisted() {
43
- if (state === undefined)
55
+ if (state?.checkpoints === undefined)
44
56
  return;
57
+ const checkpoints = state.checkpoints;
45
58
  for (const [repository, watermark] of pendingWatermarks) {
46
- await state.checkpoints.save(watermarkCheckpoint(adapter, repository), watermark);
59
+ await checkpoints.save(watermarkCheckpoint(adapter, repository), watermark);
47
60
  pendingWatermarks.delete(repository);
48
61
  }
49
62
  },
@@ -72,7 +85,7 @@ function limitGitHubSourceClient(client, requests) {
72
85
  };
73
86
  }
74
87
  async function pollRepository(input) {
75
- const { client, config, adapter, signal, owner, repo } = input;
88
+ const { client, config, adapter, signal, owner, repo, health } = input;
76
89
  const queriedAt = input.now();
77
90
  const context = {
78
91
  client,
@@ -87,10 +100,18 @@ async function pollRepository(input) {
87
100
  client.listIssues(owner, repo, config.polling.maxPerRepo, since),
88
101
  client.listPullRequests(owner, repo, config.polling.maxPerRepo),
89
102
  ]);
90
- if (!isFulfilled(issuesResult))
103
+ if (isFulfilled(issuesResult))
104
+ health.recordSuccess(context.repository, 'poll');
105
+ else {
91
106
  reportPartialPollFailure(context.repository, 'issues');
92
- if (!isFulfilled(pullRequestsResult))
107
+ health.recordFailure(context.repository, 'poll', issuesResult.reason);
108
+ }
109
+ if (isFulfilled(pullRequestsResult))
110
+ health.recordSuccess(context.repository, 'poll');
111
+ else {
93
112
  reportPartialPollFailure(context.repository, 'pull requests');
113
+ health.recordFailure(context.repository, 'poll', pullRequestsResult.reason);
114
+ }
94
115
  const issues = isFulfilled(issuesResult) ? issuesResult.value : [];
95
116
  const pullRequestPayloads = isFulfilled(pullRequestsResult)
96
117
  ? pullRequestsResult.value.filter((pullRequest) => since === undefined || pullRequest.updated_at >= since)
@@ -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 { createGitHubAdapterHealthRegistry } from './infrastructure/adapter-health-registry.js';
9
10
  import { createGitHubClient } from './infrastructure/client.js';
10
11
  import { createGitHubDelivery } from './infrastructure/delivery.js';
11
12
  import { resolveGitHubCliToken } from './infrastructure/gh-auth.js';
@@ -24,12 +25,14 @@ export const gitHubProviderDefinition = {
24
25
  const requests = createGitHubRequestCoordinator({
25
26
  maxConcurrent: config.polling.maxConcurrent,
26
27
  });
28
+ const health = createGitHubAdapterHealthRegistry(config.repositories);
27
29
  return {
28
30
  adapter,
29
31
  eventTypes: Object.values(GitHubEventType),
30
32
  source: createGitHubSource(config, client, adapter, requests, {
31
33
  checkpoints: services.checkpoints,
32
34
  now: () => services.clock.now().getTime(),
35
+ health,
33
36
  }),
34
37
  maintenance: createGitHubWakeLabelReconciler({
35
38
  orchestration: services.orchestration,
@@ -39,16 +42,29 @@ export const gitHubProviderDefinition = {
39
42
  setLabels: client.setIssueLabels,
40
43
  requests,
41
44
  }),
45
+ health: () => health.snapshotAll(),
42
46
  delivery: createGitHubDelivery(async (intent, idempotencyKey) => {
43
47
  const resource = await services.resources.get(resourceId(intent.resourceId));
44
48
  if (resource === null)
45
49
  throw new Error(`GitHub resource ${intent.resourceId} is unavailable`);
46
- return client.deliver({
47
- ...translateGitHubOutbound(resource, intent, {
48
- publicUiUrl: services.publicUiUrl,
49
- }),
50
- idempotencyKey,
51
- });
50
+ const parsedKey = parsePullRequestKey(resource.externalKey.key);
51
+ const repository = parsedKey === null ? null : `${parsedKey.owner}/${parsedKey.repo}`;
52
+ try {
53
+ const externalId = await client.deliver({
54
+ ...translateGitHubOutbound(resource, intent, {
55
+ publicUiUrl: services.publicUiUrl,
56
+ }),
57
+ idempotencyKey,
58
+ });
59
+ if (repository !== null)
60
+ health.recordSuccess(repository, 'deliver');
61
+ return externalId;
62
+ }
63
+ catch (error) {
64
+ if (repository !== null)
65
+ health.recordFailure(repository, 'deliver', error);
66
+ throw error;
67
+ }
52
68
  }, async (intent) => {
53
69
  if (intent.kind !== BuiltInActivityName.IssueComplete)
54
70
  return null;
@@ -13,5 +13,6 @@ export * from './contracts/vocabulary.js';
13
13
  export * from './domain/event-envelope.js';
14
14
  export * from './domain/match-mode.js';
15
15
  export * from './domain/relation.js';
16
+ export * from './infrastructure/cached-journal-view.js';
16
17
  export * from './infrastructure/system-clock.js';
17
18
  export * from './infrastructure/ulid-id-generator.js';
@@ -0,0 +1,21 @@
1
+ // Memoizes a full-journal derivation — a fold, decode, or index built from
2
+ // every event in history — so a resident loop calling it every tick only
3
+ // pays that cost when the journal has actually moved since the last call.
4
+ // Same gate ProjectionRunner keeps for itself (a last-seen global position),
5
+ // generalized for callers that want a materialized view rather than an
6
+ // incremental batch. Depends only on the EventJournal port, not on any
7
+ // concrete persistence implementation, so it lives in kernel rather than
8
+ // persistence — domain and adapter modules may call it directly.
9
+ export function cachedJournalView(journal, derive) {
10
+ let cache;
11
+ return {
12
+ async get() {
13
+ const position = await journal.latestGlobalPosition();
14
+ if (cache !== undefined && cache.position === position)
15
+ return cache.value;
16
+ const value = await derive(await journal.readAll(0));
17
+ cache = { position, value };
18
+ return value;
19
+ },
20
+ };
21
+ }
@@ -1,11 +1,14 @@
1
+ import { cachedJournalView, } from '../../kernel/index.js';
1
2
  import { decodeOrchestrationEvent, selectWorkflowOrchestrationEvent, } from '../contracts/event-decoder.js';
2
3
  import { workflowInstanceId } from '../contracts/identifiers.js';
3
4
  import { isWorkflowInstanceStream, workflowInstanceStream } from '../contracts/streams.js';
4
5
  import { foldWorkflowInstance } from '../domain/workflow-instance.js';
5
6
  export class OrchestrationRepository {
6
7
  journal;
8
+ listed;
7
9
  constructor(journal) {
8
10
  this.journal = journal;
11
+ this.listed = cachedJournalView(journal, (events) => deriveList(events));
9
12
  }
10
13
  async load(id) {
11
14
  const events = await this.journal.readStream(workflowInstanceStream(workflowInstanceId(id)));
@@ -25,27 +28,29 @@ export class OrchestrationRepository {
25
28
  return events.map(decodeOrchestrationEvent).filter(isWorkflowEvent);
26
29
  }
27
30
  async list() {
28
- const events = await this.journal.readAll(0);
29
- const streams = new Map();
30
- for (const event of events) {
31
- if (!isWorkflowInstanceStream(event.stream))
32
- continue;
33
- const existing = streams.get(event.stream.id);
34
- if (existing === undefined)
35
- streams.set(event.stream.id, [event]);
36
- else
37
- existing.push(event);
38
- }
39
- // `sequence` counts every event on the stream, matching readStream, while the
40
- // fold sees only owned orchestration events — exactly what load() computes.
41
- return [...streams.values()].map((streamEvents) => ({
42
- sequence: streamEvents.length,
43
- view: foldWorkflowInstance(streamEvents
44
- .map(selectWorkflowOrchestrationEvent)
45
- .filter((event) => event !== null && isWorkflowInstanceStream(event.stream))),
46
- }));
31
+ return this.listed.get();
47
32
  }
48
33
  }
49
34
  function isWorkflowEvent(event) {
50
35
  return isWorkflowInstanceStream(event.stream);
51
36
  }
37
+ // `sequence` counts every event on the stream, matching readStream, while the
38
+ // fold sees only owned orchestration events — exactly what load() computes.
39
+ function deriveList(events) {
40
+ const streams = new Map();
41
+ for (const event of events) {
42
+ if (!isWorkflowInstanceStream(event.stream))
43
+ continue;
44
+ const existing = streams.get(event.stream.id);
45
+ if (existing === undefined)
46
+ streams.set(event.stream.id, [event]);
47
+ else
48
+ existing.push(event);
49
+ }
50
+ return [...streams.values()].map((streamEvents) => ({
51
+ sequence: streamEvents.length,
52
+ view: foldWorkflowInstance(streamEvents
53
+ .map(selectWorkflowOrchestrationEvent)
54
+ .filter((event) => event !== null && isWorkflowInstanceStream(event.stream))),
55
+ }));
56
+ }
@@ -73,6 +73,10 @@ export class FileEventJournal {
73
73
  .slice(-limit)
74
74
  .reverse();
75
75
  }
76
+ async latestGlobalPosition() {
77
+ const events = await this.scan();
78
+ return events.at(-1)?.globalPosition ?? 0;
79
+ }
76
80
  async scan() {
77
81
  const directory = join(this.root, 'events');
78
82
  let files;
@@ -55,6 +55,9 @@ export class InMemoryEventJournal {
55
55
  .slice(-limit)
56
56
  .reverse();
57
57
  }
58
+ async latestGlobalPosition() {
59
+ return this.events.length;
60
+ }
58
61
  rejectChangedEventIds(drafts, existingEvents) {
59
62
  for (const [index, draft] of drafts.entries()) {
60
63
  const existing = existingEvents[index];