@atolis-hq/wake 0.3.79 → 0.3.81

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,17 +1,27 @@
1
1
  import { ResourceCorrelationRole } from '../resources/index.js';
2
2
  export function createCapabilityResourceTransitionEvidence(input) {
3
+ const registrationFor = (capabilities) => input.policies.find((registration) => registration.capabilities.some((capability) => capabilities.includes(capability)));
3
4
  return {
4
5
  triggers: [...new Set(input.policies.flatMap(({ policy }) => policy.triggers))],
5
6
  async resolve(evidence) {
7
+ // A work item may hold several primary correlations (e.g. its
8
+ // originating issue and an implementation PR). Only the correlation
9
+ // whose resource matches a registered policy's capabilities is
10
+ // relevant here; the rest drop out rather than blocking resolution.
6
11
  const correlations = await input.resources.correlationsForWork(evidence.workItemId);
7
12
  const primaries = correlations.filter(({ role }) => role === ResourceCorrelationRole.Primary);
8
- if (primaries.length !== 1)
13
+ const registrations = [];
14
+ for (const primary of primaries) {
15
+ const resource = await input.resources.get(primary.resourceId);
16
+ if (resource === null)
17
+ continue;
18
+ const registration = registrationFor(resource.capabilities);
19
+ if (registration !== undefined)
20
+ registrations.push(registration);
21
+ }
22
+ if (registrations.length !== 1)
9
23
  return null;
10
- const resource = await input.resources.get(primaries[0].resourceId);
11
- if (resource === null)
12
- return null;
13
- const registration = input.policies.find(({ capabilities }) => capabilities.some((capability) => resource.capabilities.includes(capability)));
14
- return registration?.policy.resolve(evidence) ?? null;
24
+ return registrations[0].policy.resolve(evidence);
15
25
  },
16
26
  };
17
27
  }
@@ -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 = "gb43010f";
111
+ export const wakeVersion = "gb8f09ad";
@@ -1,22 +1,36 @@
1
1
  import { ActivityOutcomeKind, BuiltInActivityName } from '../../activities/index.js';
2
2
  import { OrchestrationEventType, WatchGateVerdictSignal } from '../contracts/events.js';
3
- import { stageName, watchId } from '../contracts/identifiers.js';
3
+ import { signalName, stageName, watchId } from '../contracts/identifiers.js';
4
4
  import { ActivityActivationStatus, ApprovalAuthorityKind, WorkflowStatus, } from '../contracts/vocabulary.js';
5
5
  import { activation, nextOrdinal, stateDraft } from './decision-events.js';
6
+ // `then: await-human` on a `failed` route compiles its wait to this literal
7
+ // signal name (see compileTarget); a workflow parked there has exhausted
8
+ // whatever automatic retries it had and is durably equivalent to a blocked
9
+ // failed stage for operator-retry purposes.
10
+ const FailedOutcomeSignal = signalName(ActivityOutcomeKind.Failed);
6
11
  export function isOperatorRetryEligible(view) {
7
12
  const pending = view.pendingActivation;
8
- const eligibleActivation = view.status === WorkflowStatus.Blocked &&
9
- pending !== undefined &&
13
+ const eligibleActivation = pending !== undefined &&
10
14
  pending.status === ActivityActivationStatus.Completed &&
11
15
  pending.supplemental !== true &&
12
16
  pending.followOnIndex === undefined &&
13
17
  view.acceptedOutcomes.includes(pending.activationId);
14
18
  if (!eligibleActivation || pending === undefined)
15
19
  return false;
20
+ if (view.status === WorkflowStatus.Blocked)
21
+ return isRetryEligibleBlock(view, pending);
22
+ return isRetryEligibleFailedWait(view);
23
+ }
24
+ function isRetryEligibleBlock(view, pending) {
16
25
  return ((view.blockReason === 'unconfigured outcome failed' &&
17
26
  view.lastOutcome?.kind === ActivityOutcomeKind.Failed) ||
18
27
  view.executionFailure?.activationId === pending.activationId);
19
28
  }
