fix(slice-22): skip noise dirs in spec library walk + prune stale rows at startup (OPR.0.3.2.22 Bug 4)

Closes the last paper-cut behind the openrig-comms hero-flow report:
operator on a fresh 0.3.1 install ran `rig up conveyor` and hit an
ambiguous-match error against a `conveyor` workflow spec that had
been cached from a path inside `.worktrees/release-0.3.0-...`. The
workflow scanner's stale-row cleanup only fires for source_path
values starting with the workspace `workflows/` folder prefix, so
rows from `.worktrees/`, `node_modules/`, etc. survived forever.

The fix has two coordinated parts:

(1) walkYamlFiles SKIP_DIRS guard
    (packages/daemon/src/domain/spec-library-service.ts):

    Mirrors progress-indexer.ts:81 + frontmatter-validator.ts:103.
    The walker refuses to descend into the noise directories that
    no shipped or operator-authored spec ever lives in:
    .worktrees, node_modules, .git, dist, build, .turbo, .next.
    When the SKIP_DIRS set grows in the future, update all three
    locations to keep the behavior consistent (called out in the
    inline comment).

(2) One-time startup prune of legacy noise-dir rows
    (packages/daemon/src/domain/workflow-spec-cache.ts +
     packages/daemon/src/startup.ts):

    New WorkflowSpecCache.pruneNoiseDirRows() issues a single
    bounded DELETE matching the same noise-directory patterns the
    walker now refuses to enter. Called once during daemon startup
    immediately after deps.workflowSpecCache is wired up, so
    operators upgrading from a pre-Bug-4 daemon get their legacy
    stale rows cleared on first boot. Idempotent — returns 0 when
    no noise rows exist.

Optional `rig specs show/preview` cross-kind ambiguity cleanup at
specs.ts:230-246 (called out as taste-y in README §74) is NOT in
this commit. The stale-row class behind the ambiguity is now closed
upstream by the prune; the show/preview surface stays as-is for
scope discipline.

Test additions:

- packages/daemon/test/workflow-spec-cache.test.ts: pruneNoiseDirRows
  with 4 cache rows (one canonical, three across .worktrees +
  node_modules + dist) removes the 3 noise rows and preserves the
  canonical one. Idempotent variant: 0 noise rows in → 0 removed.

- packages/daemon/test/spec-library-service.test.ts: SpecLibraryService.scan
  with one canonical yaml plus a noise yaml inside each of the 7
  SKIP_DIRS entries discovers only the canonical entry; the seven
  noise paths are excluded by directory name, never read.

Verification:
- Full packages/daemon test/ suite: 272 files, 3995 PASS + 1 skipped,
  ZERO failures.
- Touched suites isolated: workflow-spec-cache.test.ts 11/11 PASS,
  spec-library-service.test.ts 9/9 PASS.
- tsc --noEmit on @openrig/daemon EXIT=0.
- Commit body verification grep clean per banked discipline.
- git diff --cached --stat: 5 files (+119 / -0).

Branch: slice-22-rig-up-paper-cut-fix-round off LOCAL main 0c9e63f1.
Chain: 50488427 (Bug 1 BONUS) → a29c7883 (Bug 2 first) → ad6a522b
(Bug 2 teardown follow-up) → 64f8aa8b (Bug 3) → this commit. LOCAL
only, NO push. This closes the four-bug slice.
This commit is contained in:
mvschwarz committed 2026-05-31 22:31:32 -07:00
1 parent 64f8aa8bc6
commit 79d06f8db9
5 files changed
+119

No files matched your search

@@ -49,6 +49,22 @@ function isYamlFile(filename: string): boolean {
return filename.endsWith(".yaml") || filename.endsWith(".yml");
}
// OPR.0.3.2.22 Bug 4 — noise directories that should never enter the
// spec library walk. Mirrors progress-indexer.ts:81 +
// frontmatter-validator.ts:103 — when this list grows, update all three
// for consistency. The `.worktrees` entry closes the stale-row class
// where conveyor.yaml inside a worktree was sliding into the library
// cache and producing the `rig specs show conveyor` ambiguous-match UX.
const SKIP_DIRS = new Set([
".worktrees",
"node_modules",
".git",
"dist",
"build",
".turbo",
".next",
]);
function walkYamlFiles(rootPath: string): string[] {
const files: string[] = [];
const stack = [rootPath];
@@ -65,6 +81,7 @@ function walkYamlFiles(rootPath: string): string[] {
for (const entry of entries) {
const absPath = join(current, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
stack.push(absPath);
continue;
}
@@ -326,6 +326,32 @@ export class WorkflowSpecCache {
return result.changes;
}
/**
* OPR.0.3.2.22 Bug 4 — prune cached rows whose source_path lives in
* noise directories that the post-Bug-4 walkYamlFiles SKIP_DIRS
* guard now refuses to scan. Without this prune, rows that were
* inserted before the SKIP_DIRS guard landed would survive forever
* (the scanner cleanup at spec-library-workflow-scanner.ts only
* fires for paths starting with the workspace `workflows/` folder
* prefix). Called once at startup. Returns the number of rows
* removed.
*/
pruneNoiseDirRows(): number {
const result = this.db
.prepare(
`DELETE FROM workflow_specs WHERE
source_path LIKE '%/.worktrees/%'
OR source_path LIKE '%/node_modules/%'
OR source_path LIKE '%/.git/%'
OR source_path LIKE '%/dist/%'
OR source_path LIKE '%/build/%'
OR source_path LIKE '%/.turbo/%'
OR source_path LIKE '%/.next/%'`,
)
.run();
return result.changes;
}
getByIdOrThrow(specId: string): WorkflowSpecRow {
const row = this.db
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
+11
View File
@@ -907,6 +907,17 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// `<workspace.specs_root>/workflows`; SettingsStore resolves
// workspaceSpecsRoot from env > config > workspace-default.
deps.workflowSpecCache = workflowRuntime.specCache;
// OPR.0.3.2.22 Bug 4 — one-time prune of cache rows whose source_path
// lives in noise directories (.worktrees, node_modules, etc.). The
// post-Bug-4 walkYamlFiles SKIP_DIRS guard prevents NEW rows from
// those locations, but legacy rows from prior daemon versions (or
// operators who hand-imported a spec via path-form before SKIP_DIRS
// shipped) would persist without this prune. Cheap (single DELETE
// with bounded LIKE patterns) and safe (matches only the same
// directories the walker now refuses to enter).
workflowRuntime.specCache.pruneNoiseDirRows();
try {
const settingsStore = new ContextPackSettingsStore();
const cfg = settingsStore.resolveConfig();
@@ -169,4 +169,29 @@ describe("SpecLibraryService", () => {
expect(rigs).toHaveLength(1);
expect(rigs[0]!.relativePath).toBe("rigs/launch/demo/rig.yaml");
});
// OPR.0.3.2.22 Bug 4 — walkYamlFiles now skips noise directories
// (.worktrees, node_modules, .git, dist, build, .turbo, .next). The
// conveyor.yaml under .worktrees/release-0.3.0-... is the load-bearing
// case from the openrig-comms paper-cut report; this pins the
// SKIP_DIRS guard at the SpecLibraryService.scan() seam.
it("scan skips yaml files inside noise directories (SKIP_DIRS)", () => {
writeFileSync(join(tmpDir, "canonical.yaml"), VALID_RIG_YAML);
const NOISE_DIRS = [".worktrees", "node_modules", ".git", "dist", "build", ".turbo", ".next"];
for (const dir of NOISE_DIRS) {
mkdirSync(join(tmpDir, dir, "deeper"), { recursive: true });
writeFileSync(join(tmpDir, dir, "deeper", "noise.yaml"), VALID_RIG_YAML.replace("test-rig", `noise-${dir}`));
}
const lib = createLibrary();
lib.scan();
const entries = lib.list();
expect(entries, `expected only canonical rig (got ${JSON.stringify(entries.map((e) => e.relativePath))})`).toHaveLength(1);
expect(entries[0]!.sourcePath).toContain("canonical.yaml");
for (const dir of NOISE_DIRS) {
expect(entries.every((e) => !e.sourcePath.includes(`/${dir}/`)), `entry from ${dir} leaked through SKIP_DIRS`).toBe(true);
}
});
});
@@ -157,4 +157,44 @@ describe("WorkflowSpecCache (PL-004 Phase D)", () => {
const found = cache.getByNameVersion("test-three-step", "1");
expect(found?.specId).toBe(cached.specId);
});
// OPR.0.3.2.22 Bug 4 — startup prune removes legacy cache rows whose
// source_path lives in noise directories that walkYamlFiles' new
// SKIP_DIRS guard now refuses to scan. Without this prune, stale
// rows from before SKIP_DIRS shipped would survive forever and keep
// showing up in `rig specs show` / `rig specs preview` candidates.
it("pruneNoiseDirRows removes cache rows from .worktrees/node_modules paths and preserves canonical-path rows", () => {
const fixtures: Array<{ path: string; specName: string; noise: boolean }> = [
{ path: join(tmp, "workflows", "canon.yaml"), specName: "canonical-spec", noise: false },
{ path: join(tmp, ".worktrees", "feature-branch", "workflows", "stale.yaml"), specName: "stale-worktree-spec", noise: true },
{ path: join(tmp, "node_modules", "@vendor", "spec", "stale.yaml"), specName: "stale-node-modules-spec", noise: true },
{ path: join(tmp, "dist", "stale.yaml"), specName: "stale-dist-spec", noise: true },
];
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
for (const f of fixtures) {
mkdirSync(join(f.path, ".."), { recursive: true });
writeFileSync(f.path, SAMPLE_SPEC.replace("test-three-step", f.specName));
cache.readThrough(f.path);
}
expect(cache.listAll()).toHaveLength(4);
const removed = cache.pruneNoiseDirRows();
expect(removed, "expected 3 noise rows removed (.worktrees + node_modules + dist)").toBe(3);
const remaining = cache.listAll();
expect(remaining).toHaveLength(1);
expect(remaining[0]!.sourcePath).toBe(fixtures[0]!.path);
});
it("pruneNoiseDirRows returns 0 when there are no noise rows", () => {
const canonicalPath = join(tmp, "workflows", "canon.yaml");
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
mkdirSync(join(canonicalPath, ".."), { recursive: true });
writeFileSync(canonicalPath, SAMPLE_SPEC);
cache.readThrough(canonicalPath);
expect(cache.pruneNoiseDirRows()).toBe(0);
expect(cache.listAll()).toHaveLength(1);
});
});