@atolis-hq/wake 0.2.43 → 0.2.45

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.
@@ -1,6 +1,7 @@
1
1
  import { parseClaudePrintResult, parseRunnerResult } from '../../domain/schema.js';
2
2
  import { runAgentCliCommand } from '../runner/cli-command.js';
3
3
  import { buildStagePrompt } from '../runner/stage-prompt.js';
4
+ import { emitRuntimeEvent, runnerRuntimeEvent } from '../runner/runtime-events.js';
4
5
  import { writeRunnerTranscript } from '../runner/transcripts.js';
5
6
  export { buildStagePrompt } from '../runner/stage-prompt.js';
6
7
  function slugify(value, maxLength = 40) {
@@ -221,7 +222,18 @@ export function createClaudeRunner(options) {
221
222
  args,
222
223
  cwd: input.workspacePath ?? options.cwd,
223
224
  timeoutMs: options.settings.timeoutMs,
224
- ...(input.onProcessStart === undefined ? {} : { onProcessStart: input.onProcessStart }),
225
+ onProcessStart: async (identity) => {
226
+ await input.onProcessStart?.(identity);
227
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
228
+ type: 'agent.process.started',
229
+ runId: input.runId,
230
+ projection: input.projection,
231
+ routing: input.routing,
232
+ cli: CLAUDE_CLI_NAME,
233
+ model,
234
+ payload: { pid: identity.pid, processStartedAt: identity.processStartedAt },
235
+ }));
236
+ },
225
237
  });
226
238
  const responseTranscriptPath = await writeRunnerTranscript({
227
239
  config: input.config,
@@ -250,6 +262,15 @@ export function createClaudeRunner(options) {
250
262
  ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
251
263
  exitCode: result.exitCode,
252
264
  }));
265
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
266
+ type: 'agent.process.exited',
267
+ runId: input.runId,
268
+ projection: input.projection,
269
+ routing: input.routing,
270
+ cli: CLAUDE_CLI_NAME,
271
+ model,
272
+ payload: { exitCode: result.exitCode, timedOut: result.timedOut },
273
+ }));
253
274
  let parsedReason;
254
275
  let stdoutFailureDetail;
255
276
  const trimmedStdout = result.stdout.trim();
@@ -295,6 +316,16 @@ export function createClaudeRunner(options) {
295
316
  }
296
317
  const parsed = parseClaudePrintOutput(result.stdout);
297
318
  const sandboxLog = readSandboxLogBreadcrumb();
319
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
320
+ type: 'agent.progress',
321
+ runId: input.runId,
322
+ projection: input.projection,
323
+ routing: input.routing,
324
+ cli: CLAUDE_CLI_NAME,
325
+ model,
326
+ ...(parsed.session_id === undefined ? {} : { sessionId: parsed.session_id }),
327
+ payload: { message: 'Claude print result received', raw: parsed },
328
+ }));
298
329
  console.log(formatClaudeRunLogLine({
299
330
  phase: 'success',
300
331
  runId: input.runId,
@@ -307,6 +338,28 @@ export function createClaudeRunner(options) {
307
338
  ...(parsed.session_id === undefined ? {} : { sessionId: parsed.session_id }),
308
339
  }));
309
340
  const tokenUsage = extractTokenUsage(parsed);
341
+ if (tokenUsage !== undefined) {
342
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
343
+ type: 'agent.usage.updated',
344
+ runId: input.runId,
345
+ projection: input.projection,
346
+ routing: input.routing,
347
+ cli: CLAUDE_CLI_NAME,
348
+ model,
349
+ ...(parsed.session_id === undefined ? {} : { sessionId: parsed.session_id }),
350
+ payload: { tokenUsage },
351
+ }));
352
+ }
353
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
354
+ type: 'agent.process.exited',
355
+ runId: input.runId,
356
+ projection: input.projection,
357
+ routing: input.routing,
358
+ cli: CLAUDE_CLI_NAME,
359
+ model,
360
+ ...(parsed.session_id === undefined ? {} : { sessionId: parsed.session_id }),
361
+ payload: { exitCode: result.exitCode, timedOut: result.timedOut },
362
+ }));
310
363
  return {
311
364
  result: parsed.result,
312
365
  model,
@@ -1,5 +1,6 @@
1
1
  import { buildStagePrompt } from '../runner/stage-prompt.js';
2
2
  import { runAgentCliCommand } from '../runner/cli-command.js';
3
+ import { emitRuntimeEvent, runnerRuntimeEvent } from '../runner/runtime-events.js';
3
4
  import { writeRunnerTranscript } from '../runner/transcripts.js';
4
5
  import { parseRunnerResult } from '../../domain/schema.js';
5
6
  const CODEX_CLI_NAME = 'Codex';
@@ -268,7 +269,18 @@ export function createCodexRunner(options) {
268
269
  }),
269
270
  cwd,
270
271
  timeoutMs: options.settings.timeoutMs,
