@adhdev/daemon-core 0.9.82-rc.522 → 0.9.82-rc.524
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/commands/router-refine.d.ts +86 -5
- package/dist/commands/router.d.ts +9 -0
- package/dist/git/git-status.d.ts +14 -1
- package/dist/index.js +641 -159
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +641 -159
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-queue-assignment.d.ts +13 -0
- package/dist/mesh/mesh-refine-gates.d.ts +5 -0
- package/dist/mesh/refine-config.d.ts +36 -0
- package/package.json +3 -3
- package/src/commands/router-refine.ts +756 -164
- package/src/commands/router.ts +9 -0
- package/src/git/git-status.ts +38 -6
- package/src/mesh/mesh-queue-assignment.ts +64 -2
- package/src/mesh/mesh-reconcile-loop.ts +18 -1
- package/src/mesh/mesh-refine-gates.ts +49 -1
- package/src/mesh/refine-config.ts +44 -0
package/src/commands/router.ts
CHANGED
|
@@ -316,6 +316,15 @@ export class DaemonCommandRouter {
|
|
|
316
316
|
runningRefineBatchJobs = new Map<string, MeshRefineBatchJobHandle>();
|
|
317
317
|
/** Terminal async batch Refinery jobs preserve the last batch outcome for late readers. */
|
|
318
318
|
terminalRefineBatchJobs = new Map<string, MeshRefineBatchTerminalJob>();
|
|
319
|
+
/**
|
|
320
|
+
* DS2: in-process refinement leases keyed by `${repoRoot}::${baseBranch}`. Serialize
|
|
321
|
+
* the base-mutating window (candidate-SHA pin → merge → push) of concurrent single-node
|
|
322
|
+
* refines that target the SAME base branch in the same repo, so two refines cannot both
|
|
323
|
+
* validate against one baseHead and then race their merges (the base-movement race). The
|
|
324
|
+
* batch path is already sequential, so this only matters for overlapping single-node
|
|
325
|
+
* async jobs. Value = the meshId:nodeId job key holding the lease (for diagnostics).
|
|
326
|
+
*/
|
|
327
|
+
refineBaseLeases = new Map<string, string>();
|
|
319
328
|
|
|
320
329
|
constructor(deps: CommandRouterDeps) {
|
|
321
330
|
this.deps = deps;
|
package/src/git/git-status.ts
CHANGED
|
@@ -443,6 +443,29 @@ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy):
|
|
|
443
443
|
export interface ChangedPackageClassification {
|
|
444
444
|
isDaemonAffecting: boolean;
|
|
445
445
|
affectedPackages: string[];
|
|
446
|
+
/**
|
|
447
|
+
* DOCS-ROOT: three-way change area, refining the binary isDaemonAffecting so a
|
|
448
|
+
* docs-only branch is distinguishable from a code (web) branch:
|
|
449
|
+
* 'daemon' — a daemon-runtime package (or an unknown/ambiguous path) changed;
|
|
450
|
+
* full validation + daemon rebuild/restart required.
|
|
451
|
+
* 'web' — only web-only packages changed; web validation but no daemon restart.
|
|
452
|
+
* 'none' — no package changed at all; every changed file is a benign non-runtime
|
|
453
|
+
* root file (docs/markers). No code validation is meaningful — only an
|
|
454
|
+
* explicit docs-scoped profile (e.g. docs:verify) should run.
|
|
455
|
+
* Derived from the same facts as isDaemonAffecting, so it never contradicts it
|
|
456
|
+
* (changeArea === 'daemon' ⇔ isDaemonAffecting === true).
|
|
457
|
+
*/
|
|
458
|
+
changeArea: ChangeImpactKind;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Derive the three-way {@link ChangeImpactKind} from the binary daemon verdict and the
|
|
463
|
+
* affected-package set, using the exact rule the stale-build warning layer already
|
|
464
|
+
* applies: daemon-affecting → 'daemon'; else any package changed → 'web'; else (only
|
|
465
|
+
* benign non-runtime files, no package) → 'none'.
|
|
466
|
+
*/
|
|
467
|
+
function deriveChangeArea(isDaemonAffecting: boolean, affectedPackages: string[]): ChangeImpactKind {
|
|
468
|
+
return isDaemonAffecting ? 'daemon' : affectedPackages.length > 0 ? 'web' : 'none';
|
|
446
469
|
}
|
|
447
470
|
|
|
448
471
|
/**
|
|
@@ -452,7 +475,10 @@ export interface ChangedPackageClassification {
|
|
|
452
475
|
* gitlinks — a bare submodule path (e.g. `oss`) is runtime-ambiguous from the root's
|
|
453
476
|
* point of view, but its *content* diff may be entirely web-only.
|
|
454
477
|
*/
|
|
455
|
-
|
|
478
|
+
// Intermediate verdict: carries the binary daemon signal + ambiguous paths, but NOT
|
|
479
|
+
// the derived `changeArea` — that is computed once at the final classifyChangedPackages
|
|
480
|
+
// boundary (`strip` / the submodule folds) so the file-list bucketer stays area-agnostic.
|
|
481
|
+
interface ChangedFileListClassification extends Omit<ChangedPackageClassification, 'changeArea'> {
|
|
456
482
|
/** Non-package paths that were not recognized as benign root files. */
|
|
457
483
|
ambiguousNonPackageFiles: string[];
|
|
458
484
|
}
|
|
@@ -517,10 +543,11 @@ async function classifyDaemonBuildChange(
|
|
|
517
543
|
.split('\n')
|
|
518
544
|
.map((line) => line.trim())
|
|
519
545
|
.filter(Boolean);
|
|
520
|
-
|
|
546
|
+
const { isDaemonAffecting, affectedPackages } = classifyChangedFileList(files, policy);
|
|
547
|
+
return { isDaemonAffecting, affectedPackages, changeArea: deriveChangeArea(isDaemonAffecting, affectedPackages) };
|
|
521
548
|
} catch {
|
|
522
549
|
// diff probe failed → can't prove web-only; stay conservative.
|
|
523
|
-
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
550
|
+
return { isDaemonAffecting: true, affectedPackages: [], changeArea: 'daemon' };
|
|
524
551
|
}
|
|
525
552
|
}
|
|
526
553
|
|
|
@@ -584,7 +611,8 @@ async function refineVerdictThroughSubmodules(
|
|
|
584
611
|
policy: ResolvedChangeImpactPolicy,
|
|
585
612
|
rootVerdict: ChangedFileListClassification,
|
|
586
613
|
): Promise<ChangedPackageClassification> {
|
|
587
|
-
const strip = ({ isDaemonAffecting, affectedPackages }:
|
|
614
|
+
const strip = ({ isDaemonAffecting, affectedPackages }: ChangedFileListClassification): ChangedPackageClassification =>
|
|
615
|
+
({ isDaemonAffecting, affectedPackages, changeArea: deriveChangeArea(isDaemonAffecting, affectedPackages) });
|
|
588
616
|
const ambiguous = rootVerdict.ambiguousNonPackageFiles;
|
|
589
617
|
// Fast path: nothing to descend into, or the root is daemon-affecting for a reason
|
|
590
618
|
// other than an ambiguous path (an unknown/daemon package). Submodule descent only
|
|
@@ -628,9 +656,11 @@ async function refineVerdictThroughSubmodules(
|
|
|
628
656
|
if (subVerdict.isDaemonAffecting) {
|
|
629
657
|
// The submodule content really does touch daemon runtime → keep daemon-affecting,
|
|
630
658
|
// surfacing the submodule packages so the reason is visible.
|
|
659
|
+
const affectedPackages = [...new Set([...rootVerdict.affectedPackages, ...subVerdict.affectedPackages])].sort();
|
|
631
660
|
return {
|
|
632
661
|
isDaemonAffecting: true,
|
|
633
|
-
affectedPackages
|
|
662
|
+
affectedPackages,
|
|
663
|
+
changeArea: deriveChangeArea(true, affectedPackages),
|
|
634
664
|
};
|
|
635
665
|
}
|
|
636
666
|
submoduleAffectedPackages.push(...subVerdict.affectedPackages);
|
|
@@ -642,9 +672,11 @@ async function refineVerdictThroughSubmodules(
|
|
|
642
672
|
const rootPackagesBenign = rootVerdict.affectedPackages.every(
|
|
643
673
|
(p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p),
|
|
644
674
|
);
|
|
675
|
+
const affectedPackages = [...new Set([...rootVerdict.affectedPackages, ...submoduleAffectedPackages])].sort();
|
|
645
676
|
return {
|
|
646
677
|
isDaemonAffecting: !rootPackagesBenign,
|
|
647
|
-
affectedPackages
|
|
678
|
+
affectedPackages,
|
|
679
|
+
changeArea: deriveChangeArea(!rootPackagesBenign, affectedPackages),
|
|
648
680
|
};
|
|
649
681
|
}
|
|
650
682
|
|
|
@@ -15,12 +15,12 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
|
|
|
15
15
|
import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
|
|
16
16
|
import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
|
|
17
17
|
import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
18
|
-
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
|
|
18
|
+
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
|
|
19
19
|
import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
|
|
20
20
|
import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
|
|
21
21
|
import { readNonEmptyString } from './mesh-events-utils.js';
|
|
22
22
|
import { readMeshNodeDaemonId, isMeshNodeHealthLaunchable } from './mesh-node-identity.js';
|
|
23
|
-
import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent } from './mesh-events-pending.js';
|
|
23
|
+
import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
|
|
24
24
|
import { isWorktreeBootstrapStaleRunning, shouldDeferDispatchForBootstrap } from './worktree-bootstrap-config.js';
|
|
25
25
|
import { isWithinCloneBootstrapGrace } from './mesh-clone-grace.js';
|
|
26
26
|
import { beginTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
|
|
@@ -2746,6 +2746,68 @@ export async function runContinuousAutoFastForwardScan(components: DaemonCompone
|
|
|
2746
2746
|
}
|
|
2747
2747
|
}
|
|
2748
2748
|
|
|
2749
|
+
/**
|
|
2750
|
+
* DS3: drain and act on `coordinator_catchup` markers queued by a remote node's Refinery
|
|
2751
|
+
* after it pushed the base branch to origin. The originating coordinator is THIS daemon;
|
|
2752
|
+
* its local base checkout is now behind origin. Bring it up to date with a guarded ff-only
|
|
2753
|
+
* merge — but ONLY when the coordinator base node has no active mesh work (busy → leave the
|
|
2754
|
+
* marker for the next idle tick) and fastForwardMeshNode's own clean/ahead=0/behind>0 gate
|
|
2755
|
+
* is satisfied (ahead/diverged/dirty → it returns a structured block, never a rebase).
|
|
2756
|
+
*
|
|
2757
|
+
* These markers are drained on a DEDICATED event-name filter so they never reach the
|
|
2758
|
+
* coordinator chat-injection path (they are actions, not messages). A busy/blocked node
|
|
2759
|
+
* re-queues the marker so a later idle tick retries; a successful/no-op ff consumes it.
|
|
2760
|
+
*/
|
|
2761
|
+
export async function runPendingCoordinatorCatchupScan(components: DaemonComponents, mesh: any): Promise<void> {
|
|
2762
|
+
const meshId = readNonEmptyString(mesh?.id);
|
|
2763
|
+
if (!meshId) return;
|
|
2764
|
+
const localIds = expandDaemonIdForms([
|
|
2765
|
+
readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId),
|
|
2766
|
+
readNonEmptyString(loadConfig().machineId),
|
|
2767
|
+
]);
|
|
2768
|
+
let markers: Awaited<ReturnType<typeof drainPendingMeshCoordinatorEvents>> = [];
|
|
2769
|
+
try {
|
|
2770
|
+
markers = drainPendingMeshCoordinatorEvents(
|
|
2771
|
+
meshId,
|
|
2772
|
+
localIds.length > 0 ? localIds : undefined,
|
|
2773
|
+
{ onlyEvents: new Set(['coordinator_catchup']) },
|
|
2774
|
+
);
|
|
2775
|
+
} catch (e: any) {
|
|
2776
|
+
LOG.warn('MeshReconcile', `Coordinator-catchup drain failed for mesh ${meshId}: ${e?.message || e}`);
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
if (markers.length === 0) return;
|
|
2780
|
+
const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
|
|
2781
|
+
for (const marker of markers) {
|
|
2782
|
+
const meta = (marker.metadataEvent || {}) as Record<string, unknown>;
|
|
2783
|
+
const nodeId = readNonEmptyString(marker.nodeId) || readNonEmptyString(meta.nodeId as string);
|
|
2784
|
+
const workspace = readNonEmptyString(marker.workspace) || readNonEmptyString(meta.workspace as string);
|
|
2785
|
+
const baseBranch = readNonEmptyString(meta.baseBranch as string);
|
|
2786
|
+
if (!workspace) continue;
|
|
2787
|
+
// Busy node → re-queue and defer to the next idle tick (never advance a base a
|
|
2788
|
+
// session is actively working on).
|
|
2789
|
+
if (nodeId && nodeHasActiveMeshWork(components, meshId, nodeId)) {
|
|
2790
|
+
try { queuePendingMeshCoordinatorEvent(marker); } catch { /* best-effort re-queue */ }
|
|
2791
|
+
continue;
|
|
2792
|
+
}
|
|
2793
|
+
try {
|
|
2794
|
+
const ff = await fastForwardMeshNode({
|
|
2795
|
+
meshId,
|
|
2796
|
+
...(nodeId ? { nodeId } : {}),
|
|
2797
|
+
workspace,
|
|
2798
|
+
...(baseBranch ? { branch: baseBranch } : {}),
|
|
2799
|
+
mode: 'merge',
|
|
2800
|
+
execute: true,
|
|
2801
|
+
trigger: 'refine_post_push_catchup',
|
|
2802
|
+
allowAutoPublishSubmoduleMainCommits: mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true,
|
|
2803
|
+
});
|
|
2804
|
+
LOG.info('MeshReconcile', `Coordinator catch-up ff for ${meshId}/${nodeId || workspace}: ${ff.code} (executed=${ff.executed})`);
|
|
2805
|
+
} catch (e: any) {
|
|
2806
|
+
LOG.warn('MeshReconcile', `Coordinator catch-up ff failed for ${meshId}/${nodeId || workspace}: ${e?.message || e}`);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2749
2811
|
export function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
|
|
2750
2812
|
meshId: string;
|
|
2751
2813
|
nodeId: string;
|
|
@@ -61,7 +61,7 @@ import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage }
|
|
|
61
61
|
import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
|
|
62
62
|
import { expandDaemonIdForms, daemonIdsEquivalent, sessionIdsEquivalent, meshNodeIdMatches, withStatusProbeMarker } from '@adhdev/mesh-shared';
|
|
63
63
|
import { getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
|
|
64
|
-
import { resolveSessionBusyVerdict, runContinuousAutoFastForwardScan } from './mesh-queue-assignment.js';
|
|
64
|
+
import { resolveSessionBusyVerdict, runContinuousAutoFastForwardScan, runPendingCoordinatorCatchupScan } from './mesh-queue-assignment.js';
|
|
65
65
|
import { readLedgerEntries } from './mesh-ledger.js';
|
|
66
66
|
import type { MeshLedgerEntry } from './mesh-ledger.js';
|
|
67
67
|
import { findTerminalLedgerEvidenceForTask } from './mesh-events-stale.js';
|
|
@@ -1082,6 +1082,23 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
1082
1082
|
}
|
|
1083
1083
|
}
|
|
1084
1084
|
|
|
1085
|
+
// ── PHASE 2.6: DS3 coordinator local catch-up ──────────────────────────────
|
|
1086
|
+
// Drain `coordinator_catchup` markers a remote node's Refinery queued after pushing
|
|
1087
|
+
// the base to origin, and guarded-ff this daemon's own coordinator base checkout up to
|
|
1088
|
+
// the pushed commit (busy → deferred to a later tick; ahead/diverged/dirty → the ff
|
|
1089
|
+
// helper structured-blocks, never rebases). Runs for EVERY mesh this daemon hosts
|
|
1090
|
+
// (not gated on continuous mode) and BEFORE PHASE 3 so a caught-up base is current
|
|
1091
|
+
// before any new task is dispatched. No markers → immediate no-op.
|
|
1092
|
+
for (const mesh of listMeshes()) {
|
|
1093
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
1094
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
1095
|
+
try {
|
|
1096
|
+
await runPendingCoordinatorCatchupScan(components, mesh);
|
|
1097
|
+
} catch (e: any) {
|
|
1098
|
+
LOG.warn('MeshReconcile', `Coordinator catch-up scan failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1085
1102
|
// ── PHASE 2.7: continuous remote auto fast-forward (opt-in, default OFF) ────
|
|
1086
1103
|
// mode:"continuous" + remoteNodes:true only. Catch up an online/clean/behind
|
|
1087
1104
|
// REMOTE base node that emits no fresh idle edge (e.g. a long-idle base node while
|
|
@@ -16,7 +16,7 @@ import { getGitRepoStatus } from '../git/git-status.js';
|
|
|
16
16
|
import type { ChangedPackageClassification } from '../git/git-status.js';
|
|
17
17
|
import * as yaml from 'js-yaml';
|
|
18
18
|
import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
|
|
19
|
-
import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
|
|
19
|
+
import type { MeshRefineValidationCommandPlan, MeshRefineValidationScope } from '../mesh/refine-config.js';
|
|
20
20
|
import { evaluateWorktreeBootstrapState, loadMeshWorktreeBootstrapConfig, runMeshWorktreeBootstrap, resolveSubmoduleDefaultBranch } from '../mesh/worktree-bootstrap-config.js';
|
|
21
21
|
import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
|
|
22
22
|
import { basename as pathBasename, join as pathJoin, resolve as pathResolve } from 'path';
|
|
@@ -85,8 +85,12 @@ type MeshRefineValidationSummary = {
|
|
|
85
85
|
changeImpact?: {
|
|
86
86
|
isDaemonAffecting: boolean;
|
|
87
87
|
affectedPackages: string[];
|
|
88
|
+
/** DOCS-ROOT: three-way change area ('none' | 'web' | 'daemon') when known. */
|
|
89
|
+
changeArea?: MeshRefineValidationScope;
|
|
88
90
|
/** displayCommands skipped because the daemon scope is unaffected. */
|
|
89
91
|
skippedDaemonCommands?: string[];
|
|
92
|
+
/** DOCS-ROOT: displayCommands skipped because the change-area scope excluded them. */
|
|
93
|
+
skippedScopeCommands?: string[];
|
|
90
94
|
};
|
|
91
95
|
};
|
|
92
96
|
|
|
@@ -1474,6 +1478,9 @@ export function buildMeshRefineValidationPlan(mesh: any, workspace: string): Rec
|
|
|
1474
1478
|
source: command.source,
|
|
1475
1479
|
cwd: command.cwd,
|
|
1476
1480
|
timeoutMs: command.timeoutMs,
|
|
1481
|
+
// DOCS-ROOT: surface the change-impact scopes so `mesh_refine_config` shows which
|
|
1482
|
+
// area(s) each command runs in (absent → every area).
|
|
1483
|
+
...(command.scopes ? { scopes: command.scopes } : {}),
|
|
1477
1484
|
});
|
|
1478
1485
|
return {
|
|
1479
1486
|
source: plan.source,
|
|
@@ -1633,9 +1640,48 @@ export async function runMeshRefineValidationGate(
|
|
|
1633
1640
|
};
|
|
1634
1641
|
|
|
1635
1642
|
const scopeUnaffectedDaemon = opts?.changeImpact?.isDaemonAffecting === false;
|
|
1643
|
+
// DOCS-ROOT: the branch's three-way change area ('none' | 'web' | 'daemon'), when
|
|
1644
|
+
// known. `none` (docs-only) is the case this scoping exists for: a docs-only branch
|
|
1645
|
+
// must skip every code validation command and run ONLY commands explicitly scoped
|
|
1646
|
+
// ['none'] (e.g. a light docs:verify profile). Fail-open: an unknown change area
|
|
1647
|
+
// (changeImpact undefined) leaves changeArea undefined → no scope filtering, the full
|
|
1648
|
+
// command set runs exactly as before.
|
|
1649
|
+
const changeArea: MeshRefineValidationScope | undefined = opts?.changeImpact?.changeArea;
|
|
1650
|
+
// A command runs in the current change area when: the branch area is unknown (run
|
|
1651
|
+
// everything), OR the command declared no scopes (runs everywhere), OR the command's
|
|
1652
|
+
// scopes include the current area. When the branch is docs-only ('none'), an
|
|
1653
|
+
// un-scoped command does NOT run — only commands that explicitly opted into 'none'.
|
|
1654
|
+
const commandRunsInArea = (candidate: MeshRefineValidationCommand): boolean => {
|
|
1655
|
+
if (!changeArea) return true; // fail-open on unknown area
|
|
1656
|
+
const scopes = candidate.scopes;
|
|
1657
|
+
if (scopes && scopes.length) return scopes.includes(changeArea);
|
|
1658
|
+
// Un-scoped command: runs in web/daemon (code areas) but NOT on a docs-only
|
|
1659
|
+
// branch — there is nothing for a code command to validate when only docs changed.
|
|
1660
|
+
return changeArea !== 'none';
|
|
1661
|
+
};
|
|
1636
1662
|
const skippedDaemonCommands: string[] = [];
|
|
1663
|
+
const skippedScopeCommands: string[] = [];
|
|
1637
1664
|
const commandsToRun: MeshRefineValidationCommand[] = [];
|
|
1638
1665
|
for (const candidate of selection.commands) {
|
|
1666
|
+
// DOCS-ROOT scope filter runs first: it is the explicit, config-declared signal
|
|
1667
|
+
// and supersedes the coarse daemon heuristic. A command excluded by change-area
|
|
1668
|
+
// scope is recorded skipped with `unaffected_change_scope`.
|
|
1669
|
+
if (!commandRunsInArea(candidate)) {
|
|
1670
|
+
skippedScopeCommands.push(candidate.displayCommand);
|
|
1671
|
+
summary.commandsRun.push({
|
|
1672
|
+
command: candidate.command,
|
|
1673
|
+
args: candidate.args,
|
|
1674
|
+
displayCommand: candidate.displayCommand,
|
|
1675
|
+
category: candidate.category,
|
|
1676
|
+
source: candidate.source,
|
|
1677
|
+
passed: true,
|
|
1678
|
+
skipped: true,
|
|
1679
|
+
skipReason: 'unaffected_change_scope',
|
|
1680
|
+
changeArea,
|
|
1681
|
+
...(candidate.scopes ? { scopes: candidate.scopes } : {}),
|
|
1682
|
+
});
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1639
1685
|
if (scopeUnaffectedDaemon && isDaemonScopedCommand(candidate)) {
|
|
1640
1686
|
skippedDaemonCommands.push(candidate.displayCommand);
|
|
1641
1687
|
// Record the skip so it's visible in the summary, never silently dropped.
|
|
@@ -1657,7 +1703,9 @@ export async function runMeshRefineValidationGate(
|
|
|
1657
1703
|
summary.changeImpact = {
|
|
1658
1704
|
isDaemonAffecting: opts.changeImpact.isDaemonAffecting,
|
|
1659
1705
|
affectedPackages: opts.changeImpact.affectedPackages,
|
|
1706
|
+
...(changeArea ? { changeArea } : {}),
|
|
1660
1707
|
...(skippedDaemonCommands.length ? { skippedDaemonCommands } : {}),
|
|
1708
|
+
...(skippedScopeCommands.length ? { skippedScopeCommands } : {}),
|
|
1661
1709
|
};
|
|
1662
1710
|
}
|
|
1663
1711
|
|
|
@@ -5,6 +5,22 @@ import * as yaml from 'js-yaml';
|
|
|
5
5
|
export const MESH_REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
|
|
6
6
|
export type MeshRefineValidationCategory = typeof MESH_REFINE_VALIDATION_CATEGORIES[number];
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* DOCS-ROOT: change-impact scope for a validation command. Mirrors ChangeImpactKind
|
|
10
|
+
* (git-status) so a command can declare WHICH change areas it should run in:
|
|
11
|
+
* 'daemon' — run when a daemon-runtime package changed,
|
|
12
|
+
* 'web' — run when a web package changed,
|
|
13
|
+
* 'none' — run when ONLY docs/markers changed (a docs-only branch).
|
|
14
|
+
* A command with NO `scopes` runs in every area (backward-compatible: the full set
|
|
15
|
+
* runs as before). This lets a repo declare a light docs-only profile — e.g. a
|
|
16
|
+
* `docs:verify` command scoped `['none']` — that runs on a docs-only branch while the
|
|
17
|
+
* heavy typecheck/test commands (implicitly all-areas, or scoped ['web','daemon'])
|
|
18
|
+
* are skipped. The scoping is applied only when the branch's changeArea is known;
|
|
19
|
+
* fail-open (unknown change area → every command runs).
|
|
20
|
+
*/
|
|
21
|
+
export const MESH_REFINE_VALIDATION_SCOPES = ['none', 'web', 'daemon'] as const;
|
|
22
|
+
export type MeshRefineValidationScope = typeof MESH_REFINE_VALIDATION_SCOPES[number];
|
|
23
|
+
|
|
8
24
|
export interface RepoMeshRefineValidationCommandConfig {
|
|
9
25
|
/** Executable name or a whitespace-tokenized command string. Never executed through a shell. */
|
|
10
26
|
command: string;
|
|
@@ -15,6 +31,12 @@ export interface RepoMeshRefineValidationCommandConfig {
|
|
|
15
31
|
timeoutMs?: number;
|
|
16
32
|
outputLimitBytes?: number;
|
|
17
33
|
env?: Record<string, string>;
|
|
34
|
+
/**
|
|
35
|
+
* DOCS-ROOT: change-impact scopes this command runs in ('none' | 'web' | 'daemon').
|
|
36
|
+
* Omitted → runs in every area (backward-compatible). Empty array is treated the
|
|
37
|
+
* same as omitted (runs everywhere) rather than "runs nowhere".
|
|
38
|
+
*/
|
|
39
|
+
scopes?: MeshRefineValidationScope[];
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
export interface RepoMeshRefineConfig {
|
|
@@ -57,6 +79,8 @@ export interface MeshRefineValidationCommandPlan {
|
|
|
57
79
|
timeoutMs?: number;
|
|
58
80
|
outputLimitBytes?: number;
|
|
59
81
|
env?: Record<string, string>;
|
|
82
|
+
/** DOCS-ROOT: normalized change-impact scopes; absent → runs in every area. */
|
|
83
|
+
scopes?: MeshRefineValidationScope[];
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
export interface MeshRefineConfigLoadResult {
|
|
@@ -133,6 +157,11 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
|
133
157
|
timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
|
|
134
158
|
outputLimitBytes: { type: 'number', minimum: 1024, maximum: 1048576 },
|
|
135
159
|
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
160
|
+
scopes: {
|
|
161
|
+
type: 'array',
|
|
162
|
+
items: { enum: [...MESH_REFINE_VALIDATION_SCOPES] },
|
|
163
|
+
description: "DOCS-ROOT: change-impact scopes this command runs in ('none'=docs-only, 'web', 'daemon'). Omitted/empty → runs in every area.",
|
|
164
|
+
},
|
|
136
165
|
},
|
|
137
166
|
},
|
|
138
167
|
},
|
|
@@ -152,6 +181,10 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
|
152
181
|
timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
|
|
153
182
|
outputLimitBytes: { type: 'number', minimum: 1024, maximum: 1048576 },
|
|
154
183
|
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
184
|
+
scopes: {
|
|
185
|
+
type: 'array',
|
|
186
|
+
items: { enum: [...MESH_REFINE_VALIDATION_SCOPES] },
|
|
187
|
+
},
|
|
155
188
|
},
|
|
156
189
|
},
|
|
157
190
|
},
|
|
@@ -239,6 +272,16 @@ export function normalizeMeshCommandConfig(entry: unknown, source: string): { co
|
|
|
239
272
|
if (entry.env !== undefined && (!isMeshConfigRecord(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
|
|
240
273
|
return { rejected: { source, command: commandText, reason: 'env must be an object of string values' } };
|
|
241
274
|
}
|
|
275
|
+
// DOCS-ROOT: validate + normalize the optional change-impact scopes.
|
|
276
|
+
let scopes: MeshRefineValidationScope[] | undefined;
|
|
277
|
+
if (entry.scopes !== undefined) {
|
|
278
|
+
if (!Array.isArray(entry.scopes) || !entry.scopes.every(s => (MESH_REFINE_VALIDATION_SCOPES as readonly string[]).includes(s as string))) {
|
|
279
|
+
return { rejected: { source, command: commandText, reason: `scopes must be an array of ${MESH_REFINE_VALIDATION_SCOPES.join(' | ')}` } };
|
|
280
|
+
}
|
|
281
|
+
// De-dupe; an empty array means "no restriction" (runs everywhere), so drop it.
|
|
282
|
+
const deduped = [...new Set(entry.scopes as MeshRefineValidationScope[])];
|
|
283
|
+
scopes = deduped.length ? deduped : undefined;
|
|
284
|
+
}
|
|
242
285
|
|
|
243
286
|
return {
|
|
244
287
|
command: {
|
|
@@ -251,6 +294,7 @@ export function normalizeMeshCommandConfig(entry: unknown, source: string): { co
|
|
|
251
294
|
...(typeof entry.timeoutMs === 'number' ? { timeoutMs: entry.timeoutMs } : {}),
|
|
252
295
|
...(typeof entry.outputLimitBytes === 'number' ? { outputLimitBytes: entry.outputLimitBytes } : {}),
|
|
253
296
|
...(isMeshConfigRecord(entry.env) ? { env: entry.env as Record<string, string> } : {}),
|
|
297
|
+
...(scopes ? { scopes } : {}),
|
|
254
298
|
},
|
|
255
299
|
};
|
|
256
300
|
}
|