@atolis-hq/wake 0.3.18 → 0.3.20
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/activities/pr/application.js +5 -1
- package/dist/src/activities/pr/policy.js +10 -0
- package/dist/src/bootstrap/activity-registry.js +32 -0
- package/dist/src/bootstrap/composition-root.js +20 -244
- package/dist/src/bootstrap/config/load-config.js +8 -7
- package/dist/src/bootstrap/fake-scenarios.js +3 -2
- package/dist/src/bootstrap/index.js +2 -0
- package/dist/src/bootstrap/integration-runtime.js +184 -0
- package/dist/src/bootstrap/persistence-composition.js +19 -0
- package/dist/src/bootstrap/resource-transition-evidence.js +17 -0
- package/dist/src/bootstrap/resource-transition-ordering.js +67 -0
- package/dist/src/bootstrap/transcript-retention.js +29 -0
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/orchestration/application/advance-workflow.js +12 -22
- package/dist/src/orchestration/application/orchestration-repository.js +19 -4
- package/dist/src/orchestration/application/orchestration-service.js +12 -2
- package/dist/src/orchestration/application/pull-request-transition-evidence.js +70 -0
- package/dist/src/orchestration/application/resource-transition-evidence.js +1 -0
- package/dist/src/orchestration/application/resource-transition-matching.js +72 -0
- package/dist/src/orchestration/application/resource-transition-reactor.js +65 -0
- package/dist/src/orchestration/application/watch-matching.js +27 -0
- package/dist/src/orchestration/contracts/config.js +24 -1
- package/dist/src/orchestration/contracts/event-decoder.js +18 -1
- package/dist/src/orchestration/contracts/events.js +1 -0
- package/dist/src/orchestration/domain/approval-defaults.js +1 -0
- package/dist/src/orchestration/domain/compiler.js +14 -1
- package/dist/src/orchestration/domain/resource-transition-compiler.js +12 -0
- package/dist/src/orchestration/domain/transition.js +18 -9
- package/dist/src/orchestration/domain/workflow-graph.js +5 -1
- package/dist/src/orchestration/domain/workflow-instance-events.js +3 -0
- package/dist/src/orchestration/index.js +4 -0
- package/dist/src/persistence/filesystem/file-lock.js +199 -27
- package/package.json +5 -3
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import { OrchestrationEventType, WatchGateVerdictSignal } from '../contracts/events.js';
|
|
1
|
+
import { OrchestrationEventType, ResourceTransitionSignal, WatchGateVerdictSignal, } from '../contracts/events.js';
|
|
2
2
|
import { ApprovalAuthorityKind, TransitionTargetKind } from '../contracts/vocabulary.js';
|
|
3
3
|
import { activation, nextOrdinal, stateDraft } from './decision-events.js';
|
|
4
|
+
// Route completion combines the mutually exclusive wait, await, and target policies.
|
|
5
|
+
// eslint-disable-next-line complexity
|
|
4
6
|
export function finishRoute(events, definition, state, input, route) {
|
|
5
|
-
if (route.watchGates !== undefined) {
|
|
6
|
-
const gate = route.watchGates[0];
|
|
7
|
+
if (route.watchGates !== undefined || route.resourceTransitions !== undefined) {
|
|
8
|
+
const gate = route.watchGates?.[0];
|
|
7
9
|
events.push(stateDraft(state, input, OrchestrationEventType.SignalWaitStarted, {
|
|
8
|
-
signalKind: WatchGateVerdictSignal,
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
signalKind: gate === undefined ? ResourceTransitionSignal : WatchGateVerdictSignal,
|
|
11
|
+
...(gate === undefined
|
|
12
|
+
? {}
|
|
13
|
+
: {
|
|
14
|
+
from: Object.freeze([
|
|
15
|
+
{ kind: ApprovalAuthorityKind.Watch, watch: gate.watch },
|
|
16
|
+
{ kind: ApprovalAuthorityKind.Human },
|
|
17
|
+
]),
|
|
18
|
+
}),
|
|
13
19
|
resume: route.target,
|
|
14
|
-
onRejectResume: gate.onRejectTarget,
|
|
20
|
+
...(gate === undefined ? {} : { onRejectResume: gate.onRejectTarget }),
|
|
21
|
+
...(route.resourceTransitions === undefined
|
|
22
|
+
? {}
|
|
23
|
+
: { resourceTransitions: route.resourceTransitions }),
|
|
15
24
|
}, events.length + 1));
|
|
16
25
|
return;
|
|
17
26
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { stageName } from '../contracts/identifiers.js';
|
|
2
2
|
import { TransitionTargetKind } from '../contracts/vocabulary.js';
|
|
3
3
|
function edges(stages, name) {
|
|
4
|
-
return Object.values(stages[name].on).flatMap((route) =>
|
|
4
|
+
return Object.values(stages[name].on).flatMap((route) => [
|
|
5
|
+
route.target,
|
|
6
|
+
...(route.watchGates ?? []).map((gate) => gate.onRejectTarget),
|
|
7
|
+
...(route.resourceTransitions ?? []).map((transition) => transition.target),
|
|
8
|
+
].flatMap((target) => (target.kind === TransitionTargetKind.Stage ? [target.stage] : [])));
|
|
5
9
|
}
|
|
6
10
|
export function assertReachable(entry, stages) {
|
|
7
11
|
const reached = new Set();
|
|
@@ -178,6 +178,9 @@ function applySignalWaitStarted(state, event) {
|
|
|
178
178
|
...(event.payload.onRejectResume === undefined
|
|
179
179
|
? {}
|
|
180
180
|
: { onRejectResume: event.payload.onRejectResume }),
|
|
181
|
+
...(event.payload.resourceTransitions === undefined
|
|
182
|
+
? {}
|
|
183
|
+
: { resourceTransitions: event.payload.resourceTransitions }),
|
|
181
184
|
};
|
|
182
185
|
}
|
|
183
186
|
function updateActivationStatus(state, activationId, status) {
|
|
@@ -2,6 +2,10 @@ 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/pull-request-transition-evidence.js';
|
|
6
|
+
export * from './application/resource-transition-evidence.js';
|
|
7
|
+
export * from './application/resource-transition-matching.js';
|
|
8
|
+
export * from './application/resource-transition-reactor.js';
|
|
5
9
|
export * from './application/signal-reactor.js';
|
|
6
10
|
export * from './application/watch-reactor.js';
|
|
7
11
|
export * from './application/workflow-definition-registry.js';
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, open, readFile, rm } from 'node:fs/promises';
|
|
3
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { mkdir, open, readdir, readFile, rm, rmdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const maximumOwnerRecords = 1024;
|
|
5
|
+
const compatibilityAcquiredAt = '9999-12-31T23:59:59.999Z';
|
|
4
6
|
export async function acquireFileLock(path, options) {
|
|
5
7
|
await mkdir(dirname(path), { recursive: true });
|
|
6
8
|
const metadata = {
|
|
@@ -8,51 +10,219 @@ export async function acquireFileLock(path, options) {
|
|
|
8
10
|
acquiredAt: (options?.now ?? new Date()).toISOString(),
|
|
9
11
|
lockId: randomUUID(),
|
|
10
12
|
};
|
|
13
|
+
return options?.staleRequiresDeadProcess
|
|
14
|
+
? acquireStrictFileLock(path, metadata, options)
|
|
15
|
+
: acquireLegacyFileLock(path, metadata, options);
|
|
16
|
+
}
|
|
17
|
+
async function acquireStrictFileLock(path, metadata, options) {
|
|
18
|
+
const ownersPath = `${path}.owners`;
|
|
19
|
+
await mkdir(ownersPath, { recursive: true });
|
|
20
|
+
const legacy = await inspectLegacyOwner(path, ownersPath, options);
|
|
21
|
+
if (legacy.blocks)
|
|
22
|
+
return unavailableFileLock();
|
|
23
|
+
if (legacy.reclaimableProtectedOwnerRecord !== undefined)
|
|
24
|
+
await rm(join(ownersPath, legacy.reclaimableProtectedOwnerRecord), { force: true });
|
|
25
|
+
const ownerName = ownerRecordName(metadata);
|
|
26
|
+
const ownerPath = join(ownersPath, ownerName);
|
|
27
|
+
await createOwnerRecord(ownerPath, metadata);
|
|
11
28
|
try {
|
|
12
|
-
|
|
29
|
+
const candidates = await readdir(ownersPath);
|
|
30
|
+
if (candidates.length > maximumOwnerRecords) {
|
|
31
|
+
await rm(ownerPath, { force: true });
|
|
32
|
+
return unavailableFileLock();
|
|
33
|
+
}
|
|
34
|
+
if (await hasBlockingOwner(candidates, ownersPath, ownerName, legacy, options)) {
|
|
35
|
+
await rm(ownerPath, { force: true });
|
|
36
|
+
return unavailableFileLock();
|
|
37
|
+
}
|
|
38
|
+
const ownsCompatibilityPath = await createCompatibilityOwner(path, metadata, ownerName);
|
|
39
|
+
if (!ownsCompatibilityPath && (await inspectLegacyOwner(path, ownersPath, options)).blocks) {
|
|
40
|
+
await rm(ownerPath, { force: true });
|
|
41
|
+
return unavailableFileLock();
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
acquired: true,
|
|
45
|
+
metadata,
|
|
46
|
+
async release() {
|
|
47
|
+
let peers;
|
|
48
|
+
try {
|
|
49
|
+
peers = (await readdir(ownersPath)).filter((candidate) => candidate !== ownerName);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT')
|
|
53
|
+
return;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (peers.length === 0)
|
|
57
|
+
await releaseCompatibilityOwner(path);
|
|
58
|
+
await rm(ownerPath, { force: true });
|
|
59
|
+
await removeEmptyOwnerDirectory(ownersPath);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
13
62
|
}
|
|
14
63
|
catch (error) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return reclaimStaleFileLock(path, metadata, options);
|
|
64
|
+
await rm(ownerPath, { force: true });
|
|
65
|
+
throw error;
|
|
18
66
|
}
|
|
19
67
|
}
|
|
20
|
-
async function
|
|
21
|
-
const handle = await open(path, 'wx');
|
|
68
|
+
async function removeEmptyOwnerDirectory(path) {
|
|
22
69
|
try {
|
|
23
|
-
await
|
|
24
|
-
await handle.sync();
|
|
70
|
+
await rmdir(path);
|
|
25
71
|
}
|
|
26
|
-
|
|
27
|
-
|
|
72
|
+
catch {
|
|
73
|
+
// A concurrent owner may have populated the directory.
|
|
28
74
|
}
|
|
29
|
-
return { acquired: true, metadata, release: () => releaseFileLock(path, metadata) };
|
|
30
75
|
}
|
|
31
|
-
async function
|
|
76
|
+
async function acquireLegacyFileLock(path, metadata, options) {
|
|
32
77
|
try {
|
|
33
|
-
|
|
34
|
-
if (current.lockId === metadata.lockId)
|
|
35
|
-
await rm(path, { force: true });
|
|
78
|
+
return await createLegacyFileLock(path, metadata);
|
|
36
79
|
}
|
|
37
|
-
catch {
|
|
38
|
-
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== 'EEXIST')
|
|
82
|
+
throw error;
|
|
39
83
|
}
|
|
40
|
-
}
|
|
41
|
-
async function reclaimStaleFileLock(path, metadata, options) {
|
|
42
84
|
if (options?.staleAfterMs === undefined)
|
|
43
85
|
return unavailableFileLock();
|
|
44
86
|
try {
|
|
45
87
|
const prior = JSON.parse(await readFile(path, 'utf8'));
|
|
46
88
|
if (!isStale(prior, options))
|
|
47
89
|
return unavailableFileLock();
|
|
48
|
-
if (options.staleRequiresDeadProcess && ownerMayBeAlive(options, prior.pid))
|
|
49
|
-
return unavailableFileLock();
|
|
50
90
|
}
|
|
51
91
|
catch {
|
|
52
|
-
//
|
|
92
|
+
// Preserve the historical time-only recovery contract for malformed or vanished locks.
|
|
53
93
|
}
|
|
54
94
|
await rm(path, { force: true });
|
|
55
|
-
|
|
95
|
+
try {
|
|
96
|
+
return await createLegacyFileLock(path, metadata);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error.code === 'EEXIST')
|
|
100
|
+
return unavailableFileLock();
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function createLegacyFileLock(path, metadata) {
|
|
105
|
+
await createOwnerRecord(path, metadata);
|
|
106
|
+
return {
|
|
107
|
+
acquired: true,
|
|
108
|
+
metadata,
|
|
109
|
+
release: () => releaseLegacyFileLock(path, metadata.lockId),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async function releaseLegacyFileLock(path, lockId) {
|
|
113
|
+
try {
|
|
114
|
+
const current = JSON.parse(await readFile(path, 'utf8'));
|
|
115
|
+
if (current.lockId === lockId)
|
|
116
|
+
await rm(path, { force: true });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* already released */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function hasBlockingOwner(candidates, ownersPath, ownerName, legacy, options) {
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
if (candidate === ownerName || candidate === legacy.protectedOwnerRecord)
|
|
125
|
+
continue;
|
|
126
|
+
const candidatePath = join(ownersPath, candidate);
|
|
127
|
+
if (await ownerBlocksAcquisition(candidatePath, options))
|
|
128
|
+
return true;
|
|
129
|
+
if (options?.staleRequiresDeadProcess)
|
|
130
|
+
await rm(candidatePath, { force: true });
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
async function inspectLegacyOwner(path, ownersPath, options) {
|
|
135
|
+
let legacy;
|
|
136
|
+
try {
|
|
137
|
+
legacy = JSON.parse(await readFile(path, 'utf8'));
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return { blocks: error.code !== 'ENOENT' };
|
|
141
|
+
}
|
|
142
|
+
if (!('compatibilityOwner' in legacy)) {
|
|
143
|
+
return { blocks: true };
|
|
144
|
+
}
|
|
145
|
+
const ownerPath = join(ownersPath, legacy.ownerRecord);
|
|
146
|
+
const blocks = await ownerBlocksAcquisition(ownerPath, options);
|
|
147
|
+
return {
|
|
148
|
+
blocks,
|
|
149
|
+
protectedOwnerRecord: legacy.ownerRecord,
|
|
150
|
+
...(blocks ? {} : { reclaimableProtectedOwnerRecord: legacy.ownerRecord }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function createCompatibilityOwner(path, metadata, ownerRecord) {
|
|
154
|
+
try {
|
|
155
|
+
await createOwnerRecord(path, {
|
|
156
|
+
...metadata,
|
|
157
|
+
acquiredAt: compatibilityAcquiredAt,
|
|
158
|
+
compatibilityOwner: true,
|
|
159
|
+
ownerRecord,
|
|
160
|
+
});
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code === 'EEXIST')
|
|
165
|
+
return false;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function releaseCompatibilityOwner(path) {
|
|
170
|
+
try {
|
|
171
|
+
const owner = JSON.parse(await readFile(path, 'utf8'));
|
|
172
|
+
if (owner.compatibilityOwner)
|
|
173
|
+
await rm(path, { force: true });
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* already released */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function createOwnerRecord(path, metadata) {
|
|
180
|
+
let handle;
|
|
181
|
+
try {
|
|
182
|
+
handle = await open(path, 'wx');
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error.code !== 'ENOENT')
|
|
186
|
+
throw error;
|
|
187
|
+
await mkdir(dirname(path), { recursive: true });
|
|
188
|
+
handle = await open(path, 'wx');
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await handle.writeFile(`${JSON.stringify(metadata)}\n`, 'utf8');
|
|
192
|
+
await handle.sync();
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await handle.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function ownerBlocksAcquisition(path, options) {
|
|
199
|
+
let owner;
|
|
200
|
+
try {
|
|
201
|
+
owner = JSON.parse(await readFile(path, 'utf8'));
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error.code === 'ENOENT')
|
|
205
|
+
return false;
|
|
206
|
+
const encoded = /^(\d+)-(\d+)-([^.]+)\.json$/.exec(path.split(/[\\/]/).at(-1) ?? '');
|
|
207
|
+
if (encoded === null)
|
|
208
|
+
return true;
|
|
209
|
+
owner = {
|
|
210
|
+
pid: Number(encoded[1]),
|
|
211
|
+
acquiredAt: new Date(Number(encoded[2])).toISOString(),
|
|
212
|
+
lockId: encoded[3],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (options?.staleAfterMs === undefined || !isStale(owner, options))
|
|
216
|
+
return true;
|
|
217
|
+
return ownerMetadataBlocks(owner, options);
|
|
218
|
+
}
|
|
219
|
+
function ownerMetadataBlocks(owner, options) {
|
|
220
|
+
if (options?.staleAfterMs === undefined || !isStale(owner, options))
|
|
221
|
+
return true;
|
|
222
|
+
return Boolean(options.staleRequiresDeadProcess && ownerMayBeAlive(options, owner.pid));
|
|
223
|
+
}
|
|
224
|
+
function ownerRecordName(metadata) {
|
|
225
|
+
return `${metadata.pid}-${Date.parse(metadata.acquiredAt)}-${metadata.lockId}.json`;
|
|
56
226
|
}
|
|
57
227
|
function isStale(prior, options) {
|
|
58
228
|
return ((options.now ?? new Date()).getTime() - Date.parse(prior.acquiredAt) >= options.staleAfterMs);
|
|
@@ -74,12 +244,14 @@ function ownerMayBeAlive(options, pid) {
|
|
|
74
244
|
return (options.isProcessAlive ?? isProcessAlive)(pid);
|
|
75
245
|
}
|
|
76
246
|
catch {
|
|
77
|
-
// Permission and platform-probe failures are indeterminate: never steal a live owner's lock.
|
|
78
247
|
return true;
|
|
79
248
|
}
|
|
80
249
|
}
|
|
81
250
|
export async function withFileLock(path, operation) {
|
|
82
|
-
const lock = await acquireFileLock(path, {
|
|
251
|
+
const lock = await acquireFileLock(path, {
|
|
252
|
+
staleAfterMs: 60_000,
|
|
253
|
+
staleRequiresDeadProcess: true,
|
|
254
|
+
});
|
|
83
255
|
if (!lock.acquired)
|
|
84
256
|
throw new Error(`File lock is already held: ${path}`);
|
|
85
257
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolis-hq/wake",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
4
4
|
"description": "Local autonomous agent control plane for software development",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -34,11 +34,12 @@
|
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc -p tsconfig.json && node scripts/embed-version.mjs && node scripts/check-cli-entrypoint.mjs",
|
|
36
36
|
"prepack": "npm run build && npm run build:web",
|
|
37
|
-
"test": "
|
|
37
|
+
"test": "npm run test:unit",
|
|
38
38
|
"test:fast": "npm run test:unit",
|
|
39
39
|
"check:catalogue": "node scripts/check-functional-catalogue.mjs",
|
|
40
40
|
"check:scenarios": "node scripts/check-scenario-coverage.mjs",
|
|
41
41
|
"check:specs": "node scripts/check-specs.mjs",
|
|
42
|
+
"check:specs:report": "node scripts/report-spec-drift.mjs",
|
|
42
43
|
"lint:architecture": "node scripts/check-module-manifests.mjs && node scripts/check-contract-vocabulary.mjs && depcruise --config dependency-cruiser.config.mjs src",
|
|
43
44
|
"build:docker": "tsc -p tsconfig.docker.json && node scripts/embed-version.mjs && npm run build:web",
|
|
44
45
|
"build:web": "npm --workspace @atolis-hq/wake-web run build",
|
|
@@ -62,7 +63,8 @@
|
|
|
62
63
|
"smoke:claude": "tsx src/main.ts smoke claude",
|
|
63
64
|
"smoke:codex": "tsx src/main.ts smoke codex",
|
|
64
65
|
"smoke:cursor": "tsx src/main.ts smoke cursor",
|
|
65
|
-
"verify": "npm run check:catalogue && npm run check:scenarios && npm run lint:architecture && npm run lint && npm run format:check && npm run build && npm test"
|
|
66
|
+
"verify": "npm run check:catalogue && npm run check:scenarios && npm run check:specs:report && npm run lint:architecture && npm run lint && npm run format:check && npm run build && npm run test:unit",
|
|
67
|
+
"verify:ci": "npm run verify && npm run test:architecture && npm run test:integration && npm run test:e2e && npm run knip && npm run test:web"
|
|
66
68
|
},
|
|
67
69
|
"dependencies": {
|
|
68
70
|
"@octokit/rest": "^22.0.0",
|