271
- ...(input.onProcessStart === undefined ? {} : { onProcessStart: input.onProcessStart }),
272
+ onProcessStart: async (identity) => {
273
+ await input.onProcessStart?.(identity);
274
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
275
+ type: 'agent.process.started',
276
+ runId: input.runId,
277
+ projection: input.projection,
278
+ routing: input.routing,
279
+ cli: CODEX_CLI_NAME,
280
+ model,
281
+ payload: { pid: identity.pid, processStartedAt: identity.processStartedAt },
282
+ }));
283
+ },
272
284
  });
273
285
  const responseTranscriptPath = await writeRunnerTranscript({
274
286
  config: input.config,
@@ -300,6 +312,15 @@ export function createCodexRunner(options) {
300
312
  ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
301
313
  exitCode: result.exitCode,
302
314
  }));
315
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
316
+ type: 'agent.process.exited',
317
+ runId: input.runId,
318
+ projection: input.projection,
319
+ routing: input.routing,
320
+ cli: CODEX_CLI_NAME,
321
+ model,
322
+ payload: { exitCode: result.exitCode, timedOut: result.timedOut },
323
+ }));
303
324
  return {
304
325
  result: [
305
326
  result.timedOut
@@ -331,6 +352,60 @@ export function createCodexRunner(options) {
331
352
  }
332
353
  const parsed = extractCodexExecResult(result.stdout);
333
354
  const sandboxLog = readSandboxLogBreadcrumb();
355
+ const rawEvents = result.stdout
356
+ .split(/\r?\n/)
357
+ .filter((line) => line.trim().length > 0)
358
+ .map((line) => JSON.parse(line));
359
+ for (const raw of rawEvents) {
360
+ if (raw.type === 'thread.started' && typeof raw.thread_id === 'string') {
361
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
362
+ type: 'agent.session.started',
363
+ runId: input.runId,
364
+ projection: input.projection,
365
+ routing: input.routing,
366
+ cli: CODEX_CLI_NAME,
367
+ model,
368
+ sessionId: raw.thread_id,
369
+ payload: { raw },
370
+ }));
371
+ }
372
+ else if (raw.type === 'turn.completed') {
373
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
374
+ type: 'agent.turn.completed',
375
+ runId: input.runId,
376
+ projection: input.projection,
377
+ routing: input.routing,
378
+ cli: CODEX_CLI_NAME,
379
+ model,
380
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
381
+ payload: { raw },
382
+ }));
383
+ }
384
+ else if (raw.type === 'item.completed') {
385
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
386
+ type: 'agent.progress',
387
+ runId: input.runId,
388
+ projection: input.projection,
389
+ routing: input.routing,
390
+ cli: CODEX_CLI_NAME,
391
+ model,
392
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
393
+ payload: { raw },
394
+ }));
395
+ }
396
+ }
397
+ if (parsed.tokenUsage !== undefined) {
398
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
399
+ type: 'agent.usage.updated',
400
+ runId: input.runId,
401
+ projection: input.projection,
402
+ routing: input.routing,
403
+ cli: CODEX_CLI_NAME,
404
+ model,
405
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
406
+ payload: { tokenUsage: parsed.tokenUsage },
407
+ }));
408
+ }
334
409
  console.log(formatCodexRunLogLine({
335
410
  phase: 'success',
336
411
  runId: input.runId,
@@ -342,6 +417,16 @@ export function createCodexRunner(options) {
342
417
  ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
343
418
  ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
344
419
  }));
420
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
421
+ type: 'agent.process.exited',
422
+ runId: input.runId,
423
+ projection: input.projection,
424
+ routing: input.routing,
425
+ cli: CODEX_CLI_NAME,
426
+ model,
427
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
428
+ payload: { exitCode: result.exitCode, timedOut: result.timedOut },
429
+ }));
345
430
  return {
346
431
  result: parsed.result,
347
432
  model,
@@ -354,10 +439,7 @@ export function createCodexRunner(options) {
354
439
  metadata: {
355
440
  stdout: result.stdout,
356
441
  stderr: result.stderr,
357
- raw: result.stdout
358
- .split(/\r?\n/)
359
- .filter((line) => line.trim().length > 0)
360
- .map((line) => JSON.parse(line)),
442
+ raw: rawEvents,
361
443
  skipApproval: stagePrompt.skipApproval,
362
444
  allowAutoApproval: stagePrompt.allowAutoApproval,
363
445
  ...(promptTranscriptPath === undefined ? {} : { promptTranscriptPath }),
@@ -1,6 +1,7 @@
1
1
  import { parseRunnerResult } from '../../domain/schema.js';
2
2
  import { buildStagePrompt } from '../runner/stage-prompt.js';
3
3
  import { runAgentCliCommand } from '../runner/cli-command.js';
4
+ import { emitRuntimeEvent, runnerRuntimeEvent } from '../runner/runtime-events.js';
4
5
  import { writeRunnerTranscript } from '../runner/transcripts.js';
5
6
  const CURSOR_CLI_NAME = 'Cursor';
6
7
  export function buildCursorAgentArgs(input) {
@@ -219,7 +220,18 @@ export function createCursorRunner(options) {
219
220
  }),
220
221
  cwd,
221
222
  timeoutMs: options.settings.timeoutMs,
222
- ...(input.onProcessStart === undefined ? {} : { onProcessStart: input.onProcessStart }),
223
+ onProcessStart: async (identity) => {
224
+ await input.onProcessStart?.(identity);
225
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
226
+ type: 'agent.process.started',
227
+ runId: input.runId,
228
+ projection: input.projection,
229
+ routing: input.routing,
230
+ cli: CURSOR_CLI_NAME,
231
+ model,
232
+ payload: { pid: identity.pid, processStartedAt: identity.processStartedAt },
233
+ }));
234
+ },
223
235
  });
