bmad-fh 6.0.0-alpha.23 → 6.0.0-alpha.23.3b00cb36
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/.github/workflows/publish.yaml +68 -0
- package/.husky/pre-commit +17 -2
- package/.husky/pre-push +10 -0
- package/README.md +117 -14
- package/eslint.config.mjs +2 -2
- package/package.json +1 -2
- package/src/bmm/module.yaml +2 -2
- package/src/core/lib/scope/artifact-resolver.js +26 -26
- package/src/core/lib/scope/event-logger.js +34 -45
- package/src/core/lib/scope/index.js +3 -3
- package/src/core/lib/scope/scope-context.js +22 -28
- package/src/core/lib/scope/scope-initializer.js +29 -31
- package/src/core/lib/scope/scope-manager.js +57 -24
- package/src/core/lib/scope/scope-migrator.js +44 -52
- package/src/core/lib/scope/scope-sync.js +42 -48
- package/src/core/lib/scope/scope-validator.js +16 -21
- package/src/core/lib/scope/state-lock.js +37 -43
- package/src/core/module.yaml +2 -2
- package/test/test-scope-cli.js +1306 -0
- package/test/test-scope-e2e.js +682 -92
- package/test/test-scope-system.js +973 -169
- package/tools/cli/bmad-cli.js +5 -0
- package/tools/cli/commands/scope.js +1250 -115
- package/tools/cli/installers/lib/modules/manager.js +6 -2
- package/tools/cli/scripts/migrate-workflows.js +43 -51
- package/.github/workflows/publish-multi-artifact.yaml +0 -50
|
@@ -7,12 +7,12 @@ const { StateLock } = require('./state-lock');
|
|
|
7
7
|
/**
|
|
8
8
|
* Handles synchronization between scopes and shared layer
|
|
9
9
|
* Implements sync-up (promote to shared) and sync-down (pull from shared)
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
11
|
* @class ScopeSync
|
|
12
12
|
* @requires fs-extra
|
|
13
13
|
* @requires yaml
|
|
14
14
|
* @requires StateLock
|
|
15
|
-
*
|
|
15
|
+
*
|
|
16
16
|
* @example
|
|
17
17
|
* const sync = new ScopeSync({ projectRoot: '/path/to/project' });
|
|
18
18
|
* await sync.syncUp('auth', ['architecture.md']);
|
|
@@ -24,13 +24,13 @@ class ScopeSync {
|
|
|
24
24
|
this.outputPath = path.join(this.projectRoot, this.outputBase);
|
|
25
25
|
this.sharedPath = path.join(this.outputPath, '_shared');
|
|
26
26
|
this.stateLock = new StateLock();
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
// Default patterns for promotable artifacts
|
|
29
29
|
this.promotablePatterns = options.promotablePatterns || [
|
|
30
30
|
'architecture/*.md',
|
|
31
31
|
'contracts/*.md',
|
|
32
32
|
'principles/*.md',
|
|
33
|
-
'project-context.md'
|
|
33
|
+
'project-context.md',
|
|
34
34
|
];
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -74,7 +74,7 @@ class ScopeSync {
|
|
|
74
74
|
*/
|
|
75
75
|
async loadSyncMeta(scopeId) {
|
|
76
76
|
const metaPath = this.getSyncMetaPath(scopeId);
|
|
77
|
-
|
|
77
|
+
|
|
78
78
|
try {
|
|
79
79
|
if (await fs.pathExists(metaPath)) {
|
|
80
80
|
const content = await fs.readFile(metaPath, 'utf8');
|
|
@@ -89,7 +89,7 @@ class ScopeSync {
|
|
|
89
89
|
lastSyncUp: null,
|
|
90
90
|
lastSyncDown: null,
|
|
91
91
|
promotedFiles: {},
|
|
92
|
-
pulledFiles: {}
|
|
92
|
+
pulledFiles: {},
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -117,14 +117,14 @@ class ScopeSync {
|
|
|
117
117
|
promoted: [],
|
|
118
118
|
conflicts: [],
|
|
119
119
|
errors: [],
|
|
120
|
-
skipped: []
|
|
120
|
+
skipped: [],
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
try {
|
|
124
124
|
const scopePath = path.join(this.outputPath, scopeId);
|
|
125
|
-
|
|
125
|
+
|
|
126
126
|
// Verify scope exists
|
|
127
|
-
if (!await fs.pathExists(scopePath)) {
|
|
127
|
+
if (!(await fs.pathExists(scopePath))) {
|
|
128
128
|
throw new Error(`Scope '${scopeId}' does not exist`);
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -133,10 +133,10 @@ class ScopeSync {
|
|
|
133
133
|
|
|
134
134
|
// Determine files to promote
|
|
135
135
|
let filesToPromote = [];
|
|
136
|
-
|
|
136
|
+
|
|
137
137
|
if (files && files.length > 0) {
|
|
138
138
|
// Use specified files
|
|
139
|
-
filesToPromote = files.map(f => path.isAbsolute(f) ? f : path.join(scopePath, f));
|
|
139
|
+
filesToPromote = files.map((f) => (path.isAbsolute(f) ? f : path.join(scopePath, f)));
|
|
140
140
|
} else {
|
|
141
141
|
// Find promotable files using patterns
|
|
142
142
|
filesToPromote = await this.findPromotableFiles(scopePath);
|
|
@@ -146,7 +146,7 @@ class ScopeSync {
|
|
|
146
146
|
for (const sourceFile of filesToPromote) {
|
|
147
147
|
try {
|
|
148
148
|
// Verify file exists
|
|
149
|
-
if (!await fs.pathExists(sourceFile)) {
|
|
149
|
+
if (!(await fs.pathExists(sourceFile))) {
|
|
150
150
|
result.skipped.push({ file: sourceFile, reason: 'File not found' });
|
|
151
151
|
continue;
|
|
152
152
|
}
|
|
@@ -156,7 +156,7 @@ class ScopeSync {
|
|
|
156
156
|
const targetPath = path.join(this.sharedPath, scopeId, relativePath);
|
|
157
157
|
|
|
158
158
|
// Check for conflicts
|
|
159
|
-
if (await fs.pathExists(targetPath) && !options.force) {
|
|
159
|
+
if ((await fs.pathExists(targetPath)) && !options.force) {
|
|
160
160
|
const sourceHash = await this.computeHash(sourceFile);
|
|
161
161
|
const targetHash = await this.computeHash(targetPath);
|
|
162
162
|
|
|
@@ -165,7 +165,7 @@ class ScopeSync {
|
|
|
165
165
|
file: relativePath,
|
|
166
166
|
source: sourceFile,
|
|
167
167
|
target: targetPath,
|
|
168
|
-
resolution: 'manual'
|
|
168
|
+
resolution: 'manual',
|
|
169
169
|
});
|
|
170
170
|
continue;
|
|
171
171
|
}
|
|
@@ -184,7 +184,7 @@ class ScopeSync {
|
|
|
184
184
|
promoted_at: new Date().toISOString(),
|
|
185
185
|
original_path: relativePath,
|
|
186
186
|
original_hash: await this.computeHash(sourceFile),
|
|
187
|
-
version: (meta.promotedFiles[relativePath]?.version || 0) + 1
|
|
187
|
+
version: (meta.promotedFiles[relativePath]?.version || 0) + 1,
|
|
188
188
|
};
|
|
189
189
|
await fs.writeFile(metaFilePath, yaml.stringify(fileMeta), 'utf8');
|
|
190
190
|
|
|
@@ -192,18 +192,17 @@ class ScopeSync {
|
|
|
192
192
|
meta.promotedFiles[relativePath] = {
|
|
193
193
|
promotedAt: fileMeta.promoted_at,
|
|
194
194
|
hash: fileMeta.original_hash,
|
|
195
|
-
version: fileMeta.version
|
|
195
|
+
version: fileMeta.version,
|
|
196
196
|
};
|
|
197
197
|
|
|
198
198
|
result.promoted.push({
|
|
199
199
|
file: relativePath,
|
|
200
|
-
target: targetPath
|
|
200
|
+
target: targetPath,
|
|
201
201
|
});
|
|
202
|
-
|
|
203
202
|
} catch (error) {
|
|
204
203
|
result.errors.push({
|
|
205
204
|
file: sourceFile,
|
|
206
|
-
error: error.message
|
|
205
|
+
error: error.message,
|
|
207
206
|
});
|
|
208
207
|
}
|
|
209
208
|
}
|
|
@@ -213,7 +212,6 @@ class ScopeSync {
|
|
|
213
212
|
await this.saveSyncMeta(scopeId, meta);
|
|
214
213
|
|
|
215
214
|
result.success = result.errors.length === 0;
|
|
216
|
-
|
|
217
215
|
} catch (error) {
|
|
218
216
|
result.success = false;
|
|
219
217
|
result.errors.push({ error: error.message });
|
|
@@ -234,14 +232,14 @@ class ScopeSync {
|
|
|
234
232
|
pulled: [],
|
|
235
233
|
conflicts: [],
|
|
236
234
|
errors: [],
|
|
237
|
-
upToDate: []
|
|
235
|
+
upToDate: [],
|
|
238
236
|
};
|
|
239
237
|
|
|
240
238
|
try {
|
|
241
239
|
const scopePath = path.join(this.outputPath, scopeId);
|
|
242
|
-
|
|
240
|
+
|
|
243
241
|
// Verify scope exists
|
|
244
|
-
if (!await fs.pathExists(scopePath)) {
|
|
242
|
+
if (!(await fs.pathExists(scopePath))) {
|
|
245
243
|
throw new Error(`Scope '${scopeId}' does not exist`);
|
|
246
244
|
}
|
|
247
245
|
|
|
@@ -250,10 +248,10 @@ class ScopeSync {
|
|
|
250
248
|
|
|
251
249
|
// Find all shared files from any scope
|
|
252
250
|
const sharedScopeDirs = await fs.readdir(this.sharedPath, { withFileTypes: true });
|
|
253
|
-
|
|
251
|
+
|
|
254
252
|
for (const dir of sharedScopeDirs) {
|
|
255
253
|
if (!dir.isDirectory() || dir.name.startsWith('.')) continue;
|
|
256
|
-
|
|
254
|
+
|
|
257
255
|
const sharedScopePath = path.join(this.sharedPath, dir.name);
|
|
258
256
|
const files = await this.getAllFiles(sharedScopePath);
|
|
259
257
|
|
|
@@ -264,7 +262,7 @@ class ScopeSync {
|
|
|
264
262
|
try {
|
|
265
263
|
const relativePath = path.relative(sharedScopePath, sharedFile);
|
|
266
264
|
const targetPath = path.join(scopePath, 'shared', dir.name, relativePath);
|
|
267
|
-
|
|
265
|
+
|
|
268
266
|
// Load shared file metadata
|
|
269
267
|
const metaFilePath = `${sharedFile}.meta`;
|
|
270
268
|
let fileMeta = null;
|
|
@@ -281,7 +279,7 @@ class ScopeSync {
|
|
|
281
279
|
}
|
|
282
280
|
|
|
283
281
|
// Check for local conflicts
|
|
284
|
-
if (await fs.pathExists(targetPath) && !options.force) {
|
|
282
|
+
if ((await fs.pathExists(targetPath)) && !options.force) {
|
|
285
283
|
const localHash = await this.computeHash(targetPath);
|
|
286
284
|
const sharedHash = await this.computeHash(sharedFile);
|
|
287
285
|
|
|
@@ -294,7 +292,7 @@ class ScopeSync {
|
|
|
294
292
|
scope: dir.name,
|
|
295
293
|
local: targetPath,
|
|
296
294
|
shared: sharedFile,
|
|
297
|
-
resolution: options.resolution || 'prompt'
|
|
295
|
+
resolution: options.resolution || 'prompt',
|
|
298
296
|
});
|
|
299
297
|
continue;
|
|
300
298
|
}
|
|
@@ -311,19 +309,18 @@ class ScopeSync {
|
|
|
311
309
|
meta.pulledFiles[`${dir.name}/${relativePath}`] = {
|
|
312
310
|
pulledAt: new Date().toISOString(),
|
|
313
311
|
version: fileMeta?.version || 1,
|
|
314
|
-
hash: await this.computeHash(targetPath)
|
|
312
|
+
hash: await this.computeHash(targetPath),
|
|
315
313
|
};
|
|
316
314
|
|
|
317
315
|
result.pulled.push({
|
|
318
316
|
file: relativePath,
|
|
319
317
|
scope: dir.name,
|
|
320
|
-
target: targetPath
|
|
318
|
+
target: targetPath,
|
|
321
319
|
});
|
|
322
|
-
|
|
323
320
|
} catch (error) {
|
|
324
321
|
result.errors.push({
|
|
325
322
|
file: sharedFile,
|
|
326
|
-
error: error.message
|
|
323
|
+
error: error.message,
|
|
327
324
|
});
|
|
328
325
|
}
|
|
329
326
|
}
|
|
@@ -334,7 +331,6 @@ class ScopeSync {
|
|
|
334
331
|
await this.saveSyncMeta(scopeId, meta);
|
|
335
332
|
|
|
336
333
|
result.success = result.errors.length === 0;
|
|
337
|
-
|
|
338
334
|
} catch (error) {
|
|
339
335
|
result.success = false;
|
|
340
336
|
result.errors.push({ error: error.message });
|
|
@@ -350,18 +346,18 @@ class ScopeSync {
|
|
|
350
346
|
*/
|
|
351
347
|
async findPromotableFiles(scopePath) {
|
|
352
348
|
const files = [];
|
|
353
|
-
|
|
349
|
+
|
|
354
350
|
for (const pattern of this.promotablePatterns) {
|
|
355
351
|
// Simple glob-like matching
|
|
356
352
|
const parts = pattern.split('/');
|
|
357
353
|
const dir = parts.slice(0, -1).join('/');
|
|
358
354
|
const filePattern = parts.at(-1);
|
|
359
|
-
|
|
355
|
+
|
|
360
356
|
const searchDir = path.join(scopePath, dir);
|
|
361
|
-
|
|
357
|
+
|
|
362
358
|
if (await fs.pathExists(searchDir)) {
|
|
363
359
|
const entries = await fs.readdir(searchDir, { withFileTypes: true });
|
|
364
|
-
|
|
360
|
+
|
|
365
361
|
for (const entry of entries) {
|
|
366
362
|
if (entry.isFile() && this.matchPattern(entry.name, filePattern)) {
|
|
367
363
|
files.push(path.join(searchDir, entry.name));
|
|
@@ -380,9 +376,7 @@ class ScopeSync {
|
|
|
380
376
|
* @returns {boolean} True if matches
|
|
381
377
|
*/
|
|
382
378
|
matchPattern(filename, pattern) {
|
|
383
|
-
const regexPattern = pattern
|
|
384
|
-
.replaceAll('.', String.raw`\.`)
|
|
385
|
-
.replaceAll('*', '.*');
|
|
379
|
+
const regexPattern = pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*');
|
|
386
380
|
const regex = new RegExp(`^${regexPattern}$`);
|
|
387
381
|
return regex.test(filename);
|
|
388
382
|
}
|
|
@@ -394,13 +388,13 @@ class ScopeSync {
|
|
|
394
388
|
*/
|
|
395
389
|
async getAllFiles(dir) {
|
|
396
390
|
const files = [];
|
|
397
|
-
|
|
391
|
+
|
|
398
392
|
async function walk(currentDir) {
|
|
399
393
|
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
400
|
-
|
|
394
|
+
|
|
401
395
|
for (const entry of entries) {
|
|
402
396
|
const fullPath = path.join(currentDir, entry.name);
|
|
403
|
-
|
|
397
|
+
|
|
404
398
|
if (entry.isDirectory()) {
|
|
405
399
|
await walk(fullPath);
|
|
406
400
|
} else {
|
|
@@ -408,7 +402,7 @@ class ScopeSync {
|
|
|
408
402
|
}
|
|
409
403
|
}
|
|
410
404
|
}
|
|
411
|
-
|
|
405
|
+
|
|
412
406
|
await walk(dir);
|
|
413
407
|
return files;
|
|
414
408
|
}
|
|
@@ -420,14 +414,14 @@ class ScopeSync {
|
|
|
420
414
|
*/
|
|
421
415
|
async getSyncStatus(scopeId) {
|
|
422
416
|
const meta = await this.loadSyncMeta(scopeId);
|
|
423
|
-
|
|
417
|
+
|
|
424
418
|
return {
|
|
425
419
|
lastSyncUp: meta.lastSyncUp,
|
|
426
420
|
lastSyncDown: meta.lastSyncDown,
|
|
427
421
|
promotedCount: Object.keys(meta.promotedFiles).length,
|
|
428
422
|
pulledCount: Object.keys(meta.pulledFiles).length,
|
|
429
423
|
promotedFiles: Object.keys(meta.promotedFiles),
|
|
430
|
-
pulledFiles: Object.keys(meta.pulledFiles)
|
|
424
|
+
pulledFiles: Object.keys(meta.pulledFiles),
|
|
431
425
|
};
|
|
432
426
|
}
|
|
433
427
|
|
|
@@ -452,7 +446,7 @@ class ScopeSync {
|
|
|
452
446
|
case 'keep-shared': {
|
|
453
447
|
// Overwrite with shared
|
|
454
448
|
await fs.copy(conflict.shared || conflict.source, conflict.local || conflict.target, {
|
|
455
|
-
overwrite: true
|
|
449
|
+
overwrite: true,
|
|
456
450
|
});
|
|
457
451
|
result.action = 'kept-shared';
|
|
458
452
|
result.success = true;
|
|
@@ -464,7 +458,7 @@ class ScopeSync {
|
|
|
464
458
|
const backupPath = `${conflict.local || conflict.target}.backup.${Date.now()}`;
|
|
465
459
|
await fs.copy(conflict.local || conflict.target, backupPath);
|
|
466
460
|
await fs.copy(conflict.shared || conflict.source, conflict.local || conflict.target, {
|
|
467
|
-
overwrite: true
|
|
461
|
+
overwrite: true,
|
|
468
462
|
});
|
|
469
463
|
result.action = 'backed-up-and-updated';
|
|
470
464
|
result.backupPath = backupPath;
|
|
@@ -7,13 +7,13 @@ const yaml = require('yaml');
|
|
|
7
7
|
class ScopeValidator {
|
|
8
8
|
constructor() {
|
|
9
9
|
// Scope ID validation pattern: lowercase alphanumeric + hyphens, 2-50 chars
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
// Reserved scope IDs that cannot be used
|
|
12
12
|
this.reservedIds = ['_shared', '_events', '_config', 'global', 'default'];
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
// Valid isolation modes
|
|
15
15
|
this.validIsolationModes = ['strict', 'warn', 'permissive'];
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
// Valid scope statuses
|
|
18
18
|
this.validStatuses = ['active', 'archived'];
|
|
19
19
|
}
|
|
@@ -38,7 +38,8 @@ class ScopeValidator {
|
|
|
38
38
|
if (!this.scopeIdPattern.test(scopeId)) {
|
|
39
39
|
return {
|
|
40
40
|
valid: false,
|
|
41
|
-
error:
|
|
41
|
+
error:
|
|
42
|
+
'Scope ID must start with lowercase letter, contain only lowercase letters, numbers, and hyphens, and end with letter or number',
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -46,7 +47,7 @@ class ScopeValidator {
|
|
|
46
47
|
if (this.reservedIds.includes(scopeId)) {
|
|
47
48
|
return {
|
|
48
49
|
valid: false,
|
|
49
|
-
error: `Scope ID '${scopeId}' is reserved and cannot be used
|
|
50
|
+
error: `Scope ID '${scopeId}' is reserved and cannot be used`,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -132,8 +133,8 @@ class ScopeValidator {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
if (scope._meta.artifact_count !== undefined && (!Number.isInteger(scope._meta.artifact_count) || scope._meta.artifact_count < 0)) {
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
errors.push('_meta.artifact_count must be a non-negative integer');
|
|
137
|
+
}
|
|
137
138
|
} else {
|
|
138
139
|
errors.push('Scope _meta must be an object');
|
|
139
140
|
}
|
|
@@ -141,7 +142,7 @@ class ScopeValidator {
|
|
|
141
142
|
|
|
142
143
|
return {
|
|
143
144
|
valid: errors.length === 0,
|
|
144
|
-
errors
|
|
145
|
+
errors,
|
|
145
146
|
};
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -172,13 +173,7 @@ class ScopeValidator {
|
|
|
172
173
|
// Recursively check this dependency's dependencies
|
|
173
174
|
const depScope = allScopes[dep];
|
|
174
175
|
if (depScope && depScope.dependencies) {
|
|
175
|
-
const result = this.detectCircularDependencies(
|
|
176
|
-
dep,
|
|
177
|
-
depScope.dependencies,
|
|
178
|
-
allScopes,
|
|
179
|
-
new Set(visited),
|
|
180
|
-
[...chain]
|
|
181
|
-
);
|
|
176
|
+
const result = this.detectCircularDependencies(dep, depScope.dependencies, allScopes, new Set(visited), [...chain]);
|
|
182
177
|
if (result.hasCircular) {
|
|
183
178
|
return result;
|
|
184
179
|
}
|
|
@@ -249,7 +244,7 @@ class ScopeValidator {
|
|
|
249
244
|
|
|
250
245
|
return {
|
|
251
246
|
valid: errors.length === 0,
|
|
252
|
-
errors
|
|
247
|
+
errors,
|
|
253
248
|
};
|
|
254
249
|
}
|
|
255
250
|
|
|
@@ -262,17 +257,17 @@ class ScopeValidator {
|
|
|
262
257
|
try {
|
|
263
258
|
const config = yaml.parse(yamlContent);
|
|
264
259
|
const validation = this.validateConfig(config);
|
|
265
|
-
|
|
260
|
+
|
|
266
261
|
return {
|
|
267
262
|
valid: validation.valid,
|
|
268
263
|
errors: validation.errors,
|
|
269
|
-
config: validation.valid ? config : null
|
|
264
|
+
config: validation.valid ? config : null,
|
|
270
265
|
};
|
|
271
266
|
} catch (error) {
|
|
272
267
|
return {
|
|
273
268
|
valid: false,
|
|
274
269
|
errors: [`Failed to parse YAML: ${error.message}`],
|
|
275
|
-
config: null
|
|
270
|
+
config: null,
|
|
276
271
|
};
|
|
277
272
|
}
|
|
278
273
|
}
|
|
@@ -288,9 +283,9 @@ class ScopeValidator {
|
|
|
288
283
|
allow_adhoc_scopes: true,
|
|
289
284
|
isolation_mode: 'strict',
|
|
290
285
|
default_output_base: '_bmad-output',
|
|
291
|
-
default_shared_path: '_bmad-output/_shared'
|
|
286
|
+
default_shared_path: '_bmad-output/_shared',
|
|
292
287
|
},
|
|
293
|
-
scopes: {}
|
|
288
|
+
scopes: {},
|
|
294
289
|
};
|
|
295
290
|
}
|
|
296
291
|
scopeIdPattern = /^[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
@@ -5,11 +5,11 @@ const yaml = require('yaml');
|
|
|
5
5
|
/**
|
|
6
6
|
* File locking utilities for safe concurrent access to state files
|
|
7
7
|
* Uses file-based locking for cross-process synchronization
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
9
|
* @class StateLock
|
|
10
10
|
* @requires fs-extra
|
|
11
11
|
* @requires yaml
|
|
12
|
-
*
|
|
12
|
+
*
|
|
13
13
|
* @example
|
|
14
14
|
* const lock = new StateLock();
|
|
15
15
|
* const result = await lock.withLock('/path/to/state.yaml', async () => {
|
|
@@ -57,25 +57,22 @@ class StateLock {
|
|
|
57
57
|
*/
|
|
58
58
|
async acquireLock(filePath) {
|
|
59
59
|
const lockPath = this.getLockPath(filePath);
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
for (let attempt = 0; attempt < this.retries; attempt++) {
|
|
62
62
|
try {
|
|
63
63
|
// Check if lock exists
|
|
64
64
|
const lockExists = await fs.pathExists(lockPath);
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
if (lockExists) {
|
|
67
67
|
// Check if lock is stale
|
|
68
68
|
const isStale = await this.isLockStale(lockPath);
|
|
69
|
-
|
|
69
|
+
|
|
70
70
|
if (isStale) {
|
|
71
71
|
// Remove stale lock
|
|
72
72
|
await fs.remove(lockPath);
|
|
73
73
|
} else {
|
|
74
74
|
// Lock is active, wait and retry
|
|
75
|
-
const waitTime = Math.min(
|
|
76
|
-
this.minTimeout * Math.pow(2, attempt),
|
|
77
|
-
this.maxTimeout
|
|
78
|
-
);
|
|
75
|
+
const waitTime = Math.min(this.minTimeout * Math.pow(2, attempt), this.maxTimeout);
|
|
79
76
|
await this.sleep(waitTime);
|
|
80
77
|
continue;
|
|
81
78
|
}
|
|
@@ -85,22 +82,19 @@ class StateLock {
|
|
|
85
82
|
const lockContent = {
|
|
86
83
|
pid: process.pid,
|
|
87
84
|
hostname: require('node:os').hostname(),
|
|
88
|
-
created: new Date().toISOString()
|
|
85
|
+
created: new Date().toISOString(),
|
|
89
86
|
};
|
|
90
87
|
|
|
91
88
|
// Use exclusive flag for atomic creation
|
|
92
89
|
await fs.writeFile(lockPath, JSON.stringify(lockContent), {
|
|
93
|
-
flag: 'wx' // Exclusive create
|
|
90
|
+
flag: 'wx', // Exclusive create
|
|
94
91
|
});
|
|
95
92
|
|
|
96
93
|
return { success: true, lockPath };
|
|
97
94
|
} catch (error) {
|
|
98
95
|
if (error.code === 'EEXIST') {
|
|
99
96
|
// Lock was created by another process, retry
|
|
100
|
-
const waitTime = Math.min(
|
|
101
|
-
this.minTimeout * Math.pow(2, attempt),
|
|
102
|
-
this.maxTimeout
|
|
103
|
-
);
|
|
97
|
+
const waitTime = Math.min(this.minTimeout * Math.pow(2, attempt), this.maxTimeout);
|
|
104
98
|
await this.sleep(waitTime);
|
|
105
99
|
continue;
|
|
106
100
|
}
|
|
@@ -118,7 +112,7 @@ class StateLock {
|
|
|
118
112
|
*/
|
|
119
113
|
async releaseLock(filePath) {
|
|
120
114
|
const lockPath = this.getLockPath(filePath);
|
|
121
|
-
|
|
115
|
+
|
|
122
116
|
try {
|
|
123
117
|
await fs.remove(lockPath);
|
|
124
118
|
return true;
|
|
@@ -138,7 +132,7 @@ class StateLock {
|
|
|
138
132
|
*/
|
|
139
133
|
async withLock(filePath, operation) {
|
|
140
134
|
const lockResult = await this.acquireLock(filePath);
|
|
141
|
-
|
|
135
|
+
|
|
142
136
|
if (!lockResult.success) {
|
|
143
137
|
throw new Error(`Failed to acquire lock on ${filePath}: ${lockResult.reason}`);
|
|
144
138
|
}
|
|
@@ -159,12 +153,12 @@ class StateLock {
|
|
|
159
153
|
try {
|
|
160
154
|
const content = await fs.readFile(filePath, 'utf8');
|
|
161
155
|
const data = yaml.parse(content);
|
|
162
|
-
|
|
156
|
+
|
|
163
157
|
// Ensure version field exists
|
|
164
158
|
if (!data._version) {
|
|
165
159
|
data._version = 0;
|
|
166
160
|
}
|
|
167
|
-
|
|
161
|
+
|
|
168
162
|
return data;
|
|
169
163
|
} catch (error) {
|
|
170
164
|
if (error.code === 'ENOENT') {
|
|
@@ -183,17 +177,17 @@ class StateLock {
|
|
|
183
177
|
async writeYaml(filePath, data) {
|
|
184
178
|
// Ensure directory exists
|
|
185
179
|
await fs.ensureDir(path.dirname(filePath));
|
|
186
|
-
|
|
180
|
+
|
|
187
181
|
// Update version and timestamp
|
|
188
182
|
const versionedData = {
|
|
189
183
|
...data,
|
|
190
184
|
_version: (data._version || 0) + 1,
|
|
191
|
-
_lastModified: new Date().toISOString()
|
|
185
|
+
_lastModified: new Date().toISOString(),
|
|
192
186
|
};
|
|
193
187
|
|
|
194
188
|
const yamlContent = yaml.stringify(versionedData, { indent: 2 });
|
|
195
189
|
await fs.writeFile(filePath, yamlContent, 'utf8');
|
|
196
|
-
|
|
190
|
+
|
|
197
191
|
return versionedData;
|
|
198
192
|
}
|
|
199
193
|
|
|
@@ -208,17 +202,17 @@ class StateLock {
|
|
|
208
202
|
// Read current data
|
|
209
203
|
const data = await this.readYaml(filePath);
|
|
210
204
|
const currentVersion = data._version || 0;
|
|
211
|
-
|
|
205
|
+
|
|
212
206
|
// Apply modifications
|
|
213
207
|
const modified = await modifier(data);
|
|
214
|
-
|
|
208
|
+
|
|
215
209
|
// Update version
|
|
216
210
|
modified._version = currentVersion + 1;
|
|
217
211
|
modified._lastModified = new Date().toISOString();
|
|
218
|
-
|
|
212
|
+
|
|
219
213
|
// Write back
|
|
220
214
|
await this.writeYaml(filePath, modified);
|
|
221
|
-
|
|
215
|
+
|
|
222
216
|
return modified;
|
|
223
217
|
});
|
|
224
218
|
}
|
|
@@ -233,30 +227,30 @@ class StateLock {
|
|
|
233
227
|
async optimisticUpdate(filePath, expectedVersion, newData) {
|
|
234
228
|
return this.withLock(filePath, async () => {
|
|
235
229
|
const current = await this.readYaml(filePath);
|
|
236
|
-
|
|
230
|
+
|
|
237
231
|
// Check version
|
|
238
232
|
if (current._version !== expectedVersion) {
|
|
239
233
|
return {
|
|
240
234
|
success: false,
|
|
241
235
|
data: current,
|
|
242
236
|
conflict: true,
|
|
243
|
-
message: `Version conflict: expected ${expectedVersion}, found ${current._version}
|
|
237
|
+
message: `Version conflict: expected ${expectedVersion}, found ${current._version}`,
|
|
244
238
|
};
|
|
245
239
|
}
|
|
246
|
-
|
|
240
|
+
|
|
247
241
|
// Update with new version
|
|
248
242
|
const updated = {
|
|
249
243
|
...newData,
|
|
250
244
|
_version: expectedVersion + 1,
|
|
251
|
-
_lastModified: new Date().toISOString()
|
|
245
|
+
_lastModified: new Date().toISOString(),
|
|
252
246
|
};
|
|
253
|
-
|
|
247
|
+
|
|
254
248
|
await this.writeYaml(filePath, updated);
|
|
255
|
-
|
|
249
|
+
|
|
256
250
|
return {
|
|
257
251
|
success: true,
|
|
258
252
|
data: updated,
|
|
259
|
-
conflict: false
|
|
253
|
+
conflict: false,
|
|
260
254
|
};
|
|
261
255
|
});
|
|
262
256
|
}
|
|
@@ -267,7 +261,7 @@ class StateLock {
|
|
|
267
261
|
* @returns {Promise<void>}
|
|
268
262
|
*/
|
|
269
263
|
sleep(ms) {
|
|
270
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
264
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
271
265
|
}
|
|
272
266
|
|
|
273
267
|
/**
|
|
@@ -277,14 +271,14 @@ class StateLock {
|
|
|
277
271
|
*/
|
|
278
272
|
async isLocked(filePath) {
|
|
279
273
|
const lockPath = this.getLockPath(filePath);
|
|
280
|
-
|
|
274
|
+
|
|
281
275
|
try {
|
|
282
276
|
const exists = await fs.pathExists(lockPath);
|
|
283
|
-
|
|
277
|
+
|
|
284
278
|
if (!exists) {
|
|
285
279
|
return false;
|
|
286
280
|
}
|
|
287
|
-
|
|
281
|
+
|
|
288
282
|
// Check if lock is stale
|
|
289
283
|
const isStale = await this.isLockStale(lockPath);
|
|
290
284
|
return !isStale;
|
|
@@ -300,22 +294,22 @@ class StateLock {
|
|
|
300
294
|
*/
|
|
301
295
|
async getLockInfo(filePath) {
|
|
302
296
|
const lockPath = this.getLockPath(filePath);
|
|
303
|
-
|
|
297
|
+
|
|
304
298
|
try {
|
|
305
299
|
const exists = await fs.pathExists(lockPath);
|
|
306
|
-
|
|
300
|
+
|
|
307
301
|
if (!exists) {
|
|
308
302
|
return null;
|
|
309
303
|
}
|
|
310
|
-
|
|
304
|
+
|
|
311
305
|
const content = await fs.readFile(lockPath, 'utf8');
|
|
312
306
|
const info = JSON.parse(content);
|
|
313
307
|
const stat = await fs.stat(lockPath);
|
|
314
|
-
|
|
308
|
+
|
|
315
309
|
return {
|
|
316
310
|
...info,
|
|
317
311
|
age: Date.now() - stat.mtimeMs,
|
|
318
|
-
isStale: Date.now() - stat.mtimeMs > this.staleTimeout
|
|
312
|
+
isStale: Date.now() - stat.mtimeMs > this.staleTimeout,
|
|
319
313
|
};
|
|
320
314
|
} catch {
|
|
321
315
|
return null;
|
|
@@ -329,7 +323,7 @@ class StateLock {
|
|
|
329
323
|
*/
|
|
330
324
|
async forceRelease(filePath) {
|
|
331
325
|
const lockPath = this.getLockPath(filePath);
|
|
332
|
-
|
|
326
|
+
|
|
333
327
|
try {
|
|
334
328
|
await fs.remove(lockPath);
|
|
335
329
|
return true;
|
package/src/core/module.yaml
CHANGED
|
@@ -29,12 +29,12 @@ output_folder:
|
|
|
29
29
|
scope_settings:
|
|
30
30
|
header: "Scope System Settings"
|
|
31
31
|
subheader: "Configure multi-scope artifact isolation"
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
allow_adhoc_scopes:
|
|
34
34
|
prompt: "Allow creating scopes on-demand during workflows?"
|
|
35
35
|
default: true
|
|
36
36
|
result: "{value}"
|
|
37
|
-
|
|
37
|
+
|
|
38
38
|
isolation_mode:
|
|
39
39
|
prompt: "Scope isolation mode"
|
|
40
40
|
default: "strict"
|