Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>/.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. |
Expand Down
223 changes: 221 additions & 2 deletions extension/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,25 @@ 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 {
text: string;
options?: Record<string, unknown>;
}

function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
function makeFakePi(
opts: {
idle?: boolean;
priorEntries?: any[];
ctxFields?: Record<string, unknown>;
} = {},
): {
pi: any;
wakes: CapturedWake[];
entries: any[];
Expand Down Expand Up @@ -60,6 +67,7 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
hasUI: false,
ui: { notify() {}, setWidget() {}, setStatus() {} },
sessionManager: { getEntries: () => entries },
...(opts.ctxFields as Record<string, unknown> | undefined),
};
const pi = {
sendUserMessage(text: string, options?: Record<string, unknown>) {
Expand Down Expand Up @@ -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 });
}
});
Loading
Loading