Merge origin/main (v1.60.1.0) into garrytan/gstack-fix-wave

Semantic reconciliation with the parallel time-attack wave (#2264):
- careful: adopt main's anchored full-command whitelist (stricter — also
  catches comment-hiding), re-apply this wave's two hardenings on top
  (capital -[rR] in the flag cluster; exclude `(` and backtick from safe
  targets so $()/backtick substitution cannot ride the whitelist). Union
  of both waves' test batteries passes (main's test.each incl. comment
  case + this wave's substitution/capital-R/FP-pin cases).
- one-way-doors: main landed the singular noun unification (a2a447a1);
  keep this wave's superset (plural s? + --summary-stdin runtime wiring).
- gbrain-local-status: union of states — main's engine-locked (#2194,
  exit 124 PGLite lock) + this wave's thin-client (#2051). --is-ok keeps
  main's intent (engine-locked = STOP) and this wave's (thin-client =
  usable). Test harness unions both fake behaviors.
- sync-gbrain/setup-gbrain tmpls: both Step 1.5 branches kept; generated
  SKILL.md resolved via bun run gen:skill-docs (never hand-edited).
- VERSION/package.json -> 1.61.0.0 per bin/gstack-next-version (main took
  1.60.1.0; PR #2470 claims 1.60.2.0). CHANGELOG: wave entry renumbered
  1.61.0.0 on top of main's 1.60.1.0; careful/#2024 bullets updated to
  describe the delta vs current main. TODOS: union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-07 14:36:05 -07:00
co-authored by Claude Fable 5
82 changed files with 7234 additions and 883 deletions
+21
View File
@@ -19,6 +19,8 @@
* Broken-config → config exists but `gbrain sources list` fails with config parse error
* (or any non-recognized error — defensive default per codex #8).
* Broken-db → config exists, DB unreachable per stderr classification.
* Engine-locked → PGLite probe hit gbrain's own connect timeout, usually
* because another `gbrain serve` process owns the embedded DB.
* Timeout → probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no
* recognized error — engine is likely healthy but slow (e.g. a cold
* pooler connection, #1964). Consumers treat this as usable.
@@ -53,6 +55,7 @@ export type LocalEngineStatus =
| "missing-config"
| "broken-config"
| "broken-db"
| "engine-locked"
| "timeout"
| "thin-client";
@@ -125,6 +128,15 @@ function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
return join(gbrainHome, "config.json");
}
function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null {
try {
const parsed = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { engine?: string };
return parsed.engine === "pglite" || parsed.engine === "postgres" ? parsed.engine : null;
} catch {
return null;
}
}
function hashPath(p: string): string {
return createHash("sha256").update(p).digest("hex").slice(0, 16);
}
@@ -308,6 +320,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
stderr?: Buffer | string;
killed?: boolean;
signal?: NodeJS.Signals | null;
status?: number | null;
};
const stderr = (e.stderr ? e.stderr.toString() : "") || "";
@@ -323,6 +336,14 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
if (stderr.includes("Cannot connect to database")) return "broken-db";
if (stderr.includes("config.json")) return "broken-config";
// PGLite is single-process. A long-lived `gbrain serve` can own the
// embedded database, causing the CLI to finish with its own exit 124 and
// "connect timed out" message. This is neither our watchdog timeout nor
// evidence that the valid config is malformed (#2194).
if (stderr.includes("connect timed out") || e.status === 124) {
return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db";
}
// Probe killed by the timeout with no recognized error: the engine is
// most likely healthy but slow (cold pooler connections measured at
// 6.9-10.7s in #1964). Don't tell the user their config is malformed.
+42 -1
View File
@@ -106,9 +106,15 @@ export function canonicalizeRemote(url: string | null | undefined): string {
// strip user@ prefix on URL-style remotes
s = s.replace(/^[^@\/]+@/, "");
}
// strip trailing slash(es) first, so a URL written with a trailing slash
// still matches the `.git$` suffix below (e.g. ".../repo.git/" must
// canonicalize to ".../repo", not ".../repo.git").
s = s.replace(/\/+$/, "");
// strip trailing .git
s = s.replace(/\.git$/i, "");
// strip trailing slash
// re-strip trailing slash(es): a path remote ending in a `.git` directory
// component ("/repo/.git") exposes a new trailing slash once `.git` is
// stripped, which would split the repo into a second identity.
s = s.replace(/\/+$/, "");
// collapse multiple slashes (after path normalization)
s = s.replace(/\/{2,}/g, "/");
@@ -396,6 +402,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
const globM = body.match(/(?:^|\n)\s*glob\s*:\s*"?([^"\n]+?)"?\s*$/m);
const sortM = body.match(/(?:^|\n)\s*sort\s*:\s*([^\n]+)/);
const tailM = body.match(/(?:^|\n)\s*tail\s*:\s*(\d+)/);
const filterMap = parseFilterMap(body);
if (idM) q.id = idM[1].trim();
if (kindM) {
@@ -408,6 +415,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
if (globM) q.glob = globM[1].trim();
if (sortM) q.sort = sortM[1].trim();
if (tailM) q.tail = parseInt(tailM[1], 10);
if (filterMap) q.filter = filterMap;
if (q.id && q.kind && q.render_as) {
queries.push(q as GbrainManifestQuery);
@@ -418,6 +426,39 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null {
return { schema, context_queries: queries };
}
/**
* Parse a nested `filter:` block map out of a single context_queries item body.
*
* The block is a YAML map nested under the `filter:` key:
*
* filter:
* type: timeline
* tags_contains: "repo:{repo_slug}"
*
* Each sub-key sits one indent level deeper than `filter:`. Surrounding quotes
* are stripped and template vars ({repo_slug}, now-7d, ...) are left intact for
* downstream substitution, matching how dispatchList stringifies each value
* into a `--filter k=v` argument. Returns undefined when there is no `filter:`
* block or it is empty.
*/
function parseFilterMap(body: string): Record<string, string> | undefined {
const lines = body.split("\n");
const filterIdx = lines.findIndex((l) => /^\s*filter\s*:\s*$/.test(l));
if (filterIdx === -1) return undefined;
const filterIndent = lines[filterIdx].match(/^\s*/)![0].length;
const filter: Record<string, string> = {};
for (let i = filterIdx + 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "") continue; // tolerate blank lines within the block
const indent = line.match(/^\s*/)![0].length;
if (indent <= filterIndent) break; // dedent to a sibling key ends the block
const kv = line.match(/^\s*([A-Za-z0-9_]+)\s*:\s*"?(.*?)"?\s*$/);
if (kv) filter[kv[1]] = kv[2].trim();
}
return Object.keys(filter).length > 0 ? filter : undefined;
}
// ── Public: withErrorContext ──────────────────────────────────────────────
const ERROR_LOG_PATH = join(gstackHome(), ".gbrain-errors.jsonl");