29
+ function isRetryEligibleFailedWait(view) {
30
+ return (view.status === WorkflowStatus.Waiting &&
31
+ view.waitingFor?.signalKind === FailedOutcomeSignal &&
32
+ view.lastOutcome?.kind === ActivityOutcomeKind.Failed);
33
+ }
20
34
  export function selectOperatorRetryTarget(workflows) {
21
35
  const primary = workflows.find((workflow) => workflow.parentWorkflowInstanceId === undefined);
22
36
  if (primary === undefined)
@@ -52,7 +66,7 @@ export function requestOperatorRetry(definition, state, input) {
52
66
  if (!isOperatorRetryEligible(state))
53
67
  return {
54
68
  kind: 'ignored',
55
- reason: 'workflow is not blocked for a retryable failed stage',
69
+ reason: 'workflow is not in a retryable failed-stage state',
56
70
  };
57
71
  const stage = definition.stages[stageName(state.currentStage)];
58
72
  const events = [
@@ -53,9 +53,10 @@ export class FileEventJournal {
53
53
  const directory = join(this.root, 'events');
54
54
  await mkdir(directory, { recursive: true });
55
55
  const day = recordedAt.slice(0, 10);
56
- await appendFile(join(directory, `${day}.jsonl`), newEnvelopes.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
56
+ const file = `${day}.jsonl`;
57
+ await appendFile(join(directory, file), newEnvelopes.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
58
+ await this.extendCache(file, current, newEnvelopes);
57
59
  }
58
- this.cached = undefined;
59
60
  return finalizedEnvelopes;
60
61
  });
61
62
  }
@@ -77,6 +78,20 @@ export class FileEventJournal {
77
78
  const events = await this.scan();
78
79
  return events.at(-1)?.globalPosition ?? 0;
79
80
  }
81
+ // Extends the cache in place rather than invalidating it, so a self-caused
82
+ // write doesn't force the next read (by this call or any other reader
83
+ // sharing this instance) to re-parse the entire on-disk history. Only a
84
+ // change this instance didn't make itself — another process writing to the
85
+ // same journal — falls through to scan()'s full re-read.
86
+ async extendCache(file, priorEvents, newEnvelopes) {
87
+ const info = await stat(join(this.root, 'events', file));
88
+ const updatedEntry = { file, size: info.size, mtimeMs: info.mtimeMs };
89
+ const priorEntries = this.cached?.entries ?? [];
90
+ const entries = priorEntries.some((entry) => entry.file === file)
91
+ ? priorEntries.map((entry) => (entry.file === file ? updatedEntry : entry))
92
+ : [...priorEntries, updatedEntry];
93
+ this.cached = { entries, events: [...priorEvents, ...newEnvelopes] };
94
+ }
80
95
  async scan() {
81
96
  const directory = join(this.root, 'events');
82
97
  let files;
@@ -90,11 +105,11 @@ export class FileEventJournal {
90
105
  return [];
91
106
  throw error;
92
107
  }
93
- const fingerprint = (await Promise.all(files.map(async (file) => {
108
+ const entries = await Promise.all(files.map(async (file) => {
94
109
  const info = await stat(join(directory, file));
95
- return `${file}:${info.size}:${info.mtimeMs}`;
96
- }))).join('|');
97
- if (this.cached?.fingerprint === fingerprint)
110
+ return { file, size: info.size, mtimeMs: info.mtimeMs };
111
+ }));
112
+ if (this.cached !== undefined && sameEntries(this.cached.entries, entries))
98
113
  return this.cached.events;
99
114
  const events = [];
100
115
  for (const file of files) {
@@ -115,10 +130,17 @@ export class FileEventJournal {
115
130
  }
116
131
  }
117
132
  }
118
- this.cached = { fingerprint, events };
133
+ this.cached = { entries, events };
119
134
  return events;
120
135
  }
121
136
  }
