mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 23:19:09 +02:00
Merge origin/main (v1.83.0.0, Memorable recall bridge) into tehran-v1; re-bump to v1.84.0.0
Conflicts resolved by keeping both sides: gstack-config enumerates design_detector, design_detector_install_prompted, and memorable_recall; the egress wiring test carries both new fail-closed sinks (design-detect-engine- download, memorable-recall) and both new module sinks; PROJECT_STRUCTURE's bin/ line names the design tools and gstack-memorable. This branch's CHANGELOG entry moves to 1.84.0.0 (dated today) above main's 1.83.0.0; VERSION, package.json, and the agents digest were written by gstack-version-bump. Main touched no template or resolver, so no render changed (gen-skill-docs --dry-run: all FRESH). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+32
-7
@@ -72,6 +72,13 @@ export interface WriteReceiptOptions {
|
||||
sha256?: string | null;
|
||||
/** the consent key+value that authorizes this send */
|
||||
consent: string;
|
||||
/**
|
||||
* Max milliseconds to wait for the ledger lock (default LEDGER_LOCK_BUDGET_MS).
|
||||
* Callers on an interactive hot path with their own deadline (a Claude Code
|
||||
* hook under a 5 s kill) pass what they can afford; the lock is always
|
||||
* tried at least once.
|
||||
*/
|
||||
lockBudgetMs?: number;
|
||||
}
|
||||
|
||||
export interface WriteOutcomeOptions {
|
||||
@@ -80,8 +87,13 @@ export interface WriteOutcomeOptions {
|
||||
/** receipt id returned by writeReceipt */
|
||||
receipt: string;
|
||||
status?: string | number;
|
||||
/** see WriteReceiptOptions.lockBudgetMs */
|
||||
lockBudgetMs?: number;
|
||||
}
|
||||
|
||||
/** Default spin budget for the ledger lock. Egress events are rare (minutes apart). */
|
||||
export const LEDGER_LOCK_BUDGET_MS = 2500;
|
||||
|
||||
export interface LedgerLine {
|
||||
lineNo: number;
|
||||
raw: string;
|
||||
@@ -139,8 +151,10 @@ function requireString(value: unknown, name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* mkdir spin lock, ~2.5s budget. Egress events are rare (minutes apart); the
|
||||
* lock only protects the read-last-line → append window.
|
||||
* mkdir spin lock; the budget defaults to LEDGER_LOCK_BUDGET_MS (2.5 s) and
|
||||
* callers on their own deadline pass less. Egress events are usually rare
|
||||
* (minutes apart; the memorable hook is the per-prompt exception); the lock
|
||||
* only protects the read-last-line → append window.
|
||||
*
|
||||
* Stale-lock reclaim: a crashed writer strands the lock dir. Once the spin
|
||||
* budget is exhausted, a lock dir whose mtime is >10s old is stale by
|
||||
@@ -148,9 +162,9 @@ function requireString(value: unknown, name: string): string {
|
||||
* retries instead of failing. The rmdir/stat races with a concurrent
|
||||
* reclaimer or the owner's own cleanup are harmless — losers just loop.
|
||||
*/
|
||||
function withLedgerLock<T>(ledger: string, callback: () => T): T {
|
||||
function withLedgerLock<T>(ledger: string, callback: () => T, budgetMs: number = LEDGER_LOCK_BUDGET_MS): T {
|
||||
const lock = `${ledger}.lock`;
|
||||
const deadline = Date.now() + 2500;
|
||||
const deadline = Date.now() + Math.max(0, budgetMs);
|
||||
for (;;) {
|
||||
try {
|
||||
fs.mkdirSync(lock);
|
||||
@@ -240,10 +254,19 @@ export function ledgerSizeWarning(ledger: string, size: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
function lockBudget(value: number | undefined): number {
|
||||
if (value === undefined) return LEDGER_LOCK_BUDGET_MS;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw receiptError('Egress receipt lockBudgetMs must be a non-negative finite number');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendChained(
|
||||
homeOrNull: string | null,
|
||||
record: Record<string, unknown>,
|
||||
env?: Env,
|
||||
budgetMs: number = LEDGER_LOCK_BUDGET_MS,
|
||||
): { id: string; path: string } {
|
||||
const home = homeOrNull ?? resolveEgressHome(env);
|
||||
const ledger = egressLedgerPath(home);
|
||||
@@ -256,7 +279,7 @@ function appendChained(
|
||||
fs.appendFileSync(ledger, `${line}\n`, { mode: 0o600 });
|
||||
if (!existed) fs.chmodSync(ledger, 0o600); // umask must not weaken the ledger
|
||||
return { id: sha256Hex(line), path: ledger };
|
||||
});
|
||||
}, budgetMs);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === EGRESS_RECEIPT_FAILED) throw error;
|
||||
throw receiptError(
|
||||
@@ -282,6 +305,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0) throw receiptError('Egress receipt bytes must be a non-negative integer');
|
||||
const sha256 = opts.sha256 ?? null;
|
||||
if (sha256 !== null && !SHA256_HEX.test(String(sha256))) throw receiptError('Egress receipt sha256 must be 64 lowercase hex chars or null');
|
||||
const budgetMs = lockBudget(opts.lockBudgetMs);
|
||||
const home = opts.home ?? resolveEgressHome(opts.env);
|
||||
warnLedgerSizeOnce(egressLedgerPath(home));
|
||||
return appendChained(home, {
|
||||
@@ -293,7 +317,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
|
||||
bytes,
|
||||
sha256,
|
||||
consent,
|
||||
}, opts.env);
|
||||
}, opts.env, budgetMs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,12 +327,13 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
|
||||
*/
|
||||
export function writeOutcome(opts: WriteOutcomeOptions): { id: string; path: string } {
|
||||
const receipt = requireString(opts.receipt, 'receipt id');
|
||||
const budgetMs = lockBudget(opts.lockBudgetMs);
|
||||
return appendChained(opts.home ?? null, {
|
||||
ts: new Date().toISOString(),
|
||||
type: 'outcome',
|
||||
receipt,
|
||||
status: String(opts.status ?? 'unknown'),
|
||||
}, opts.env);
|
||||
}, opts.env, budgetMs);
|
||||
}
|
||||
|
||||
/** Raw parsed lines: [{lineNo, raw, record|null}]. Missing ledger → []. */
|
||||
|
||||
@@ -57,8 +57,16 @@ const POLICY_SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-pol
|
||||
* Fast paths (no subprocess): no store on disk → `none`; no remote URL →
|
||||
* `none` (policy is keyed by origin remote, so nothing can be set for the
|
||||
* repo). Everything else shells to the script, which owns normalization.
|
||||
*
|
||||
* `timeoutMs` bounds that spawn (default 10 s). A caller on its own deadline
|
||||
* (a Claude Code hook) passes what it can afford; a timeout reads as
|
||||
* `unreadable`, and polarity stays the caller's.
|
||||
*/
|
||||
export function repoPolicyTier(url: string | null, env: NodeJS.ProcessEnv = process.env): RepoPolicyResult {
|
||||
export function repoPolicyTier(
|
||||
url: string | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
timeoutMs: number = 10_000,
|
||||
): RepoPolicyResult {
|
||||
if (!hasRepoPolicyStore(env)) return { tier: "none" };
|
||||
if (!url) return { tier: "none" };
|
||||
// The script is `#!/usr/bin/env bash`; win32 can't exec a shebang file, so
|
||||
@@ -67,7 +75,7 @@ export function repoPolicyTier(url: string | null, env: NodeJS.ProcessEnv = proc
|
||||
process.platform === "win32" ? ["bash", [POLICY_SCRIPT, "get", url]] : [POLICY_SCRIPT, ["get", url]];
|
||||
const res = spawnSync(cmd, args, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
timeout: Math.max(1, timeoutMs),
|
||||
// Explicit env: Bun's spawnSync default env snapshot misses runtime
|
||||
// process.env mutations (e.g. tests redirecting GSTACK_HOME).
|
||||
env: { ...env } as NodeJS.ProcessEnv,
|
||||
|
||||
+24
-12
@@ -163,18 +163,28 @@ export function normalizeWithMap(input: string): {
|
||||
|
||||
// ── Offset → line/col on the ORIGINAL text ────────────────────────────────────
|
||||
|
||||
function lineColAt(original: string, offset: number): { line: number; col: number } {
|
||||
let line = 1;
|
||||
let col = 1;
|
||||
for (let i = 0; i < offset && i < original.length; i++) {
|
||||
if (original[i] === "\n") {
|
||||
line += 1;
|
||||
col = 1;
|
||||
} else {
|
||||
col += 1;
|
||||
}
|
||||
/** Start offset of every line, built once per scan and only when a finding needs it. */
|
||||
function lineStarts(original: string): number[] {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < original.length; i++) if (original[i] === "\n") starts.push(i + 1);
|
||||
return starts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary search over lineStarts: O(log lines) per finding. The previous walk
|
||||
* from offset 0 per finding made a match-dense input (a pasted log full of
|
||||
* emails and IPs) cost O(findings x bytes) — seconds for a few hundred KiB.
|
||||
*/
|
||||
function lineColAt(starts: number[], original: string, offset: number): { line: number; col: number } {
|
||||
const at = Math.min(Math.max(0, offset), original.length);
|
||||
let lo = 0;
|
||||
let hi = starts.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (starts[mid] <= at) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return { line, col };
|
||||
return { line: lo + 1, col: at - starts[lo] + 1 };
|
||||
}
|
||||
|
||||
// ── Safe preview masking ──────────────────────────────────────────────────────
|
||||
@@ -318,6 +328,7 @@ function emailAllowed(
|
||||
|
||||
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
|
||||
let starts: number[] | null = null; // line index, built on the first finding
|
||||
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
|
||||
// (NaN from a malformed --max-bytes, or a negative) would make `byteLen >
|
||||
// maxBytes` always false and silently disable the fail-closed oversize guard.
|
||||
@@ -395,7 +406,8 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const { line, col } = lineColAt(input, origOffset);
|
||||
starts ??= lineStarts(input);
|
||||
const { line, col } = lineColAt(starts, input, origOffset);
|
||||
|
||||
// Tool-fence degrade: only credential-category, only obvious doc examples.
|
||||
let severity: Severity = pat.tier;
|
||||
|
||||
Reference in New Issue
Block a user