@atolis-hq/wake 0.3.25 → 0.3.26

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.
@@ -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 = "g5d24549";
111
+ export const wakeVersion = "g883e6ff";
@@ -5,6 +5,7 @@ import { workflowInstanceStream } from '../contracts/streams.js';
5
5
  import { WorkflowStatus } from '../contracts/vocabulary.js';
6
6
  import { requestChangesResume as decideChangesResume, requestOperatorRetry as decideOperatorRetry, requestSupplementalActivity as decideSupplementalActivity, } from '../domain/interpreter.js';
7
7
  import { isAuthorisedActor } from '../domain/supplemental-policy.js';
8
+ import { appendWithIntentRecovery } from './durable-append.js';
8
9
  import { acceptResourceTransition, matchResourceTransitions, } from './resource-transition-matching.js';
9
10
  import { matchWatches } from './watch-matching.js';
10
11
  export class OperatorRetryIneligibleError extends Error {
@@ -126,15 +127,15 @@ export class AdvanceWorkflow {
126
127
  });
127
128
  if (decision.kind === 'ignored')
128
129
  throw new OperatorRetryIneligibleError(decision.reason);
129
- try {
130
- await this.repository.append(id, loaded.sequence, decision.events);
131
- }
132
- catch (error) {
133
- const reloaded = await this.repository.loadRequired(id);
134
- if (reloaded.view.operatorRetryCommandIds.includes(context.commandId))
135
- return reloaded.view;
136
- throw error;
137
- }
130
+ const recovered = await appendWithIntentRecovery({
131
+ append: async () => {
132
+ await this.repository.append(id, loaded.sequence, decision.events);
133
+ },
134
+ load: () => this.repository.loadRequired(id),
135
+ alreadyApplied: (reloaded) => reloaded.view.operatorRetryCommandIds.includes(context.commandId),
136
+ });
137
+ if (recovered !== undefined)
138
+ return recovered.view;
138
139
  return (await this.repository.loadRequired(id)).view;
139
140
  }
140
141
  async resumeBlockedStageForChanges(id, context) {
@@ -153,15 +154,15 @@ export class AdvanceWorkflow {
153
154
  });
154
155
  if (decision.kind === 'ignored')
155
156
  return loaded.view;
156
- try {
157
- await this.repository.append(id, loaded.sequence, decision.events);
158
- }
159
- catch (error) {
160
- const reloaded = await this.repository.loadRequired(id);
161
- if (reloaded.view.operatorRetryCommandIds.includes(context.commandId))
162
- return reloaded.view;
163
- throw error;
164
- }
157
+ const recovered = await appendWithIntentRecovery({
158
+ append: async () => {
159
+ await this.repository.append(id, loaded.sequence, decision.events);
160
+ },
161
+ load: () => this.repository.loadRequired(id),
162
+ alreadyApplied: (reloaded) => reloaded.view.operatorRetryCommandIds.includes(context.commandId),
163
+ });
164
+ if (recovered !== undefined)
165
+ return recovered.view;
165
166
  return (await this.repository.loadRequired(id)).view;
166
167
  }
