@atolis-hq/wake 0.1.0

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 (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +140 -0
  3. package/dist/src/adapters/claude/claude-runner.js +388 -0
  4. package/dist/src/adapters/claude/prompt-templates.js +57 -0
  5. package/dist/src/adapters/codex/codex-runner.js +391 -0
  6. package/dist/src/adapters/cursor/cursor-runner.js +352 -0
  7. package/dist/src/adapters/docker/docker-cli.js +97 -0
  8. package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
  9. package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
  10. package/dist/src/adapters/fake/fake-resource-index.js +21 -0
  11. package/dist/src/adapters/fake/fake-runner.js +22 -0
  12. package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
  13. package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
  14. package/dist/src/adapters/fs/resource-index.js +84 -0
  15. package/dist/src/adapters/fs/self-update-ledger.js +17 -0
  16. package/dist/src/adapters/fs/state-store.js +364 -0
  17. package/dist/src/adapters/git/git-workspace-manager.js +168 -0
  18. package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
  19. package/dist/src/adapters/github/github-auth.js +16 -0
  20. package/dist/src/adapters/github/github-client.js +100 -0
  21. package/dist/src/adapters/github/github-issues-work-source.js +410 -0
  22. package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
  23. package/dist/src/adapters/http/ui-assets.js +302 -0
  24. package/dist/src/adapters/http/ui-data.js +389 -0
  25. package/dist/src/adapters/http/ui-server.js +151 -0
  26. package/dist/src/adapters/runner/cli-command.js +43 -0
  27. package/dist/src/adapters/runner/prompt-templates.js +76 -0
  28. package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
  29. package/dist/src/adapters/runner/runner-registry.js +141 -0
  30. package/dist/src/adapters/runner/stage-prompt.js +256 -0
  31. package/dist/src/adapters/runner/transcripts.js +25 -0
  32. package/dist/src/cli/correlate-command.js +62 -0
  33. package/dist/src/cli/init-command.js +11 -0
  34. package/dist/src/cli/locks-command.js +19 -0
  35. package/dist/src/cli/sandbox-command.js +191 -0
  36. package/dist/src/cli/sandbox-logging.js +30 -0
  37. package/dist/src/cli/sandbox-resume.js +77 -0
  38. package/dist/src/cli/scaffold-assets.js +173 -0
  39. package/dist/src/cli/self-update-command.js +158 -0
  40. package/dist/src/cli/startup-preflight.js +127 -0
  41. package/dist/src/cli/stop-command.js +42 -0
  42. package/dist/src/cli/ui-command.js +27 -0
  43. package/dist/src/config/defaults.js +11 -0
  44. package/dist/src/config/load-config.js +26 -0
  45. package/dist/src/core/contracts.js +1 -0
  46. package/dist/src/core/control-plane.js +127 -0
  47. package/dist/src/core/lifecycle-service.js +8 -0
  48. package/dist/src/core/policy-engine.js +200 -0
  49. package/dist/src/core/projection-updater.js +438 -0
  50. package/dist/src/core/quota-backoff.js +69 -0
  51. package/dist/src/core/sink-router.js +85 -0
  52. package/dist/src/core/tick-runner.js +1347 -0
  53. package/dist/src/domain/branch-naming.js +14 -0
  54. package/dist/src/domain/resource-uri.js +38 -0
  55. package/dist/src/domain/runner-routing.js +108 -0
  56. package/dist/src/domain/schema.js +685 -0
  57. package/dist/src/domain/sources.js +16 -0
  58. package/dist/src/domain/stages.js +39 -0
  59. package/dist/src/domain/types.js +1 -0
  60. package/dist/src/domain/workflows.js +83 -0
  61. package/dist/src/lib/clock.js +5 -0
  62. package/dist/src/lib/event-log.js +21 -0
  63. package/dist/src/lib/json-file.js +23 -0
  64. package/dist/src/lib/lock.js +118 -0
  65. package/dist/src/lib/paths.js +44 -0
  66. package/dist/src/lib/work-id.js +19 -0
  67. package/dist/src/main.js +523 -0
  68. package/dist/src/version.js +1 -0
  69. package/docker/Dockerfile +54 -0
  70. package/docker/entrypoint.sh +75 -0
  71. package/docker/log-command.sh +107 -0
  72. package/docker/setup.sh +72 -0
  73. package/package.json +52 -0
  74. package/prompts/implement.md +49 -0
  75. package/prompts/refine.md +44 -0
@@ -0,0 +1,410 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { defaultAgentIdentity } from '../../domain/schema.js';
4
+ import { buildResourceUri } from '../../domain/resource-uri.js';
5
+ import { wakeStageLabelPrefix } from '../../domain/stages.js';
6
+ import { createEventEnvelope, createUnkeyedEventEnvelope } from '../../lib/event-log.js';
7
+ import { wakeVersion } from '../../version.js';
8
+ import { buildResumeCommandForCli } from '../runner/runner-cli-adapter.js';
9
+ const wakeStatusLabelPrefix = 'wake:status.';
10
+ const pollOverlapMs = 60 * 60 * 1000;
11
+ // Hidden marker appended to every comment Wake posts. `expectedEcho` normally
12
+ // suppresses Wake's own comments from re-entering the projection, but if Wake
13
+ // crashes after posting and before the reply-published event is processed,
14
+ // expectedEcho is never updated — on restart the comment would otherwise be
15
+ // polled back in as a new human comment. The marker is a second, independent
16
+ // signal so bot-authored detection doesn't depend on account type or
17
+ // expectedEcho bookkeeping surviving a crash (#145).
18
+ const wakeCommentMarker = '<!-- wake:agent -->';
19
+ const githubSource = 'github';
20
+ function normalizeTicketUpsert(input) {
21
+ // Names the resource, never the work item — the resolver stamps the
22
+ // canonical workItemKey after the poll (spec D1).
23
+ return createUnkeyedEventEnvelope({
24
+ eventId: `github-issue-${input.repo}-${input.issue.number}-${input.issue.updated_at}`,
25
+ streamScope: 'global-intake',
26
+ direction: 'inbound',
27
+ sourceSystem: 'github',
28
+ sourceEventType: 'ticket.upsert',
29
+ sourceRefs: {
30
+ repo: input.repo,
31
+ issueNumber: input.issue.number,
32
+ sourceUrl: input.issue.html_url,
33
+ resourceUri: buildResourceUri(githubSource, 'issue', `${input.repo}#${input.issue.number}`),
34
+ },
35
+ occurredAt: input.issue.updated_at,
36
+ ingestedAt: input.ingestedAt,
37
+ trigger: input.expectedEcho === true ? 'context-only' : 'immediate',
38
+ payload: {
39
+ ticket: {
40
+ repo: input.repo,
41
+ number: input.issue.number,
42
+ title: input.issue.title,
43
+ body: input.issue.body ?? '',
44
+ labels: (input.issue.labels ?? [])
45
+ .map((label) => (typeof label === 'string' ? label : label.name))
46
+ .filter((label) => typeof label === 'string'),
47
+ assignees: (input.issue.assignees ?? [])
48
+ .map((assignee) => assignee.login)
49
+ .filter((login) => typeof login === 'string'),
50
+ isPullRequest: input.issue.pull_request !== undefined,
51
+ state: input.issue.state === 'closed' ? 'closed' : 'open',
52
+ url: input.issue.html_url,
53
+ createdAt: input.issue.created_at,
54
+ updatedAt: input.issue.updated_at,
55
+ },
56
+ providerEventType: 'github.issue.upsert',
57
+ },
58
+ raw: {
59
+ github: {
60
+ issueUpdatedAt: input.issue.updated_at,
61
+ },
62
+ },
63
+ ...(input.expectedEcho === true
64
+ ? { derivedHints: { expectedEcho: true } }
65
+ : {}),
66
+ });
67
+ }
68
+ function normalizeTicketCommentEvent(input) {
69
+ const isUpdate = input.existingUpdatedAt !== undefined &&
70
+ input.existingUpdatedAt !== input.comment.updated_at;
71
+ return createUnkeyedEventEnvelope({
72
+ eventId: `github-comment-${input.repo}-${input.issueNumber}-${input.comment.id}-${input.comment.updated_at}`,
73
+ streamScope: 'work-item',
74
+ direction: 'inbound',
75
+ sourceSystem: 'github',
76
+ sourceEventType: isUpdate ? 'ticket.comment.updated' : 'ticket.comment.created',
77
+ sourceRefs: {
78
+ repo: input.repo,
79
+ issueNumber: input.issueNumber,
80
+ commentId: String(input.comment.id),
81
+ sourceUrl: input.comment.html_url,
82
+ // The comment belongs to the issue's work item; the issue is the
83
+ // resource the resolver knows it by.
84
+ resourceUri: buildResourceUri(githubSource, 'issue', `${input.repo}#${input.issueNumber}`),
85
+ },
86
+ occurredAt: input.comment.updated_at,
87
+ ingestedAt: input.ingestedAt,
88
+ trigger: 'context-only',
89
+ payload: {
90
+ comment: {
91
+ id: String(input.comment.id),
92
+ body: input.comment.body ?? '',
93
+ author: {
94
+ login: input.comment.user?.login ?? 'unknown',
95
+ },
96
+ createdAt: input.comment.created_at,
97
+ updatedAt: input.comment.updated_at,
98
+ },
99
+ providerEventType: isUpdate
100
+ ? 'github.issue.comment.updated'
101
+ : 'github.issue.comment.created',
102
+ },
103
+ derivedHints: {
104
+ // Third-party bots/integrations (CI, Dependabot, Renovate, etc.) must
105
+ // not be able to unblock a blocked issue; only an actual human reply should.
106
+ // The marker check catches Wake's own comments even when expectedEcho
107
+ // missed them (crash-recovery gap) or the agent account type is 'User'.
108
+ botAuthoredComment: input.comment.user?.type === 'Bot' ||
109
+ (input.comment.body ?? '').includes(wakeCommentMarker),
110
+ },
111
+ });
112
+ }
113
+ function normalizeLabels(labels) {
114
+ return [...labels].sort();
115
+ }
116
+ function labelsMatch(left, right) {
117
+ const normalizedLeft = normalizeLabels(left);
118
+ const normalizedRight = normalizeLabels(right);
119
+ return (normalizedLeft.length === normalizedRight.length &&
120
+ normalizedLeft.every((label, index) => label === normalizedRight[index]));
121
+ }
122
+ function issueLabels(issue) {
123
+ return (issue.labels ?? [])
124
+ .map((label) => (typeof label === 'string' ? label : label.name))
125
+ .filter((label) => typeof label === 'string');
126
+ }
127
+ function issueAssignees(issue) {
128
+ return (issue.assignees ?? [])
129
+ .map((assignee) => assignee.login)
130
+ .filter((login) => typeof login === 'string');
131
+ }
132
+ function isExpectedLabelEcho(issue, local) {
133
+ if (local === null || local.wake.expectedEcho.labels.length === 0) {
134
+ return false;
135
+ }
136
+ return (issue.title === local.issue.title &&
137
+ (issue.body ?? '') === local.issue.body &&
138
+ (issue.state === 'closed' ? 'closed' : 'open') === local.issue.state &&
139
+ issue.html_url === local.issue.url &&
140
+ labelsMatch(issueAssignees(issue), local.issue.assignees) &&
141
+ labelsMatch(issueLabels(issue), local.wake.expectedEcho.labels));
142
+ }
143
+ function extractCreatedCommentId(response) {
144
+ if (response === null || typeof response !== 'object') {
145
+ return undefined;
146
+ }
147
+ const directId = response.id;
148
+ if (typeof directId === 'number' || typeof directId === 'string') {
149
+ return String(directId);
150
+ }
151
+ const data = response.data;
152
+ if (data !== null && typeof data === 'object') {
153
+ const dataId = data.id;
154
+ if (typeof dataId === 'number' || typeof dataId === 'string') {
155
+ return String(dataId);
156
+ }
157
+ }
158
+ return undefined;
159
+ }
160
+ function formatControlPlaneLink(url) {
161
+ try {
162
+ const parsed = new URL(url);
163
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
164
+ return null;
165
+ }
166
+ return parsed.toString();
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ export async function readControlPlaneUiUrl(wakeRoot) {
173
+ try {
174
+ const raw = await readFile(resolve(wakeRoot, 'control-plane-ui-url'), 'utf8');
175
+ return formatControlPlaneLink(raw.trim()) ?? undefined;
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ }
181
+ export function formatWakeComment(payload, controlPlaneUrl) {
182
+ const body = typeof payload.body === 'string' ? payload.body : '';
183
+ const kind = typeof payload.kind === 'string' ? payload.kind : undefined;
184
+ const action = typeof payload.action === 'string' ? payload.action : undefined;
185
+ const runId = typeof payload.runId === 'string' ? payload.runId : undefined;
186
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
187
+ const model = typeof payload.model === 'string' ? payload.model : undefined;
188
+ const cli = typeof payload.cli === 'string' ? payload.cli : undefined;
189
+ const runnerName = typeof payload.runnerName === 'string' ? payload.runnerName : undefined;
190
+ const runnerTier = typeof payload.runnerTier === 'string' ? payload.runnerTier : undefined;
191
+ const duration = typeof payload.duration === 'string' ? payload.duration : undefined;
192
+ const tokens = typeof payload.tokens === 'string' ? payload.tokens : undefined;
193
+ const cost = typeof payload.cost === 'string' ? payload.cost : undefined;
194
+ const workspacePath = typeof payload.workspacePath === 'string' ? payload.workspacePath : undefined;
195
+ const details = [
196
+ action === undefined ? undefined : `stage \`${action}\``,
197
+ runnerName === undefined ? undefined : `runner \`${runnerName}\``,
198
+ runnerTier === undefined ? undefined : `tier \`${runnerTier}\``,
199
+ cli === undefined ? undefined : `cli ${cli}`,
200
+ model === undefined ? undefined : `model \`${model}\``,
201
+ duration === undefined ? undefined : `duration ${duration}`,
202
+ tokens === undefined ? undefined : `tokens ${tokens}`,
203
+ cost === undefined ? undefined : `cost ${cost}`,
204
+ runId === undefined ? undefined : `run \`${runId}\``,
205
+ ].filter((part) => part !== undefined);
206
+ const name = controlPlaneUrl === undefined ? defaultAgentIdentity : `[${defaultAgentIdentity}](${controlPlaneUrl})`;
207
+ const header = `**${name}** _(Wake ${wakeVersion}${details.length > 0 ? ` · ${details.join(' · ')}` : ''})_`;
208
+ const sections = [wakeCommentMarker, header, body];
209
+ if (kind === 'approval-request') {
210
+ sections.push('_To approve this work, reply with `/approved`. To request changes, reply with `/changes` followed by your feedback. To ask a question without requesting changes, reply with `/question` followed by your question._');
211
+ }
212
+ if (sessionId !== undefined) {
213
+ const resumeCommandArgs = cli === undefined
214
+ ? null
215
+ : buildResumeCommandForCli({
216
+ cli,
217
+ sessionId,
218
+ });
219
+ const resumeCommandText = cli === undefined
220
+ ? `<resume command unavailable: missing runner identity for session ${sessionId}>`
221
+ : resumeCommandArgs === null
222
+ ? `<resume command unavailable: unsupported runner identity for session ${sessionId}>`
223
+ : resumeCommandArgs.join(' ');
224
+ const resumeCommand = workspacePath === undefined
225
+ ? resumeCommandText
226
+ : `cd "${workspacePath}"\n${resumeCommandText}`;
227
+ sections.push([
228
+ '---',
229
+ `_Next steps: reply on this thread to continue, or resume this exact ${defaultAgentIdentity} session locally:_`,
230
+ '```',
231
+ resumeCommand,
232
+ '```',
233
+ ].join('\n'));
234
+ }
235
+ return sections.join('\n\n');
236
+ }
237
+ export function createGitHubIssuesWorkSource(deps) {
238
+ /** O(1): one shard read, then a direct projection read by work id. */
239
+ async function readProjectionForIssue(repo, issueNumber) {
240
+ const uri = buildResourceUri(githubSource, 'issue', `${repo}#${issueNumber}`);
241
+ const workItemKey = await deps.resourceIndex.resolve(uri);
242
+ if (workItemKey === undefined) {
243
+ return null;
244
+ }
245
+ return deps.stateStore.readIssueState(workItemKey);
246
+ }
247
+ return {
248
+ async pollEvents(_input) {
249
+ const ingestedAt = deps.now().toISOString();
250
+ const events = [];
251
+ for (const repoRef of deps.config.sources.github.repos) {
252
+ const [owner, repo] = repoRef.split('/');
253
+ if (owner === undefined || repo === undefined) {
254
+ continue;
255
+ }
256
+ // One repo's failure (deleted repo, revoked access, transient API
257
+ // error) must not stop polling for every other configured repo (E3).
258
+ // Skipping `writeSourceState` below on failure means the `since`
259
+ // cursor doesn't advance, so the next tick retries this repo from the
260
+ // same point instead of silently losing the gap.
261
+ try {
262
+ const previousPoll = await deps.stateStore.readSourceState('github', repoRef);
263
+ const since = previousPoll === null
264
+ ? undefined
265
+ : new Date(Date.parse(previousPoll.lastSuccessfulPollAt) - pollOverlapMs).toISOString();
266
+ const issues = since === undefined
267
+ ? await deps.client.listIssues(owner, repo, deps.config.sources.github.polling.maxIssuesPerRepo)
268
+ : await deps.client.listIssues(owner, repo, deps.config.sources.github.polling.maxIssuesPerRepo, since);
269
+ for (const issue of issues) {
270
+ if (issue.pull_request !== undefined) {
271
+ continue;
272
+ }
273
+ // Poll dedup + echo suppression only, never identity: the uri is
274
+ // constructed (never parsed) and resolved through the index, which
275
+ // keeps a poll flat in the number of work items rather than
276
+ // O(issues x projections) (spec D2).
277
+ const local = await readProjectionForIssue(repoRef, issue.number);
278
+ if (local?.issue.updatedAt !== issue.updated_at) {
279
+ events.push(normalizeTicketUpsert({
280
+ repo: repoRef,
281
+ issue,
282
+ ingestedAt,
283
+ expectedEcho: isExpectedLabelEcho(issue, local ?? null),
284
+ }));
285
+ }
286
+ const comments = await deps.client.listComments(owner, repo, issue.number, deps.config.sources.github.polling.commentPageSize);
287
+ for (const comment of comments) {
288
+ const known = local?.comments.find((entry) => entry.id === String(comment.id));
289
+ if (known?.updatedAt === comment.updated_at) {
290
+ continue;
291
+ }
292
+ if (local?.wake.expectedEcho.commentIds.includes(String(comment.id)) === true) {
293
+ continue;
294
+ }
295
+ events.push(normalizeTicketCommentEvent({
296
+ repo: repoRef,
297
+ issueNumber: issue.number,
298
+ comment,
299
+ ingestedAt,
300
+ ...(known?.updatedAt === undefined
301
+ ? {}
302
+ : { existingUpdatedAt: known.updatedAt }),
303
+ }));
304
+ }
305
+ }
306
+ await deps.stateStore.writeSourceState({
307
+ schemaVersion: 1,
308
+ source: 'github',
309
+ key: repoRef,
310
+ lastSuccessfulPollAt: ingestedAt,
311
+ });
312
+ }
313
+ catch (error) {
314
+ console.error(`[github-work-source] poll failed for ${repoRef}, skipping this tick: ${error instanceof Error ? error.message : String(error)}`);
315
+ }
316
+ }
317
+ return events;
318
+ },
319
+ async deliverIntent(input) {
320
+ const repo = input.event.sourceRefs.repo;
321
+ const issueNumber = input.event.sourceRefs.issueNumber;
322
+ if (repo === undefined || issueNumber === undefined) {
323
+ throw new Error(`cannot deliver intent ${input.event.eventId}: missing sourceRefs.repo/issueNumber`);
324
+ }
325
+ const [owner, repoName] = repo.split('/');
326
+ if (owner === undefined || repoName === undefined) {
327
+ throw new Error(`cannot deliver intent ${input.event.eventId}: malformed repo "${repo}"`);
328
+ }
329
+ const publishedAt = deps.now().toISOString();
330
+ if (input.event.sourceEventType === 'wake.labels.requested') {
331
+ // Outbound intents are keyed envelopes Wake itself minted, so the work
332
+ // item is already named on the event — a direct read, not a lookup.
333
+ const projection = await deps.stateStore.readIssueState(input.event.workItemKey);
334
+ const currentLabels = projection?.issue.labels ?? [];
335
+ const nextStatusLabel = typeof input.event.payload.statusLabel === 'string'
336
+ ? input.event.payload.statusLabel
337
+ : undefined;
338
+ const nextStageLabel = typeof input.event.payload.stageLabel === 'string'
339
+ ? input.event.payload.stageLabel
340
+ : undefined;
341
+ const nextLabels = [
342
+ ...currentLabels.filter((label) => !label.startsWith(wakeStatusLabelPrefix) &&
343
+ !label.startsWith(wakeStageLabelPrefix)),
344
+ ...(nextStatusLabel !== undefined
345
+ ? [nextStatusLabel]
346
+ : currentLabels.filter((label) => label.startsWith(wakeStatusLabelPrefix))),
347
+ ...(nextStageLabel !== undefined
348
+ ? [nextStageLabel]
349
+ : currentLabels.filter((label) => label.startsWith(wakeStageLabelPrefix))),
350
+ ];
351
+ const labelsChanged = nextLabels.length !== currentLabels.length ||
352
+ !nextLabels.every((label, index) => label === currentLabels[index]);
353
+ if (labelsChanged) {
354
+ await deps.client.setLabels(owner, repoName, issueNumber, nextLabels);
355
+ return [
356
+ createEventEnvelope({
357
+ eventId: `${input.event.eventId}-labels-updated`,
358
+ workItemKey: input.event.workItemKey,
359
+ streamScope: 'work-item',
360
+ direction: 'outbound',
361
+ sourceSystem: 'github',
362
+ sourceEventType: 'ticket.labels.updated',
363
+ sourceRefs: {
364
+ repo,
365
+ issueNumber,
366
+ },
367
+ occurredAt: publishedAt,
368
+ ingestedAt: publishedAt,
369
+ trigger: 'context-only',
370
+ payload: {
371
+ intentEventId: input.event.eventId,
372
+ ...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
373
+ ...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
374
+ labels: nextLabels,
375
+ providerEventType: 'github.issue.labels.updated',
376
+ },
377
+ }),
378
+ ];
379
+ }
380
+ return [];
381
+ }
382
+ const response = await deps.client.createComment(owner, repoName, issueNumber, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
383
+ const commentId = extractCreatedCommentId(response);
384
+ return [
385
+ createEventEnvelope({
386
+ eventId: `${input.event.eventId}-published`,
387
+ workItemKey: input.event.workItemKey,
388
+ streamScope: 'work-item',
389
+ direction: 'outbound',
390
+ sourceSystem: 'github',
391
+ sourceEventType: 'ticket.reply.published',
392
+ sourceRefs: {
393
+ repo,
394
+ issueNumber,
395
+ ...(commentId === undefined ? {} : { commentId }),
396
+ },
397
+ occurredAt: publishedAt,
398
+ ingestedAt: publishedAt,
399
+ trigger: 'context-only',
400
+ payload: {
401
+ intentEventId: input.event.eventId,
402
+ kind: input.event.payload.kind,
403
+ body: input.event.payload.body,
404
+ providerEventType: 'github.issue.comment.published',
405
+ },
406
+ }),
407
+ ];
408
+ },
409
+ };
410
+ }
@@ -0,0 +1,263 @@
1
+ import { buildResourceUri } from '../../domain/resource-uri.js';
2
+ import { createUnkeyedEventEnvelope, createEventEnvelope } from '../../lib/event-log.js';
3
+ import { formatWakeComment, readControlPlaneUiUrl } from './github-issues-work-source.js';
4
+ const githubPrSource = 'github-pr';
5
+ const wakeCommentMarker = '<!-- wake:agent -->';
6
+ function prResourceUri(repo, number) {
7
+ return buildResourceUri('github', 'pr', `${repo}#${number}`);
8
+ }
9
+ function reviewThreadRootId(comment, byId) {
10
+ let current = comment;
11
+ const visited = new Set();
12
+ while (current.in_reply_to_id !== undefined && !visited.has(current.id)) {
13
+ visited.add(current.id);
14
+ const parent = byId.get(current.in_reply_to_id);
15
+ if (parent === undefined) {
16
+ break;
17
+ }
18
+ current = parent;
19
+ }
20
+ return current.id;
21
+ }
22
+ function reviewThreadResourceUri(repo, prNumber, rootId) {
23
+ return buildResourceUri('github', 'pr-review-thread', `${repo}#${prNumber}/rt_${rootId}`);
24
+ }
25
+ function isBotAuthored(comment) {
26
+ return comment.user?.type === 'Bot' || (comment.body ?? '').includes(wakeCommentMarker);
27
+ }
28
+ export function createGitHubPullRequestActivitySource(deps) {
29
+ function repoAndNumberFromPrUri(resourceUri) {
30
+ // github:pr:<owner>/<repo>#<number>
31
+ const locator = resourceUri.split(':').slice(2).join(':');
32
+ const match = /^([^/]+)\/([^#]+)#(\d+)$/.exec(locator);
33
+ if (match === null) {
34
+ return null;
35
+ }
36
+ const [, owner, repo, numberStr] = match;
37
+ if (owner === undefined || repo === undefined || numberStr === undefined) {
38
+ return null;
39
+ }
40
+ return { owner, repo, repoRef: `${owner}/${repo}`, number: Number(numberStr) };
41
+ }
42
+ async function discoverPullRequests(ingestedAt) {
43
+ if (!deps.config.sources.github.pullRequests.enabled) {
44
+ return [];
45
+ }
46
+ const events = [];
47
+ for (const repoRef of deps.config.sources.github.repos) {
48
+ const [owner, repo] = repoRef.split('/');
49
+ if (owner === undefined || repo === undefined) {
50
+ continue;
51
+ }
52
+ try {
53
+ const prs = await deps.client.listPullRequests(owner, repo, deps.config.sources.github.pullRequests.maxPullRequestsPerRepo);
54
+ for (const pr of prs) {
55
+ const resourceUri = prResourceUri(repoRef, pr.number);
56
+ const known = await deps.resourceIndex.resolve(resourceUri);
57
+ if (known !== undefined) {
58
+ continue;
59
+ }
60
+ events.push(createUnkeyedEventEnvelope({
61
+ // Embeds updated_at (mirroring the issue path's ticket.upsert
62
+ // eventId) so a PR that fails mint qualification is re-offered
63
+ // for qualification whenever it actually changes — not
64
+ // permanently quarantined under UNRESOLVED_WORK_ITEM_KEY the
65
+ // first time it's seen, even after config or the PR itself
66
+ // changes. appendEventEnvelope still dedups on eventId, so an
67
+ // unchanged PR is only ever appended once per updated_at value.
68
+ eventId: `pr-seen-${repoRef.replace(/[^a-z0-9]+/gi, '-')}-${pr.number}-${pr.updated_at}`,
69
+ streamScope: 'global-intake',
70
+ direction: 'inbound',
71
+ sourceSystem: githubPrSource,
72
+ sourceEventType: 'pr.seen',
73
+ sourceRefs: { repo: repoRef, sourceUrl: pr.html_url, resourceUri },
74
+ occurredAt: pr.updated_at,
75
+ ingestedAt,
76
+ trigger: 'context-only',
77
+ payload: {
78
+ pr: { number: pr.number, author: pr.user?.login ?? 'unknown', headRef: pr.head.ref },
79
+ },
80
+ }));
81
+ }
82
+ }
83
+ catch (error) {
84
+ console.error(`[github-pr-activity-source] discovery failed for ${repoRef}, skipping this tick: ${error instanceof Error ? error.message : String(error)}`);
85
+ }
86
+ }
87
+ return events;
88
+ }
89
+ async function pollWatchedPr(resourceUri, ingestedAt) {
90
+ if (!deps.config.sources.github.pullRequests.enabled) {
91
+ return [];
92
+ }
93
+ const ref = repoAndNumberFromPrUri(resourceUri);
94
+ if (ref === null) {
95
+ return [];
96
+ }
97
+ const events = [];
98
+ const perPage = deps.config.sources.github.pullRequests.commentPageSize;
99
+ try {
100
+ const comments = await deps.client.listComments(ref.owner, ref.repo, ref.number, perPage);
101
+ for (const comment of comments) {
102
+ events.push(createUnkeyedEventEnvelope({
103
+ eventId: `pr-comment-${ref.repoRef}-${ref.number}-${comment.id}-${comment.updated_at}`,
104
+ streamScope: 'work-item',
105
+ direction: 'inbound',
106
+ sourceSystem: githubPrSource,
107
+ sourceEventType: 'pr.comment.created',
108
+ sourceRefs: { repo: ref.repoRef, commentId: String(comment.id), sourceUrl: comment.html_url, resourceUri },
109
+ occurredAt: comment.updated_at,
110
+ ingestedAt,
111
+ trigger: 'context-only',
112
+ payload: {
113
+ comment: {
114
+ id: `pr-${comment.id}`,
115
+ body: comment.body ?? '',
116
+ author: { login: comment.user?.login ?? 'unknown' },
117
+ createdAt: comment.created_at,
118
+ updatedAt: comment.updated_at,
119
+ resourceUri,
120
+ },
121
+ },
122
+ derivedHints: { botAuthoredComment: isBotAuthored(comment) },
123
+ }));
124
+ }
125
+ const reviews = await deps.client.listReviews(ref.owner, ref.repo, ref.number, perPage);
126
+ for (const review of reviews) {
127
+ // GitHub allows submitting a review (e.g. "Request changes" or
128
+ // "Approve") with no comment text — skipping those entirely would
129
+ // lose the state-change signal itself, not just a missing comment,
130
+ // so a bare state marker is still emitted.
131
+ const body = (review.body ?? '').trim();
132
+ const submittedAt = review.submitted_at ?? ingestedAt;
133
+ events.push(createUnkeyedEventEnvelope({
134
+ eventId: `pr-review-${ref.repoRef}-${ref.number}-${review.id}`,
135
+ streamScope: 'work-item',
136
+ direction: 'inbound',
137
+ sourceSystem: githubPrSource,
138
+ sourceEventType: 'pr.review.created',
139
+ sourceRefs: { repo: ref.repoRef, commentId: `review-${review.id}`, sourceUrl: review.html_url, resourceUri },
140
+ occurredAt: submittedAt,
141
+ ingestedAt,
142
+ trigger: 'context-only',
143
+ payload: {
144
+ comment: {
145
+ id: `pr-review-${review.id}`,
146
+ body: body.length === 0 ? `[${review.state}]` : `[${review.state}] ${body}`,
147
+ author: { login: review.user?.login ?? 'unknown' },
148
+ createdAt: submittedAt,
149
+ updatedAt: submittedAt,
150
+ resourceUri,
151
+ },
152
+ },
153
+ derivedHints: { botAuthoredComment: isBotAuthored(review) },
154
+ }));
155
+ }
156
+ const reviewComments = await deps.client.listReviewComments(ref.owner, ref.repo, ref.number, perPage);
157
+ const byId = new Map(reviewComments.map((c) => [c.id, c]));
158
+ for (const comment of reviewComments) {
159
+ const rootId = reviewThreadRootId(comment, byId);
160
+ const threadUri = reviewThreadResourceUri(ref.repoRef, ref.number, rootId);
161
+ events.push(createUnkeyedEventEnvelope({
162
+ eventId: `pr-review-comment-${ref.repoRef}-${ref.number}-${comment.id}-${comment.updated_at}`,
163
+ streamScope: 'work-item',
164
+ direction: 'inbound',
165
+ sourceSystem: githubPrSource,
166
+ sourceEventType: 'pr.review-comment.created',
167
+ sourceRefs: {
168
+ repo: ref.repoRef,
169
+ commentId: String(comment.id),
170
+ sourceUrl: comment.html_url,
171
+ resourceUri: threadUri,
172
+ // The thread's own resourceUri is never registered on its own
173
+ // (each thread is unique), so core falls back to resolving via
174
+ // the owning PR when this misses the index on first sighting.
175
+ parentResourceUri: resourceUri,
176
+ },
177
+ occurredAt: comment.updated_at,
178
+ ingestedAt,
179
+ trigger: 'context-only',
180
+ payload: {
181
+ comment: {
182
+ id: `pr-review-comment-${comment.id}`,
183
+ body: comment.body ?? '',
184
+ author: { login: comment.user?.login ?? 'unknown' },
185
+ createdAt: comment.created_at,
186
+ updatedAt: comment.updated_at,
187
+ resourceUri: threadUri,
188
+ reviewThread: { path: comment.path, line: comment.line ?? comment.original_line ?? undefined },
189
+ },
190
+ },
191
+ derivedHints: { botAuthoredComment: isBotAuthored(comment) },
192
+ }));
193
+ }
194
+ }
195
+ catch (error) {
196
+ console.error(`[github-pr-activity-source] activity poll failed for ${resourceUri}, skipping this tick: ${error instanceof Error ? error.message : String(error)}`);
197
+ }
198
+ return events;
199
+ }
200
+ return {
201
+ async pollEvents(input) {
202
+ const ingestedAt = deps.now().toISOString();
203
+ const watched = (input?.watch ?? []).filter((ref) => ref.resourceUri.startsWith('github:pr:'));
204
+ const discovered = await discoverPullRequests(ingestedAt);
205
+ const activityBatches = await Promise.all(watched.map((ref) => pollWatchedPr(ref.resourceUri, ingestedAt)));
206
+ return [...discovered, ...activityBatches.flat()];
207
+ },
208
+ async deliverIntent(input) {
209
+ const resourceUri = input.event.sourceRefs.resourceUri;
210
+ if (resourceUri === undefined) {
211
+ throw new Error(`cannot deliver intent ${input.event.eventId}: missing sourceRefs.resourceUri`);
212
+ }
213
+ const publishedAt = deps.now().toISOString();
214
+ if (resourceUri.startsWith('github:pr-review-thread:')) {
215
+ const locator = resourceUri.split(':').slice(2).join(':');
216
+ const match = /^([^/]+)\/([^#]+)#(\d+)\/rt_(\d+)$/.exec(locator);
217
+ if (match === null) {
218
+ throw new Error(`cannot deliver intent ${input.event.eventId}: malformed review-thread uri ${resourceUri}`);
219
+ }
220
+ const [, owner, repo, numberStr, rootIdStr] = match;
221
+ if (owner === undefined || repo === undefined || numberStr === undefined || rootIdStr === undefined) {
222
+ throw new Error(`cannot deliver intent ${input.event.eventId}: malformed review-thread uri ${resourceUri}`);
223
+ }
224
+ const response = await deps.client.replyToReviewComment(owner, repo, Number(numberStr), Number(rootIdStr), formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
225
+ return [
226
+ createEventEnvelope({
227
+ eventId: `${input.event.eventId}-published`,
228
+ workItemKey: input.event.workItemKey,
229
+ streamScope: 'work-item',
230
+ direction: 'outbound',
231
+ sourceSystem: githubPrSource,
232
+ sourceEventType: 'pr.review-comment.reply.published',
233
+ sourceRefs: { resourceUri, sourceUrl: response?.html_url },
234
+ occurredAt: publishedAt,
235
+ ingestedAt: publishedAt,
236
+ trigger: 'context-only',
237
+ payload: { intentEventId: input.event.eventId, kind: input.event.payload.kind, body: input.event.payload.body },
238
+ }),
239
+ ];
240
+ }
241
+ const ref = repoAndNumberFromPrUri(resourceUri);
242
+ if (ref === null) {
243
+ throw new Error(`cannot deliver intent ${input.event.eventId}: malformed pr uri ${resourceUri}`);
244
+ }
245
+ await deps.client.createComment(ref.owner, ref.repo, ref.number, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
246
+ return [
247
+ createEventEnvelope({
248
+ eventId: `${input.event.eventId}-published`,
249
+ workItemKey: input.event.workItemKey,
250
+ streamScope: 'work-item',
251
+ direction: 'outbound',
252
+ sourceSystem: githubPrSource,
253
+ sourceEventType: 'pr.comment.reply.published',
254
+ sourceRefs: { repo: ref.repoRef, resourceUri },
255
+ occurredAt: publishedAt,
256
+ ingestedAt: publishedAt,
257
+ trigger: 'context-only',
258
+ payload: { intentEventId: input.event.eventId, kind: input.event.payload.kind, body: input.event.payload.body },
259
+ }),
260
+ ];
261
+ },
262
+ };
263
+ }