@atolis-hq/wake 0.2.30 → 0.2.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/adapters/fake/fake-ticketing-system.js +5 -0
- package/dist/src/adapters/github/github-client.js +8 -0
- package/dist/src/adapters/github/github-issues-work-source.js +173 -22
- package/dist/src/adapters/github/github-pull-request-activity-source.js +104 -13
- package/dist/src/core/event-builders.js +11 -0
- package/dist/src/core/outbox.js +95 -18
- package/dist/src/core/sink-router.js +49 -26
- package/dist/src/core/tick-runner.js +81 -13
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -125,6 +125,8 @@ export function createFakeTicketingSystem(options) {
|
|
|
125
125
|
trigger: 'context-only',
|
|
126
126
|
payload: {
|
|
127
127
|
intentEventId: input.event.eventId,
|
|
128
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
129
|
+
deliveryState: 'CONFIRMED',
|
|
128
130
|
labels,
|
|
129
131
|
},
|
|
130
132
|
}),
|
|
@@ -149,6 +151,9 @@ export function createFakeTicketingSystem(options) {
|
|
|
149
151
|
trigger: 'context-only',
|
|
150
152
|
payload: {
|
|
151
153
|
intentEventId: input.event.eventId,
|
|
154
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
155
|
+
deliveryState: 'CONFIRMED',
|
|
156
|
+
providerId: commentId,
|
|
152
157
|
kind: input.event.payload.kind,
|
|
153
158
|
body: input.event.payload.body,
|
|
154
159
|
},
|
|
@@ -66,6 +66,14 @@ export function createGitHubClient(token) {
|
|
|
66
66
|
}),
|
|
67
67
|
});
|
|
68
68
|
},
|
|
69
|
+
async getIssue(owner, repo, issueNumber) {
|
|
70
|
+
const { data } = await octokit.rest.issues.get({
|
|
71
|
+
owner,
|
|
72
|
+
repo,
|
|
73
|
+
issue_number: issueNumber,
|
|
74
|
+
});
|
|
75
|
+
return data;
|
|
76
|
+
},
|
|
69
77
|
async createComment(owner, repo, issueNumber, body) {
|
|
70
78
|
return octokit.rest.issues.createComment({
|
|
71
79
|
owner,
|
|
@@ -18,6 +18,11 @@ const pollOverlapMs = 60 * 60 * 1000;
|
|
|
18
18
|
// expectedEcho bookkeeping surviving a crash (#145).
|
|
19
19
|
const wakeCommentMarker = '<!-- wake:agent -->';
|
|
20
20
|
const githubSource = 'github';
|
|
21
|
+
export function wakeIdempotencyMarker(idempotencyKey) {
|
|
22
|
+
return typeof idempotencyKey === 'string'
|
|
23
|
+
? `<!-- wake:idempotency ${idempotencyKey} -->`
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
21
26
|
function normalizeTicketUpsert(input) {
|
|
22
27
|
// Names the resource, never the work item — the resolver stamps the
|
|
23
28
|
// canonical workItemKey after the poll (spec D1).
|
|
@@ -180,6 +185,9 @@ export async function readControlPlaneUiUrl(wakeRoot) {
|
|
|
180
185
|
return undefined;
|
|
181
186
|
}
|
|
182
187
|
}
|
|
188
|
+
function isGitHubNotFound(error) {
|
|
189
|
+
return error instanceof Error && error.status === 404;
|
|
190
|
+
}
|
|
183
191
|
export function formatGitHubError(error) {
|
|
184
192
|
if (error instanceof Error) {
|
|
185
193
|
const octokit = error;
|
|
@@ -210,6 +218,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
|
|
|
210
218
|
const tokens = typeof payload.tokens === 'string' ? payload.tokens : undefined;
|
|
211
219
|
const cost = typeof payload.cost === 'string' ? payload.cost : undefined;
|
|
212
220
|
const workspacePath = typeof payload.workspacePath === 'string' ? payload.workspacePath : undefined;
|
|
221
|
+
const idempotencyMarker = wakeIdempotencyMarker(payload.idempotencyKey);
|
|
213
222
|
const details = [
|
|
214
223
|
action === undefined ? undefined : `stage \`${action}\``,
|
|
215
224
|
runnerName === undefined ? undefined : `runner \`${runnerName}\``,
|
|
@@ -225,7 +234,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
|
|
|
225
234
|
? defaultAgentIdentity
|
|
226
235
|
: `[${defaultAgentIdentity}](${controlPlaneUrl})`;
|
|
227
236
|
const header = `**${name}** _(Wake ${wakeVersion}${details.length > 0 ? ` · ${details.join(' · ')}` : ''})_`;
|
|
228
|
-
const sections = [wakeCommentMarker, header, body];
|
|
237
|
+
const sections = [wakeCommentMarker, idempotencyMarker, header, body].filter((section) => section !== undefined);
|
|
229
238
|
if (kind === 'approval-request') {
|
|
230
239
|
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 `/ask` followed by your question._');
|
|
231
240
|
}
|
|
@@ -257,6 +266,33 @@ export function formatWakeComment(payload, controlPlaneUrl) {
|
|
|
257
266
|
}
|
|
258
267
|
return sections.join('\n\n');
|
|
259
268
|
}
|
|
269
|
+
function createIssueCommentPublishedEvent(input) {
|
|
270
|
+
return createEventEnvelope({
|
|
271
|
+
eventId: `${input.event.eventId}-published`,
|
|
272
|
+
workItemKey: input.event.workItemKey,
|
|
273
|
+
streamScope: 'work-item',
|
|
274
|
+
direction: 'outbound',
|
|
275
|
+
sourceSystem: 'github',
|
|
276
|
+
sourceEventType: 'ticket.reply.published',
|
|
277
|
+
sourceRefs: {
|
|
278
|
+
repo: input.repo,
|
|
279
|
+
issueNumber: input.issueNumber,
|
|
280
|
+
...(input.commentId === undefined ? {} : { commentId: input.commentId }),
|
|
281
|
+
},
|
|
282
|
+
occurredAt: input.publishedAt,
|
|
283
|
+
ingestedAt: input.publishedAt,
|
|
284
|
+
trigger: 'context-only',
|
|
285
|
+
payload: {
|
|
286
|
+
intentEventId: input.event.eventId,
|
|
287
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
288
|
+
deliveryState: 'CONFIRMED',
|
|
289
|
+
kind: input.event.payload.kind,
|
|
290
|
+
body: input.event.payload.body,
|
|
291
|
+
providerEventType: 'github.issue.comment.published',
|
|
292
|
+
...(input.commentId === undefined ? {} : { providerId: input.commentId }),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
260
296
|
export function createGitHubIssuesWorkSource(deps) {
|
|
261
297
|
/** O(1): one shard read, then a direct projection read by work id. */
|
|
262
298
|
async function readProjectionForIssue(repo, issueNumber) {
|
|
@@ -268,6 +304,71 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
268
304
|
return deps.stateStore.readIssueState(workItemKey);
|
|
269
305
|
}
|
|
270
306
|
return {
|
|
307
|
+
async refreshForDispatch(input) {
|
|
308
|
+
const repoRef = input.projection.issue.repo;
|
|
309
|
+
if (deps.client.getIssue === undefined) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
if (!deps.config.sources.github.repos.includes(repoRef)) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const [owner, repo] = repoRef.split('/');
|
|
316
|
+
if (owner === undefined || repo === undefined) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const ingestedAt = deps.now().toISOString();
|
|
320
|
+
let issue;
|
|
321
|
+
try {
|
|
322
|
+
issue = await deps.client.getIssue(owner, repo, input.projection.issue.number);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (isGitHubNotFound(error)) {
|
|
326
|
+
return {
|
|
327
|
+
events: [],
|
|
328
|
+
sourceRevision: `github:issue:${repoRef}#${input.projection.issue.number}@missing`,
|
|
329
|
+
sourceExists: false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
const events = [];
|
|
335
|
+
if (input.projection.issue.updatedAt !== issue.updated_at) {
|
|
336
|
+
events.push(normalizeTicketUpsert({
|
|
337
|
+
repo: repoRef,
|
|
338
|
+
issue,
|
|
339
|
+
ingestedAt,
|
|
340
|
+
expectedEcho: isExpectedLabelEcho(issue, input.projection),
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
const comments = await deps.client.listComments(owner, repo, input.projection.issue.number, deps.config.sources.github.polling.commentPageSize);
|
|
344
|
+
let latestCommentRevision = '';
|
|
345
|
+
for (const comment of comments) {
|
|
346
|
+
if (comment.updated_at > latestCommentRevision) {
|
|
347
|
+
latestCommentRevision = comment.updated_at;
|
|
348
|
+
}
|
|
349
|
+
const known = input.projection.comments.find((entry) => entry.id === String(comment.id));
|
|
350
|
+
if (known?.updatedAt === comment.updated_at) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (input.projection.wake.expectedEcho.commentIds.includes(String(comment.id))) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
events.push(normalizeTicketCommentEvent({
|
|
357
|
+
repo: repoRef,
|
|
358
|
+
issueNumber: input.projection.issue.number,
|
|
359
|
+
comment,
|
|
360
|
+
ingestedAt,
|
|
361
|
+
...(deps.selfLogin === undefined ? {} : { selfLogin: deps.selfLogin }),
|
|
362
|
+
...(known?.updatedAt === undefined ? {} : { existingUpdatedAt: known.updatedAt }),
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
events,
|
|
367
|
+
sourceRevision: latestCommentRevision.length > 0
|
|
368
|
+
? `github:issue:${repoRef}#${issue.number}@${issue.updated_at};comments@${latestCommentRevision}`
|
|
369
|
+
: `github:issue:${repoRef}#${issue.number}@${issue.updated_at}`,
|
|
370
|
+
};
|
|
371
|
+
},
|
|
271
372
|
async pollEvents(_input) {
|
|
272
373
|
const ingestedAt = deps.now().toISOString();
|
|
273
374
|
const events = [];
|
|
@@ -398,6 +499,8 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
398
499
|
trigger: 'context-only',
|
|
399
500
|
payload: {
|
|
400
501
|
intentEventId: input.event.eventId,
|
|
502
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
503
|
+
deliveryState: 'CONFIRMED',
|
|
401
504
|
...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
|
|
402
505
|
...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
|
|
403
506
|
...(nextWorkflowLabel !== undefined ? { workflowLabel: nextWorkflowLabel } : {}),
|
|
@@ -412,27 +515,75 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
412
515
|
const response = await deps.client.createComment(owner, repoName, issueNumber, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
|
|
413
516
|
const commentId = extractCreatedCommentId(response);
|
|
414
517
|
return [
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
518
|
+
createIssueCommentPublishedEvent({
|
|
519
|
+
event: input.event,
|
|
520
|
+
repo,
|
|
521
|
+
issueNumber,
|
|
522
|
+
publishedAt,
|
|
523
|
+
...(commentId === undefined ? {} : { commentId }),
|
|
524
|
+
}),
|
|
525
|
+
];
|
|
526
|
+
},
|
|
527
|
+
async reconcileIntent(input) {
|
|
528
|
+
const repo = input.event.sourceRefs.repo;
|
|
529
|
+
const issueNumber = input.event.sourceRefs.issueNumber;
|
|
530
|
+
if (repo === undefined || issueNumber === undefined) {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
const [owner, repoName] = repo.split('/');
|
|
534
|
+
if (owner === undefined || repoName === undefined) {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
537
|
+
const publishedAt = deps.now().toISOString();
|
|
538
|
+
if (input.event.sourceEventType === 'wake.labels.requested') {
|
|
539
|
+
const projection = await deps.stateStore.readIssueState(input.event.workItemKey);
|
|
540
|
+
const currentLabels = projection?.issue.labels ?? [];
|
|
541
|
+
const expected = [
|
|
542
|
+
input.event.payload.statusLabel,
|
|
543
|
+
input.event.payload.stageLabel,
|
|
544
|
+
input.event.payload.workflowLabel,
|
|
545
|
+
].filter((label) => typeof label === 'string');
|
|
546
|
+
if (expected.every((label) => currentLabels.includes(label))) {
|
|
547
|
+
return [
|
|
548
|
+
createEventEnvelope({
|
|
549
|
+
eventId: `${input.event.eventId}-labels-updated`,
|
|
550
|
+
workItemKey: input.event.workItemKey,
|
|
551
|
+
streamScope: 'work-item',
|
|
552
|
+
direction: 'outbound',
|
|
553
|
+
sourceSystem: 'github',
|
|
554
|
+
sourceEventType: 'ticket.labels.updated',
|
|
555
|
+
sourceRefs: { repo, issueNumber },
|
|
556
|
+
occurredAt: publishedAt,
|
|
557
|
+
ingestedAt: publishedAt,
|
|
558
|
+
trigger: 'context-only',
|
|
559
|
+
payload: {
|
|
560
|
+
intentEventId: input.event.eventId,
|
|
561
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
562
|
+
deliveryState: 'CONFIRMED',
|
|
563
|
+
labels: currentLabels,
|
|
564
|
+
providerEventType: 'github.issue.labels.updated',
|
|
565
|
+
},
|
|
566
|
+
}),
|
|
567
|
+
];
|
|
568
|
+
}
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
|
|
572
|
+
if (marker === undefined) {
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
const comments = await deps.client.listComments(owner, repoName, issueNumber, deps.config.sources.github.polling.commentPageSize);
|
|
576
|
+
const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
|
|
577
|
+
if (existing === undefined) {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
return [
|
|
581
|
+
createIssueCommentPublishedEvent({
|
|
582
|
+
event: input.event,
|
|
583
|
+
repo,
|
|
584
|
+
issueNumber,
|
|
585
|
+
publishedAt,
|
|
586
|
+
commentId: String(existing.id),
|
|
436
587
|
}),
|
|
437
588
|
];
|
|
438
589
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildResourceUri } from '../../domain/resource-uri.js';
|
|
2
2
|
import { createUnkeyedEventEnvelope, createEventEnvelope } from '../../lib/event-log.js';
|
|
3
|
-
import { formatGitHubError, formatWakeComment, readControlPlaneUiUrl, } from './github-issues-work-source.js';
|
|
3
|
+
import { formatGitHubError, formatWakeComment, readControlPlaneUiUrl, wakeIdempotencyMarker, } from './github-issues-work-source.js';
|
|
4
4
|
const githubPrSource = 'github-pr';
|
|
5
5
|
const wakeCommentMarker = '<!-- wake:agent -->';
|
|
6
6
|
function prResourceUri(repo, number) {
|
|
@@ -61,6 +61,27 @@ export function createGitHubPullRequestActivitySource(deps) {
|
|
|
61
61
|
}
|
|
62
62
|
return { owner, repo, repoRef: `${owner}/${repo}`, number: Number(numberStr) };
|
|
63
63
|
}
|
|
64
|
+
function reviewThreadRefFromUri(resourceUri) {
|
|
65
|
+
const locator = resourceUri.split(':').slice(2).join(':');
|
|
66
|
+
const match = /^([^/]+)\/([^#]+)#(\d+)\/rt_(\d+)$/.exec(locator);
|
|
67
|
+
if (match === null) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const [, owner, repo, numberStr, rootIdStr] = match;
|
|
71
|
+
if (owner === undefined ||
|
|
72
|
+
repo === undefined ||
|
|
73
|
+
numberStr === undefined ||
|
|
74
|
+
rootIdStr === undefined) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
owner,
|
|
79
|
+
repo,
|
|
80
|
+
repoRef: `${owner}/${repo}`,
|
|
81
|
+
number: Number(numberStr),
|
|
82
|
+
rootId: Number(rootIdStr),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
64
85
|
async function discoverPullRequests(ingestedAt) {
|
|
65
86
|
const seenPrData = new Map();
|
|
66
87
|
const confirmedOpenRepos = new Set();
|
|
@@ -404,19 +425,11 @@ export function createGitHubPullRequestActivitySource(deps) {
|
|
|
404
425
|
}
|
|
405
426
|
const publishedAt = deps.now().toISOString();
|
|
406
427
|
if (resourceUri.startsWith('github:pr-review-thread:')) {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
if (match === null) {
|
|
410
|
-
throw new Error(`cannot deliver intent ${input.event.eventId}: malformed review-thread uri ${resourceUri}`);
|
|
411
|
-
}
|
|
412
|
-
const [, owner, repo, numberStr, rootIdStr] = match;
|
|
413
|
-
if (owner === undefined ||
|
|
414
|
-
repo === undefined ||
|
|
415
|
-
numberStr === undefined ||
|
|
416
|
-
rootIdStr === undefined) {
|
|
428
|
+
const ref = reviewThreadRefFromUri(resourceUri);
|
|
429
|
+
if (ref === null) {
|
|
417
430
|
throw new Error(`cannot deliver intent ${input.event.eventId}: malformed review-thread uri ${resourceUri}`);
|
|
418
431
|
}
|
|
419
|
-
const response = await deps.client.replyToReviewComment(owner, repo,
|
|
432
|
+
const response = await deps.client.replyToReviewComment(ref.owner, ref.repo, ref.number, ref.rootId, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
|
|
420
433
|
return [
|
|
421
434
|
createEventEnvelope({
|
|
422
435
|
eventId: `${input.event.eventId}-published`,
|
|
@@ -434,8 +447,11 @@ export function createGitHubPullRequestActivitySource(deps) {
|
|
|
434
447
|
trigger: 'context-only',
|
|
435
448
|
payload: {
|
|
436
449
|
intentEventId: input.event.eventId,
|
|
450
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
451
|
+
deliveryState: 'CONFIRMED',
|
|
437
452
|
kind: input.event.payload.kind,
|
|
438
453
|
body: input.event.payload.body,
|
|
454
|
+
providerId: response?.id,
|
|
439
455
|
},
|
|
440
456
|
}),
|
|
441
457
|
];
|
|
@@ -444,7 +460,79 @@ export function createGitHubPullRequestActivitySource(deps) {
|
|
|
444
460
|
if (ref === null) {
|
|
445
461
|
throw new Error(`cannot deliver intent ${input.event.eventId}: malformed pr uri ${resourceUri}`);
|
|
446
462
|
}
|
|
447
|
-
await deps.client.createComment(ref.owner, ref.repo, ref.number, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
|
|
463
|
+
const response = await deps.client.createComment(ref.owner, ref.repo, ref.number, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
|
|
464
|
+
return [
|
|
465
|
+
createEventEnvelope({
|
|
466
|
+
eventId: `${input.event.eventId}-published`,
|
|
467
|
+
workItemKey: input.event.workItemKey,
|
|
468
|
+
streamScope: 'work-item',
|
|
469
|
+
direction: 'outbound',
|
|
470
|
+
sourceSystem: githubPrSource,
|
|
471
|
+
sourceEventType: 'pr.comment.reply.published',
|
|
472
|
+
sourceRefs: { repo: ref.repoRef, resourceUri },
|
|
473
|
+
occurredAt: publishedAt,
|
|
474
|
+
ingestedAt: publishedAt,
|
|
475
|
+
trigger: 'context-only',
|
|
476
|
+
payload: {
|
|
477
|
+
intentEventId: input.event.eventId,
|
|
478
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
479
|
+
deliveryState: 'CONFIRMED',
|
|
480
|
+
kind: input.event.payload.kind,
|
|
481
|
+
body: input.event.payload.body,
|
|
482
|
+
providerId: response?.data?.id,
|
|
483
|
+
},
|
|
484
|
+
}),
|
|
485
|
+
];
|
|
486
|
+
},
|
|
487
|
+
async reconcileIntent(input) {
|
|
488
|
+
const resourceUri = input.event.sourceRefs.resourceUri;
|
|
489
|
+
const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
|
|
490
|
+
if (resourceUri === undefined || marker === undefined) {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
const publishedAt = deps.now().toISOString();
|
|
494
|
+
if (resourceUri.startsWith('github:pr-review-thread:')) {
|
|
495
|
+
const ref = reviewThreadRefFromUri(resourceUri);
|
|
496
|
+
if (ref === null) {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
const comments = await deps.client.listReviewComments(ref.owner, ref.repo, ref.number, deps.config.sources.github.pullRequests.commentPageSize);
|
|
500
|
+
const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
|
|
501
|
+
if (existing === undefined) {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
return [
|
|
505
|
+
createEventEnvelope({
|
|
506
|
+
eventId: `${input.event.eventId}-published`,
|
|
507
|
+
workItemKey: input.event.workItemKey,
|
|
508
|
+
streamScope: 'work-item',
|
|
509
|
+
direction: 'outbound',
|
|
510
|
+
sourceSystem: githubPrSource,
|
|
511
|
+
sourceEventType: 'pr.review-comment.reply.published',
|
|
512
|
+
sourceRefs: { resourceUri, sourceUrl: existing.html_url },
|
|
513
|
+
occurredAt: publishedAt,
|
|
514
|
+
ingestedAt: publishedAt,
|
|
515
|
+
trigger: 'context-only',
|
|
516
|
+
payload: {
|
|
517
|
+
intentEventId: input.event.eventId,
|
|
518
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
519
|
+
deliveryState: 'CONFIRMED',
|
|
520
|
+
kind: input.event.payload.kind,
|
|
521
|
+
body: input.event.payload.body,
|
|
522
|
+
providerId: existing.id,
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
525
|
+
];
|
|
526
|
+
}
|
|
527
|
+
const ref = repoAndNumberFromPrUri(resourceUri);
|
|
528
|
+
if (ref === null) {
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
const comments = await deps.client.listComments(ref.owner, ref.repo, ref.number, deps.config.sources.github.pullRequests.commentPageSize);
|
|
532
|
+
const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
|
|
533
|
+
if (existing === undefined) {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
448
536
|
return [
|
|
449
537
|
createEventEnvelope({
|
|
450
538
|
eventId: `${input.event.eventId}-published`,
|
|
@@ -459,8 +547,11 @@ export function createGitHubPullRequestActivitySource(deps) {
|
|
|
459
547
|
trigger: 'context-only',
|
|
460
548
|
payload: {
|
|
461
549
|
intentEventId: input.event.eventId,
|
|
550
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
551
|
+
deliveryState: 'CONFIRMED',
|
|
462
552
|
kind: input.event.payload.kind,
|
|
463
553
|
body: input.event.payload.body,
|
|
554
|
+
providerId: existing.id,
|
|
464
555
|
},
|
|
465
556
|
}),
|
|
466
557
|
];
|
|
@@ -29,6 +29,8 @@ function isFreshTriggeringComment(candidate) {
|
|
|
29
29
|
export function createPublishIntentEvent(input) {
|
|
30
30
|
const tokenCount = extractTokenCount(input.runnerResult.tokenUsage);
|
|
31
31
|
const duration = formatDuration(input.startedAt, input.occurredAt);
|
|
32
|
+
const failureRepeated = input.runnerResult.failureClass !== undefined &&
|
|
33
|
+
input.previousFailureClass === input.runnerResult.failureClass;
|
|
32
34
|
return createEventEnvelope({
|
|
33
35
|
eventId: `${input.runId}-publish-intent`,
|
|
34
36
|
workItemKey: input.projection.workItemKey,
|
|
@@ -107,6 +109,12 @@ export function createPublishIntentEvent(input) {
|
|
|
107
109
|
? {}
|
|
108
110
|
: { cost: formatCostUsd(input.runnerResult.tokenUsage.costUsd) }),
|
|
109
111
|
...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
|
|
112
|
+
idempotencyKey: `${input.runId}:result-comment`,
|
|
113
|
+
deliveryState: 'PENDING',
|
|
114
|
+
...(failureRepeated ? { failureRepeated } : {}),
|
|
115
|
+
...(input.previousFailureClass === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { previousFailureClass: input.previousFailureClass }),
|
|
110
118
|
},
|
|
111
119
|
derivedHints: {
|
|
112
120
|
stage: input.sentinel === 'DONE' ? 'done' : input.projection.wake.stage,
|
|
@@ -114,6 +122,7 @@ export function createPublishIntentEvent(input) {
|
|
|
114
122
|
});
|
|
115
123
|
}
|
|
116
124
|
export function createLabelsEvent(input) {
|
|
125
|
+
const labelKind = input.statusLabel === 'wake:status.working' ? 'working-label' : 'completion-label';
|
|
117
126
|
return createEventEnvelope({
|
|
118
127
|
eventId: `${input.runId}-labels-${input.statusLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.stageLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.workflowLabel.replace(/[^a-z0-9]+/gi, '-')}`,
|
|
119
128
|
workItemKey: input.projection.workItemKey,
|
|
@@ -134,6 +143,8 @@ export function createLabelsEvent(input) {
|
|
|
134
143
|
stageLabel: input.stageLabel,
|
|
135
144
|
workflowLabel: input.workflowLabel,
|
|
136
145
|
origin: input.projection.origin ?? 'github',
|
|
146
|
+
idempotencyKey: `${input.runId}:${labelKind}`,
|
|
147
|
+
deliveryState: 'PENDING',
|
|
137
148
|
},
|
|
138
149
|
});
|
|
139
150
|
}
|
package/dist/src/core/outbox.js
CHANGED
|
@@ -12,6 +12,10 @@ const outboundConfirmationEventTypes = new Set([
|
|
|
12
12
|
'pr.comment.reply.published',
|
|
13
13
|
'pr.review-comment.reply.published',
|
|
14
14
|
]);
|
|
15
|
+
function intentId(event) {
|
|
16
|
+
const intentEventId = event.payload.intentEventId;
|
|
17
|
+
return typeof intentEventId === 'string' ? intentEventId : undefined;
|
|
18
|
+
}
|
|
15
19
|
// The outbox: outbound delivery (comments, labels) attempted independently of
|
|
16
20
|
// run-outcome recording, with a durable, bounded retry trace. Extracted from
|
|
17
21
|
// tick-runner.ts so it can be exercised in isolation; it has the cleanest
|
|
@@ -33,12 +37,60 @@ export function createOutbox(deps) {
|
|
|
33
37
|
payload: {
|
|
34
38
|
intentEventId: intentEvent.eventId,
|
|
35
39
|
intentEventType: intentEvent.sourceEventType,
|
|
40
|
+
idempotencyKey: intentEvent.payload.idempotencyKey,
|
|
41
|
+
deliveryState: 'PENDING',
|
|
36
42
|
error: err instanceof Error ? err.message : String(err),
|
|
37
43
|
},
|
|
38
44
|
});
|
|
39
45
|
await deps.stateStore.appendEventEnvelope(failureEvent);
|
|
40
46
|
await deps.projectionUpdater.rebuildFromEvents([failureEvent]);
|
|
41
47
|
}
|
|
48
|
+
async function recordSentUnconfirmed(intentEvent) {
|
|
49
|
+
const occurredAt = deps.clock.now().toISOString();
|
|
50
|
+
const sentEvent = createEventEnvelope({
|
|
51
|
+
eventId: `${intentEvent.eventId}-sent-unconfirmed-${randomUUID()}`,
|
|
52
|
+
workItemKey: intentEvent.workItemKey,
|
|
53
|
+
streamScope: 'work-item',
|
|
54
|
+
direction: 'internal',
|
|
55
|
+
sourceSystem: 'wake',
|
|
56
|
+
sourceEventType: 'wake.publish.sent-unconfirmed',
|
|
57
|
+
sourceRefs: intentEvent.sourceRefs,
|
|
58
|
+
occurredAt,
|
|
59
|
+
ingestedAt: occurredAt,
|
|
60
|
+
trigger: 'context-only',
|
|
61
|
+
payload: {
|
|
62
|
+
intentEventId: intentEvent.eventId,
|
|
63
|
+
intentEventType: intentEvent.sourceEventType,
|
|
64
|
+
idempotencyKey: intentEvent.payload.idempotencyKey,
|
|
65
|
+
deliveryState: 'SENT_UNCONFIRMED',
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await deps.stateStore.appendEventEnvelope(sentEvent);
|
|
69
|
+
await deps.projectionUpdater.rebuildFromEvents([sentEvent]);
|
|
70
|
+
}
|
|
71
|
+
async function recordConfirmed(intentEvent, payload = {}) {
|
|
72
|
+
const confirmedAt = deps.clock.now().toISOString();
|
|
73
|
+
const confirmedEvent = createEventEnvelope({
|
|
74
|
+
eventId: `${intentEvent.eventId}-confirmed`,
|
|
75
|
+
workItemKey: intentEvent.workItemKey,
|
|
76
|
+
streamScope: 'work-item',
|
|
77
|
+
direction: 'internal',
|
|
78
|
+
sourceSystem: 'wake',
|
|
79
|
+
sourceEventType: 'wake.publish.confirmed',
|
|
80
|
+
sourceRefs: intentEvent.sourceRefs,
|
|
81
|
+
occurredAt: confirmedAt,
|
|
82
|
+
ingestedAt: confirmedAt,
|
|
83
|
+
trigger: 'context-only',
|
|
84
|
+
payload: {
|
|
85
|
+
intentEventId: intentEvent.eventId,
|
|
86
|
+
idempotencyKey: intentEvent.payload.idempotencyKey,
|
|
87
|
+
deliveryState: 'CONFIRMED',
|
|
88
|
+
...payload,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const appended = await deps.stateStore.appendEventEnvelope(confirmedEvent);
|
|
92
|
+
await deps.projectionUpdater.rebuildFromEvents([appended]);
|
|
93
|
+
}
|
|
42
94
|
// Outbound delivery (comments, labels) is attempted independently of run-outcome
|
|
43
95
|
// recording: a delivery failure must never rewrite an already-recorded run result
|
|
44
96
|
// (S1), and must always leave a durable, retryable trace instead of being lost
|
|
@@ -48,6 +100,7 @@ export function createOutbox(deps) {
|
|
|
48
100
|
return;
|
|
49
101
|
}
|
|
50
102
|
try {
|
|
103
|
+
await recordSentUnconfirmed(event);
|
|
51
104
|
const deliveryEvents = await deps.outboundSink.deliverIntent({ event });
|
|
52
105
|
for (const deliveryEvent of deliveryEvents) {
|
|
53
106
|
await deps.stateStore.appendEventEnvelope(deliveryEvent);
|
|
@@ -57,21 +110,7 @@ export function createOutbox(deps) {
|
|
|
57
110
|
// No confirmation event was produced (e.g. a no-op label update) but the
|
|
58
111
|
// sink did not throw. Record that delivery was attempted successfully so
|
|
59
112
|
// the outbox scan below does not retry it indefinitely.
|
|
60
|
-
|
|
61
|
-
const confirmedEvent = createEventEnvelope({
|
|
62
|
-
eventId: `${event.eventId}-confirmed`,
|
|
63
|
-
workItemKey: event.workItemKey,
|
|
64
|
-
streamScope: 'work-item',
|
|
65
|
-
direction: 'internal',
|
|
66
|
-
sourceSystem: 'wake',
|
|
67
|
-
sourceEventType: 'wake.publish.confirmed',
|
|
68
|
-
sourceRefs: event.sourceRefs,
|
|
69
|
-
occurredAt: confirmedAt,
|
|
70
|
-
ingestedAt: confirmedAt,
|
|
71
|
-
trigger: 'context-only',
|
|
72
|
-
payload: { intentEventId: event.eventId },
|
|
73
|
-
});
|
|
74
|
-
await deps.stateStore.appendEventEnvelope(confirmedEvent);
|
|
113
|
+
await recordConfirmed(event);
|
|
75
114
|
}
|
|
76
115
|
}
|
|
77
116
|
catch (err) {
|
|
@@ -83,6 +122,18 @@ export function createOutbox(deps) {
|
|
|
83
122
|
await deps.projectionUpdater.rebuildFromEvents([event]);
|
|
84
123
|
await attemptDelivery(event);
|
|
85
124
|
}
|
|
125
|
+
async function suppressOutboundEvent(event, input) {
|
|
126
|
+
const suppressedEvent = await deps.stateStore.appendEventEnvelope({
|
|
127
|
+
...event,
|
|
128
|
+
payload: {
|
|
129
|
+
...event.payload,
|
|
130
|
+
deliveryState: 'CONFIRMED',
|
|
131
|
+
suppressedPublishReason: input.suppressedPublishReason,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
await deps.projectionUpdater.rebuildFromEvents([suppressedEvent]);
|
|
135
|
+
await recordConfirmed(event, { suppressedPublishReason: input.suppressedPublishReason });
|
|
136
|
+
}
|
|
86
137
|
// Adopts the outbox pattern: an intent is only considered delivered once a
|
|
87
138
|
// matching confirmation event exists. Anything left unconfirmed by a prior tick
|
|
88
139
|
// (e.g. the process crashed mid-delivery) is retried here, bounded so a
|
|
@@ -94,9 +145,10 @@ export function createOutbox(deps) {
|
|
|
94
145
|
const events = await deps.stateStore.listEventEnvelopes();
|
|
95
146
|
const confirmedIntentIds = new Set();
|
|
96
147
|
const failureAttempts = new Map();
|
|
148
|
+
const uncertainIntentIds = new Set();
|
|
97
149
|
for (const event of events) {
|
|
98
|
-
const intentEventId = event
|
|
99
|
-
if (
|
|
150
|
+
const intentEventId = intentId(event);
|
|
151
|
+
if (intentEventId === undefined) {
|
|
100
152
|
continue;
|
|
101
153
|
}
|
|
102
154
|
if (outboundConfirmationEventTypes.has(event.sourceEventType)) {
|
|
@@ -105,6 +157,9 @@ export function createOutbox(deps) {
|
|
|
105
157
|
if (event.sourceEventType === 'wake.publish.failed') {
|
|
106
158
|
failureAttempts.set(intentEventId, (failureAttempts.get(intentEventId) ?? 0) + 1);
|
|
107
159
|
}
|
|
160
|
+
if (event.sourceEventType === 'wake.publish.sent-unconfirmed') {
|
|
161
|
+
uncertainIntentIds.add(intentEventId);
|
|
162
|
+
}
|
|
108
163
|
}
|
|
109
164
|
for (const intent of events) {
|
|
110
165
|
if (!outboundIntentEventTypes.has(intent.sourceEventType)) {
|
|
@@ -116,8 +171,30 @@ export function createOutbox(deps) {
|
|
|
116
171
|
if ((failureAttempts.get(intent.eventId) ?? 0) >= outboxMaxAttempts) {
|
|
117
172
|
continue;
|
|
118
173
|
}
|
|
174
|
+
if (uncertainIntentIds.has(intent.eventId) &&
|
|
175
|
+
deps.outboundSink.reconcileIntent !== undefined) {
|
|
176
|
+
try {
|
|
177
|
+
const reconciledEvents = await deps.outboundSink.reconcileIntent({ event: intent });
|
|
178
|
+
for (const reconciledEvent of reconciledEvents) {
|
|
179
|
+
await deps.stateStore.appendEventEnvelope(reconciledEvent);
|
|
180
|
+
}
|
|
181
|
+
await deps.projectionUpdater.rebuildFromEvents(reconciledEvents);
|
|
182
|
+
if (reconciledEvents.length > 0) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
await recordDeliveryFailure(intent, err);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
119
191
|
await attemptDelivery(intent);
|
|
120
192
|
}
|
|
121
193
|
}
|
|
122
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
attemptDelivery,
|
|
196
|
+
deliverOutboundEvent,
|
|
197
|
+
retryUnconfirmedDeliveries,
|
|
198
|
+
suppressOutboundEvent,
|
|
199
|
+
};
|
|
123
200
|
}
|
|
@@ -4,6 +4,18 @@ export function createWorkSourceFanIn(sources) {
|
|
|
4
4
|
const batches = await Promise.all(sources.map((source) => source.pollEvents(input)));
|
|
5
5
|
return batches.flat();
|
|
6
6
|
},
|
|
7
|
+
async refreshForDispatch(input) {
|
|
8
|
+
for (const source of sources) {
|
|
9
|
+
if (source.refreshForDispatch === undefined) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const result = await source.refreshForDispatch(input);
|
|
13
|
+
if (result !== null) {
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
},
|
|
7
19
|
};
|
|
8
20
|
}
|
|
9
21
|
function intentKind(event) {
|
|
@@ -38,36 +50,34 @@ function withSinkRef(event, sink) {
|
|
|
38
50
|
},
|
|
39
51
|
};
|
|
40
52
|
}
|
|
53
|
+
function targetSinksForEvent(input) {
|
|
54
|
+
const targetSinks = new Set();
|
|
55
|
+
const origin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
|
|
56
|
+
const sourceOrigin = input.event.sourceRefs.sink ?? origin;
|
|
57
|
+
if (input.event.sourceEventType === 'wake.labels.requested') {
|
|
58
|
+
const projectionOrigin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
|
|
59
|
+
if (projectionOrigin !== undefined) {
|
|
60
|
+
targetSinks.add(projectionOrigin);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const resourceUri = input.event.sourceRefs.resourceUri;
|
|
64
|
+
if (input.event.sourceEventType === 'wake.publish.intent.requested' &&
|
|
65
|
+
sourceOrigin !== undefined) {
|
|
66
|
+
const resourceSink = resourceUri === undefined ? sourceOrigin : sinkNameForResourceUri(resourceUri, sourceOrigin);
|
|
67
|
+
targetSinks.add(input.sinksByName.has(resourceSink) ? resourceSink : sourceOrigin);
|
|
68
|
+
}
|
|
69
|
+
for (const [sinkName, sinkConfig] of Object.entries(input.config.sinks ?? {})) {
|
|
70
|
+
if (sinkConfig.subscribe.some((subscription) => subscriptionMatches(input.event, subscription))) {
|
|
71
|
+
targetSinks.add(sinkName);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return targetSinks;
|
|
75
|
+
}
|
|
41
76
|
export function createOutboundSinkRouter(input) {
|
|
42
77
|
const sinksByName = new Map(input.sinks.map((sink) => [sink.sink, sink]));
|
|
43
78
|
return {
|
|
44
79
|
async deliverIntent({ event }) {
|
|
45
|
-
const targetSinks =
|
|
46
|
-
const origin = typeof event.payload.origin === 'string' ? event.payload.origin : undefined;
|
|
47
|
-
const sourceOrigin = event.sourceRefs.sink ?? origin;
|
|
48
|
-
if (event.sourceEventType === 'wake.labels.requested') {
|
|
49
|
-
const projectionOrigin = typeof event.payload.origin === 'string' ? event.payload.origin : undefined;
|
|
50
|
-
if (projectionOrigin !== undefined) {
|
|
51
|
-
targetSinks.add(projectionOrigin);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
const resourceUri = event.sourceRefs.resourceUri;
|
|
55
|
-
if (event.sourceEventType === 'wake.publish.intent.requested' && sourceOrigin !== undefined) {
|
|
56
|
-
const resourceSink = resourceUri === undefined
|
|
57
|
-
? sourceOrigin
|
|
58
|
-
: sinkNameForResourceUri(resourceUri, sourceOrigin);
|
|
59
|
-
// A resource-derived sink name (e.g. a PR surface) may not be
|
|
60
|
-
// registered — the source that owns it can be disabled independently
|
|
61
|
-
// of the origin sink. Falling back to sourceOrigin here, rather than
|
|
62
|
-
// silently skipping an unregistered sink below, is what keeps a reply
|
|
63
|
-
// from being dropped-but-marked-delivered when that happens.
|
|
64
|
-
targetSinks.add(sinksByName.has(resourceSink) ? resourceSink : sourceOrigin);
|
|
65
|
-
}
|
|
66
|
-
for (const [sinkName, sinkConfig] of Object.entries(input.config.sinks ?? {})) {
|
|
67
|
-
if (sinkConfig.subscribe.some((subscription) => subscriptionMatches(event, subscription))) {
|
|
68
|
-
targetSinks.add(sinkName);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
80
|
+
const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
|
|
71
81
|
const deliveryEvents = [];
|
|
72
82
|
for (const sinkName of targetSinks) {
|
|
73
83
|
const sink = sinksByName.get(sinkName);
|
|
@@ -79,5 +89,18 @@ export function createOutboundSinkRouter(input) {
|
|
|
79
89
|
}
|
|
80
90
|
return deliveryEvents;
|
|
81
91
|
},
|
|
92
|
+
async reconcileIntent({ event }) {
|
|
93
|
+
const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
|
|
94
|
+
const deliveryEvents = [];
|
|
95
|
+
for (const sinkName of targetSinks) {
|
|
96
|
+
const sink = sinksByName.get(sinkName);
|
|
97
|
+
if (sink?.reconcileIntent === undefined) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const sinkDeliveryEvents = await sink.reconcileIntent({ event });
|
|
101
|
+
deliveryEvents.push(...sinkDeliveryEvents.map((deliveryEvent) => withSinkRef(deliveryEvent, sinkName)));
|
|
102
|
+
}
|
|
103
|
+
return deliveryEvents;
|
|
104
|
+
},
|
|
82
105
|
};
|
|
83
106
|
}
|
|
@@ -21,6 +21,15 @@ function latestHumanCommentId(candidate) {
|
|
|
21
21
|
const human = candidate.comments.filter((c) => !c.isBotAuthored);
|
|
22
22
|
return human.at(-1)?.id;
|
|
23
23
|
}
|
|
24
|
+
function projectedSourceRevision(projection) {
|
|
25
|
+
const latestCommentUpdatedAt = projection.comments
|
|
26
|
+
.map((comment) => comment.updatedAt)
|
|
27
|
+
.sort()
|
|
28
|
+
.at(-1);
|
|
29
|
+
return latestCommentUpdatedAt === undefined
|
|
30
|
+
? `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt}`
|
|
31
|
+
: `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt};comments@${latestCommentUpdatedAt}`;
|
|
32
|
+
}
|
|
24
33
|
function isLateralReadOnlyAction(action, config) {
|
|
25
34
|
return isCustomCommandAction(action, config);
|
|
26
35
|
}
|
|
@@ -41,7 +50,7 @@ export function createTickRunner(deps) {
|
|
|
41
50
|
resourceIndex: deps.resourceIndex,
|
|
42
51
|
config: deps.config,
|
|
43
52
|
});
|
|
44
|
-
const { deliverOutboundEvent, retryUnconfirmedDeliveries } = createOutbox({
|
|
53
|
+
const { deliverOutboundEvent, retryUnconfirmedDeliveries, suppressOutboundEvent } = createOutbox({
|
|
45
54
|
clock: deps.clock,
|
|
46
55
|
stateStore: deps.stateStore,
|
|
47
56
|
projectionUpdater,
|
|
@@ -137,6 +146,7 @@ export function createTickRunner(deps) {
|
|
|
137
146
|
relation: 'primary',
|
|
138
147
|
provenance: 'agent-reported',
|
|
139
148
|
registeredBy: input.runId,
|
|
149
|
+
idempotencyKey: `${input.runId}:artifact-registration:${verified.resourceUri}`,
|
|
140
150
|
},
|
|
141
151
|
});
|
|
142
152
|
const appended = await deps.stateStore.appendEventEnvelope(event);
|
|
@@ -220,6 +230,18 @@ export function createTickRunner(deps) {
|
|
|
220
230
|
processStartedAt: record.workerProcessStartedAt,
|
|
221
231
|
}));
|
|
222
232
|
}
|
|
233
|
+
async function hasSchedulerCapacity(now) {
|
|
234
|
+
const runRecords = await deps.stateStore.listRunRecords();
|
|
235
|
+
for (const record of runRecords) {
|
|
236
|
+
if (record.status !== 'running') {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (await isRunningRecordActive(record, now)) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
223
245
|
async function parkConfigDriftedProjections(projections) {
|
|
224
246
|
let parked = false;
|
|
225
247
|
for (const projection of projections) {
|
|
@@ -313,10 +335,31 @@ export function createTickRunner(deps) {
|
|
|
313
335
|
if (await parkConfigDriftedProjections(projections)) {
|
|
314
336
|
return { status: 'processed' };
|
|
315
337
|
}
|
|
316
|
-
|
|
338
|
+
let candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
|
|
317
339
|
if (candidate === undefined) {
|
|
318
340
|
return { status: 'idle' };
|
|
319
341
|
}
|
|
342
|
+
let sourceRevision = projectedSourceRevision(candidate);
|
|
343
|
+
let refresh;
|
|
344
|
+
try {
|
|
345
|
+
refresh = await deps.workSource.refreshForDispatch?.({ projection: candidate });
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return { status: 'idle' };
|
|
349
|
+
}
|
|
350
|
+
if (refresh !== undefined && refresh !== null) {
|
|
351
|
+
if (refresh.sourceExists === false) {
|
|
352
|
+
return { status: 'idle' };
|
|
353
|
+
}
|
|
354
|
+
sourceRevision = refresh.sourceRevision;
|
|
355
|
+
if (refresh.events.length > 0) {
|
|
356
|
+
await ingestInboundEvents(refresh.events);
|
|
357
|
+
candidate = (await deps.stateStore.readIssueState(candidate.workItemKey)) ?? candidate;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (policy.resolveNextEligibleAction(candidate, deps.config) === null) {
|
|
361
|
+
return { status: 'idle' };
|
|
362
|
+
}
|
|
320
363
|
const workflow = workflowForProjection(candidate, deps.config);
|
|
321
364
|
if (workflow === null) {
|
|
322
365
|
return { status: 'idle' };
|
|
@@ -443,6 +486,9 @@ export function createTickRunner(deps) {
|
|
|
443
486
|
if (routing === null) {
|
|
444
487
|
return { status: 'idle' };
|
|
445
488
|
}
|
|
489
|
+
if (!(await hasSchedulerCapacity(deps.clock.now()))) {
|
|
490
|
+
return { status: 'idle' };
|
|
491
|
+
}
|
|
446
492
|
const runId = `run-${candidate.issue.number}-${deps.clock.now().getTime()}`;
|
|
447
493
|
const lease = createRunLease({ clock: deps.clock, ownerInstanceId });
|
|
448
494
|
const workerIdentity = currentProcessIdentity();
|
|
@@ -460,6 +506,9 @@ export function createTickRunner(deps) {
|
|
|
460
506
|
lease,
|
|
461
507
|
workerPid: workerIdentity.pid,
|
|
462
508
|
workerProcessStartedAt: workerIdentity.processStartedAt,
|
|
509
|
+
metadata: {
|
|
510
|
+
sourceRevision,
|
|
511
|
+
},
|
|
463
512
|
};
|
|
464
513
|
await deps.stateStore.writeRunRecord(runningRecord);
|
|
465
514
|
async function transitionRunLifecycle(lifecycle) {
|
|
@@ -488,6 +537,7 @@ export function createTickRunner(deps) {
|
|
|
488
537
|
action,
|
|
489
538
|
priorStage: candidate.wake.stage,
|
|
490
539
|
claimedStage,
|
|
540
|
+
sourceRevision,
|
|
491
541
|
},
|
|
492
542
|
});
|
|
493
543
|
await deps.stateStore.appendEventEnvelope(claimedEvent);
|
|
@@ -721,23 +771,31 @@ export function createTickRunner(deps) {
|
|
|
721
771
|
workflowLabel: workflowLabelForWorkflowName(workflowName),
|
|
722
772
|
occurredAt: finishedAt,
|
|
723
773
|
}));
|
|
774
|
+
const publishIntent = createPublishIntentEvent({
|
|
775
|
+
projection: candidate,
|
|
776
|
+
runId,
|
|
777
|
+
action,
|
|
778
|
+
runnerResult,
|
|
779
|
+
parsedRunnerResult,
|
|
780
|
+
sentinel,
|
|
781
|
+
occurredAt: finishedAt,
|
|
782
|
+
startedAt: nowIso,
|
|
783
|
+
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
784
|
+
...(typeof candidate.context.lastFailureClass === 'string'
|
|
785
|
+
? { previousFailureClass: candidate.context.lastFailureClass }
|
|
786
|
+
: {}),
|
|
787
|
+
});
|
|
724
788
|
if (shouldPublishRunResult({
|
|
725
789
|
failureClass: runnerResult.failureClass,
|
|
726
790
|
previousFailureClass: candidate.context.lastFailureClass,
|
|
727
791
|
})) {
|
|
728
|
-
const publishIntent = createPublishIntentEvent({
|
|
729
|
-
projection: candidate,
|
|
730
|
-
runId,
|
|
731
|
-
action,
|
|
732
|
-
runnerResult,
|
|
733
|
-
parsedRunnerResult,
|
|
734
|
-
sentinel,
|
|
735
|
-
occurredAt: finishedAt,
|
|
736
|
-
startedAt: nowIso,
|
|
737
|
-
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
738
|
-
});
|
|
739
792
|
await deliverOutboundEvent(publishIntent);
|
|
740
793
|
}
|
|
794
|
+
else {
|
|
795
|
+
await suppressOutboundEvent(publishIntent, {
|
|
796
|
+
suppressedPublishReason: runnerResult.failureClass === 'quota' ? 'quota-failure' : 'repeated-infra-failure',
|
|
797
|
+
});
|
|
798
|
+
}
|
|
741
799
|
return {
|
|
742
800
|
status: 'processed',
|
|
743
801
|
runId,
|
|
@@ -759,6 +817,7 @@ export function createTickRunner(deps) {
|
|
|
759
817
|
executionOutcome: 'PROCESS_FAILED',
|
|
760
818
|
summary: err instanceof Error ? err.message : String(err),
|
|
761
819
|
metadata: {
|
|
820
|
+
...failedRecord.metadata,
|
|
762
821
|
failureClass: 'infra',
|
|
763
822
|
},
|
|
764
823
|
});
|
|
@@ -805,6 +864,7 @@ export function createTickRunner(deps) {
|
|
|
805
864
|
result: errorMessage,
|
|
806
865
|
model: 'unknown',
|
|
807
866
|
cli: 'unknown',
|
|
867
|
+
failureClass: 'infra',
|
|
808
868
|
};
|
|
809
869
|
const parsedInfraFailureResult = parseRunnerResult(infraFailureResult.result);
|
|
810
870
|
const failurePublishIntent = createPublishIntentEvent({
|
|
@@ -816,6 +876,9 @@ export function createTickRunner(deps) {
|
|
|
816
876
|
sentinel,
|
|
817
877
|
occurredAt: finishedAt,
|
|
818
878
|
startedAt: nowIso,
|
|
879
|
+
...(typeof candidate.context.lastFailureClass === 'string'
|
|
880
|
+
? { previousFailureClass: candidate.context.lastFailureClass }
|
|
881
|
+
: {}),
|
|
819
882
|
});
|
|
820
883
|
if (shouldPublishRunResult({
|
|
821
884
|
failureClass: 'infra',
|
|
@@ -823,6 +886,11 @@ export function createTickRunner(deps) {
|
|
|
823
886
|
})) {
|
|
824
887
|
await deliverOutboundEvent(failurePublishIntent);
|
|
825
888
|
}
|
|
889
|
+
else {
|
|
890
|
+
await suppressOutboundEvent(failurePublishIntent, {
|
|
891
|
+
suppressedPublishReason: 'repeated-infra-failure',
|
|
892
|
+
});
|
|
893
|
+
}
|
|
826
894
|
return {
|
|
827
895
|
status: 'processed',
|
|
828
896
|
runId,
|
package/dist/src/version.js
CHANGED