@atolis-hq/wake 0.3.4 → 0.3.6

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.
@@ -97,6 +97,7 @@ async function buildUntrustedContext(workItemId, contextReader) {
97
97
  issueTitle: context.title,
98
98
  issueBody: context.body,
99
99
  comments: context.comments,
100
+ ...(context.pullRequest === undefined ? {} : { pullRequest: context.pullRequest }),
100
101
  };
101
102
  }
102
103
  function untrustedDataBlock(context) {
@@ -108,6 +109,7 @@ function untrustedDataBlock(context) {
108
109
  escapeUntrustedJson(JSON.stringify({
109
110
  issue: { title: context.issueTitle, body: context.issueBody },
110
111
  comments: context.comments,
112
+ ...(context.pullRequest === undefined ? {} : { pullRequest: context.pullRequest }),
111
113
  }, null, 2)),
112
114
  '</wake-untrusted-data>',
113
115
  ].join('\n');
@@ -620,7 +620,7 @@ function createSandboxEntrypointDependencies(root) {
620
620
  resolve();
621
621
  });
622
622
  });
623
- const drained = drainProcessOutput(child, sink, (error) => {
623
+ const drained = drainProcessOutput(child, sink, (value) => process.stderr.write(value), (error) => {
624
624
  process.stderr.write(`Wake detached start log failure: ${error instanceof Error ? error.message : String(error)}\n`);
625
625
  });
626
626
  const completion = closed.then(() => drained).then(() => closeStatus);
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "g96ae149";
111
+ export const wakeVersion = "gd042cce";
@@ -1,6 +1,8 @@
1
- import { ResourceCorrelationRole } from '../../../resources/index.js';
1
+ import { PullRequestCheckState, } from '../../../activities/index.js';
2
+ import { BuiltInResourceKind, ResourceCorrelationRole, } from '../../../resources/index.js';
2
3
  import { adapterId } from '../../contracts/identifiers.js';
3
4
  import { integrationStream } from '../../contracts/streams.js';
5
+ import { boundedDiagnosticEvidence } from '../contracts/check-evidence.js';
4
6
  import { GitHubEventType, selectGitHubAdapterEvent } from '../contracts/events.js';
5
7
  import { createCommentHistoryReader } from './comment-history-reader.js';
6
8
  export function createGitHubAgentContextReader(journal, resources) {
@@ -11,6 +13,7 @@ export function createGitHubAgentContextReader(journal, resources) {
11
13
  return {
12
14
  ...(await currentWorkItemContent(journal, resources, workItemId)),
13
15
  comments,
16
+ ...pullRequestContextField(await currentPullRequestContext(journal, resources, workItemId)),
14
17
  };
15
18
  },
16
19
  };
@@ -28,6 +31,38 @@ async function currentWorkItemContent(journal, resources, workItemId) {
28
31
  return latestWorkObservedContent(await journal.readStream(integrationStream(adapter)), adapter, resource.externalKey.key);
29
32
  }
30
33
  const emptyWorkItemContent = { title: '', body: '' };
34
+ async function currentPullRequestContext(journal, resources, workItemId) {
35
+ let current;
36
+ for (const correlation of await resources.correlationsForWork(workItemId)) {
37
+ const resource = await resources.get(correlation.resourceId);
38
+ if (resource?.kind !== BuiltInResourceKind.PullRequest)
39
+ continue;
40
+ const adapter = parseAdapterId(resource.externalKey.adapter);
41
+ if (adapter === null)
42
+ continue;
43
+ for (const event of await journal.readStream(integrationStream(adapter))) {
44
+ const observed = selectGitHubAdapterEvent(event);
45
+ if (!isCurrentPullRequestObservedEvent(observed, adapter, resource.externalKey.key))
46
+ continue;
47
+ if (current === undefined || event.globalPosition > current.position)
48
+ current = { context: pullRequestContext(observed.payload), position: event.globalPosition };
49
+ }
50
+ }
51
+ return current?.context;
52
+ }
53
+ function pullRequestContext(payload) {
54
+ return {
55
+ checks: payload.checks ?? PullRequestCheckState.Unknown,
56
+ checkRuns: rawEvidenceList(payload.raw.checkRuns),
57
+ statuses: rawEvidenceList(payload.raw.statuses),
58
+ };
59
+ }
60
+ function rawEvidenceList(value) {
61
+ return Array.isArray(value) ? boundedDiagnosticEvidence(value) : [];
62
+ }
63
+ function pullRequestContextField(context) {
64
+ return context === undefined ? {} : { pullRequest: context };
65
+ }
31
66
  function latestWorkObservedContent(events, adapter, externalKey) {
32
67
  let current;
33
68
  for (const event of events) {
@@ -44,6 +79,10 @@ function isCurrentWorkObservedEvent(observed, adapter, externalKey) {
44
79
  observed.source.id === adapter &&
45
80
  observed.payload.externalKey === externalKey);
46
81
  }
82
+ function isCurrentPullRequestObservedEvent(observed, adapter, externalKey) {
83
+ return (isCurrentWorkObservedEvent(observed, adapter, externalKey) &&
84
+ observed.payload.kind === 'pull-request');
85
+ }
47
86
  function parseAdapterId(value) {
48
87
  try {
49
88
  return adapterId(value);
@@ -20,6 +20,7 @@ export function createGitHubWakeLabelReconciler(input) {
20
20
  }
21
21
  return open;
22
22
  };
23
+ const onError = input.onError ?? defaultOnError;
23
24
  return {
24
25
  async runOnce() {
25
26
  const workflows = await input.orchestration.listAll();
@@ -49,15 +50,27 @@ export function createGitHubWakeLabelReconciler(input) {
49
50
  const locator = parseGitHubIssueKey(resource.externalKey.key);
50
51
  if (locator === null)
51
52
  continue;
52
- const current = await input.getLabels(locator.owner, locator.repo, locator.number);
53
- const next = reconcileGitHubWakeLabels(current, desired);
54
- if (!sameLabels(current, next))
55
- await input.setLabels(locator.owner, locator.repo, locator.number, next);
53
+ // One issue's persistent failure (rate limit, permissions, a stale
54
+ // resource) must not stop every other open work item from being
55
+ // reconciled this pass — each correlation is an independent GitHub
56
+ // call with no ordering dependency on the others.
57
+ try {
58
+ const current = await input.getLabels(locator.owner, locator.repo, locator.number);
59
+ const next = reconcileGitHubWakeLabels(current, desired);
60
+ if (!sameLabels(current, next))
61
+ await input.setLabels(locator.owner, locator.repo, locator.number, next);
62
+ }
63
+ catch (error) {
64
+ onError({ workItemId: workflow.workItemId, ...locator }, error);
65
+ }
56
66
  }
57
67
  }
58
68
  },
59
69
  };
60
70
  }
71
+ function defaultOnError(failure, error) {
72
+ process.stderr.write(`GitHub label reconcile failed for ${failure.owner}/${failure.repo}#${failure.number}: ${error instanceof Error ? error.message : String(error)}\n`);
73
+ }
61
74
  function desiredWakeLabels(workflow, watching) {
62
75
  return [
63
76
  `wake:status.${statusLabel(workflow.status)}`,
@@ -0,0 +1,37 @@
1
+ const diagnosticKeys = [
2
+ 'name',
3
+ 'status',
4
+ 'conclusion',
5
+ 'started_at',
6
+ 'completed_at',
7
+ 'details_url',
8
+ 'html_url',
9
+ 'context',
10
+ 'state',
11
+ 'target_url',
12
+ ];
13
+ const maxEvidenceEntries = 20;
14
+ const maxEvidenceBytes = 12_000;
15
+ export function boundedDiagnosticEvidence(evidence) {
16
+ const bounded = [];
17
+ let bytes = 0;
18
+ for (const value of evidence) {
19
+ if (bounded.length === maxEvidenceEntries)
20
+ break;
21
+ if (!isRecord(value))
22
+ continue;
23
+ const diagnostic = diagnosticEvidence(value);
24
+ const entryBytes = Buffer.byteLength(JSON.stringify(diagnostic), 'utf8');
25
+ if (bytes + entryBytes > maxEvidenceBytes)
26
+ break;
27
+ bounded.push(diagnostic);
28
+ bytes += entryBytes;
29
+ }
30
+ return bounded;
31
+ }
32
+ function diagnosticEvidence(entry) {
33
+ return Object.fromEntries(diagnosticKeys.flatMap((key) => typeof entry[key] === 'string' || entry[key] === null ? [[key, entry[key]]] : []));
34
+ }
35
+ function isRecord(value) {
36
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
37
+ }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { PullRequestCheckState, PullRequestState, ReviewActorKind, } from '../../../activities/index.js';
3
3
  import { createEventDraft, EventActorKind, EventSourceKind } from '../../../kernel/index.js';
4
4
  import { integrationStream } from '../../contracts/streams.js';
5
+ import { boundedDiagnosticEvidence } from '../contracts/check-evidence.js';
5
6
  import { GitHubEventType } from '../contracts/events.js';
6
7
  import { formatGitHubResourceKey } from '../contracts/external-key.js';
7
8
  import { gitHubAssigneeLogins, gitHubLabelNames, } from '../contracts/payloads.js';
@@ -53,7 +54,11 @@ function pullRequestObservation(input) {
53
54
  },
54
55
  labels: gitHubLabelNames(pullRequest),
55
56
  assignees: gitHubAssigneeLogins(pullRequest),
56
- raw: { number: pullRequest.number },
57
+ raw: {
58
+ number: pullRequest.number,
59
+ checkRuns: boundedDiagnosticEvidence(input.evidence.checkRuns),
60
+ statuses: boundedDiagnosticEvidence(input.evidence.statuses),
61
+ },
57
62
  };
58
63
  const fingerprint = evidenceFingerprint(payload, input.evidence);
59
64
  return createEventDraft({
@@ -1,3 +1,4 @@
1
+ import { ActivityEventType, PullRequestCheckState, selectActivityEvent, } from '../../activities/index.js';
1
2
  import { EventSourceKind, createEventDraft } from '../../kernel/index.js';
2
3
  import { OrchestrationEventType } from '../contracts/events.js';
3
4
  import { commandName, workflowInstanceId, } from '../contracts/identifiers.js';
@@ -147,14 +148,22 @@ export class AdvanceWorkflow {
147
148
  async listAll() {
148
149
  return (await this.repository.list()).filter((view) => view !== null);
149
150
  }
150
- async listWatchMatches(eventType) {
151
+ async listWatchMatches(event) {
151
152
  return (await this.listAll()).flatMap((parent) => {
152
153
  const definition = this.workflows.definition(parent.workflowName);
153
154
  return definition.watches
154
- .filter((watch) => watch.on?.events.includes(eventType) === true &&
155
+ .filter((watch) => watch.on?.events.includes(event.eventType) === true &&
155
156
  watch.while.stages.includes(parent.currentStage) &&
156
- watch.while.statuses.some((status) => status === parent.status))
157
+ watch.while.statuses.some((status) => status === parent.status) &&
158
+ matchesWatchPredicate(watch.where, event))
157
159
  .map((watch) => ({ parent, watch }));
158
160
  });
159
161
  }
160
162
  }
163
+ function matchesWatchPredicate(predicate, event) {
164
+ if (predicate === undefined)
165
+ return true;
166
+ const activityEvent = selectActivityEvent(event);
167
+ return (activityEvent?.eventType === ActivityEventType.PrChecksChanged &&
168
+ activityEvent.payload.checks === PullRequestCheckState.Failing);
169
+ }
@@ -75,8 +75,8 @@ export class OrchestrationService {
75
75
  isCausalRepeat(workflowInstanceId, triggerId, causalCycleId, requestId) {
76
76
  return this.childWorkflows.isCausalRepeat(workflowInstanceId, triggerId, causalCycleId, requestId);
77
77
  }
78
- listWatchMatches(eventType) {
79
- return this.advanceWorkflow.listWatchMatches(eventType);
78
+ listWatchMatches(event) {
79
+ return this.advanceWorkflow.listWatchMatches(event);
80
80
  }
81
81
  }
82
82
  export const createOrchestrationService = (journal, work, definitions) => new OrchestrationService(journal, work, definitions);
@@ -8,7 +8,7 @@ export function createWatchReactor(orchestration, journal, checkpoints, runs) {
8
8
  async react(event, context) {
9
9
  const causalCycle = orchestrationCausalCycleId(selectOrchestrationEvent(event));
10
10
  const sourceWorkflowInstanceId = await resolveRunWorkflowInstanceId(event, runs);
11
- for (const match of await orchestration.listWatchMatches(event.eventType)) {
11
+ for (const match of await orchestration.listWatchMatches(event)) {
12
12
  if (sourceWorkflowInstanceId !== undefined &&
13
13
  match.parent.workflowInstanceId !== sourceWorkflowInstanceId)
14
14
  continue;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ApprovalAuthorityKind, WorkflowStatus } from './vocabulary.js';
3
+ import { PullRequestCheckState } from '../../activities/index.js';
3
4
  import { WorkspaceMode } from '../../execution/index.js';
4
5
  import { MatchMode } from '../../kernel/index.js';
5
6
  const identifier = z.string().trim().min(1);
@@ -33,6 +34,9 @@ const watchGateConfigSchema = z.union([
33
34
  const commandName = z.string().regex(/^\/[a-z][a-z0-9-]*$/);
34
35
  const canonicalEventName = z.string().regex(/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/);
35
36
  const watchStatus = z.enum([WorkflowStatus.Active, WorkflowStatus.Waiting, WorkflowStatus.Blocked]);
37
+ const failingChecksWatchPredicateSchema = z
38
+ .object({ checks: z.literal(PullRequestCheckState.Failing) })
39
+ .strict();
36
40
  export const watchConfigSchema = z
37
41
  .object({
38
42
  id: identifier,
@@ -46,6 +50,7 @@ export const watchConfigSchema = z
46
50
  .object({ events: z.array(canonicalEventName).min(1).readonly() })
47
51
  .strict()
48
52
  .optional(),
53
+ where: failingChecksWatchPredicateSchema.optional(),
49
54
  schedule: z.object({ cron: identifier }).strict().optional(),
50
55
  workflow: identifier,
51
56
  maxPerGroup: z.number().int().positive(),
@@ -1,4 +1,4 @@
1
- import { activityName, ActivityOutcomeKind, } from '../../activities/index.js';
1
+ import { ActivityEventType, activityName, ActivityOutcomeKind, } from '../../activities/index.js';
2
2
  import { workflowDefinitionConfigSchema, } from '../contracts/config.js';
3
3
  import { commandName, signalName, stageName, watchId, workflowName, } from '../contracts/identifiers.js';
4
4
  import { ApprovalAuthorityKind, TransitionTargetKind } from '../contracts/vocabulary.js';
@@ -42,6 +42,10 @@ function compileWatches(config, stageNames, knownWorkflowNames) {
42
42
  throw new Error(`Unknown watch stage: ${unknownStages.join(', ')}`);
43
43
  if (!knownWorkflowNames.includes(watch.workflow))
44
44
  throw new Error(`Unknown watch workflow: ${watch.workflow}`);
45
+ if (watch.where !== undefined &&
46
+ (watch.on === undefined ||
47
+ watch.on.events.some((event) => event !== ActivityEventType.PrChecksChanged)))
48
+ throw new Error('Watch where.checks is only valid with pr.checks-changed');
45
49
  return Object.freeze({
46
50
  ...watch,
47
51
  id: watchId(watch.id),
@@ -41,8 +41,14 @@ export function createProcessLogSink(path, output, options = {}) {
41
41
  * Serializes detached child output into a sink and resolves only after Node's
42
42
  * `close` event confirms its stdio streams have closed. Sink failures are
43
43
  * reported rather than becoming detached, unhandled promise rejections.
44
+ *
45
+ * The child's full stdout+stderr always reaches the sink (durable file), but
46
+ * only stderr is also handed to `onStderr` — the child already writes its
47
+ * actionable failures there (GitHub request failures, tick failures) and
48
+ * routine detail to stdout, so this lets a detached supervisor surface just
49
+ * the actionable half on its own stderr without re-deriving severity here.
44
50
  */
45
- export function drainProcessOutput(process, sink, reportError) {
51
+ export function drainProcessOutput(process, sink, onStderr, reportError) {
46
52
  let writes = Promise.resolve();
47
53
  const report = (error) => {
48
54
  try {
@@ -52,12 +58,18 @@ export function drainProcessOutput(process, sink, reportError) {
52
58
  // A reporting failure must not make a detached child unhandled.
53
59
  }
54
60
  };
55
- const enqueue = (chunk) => {
61
+ const enqueue = (chunk, escalate) => {
56
62
  const value = chunk.toString();
57
- writes = writes.then(() => sink.write(value)).catch(report);
63
+ writes = writes
64
+ .then(() => sink.write(value))
65
+ .then(() => {
66
+ if (escalate)
67
+ onStderr(scrubProcessLog(value));
68
+ })
69
+ .catch(report);
58
70
  };
59
- process.stdout?.on('data', enqueue);
60
- process.stderr?.on('data', enqueue);
71
+ process.stdout?.on('data', (chunk) => enqueue(chunk, false));
72
+ process.stderr?.on('data', (chunk) => enqueue(chunk, true));
61
73
  process.on('error', report);
62
74
  return new Promise((resolve) => {
63
75
  process.once('close', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {