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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The host also decides several details, all handled internally:
| `bgrun` | Launch a command detached in the background. Optional `name` gives the job a short human-readable label. Returns `started: <job-id>` immediately. Wakes the session automatically on completion. |
| `bgstatus` | Show job status. With an id: any job's state + exit code — a `bg_N` id (the host's own background job on omp) is answered with its state, where its output went and how to cancel it, rather than "not found". Without: this session's running jobs (finished jobs hidden by default — pass `includeDone: true` or set `showCompletedJobs`), then the host's background jobs under their own heading. Other sessions' *running* jobs are listed only when `adoptForeignJobs` is enabled; finished foreign logs from the shared dir can also appear when finished jobs are included. |
| `bgtail` | Read the newest lines of a job's log (default 40; it reads the log's **last 2 MB** — widen with `bytes`, max 64 MiB + 4 KiB of wrapper overhead), **condensed for context**: ANSI escapes stripped, repeated lines collapsed (a run of 3+ folds into one line carrying its `[xN]` count; a pair is kept as two lines — a fold needs its count to stay legible), long lines and total size capped. First read = full last-N tail; repeat reads return **only lines appended since your last read** (delta tailing) — polling a running job never re-pays for lines already seen. A repeat read asking for **more** lines than you have already been shown is a widening, not a poll: it returns the lines above your current coverage rather than the whole window again — coverage is a trailing run, so a line seen before an intervening gap can repeat. Pass `raw: true` for the unprocessed last-N window (still advances the bookmark). On omp, a native `bg_N` job's spilled output is read the same way, stamped as the host's file. |
| `bggrep` | Regex search over the **last 2 MB** of a job's log (`bytes` widens the window, max 64 MiB + 4 KiB of wrapper overhead; on omp, a native `bg_N` job's spilled output is searchable the same way): line-numbered matches, optional `context` lines, each line pre-truncated to 10 000 chars before matching, results capped (~50 matches, ~8KB) and condensed. Resolves the job id to the configured jobs dir itself — no log path to reconstruct. `ctx_execute_file` can read the same file (it takes an absolute path; only your Read-deny rules apply), but it needs that path. Matching runs under a wall-clock budget ([Bounded matching](#bounded-matching)). With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). |
| `bggrep` | Regex search over the **last 2 MB** of a job's log (`bytes` widens the window, max 64 MiB + 4 KiB of wrapper overhead; on omp, a native `bg_N` job's spilled output is searchable the same way): line-numbered matches, optional `context` lines, each line pre-truncated to 10 000 chars before matching, results capped (~50 matches, ~8KB) and condensed. JavaScript regex syntax: a **leading** `(?i)`/`(?m)`/`(?s)` group is accepted and translated to the equivalent flags (`(?i)error` searches case-insensitively); a *scoped* group (`(?i:error)`) is native anywhere; a bare flag group anywhere else is rejected with an explanation — JS `RegExp` has no unscoped inline flags. Resolves the job id to the configured jobs dir itself — no log path to reconstruct. `ctx_execute_file` can read the same file (it takes an absolute path; only your Read-deny rules apply), but it needs that path. Matching runs under a wall-clock budget ([Bounded matching](#bounded-matching)). With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). |
| `bgclean` | Remove old job logs. **Default scope: this session's jobs only** — other sessions' logs are untouched — and it also drops stale per-project digest markers (`.bgrun-used-*`, `.digest-nudge-*`) in the session's jobs dir (markers are not session data). Pass `all: true` to sweep every shared jobs dir — under the project-local default that is the project's dir plus the machine-global one, while an explicit absolute `jobsDir` is swept alone — and do the same marker sweep across them. Retention: `cleanupDays` config (7 days); `days` must be a positive number (`days: 0` is rejected rather than purging everything). Never removes a running job's log. |
| `bgkill` | Stop a running job. Optional `force: true` sends `SIGKILL` instead of `SIGTERM`. Signals the job's **whole process group** (the child is spawned detached, so the command and anything it started go together; where a platform cannot signal groups, it falls back to the single pid and says so). Refuses an id that already finished, one that is not a bgrun job (`bg_N` belongs to the host — `hub cancel`), another session's job unless `includeForeign: true`, and a pid that cannot be the job's — gone, unusable, or recycled onto an unrelated process. It reports the signal it sent, not a guess at the outcome: the authoritative result is the wake, or the stale-check for a foreign job. |

Expand Down
138 changes: 137 additions & 1 deletion extension/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3261,6 +3261,118 @@ test("bggrep: invalid pattern errors clearly", async () => {
});
});

test("bggrep: a leading (?i) group searches case-insensitively", async () => {
await withJobsDir(async (_dir, h) => {
const { wakes, tools, ctx } = h;
const bgrun = tools.get("bgrun")!;
const bggrep = tools.get("bggrep")!;
const res = await bgrun.execute(
"c1",
{ command: "printf 'DIAGNOSTIC_MARKER_UPSTREAM\\nplain line\\n'" },
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);

// Lowercase pattern against an uppercase line: only the flag can match it.
const flagged = await bggrep.execute(
"c2",
{ id, pattern: "(?i)diagnostic_marker" },
undefined,
undefined,
ctx,
);
assert.equal(flagged.details.matches, 1);
assert.match(
flagged.content[0].text as string,
/DIAGNOSTIC_MARKER_UPSTREAM/,
);
// The header AND details quote the pattern as the caller wrote it, flags
// included — the caller must be able to see what was searched for.
assert.match(
flagged.content[0].text as string,
/1 match for \/\(\?i\)diagnostic_marker\//,
);
assert.equal(flagged.details.pattern, "(?i)diagnostic_marker");

// Without the flag the same pattern matches nothing — so it is the
// translation doing the work, not something else.
const plain = await bggrep.execute(
"c3",
{ id, pattern: "diagnostic_marker" },
undefined,
undefined,
ctx,
);
assert.equal(plain.details.matches, 0);
});
});

test("bggrep: an inline flag group that is not leading is explained, not swallowed", async () => {
await withJobsDir(async (_dir, h) => {
const { wakes, tools, ctx } = h;
const bgrun = tools.get("bgrun")!;
const bggrep = tools.get("bggrep")!;
const res = await bgrun.execute(
"c1",
{ command: "echo hit" },
undefined,
undefined,
ctx,
);
const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
[])[1];
await waitForWakes(wakes, 1);
// PCRE scopes this to the rest of the pattern, so stripping it would silently
// widen the match — it must fail, and say why.
await assert.rejects(
bggrep.execute(
"c2",
{ id, pattern: "hit(?i)more" },
undefined,
undefined,
ctx,
),
/unscoped inline flags/,
);
});
});

