switchman-dev 0.1.6 → 0.1.7
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/README.md +171 -4
- package/examples/README.md +28 -0
- package/package.json +1 -1
- package/src/cli/index.js +2801 -332
- package/src/core/ci.js +204 -0
- package/src/core/db.js +822 -26
- package/src/core/enforcement.js +18 -5
- package/src/core/git.js +286 -1
- package/src/core/merge-gate.js +17 -2
- package/src/core/outcome.js +1 -1
- package/src/core/pipeline.js +2399 -59
- package/src/core/planner.js +25 -5
- package/src/core/policy.js +105 -0
- package/src/core/queue.js +643 -27
- package/src/core/semantic.js +71 -5
package/src/core/enforcement.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
2
|
-
import { dirname, join,
|
|
2
|
+
import { dirname, join, posix, relative, resolve } from 'path';
|
|
3
3
|
import { execFileSync, spawnSync } from 'child_process';
|
|
4
4
|
|
|
5
5
|
import {
|
|
@@ -212,12 +212,15 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
function normalizeRepoPath(repoRoot, targetPath) {
|
|
215
|
-
const
|
|
215
|
+
const rawPath = String(targetPath || '').replace(/\\/g, '/').trim();
|
|
216
|
+
const relativePath = posix.normalize(rawPath.replace(/^\.\/+/, ''));
|
|
216
217
|
if (
|
|
217
218
|
relativePath === '' ||
|
|
218
|
-
relativePath.startsWith('..') ||
|
|
219
219
|
relativePath === '.' ||
|
|
220
|
-
relativePath
|
|
220
|
+
relativePath === '..' ||
|
|
221
|
+
relativePath.startsWith('../') ||
|
|
222
|
+
rawPath.startsWith('/') ||
|
|
223
|
+
/^[A-Za-z]:\//.test(rawPath)
|
|
221
224
|
) {
|
|
222
225
|
throw new Error('Target path must point to a file inside the repository.');
|
|
223
226
|
}
|
|
@@ -529,7 +532,17 @@ export function gatewayMovePath(db, repoRoot, { leaseId, sourcePath, destination
|
|
|
529
532
|
}
|
|
530
533
|
|
|
531
534
|
export function gatewayMakeDirectory(db, repoRoot, { leaseId, path: targetPath, worktree = null }) {
|
|
532
|
-
|
|
535
|
+
let normalizedPath;
|
|
536
|
+
try {
|
|
537
|
+
normalizedPath = normalizeRepoPath(repoRoot, targetPath).relativePath.replace(/\/+$/, '');
|
|
538
|
+
} catch {
|
|
539
|
+
return {
|
|
540
|
+
ok: false,
|
|
541
|
+
reason_code: 'policy_exception_required',
|
|
542
|
+
file_path: targetPath,
|
|
543
|
+
lease_id: leaseId,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
533
546
|
const lease = getLease(db, leaseId);
|
|
534
547
|
|
|
535
548
|
if (!lease || lease.status !== 'active') {
|
package/src/core/git.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { execFileSync, execSync, spawnSync } from 'child_process';
|
|
7
|
-
import { existsSync, realpathSync } from 'fs';
|
|
7
|
+
import { existsSync, realpathSync, rmSync, statSync } from 'fs';
|
|
8
8
|
import { join, relative, resolve, basename } from 'path';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
9
10
|
import { filterIgnoredPaths } from './ignore.js';
|
|
10
11
|
|
|
11
12
|
function normalizeFsPath(path) {
|
|
@@ -339,6 +340,289 @@ export function gitMergeBranchInto(repoRoot, baseBranch, topicBranch) {
|
|
|
339
340
|
}
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
export function gitAssessBranchFreshness(repoRoot, baseBranch, topicBranch) {
|
|
344
|
+
const baseCommit = gitRevParse(repoRoot, baseBranch);
|
|
345
|
+
const topicCommit = gitRevParse(repoRoot, topicBranch);
|
|
346
|
+
if (!baseCommit || !topicCommit) {
|
|
347
|
+
return {
|
|
348
|
+
state: 'unknown',
|
|
349
|
+
base_commit: baseCommit || null,
|
|
350
|
+
topic_commit: topicCommit || null,
|
|
351
|
+
merge_base: null,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const mergeBase = execFileSync('git', ['merge-base', baseBranch, topicBranch], {
|
|
357
|
+
cwd: repoRoot,
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
360
|
+
}).trim();
|
|
361
|
+
return {
|
|
362
|
+
state: mergeBase === baseCommit ? 'fresh' : 'behind',
|
|
363
|
+
base_commit: baseCommit,
|
|
364
|
+
topic_commit: topicCommit,
|
|
365
|
+
merge_base: mergeBase,
|
|
366
|
+
};
|
|
367
|
+
} catch {
|
|
368
|
+
return {
|
|
369
|
+
state: 'unknown',
|
|
370
|
+
base_commit: baseCommit,
|
|
371
|
+
topic_commit: topicCommit,
|
|
372
|
+
merge_base: null,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function gitMaterializeIntegrationBranch(repoRoot, {
|
|
378
|
+
branch,
|
|
379
|
+
baseBranch = 'main',
|
|
380
|
+
mergeBranches = [],
|
|
381
|
+
tempWorktreePath = null,
|
|
382
|
+
} = {}) {
|
|
383
|
+
const uniqueBranches = [...new Set(mergeBranches.filter(Boolean))].filter((candidate) => candidate !== branch);
|
|
384
|
+
const resolvedTempWorktreePath = tempWorktreePath || join(tmpdir(), `switchman-landing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
execFileSync('git', ['worktree', 'add', '--detach', resolvedTempWorktreePath, baseBranch], {
|
|
388
|
+
cwd: repoRoot,
|
|
389
|
+
encoding: 'utf8',
|
|
390
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
execFileSync('git', ['checkout', '-B', branch, baseBranch], {
|
|
394
|
+
cwd: resolvedTempWorktreePath,
|
|
395
|
+
encoding: 'utf8',
|
|
396
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
for (const mergeBranch of uniqueBranches) {
|
|
400
|
+
execFileSync('git', ['merge', '--no-ff', '--no-edit', mergeBranch], {
|
|
401
|
+
cwd: resolvedTempWorktreePath,
|
|
402
|
+
encoding: 'utf8',
|
|
403
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
branch,
|
|
409
|
+
base_branch: baseBranch,
|
|
410
|
+
merged_branches: uniqueBranches,
|
|
411
|
+
head_commit: gitRevParse(resolvedTempWorktreePath, 'HEAD'),
|
|
412
|
+
temp_worktree_path: resolvedTempWorktreePath,
|
|
413
|
+
};
|
|
414
|
+
} catch (err) {
|
|
415
|
+
const stderr = String(err?.stderr || '');
|
|
416
|
+
const stdout = String(err?.stdout || '');
|
|
417
|
+
const combinedOutput = `${stdout}${stderr}`.trim();
|
|
418
|
+
const message = String(err?.message || combinedOutput || 'Failed to materialize integration branch.');
|
|
419
|
+
const currentMergeBranch = uniqueBranches.find((candidate) =>
|
|
420
|
+
message.includes(candidate) || combinedOutput.includes(candidate),
|
|
421
|
+
) || null;
|
|
422
|
+
let reasonCode = 'landing_branch_materialization_failed';
|
|
423
|
+
if (/CONFLICT|Automatic merge failed|Merge conflict/i.test(message) || /CONFLICT|Automatic merge failed/i.test(combinedOutput)) {
|
|
424
|
+
reasonCode = 'landing_branch_merge_conflict';
|
|
425
|
+
} else if (/not something we can merge|unknown revision|bad revision|not a valid object name/i.test(message) || /not something we can merge|unknown revision|bad revision|not a valid object name/i.test(combinedOutput)) {
|
|
426
|
+
reasonCode = 'landing_branch_missing_component';
|
|
427
|
+
} else if (message.includes(baseBranch) && /not something we can merge|unknown revision|bad revision|not a valid object name/i.test(message)) {
|
|
428
|
+
reasonCode = 'landing_branch_missing_base';
|
|
429
|
+
}
|
|
430
|
+
const conflictingFiles = reasonCode === 'landing_branch_merge_conflict'
|
|
431
|
+
? execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
432
|
+
cwd: resolvedTempWorktreePath,
|
|
433
|
+
encoding: 'utf8',
|
|
434
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
435
|
+
}).trim().split('\n').filter(Boolean)
|
|
436
|
+
: [];
|
|
437
|
+
try {
|
|
438
|
+
execFileSync('git', ['merge', '--abort'], {
|
|
439
|
+
cwd: resolvedTempWorktreePath,
|
|
440
|
+
encoding: 'utf8',
|
|
441
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
442
|
+
});
|
|
443
|
+
} catch {
|
|
444
|
+
// No active merge to abort.
|
|
445
|
+
}
|
|
446
|
+
const landingError = new Error(message);
|
|
447
|
+
landingError.code = reasonCode;
|
|
448
|
+
landingError.details = {
|
|
449
|
+
branch,
|
|
450
|
+
base_branch: baseBranch,
|
|
451
|
+
merge_branches: uniqueBranches,
|
|
452
|
+
failed_branch: currentMergeBranch,
|
|
453
|
+
conflicting_files: conflictingFiles,
|
|
454
|
+
output: combinedOutput.slice(0, 1000),
|
|
455
|
+
temp_worktree_path: resolvedTempWorktreePath,
|
|
456
|
+
};
|
|
457
|
+
throw landingError;
|
|
458
|
+
} finally {
|
|
459
|
+
try {
|
|
460
|
+
execFileSync('git', ['worktree', 'remove', resolvedTempWorktreePath, '--force'], {
|
|
461
|
+
cwd: repoRoot,
|
|
462
|
+
encoding: 'utf8',
|
|
463
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
464
|
+
});
|
|
465
|
+
} catch {
|
|
466
|
+
rmSync(resolvedTempWorktreePath, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function gitPrepareIntegrationRecoveryWorktree(repoRoot, {
|
|
472
|
+
branch,
|
|
473
|
+
baseBranch = 'main',
|
|
474
|
+
mergeBranches = [],
|
|
475
|
+
recoveryPath,
|
|
476
|
+
} = {}) {
|
|
477
|
+
const uniqueBranches = [...new Set(mergeBranches.filter(Boolean))].filter((candidate) => candidate !== branch);
|
|
478
|
+
if (!recoveryPath) {
|
|
479
|
+
throw new Error('Recovery path is required.');
|
|
480
|
+
}
|
|
481
|
+
if (existsSync(recoveryPath)) {
|
|
482
|
+
throw new Error(`Recovery path already exists: ${recoveryPath}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
execFileSync('git', ['worktree', 'add', '--detach', recoveryPath, baseBranch], {
|
|
486
|
+
cwd: repoRoot,
|
|
487
|
+
encoding: 'utf8',
|
|
488
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
execFileSync('git', ['checkout', '-B', branch, baseBranch], {
|
|
493
|
+
cwd: recoveryPath,
|
|
494
|
+
encoding: 'utf8',
|
|
495
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
for (const mergeBranch of uniqueBranches) {
|
|
499
|
+
try {
|
|
500
|
+
execFileSync('git', ['merge', '--no-ff', '--no-edit', mergeBranch], {
|
|
501
|
+
cwd: recoveryPath,
|
|
502
|
+
encoding: 'utf8',
|
|
503
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
504
|
+
});
|
|
505
|
+
} catch (err) {
|
|
506
|
+
const conflictingFiles = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
507
|
+
cwd: recoveryPath,
|
|
508
|
+
encoding: 'utf8',
|
|
509
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
510
|
+
}).trim().split('\n').filter(Boolean);
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
branch,
|
|
514
|
+
base_branch: baseBranch,
|
|
515
|
+
recovery_path: recoveryPath,
|
|
516
|
+
merged_branches: uniqueBranches,
|
|
517
|
+
failed_branch: mergeBranch,
|
|
518
|
+
conflicting_files: conflictingFiles,
|
|
519
|
+
output: `${String(err.stdout || '')}${String(err.stderr || '')}`.trim().slice(0, 1000),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
ok: true,
|
|
526
|
+
branch,
|
|
527
|
+
base_branch: baseBranch,
|
|
528
|
+
recovery_path: recoveryPath,
|
|
529
|
+
merged_branches: uniqueBranches,
|
|
530
|
+
head_commit: gitRevParse(recoveryPath, 'HEAD'),
|
|
531
|
+
conflicting_files: [],
|
|
532
|
+
failed_branch: null,
|
|
533
|
+
output: '',
|
|
534
|
+
};
|
|
535
|
+
} catch (err) {
|
|
536
|
+
try {
|
|
537
|
+
execFileSync('git', ['worktree', 'remove', recoveryPath, '--force'], {
|
|
538
|
+
cwd: repoRoot,
|
|
539
|
+
encoding: 'utf8',
|
|
540
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
541
|
+
});
|
|
542
|
+
} catch {
|
|
543
|
+
rmSync(recoveryPath, { recursive: true, force: true });
|
|
544
|
+
}
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function gitRemoveWorktree(repoRoot, worktreePath) {
|
|
550
|
+
execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], {
|
|
551
|
+
cwd: repoRoot,
|
|
552
|
+
encoding: 'utf8',
|
|
553
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function gitPruneWorktrees(repoRoot) {
|
|
558
|
+
execFileSync('git', ['worktree', 'prune'], {
|
|
559
|
+
cwd: repoRoot,
|
|
560
|
+
encoding: 'utf8',
|
|
561
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isSwitchmanTempLandingWorktreePath(worktreePath) {
|
|
566
|
+
const resolvedPath = normalizeFsPath(worktreePath || '');
|
|
567
|
+
const tempRoot = normalizeFsPath(tmpdir());
|
|
568
|
+
return resolvedPath.startsWith(`${tempRoot}/`) && basename(resolvedPath).startsWith('switchman-landing-');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function cleanupCrashedLandingTempWorktrees(
|
|
572
|
+
repoRoot,
|
|
573
|
+
{
|
|
574
|
+
olderThanMs = 15 * 60 * 1000,
|
|
575
|
+
now = Date.now(),
|
|
576
|
+
} = {},
|
|
577
|
+
) {
|
|
578
|
+
const actions = [];
|
|
579
|
+
const beforePrune = listGitWorktrees(repoRoot)
|
|
580
|
+
.filter((worktree) => isSwitchmanTempLandingWorktreePath(worktree.path));
|
|
581
|
+
const missingBeforePrune = beforePrune.filter((worktree) => !existsSync(worktree.path));
|
|
582
|
+
|
|
583
|
+
gitPruneWorktrees(repoRoot);
|
|
584
|
+
|
|
585
|
+
const afterPrune = listGitWorktrees(repoRoot)
|
|
586
|
+
.filter((worktree) => isSwitchmanTempLandingWorktreePath(worktree.path));
|
|
587
|
+
const remainingPaths = new Set(afterPrune.map((worktree) => worktree.path));
|
|
588
|
+
|
|
589
|
+
for (const worktree of missingBeforePrune) {
|
|
590
|
+
if (!remainingPaths.has(worktree.path)) {
|
|
591
|
+
actions.push({
|
|
592
|
+
kind: 'stale_temp_worktree_pruned',
|
|
593
|
+
path: worktree.path,
|
|
594
|
+
branch: worktree.branch || null,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
for (const worktree of afterPrune) {
|
|
600
|
+
if (!existsSync(worktree.path)) continue;
|
|
601
|
+
|
|
602
|
+
let ageMs = 0;
|
|
603
|
+
try {
|
|
604
|
+
ageMs = Math.max(0, now - statSync(worktree.path).mtimeMs);
|
|
605
|
+
} catch {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (ageMs < olderThanMs) continue;
|
|
610
|
+
|
|
611
|
+
gitRemoveWorktree(repoRoot, worktree.path);
|
|
612
|
+
actions.push({
|
|
613
|
+
kind: 'stale_temp_worktree_removed',
|
|
614
|
+
path: worktree.path,
|
|
615
|
+
branch: worktree.branch || null,
|
|
616
|
+
age_ms: ageMs,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
repaired: actions.length > 0,
|
|
622
|
+
actions,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
342
626
|
/**
|
|
343
627
|
* Create a new git worktree
|
|
344
628
|
*/
|
|
@@ -348,6 +632,7 @@ export function createGitWorktree(repoRoot, name, branch) {
|
|
|
348
632
|
execSync(`git worktree add -b "${branch}" "${wtPath}"`, {
|
|
349
633
|
cwd: repoRoot,
|
|
350
634
|
encoding: 'utf8',
|
|
635
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
351
636
|
});
|
|
352
637
|
return wtPath;
|
|
353
638
|
}
|
package/src/core/merge-gate.js
CHANGED
|
@@ -225,10 +225,24 @@ function evaluateDependencyInvalidations(db) {
|
|
|
225
225
|
return listDependencyInvalidations(db, { status: 'stale' })
|
|
226
226
|
.map((state) => {
|
|
227
227
|
const affectedTask = getTask(db, state.affected_task_id);
|
|
228
|
-
const
|
|
228
|
+
const details = state.details || {};
|
|
229
|
+
const severity = details.severity || (affectedTask?.status === 'done' ? 'blocked' : 'warn');
|
|
229
230
|
const staleArea = state.reason_type === 'subsystem_overlap'
|
|
230
231
|
? `subsystem:${state.subsystem_tag}`
|
|
232
|
+
: state.reason_type === 'semantic_contract_drift'
|
|
233
|
+
? `contract:${(details.contract_names || []).join('|') || 'unknown'}`
|
|
234
|
+
: state.reason_type === 'semantic_object_overlap'
|
|
235
|
+
? `object:${(details.object_names || []).join('|') || 'unknown'}`
|
|
236
|
+
: state.reason_type === 'shared_module_drift'
|
|
237
|
+
? `module:${(details.module_paths || []).join('|') || 'unknown'}`
|
|
231
238
|
: `${state.source_scope_pattern} ↔ ${state.affected_scope_pattern}`;
|
|
239
|
+
const summary = state.reason_type === 'semantic_contract_drift'
|
|
240
|
+
? `${details.source_task_title || state.source_task_id} changed shared contract ${(details.contract_names || []).join(', ') || 'unknown'}`
|
|
241
|
+
: state.reason_type === 'semantic_object_overlap'
|
|
242
|
+
? `${details.source_task_title || state.source_task_id} changed shared exported object ${(details.object_names || []).join(', ') || 'unknown'}`
|
|
243
|
+
: state.reason_type === 'shared_module_drift'
|
|
244
|
+
? `${details.source_task_title || state.source_task_id} changed shared module ${(details.module_paths || []).join(', ') || 'unknown'} used by ${(details.dependent_files || []).join(', ') || state.affected_task_id}`
|
|
245
|
+
: `${affectedTask?.title || state.affected_task_id} is stale because ${details?.source_task_title || state.source_task_id} changed shared ${staleArea}`;
|
|
232
246
|
return {
|
|
233
247
|
source_lease_id: state.source_lease_id,
|
|
234
248
|
source_task_id: state.source_task_id,
|
|
@@ -243,9 +257,10 @@ function evaluateDependencyInvalidations(db) {
|
|
|
243
257
|
subsystem_tag: state.subsystem_tag,
|
|
244
258
|
source_scope_pattern: state.source_scope_pattern,
|
|
245
259
|
affected_scope_pattern: state.affected_scope_pattern,
|
|
246
|
-
summary
|
|
260
|
+
summary,
|
|
247
261
|
stale_area: staleArea,
|
|
248
262
|
created_at: state.created_at,
|
|
263
|
+
details,
|
|
249
264
|
};
|
|
250
265
|
});
|
|
251
266
|
}
|
package/src/core/outcome.js
CHANGED
|
@@ -183,7 +183,7 @@ export function evaluateTaskOutcome(db, repoRoot, { taskId = null, leaseId = nul
|
|
|
183
183
|
};
|
|
184
184
|
|
|
185
185
|
if (execution.leaseId) {
|
|
186
|
-
touchBoundaryValidationState(db, execution.leaseId, 'task_outcome_accepted');
|
|
186
|
+
touchBoundaryValidationState(db, execution.leaseId, 'task_outcome_accepted', { changed_files: changedFiles });
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
return result;
|