mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 01:38:10 +01:00
W6 T1 (eng), T1 (DX): shared transcript consent policy and value grammar
lib/transcript-consent.ts reads transcript_ingest_mode and transcript_repos together from every merged state root and returns one policy: off in any root is off, cutoffs and 90-day windows stack, allowlists intersect (EMPTY denies), and a +repos marker without a readable allowlist denies. It also owns the per-session and per-page scope checks, the staged-page purge and the unpublished-range guard, with a --check/--describe/--purge/ --guard-unpublished CLI for gstack-brain-sync. state-root exports its per-root key reader; transcript_ingest_mode stays out of MERGED_CONFIG_KEYS.
This commit is contained in:
1 parent
ac20ef1e13
commit
9c077137ee
3 files changed
+711
-1
No files matched your search
+1
-1
@@ -55,7 +55,7 @@ export const MERGED_CONFIG_KEYS: Record<string, readonly string[]> = {
|
||||
};
|
||||
|
||||
/** Same parse as `gstack-config get`: last `^key:` line wins, value trimmed, empty = unset. */
|
||||
function readKeyFromRoot(root: string, key: string): string | null {
|
||||
export function readKeyFromRoot(root: string, key: string): string | null {
|
||||
let yaml: string;
|
||||
try {
|
||||
yaml = fs.readFileSync(`${root}/config.yaml`, 'utf-8');
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* transcript-consent — the one reader of transcript consent. Reads
|
||||
* `transcript_ingest_mode` and `transcript_repos` together from every root in
|
||||
* mergedStateRoots() and returns one policy that gstack-memory-ingest,
|
||||
* gstack-gbrain-sync and gstack-brain-sync (through this file's CLI) share.
|
||||
*
|
||||
* Value grammar: `<base>[@<YYYY-MM-DDTHH:MM:SSZ>][+repos]`, base recent | all |
|
||||
* off | new. `@<time>` is required for `new` and invalid otherwise; `off+repos`
|
||||
* is invalid. Readers parse case-insensitively. `+repos` marks a value whose
|
||||
* scope lives in `transcript_repos`, so a gstack that predates the allowlist
|
||||
* reads it as unrecognized and ingests nothing.
|
||||
*
|
||||
* Across roots: a non-consenting value in any root wins (off, legacy,
|
||||
* unrecognized). The resolved root must hold the consent itself. Time
|
||||
* constraints stack (a 90-day window from any `recent`, the latest `new@`
|
||||
* cutoff), and the repo set is the intersection of every root's allowlist.
|
||||
*
|
||||
* CLI (bin/gstack-brain-sync):
|
||||
* --check exit 0 iff transcript pages may sync at all
|
||||
* --describe print the effective consent in words
|
||||
* --purge remove unpublished staged pages outside a scoped consent
|
||||
* --guard-unpublished drop excluded pages from unpublished sync commits;
|
||||
* exit 3 when the push must not happen
|
||||
* Docs: setup-gbrain/memory.md#transcripts.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { canonicalizeRemote } from './gstack-memory-helpers';
|
||||
import { mergedStateRoots, readKeyFromRoot, resolveStateRoot } from './state-root';
|
||||
|
||||
export const MODE_KEY = 'transcript_ingest_mode';
|
||||
export const REPOS_KEY = 'transcript_repos';
|
||||
const BRAIN_SYNC_AUTHOR = 'gstack-brain-sync';
|
||||
const MODE_RE = /^(recent|all|off|new)(?:@(\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}z))?(\+repos)?$/i;
|
||||
const ROLLING_DAYS = 90;
|
||||
|
||||
export type TranscriptBase = 'recent' | 'all' | 'off' | 'new';
|
||||
|
||||
export interface TranscriptModeValue {
|
||||
base: TranscriptBase;
|
||||
/** ISO-8601 UTC cutoff (`new` only). */
|
||||
cutoff: string | null;
|
||||
/** The value carries the `+repos` allowlist marker. */
|
||||
repos: boolean;
|
||||
}
|
||||
|
||||
/** True when `s` is exactly `YYYY-MM-DDTHH:MM:SSZ` and names a real instant. */
|
||||
export function isUtcSecond(s: string): boolean {
|
||||
const ms = Date.parse(s);
|
||||
return Number.isFinite(ms) && new Date(ms).toISOString().replace('.000Z', 'Z') === s;
|
||||
}
|
||||
|
||||
/** Parse one stored value (case-insensitive). null = not valid grammar. */
|
||||
export function parseTranscriptMode(raw: string): TranscriptModeValue | null {
|
||||
const m = MODE_RE.exec(raw.trim());
|
||||
if (!m) return null;
|
||||
const base = m[1].toLowerCase() as TranscriptBase;
|
||||
const cutoff = m[2] ? m[2].toUpperCase() : null;
|
||||
if ((base === 'new') !== (cutoff !== null)) return null;
|
||||
if (cutoff !== null && !isUtcSecond(cutoff)) return null;
|
||||
if (base === 'off' && m[3]) return null;
|
||||
return { base, cutoff, repos: m[3] !== undefined };
|
||||
}
|
||||
|
||||
export type TranscriptConsentReason =
|
||||
| 'recent'
|
||||
| 'all'
|
||||
| 'new'
|
||||
| 'off'
|
||||
| 'not-set'
|
||||
| 'legacy'
|
||||
| 'unrecognized'
|
||||
| 'repos-unreadable';
|
||||
|
||||
export interface TranscriptConsent {
|
||||
/** Every root consents; transcripts may be walked without an override. */
|
||||
affirmative: boolean;
|
||||
/** recent = rolling 90 days (mtime, as before), new = cutoff only, all = no time limit. */
|
||||
window: 'recent' | 'all' | 'new' | null;
|
||||
reason: TranscriptConsentReason;
|
||||
/** The resolved root's stored value (null when absent). */
|
||||
value: string | null;
|
||||
/** Latest `new@` cutoff across roots. Applies even under an explicit override. */
|
||||
cutoff?: string;
|
||||
/** Canonical remotes allowed across roots ([] = none). Applies even under an override. */
|
||||
repos?: string[];
|
||||
}
|
||||
|
||||
export interface TranscriptPolicy extends TranscriptConsent {
|
||||
/** Roots whose config.yaml sets either key. */
|
||||
roots: string[];
|
||||
}
|
||||
|
||||
export type TranscriptExclusion = 'pre-cutoff' | 'missing-start' | 'not-allowlisted';
|
||||
|
||||
export function parseRepoList(raw: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const part of raw.split(',')) {
|
||||
const remote = canonicalizeRemote(part);
|
||||
if (remote && !out.includes(remote)) out.push(remote);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read the effective transcript consent across `roots` (resolved root first). */
|
||||
export function readTranscriptConsent(roots: string[] = mergedStateRoots()): TranscriptPolicy {
|
||||
const resolvedValue = roots.length > 0 ? readKeyFromRoot(roots[0], MODE_KEY) : null;
|
||||
let reason: TranscriptConsentReason | null = resolvedValue === null ? 'not-set' : null;
|
||||
let rolling = false;
|
||||
let cutoff: string | undefined;
|
||||
let repos: string[] | undefined;
|
||||
const contributing: string[] = [];
|
||||
for (const root of roots) {
|
||||
const raw = readKeyFromRoot(root, MODE_KEY);
|
||||
const list = readKeyFromRoot(root, REPOS_KEY);
|
||||
if (raw === null && list === null) continue;
|
||||
contributing.push(root);
|
||||
let marker = false;
|
||||
if (raw !== null) {
|
||||
const v = raw.trim().toLowerCase();
|
||||
const parsed = parseTranscriptMode(v);
|
||||
let rootReason: TranscriptConsentReason | null = null;
|
||||
if (!parsed) rootReason = /^[a-e]$/.test(v) || v === 'incremental' ? 'legacy' : 'unrecognized';
|
||||
else if (parsed.base === 'off') rootReason = 'off';
|
||||
else {
|
||||
marker = parsed.repos;
|
||||
if (parsed.base === 'recent') rolling = true;
|
||||
if (parsed.cutoff && (!cutoff || parsed.cutoff > cutoff)) cutoff = parsed.cutoff;
|
||||
}
|
||||
reason ??= rootReason;
|
||||
}
|
||||
const entries = list === null ? [] : parseRepoList(list);
|
||||
if (entries.length > 0) repos = repos ? repos.filter((r) => entries.includes(r)) : entries;
|
||||
else if (marker) {
|
||||
repos = [];
|
||||
reason ??= 'repos-unreadable';
|
||||
}
|
||||
}
|
||||
const affirmative = reason === null;
|
||||
const window = !affirmative ? null : rolling ? 'recent' : cutoff ? 'new' : 'all';
|
||||
const policy: TranscriptPolicy = {
|
||||
affirmative,
|
||||
window,
|
||||
reason: reason ?? (window as TranscriptConsentReason),
|
||||
value: resolvedValue,
|
||||
roots: contributing,
|
||||
};
|
||||
if (cutoff !== undefined) policy.cutoff = cutoff;
|
||||
if (repos !== undefined) policy.repos = repos;
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** A cutoff or an allowlist narrows consent to some sessions. */
|
||||
export function isScoped(c: TranscriptConsent): boolean {
|
||||
return c.cutoff !== undefined || c.repos !== undefined;
|
||||
}
|
||||
|
||||
/** Transcript pages may sync at all: consent, and a repo set that is not empty. */
|
||||
export function allowsTranscripts(c: TranscriptConsent): boolean {
|
||||
return c.affirmative && !(c.repos !== undefined && c.repos.length === 0);
|
||||
}
|
||||
|
||||
/** Short stable id of the allowlist, recorded with a memory stage so a scope change restages. */
|
||||
export function reposHash(repos: string[]): string {
|
||||
return createHash('sha256').update([...repos].sort().join('\n')).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/** The `new@` cutoff check on a session's first record timestamp (never mtime). */
|
||||
export function cutoffExclusion(c: TranscriptConsent, startedAt: string | null | undefined): 'pre-cutoff' | 'missing-start' | null {
|
||||
if (c.cutoff === undefined) return null;
|
||||
const ms = startedAt ? Date.parse(startedAt) : NaN;
|
||||
if (!Number.isFinite(ms)) return 'missing-start';
|
||||
return ms < Date.parse(c.cutoff) ? 'pre-cutoff' : null;
|
||||
}
|
||||
|
||||
/** The allowlist check on a session's canonical git remote. */
|
||||
export function repoExclusion(c: TranscriptConsent, remote: string | null | undefined): 'not-allowlisted' | null {
|
||||
if (c.repos === undefined) return null;
|
||||
const canonical = canonicalizeRemote(remote ?? '');
|
||||
return canonical && c.repos.includes(canonical) ? null : 'not-allowlisted';
|
||||
}
|
||||
|
||||
/** Why one session is outside the scope, or null when it is inside. Consent itself is checked by callers. */
|
||||
export function sessionExclusion(
|
||||
c: TranscriptConsent,
|
||||
session: { startedAt?: string | null; remote?: string | null },
|
||||
): TranscriptExclusion | null {
|
||||
return cutoffExclusion(c, session.startedAt) ?? repoExclusion(c, session.remote);
|
||||
}
|
||||
|
||||
function formatCutoff(cutoff: string): string {
|
||||
return `${cutoff.slice(0, 10)} ${cutoff.slice(11, 19)} UTC`;
|
||||
}
|
||||
|
||||
/** The effective consent in words: window or cutoff, up to 3 repos, contributing roots. */
|
||||
export function describeTranscriptPolicy(p: TranscriptPolicy): string {
|
||||
let time: string;
|
||||
if (p.window === 'recent') time = `sessions from the last ${ROLLING_DAYS} days${p.cutoff ? ` that started after ${formatCutoff(p.cutoff)}` : ''}`;
|
||||
else if (p.cutoff) time = `sessions that started after ${formatCutoff(p.cutoff)}`;
|
||||
else time = 'all history';
|
||||
let scope: string;
|
||||
if (p.repos === undefined) scope = 'every repo your repo policy allows';
|
||||
else if (p.repos.length === 0) scope = 'no repo (the repo allowlists share no repo, or one is unreadable)';
|
||||
else {
|
||||
const shown = p.repos.slice(0, 3).join(', ');
|
||||
scope = `${p.repos.length === 1 ? 'repo' : 'repos'} ${shown}${p.repos.length > 3 ? ` +${p.repos.length - 3} more` : ''}`;
|
||||
}
|
||||
const roots = p.roots.length > 0 ? `; config: ${p.roots.join(', ')}` : '';
|
||||
if (!p.affirmative) return `not consented (${p.reason}); scope if overridden: ${time}; ${scope}${roots}`;
|
||||
return `${time}; ${scope}${roots}`;
|
||||
}
|
||||
|
||||
// ── Staged pages ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Frontmatter fields of a staged page (flat `key: value` lines). */
|
||||
export function pageFrontmatter(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!text.startsWith('---\n')) return out;
|
||||
const end = text.indexOf('\n---', 4);
|
||||
if (end < 0) return out;
|
||||
for (const line of text.slice(4, end).split('\n')) {
|
||||
const m = /^([a-z_]+):\s?(.*)$/.exec(line);
|
||||
if (m) out[m[1]] = m[2].trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a staged page under transcripts/ must not sync, or null. Pages typed as
|
||||
* another memory type (artifacts staged in a remote-http run dir) are never
|
||||
* excluded here. Without consent every transcript page is excluded.
|
||||
*/
|
||||
export function pageExclusion(c: TranscriptConsent, text: string): TranscriptExclusion | 'not-consented' | null {
|
||||
const fm = pageFrontmatter(text);
|
||||
if (fm.type && fm.type !== 'transcript') return null;
|
||||
if (!c.affirmative) return 'not-consented';
|
||||
return sessionExclusion(c, { startedAt: fm.session_started_at || fm.start_time, remote: fm.git_remote });
|
||||
}
|
||||
|
||||
function git(home: string, args: string[]): { ok: boolean; out: string } {
|
||||
const r = spawnSync('git', ['-C', home, ...args], { encoding: 'utf-8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return { ok: r.status === 0, out: r.stdout || '' };
|
||||
}
|
||||
|
||||
/** origin/<branch> for the checked-out branch, or null (no repo, detached, never pushed). */
|
||||
function upstreamRef(home: string): string | null {
|
||||
if (!fs.existsSync(path.join(home, '.git'))) return null;
|
||||
const branch = git(home, ['rev-parse', '--abbrev-ref', 'HEAD']).out.trim();
|
||||
if (!branch || branch === 'HEAD') return null;
|
||||
const ref = `origin/${branch}`;
|
||||
return git(home, ['rev-parse', '--verify', '--quiet', ref]).ok ? ref : null;
|
||||
}
|
||||
|
||||
function walkMarkdown(dir: string, out: string[] = []): string[] {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walkMarkdown(full, out);
|
||||
else if (e.isFile() && e.name.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop ingest fingerprints so a later widening re-stages these sources. */
|
||||
function clearFingerprints(home: string, sourcePaths: string[]): void {
|
||||
if (sourcePaths.length === 0) return;
|
||||
const statePath = path.join(home, '.transcript-ingest-state.json');
|
||||
let state: { sessions?: Record<string, unknown> };
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!state.sessions) return;
|
||||
let changed = false;
|
||||
for (const p of sourcePaths) {
|
||||
if (p in state.sessions) {
|
||||
delete state.sessions[p];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
const tmp = `${statePath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
|
||||
fs.renameSync(tmp, statePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove staged transcript pages that are not yet published and fall outside
|
||||
* a scoped consent (cutoff or allowlist). Without consent pages are kept, as
|
||||
* before: they wait in the queue until the user consents. Published pages are
|
||||
* left alone; removing them from the brain is a separate operation.
|
||||
*/
|
||||
export function purgeStagedTranscripts(home: string, c: TranscriptConsent): number {
|
||||
if (!c.affirmative || !isScoped(c)) return 0;
|
||||
const dir = path.join(home, 'transcripts');
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
const ref = upstreamRef(home);
|
||||
const published = new Set(
|
||||
ref ? git(home, ['ls-tree', '-r', '--name-only', ref, '--', 'transcripts']).out.split('\n').filter(Boolean) : [],
|
||||
);
|
||||
const sources: string[] = [];
|
||||
let removed = 0;
|
||||
for (const file of walkMarkdown(dir)) {
|
||||
const rel = path.relative(home, file).split(path.sep).join('/');
|
||||
if (published.has(rel)) continue;
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (pageExclusion(c, text) === null) continue;
|
||||
fs.rmSync(file, { force: true });
|
||||
removed++;
|
||||
const source = pageFrontmatter(text).source_path;
|
||||
if (source) sources.push(source);
|
||||
}
|
||||
clearFingerprints(home, sources);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Queue a path again (same record shape as bin/gstack-brain-enqueue). */
|
||||
function enqueue(home: string, rel: string, seq: number): void {
|
||||
const spool = path.join(home, '.brain-queue.d');
|
||||
fs.mkdirSync(spool, { recursive: true });
|
||||
const tmp = path.join(spool, `.tmp-${process.pid}-g${seq}`);
|
||||
fs.writeFileSync(tmp, JSON.stringify({ file: rel, ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') }) + '\n');
|
||||
fs.renameSync(tmp, path.join(spool, `${Math.floor(Date.now() / 1000)}-${process.pid}-g${seq}.json`));
|
||||
}
|
||||
|
||||
export interface GuardResult {
|
||||
status: 'clean' | 'rewritten' | 'blocked';
|
||||
removed: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a push: scan the unpublished range for transcript pages outside the
|
||||
* current consent. When some exist and every unpublished commit is
|
||||
* gstack-brain-sync's own, soft-reset to the upstream, unstage the excluded
|
||||
* pages and recommit, so nothing excluded is ever pushed. Otherwise block.
|
||||
* Under a scoped consent the excluded pages are also purged from disk (with
|
||||
* their fingerprints). Pages removed while consent is absent stay on disk and
|
||||
* are queued again, so they wait for consent like never-committed pages.
|
||||
*/
|
||||
export function guardUnpublished(home: string, c: TranscriptConsent): GuardResult {
|
||||
if (!fs.existsSync(path.join(home, '.git'))) return { status: 'clean', removed: [] };
|
||||
const ref = upstreamRef(home);
|
||||
const range = ref ? `${ref}..HEAD` : 'HEAD';
|
||||
const touched = [...new Set(git(home, ['log', '--format=', '--name-only', range, '--', 'transcripts']).out.split('\n').filter(Boolean))];
|
||||
if (touched.length === 0) return { status: 'clean', removed: [] };
|
||||
purgeStagedTranscripts(home, c);
|
||||
const inHead = new Set(git(home, ['ls-tree', '-r', '--name-only', 'HEAD', '--', 'transcripts']).out.split('\n').filter(Boolean));
|
||||
const excluded: string[] = [];
|
||||
for (const rel of touched) {
|
||||
let text: string | null = null;
|
||||
if (inHead.has(rel)) text = git(home, ['show', `HEAD:${rel}`]).out;
|
||||
else {
|
||||
const sha = git(home, ['log', '-n1', '--format=%H', '--diff-filter=AM', range, '--', rel]).out.trim();
|
||||
if (sha) text = git(home, ['show', `${sha}:${rel}`]).out;
|
||||
}
|
||||
if (text !== null && pageExclusion(c, text) !== null) excluded.push(rel);
|
||||
}
|
||||
if (excluded.length === 0) return { status: 'clean', removed: [] };
|
||||
const authors = new Set(git(home, ['log', '--no-merges', '--format=%an', range]).out.split('\n').filter(Boolean));
|
||||
if (!ref) return { status: 'blocked', removed: [], message: `push blocked: ${excluded.length} transcript page(s) outside your transcript consent are in unpushed commits and there is no upstream branch to rewrite against` };
|
||||
if ([...authors].some((a) => a !== BRAIN_SYNC_AUTHOR)) {
|
||||
return { status: 'blocked', removed: [], message: `push blocked: ${excluded.length} transcript page(s) outside your transcript consent are in unpushed commits that include your own commits; remove them (git rm --cached) and amend, then sync again` };
|
||||
}
|
||||
if (!git(home, ['diff', '--cached', '--quiet']).ok) {
|
||||
return { status: 'blocked', removed: [], message: 'push blocked: excluded transcript pages are in unpushed commits and the index has other staged changes' };
|
||||
}
|
||||
if (!git(home, ['reset', '-q', '--soft', ref]).ok) return { status: 'blocked', removed: [], message: 'push blocked: could not rewrite unpushed sync commits' };
|
||||
const headExcluded = excluded.filter((rel) => inHead.has(rel));
|
||||
const published = new Set(git(home, ['ls-tree', '-r', '--name-only', ref, '--', ...headExcluded]).out.split('\n').filter(Boolean));
|
||||
const restore = headExcluded.filter((rel) => published.has(rel));
|
||||
const drop = headExcluded.filter((rel) => !published.has(rel));
|
||||
if (restore.length > 0) git(home, ['reset', '-q', ref, '--', ...restore]);
|
||||
if (drop.length > 0) git(home, ['rm', '-q', '--cached', '--ignore-unmatch', '--', ...drop]);
|
||||
if (!git(home, ['diff', '--cached', '--quiet']).ok) {
|
||||
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const commit = spawnSync('git', ['-C', home, '-c', 'user.email=gstack@localhost', '-c', `user.name=${BRAIN_SYNC_AUTHOR}`, 'commit', '-q', '-m', `sync: rewritten without ${excluded.length} excluded transcript page(s) | ${ts}`], { encoding: 'utf-8', timeout: 30_000 });
|
||||
if (commit.status !== 0) return { status: 'blocked', removed: [], message: 'push blocked: could not recommit unpushed sync changes' };
|
||||
}
|
||||
if (!c.affirmative) {
|
||||
let seq = 0;
|
||||
for (const rel of drop) if (fs.existsSync(path.join(home, rel))) enqueue(home, rel, ++seq);
|
||||
}
|
||||
return { status: 'rewritten', removed: excluded };
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const cmd = process.argv[2];
|
||||
const home = resolveStateRoot();
|
||||
const policy = readTranscriptConsent();
|
||||
if (cmd === '--check') process.exit(allowsTranscripts(policy) ? 0 : 1);
|
||||
if (cmd === '--describe') {
|
||||
console.log(describeTranscriptPolicy(policy));
|
||||
process.exit(0);
|
||||
}
|
||||
if (cmd === '--purge') {
|
||||
const n = purgeStagedTranscripts(home, policy);
|
||||
if (n > 0) console.error(`BRAIN_SYNC: removed ${n} staged transcript pages outside the new scope`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (cmd === '--guard-unpublished') {
|
||||
const r = guardUnpublished(home, policy);
|
||||
if (r.status === 'rewritten') console.error(`BRAIN_SYNC: removed ${r.removed.length} excluded transcript pages from unpublished sync commits`);
|
||||
if (r.status === 'blocked') {
|
||||
console.error(`BRAIN_SYNC: ${r.message}`);
|
||||
process.exit(3);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('Usage: bun run lib/transcript-consent.ts --check | --describe | --purge | --guard-unpublished');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* lib/transcript-consent.ts: the value grammar, the multi-root policy, the
|
||||
* per-session and per-page scope checks, and the parity of the bash validator
|
||||
* and remote canonicalizer in bin/gstack-config with their TS twins.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { canonicalizeRemote } from '../lib/gstack-memory-helpers';
|
||||
import {
|
||||
describeTranscriptPolicy,
|
||||
pageExclusion,
|
||||
parseTranscriptMode,
|
||||
readTranscriptConsent,
|
||||
reposHash,
|
||||
sessionExclusion,
|
||||
type TranscriptConsent,
|
||||
} from '../lib/transcript-consent';
|
||||
|
||||
const CONFIG_BIN = join(import.meta.dir, '..', 'bin', 'gstack-config');
|
||||
const TS = '2026-10-03T17:00:00Z';
|
||||
|
||||
/** Every grammar combination: base x (cutoff | none) x (+repos | none). */
|
||||
function combinations(): Array<{ value: string; valid: boolean }> {
|
||||
const out: Array<{ value: string; valid: boolean }> = [];
|
||||
for (const base of ['recent', 'all', 'off', 'new']) {
|
||||
for (const at of ['', `@${TS}`]) {
|
||||
for (const marker of ['', '+repos']) {
|
||||
const valid = (base === 'new') === (at !== '') && !(base === 'off' && marker !== '');
|
||||
out.push({ value: `${base}${at}${marker}`, valid });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const INVALID_FORMS = [
|
||||
`new@2026-10-03T17:00:00+00:00`, // offsets are rejected; only Z
|
||||
`new@2026-10-03T17:00:00.000Z`, // milliseconds are not part of the grammar
|
||||
`new@2026-10-03 17:00:00Z`,
|
||||
`new@2026-10-03`,
|
||||
`new@2026-02-30T00:00:00Z`, // not a real day
|
||||
`new@2026-13-01T00:00:00Z`,
|
||||
`new@2026-10-03T24:00:00Z`,
|
||||
`new+repos@${TS}`, // @ precedes +repos
|
||||
`new@`,
|
||||
'recent+repo',
|
||||
'yes',
|
||||
'A',
|
||||
'incremental',
|
||||
'',
|
||||
];
|
||||
|
||||
describe('grammar', () => {
|
||||
test('every base x cutoff x marker combination', () => {
|
||||
for (const { value, valid } of combinations()) {
|
||||
const parsed = parseTranscriptMode(value);
|
||||
expect({ value, valid: parsed !== null }).toEqual({ value, valid });
|
||||
if (parsed) {
|
||||
expect(parsed.base).toBe(value.split(/[@+]/)[0] as never);
|
||||
expect(parsed.cutoff).toBe(value.includes('@') ? TS : null);
|
||||
expect(parsed.repos).toBe(value.endsWith('+repos'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('readers are case-insensitive; offsets, bad dates and misplaced parts are rejected', () => {
|
||||
expect(parseTranscriptMode(`NEW@2026-10-03t17:00:00z+REPOS`)).toEqual({ base: 'new', cutoff: TS, repos: true });
|
||||
expect(parseTranscriptMode(' Recent ')).toEqual({ base: 'recent', cutoff: null, repos: false });
|
||||
expect(parseTranscriptMode('new@2028-02-29T00:00:00Z')).not.toBeNull();
|
||||
for (const value of INVALID_FORMS) expect({ value, parsed: parseTranscriptMode(value) }).toEqual({ value, parsed: null });
|
||||
});
|
||||
|
||||
test('gstack-config set accepts exactly the canonical valid forms and rejects the rest with the file unchanged', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gstack-grammar-'));
|
||||
try {
|
||||
const env = { ...process.env, GSTACK_STATE_ROOT: root };
|
||||
const run = (...args: string[]) => spawnSync('bash', [CONFIG_BIN, ...args], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
expect(run('set', 'transcript_repos', 'github.com/acme/app').status).toBe(0);
|
||||
for (const { value, valid } of combinations()) {
|
||||
const r = run('set', 'transcript_ingest_mode', value);
|
||||
expect({ value, status: r.status }).toEqual({ value, status: valid ? 0 : 1 });
|
||||
}
|
||||
run('set', 'transcript_ingest_mode', 'recent');
|
||||
for (const value of [...INVALID_FORMS.filter(Boolean), 'Recent', `new@2026-10-03t17:00:00z`]) {
|
||||
const r = run('set', 'transcript_ingest_mode', value);
|
||||
expect({ value, status: r.status }).toEqual({ value, status: 1 });
|
||||
expect(r.stderr).toContain(`Error: transcript_ingest_mode '${value}' not recognized. Valid values: recent, all, off. Existing value left unchanged.`);
|
||||
expect(r.stderr).toContain('new@<YYYY-MM-DDTHH:MM:SSZ>');
|
||||
}
|
||||
expect(run('get', 'transcript_ingest_mode').stdout).toBe('recent+repos');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the bash remote canonicalizer matches canonicalizeRemote', () => {
|
||||
const inputs = [
|
||||
'https://github.com/Acme/App.git',
|
||||
'git@github.com:acme/app.git',
|
||||
'ssh://git@github.com/acme/app',
|
||||
'http://user@gitlab.example.com/group/sub/repo.git/',
|
||||
' "github.com/acme/app/" ',
|
||||
'github.com//acme///app',
|
||||
'/srv/git/repo/.git',
|
||||
'file:///srv/git/repo',
|
||||
];
|
||||
const script = `. /dev/null; source <(sed -n '/^canonical_remote() {/,/^}/p' "${CONFIG_BIN}"); for x in "$@"; do canonical_remote "$x"; echo; done`;
|
||||
const r = spawnSync('bash', ['-c', script, 'bash', ...inputs], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.split('\n').slice(0, inputs.length)).toEqual(inputs.map((i) => canonicalizeRemote(i)));
|
||||
});
|
||||
});
|
||||
|
||||
/** The normalizer shipped before this change (bin/gstack-memory-ingest.ts at v1.91.16.0), verbatim in behavior. */
|
||||
function olderNormalizerConsents(value: string | null): boolean {
|
||||
const v = (value ?? '').trim().toLowerCase();
|
||||
return value !== null && (v === 'recent' || v === 'all');
|
||||
}
|
||||
|
||||
describe('fail-closed encoding', () => {
|
||||
test('a gstack without new@ or allowlist support reads every new value form as no consent', () => {
|
||||
for (const value of [`new@${TS}`, `new@${TS}+repos`, 'recent+repos', 'all+repos']) {
|
||||
expect({ value, consents: olderNormalizerConsents(value) }).toEqual({ value, consents: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('the current normalizer treats any unrecognized value as no consent', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gstack-consent-'));
|
||||
try {
|
||||
for (const value of ['yes', 'new', `recent@${TS}`, 'off+repos', 'new@tomorrow', 'recent+repos+repos']) {
|
||||
writeFileSync(join(root, 'config.yaml'), `transcript_ingest_mode: ${value}\n`);
|
||||
expect(readTranscriptConsent([root])).toMatchObject({ affirmative: false, reason: 'unrecognized', value });
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('effective consent across state roots', () => {
|
||||
const roots: string[] = [];
|
||||
const root = (yaml: string | null) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-root-'));
|
||||
roots.push(dir);
|
||||
if (yaml !== null) writeFileSync(join(dir, 'config.yaml'), yaml);
|
||||
return dir;
|
||||
};
|
||||
const cleanup = () => roots.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
||||
|
||||
test('off in one root plus new@ in another gives no consent', () => {
|
||||
try {
|
||||
const p = readTranscriptConsent([root(`transcript_ingest_mode: new@${TS}\n`), root('transcript_ingest_mode: off\n')]);
|
||||
expect(p).toMatchObject({ affirmative: false, reason: 'off', cutoff: TS });
|
||||
const q = readTranscriptConsent([root('transcript_ingest_mode: off\n'), root(`transcript_ingest_mode: new@${TS}\n`)]);
|
||||
expect(q).toMatchObject({ affirmative: false, reason: 'off' });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('time constraints stack: the latest cutoff and any 90-day window', () => {
|
||||
try {
|
||||
const later = '2026-11-01T00:00:00Z';
|
||||
expect(readTranscriptConsent([root(`transcript_ingest_mode: new@${TS}\n`), root(`transcript_ingest_mode: new@${later}\n`)]))
|
||||
.toMatchObject({ affirmative: true, window: 'new', reason: 'new', cutoff: later });
|
||||
expect(readTranscriptConsent([root('transcript_ingest_mode: all\n'), root(`transcript_ingest_mode: new@${TS}\n`)]))
|
||||
.toMatchObject({ affirmative: true, window: 'new', cutoff: TS });
|
||||
expect(readTranscriptConsent([root(`transcript_ingest_mode: new@${TS}\n`), root('transcript_ingest_mode: recent\n')]))
|
||||
.toMatchObject({ affirmative: true, window: 'recent', cutoff: TS });
|
||||
const all = readTranscriptConsent([root('transcript_ingest_mode: all\n'), root(null)]);
|
||||
expect(all).toMatchObject({ affirmative: true, window: 'all' });
|
||||
expect(all.cutoff).toBeUndefined();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('allowlists intersect; disjoint lists give EMPTY and nothing is ingested', () => {
|
||||
try {
|
||||
const a = root('transcript_ingest_mode: recent+repos\ntranscript_repos: github.com/acme/app,github.com/acme/api\n');
|
||||
const b = root('transcript_repos: https://github.com/acme/api.git,github.com/acme/web\n');
|
||||
expect(readTranscriptConsent([a, b])).toMatchObject({ affirmative: true, repos: ['github.com/acme/api'], roots: [a, b] });
|
||||
const c = root('transcript_ingest_mode: all+repos\ntranscript_repos: github.com/other/x\n');
|
||||
const empty = readTranscriptConsent([a, c]);
|
||||
expect(empty.repos).toEqual([]);
|
||||
for (const remote of ['github.com/acme/app', 'github.com/other/x', '']) {
|
||||
expect(sessionExclusion(empty, { startedAt: TS, remote })).toBe('not-allowlisted');
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a +repos marker without a readable allowlist denies', () => {
|
||||
try {
|
||||
for (const yaml of ['transcript_ingest_mode: recent+repos\n', 'transcript_ingest_mode: recent+repos\ntranscript_repos: \n', 'transcript_ingest_mode: all+repos\ntranscript_repos: ,\n']) {
|
||||
expect(readTranscriptConsent([root(yaml)])).toMatchObject({ affirmative: false, reason: 'repos-unreadable', repos: [] });
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('the resolved root must hold the consent; other roots only restrict', () => {
|
||||
try {
|
||||
expect(readTranscriptConsent([root(null), root('transcript_ingest_mode: all\n')])).toMatchObject({ affirmative: false, reason: 'not-set' });
|
||||
expect(readTranscriptConsent([root('transcript_ingest_mode: recent\n'), root('transcript_ingest_mode: A\n')])).toMatchObject({ affirmative: false, reason: 'legacy' });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope checks', () => {
|
||||
const scoped: TranscriptConsent = { affirmative: true, window: 'new', reason: 'new', value: `new@${TS}+repos`, cutoff: TS, repos: ['github.com/acme/app'] };
|
||||
|
||||
test('sessions: the cutoff uses the start time; the allowlist uses the canonical remote', () => {
|
||||
expect(sessionExclusion(scoped, { startedAt: '2026-10-03T16:59:59Z', remote: 'github.com/acme/app' })).toBe('pre-cutoff');
|
||||
expect(sessionExclusion(scoped, { startedAt: TS, remote: 'git@github.com:Acme/App.git' })).toBeNull();
|
||||
expect(sessionExclusion(scoped, { startedAt: '', remote: 'github.com/acme/app' })).toBe('missing-start');
|
||||
expect(sessionExclusion(scoped, { startedAt: 'not a date', remote: 'github.com/acme/app' })).toBe('missing-start');
|
||||
expect(sessionExclusion(scoped, { startedAt: '2026-10-04T00:00:00Z', remote: 'github.com/acme/web' })).toBe('not-allowlisted');
|
||||
expect(sessionExclusion({ affirmative: true, window: 'recent', reason: 'recent', value: 'recent' }, { startedAt: null, remote: null })).toBeNull();
|
||||
});
|
||||
|
||||
test('pages: frontmatter decides; artifact pages are never excluded; no consent excludes every transcript page', () => {
|
||||
const page = (fm: string) => `---\n${fm}\n---\n\nbody\n`;
|
||||
expect(pageExclusion(scoped, page(`type: transcript\nsession_started_at: 2026-10-04T00:00:00.000Z\ngit_remote: github.com/acme/app`))).toBeNull();
|
||||
expect(pageExclusion(scoped, page(`type: transcript\nstart_time: 2026-09-01T00:00:00Z\ngit_remote: github.com/acme/app`))).toBe('pre-cutoff');
|
||||
expect(pageExclusion(scoped, page('type: transcript\ngit_remote: github.com/acme/app'))).toBe('missing-start');
|
||||
expect(pageExclusion(scoped, page('type: learning'))).toBeNull();
|
||||
expect(pageExclusion(scoped, '# no frontmatter\n')).toBe('missing-start');
|
||||
const recent: TranscriptConsent = { affirmative: true, window: 'recent', reason: 'recent', value: 'recent' };
|
||||
expect(pageExclusion(recent, '# no frontmatter\n')).toBeNull();
|
||||
const off: TranscriptConsent = { affirmative: false, window: null, reason: 'off', value: 'off' };
|
||||
expect(pageExclusion(off, page('type: transcript'))).toBe('not-consented');
|
||||
});
|
||||
|
||||
test('the allowlist hash is order-independent', () => {
|
||||
expect(reposHash(['b', 'a'])).toBe(reposHash(['a', 'b']));
|
||||
expect(reposHash(['a'])).not.toBe(reposHash(['a', 'b']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('consent in words', () => {
|
||||
test('window or cutoff, up to three repos then +N more, contributing roots', () => {
|
||||
const base = { affirmative: true, reason: 'new' as const, value: 'x', roots: ['/r1', '/r2'] };
|
||||
expect(describeTranscriptPolicy({ ...base, window: 'recent', reason: 'recent' })).toBe('sessions from the last 90 days; every repo your repo policy allows; config: /r1, /r2');
|
||||
expect(describeTranscriptPolicy({ ...base, window: 'new', cutoff: TS, repos: ['a/b/c', 'd/e/f', 'g/h/i', 'j/k/l', 'm/n/o'] }))
|
||||
.toBe('sessions that started after 2026-10-03 17:00:00 UTC; repos a/b/c, d/e/f, g/h/i +2 more; config: /r1, /r2');
|
||||
expect(describeTranscriptPolicy({ ...base, window: 'all', reason: 'all', repos: ['a/b/c'], roots: [] })).toBe('all history; repo a/b/c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI', () => {
|
||||
test('--check exits 0 only when transcript pages may sync', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gstack-check-'));
|
||||
mkdirSync(join(root, 'legacy'));
|
||||
try {
|
||||
const check = () =>
|
||||
spawnSync('bun', ['run', join(import.meta.dir, '..', 'lib', 'transcript-consent.ts'), '--check'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, GSTACK_STATE_ROOT: root, GSTACK_TEST_LEGACY_ROOT: join(root, 'legacy') },
|
||||
}).status;
|
||||
expect(check()).toBe(1);
|
||||
for (const [yaml, status] of [
|
||||
['transcript_ingest_mode: recent\n', 0],
|
||||
[`transcript_ingest_mode: new@${TS}\n`, 0],
|
||||
['transcript_ingest_mode: off\n', 1],
|
||||
['transcript_ingest_mode: recent+repos\n', 1],
|
||||
['transcript_ingest_mode: recent+repos\ntranscript_repos: github.com/a/b\n', 0],
|
||||
] as const) {
|
||||
writeFileSync(join(root, 'config.yaml'), yaml);
|
||||
expect({ yaml, status: check() }).toEqual({ yaml, status });
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user