test("splitInlineFlags: only a leading group translates; the hint covers the rest", async () => {
// loadModule() is typed by the helper; the exports under test are declared in
// the extension module, so no cast is needed here.
const mod = await loadModule();
assert.deepEqual(mod.splitInlineFlags("(?i)error"), {
source: "error",
flags: "i",
});
assert.deepEqual(mod.splitInlineFlags("(?mi)x"), { source: "x", flags: "im" });
assert.deepEqual(mod.splitInlineFlags("(?is)a(?m)b"), {
source: "a(?m)b",
flags: "is",
});
assert.deepEqual(mod.splitInlineFlags("plain"), { source: "plain", flags: "" });
// Not leading → left untouched for the engine to reject, with the reason.
assert.deepEqual(mod.splitInlineFlags("a(?i)b"), {
source: "a(?i)b",
flags: "",
});
assert.equal(mod.inlineFlagHint("plain"), "");
assert.match(mod.inlineFlagHint("a(?i)b"), /unscoped inline flags/);
assert.match(mod.inlineFlagHint("x(?s:y)"), /unscoped inline flags/);
// A leading group we honored is not the reason for a later compile failure.
assert.equal(mod.inlineFlagHint("(?i)[unclosed"), "");
// A SECOND group further inside IS the reason, even when the pattern starts with
// one. Keying this check on the raw pattern suppressed the hint here — which is
// the only case the hint exists for.
assert.match(mod.inlineFlagHint("(?is)a(?m)b"), /unscoped inline flags/);
assert.match(mod.inlineFlagHint("(?i)(?i)b"), /unscoped inline flags/);
});

test("bggrep: caps at 50 matches with a not-shown note", async () => {
await withJobsDir(async (_dir, h) => {
const { wakes, tools, ctx } = h;
Expand Down Expand Up @@ -7203,7 +7315,7 @@ test("bggrep: the budget terminates a worker that is stuck mid-match (abort path
["a".repeat(50)],
10,
300,
stall,
{ workerSource: stall },
);
const elapsed = Date.now() - t0;
assert.equal(outcome.kind, "timeout", "a stuck worker is reported as a timeout");
Expand Down Expand Up @@ -7243,6 +7355,30 @@ test("bggrep sync fallback: same results as the worker path, including the line
(await mod.matchLinesWithBudget("(", lines, 10, 2_000)).kind,
"invalid",
);

// The flags argument must reach BOTH paths. Only the worker is reachable from the
// tool in this suite (worker_threads exists on Node and Bun), so a regression that
// dropped the argument at either fallback call site — the exact bug class this
// feature fixed — would otherwise ship silently.
const upper = ["DIAGNOSTIC_MARKER"];
assert.deepEqual(
mod.matchLinesSyncBounded("diagnostic_marker", upper, 100, 1_000, "i"),
{ kind: "ok", matchIdx: [0] },
"the sync fallback honours flags",
);
assert.deepEqual(
await mod.matchLinesWithBudget("diagnostic_marker", upper, 100, 2_000, {
flags: "i",
}),
{ kind: "ok", matchIdx: [0] },
"the worker honours flags",
);
// Without the flag the same pattern misses on both paths, so the assertions above
// are testing the flag rather than a pattern that would hit anyway.
assert.deepEqual(
mod.matchLinesSyncBounded("diagnostic_marker", upper, 100, 1_000),
{ kind: "ok", matchIdx: [] },
);
});

test("bggrep sync fallback: a spent budget stops the scan before the first line", async () => {
Expand Down
Loading
Loading