167
168
  async get(id) {
@@ -1,7 +1,8 @@
1
- import { createEventDraft, EventSourceKind, WrongExpectedSequenceError, } from '../../kernel/index.js';
1
+ import { createEventDraft, EventSourceKind, } from '../../kernel/index.js';
2
2
  import { selectOrchestrationEvent } from '../contracts/event-decoder.js';
3
3
  import { OrchestrationEventType } from '../contracts/events.js';
4
4
  import { isOrchestrationGroupStream, primaryOrchestrationGroupStream, } from '../contracts/streams.js';
5
+ import { claimWithCasRetry } from './durable-append.js';
5
6
  export class CoordinationClaims {
6
7
  journal;
7
8
  constructor(journal) {
@@ -9,16 +10,19 @@ export class CoordinationClaims {
9
10
  }
10
11
  async claimPrimary(workItemId, workflowInstanceId, context) {
11
12
  const stream = primaryOrchestrationGroupStream(workItemId);
12
- for (;;) {
13
- const events = await this.journal.readStream(stream);
14
- const owner = primaryOwner(groupEvents(events));
15
- if (owner !== undefined) {
13
+ await claimWithCasRetry({
14
+ read: () => this.journal.readStream(stream),
15
+ decode: groupEvents,
16
+ alreadyClaimed: (events) => {
17
+ const owner = primaryOwner(events);
18
+ if (owner === undefined)
19
+ return false;
16
20
  if (owner === workflowInstanceId)
17
- return;
21
+ return true;
18
22
  throw new Error(`WorkItem already has an active primary workflow owned by ${owner}`);
19
- }
20
- try {
21
- await this.journal.append(stream, events.length, [
23
+ },
24
+ append: async (sequence) => {
25
+ await this.journal.append(stream, sequence, [
22
26
  createEventDraft({
23
27
  eventId: `${context.commandId}:${OrchestrationEventType.PrimaryClaimed}:${workItemId}`,
24
28
  eventType: OrchestrationEventType.PrimaryClaimed,
@@ -31,26 +35,23 @@ export class CoordinationClaims {
31
35
  payload: { workItemId, workflowInstanceId },
32
36
  }),
33
37
  ]);
34
- return;
35
- }
36
- catch (error) {
37
- if (!(error instanceof WrongExpectedSequenceError))
38
- throw error;
39
- }
40
- }
38
+ },
39
+ });
41
40
  }
42
41
  async primaryWorkflowInstanceId(workItemId) {
43
42
  return primaryOwner(groupEvents(await this.journal.readStream(primaryOrchestrationGroupStream(workItemId))));
44
43
  }
45
44
  async claimWithinBudget(stream, request, context) {
46
- for (;;) {
47
- const events = await this.journal.readStream(stream);
48
- if (claimedRequestIds(groupEvents(events)).has(request.requestId))
49
- return true;
50
- if (events.length >= request.maxPerGroup)
51
- return false;
52
- try {
53
- await this.journal.append(stream, events.length, [
45
+ const existing = groupEvents(await this.journal.readStream(stream));
46
+ if (claimedRequestIds(existing).has(request.requestId))
47
+ return true;
48
+ const claimed = await claimWithCasRetry({
49
+ read: () => this.journal.readStream(stream),
50
+ decode: groupEvents,
51
+ alreadyClaimed: (events) => claimedRequestIds(events).has(request.requestId),
52
+ canAppend: (events) => events.length < request.maxPerGroup,
53
+ append: async (sequence) => {
54
+ await this.journal.append(stream, sequence, [
54
55
  createEventDraft({
55
56
  eventId: `${context.commandId}:${OrchestrationEventType.GroupClaimed}:${request.requestId}`,
56
57
  eventType: OrchestrationEventType.GroupClaimed,
@@ -63,13 +64,11 @@ export class CoordinationClaims {
63
64
  payload: { key: stream.id, requestId: request.requestId },
64
65
  }),
65
66
  ]);
66
- return true;
67
- }
68
- catch (error) {
69
- if (!(error instanceof WrongExpectedSequenceError))
70
- throw error;
71
- }
72
- }
67
+ },
68
+ });
69
+ if (claimed)
70
+ return true;
71
+ return claimedRequestIds(groupEvents(await this.journal.readStream(stream))).has(request.requestId);
73
72
  }
74
73
  }
75
74
  function primaryOwner(events) {
@@ -0,0 +1,36 @@
1
+ import { WrongExpectedSequenceError } from '../../kernel/index.js';
2
+ /** Recover a competing append only when the caller's durable intent is now visible. */
3
+ export async function appendWithIntentRecovery(input) {
4
+ try {
5
+ await input.append();
6
+ return undefined;
7
+ }
8
+ catch (error) {
9
+ const reloaded = await input.load();
10
+ if (input.alreadyApplied(reloaded))
11
+ return reloaded;
12
+ throw error;
13
+ }
14
+ }
15
+ /**
16
+ * Append a single claim with optimistic concurrency. The claim is inspected
17
+ * before every append, so a losing racer terminates on its next read.
18
+ */
19
+ export async function claimWithCasRetry(input) {
20
+ for (;;) {
21
+ const events = await input.read();
22
+ const decoded = input.decode(events);
23
+ if (input.alreadyClaimed(decoded))
24
+ return false;
25
+ if (input.canAppend?.(events, decoded) === false)
26
+ return false;
27
+ try {
28
+ await input.append(events.length);
29
+ return true;
30
+ }
31
+ catch (error) {
32
+ if (!(error instanceof WrongExpectedSequenceError))
33
+ throw error;
34
+ }
35
+ }
36
+ }
@@ -1,17 +1,14 @@
1
- import { OrchestrationEventType } from '../contracts/events.js';
2
- import { isWorkflowInstanceStream } from '../contracts/streams.js';
3
1
  import { WorkflowStatus } from '../contracts/vocabulary.js';
4
2
  import { acceptSignal as decideSignal } from '../domain/interpreter.js';
3
+ import { appendWithIntentRecovery } from './durable-append.js';
4
+ import { resolveTriggerWorkflowInstanceId } from './trigger-workflow-instance.js';
5
5
  // Generic: no resource-kind knowledge lives here. A `signal-wait-started`
6
6
  // trigger on the instance's own stream returns every declared transition
7
7
  // unfiltered, so the reactor's evidence policy can check prior journal
8
8
  // history for a fact that arrived before the wait began. Any other event
9
9
  // is matched against each instance's declared predicates directly.
10
- export function matchResourceTransitions(loaded, event) {
11
- const waitStart = event.eventType === OrchestrationEventType.SignalWaitStarted &&
12
- isWorkflowInstanceStream(event.stream)
13
- ? event.stream.id
14
- : undefined;
10
+ export async function matchResourceTransitions(loaded, event) {
11
+ const waitStart = await resolveTriggerWorkflowInstanceId(event, undefined);
15
12
  return loaded.flatMap(({ view }) => {
16
13
  if (view.status !== WorkflowStatus.Waiting)
17
14
  return [];
@@ -67,17 +64,17 @@ export async function acceptResourceTransition(repository, workflows, id, target
67
64
  consent: true,
68
65
  });
69
66
  if (decision.kind === 'append') {
70
- try {
71
- await repository.append(id, loaded.sequence, decision.events);
72
- }
73
- catch (error) {
74
- const reloaded = await repository.load(id);
75
- if (reloaded.view !== null &&
67
+ const recovered = await appendWithIntentRecovery({
68
+ append: async () => {
69
+ await repository.append(id, loaded.sequence, decision.events);
70
+ },
71
+ load: () => repository.load(id),
72
+ alreadyApplied: (reloaded) => reloaded.view !== null &&
76
73
  (reloaded.view.acceptedSignalIds.includes(evidenceId) ||
77
- reloaded.view.waitingFor === undefined))
78
- return reloaded.view;
79
- throw error;
80
- }
74
+ reloaded.view.waitingFor === undefined),
75
+ });
76
+ if (recovered !== undefined)
77
+ return recovered.view;
81
78
  }
82
79
  return (await repository.load(id)).view;
83
80
  }
@@ -0,0 +1,20 @@
1
+ import { selectRunExecutionEvent } from '../../execution/index.js';
2
+ import { OrchestrationEventType } from '../contracts/events.js';
3
+ import { isWorkflowInstanceStream } from '../contracts/streams.js';
4
+ /**
5
+ * Resolve the one instance an event belongs to, when it has one. Run events
6
+ * inherit their scope from the run. `ChildCompleted` deliberately does not:
7
+ * it is a cross-instance coordination fact and stream-scoping it would hide
8
+ * valid parent/child coordination from other eligible instances.
9
+ */
10
+ export async function resolveTriggerWorkflowInstanceId(event, runs) {
11
+ if (event.eventType === OrchestrationEventType.SignalWaitStarted &&
12
+ isWorkflowInstanceStream(event.stream))
13
+ return event.stream.id;
14
+ if (runs === undefined)
15
+ return undefined;
16
+ const runEvent = selectRunExecutionEvent(event);
17
+ if (runEvent === null)
18
+ return undefined;
19
+ return (await runs.load(runEvent.stream.id)).view?.workflowInstanceId;
20
+ }
@@ -1,9 +1,7 @@
1
- import { selectRunExecutionEvent } from '../../execution/index.js';
2
1
  import { correlationId, EventActorKind, } from '../../kernel/index.js';
3
2
  import { selectOrchestrationEvent } from '../contracts/event-decoder.js';
4
- import { OrchestrationEventType, } from '../contracts/events.js';
5
3
  import { workflowInstanceId, workflowName, } from '../contracts/identifiers.js';
6
- import { isWorkflowInstanceStream } from '../contracts/streams.js';
4
+ import { resolveTriggerWorkflowInstanceId } from './trigger-workflow-instance.js';
7
5
  const checkpoint = 'reactor:orchestration.watch';
8
6
  export function createWatchReactor(orchestration, journal, checkpoints, runs) {
9
7
  return {
@@ -57,36 +55,6 @@ function commandContext(event) {
57
55
  actor: { kind: EventActorKind.System, id: 'watch-reactor' },
58
56
  };
59
57
  }
60
- /**
61
- * A run-lifecycle event (e.g. `execution.run-succeeded`) fires for every run
62
- * in the system, including a watch's own spawned child re-running its own
63
- * activity on retry. Without scoping, `listWatchMatches` would treat any
64
- * currently-eligible parent's declared event type as a match regardless of
65
- * which run actually produced it, causing a child's own retry (or an
66
- * unrelated workflow's run) to spuriously re-trigger the same watch.
67
- *
68
- * For orchestration events, only `SignalWaitStarted` is scoped to its
69
- * stream's instance; this is currently the only orchestration event type
70
- * known to benefit from stream-based filtering. Other orchestration events
71
- * like `ChildCompleted` are cross-instance coordination facts by design and
72
- * must fall through unchanged to the async resolver to preserve their
73
- * existing multi-instance coordination paths.
74
- */
75
- async function resolveTriggerWorkflowInstanceId(event, runs) {
76
- if (event.eventType === OrchestrationEventType.SignalWaitStarted &&
77
- isWorkflowInstanceStream(event.stream))
78
- return event.stream.id;
79
- return resolveRunWorkflowInstanceId(event, runs);
80
- }
81
- async function resolveRunWorkflowInstanceId(event, runs) {
82
- if (runs === undefined)
83
- return undefined;
84
- const runEvent = selectRunExecutionEvent(event);
85
- if (runEvent === null)
86
- return undefined;
87
- const run = (await runs.load(runEvent.stream.id)).view;
88
- return run?.workflowInstanceId;
89
- }
90
58
  function orchestrationCausalCycleId(event) {
91
59
  return event !== null && 'causalCycleId' in event.payload
92
60
  ? event.payload.causalCycleId
@@ -98,6 +98,7 @@ function compileStage(context, rawStageName, stage, allStages) {
98
98
  const effectiveAwait = defaultApprovalAwait(stage, outcomeKind, route);
99
99
  const compiled = Object.freeze({
100
100
  target,
101
+ reentryTarget: { kind: TransitionTargetKind.Stage, stage: stageName(rawStageName) },
101
102
  ...(route.repeat === undefined ? {} : { repeat: route.repeat }),
102
103
  ...(route.retry === undefined ? {} : { retry: route.retry }),
103
104
  ...(followOns === undefined ? {} : { activities: Object.freeze(followOns) }),
@@ -24,23 +24,14 @@ export function acceptSignal(definition, state, input) {
24
24
  const events = [
25
25
  stateDraft(state, input, OrchestrationEventType.SignalAccepted, { ...signal, authority }, 1),
26
26
  ];
27
- if (signal.outcome === ActivityOutcomeKind.Rejected) {
28
- if (expected.onRejectResume !== undefined) {
29
- resumeToTarget(events, definition, state, input, expected.onRejectResume);
30
- }
31
- else {
32
- requestCurrentStage(events, definition, state, input);
33
- }
34
- }
35
- else if (expected.resume !== undefined) {
36
- resumeToTarget(events, definition, state, input, expected.resume);
37
- }
38
- else {
39
- requestCurrentStage(events, definition, state, input);
40
- }
27
+ const target = signal.outcome === ActivityOutcomeKind.Rejected ? expected.onRejectResume : expected.resume;
28
+ if (target !== undefined)
29
+ resumeToTarget(events, definition, state, input, target);
30
+ else
31
+ requestLegacyReentry(events, definition, state, input);
41
32
  return { kind: 'append', events };
42
33
  }
43
- function requestCurrentStage(events, definition, state, input) {
34
+ function requestLegacyReentry(events, definition, state, input) {
44
35
  const stage = definition.stages[stageName(state.currentStage)];
45
36
  events.push(stateDraft(state, input, OrchestrationEventType.ActivityRequested, activation(state.workflowInstanceId, nextOrdinal(state), stage.activity, stage.with, {
46
37
  execution: stage.execution,
@@ -2,7 +2,7 @@ import { OrchestrationEventType, ResourceTransitionSignal, WatchGateVerdictSigna
2
2
  import { ApprovalAuthorityKind, TransitionTargetKind } from '../contracts/vocabulary.js';
3
3
  import { activation, nextOrdinal, stateDraft } from './decision-events.js';
4
4
  // Route completion combines the mutually exclusive wait, await, and target policies.
5
- // eslint-disable-next-line complexity
5
+ // eslint-disable-next-line complexity, max-lines-per-function
6
6
  export function finishRoute(events, definition, state, input, route) {
7
7
  if (route.watchGates !== undefined || route.resourceTransitions !== undefined) {
8
8
  const gate = route.watchGates?.[0];
@@ -25,7 +25,12 @@ export function finishRoute(events, definition, state, input, route) {
25
25
  return;
26
26
  }
27
27
  if (route.await !== undefined) {
28
- events.push(stateDraft(state, input, OrchestrationEventType.SignalWaitStarted, { signalKind: route.await.signal, from: route.await.from, resume: route.await.resume }, events.length + 1));
28
+ events.push(stateDraft(state, input, OrchestrationEventType.SignalWaitStarted, {
29
+ signalKind: route.await.signal,
30
+ from: route.await.from,
31
+ resume: route.await.resume,
32
+ onRejectResume: route.reentryTarget,
33
+ }, events.length + 1));
29
34
  return;
30
35
  }
31
36
  if (route.target.kind !== TransitionTargetKind.Stage) {
@@ -2,12 +2,14 @@ export * from './application/orchestration-projection.js';
2
2
  export * from './application/orchestration-repository.js';
3
3
  export * from './application/orchestration-service.js';
4
4
  export * from './application/advance-workflow.js';
5
+ export * from './application/durable-append.js';
5
6
  export * from './application/pull-request-transition-evidence.js';
6
7
  export * from './application/resource-transition-evidence.js';
7
8
  export * from './application/resource-transition-matching.js';
8
9
  export * from './application/resource-transition-reactor.js';
9
10
  export * from './application/signal-reactor.js';
10
11
  export * from './application/watch-reactor.js';
12
+ export * from './application/trigger-workflow-instance.js';
11
13
  export * from './application/workflow-definition-registry.js';
12
14
  export * from './contracts/activity-outcome.js';
13
15
  export * from './contracts/commands.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {