diff --git a/README.md b/README.md index fe53acb..a8e6126 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,41 @@ config file (trusted projects only) ← environment variables**. } ``` +### Project-local logs + +A **relative** `jobsDir` (from any config layer, or `PI_BGRUN_DIR`) opts into +project-local logs: it resolves against the session's project root, so job logs +land inside the workspace — e.g. `"jobsDir": ".pi-bgrun/jobs"` in the project +config writes logs to `/.pi-bgrun/jobs`. + +Why you might want this: + +- Logs sit inside the project sandbox, so project-confined analysis tools + (e.g. context-mode's `ctx_execute_file` / `ctx_index`) can process whole logs + without pulling raw bytes into the context window. +- Each checkout/worktree gets its own logs — no cross-project clutter in the + shared dir. +- The dir is auto-added to the repo's `.git/info/exclude` (local-only — the + tracked `.gitignore` is never touched), so logs never pollute `git status`. + Works in linked worktrees too (`.git` file → pointed git dir). + +Rules and migration notes: + +- Absolute `jobsDir` values behave exactly as in older versions — nothing + moves, nothing breaks on upgrade. +- If the session cwd is not a recognizable project root (no `.git`/`.pi`), a + relative path falls back to the global dir rather than scattering logs + across arbitrary directories. +- Tools resolve a job's log from the session's job record first, so jobs + started before a config change stay readable after it. +- Existing logs in the old global dir are not migrated (they're ephemeral, + `cleanupDays`-retained); `bgclean all` sweeps them once you've switched. + Environment variables (same knobs, handy for one-off overrides): | Variable | Default | Description | | --- | --- | --- | -| `PI_BGRUN_DIR` | `~/.pi-bgrun/jobs` | Override where job logs are stored. | +| `PI_BGRUN_DIR` | `~/.pi-bgrun/jobs` | Override where job logs are stored. An absolute path is used as-is; a **relative** path resolves against the project root (see [project-local logs](#project-local-logs)), falling back to the default when there is no project root. | | `PI_BGRUN_FOREIGN_JOBS` | `false` | Adopt other sessions' running jobs into this session's widget and job list. Adopted jobs are polled so they leave the widget when they finish. | | `PI_BGRUN_SHOW_COMPLETED` | `false` | Include finished jobs in `bgstatus` listings by default. | | `PI_BGRUN_CLEANUP_DAYS` | `7` | Log retention for cleanup sweeps and the `bgclean` default. | diff --git a/extension/index.test.ts b/extension/index.test.ts index 484c47a..e3f8351 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -19,10 +19,11 @@ import { readFileSync, writeFileSync, existsSync, + mkdirSync, readdirSync, } from "node:fs"; import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; interface CapturedWake { @@ -30,7 +31,13 @@ interface CapturedWake { options?: Record; } -function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): { +function makeFakePi( + opts: { + idle?: boolean; + priorEntries?: any[]; + ctxFields?: Record; + } = {}, +): { pi: any; wakes: CapturedWake[]; entries: any[]; @@ -60,6 +67,7 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): { hasUI: false, ui: { notify() {}, setWidget() {}, setStatus() {} }, sessionManager: { getEntries: () => entries }, + ...(opts.ctxFields as Record | undefined), }; const pi = { sendUserMessage(text: string, options?: Record) { @@ -1770,3 +1778,214 @@ test("formatSince: same-day shows time only; older days include the date", async assert.match(prevYearStr, /Dec 30/); assert.match(prevYearStr, /08:00:00/); }); + +// ── Project-local jobs dir ────────────────────────────────────────────────── + +test("resolveJobsDirPath: relative resolves against a project root; absolute and no-root fall back", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + const scratch = mkdtempSync(join(tmpdir(), "pi-bgrun-scratch-")); + try { + mkdirSync(join(proj, ".git"), { recursive: true }); + + // absolute → used as-is, never flagged project-local (older configs keep + // working unchanged — the migration guarantee) + const absPath = join(proj, "abs-jobs"); + const abs = mod.resolveJobsDirPath(absPath, { cwd: proj }); + assert.equal(abs.dir, absPath); + assert.equal(abs.projectLocal, false); + + // relative + project root → resolved against the root, flagged project-local + const rel = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: proj }); + assert.equal(rel.dir, join(proj, ".pi-bgrun", "jobs")); + assert.equal(rel.projectLocal, true); + + // unset → global default + const none = mod.resolveJobsDirPath(undefined, { cwd: proj }); + assert.equal(none.dir, join(homedir(), ".pi-bgrun", "jobs")); + assert.equal(none.projectLocal, false); + + // relative + cwd that is not a project → global fallback, never cwd-relative + const fb = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: scratch }); + assert.equal(fb.dir, join(homedir(), ".pi-bgrun", "jobs")); + assert.equal(fb.projectLocal, false); + } finally { + rmSync(proj, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + } +}); + +test("ensureGitExcluded: appends the jobs dir pattern to .git/info/exclude once per dir", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-")); + try { + mkdirSync(join(repo, ".git", "info"), { recursive: true }); + mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs")); + mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs")); + // a second, different jobs dir under the same repo adds its own pattern + mod.ensureGitExcluded(join(repo, ".pi-bgrun", "other")); + const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8"); + assert.match(exclude, /# pi-bgrun job logs/); + assert.equal( + exclude.split("\n").filter((l) => l.trim() === ".pi-bgrun/jobs/").length, + 1, + "pattern appears exactly once", + ); + assert.ok(exclude.split("\n").includes(".pi-bgrun/other/")); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git dir", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-")); + const gd = mkdtempSync(join(tmpdir(), "pi-bgrun-gitdir-")); + try { + writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`); + mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")); + const exclude = readFileSync(join(gd, "info", "exclude"), "utf8"); + assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); + // nothing was created inside the worktree's own .git (it's a file) + assert.ok(!existsSync(join(wt, ".git", "info"))); + } finally { + rmSync(wt, { recursive: true, force: true }); + rmSync(gd, { recursive: true, force: true }); + } +}); + +test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-")); + const gd = join(tmpdir(), "pi-bgrun git dir with spaces"); + mkdirSync(gd, { recursive: true }); + try { + writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`); + assert.equal(mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")), true); + const exclude = readFileSync(join(gd, "info", "exclude"), "utf8"); + assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); + } finally { + rmSync(wt, { recursive: true, force: true }); + rmSync(gd, { recursive: true, force: true }); + } +}); + +test("ensureGitExcluded: retries after a transient failure — memoizes only on success", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-")); + try { + mkdirSync(join(repo, ".git", "info"), { recursive: true }); + // Block the exclude path with a directory → the append fails (EISDIR) + mkdirSync(join(repo, ".git", "info", "exclude")); + const jobsDir = join(repo, ".pi-bgrun", "jobs"); + assert.equal(mod.ensureGitExcluded(jobsDir), false); + + // Unblock: the next call must retry (failure was not memoized) and succeed + rmSync(join(repo, ".git", "info", "exclude"), { recursive: true }); + assert.equal(mod.ensureGitExcluded(jobsDir), true); + const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8"); + assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("bgrun: relative jobsDir in project config → project-local log + auto git-exclude", async () => { + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + delete process.env.PI_BGRUN_DIR; + try { + mkdirSync(join(proj, ".git"), { recursive: true }); + mkdirSync(join(proj, ".pi"), { recursive: true }); + writeFileSync( + join(proj, ".pi", "pi-bgrun.json"), + JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }), + ); + const { pi, wakes, tools, ctx } = makeFakePi({ + ctxFields: { cwd: proj, isProjectTrusted: () => true }, + }); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + const res = await bgrun.execute( + "call-1", + { command: "echo project-local" }, + undefined, + undefined, + ctx, + ); + const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) || + [])[1]; + assert.ok(id, "got a job id"); + + await waitForWakes(wakes, 1); + + const logPath = join(proj, ".pi-bgrun", "jobs", `${id}.log`); + assert.ok(existsSync(logPath), "log written inside the project"); + assert.match(readFileSync(logPath, "utf8"), /project-local/); + + const exclude = join(proj, ".git", "info", "exclude"); + assert.ok(existsSync(exclude), "exclude file created"); + assert.match(readFileSync(exclude, "utf8"), /^\.pi-bgrun\/jobs\/$/m); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(proj, { recursive: true, force: true }); + } +}); + +test("bgtail: prefers the session record's logPath when the jobsDir config changes", async () => { + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + delete process.env.PI_BGRUN_DIR; + try { + mkdirSync(join(proj, ".git"), { recursive: true }); + mkdirSync(join(proj, ".pi"), { recursive: true }); + writeFileSync( + join(proj, ".pi", "pi-bgrun.json"), + JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }), + ); + const { pi, wakes, tools, ctx } = makeFakePi({ + ctxFields: { cwd: proj, isProjectTrusted: () => true }, + }); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + const bgtail = tools.get("bgtail")!; + + const res = await bgrun.execute( + "call-1", + { command: "echo migrated-log" }, + undefined, + undefined, + ctx, + ); + const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) || + [])[1]; + assert.ok(id, "got a job id"); + await waitForWakes(wakes, 1); + + // A ctx with no project config/trust now resolves the jobs dir to the + // GLOBAL default — only the session record's logPath can still find the + // log (the mid-upgrade config-change scenario). + const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined }; + const tail = await bgtail.execute( + "call-2", + { id, lines: 10 }, + undefined, + undefined, + plainCtx, + ); + assert.equal(tail.details.notFound, false); + assert.match(tail.content[0].text as string, /migrated-log/); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(proj, { recursive: true, force: true }); + } +}); diff --git a/extension/index.ts b/extension/index.ts index dd3a559..153797e 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -34,17 +34,19 @@ import { Type } from "typebox"; import { Box, Text } from "@earendil-works/pi-tui"; import { spawn } from "node:child_process"; import { - openSync, + appendFileSync, closeSync, - readFileSync, + existsSync, mkdirSync, + openSync, + readFileSync, readdirSync, renameSync, - unlinkSync, statSync, + unlinkSync, writeFileSync, } from "node:fs"; -import { join } from "node:path"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; import { homedir } from "node:os"; // Exit marker appended to every log so the file is self-describing: the exit @@ -54,6 +56,7 @@ const EXIT_MARKER = "__BGRUN_EXIT__="; const DEFAULT_CLEANUP_DAYS = 7; const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle +const GLOBAL_JOBS_DIR = join(homedir(), ".pi-bgrun", "jobs"); // ── Configuration ─────────────────────────────────────────────────────────── // @@ -66,6 +69,10 @@ const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child h interface BgrunConfig { jobsDir: string; + // True when jobsDir came from a RELATIVE path resolved against the project + // root (project-local logs). Only then does bgrun auto-ignore the dir in + // .git/info/exclude — an absolute dir is the user's explicit choice. + jobsDirProjectLocal: boolean; // Adopt other sessions' running jobs (found in the shared jobs dir) into // this session's widget and job list. Default false — most sessions don't // want unrelated jobs from other projects cluttering the widget. @@ -112,6 +119,106 @@ function readConfigFile(path: string): BgrunConfigFile { return {}; } +// ── Project-local jobs dir ────────────────────────────────────────────────── +// +// A RELATIVE `jobsDir` (from any config layer, or PI_BGRUN_DIR) opts into +// project-local logs: it resolves against the session's project root, so logs +// land inside the workspace. That keeps them within the project sandbox — +// analysis tools confined to the project root (e.g. context-mode's +// ctx_execute_file/ctx_index) can then process whole logs without flooding +// context. Absolute paths behave exactly as in older versions +// (migration-safe), and with no recognizable project root a relative path +// falls back to the global dir instead of scattering logs across whatever +// directory pi happened to start in. + +function isProjectRootLike(dir: string): boolean { + // Cheap heuristic: a directory holding .git or pi's config dir is a project. + return ( + existsSync(join(dir, ".git")) || existsSync(join(dir, CONFIG_DIR_NAME)) + ); +} + +export function resolveJobsDirPath( + raw: string | undefined, + ctx?: { cwd?: string }, +): { dir: string; projectLocal: boolean } { + if (!raw) return { dir: GLOBAL_JOBS_DIR, projectLocal: false }; + if (isAbsolute(raw)) return { dir: raw, projectLocal: false }; + const root = ctx?.cwd ?? process.cwd(); + if (!root || !isProjectRootLike(root)) { + return { dir: GLOBAL_JOBS_DIR, projectLocal: false }; + } + return { dir: join(root, raw), projectLocal: true }; +} + +// Auto-ignore a project-local jobs dir in git so logs never pollute +// `git status`: appends the dir pattern to the enclosing repo's +// .git/info/exclude (local-only — the tracked .gitignore is never touched). +// Memoized only on SUCCESS — a transient failure (unwritable exclude file, +// .git appearing later) is retried on the next bgrun. Every step is +// best-effort and must never fail a bgrun. +const gitExcludedDirs = new Set(); + +// Returns true when the dir is settled (pattern written, already present, or +// legitimately nothing to do — no repo above, dir is the repo root itself). +// False only on failure, so the caller retries next time. +export function ensureGitExcluded(jobsDir: string): boolean { + if (gitExcludedDirs.has(jobsDir)) return true; + if (tryEnsureGitExcluded(jobsDir)) { + gitExcludedDirs.add(jobsDir); + return true; + } + return false; +} + +function tryEnsureGitExcluded(jobsDir: string): boolean { + try { + // Walk up from jobsDir to the enclosing work tree. + let cur = jobsDir; + for (;;) { + const dot = join(cur, ".git"); + if (existsSync(dot)) return appendExcludePattern(cur, dot, jobsDir); + const parent = dirname(cur); + if (parent === cur) return true; // filesystem root — no repo above; nothing to do + cur = parent; + } + } catch { + // best-effort — ignore hygiene must never break job creation + return false; + } +} + +function appendExcludePattern( + repoRoot: string, + dotGit: string, + jobsDir: string, +): boolean { + if (jobsDir === repoRoot) return true; // can't exclude the whole repo; nothing to do + // `.git` is a directory in a normal checkout, or a file pointing at the + // real git dir in linked worktrees (git worktree add) and submodules. + let gitDir = dotGit; + if (statSync(dotGit).isFile()) { + const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m); + if (!m) return false; // unparseable .git file — retry later + gitDir = m[1].trim(); + } + const pattern = relative(repoRoot, jobsDir).split(sep).join("/") + "/"; + const excludePath = join(gitDir, "info", "exclude"); + let existing = ""; + try { + existing = readFileSync(excludePath, "utf8"); + } catch { + // no exclude file yet — we'll create it + } + if (existing.split("\n").some((l) => l.trim() === pattern)) return true; + mkdirSync(join(gitDir, "info"), { recursive: true }); + appendFileSync( + excludePath, + `\n# pi-bgrun job logs (auto-added)\n${pattern}\n`, + ); + return true; +} + // Resolved per call (cheap: at most two small file reads) so env/config // changes are picked up without module reloads — and tests can isolate. function resolveConfig(ctx?: { @@ -154,11 +261,11 @@ function resolveConfig(ctx?: { : undefined; const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS); const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined; + const { dir: jobsDir, projectLocal: jobsDirProjectLocal } = + resolveJobsDirPath(process.env.PI_BGRUN_DIR || dirFile, ctx); return { - jobsDir: - process.env.PI_BGRUN_DIR || - dirFile || - join(homedir(), ".pi-bgrun", "jobs"), + jobsDir, + jobsDirProjectLocal, adoptForeignJobs: parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false, showCompletedJobs: @@ -733,7 +840,11 @@ export default function (pi: ExtensionAPI) { } const name = sanitizeName(rawName); - const jobsDir = resolveConfig(ctx).jobsDir; + const cfg = resolveConfig(ctx); + // Project-local logs are auto-ignored in .git/info/exclude (best-effort) + // so they never pollute `git status`. Absolute dirs are left untouched. + if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir); + const jobsDir = cfg.jobsDir; mkdirSync(jobsDir, { recursive: true }); const slug = makeSlug(name ?? command); @@ -962,7 +1073,11 @@ export default function (pi: ExtensionAPI) { }> { const { id, lines = 40, raw = false } = params; if (!id) throw new Error("bgtail: id is required"); - const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`); + // Prefer this session's record: its logPath stays correct even if the + // config (and thus the resolved jobs dir) changes mid-session — e.g. a + // user switching to project-local logs right after upgrading. + const logPath = + jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`); try { const content = readFileSync(logPath, "utf8"); const all = content diff --git a/skill/run-bg/SKILL.md b/skill/run-bg/SKILL.md index 203c807..8f6d3ce 100644 --- a/skill/run-bg/SKILL.md +++ b/skill/run-bg/SKILL.md @@ -54,7 +54,7 @@ no polling. - **Quick peek (≤40 lines):** call `bgtail` with the job id and `lines: 40` — strips the `__BGRUN_EXIT__` marker. - **Whole-log failure analysis:** `ctx_execute_file` on the log path: - ``` + ```javascript ctx_execute_file( path: "~/.pi-bgrun/jobs/.log", language: "javascript", @@ -85,7 +85,10 @@ no polling. - Call the tools; never hand-roll `nohup … &` inline. - One job = one id. Multiple concurrent jobs are fine — each has its own log. -- Logs live in `~/.pi-bgrun/jobs` (override with `PI_BGRUN_DIR`). +- Logs live in `~/.pi-bgrun/jobs` (override with `PI_BGRUN_DIR`). A **relative** + `jobsDir` in the project config (e.g. `.pi-bgrun/jobs`) puts logs inside the + project — auto-ignored via `.git/info/exclude` — which keeps them reachable + for project-sandboxed analysis tools like `ctx_execute_file`. - Cleanup: `bgclean` removes only THIS session's old logs; `bgclean all` sweeps every session's. Auto-sweeps at session start/shutdown are session-scoped plus a global orphan pass (default on — removes finished