Files
gstack/test/gstack-config-transcript-scope.test.ts
T
garrytan a95f5fa209 W6 T3 (eng), T2 (DX): gstack-config lock, unset and transcript_repos
Every config.yaml mutation (set, unset, the allowlist pair, the user-slug
writer) holds one mkdir lock (pid file, stale after 10 s or a dead holder)
and replaces the file with one rename. set validates the full
transcript_ingest_mode grammar (new@<UTC>, +repos) with the existing error
shape; transcript_repos stores canonical remotes, rejects an empty list and
keeps the +repos marker in step in the same write; unset removes a key.
2026-10-03 21:14:47 +00:00

162 lines
7.7 KiB
TypeScript

/**
* bin/gstack-config writes: the transcript_repos allowlist and its +repos
* marker change together in one locked rename, `unset` removes a key, and
* every mutation goes through one mkdir lock with a read-modify-write rename,
* so concurrent writers lose nothing and an interrupted write leaves the old
* file.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawn, spawnSync } from 'child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const CONFIG_BIN = join(import.meta.dir, '..', 'bin', 'gstack-config');
let root: string;
const env = () => ({ ...process.env, GSTACK_STATE_ROOT: root });
const cfg = (...args: string[]) => {
const r = spawnSync('bash', [CONFIG_BIN, ...args], { encoding: 'utf-8', timeout: 30_000, env: env() });
return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
};
const file = () => join(root, 'config.yaml');
const snapshot = () => (existsSync(file()) ? readFileSync(file(), 'utf-8') : null);
const keyLines = (key: string) => (snapshot() ?? '').split('\n').filter((l) => l.startsWith(`${key}:`));
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'gstack-config-scope-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
describe('transcript_repos and the +repos marker', () => {
test('setting the allowlist stores canonical remotes and marks the mode in the same write', () => {
expect(cfg('set', 'transcript_ingest_mode', 'recent').status).toBe(0);
expect(cfg('set', 'transcript_repos', 'https://github.com/Acme/App.git, git@github.com:acme/api.git,github.com/acme/app').status).toBe(0);
expect(cfg('get', 'transcript_repos').out).toBe('github.com/acme/app,github.com/acme/api');
expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent+repos');
});
test('a mode change keeps the marker while the allowlist exists; off never carries it', () => {
cfg('set', 'transcript_repos', 'github.com/acme/app');
expect(cfg('get', 'transcript_ingest_mode').status).toBe(0);
expect(keyLines('transcript_ingest_mode')).toEqual([]);
cfg('set', 'transcript_ingest_mode', 'all');
expect(cfg('get', 'transcript_ingest_mode').out).toBe('all+repos');
cfg('set', 'transcript_ingest_mode', 'new@2026-10-03T17:00:00Z');
expect(cfg('get', 'transcript_ingest_mode').out).toBe('new@2026-10-03T17:00:00Z+repos');
cfg('set', 'transcript_ingest_mode', 'off');
expect(cfg('get', 'transcript_ingest_mode').out).toBe('off');
cfg('set', 'transcript_ingest_mode', 'recent+repos');
expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent+repos');
});
test('unset transcript_repos drops the marker in the same write; unset of an absent key is a no-op', () => {
cfg('set', 'transcript_ingest_mode', 'recent');
cfg('set', 'transcript_repos', 'github.com/acme/app');
expect(cfg('unset', 'transcript_repos').status).toBe(0);
expect(cfg('has', 'transcript_repos').status).toBe(1);
expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent');
const before = snapshot();
expect(cfg('unset', 'transcript_repos').status).toBe(0);
expect(snapshot()).toBe(before);
expect(cfg('unset', 'bad key!').status).toBe(1);
});
test('unset removes only the named key', () => {
cfg('set', 'proactive', 'false');
cfg('set', 'skill_prefix', 'true');
expect(cfg('unset', 'proactive').status).toBe(0);
expect(cfg('has', 'proactive').status).toBe(1);
expect(cfg('get', 'skill_prefix').out).toBe('true');
});
test('a +repos value without an allowlist and an empty allowlist are rejected with the file unchanged', () => {
cfg('set', 'transcript_ingest_mode', 'recent');
const before = snapshot();
const marked = cfg('set', 'transcript_ingest_mode', 'all+repos');
expect(marked.status).toBe(1);
expect(marked.err).toContain('Existing value left unchanged');
for (const value of [' , ', ',']) {
const r = cfg('set', 'transcript_repos', value);
expect(r.status).toBe(1);
expect(r.err).toContain('Existing value left unchanged');
expect(r.err).toContain('gstack-config unset transcript_repos');
}
expect(snapshot()).toBe(before);
});
});
describe('one lock and one rename for every mutation', () => {
test('concurrent unrelated sets lose nothing', async () => {
cfg('set', 'proactive', 'true');
const keys = Array.from({ length: 12 }, (_, i) => `scope_test_key_${i}`);
const codes = await Promise.all(
keys.map(
(key, i) =>
new Promise<number | null>((resolve) => {
const child = spawn('bash', [CONFIG_BIN, 'set', key, `value-${i}`], { env: env(), stdio: 'ignore' });
child.on('exit', resolve);
}),
),
);
expect(codes).toEqual(keys.map(() => 0));
for (const [i, key] of keys.entries()) expect(keyLines(key)).toEqual([`${key}: value-${i}`]);
expect(keyLines('proactive')).toEqual(['proactive: true']);
expect(existsSync(join(root, '.config-yaml.lock'))).toBe(false);
}, 60_000);
test('an interrupted write leaves the old file, and the next writer takes over the dead lock', () => {
cfg('set', 'transcript_ingest_mode', 'recent');
cfg('set', 'transcript_repos', 'github.com/acme/app');
const before = snapshot();
// A mv that kills gstack-config right before the rename over config.yaml.
const shim = join(root, 'shim');
mkdirSync(shim);
writeFileSync(join(shim, 'mv'), '#!/bin/sh\nfor last; do :; done\ncase "$last" in */config.yaml) kill -9 $PPID; exit 1 ;; esac\nexec /bin/mv "$@"\n');
chmodSync(join(shim, 'mv'), 0o755);
const crashed = spawnSync('bash', [CONFIG_BIN, 'unset', 'transcript_repos'], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...env(), PATH: `${shim}:${process.env.PATH}` },
});
expect(crashed.status).not.toBe(0);
expect(snapshot()).toBe(before);
expect(existsSync(join(root, '.config-yaml.lock'))).toBe(true);
const t0 = Date.now();
expect(cfg('unset', 'transcript_repos').status).toBe(0);
expect(Date.now() - t0).toBeLessThan(10_000);
expect(cfg('get', 'transcript_ingest_mode').out).toBe('recent');
}, 60_000);
test('a lock held longer than 10 s is stale even when its pid is alive', () => {
mkdirSync(join(root, '.config-yaml.lock'));
writeFileSync(join(root, '.config-yaml.lock', 'pid'), `${process.pid} ${Math.floor(Date.now() / 1000) - 20}\n`);
expect(cfg('set', 'proactive', 'false').status).toBe(0);
expect(cfg('get', 'proactive').out).toBe('false');
});
test('a live, fresh lock makes the writer wait instead of writing alongside it', async () => {
mkdirSync(join(root, '.config-yaml.lock'));
writeFileSync(join(root, '.config-yaml.lock', 'pid'), `${process.pid} ${Math.floor(Date.now() / 1000)}\n`);
const child = spawn('bash', [CONFIG_BIN, 'set', 'proactive', 'false'], { env: env(), stdio: 'ignore' });
const exited = new Promise<number | null>((resolve) => child.on('exit', resolve));
await Bun.sleep(500);
expect(snapshot()).toBeNull();
rmSync(join(root, '.config-yaml.lock'), { recursive: true, force: true });
expect(await exited).toBe(0);
expect(keyLines('proactive')).toEqual(['proactive: false']);
}, 30_000);
test('the user-slug writer goes through the same lock', () => {
const r = spawnSync('bash', [CONFIG_BIN, 'resolve-user-slug'], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...env(), HOME: root, USER: 'scope-tester' },
});
expect(r.stdout.length).toBeGreaterThan(0);
expect(keyLines('user_slug_at_local')).toEqual([`user_slug_at_local: ${r.stdout}`]);
expect(existsSync(join(root, '.config-yaml.lock'))).toBe(false);
});
});