137
+ function sameEntries(a, b) {
138
+ return (a.length === b.length &&
139
+ a.every((entry, index) => {
140
+ const other = b[index];
141
+ return (entry.file === other.file && entry.size === other.size && entry.mtimeMs === other.mtimeMs);
142
+ }));
143
+ }
122
144
  const key = (stream) => `${stream.kind}:${stream.id}`;
123
145
  const sameDraft = (event, draft) => isDeepStrictEqual({
124
146
  eventId: event.eventId,
@@ -17,9 +17,25 @@ export class FileProjectionStore {
17
17
  throw error;
18
18
  }
19
19
  }
20
+ // Patches the cached entry in place rather than invalidating the whole
21
+ // namespace, so a self-caused write doesn't force the next list() to
22
+ // re-read every other unchanged projection file in the namespace. Falls
23
+ // back to a full list() re-read for a namespace nothing has cached yet.
20
24
  async write(projection) {
21
- await atomicJson(this.path(projection.namespace, projection.key), projection);
22
- this.listCache.delete(projection.namespace);
25
+ const path = this.path(projection.namespace, projection.key);
26
+ await atomicJson(path, projection);
27
+ const cached = this.listCache.get(projection.namespace);
28
+ if (cached === undefined)
29
+ return;
30
+ const info = await stat(path);
31
+ const file = `${encode(projection.key)}.json`;
32
+ const updatedEntry = {
33
+ file,
34
+ size: info.size,
35
+ mtimeMs: info.mtimeMs,
36
+ value: projection,
37
+ };
38
+ this.listCache.set(projection.namespace, [...cached.filter((entry) => entry.file !== file), updatedEntry].sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
23
39
  }
24
40
  // Callers (advance-once, orchestration-service, DeliveryService, ...) list()
25
41
  // an entire namespace unconditionally on every control-plane tick, even when
@@ -41,16 +57,21 @@ export class FileProjectionStore {
41
57
  }
42
58
  throw error;
43
59
  }
44
- const fingerprint = (await Promise.all(files.map(async (file) => {
60
+ const stats = await Promise.all(files.map(async (file) => {
45
61
  const info = await stat(join(directory, file));
46
- return `${file}:${info.size}:${info.mtimeMs}`;
47
- }))).join('|');
62
+ return { file, size: info.size, mtimeMs: info.mtimeMs };
63
+ }));
48
64
  const cached = this.listCache.get(namespace);
49
- if (cached?.fingerprint === fingerprint)
50
- return cached.entries;
51
- const entries = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(directory, file), 'utf8'))));
52
- this.listCache.set(namespace, { fingerprint, entries });
53
- return entries;
65
+ if (cached !== undefined && sameProjectionFiles(cached, stats))
66
+ return cached.map((entry) => entry.value);
67
+ const entries = await Promise.all(stats.map(async ({ file, size, mtimeMs }) => ({
68
+ file,
69
+ size,
70
+ mtimeMs,
71
+ value: JSON.parse(await readFile(join(directory, file), 'utf8')),
72
+ })));
73
+ this.listCache.set(namespace, entries);
74
+ return entries.map((entry) => entry.value);
54
75
  }
55
76
  async clear(namespace) {
56
77
  await rm(namespace === undefined
@@ -65,6 +86,13 @@ export class FileProjectionStore {
65
86
  return join(this.root, 'projections', encode(namespace), `${encode(key)}.json`);
66
87
  }
67
88
  }
89
+ function sameProjectionFiles(cached, stats) {
90
+ return (cached.length === stats.length &&
91
+ cached.every((entry, index) => {
92
+ const other = stats[index];
93
+ return (entry.file === other.file && entry.size === other.size && entry.mtimeMs === other.mtimeMs);
94
+ }));
95
+ }
68
96
  export function encode(value) {
69
97
  if (value.length === 0 || /[\\/]/.test(value))
70
98
  throw new Error('Storage name must not contain path separators');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.79",
3
+ "version": "0.3.81",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {