@atolis-hq/wake 0.2.30 → 0.2.31
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-issues-work-source.js +105 -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 +37 -26
- package/dist/src/core/tick-runner.js +30 -12
- 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
|
},
|
|
@@ -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).
|
|
@@ -210,6 +215,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
|
|
|
210
215
|
const tokens = typeof payload.tokens === 'string' ? payload.tokens : undefined;
|
|
211
216
|
const cost = typeof payload.cost === 'string' ? payload.cost : undefined;
|
|
212
217
|
const workspacePath = typeof payload.workspacePath === 'string' ? payload.workspacePath : undefined;
|
|
218
|
+
const idempotencyMarker = wakeIdempotencyMarker(payload.idempotencyKey);
|
|
213
219
|
const details = [
|
|
214
220
|
action === undefined ? undefined : `stage \`${action}\``,
|
|
215
221
|
runnerName === undefined ? undefined : `runner \`${runnerName}\``,
|
|
@@ -225,7 +231,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
|
|
|
225
231
|
? defaultAgentIdentity
|
|
226
232
|
: `[${defaultAgentIdentity}](${controlPlaneUrl})`;
|
|
227
233
|
const header = `**${name}** _(Wake ${wakeVersion}${details.length > 0 ? ` · ${details.join(' · ')}` : ''})_`;
|
|
228
|
-
const sections = [wakeCommentMarker, header, body];
|
|
234
|
+
const sections = [wakeCommentMarker, idempotencyMarker, header, body].filter((section) => section !== undefined);
|
|
229
235
|
if (kind === 'approval-request') {
|
|
230
236
|
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
237
|
}
|
|
@@ -257,6 +263,33 @@ export function formatWakeComment(payload, controlPlaneUrl) {
|
|
|
257
263
|
}
|
|
258
264
|
return sections.join('\n\n');
|
|
259
265
|
}
|
|
266
|
+
function createIssueCommentPublishedEvent(input) {
|
|
267
|
+
return createEventEnvelope({
|
|
268
|
+
eventId: `${input.event.eventId}-published`,
|
|
269
|
+
workItemKey: input.event.workItemKey,
|
|
270
|
+
streamScope: 'work-item',
|
|
271
|
+
direction: 'outbound',
|
|
272
|
+
sourceSystem: 'github',
|
|
273
|
+
sourceEventType: 'ticket.reply.published',
|
|
274
|
+
sourceRefs: {
|
|
275
|
+
repo: input.repo,
|
|
276
|
+
issueNumber: input.issueNumber,
|
|
277
|
+
...(input.commentId === undefined ? {} : { commentId: input.commentId }),
|
|
278
|
+
},
|
|
279
|
+
occurredAt: input.publishedAt,
|
|
280
|
+
ingestedAt: input.publishedAt,
|
|
281
|
+
trigger: 'context-only',
|
|
282
|
+
payload: {
|
|
283
|
+
intentEventId: input.event.eventId,
|
|
284
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
285
|
+
deliveryState: 'CONFIRMED',
|
|
286
|
+
kind: input.event.payload.kind,
|
|
287
|
+
body: input.event.payload.body,
|
|
288
|
+
providerEventType: 'github.issue.comment.published',
|
|
289
|
+
...(input.commentId === undefined ? {} : { providerId: input.commentId }),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
260
293
|
export function createGitHubIssuesWorkSource(deps) {
|
|
261
294
|
/** O(1): one shard read, then a direct projection read by work id. */
|
|
262
295
|
async function readProjectionForIssue(repo, issueNumber) {
|
|
@@ -398,6 +431,8 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
398
431
|
trigger: 'context-only',
|
|
399
432
|
payload: {
|
|
400
433
|
intentEventId: input.event.eventId,
|
|
434
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
435
|
+
deliveryState: 'CONFIRMED',
|
|
401
436
|
...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
|
|
402
437
|
...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
|
|
403
438
|
...(nextWorkflowLabel !== undefined ? { workflowLabel: nextWorkflowLabel } : {}),
|
|
@@ -412,27 +447,75 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
412
447
|
const response = await deps.client.createComment(owner, repoName, issueNumber, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
|
|
413
448
|
const commentId = extractCreatedCommentId(response);
|
|
414
449
|
return [
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
450
|
+
createIssueCommentPublishedEvent({
|
|
451
|
+
event: input.event,
|
|
452
|
+
repo,
|
|
453
|
+
issueNumber,
|
|
454
|
+
publishedAt,
|
|
455
|
+
...(commentId === undefined ? {} : { commentId }),
|
|
456
|
+
}),
|
|
457
|
+
];
|
|
458
|
+
},
|
|
459
|
+
async reconcileIntent(input) {
|
|
460
|
+
const repo = input.event.sourceRefs.repo;
|
|
461
|
+
const issueNumber = input.event.sourceRefs.issueNumber;
|
|
462
|
+
if (repo === undefined || issueNumber === undefined) {
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
const [owner, repoName] = repo.split('/');
|
|
466
|
+
if (owner === undefined || repoName === undefined) {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const publishedAt = deps.now().toISOString();
|
|
470
|
+
if (input.event.sourceEventType === 'wake.labels.requested') {
|
|
471
|
+
const projection = await deps.stateStore.readIssueState(input.event.workItemKey);
|
|
472
|
+
const currentLabels = projection?.issue.labels ?? [];
|
|
473
|
+
const expected = [
|
|
474
|
+
input.event.payload.statusLabel,
|
|
475
|
+
input.event.payload.stageLabel,
|
|
476
|
+
input.event.payload.workflowLabel,
|
|
477
|
+
].filter((label) => typeof label === 'string');
|
|
478
|
+
if (expected.every((label) => currentLabels.includes(label))) {
|
|
479
|
+
return [
|
|
480
|
+
createEventEnvelope({
|
|
481
|
+
eventId: `${input.event.eventId}-labels-updated`,
|
|
482
|
+
workItemKey: input.event.workItemKey,
|
|
483
|
+
streamScope: 'work-item',
|
|
484
|
+
direction: 'outbound',
|
|
485
|
+
sourceSystem: 'github',
|
|
486
|
+
sourceEventType: 'ticket.labels.updated',
|
|
487
|
+
sourceRefs: { repo, issueNumber },
|
|
488
|
+
occurredAt: publishedAt,
|
|
489
|
+
ingestedAt: publishedAt,
|
|
490
|
+
trigger: 'context-only',
|
|
491
|
+
payload: {
|
|
492
|
+
intentEventId: input.event.eventId,
|
|
493
|
+
idempotencyKey: input.event.payload.idempotencyKey,
|
|
494
|
+
deliveryState: 'CONFIRMED',
|
|
495
|
+
labels: currentLabels,
|
|
496
|
+
providerEventType: 'github.issue.labels.updated',
|
|
497
|
+
},
|
|
498
|
+
}),
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
|
|
504
|
+
if (marker === undefined) {
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
const comments = await deps.client.listComments(owner, repoName, issueNumber, deps.config.sources.github.polling.commentPageSize);
|
|
508
|
+
const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
|
|
509
|
+
if (existing === undefined) {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
return [
|
|
513
|
+
createIssueCommentPublishedEvent({
|
|
514
|
+
event: input.event,
|
|
515
|
+
repo,
|
|
516
|
+
issueNumber,
|
|
517
|
+
publishedAt,
|
|
518
|
+
commentId: String(existing.id),
|
|
436
519
|
}),
|
|
437
520
|
];
|
|
438
521
|
},
|
|
@@ -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
|
}
|
|
@@ -38,36 +38,34 @@ function withSinkRef(event, sink) {
|
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
+
function targetSinksForEvent(input) {
|
|
42
|
+
const targetSinks = new Set();
|
|
43
|
+
const origin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
|
|
44
|
+
const sourceOrigin = input.event.sourceRefs.sink ?? origin;
|
|
45
|
+
if (input.event.sourceEventType === 'wake.labels.requested') {
|
|
46
|
+
const projectionOrigin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
|
|
47
|
+
if (projectionOrigin !== undefined) {
|
|
48
|
+
targetSinks.add(projectionOrigin);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const resourceUri = input.event.sourceRefs.resourceUri;
|
|
52
|
+
if (input.event.sourceEventType === 'wake.publish.intent.requested' &&
|
|
53
|
+
sourceOrigin !== undefined) {
|
|
54
|
+
const resourceSink = resourceUri === undefined ? sourceOrigin : sinkNameForResourceUri(resourceUri, sourceOrigin);
|
|
55
|
+
targetSinks.add(input.sinksByName.has(resourceSink) ? resourceSink : sourceOrigin);
|
|
56
|
+
}
|
|
57
|
+
for (const [sinkName, sinkConfig] of Object.entries(input.config.sinks ?? {})) {
|
|
58
|
+
if (sinkConfig.subscribe.some((subscription) => subscriptionMatches(input.event, subscription))) {
|
|
59
|
+
targetSinks.add(sinkName);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return targetSinks;
|
|
63
|
+
}
|
|
41
64
|
export function createOutboundSinkRouter(input) {
|
|
42
65
|
const sinksByName = new Map(input.sinks.map((sink) => [sink.sink, sink]));
|
|
43
66
|
return {
|
|
44
67
|
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
|
-
}
|
|
68
|
+
const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
|
|
71
69
|
const deliveryEvents = [];
|
|
72
70
|
for (const sinkName of targetSinks) {
|
|
73
71
|
const sink = sinksByName.get(sinkName);
|
|
@@ -79,5 +77,18 @@ export function createOutboundSinkRouter(input) {
|
|
|
79
77
|
}
|
|
80
78
|
return deliveryEvents;
|
|
81
79
|
},
|
|
80
|
+
async reconcileIntent({ event }) {
|
|
81
|
+
const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
|
|
82
|
+
const deliveryEvents = [];
|
|
83
|
+
for (const sinkName of targetSinks) {
|
|
84
|
+
const sink = sinksByName.get(sinkName);
|
|
85
|
+
if (sink?.reconcileIntent === undefined) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const sinkDeliveryEvents = await sink.reconcileIntent({ event });
|
|
89
|
+
deliveryEvents.push(...sinkDeliveryEvents.map((deliveryEvent) => withSinkRef(deliveryEvent, sinkName)));
|
|
90
|
+
}
|
|
91
|
+
return deliveryEvents;
|
|
92
|
+
},
|
|
82
93
|
};
|
|
83
94
|
}
|
|
@@ -41,7 +41,7 @@ export function createTickRunner(deps) {
|
|
|
41
41
|
resourceIndex: deps.resourceIndex,
|
|
42
42
|
config: deps.config,
|
|
43
43
|
});
|
|
44
|
-
const { deliverOutboundEvent, retryUnconfirmedDeliveries } = createOutbox({
|
|
44
|
+
const { deliverOutboundEvent, retryUnconfirmedDeliveries, suppressOutboundEvent } = createOutbox({
|
|
45
45
|
clock: deps.clock,
|
|
46
46
|
stateStore: deps.stateStore,
|
|
47
47
|
projectionUpdater,
|
|
@@ -137,6 +137,7 @@ export function createTickRunner(deps) {
|
|
|
137
137
|
relation: 'primary',
|
|
138
138
|
provenance: 'agent-reported',
|
|
139
139
|
registeredBy: input.runId,
|
|
140
|
+
idempotencyKey: `${input.runId}:artifact-registration:${verified.resourceUri}`,
|
|
140
141
|
},
|
|
141
142
|
});
|
|
142
143
|
const appended = await deps.stateStore.appendEventEnvelope(event);
|
|
@@ -721,23 +722,31 @@ export function createTickRunner(deps) {
|
|
|
721
722
|
workflowLabel: workflowLabelForWorkflowName(workflowName),
|
|
722
723
|
occurredAt: finishedAt,
|
|
723
724
|
}));
|
|
725
|
+
const publishIntent = createPublishIntentEvent({
|
|
726
|
+
projection: candidate,
|
|
727
|
+
runId,
|
|
728
|
+
action,
|
|
729
|
+
runnerResult,
|
|
730
|
+
parsedRunnerResult,
|
|
731
|
+
sentinel,
|
|
732
|
+
occurredAt: finishedAt,
|
|
733
|
+
startedAt: nowIso,
|
|
734
|
+
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
735
|
+
...(typeof candidate.context.lastFailureClass === 'string'
|
|
736
|
+
? { previousFailureClass: candidate.context.lastFailureClass }
|
|
737
|
+
: {}),
|
|
738
|
+
});
|
|
724
739
|
if (shouldPublishRunResult({
|
|
725
740
|
failureClass: runnerResult.failureClass,
|
|
726
741
|
previousFailureClass: candidate.context.lastFailureClass,
|
|
727
742
|
})) {
|
|
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
743
|
await deliverOutboundEvent(publishIntent);
|
|
740
744
|
}
|
|
745
|
+
else {
|
|
746
|
+
await suppressOutboundEvent(publishIntent, {
|
|
747
|
+
suppressedPublishReason: runnerResult.failureClass === 'quota' ? 'quota-failure' : 'repeated-infra-failure',
|
|
748
|
+
});
|
|
749
|
+
}
|
|
741
750
|
return {
|
|
742
751
|
status: 'processed',
|
|
743
752
|
runId,
|
|
@@ -805,6 +814,7 @@ export function createTickRunner(deps) {
|
|
|
805
814
|
result: errorMessage,
|
|
806
815
|
model: 'unknown',
|
|
807
816
|
cli: 'unknown',
|
|
817
|
+
failureClass: 'infra',
|
|
808
818
|
};
|
|
809
819
|
const parsedInfraFailureResult = parseRunnerResult(infraFailureResult.result);
|
|
810
820
|
const failurePublishIntent = createPublishIntentEvent({
|
|
@@ -816,6 +826,9 @@ export function createTickRunner(deps) {
|
|
|
816
826
|
sentinel,
|
|
817
827
|
occurredAt: finishedAt,
|
|
818
828
|
startedAt: nowIso,
|
|
829
|
+
...(typeof candidate.context.lastFailureClass === 'string'
|
|
830
|
+
? { previousFailureClass: candidate.context.lastFailureClass }
|
|
831
|
+
: {}),
|
|
819
832
|
});
|
|
820
833
|
if (shouldPublishRunResult({
|
|
821
834
|
failureClass: 'infra',
|
|
@@ -823,6 +836,11 @@ export function createTickRunner(deps) {
|
|
|
823
836
|
})) {
|
|
824
837
|
await deliverOutboundEvent(failurePublishIntent);
|
|
825
838
|
}
|
|
839
|
+
else {
|
|
840
|
+
await suppressOutboundEvent(failurePublishIntent, {
|
|
841
|
+
suppressedPublishReason: 'repeated-infra-failure',
|
|
842
|
+
});
|
|
843
|
+
}
|
|
826
844
|
return {
|
|
827
845
|
status: 'processed',
|
|
828
846
|
runId,
|
package/dist/src/version.js
CHANGED