mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 08:59:01 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user