@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.
- package/LICENSE +201 -0
- package/README.md +140 -0
- package/dist/src/adapters/claude/claude-runner.js +388 -0
- package/dist/src/adapters/claude/prompt-templates.js +57 -0
- package/dist/src/adapters/codex/codex-runner.js +391 -0
- package/dist/src/adapters/cursor/cursor-runner.js +352 -0
- package/dist/src/adapters/docker/docker-cli.js +97 -0
- package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
- package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
- package/dist/src/adapters/fake/fake-resource-index.js +21 -0
- package/dist/src/adapters/fake/fake-runner.js +22 -0
- package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
- package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
- package/dist/src/adapters/fs/resource-index.js +84 -0
- package/dist/src/adapters/fs/self-update-ledger.js +17 -0
- package/dist/src/adapters/fs/state-store.js +364 -0
- package/dist/src/adapters/git/git-workspace-manager.js +168 -0
- package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
- package/dist/src/adapters/github/github-auth.js +16 -0
- package/dist/src/adapters/github/github-client.js +100 -0
- package/dist/src/adapters/github/github-issues-work-source.js +410 -0
- package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
- package/dist/src/adapters/http/ui-assets.js +302 -0
- package/dist/src/adapters/http/ui-data.js +389 -0
- package/dist/src/adapters/http/ui-server.js +151 -0
- package/dist/src/adapters/runner/cli-command.js +43 -0
- package/dist/src/adapters/runner/prompt-templates.js +76 -0
- package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
- package/dist/src/adapters/runner/runner-registry.js +141 -0
- package/dist/src/adapters/runner/stage-prompt.js +256 -0
- package/dist/src/adapters/runner/transcripts.js +25 -0
- package/dist/src/cli/correlate-command.js +62 -0
- package/dist/src/cli/init-command.js +11 -0
- package/dist/src/cli/locks-command.js +19 -0
- package/dist/src/cli/sandbox-command.js +191 -0
- package/dist/src/cli/sandbox-logging.js +30 -0
- package/dist/src/cli/sandbox-resume.js +77 -0
- package/dist/src/cli/scaffold-assets.js +173 -0
- package/dist/src/cli/self-update-command.js +158 -0
- package/dist/src/cli/startup-preflight.js +127 -0
- package/dist/src/cli/stop-command.js +42 -0
- package/dist/src/cli/ui-command.js +27 -0
- package/dist/src/config/defaults.js +11 -0
- package/dist/src/config/load-config.js +26 -0
- package/dist/src/core/contracts.js +1 -0
- package/dist/src/core/control-plane.js +127 -0
- package/dist/src/core/lifecycle-service.js +8 -0
- package/dist/src/core/policy-engine.js +200 -0
- package/dist/src/core/projection-updater.js +438 -0
- package/dist/src/core/quota-backoff.js +69 -0
- package/dist/src/core/sink-router.js +85 -0
- package/dist/src/core/tick-runner.js +1347 -0
- package/dist/src/domain/branch-naming.js +14 -0
- package/dist/src/domain/resource-uri.js +38 -0
- package/dist/src/domain/runner-routing.js +108 -0
- package/dist/src/domain/schema.js +685 -0
- package/dist/src/domain/sources.js +16 -0
- package/dist/src/domain/stages.js +39 -0
- package/dist/src/domain/types.js +1 -0
- package/dist/src/domain/workflows.js +83 -0
- package/dist/src/lib/clock.js +5 -0
- package/dist/src/lib/event-log.js +21 -0
- package/dist/src/lib/json-file.js +23 -0
- package/dist/src/lib/lock.js +118 -0
- package/dist/src/lib/paths.js +44 -0
- package/dist/src/lib/work-id.js +19 -0
- package/dist/src/main.js +523 -0
- package/dist/src/version.js +1 -0
- package/docker/Dockerfile +54 -0
- package/docker/entrypoint.sh +75 -0
- package/docker/log-command.sh +107 -0
- package/docker/setup.sh +72 -0
- package/package.json +52 -0
- package/prompts/implement.md +49 -0
- 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
|
+
}
|