224
236
  const responseTranscriptPath = await writeRunnerTranscript({
225
237
  config: input.config,
@@ -248,6 +260,15 @@ export function createCursorRunner(options) {
248
260
  ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
249
261
  exitCode: result.exitCode,
250
262
  }));
263
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
264
+ type: 'agent.process.exited',
265
+ runId: input.runId,
266
+ projection: input.projection,
267
+ routing: input.routing,
268
+ cli: CURSOR_CLI_NAME,
269
+ model,
270
+ payload: { exitCode: result.exitCode, timedOut: result.timedOut },
271
+ }));
251
272
  return {
252
273
  result: [
253
274
  result.timedOut
@@ -299,6 +320,28 @@ export function createCursorRunner(options) {
299
320
  };
300
321
  }
301
322
  const sandboxLog = readSandboxLogBreadcrumb();
323
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
324
+ type: 'agent.progress',
325
+ runId: input.runId,
326
+ projection: input.projection,
327
+ routing: input.routing,
328
+ cli: CURSOR_CLI_NAME,
329
+ model,
330
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
331
+ payload: { message: 'Cursor agent result received', raw: parsed },
332
+ }));
333
+ if (parsed.tokenUsage !== undefined) {
334
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
335
+ type: 'agent.usage.updated',
336
+ runId: input.runId,
337
+ projection: input.projection,
338
+ routing: input.routing,
339
+ cli: CURSOR_CLI_NAME,
340
+ model,
341
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
342
+ payload: { tokenUsage: parsed.tokenUsage },
343
+ }));
344
+ }
302
345
  console.log(formatCursorRunLogLine({
303
346
  phase: 'success',
304
347
  runId: input.runId,
@@ -310,6 +353,16 @@ export function createCursorRunner(options) {
310
353
  ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
311
354
  ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
312
355
  }));
356
+ await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
357
+ type: 'agent.process.exited',
358
+ runId: input.runId,
359
+ projection: input.projection,
360
+ routing: input.routing,
361
+ cli: CURSOR_CLI_NAME,
362
+ model,
363
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
364
+ payload: { exitCode: result.exitCode, timedOut: result.timedOut },
365
+ }));
313
366
  return {
314
367
  result: parsed.result,
315
368
  model,
@@ -1,6 +1,37 @@
1
+ import { emitRuntimeEvent, runnerRuntimeEvent } from '../runner/runtime-events.js';
1
2
  export function createFakeRunner(result, options) {
2
3
  return {
3
4
  async run(_) {
5
+ const cli = options?.cli ?? 'Fake';
6
+ await emitRuntimeEvent(_.onRuntimeEvent, runnerRuntimeEvent({
7
+ type: 'agent.process.started',
8
+ runId: _.runId,
9
+ projection: _.projection,
10
+ routing: _.routing,
11
+ cli,
12
+ model: 'fake',
13
+ payload: { synthetic: true },
14
+ }));
15
+ await emitRuntimeEvent(_.onRuntimeEvent, runnerRuntimeEvent({
16
+ type: 'agent.progress',
17
+ runId: _.runId,
18
+ projection: _.projection,
19
+ routing: _.routing,
20
+ cli,
21
+ model: 'fake',
22
+ sessionId: 'fake-session-1',
23
+ payload: { message: 'Fake runner completed', synthetic: true },
24
+ }));
25
+ await emitRuntimeEvent(_.onRuntimeEvent, runnerRuntimeEvent({
26
+ type: 'agent.process.exited',
27
+ runId: _.runId,
28
+ projection: _.projection,
29
+ routing: _.routing,
30
+ cli,
31
+ model: 'fake',
32
+ sessionId: 'fake-session-1',
33
+ payload: { exitCode: 0, synthetic: true },
34
+ }));
4
35
  return (result ?? {
5
36
  result: [
6
37
  'Fake runner completed',
@@ -11,7 +42,7 @@ export function createFakeRunner(result, options) {
11
42
  'DONE',
12
43
  ].join('\n'),
13
44
  model: 'fake',
14
- cli: options?.cli ?? 'Fake',
45
+ cli,
15
46
  session_id: 'fake-session-1',
16
47
  metadata: {
17
48
  source: 'fake-runner',
@@ -194,6 +194,37 @@ export function createGitHubClient(token) {
194
194
  }),
195
195
  });
196
196
  },
197
+ async listPullRequestFiles(owner, repo, pullNumber) {
198
+ return fetchPaginatedWithEtag({
199
+ cache: etagCache,
200
+ cacheKey: `pr-files:${owner}/${repo}#${pullNumber}`,
201
+ pages: (headers) => octokit.paginate.iterator(octokit.rest.pulls.listFiles, {
202
+ owner,
203
+ repo,
204
+ pull_number: pullNumber,
205
+ per_page: 100,
206
+ ...(headers === undefined ? {} : { headers }),
207
+ }),
208
+ });
209
+ },
210
+ async createPullRequestApproval(owner, repo, pullNumber, body) {
211
+ await octokit.rest.pulls.createReview({
212
+ owner,
213
+ repo,
214
+ pull_number: pullNumber,
215
+ event: 'APPROVE',
216
+ body,
217
+ });
218
+ },
219
+ async enablePullRequestAutoMerge(pullRequestNodeId) {
220
+ await octokit.graphql(`mutation EnableWakeAutoMerge($pullRequestId: ID!) {
221
+ enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) {
222
+ pullRequest {
223
+ id
224
+ }
225
+ }
226
+ }`, { pullRequestId: pullRequestNodeId });
227
+ },
197
228
  async replyToReviewComment(owner, repo, pullNumber, commentId, body) {
198
229
  return octokit.rest.pulls.createReplyForReviewComment({
199
230
  owner,
@@ -0,0 +1,29 @@
1
+ function parseGithubPullRequestResourceUri(resourceUri) {
2
+ const match = /^github:pr:([^/]+)\/([^#]+)#(\d+)$/.exec(resourceUri);
3
+ if (match === null) {
4
+ throw new Error(`Unsupported GitHub pull request resource URI: ${resourceUri}`);
5
+ }
6
+ return {
7
+ owner: match[1],
8
+ repo: match[2],
9
+ pullNumber: Number(match[3]),
10
+ };
11
+ }
12
+ export function createGitHubPullRequestMergeActor(input) {
13
+ return {
14
+ async listChangedFiles(resourceUri) {
15
+ const ref = parseGithubPullRequestResourceUri(resourceUri);
16
+ const files = await input.client.listPullRequestFiles(ref.owner, ref.repo, ref.pullNumber);
17
+ return files.map((file) => file.filename);
18
+ },
19
+ async approve(resourceUri, body) {
20
+ const ref = parseGithubPullRequestResourceUri(resourceUri);
21
+ await input.client.createPullRequestApproval(ref.owner, ref.repo, ref.pullNumber, body);
22
+ },
23
+ async enableAutoMerge(resourceUri) {
24
+ const ref = parseGithubPullRequestResourceUri(resourceUri);
25
+ const pr = await input.client.getPullRequest(ref.owner, ref.repo, ref.pullNumber);
26
+ await input.client.enablePullRequestAutoMerge(pr.node_id);
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,32 @@
1
+ function runnerKindForCli(cli) {
2
+ if (cli === 'Claude') {
3
+ return 'claude';
4
+ }
5
+ if (cli === 'Codex') {
6
+ return 'codex';
7
+ }
8
+ if (cli === 'Cursor') {
9
+ return 'cursor';
10
+ }
11
+ return 'fake';
12
+ }
13
+ export function runnerRuntimeEvent(input) {
14
+ return {
15
+ type: input.type,
16
+ runId: input.runId,
17
+ workItemId: input.projection.workItemKey,
18
+ runner: {
19
+ name: input.routing?.runnerName ?? input.cli.toLowerCase(),
20
+ kind: input.routing?.runnerKind ?? runnerKindForCli(input.cli),
21
+ cli: input.cli,
22
+ ...(input.model === undefined ? {} : { model: input.model }),
23
+ },
24
+ ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
25
+ payload: input.payload ?? {},
26
+ };
27
+ }
28
+ export async function emitRuntimeEvent(emit, event) {
29
+ if (emit !== undefined) {
30
+ await emit(event);
31
+ }
32
+ }
@@ -188,7 +188,13 @@ export function createPolicyEngine() {
188
188
  if (latestComment?.isBotAuthored === true &&
189
189
  latestComment.resourceUri !== undefined &&
190
190
  latestComment.body.includes(prReviewApprovalMarker)) {
191
- return { approved: true, pendingAction };
191
+ return {
192
+ approved: true,
193
+ pendingAction,
194
+ targetResourceUri: latestComment.resourceUri,
195
+ triggeringCommentId: latestComment.id,
196
+ triggeringCommentBody: latestComment.body,
197
+ };
192
198
  }
193
199
  if (latestHumanComment === undefined) {
194
200
  return null;
@@ -6,6 +6,7 @@ import { acquireFileLock } from '../lib/lock.js';
6
6
  import { CORRELATION_REGISTERED_EVENT, CORRELATION_PRIMARY_CONFLICT_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
7
7
  import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
8
8
  import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
9
+ import { isMeaningfulRuntimeEvent } from '../domain/runtime-events.js';
9
10
  import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowLabelForWorkflowName, workflowNameForProjection, } from '../domain/workflows.js';
10
11
  import { createEventEnvelope } from '../lib/event-log.js';
11
12
  import { branchNameForIssue } from '../domain/branch-naming.js';
@@ -110,6 +111,202 @@ export function createTickRunner(deps) {
110
111
  function hasLabel(projection, label) {
111
112
  return projection.issue.labels.includes(label);
112
113
  }
114
+ function approvedMergePolicyForStage(stage) {
115
+ return (stage?.watch?.find((watch) => watch.onApproved?.merge !== undefined)?.onApproved?.merge ??
116
+ null);
117
+ }
118
+ function escapeRegex(input) {
119
+ return input.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
120
+ }
121
+ function globPatternMatches(pattern, path) {
122
+ const regex = new RegExp(`^${pattern.split('*').map(escapeRegex).join('.*')}$`);
123
+ return regex.test(path);
124
+ }
125
+ function reviewerMessageFromApprovalComment(body) {
126
+ return body
127
+ .replaceAll(prReviewApprovalMarker, '')
128
+ .replaceAll('<!-- wake:pr-review-changes-requested -->', '')
129
+ .trim();
130
+ }
131
+ async function evaluatePrMergeRisk(input) {
132
+ const incumbent = await deps.resourceIndex.resolve(input.targetResourceUri);
133
+ if (incumbent !== undefined && incumbent !== input.projection.workItemKey) {
134
+ return {
135
+ passed: false,
136
+ reason: `Merge policy blocked this PR because ${input.targetResourceUri} is primary-correlated to ${incumbent}, not ${input.projection.workItemKey}.`,
137
+ };
138
+ }
139
+ const blockedLabel = input.policy.blockedLabels.find((label) => input.projection.issue.labels.includes(label));
140
+ if (blockedLabel !== undefined) {
141
+ return {
142
+ passed: false,
143
+ reason: `Merge policy blocked this PR because the work item has blocked label "${blockedLabel}".`,
144
+ };
145
+ }
146
+ if (deps.prMergeActor === undefined) {
147
+ return {
148
+ passed: false,
149
+ reason: 'Merge policy blocked this PR because no pull request merge actor is configured.',
150
+ };
151
+ }
152
+ const filesChanged = await deps.prMergeActor.listChangedFiles(input.targetResourceUri);
153
+ if (input.policy.maxFilesChanged !== undefined &&
154
+ filesChanged.length > input.policy.maxFilesChanged) {
155
+ return {
156
+ passed: false,
157
+ reason: `Merge policy blocked this PR because it changes ${filesChanged.length} files, exceeding maxFilesChanged ${input.policy.maxFilesChanged}.`,
158
+ };
159
+ }
160
+ for (const pattern of input.policy.blockedPaths) {
161
+ const blockedPath = filesChanged.find((path) => globPatternMatches(pattern, path));
162
+ if (blockedPath !== undefined) {
163
+ return {
164
+ passed: false,
165
+ reason: `Merge policy blocked this PR because changed path "${blockedPath}" matches blockedPaths pattern "${pattern}".`,
166
+ };
167
+ }
168
+ }
169
+ return { passed: true, filesChanged };
170
+ }
171
+ async function publishPrMergePolicyBlock(input) {
172
+ const commentId = input.approvalResolution.triggeringCommentId;
173
+ const targetResourceUri = input.approvalResolution.targetResourceUri;
174
+ if (commentId === undefined || targetResourceUri === undefined) {
175
+ return { status: 'idle' };
176
+ }
177
+ const runId = `merge-gate-blocked-${commentId.replace(/[^a-z0-9]+/gi, '-')}`;
178
+ const occurredAt = eventStampNow();
179
+ const blockedEvent = createEventEnvelope({
180
+ eventId: `${runId}-completed`,
181
+ workItemKey: input.projection.workItemKey,
182
+ streamScope: 'work-item',
183
+ direction: 'internal',
184
+ sourceSystem: 'wake',
185
+ sourceEventType: 'wake.run.completed',
186
+ sourceRefs: {
187
+ repo: input.projection.issue.repo,
188
+ issueNumber: input.projection.issue.number,
189
+ runId,
190
+ resourceUri: targetResourceUri,
191
+ },
192
+ occurredAt,
193
+ ingestedAt: occurredAt,
194
+ trigger: 'immediate',
195
+ payload: {
196
+ action: input.approvalResolution.pendingAction,
197
+ sentinel: 'BLOCKED',
198
+ runId,
199
+ reason: 'pr-review-merge-policy-blocked',
200
+ blockReason: 'pr-review-merge-policy-blocked',
201
+ handledCommentId: commentId,
202
+ body: input.reason,
203
+ },
204
+ });
205
+ const appended = await deps.stateStore.appendEventEnvelope(blockedEvent);
206
+ await projectionUpdater.rebuildFromEvents([appended]);
207
+ const reviewerMessage = reviewerMessageFromApprovalComment(input.approvalResolution.triggeringCommentBody ?? '');
208
+ const body = [reviewerMessage, input.reason].filter((part) => part.length > 0).join('\n\n');
209
+ await deliverOutboundEvent(createEventEnvelope({
210
+ eventId: `${runId}-publish-intent`,
211
+ workItemKey: input.projection.workItemKey,
212
+ streamScope: 'work-item',
213
+ direction: 'outbound',
214
+ sourceSystem: 'wake',
215
+ sourceEventType: 'wake.publish.intent.requested',
216
+ sourceRefs: {
217
+ repo: input.projection.issue.repo,
218
+ issueNumber: input.projection.issue.number,
219
+ runId,
220
+ resourceUri: targetResourceUri,
221
+ },
222
+ occurredAt,
223
+ ingestedAt: occurredAt,
224
+ trigger: 'context-only',
225
+ payload: {
226
+ kind: 'question',
227
+ origin: input.projection.origin ?? 'github',
228
+ body,
229
+ action: input.approvalResolution.pendingAction,
230
+ sentinel: 'BLOCKED',
231
+ runId,
232
+ idempotencyKey: `${commentId}:pr-review-merge-policy-blocked`,
233
+ deliveryState: 'PENDING',
234
+ },
235
+ derivedHints: { stage: input.projection.wake.stage },
236
+ }));
237
+ await deliverOutboundEvent(createLabelsEvent({
238
+ projection: input.projection,
239
+ runId,
240
+ statusLabel: 'wake:status.blocked',
241
+ stageLabel: stageLabelForStage(input.projection.wake.stage),
242
+ workflowLabel: workflowLabelForWorkflowName(input.workflowName),
243
+ occurredAt,
244
+ }));
245
+ return {
246
+ status: 'processed',
247
+ runId,
248
+ sentinel: 'BLOCKED',
249
+ nextStage: null,
250
+ };
251
+ }
252
+ async function performApprovedPrMergeActions(input) {
253
+ if (deps.prMergeActor === undefined ||
254
+ input.approvalResolution.targetResourceUri === undefined ||
255
+ input.approvalResolution.triggeringCommentId === undefined) {
256
+ return;
257
+ }
258
+ const targetResourceUri = input.approvalResolution.targetResourceUri;
259
+ const commentId = input.approvalResolution.triggeringCommentId.replace(/[^a-z0-9]+/gi, '-');
260
+ const reviewBody = reviewerMessageFromApprovalComment(input.approvalResolution.triggeringCommentBody ?? '');
261
+ const approvedEventId = `pr-merge-approved-${commentId}`;
262
+ if (input.mergePolicy.approve &&
263
+ (await deps.stateStore.readEventEnvelope(approvedEventId)) === null) {
264
+ await deps.prMergeActor.approve(targetResourceUri, reviewBody);
265
+ const occurredAt = eventStampNow();
266
+ await deps.stateStore.appendEventEnvelope(createEventEnvelope({
267
+ eventId: approvedEventId,
268
+ workItemKey: input.projection.workItemKey,
269
+ streamScope: 'work-item',
270
+ direction: 'internal',
271
+ sourceSystem: 'wake',
272
+ sourceEventType: 'wake.pr-review.approved',
273
+ sourceRefs: {
274
+ resourceUri: targetResourceUri,
275
+ commentId: input.approvalResolution.triggeringCommentId,
276
+ },
277
+ occurredAt,
278
+ ingestedAt: occurredAt,
279
+ trigger: 'context-only',
280
+ payload: {
281
+ idempotencyKey: `${input.approvalResolution.triggeringCommentId}:pr-review-approval`,
282
+ },
283
+ }));
284
+ }
285
+ const autoMergeEventId = `pr-auto-merge-enabled-${commentId}`;
286
+ if (input.mergePolicy.autoMerge &&
287
+ (await deps.stateStore.readEventEnvelope(autoMergeEventId)) === null) {
288
+ await deps.prMergeActor.enableAutoMerge(targetResourceUri);
289
+ const occurredAt = eventStampNow();
290
+ await deps.stateStore.appendEventEnvelope(createEventEnvelope({
291
+ eventId: autoMergeEventId,
292
+ workItemKey: input.projection.workItemKey,
293
+ streamScope: 'work-item',
294
+ direction: 'internal',
295
+ sourceSystem: 'wake',
296
+ sourceEventType: 'wake.pr-auto-merge.enabled',
297
+ sourceRefs: {
298
+ resourceUri: targetResourceUri,
299
+ commentId: input.approvalResolution.triggeringCommentId,
300
+ },
301
+ occurredAt,
302
+ ingestedAt: occurredAt,
303
+ trigger: 'context-only',
304
+ payload: {
305
+ idempotencyKey: `${input.approvalResolution.triggeringCommentId}:pr-auto-merge`,
306
+ },
307
+ }));
308
+ }
309
+ }
113
310
  // Closes the loop on #82's review feedback: rather than scrape a PR link
114
311
  // out of the agent's free text, the agent emits a `wake-artifacts` fence
115
312
  // (domain/schema.ts's parseRunnerArtifacts) and Wake verifies each claim
@@ -656,6 +853,27 @@ export function createTickRunner(deps) {
656
853
  promptContextOverrides = workflowAction?.promptContext;
657
854
  }
658
855
  else if (approvalResolution.approved) {
856
+ const mergePolicy = approvedMergePolicyForStage(workflow.stages[candidate.wake.stage]);
857
+ if (approvalResolution.targetResourceUri !== undefined && mergePolicy !== null) {
858
+ const risk = await evaluatePrMergeRisk({
859
+ projection: candidate,
860
+ targetResourceUri: approvalResolution.targetResourceUri,
861
+ policy: mergePolicy,
862
+ });
863
+ if (!risk.passed) {
864
+ return await publishPrMergePolicyBlock({
865
+ projection: candidate,
866
+ approvalResolution,
867
+ reason: risk.reason,
868
+ workflowName,
869
+ });
870
+ }
871
+ await performApprovedPrMergeActions({
872
+ projection: candidate,
873
+ approvalResolution,
874
+ mergePolicy,
875
+ });
876
+ }
659
877
  const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
660
878
  const approvedAt = deps.clock.now().toISOString();
661
879
  const automaticApproval = approvalResolution.automatic === true;
@@ -684,7 +902,11 @@ export function createTickRunner(deps) {
684
902
  nextStage,
685
903
  runId: approvalId,
686
904
  reason: automaticApproval ? 'auto:approved' : 'human:approved',
687
- ...(automaticApproval ? {} : { handledCommentId: latestHumanCommentId(candidate) }),
905
+ ...(automaticApproval
906
+ ? {}
907
+ : {
908
+ handledCommentId: approvalResolution.triggeringCommentId ?? latestHumanCommentId(candidate),
909
+ }),
688
910
  ...(automaticApproval
689
911
  ? {
690
912
  autoResolution: {
@@ -831,6 +1053,30 @@ export function createTickRunner(deps) {
831
1053
  lifecycle,
832
1054
  });
833
1055
  }
1056
+ async function appendRuntimeEvent(event) {
1057
+ const timestamp = event.timestamp ?? deps.clock.now().toISOString();
1058
+ await deps.stateStore.updateRunRecordIf(runId, {
1059
+ expect: (record) => record.status === 'running' &&
1060
+ record.lease?.leaseId === lease.leaseId &&
1061
+ record.lease.ownerInstanceId === ownerInstanceId,
1062
+ update: (record) => {
1063
+ const runtimeEvents = record.runtimeEvents ?? [];
1064
+ const sequence = event.sequence ?? runtimeEvents.length;
1065
+ const normalizedEvent = {
1066
+ ...event,
1067
+ timestamp,
1068
+ sequence,
1069
+ };
1070
+ return {
1071
+ ...record,
1072
+ runtimeEvents: [...runtimeEvents, normalizedEvent],
1073
+ ...(isMeaningfulRuntimeEvent(normalizedEvent)
1074
+ ? { lastMeaningfulActivityAt: normalizedEvent.timestamp }
1075
+ : {}),
1076
+ };
1077
+ },
1078
+ });
1079
+ }
834
1080
  const claimedAt = eventStampNow();
835
1081
  const claimedEvent = createEventEnvelope({
836
1082
  eventId: `${runId}-claimed`,
@@ -980,6 +1226,7 @@ export function createTickRunner(deps) {
980
1226
  }),
981
1227
  });
982
1228
  },
1229
+ onRuntimeEvent: appendRuntimeEvent,
983
1230
  });
984
1231
  clearInterval(leaseRenewalTimer);
985
1232
  leaseRenewalTimer = undefined;
@@ -0,0 +1,36 @@
1
+ export const runtimeEventTypeValues = [
2
+ 'run.claimed',
3
+ 'run.started',
4
+ 'workspace.preparing',
5
+ 'workspace.ready',
6
+ 'agent.process.started',
7
+ 'agent.session.started',
8
+ 'agent.progress',
9
+ 'agent.tool.requested',
10
+ 'agent.approval.requested',
11
+ 'agent.input.required',
12
+ 'agent.usage.updated',
13
+ 'agent.turn.completed',
14
+ 'agent.process.exited',
15
+ 'run.completed',
16
+ 'run.failed',
17
+ 'run.stalled',
18
+ 'run.canceled',
19
+ ];
20
+ export function isMeaningfulRuntimeEvent(event) {
21
+ return (event.type === 'agent.progress' ||
22
+ event.type === 'agent.tool.requested' ||
23
+ event.type === 'agent.approval.requested' ||
24
+ event.type === 'agent.input.required' ||
25
+ event.type === 'agent.usage.updated' ||
26
+ event.type === 'agent.turn.completed');
27
+ }
28
+ export function runtimeEventPayload(input) {
29
+ return {
30
+ ...(input.message === undefined ? {} : { message: input.message }),
31
+ ...(input.raw === undefined ? {} : { raw: input.raw }),
32
+ ...(input.exitCode === undefined ? {} : { exitCode: input.exitCode }),
33
+ ...(input.timedOut === undefined ? {} : { timedOut: input.timedOut }),
34
+ ...(input.tokenUsage === undefined ? {} : { tokenUsage: input.tokenUsage }),
35
+ };
36
+ }
@@ -5,6 +5,7 @@ import { z } from 'zod';
5
5
  import { reservedCommandNames } from './custom-commands.js';
6
6
  import { runnerSentinelValues } from './stages.js';
7
7
  import { correlationProvenanceSchema, correlationRelationSchema, correlationRoleSchema, resourceUriSchema, } from './resource-uri.js';
8
+ import { runtimeEventTypeValues } from './runtime-events.js';
8
9
  import { alwaysManualIgnoredLabels } from './manual-labels.js';
9
10
  const isoTimestampSchema = z.string().datetime({ offset: true });
10
11
  const identifierSchema = z.string().min(1);
@@ -47,6 +48,21 @@ export const executionAttemptLifecycleValues = [
47
48
  export const executionOutcomeSchema = z.enum(executionOutcomeValues);
48
49
  export const workflowOutcomeSchema = z.enum(workflowOutcomeValues);
49
50
  export const executionAttemptLifecycleSchema = z.enum(executionAttemptLifecycleValues);
51
+ export const runtimeEventSchema = z.object({
52
+ type: z.enum(runtimeEventTypeValues),
53
+ runId: z.string(),
54
+ workItemId: z.string(),
55
+ runner: z.object({
56
+ name: z.string(),
57
+ kind: z.enum(['fake', 'claude', 'codex', 'cursor']),
58
+ cli: z.string(),
59
+ model: z.string().optional(),
60
+ }),
61
+ sessionId: z.string().optional(),
62
+ timestamp: isoTimestampSchema,
63
+ sequence: z.number().int().nonnegative(),
64
+ payload: z.record(z.string(), z.unknown()).default({}),
65
+ });
50
66
  export const defaultAgentIdentity = 'Wake';
51
67
  export const defaultSmokePrompt = `This is ${defaultAgentIdentity}, reply with "hi ${defaultAgentIdentity} only"`;
52
68
  const modelOverridesSchema = z
@@ -361,6 +377,8 @@ export const runRecordSchema = z.preprocess((input) => {
361
377
  workerProcessStartedAt: z.string().optional(),
362
378
  agentPid: z.number().int().positive().optional(),
363
379
  agentProcessStartedAt: z.string().optional(),
380
+ lastMeaningfulActivityAt: isoTimestampSchema.optional(),
381
+ runtimeEvents: z.array(runtimeEventSchema).optional(),
364
382
  tokenUsage: runTokenUsageSchema.optional(),
365
383
  metadata: z.record(z.string(), z.unknown()).optional(),
366
384
  }));
@@ -377,6 +395,20 @@ const workflowWorkspaceSchema = z.enum(['none', 'read-only', 'branch']);
377
395
  const workflowTriggerScheduleSchema = z.object({
378
396
  cron: z.string().min(1),
379
397
  });
398
+ const approvedMergePolicySchema = z
399
+ .object({
400
+ approve: z.boolean().default(false),
401
+ autoMerge: z.boolean().default(false),
402
+ maxFilesChanged: z.number().int().positive().optional(),
403
+ blockedPaths: z.array(z.string().min(1)).default([]),
404
+ blockedLabels: z.array(z.string().min(1)).default([]),
405
+ })
406
+ .default({
407
+ approve: false,
408
+ autoMerge: false,
409
+ blockedPaths: [],
410
+ blockedLabels: [],
411
+ });
380
412
  const workflowStageSchema = stageRouteSchema.extend({
381
413
  workspace: workflowWorkspaceSchema,
382
414
  onDone: identifierSchema,
@@ -392,6 +424,11 @@ const workflowStageSchema = stageRouteSchema.extend({
392
424
  .optional(),
393
425
  schedule: workflowTriggerScheduleSchema.optional(),
394
426
  workflow: identifierSchema,
427
+ onApproved: z
428
+ .object({
429
+ merge: approvedMergePolicySchema.optional(),
430
+ })
431
+ .optional(),
395
432
  }))
396
433
  .optional(),
397
434
  });
package/dist/src/main.js CHANGED
@@ -19,6 +19,7 @@ import { createGitHubArtifactVerifier } from './adapters/github/github-artifact-
19
19
  import { createGitHubClient } from './adapters/github/github-client.js';
20
20
  import { createGitHubIssuesWorkSource } from './adapters/github/github-issues-work-source.js';
21
21
  import { createGitHubPullRequestActivitySource } from './adapters/github/github-pull-request-activity-source.js';
22
+ import { createGitHubPullRequestMergeActor } from './adapters/github/github-pull-request-merge-actor.js';
22
23
  import { runAuditCommand } from './cli/audit-command.js';
23
24
  import { runCorrelateCommand } from './cli/correlate-command.js';
24
25
  import { runDoctorCommand } from './cli/doctor-command.js';
@@ -497,6 +498,9 @@ export async function buildRuntime(args) {
497
498
  const artifactVerifier = prTrackingEnabled && githubClient !== undefined
498
499
  ? createGitHubArtifactVerifier({ client: githubClient })
499
500
  : undefined;
501
+ const prMergeActor = prTrackingEnabled && githubClient !== undefined
502
+ ? createGitHubPullRequestMergeActor({ client: githubClient })
503
+ : undefined;
500
504
  const ticketingSystem = githubClient !== undefined
501
505
  ? createGitHubIssuesWorkSource({
502
506
  client: githubClient,
@@ -577,6 +581,7 @@ export async function buildRuntime(args) {
577
581
  workspaceManager,
578
582
  resourceIndex,
579
583
  ...(artifactVerifier === undefined ? {} : { artifactVerifier }),
584
+ ...(prMergeActor === undefined ? {} : { prMergeActor }),
580
585
  });
581
586
  return {
582
587
  config,
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g45197ea";
127
+ export const wakeVersion = "gee0e5b1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.43",
3
+ "version": "0.2.45",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {