mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
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.
This commit is contained in:
1 parent
9c077137ee
commit
a95f5fa209
3 files changed
+374
-30
No files matched your search
+211
-28
@@ -7,6 +7,7 @@
|
||||
# config file (get returns DEFAULTS for absent keys,
|
||||
# so callers that need provenance use this instead)
|
||||
# gstack-config set <key> <value> — write a config value
|
||||
# gstack-config unset <key> — remove a key from the config file
|
||||
# gstack-config list — show all config (values + defaults)
|
||||
# gstack-config defaults — show just the defaults table
|
||||
#
|
||||
@@ -92,17 +93,24 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# # Flip back to false to be re-prompted.
|
||||
#
|
||||
# ─── Transcript ingest (gbrain memory) ───────────────────────────────
|
||||
# transcript_ingest_mode: off # recent | all | off — consent for /sync-gbrain
|
||||
# # to ingest Claude Code and Codex session
|
||||
# # transcripts (every project repo policy allows)
|
||||
# # into your brain (local or Supabase).
|
||||
# transcript_ingest_mode: off # recent | all | off | new@<UTC> — consent for
|
||||
# # /sync-gbrain to ingest Claude Code and Codex
|
||||
# # session transcripts (every project repo policy
|
||||
# # allows) into your brain (local or Supabase).
|
||||
# # recent — last 90 days
|
||||
# # all — all history
|
||||
# # new@2026-10-03T17:00:00Z — only sessions that
|
||||
# # start after that UTC time
|
||||
# # off — never (other memory still syncs)
|
||||
# # A +repos suffix is kept automatically while
|
||||
# # transcript_repos is set.
|
||||
# # Absent, legacy (A-E, incremental) or unknown
|
||||
# # values are not consent: transcripts are skipped
|
||||
# # and /sync-gbrain asks once. An invalid value is
|
||||
# # REJECTED and the stored value kept.
|
||||
# transcript_repos: # comma-separated git remotes; only transcripts
|
||||
# # from these repos are ingested. Clear it with
|
||||
# # `gstack-config unset transcript_repos`.
|
||||
# # Docs: setup-gbrain/memory.md#transcripts
|
||||
#
|
||||
# ─── Timeline Stop hook ──────────────────────────────────────────────
|
||||
@@ -211,7 +219,8 @@ lookup_default() {
|
||||
# the callers already assume in their own `|| echo "<default>"` fallback.
|
||||
question_tuning) echo "false" ;;
|
||||
team_mode) echo "false" ;;
|
||||
transcript_ingest_mode) echo "off" ;; # recent | all | off — transcript consent; absent = not consented (use `has`)
|
||||
transcript_ingest_mode) echo "off" ;; # recent | all | off | new@<UTC> [+repos] — transcript consent; absent = not consented (use `has`)
|
||||
transcript_repos) echo "" ;; # empty = every repo; comma-separated canonical remotes narrow transcript ingest
|
||||
# repo_mode: EMPTY is load-bearing — gstack-repo-mode treats any non-empty
|
||||
# answer as a user override and skips its own classification entirely, so
|
||||
# a synthesized "unknown" default turns the classifier into dead code.
|
||||
@@ -339,14 +348,16 @@ resolve_user_slug() {
|
||||
_slug="anonymous-$(sha8_of "$(hostname 2>/dev/null || echo unknown)")"
|
||||
fi
|
||||
|
||||
# Persist via direct file write (avoid recursion into gstack-config set)
|
||||
mkdir -p "$STATE_DIR"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE"
|
||||
fi
|
||||
if ! grep -qE "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null; then
|
||||
echo "user_slug_at_${_hash}: ${_slug}" >> "$CONFIG_FILE"
|
||||
# Persist via the locked read-modify-write (no recursion into `set`).
|
||||
config_lock
|
||||
config_edit_begin
|
||||
if [ -z "$(_cfg_get "user_slug_at_${_hash}")" ]; then
|
||||
_cfg_put "user_slug_at_${_hash}" "$_slug"
|
||||
config_edit_commit
|
||||
else
|
||||
rm -f "$_cfg_tmp"
|
||||
fi
|
||||
config_unlock
|
||||
|
||||
printf '%s' "$_slug"
|
||||
}
|
||||
@@ -357,6 +368,142 @@ read_config_value() {
|
||||
gstack_read_config_key "$1"
|
||||
}
|
||||
|
||||
# ── Config writes: one lock, one read-modify-write rename ──────────────────
|
||||
# Every mutation of config.yaml (set, unset, the transcript_repos pair, the
|
||||
# user-slug writer) holds an atomic mkdir lock and replaces the file with one
|
||||
# rename, so concurrent writers never lose each other's keys and an
|
||||
# interrupted write leaves the old file. The pid file holds "<pid> <epoch>";
|
||||
# a lock whose holder is dead or older than 10 s is taken over. No flock(1):
|
||||
# stock macOS does not ship it.
|
||||
CONFIG_LOCK="$STATE_DIR/.config-yaml.lock"
|
||||
_config_locked=""
|
||||
|
||||
config_lock() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
local waited=0 held pid ts stale
|
||||
while ! mkdir "$CONFIG_LOCK" 2>/dev/null; do
|
||||
if [ ! -w "$STATE_DIR" ]; then
|
||||
echo "Error: cannot write to $STATE_DIR (its config lock could not be created). Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
waited=$(( waited + 1 ))
|
||||
if [ "$waited" -gt 400 ]; then
|
||||
echo "Error: $CONFIG_FILE is locked by another gstack-config ($(cat "$CONFIG_LOCK/pid" 2>/dev/null || echo 'unknown holder')). Existing value left unchanged. fix: retry, or remove $CONFIG_LOCK if no gstack-config is running." >&2
|
||||
exit 1
|
||||
fi
|
||||
held=$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true)
|
||||
pid=""; ts=""; stale=""
|
||||
case "$held" in *" "*) pid="${held%% *}"; ts="${held#* }"; ts="${ts%%[!0-9]*}" ;; esac
|
||||
if [ -n "$pid" ]; then
|
||||
if ! kill -0 "$pid" 2>/dev/null; then stale=1
|
||||
elif [ -n "$ts" ] && [ $(( $(date +%s) - ts )) -gt 10 ]; then stale=1; fi
|
||||
elif [ "$waited" -gt 200 ]; then
|
||||
stale=1
|
||||
fi
|
||||
# Take over only the lock we judged: a holder that changed meanwhile is live.
|
||||
if [ -n "$stale" ] && [ "$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true)" = "$held" ]; then
|
||||
rm -rf "$CONFIG_LOCK" 2>/dev/null || true
|
||||
continue
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
printf '%s %s\n' "$$" "$(date +%s)" > "$CONFIG_LOCK/pid"
|
||||
_config_locked=1
|
||||
trap 'config_unlock' EXIT
|
||||
}
|
||||
|
||||
config_unlock() {
|
||||
[ -n "$_config_locked" ] || return 0
|
||||
rm -rf "$CONFIG_LOCK" 2>/dev/null || true
|
||||
_config_locked=""
|
||||
}
|
||||
|
||||
# config_edit_begin — copy config.yaml (or the annotated header for a new
|
||||
# file) to a temp file beside it; edits go there until config_edit_commit.
|
||||
config_edit_begin() {
|
||||
_cfg_tmp="$(mktemp "$STATE_DIR/.config.yaml.XXXXXX")"
|
||||
if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" > "$_cfg_tmp"; else printf '%s' "$CONFIG_HEADER" > "$_cfg_tmp"; fi
|
||||
}
|
||||
|
||||
config_edit_commit() {
|
||||
mv "$_cfg_tmp" "$CONFIG_FILE"
|
||||
}
|
||||
|
||||
# _cfg_get KEY — last value of KEY in the temp file ("" when absent).
|
||||
_cfg_get() {
|
||||
awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) == k ":" { v = substr($0, n + 1) } END { sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v); print v }' "$_cfg_tmp"
|
||||
}
|
||||
|
||||
# _cfg_put KEY VALUE — replace every KEY line in the temp file, or append one.
|
||||
# The value travels through ENVIRON so awk applies no escape processing.
|
||||
_cfg_put() {
|
||||
_CFG_V="$2" awk -v k="$1" 'BEGIN { n = length(k) + 1; v = ENVIRON["_CFG_V"] } substr($0, 1, n) == k ":" { print k ": " v; done = 1; next } { print } END { if (!done) print k ": " v }' "$_cfg_tmp" > "$_cfg_tmp.n"
|
||||
mv "$_cfg_tmp.n" "$_cfg_tmp"
|
||||
}
|
||||
|
||||
_cfg_del() {
|
||||
awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) != k ":"' "$_cfg_tmp" > "$_cfg_tmp.n"
|
||||
mv "$_cfg_tmp.n" "$_cfg_tmp"
|
||||
}
|
||||
|
||||
# ── Transcript consent grammar ─────────────────────────────────────────────
|
||||
# transcript_ingest_mode = <base>[@<YYYY-MM-DDTHH:MM:SSZ>][+repos]; base is
|
||||
# recent, all, off or new. `@` is required for new and invalid otherwise; off
|
||||
# never carries +repos. `set` takes the canonical lowercase form (readers in
|
||||
# lib/transcript-consent.ts are case-insensitive).
|
||||
valid_utc_second() {
|
||||
[[ "$1" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$ ]] || return 1
|
||||
local y=$((10#${BASH_REMATCH[1]})) m=$((10#${BASH_REMATCH[2]})) d=$((10#${BASH_REMATCH[3]}))
|
||||
local hh=$((10#${BASH_REMATCH[4]})) mm=$((10#${BASH_REMATCH[5]})) ss=$((10#${BASH_REMATCH[6]})) dim=31
|
||||
[ "$m" -ge 1 ] && [ "$m" -le 12 ] && [ "$hh" -le 23 ] && [ "$mm" -le 59 ] && [ "$ss" -le 59 ] || return 1
|
||||
case "$m" in
|
||||
4|6|9|11) dim=30 ;;
|
||||
2) dim=28; if [ $((y % 4)) -eq 0 ] && { [ $((y % 100)) -ne 0 ] || [ $((y % 400)) -eq 0 ]; }; then dim=29; fi ;;
|
||||
esac
|
||||
[ "$d" -ge 1 ] && [ "$d" -le "$dim" ]
|
||||
}
|
||||
|
||||
# valid_transcript_mode VALUE — canonical grammar check (exit 0 = valid).
|
||||
valid_transcript_mode() {
|
||||
local v="$1" base
|
||||
base="${v%+repos}"
|
||||
case "$base" in
|
||||
recent|all) ;;
|
||||
off) [ "$base" = "$v" ] || return 1 ;;
|
||||
new@*) valid_utc_second "${base#new@}" || return 1 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# canonical_remote URL — same rules as canonicalizeRemote in
|
||||
# lib/gstack-memory-helpers.ts (test/transcript-consent.test.ts keeps them equal).
|
||||
canonical_remote() {
|
||||
local s="$1"
|
||||
s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"
|
||||
s="${s#[\"\']}"; s="${s%[\"\']}"
|
||||
if [[ "$s" =~ ^[^@[:space:]]+@([^:]+):(.+)$ ]]; then
|
||||
s="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
|
||||
else
|
||||
s=$(printf '%s' "$s" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##; s#^[^@/]+@##')
|
||||
fi
|
||||
printf '%s' "$s" | sed -E 's#/+$##; s#\.[gG][iI][tT]$##; s#/+$##; s#/{2,}#/#g' | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
# transcript_marker_sync — keep the +repos marker on transcript_ingest_mode in
|
||||
# step with transcript_repos inside the temp file: present while an allowlist
|
||||
# exists and the base consents, absent otherwise. Unrecognized values are
|
||||
# left untouched (they are not consent either way).
|
||||
transcript_marker_sync() {
|
||||
local mode list base
|
||||
mode=$(_cfg_get transcript_ingest_mode)
|
||||
[ -n "$mode" ] || return 0
|
||||
valid_transcript_mode "$mode" || return 0
|
||||
list=$(_cfg_get transcript_repos)
|
||||
base="${mode%+repos}"
|
||||
if [ -n "$list" ] && [ "$base" != "off" ]; then _cfg_put transcript_ingest_mode "$base+repos"
|
||||
else _cfg_put transcript_ingest_mode "$base"; fi
|
||||
}
|
||||
|
||||
_STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
|
||||
_SELF="$(cd "$(dirname "$0")" && pwd)/gstack-config"
|
||||
|
||||
@@ -500,12 +647,31 @@ case "${1:-}" in
|
||||
echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
# transcript_ingest_mode is a CONSENT key: recent/all let /sync-gbrain
|
||||
# transcript_ingest_mode is a CONSENT key: recent/all/new@ let /sync-gbrain
|
||||
# ingest coding-agent transcripts. Reject a typo; never coerce or store it.
|
||||
if [ "$KEY" = "transcript_ingest_mode" ] && [ "$VALUE" != "recent" ] && [ "$VALUE" != "all" ] && [ "$VALUE" != "off" ]; then
|
||||
if [ "$KEY" = "transcript_ingest_mode" ] && ! valid_transcript_mode "$VALUE"; then
|
||||
echo "Error: transcript_ingest_mode '$VALUE' not recognized. Valid values: recent, all, off. Existing value left unchanged." >&2
|
||||
echo "Also valid: new@<YYYY-MM-DDTHH:MM:SSZ> (only sessions that start after that UTC time, e.g. new@$(date -u +%Y-%m-%dT%H:%M:%SZ)). The +repos suffix follows transcript_repos; off never carries it. Docs: setup-gbrain/memory.md#transcripts" >&2
|
||||
exit 1
|
||||
fi
|
||||
# transcript_repos narrows transcript consent to these repos. Each entry is
|
||||
# stored as its canonical remote (host/owner/repo); an empty list would
|
||||
# deny every repo, so it is rejected: `unset` clears the allowlist.
|
||||
if [ "$KEY" = "transcript_repos" ]; then
|
||||
_clean=""
|
||||
_ifs="$IFS"; IFS=','
|
||||
for _x in $VALUE; do
|
||||
_x="$(canonical_remote "$_x")"
|
||||
[ -n "$_x" ] || continue
|
||||
case ",$_clean," in *",$_x,"*) ;; *) _clean="${_clean:+$_clean,}$_x" ;; esac
|
||||
done
|
||||
IFS="$_ifs"
|
||||
if [ -z "$_clean" ]; then
|
||||
echo "Error: transcript_repos '$VALUE' names no repo. Valid values: comma-separated git remotes (e.g. github.com/owner/repo). Existing value left unchanged. To allow every repo: gstack-config unset transcript_repos" >&2
|
||||
exit 1
|
||||
fi
|
||||
VALUE="$_clean"
|
||||
fi
|
||||
# memorable_recall is a CONSENT key: `on` lets a Claude Code hook hand every
|
||||
# prompt to a third-party binary. Reject like codex_reviews -- a typo must
|
||||
# never flip consent in either direction, so nothing is coerced or stored.
|
||||
@@ -572,21 +738,23 @@ case "${1:-}" in
|
||||
done
|
||||
done
|
||||
fi
|
||||
mkdir -p "$STATE_DIR"
|
||||
# Write annotated header on first creation
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE"
|
||||
# Locked read-modify-write; the annotated header is written on first creation.
|
||||
config_lock
|
||||
config_edit_begin
|
||||
if [ "$KEY" = "transcript_ingest_mode" ] && [ "${VALUE%+repos}" != "$VALUE" ] && [ -z "$(_cfg_get transcript_repos)" ]; then
|
||||
rm -f "$_cfg_tmp"
|
||||
echo "Error: transcript_ingest_mode '$VALUE' needs a repo allowlist, but transcript_repos is not set. Existing value left unchanged. fix: gstack-config set transcript_repos <remotes>, then set transcript_ingest_mode ${VALUE%+repos}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Escape sed replacement metacharacters (newlines were rejected above).
|
||||
_cfg_put "$KEY" "$VALUE"
|
||||
# The allowlist and its +repos marker change together, in this one rename.
|
||||
if [ "$KEY" = "transcript_ingest_mode" ] || [ "$KEY" = "transcript_repos" ]; then
|
||||
transcript_marker_sync
|
||||
if [ "$KEY" = "transcript_ingest_mode" ]; then VALUE="$(_cfg_get transcript_ingest_mode)"; fi
|
||||
fi
|
||||
config_edit_commit
|
||||
config_unlock
|
||||
SAFE_VALUE="$VALUE"
|
||||
ESC_VALUE="$(printf '%s' "$SAFE_VALUE" | sed 's/[&/\]/\\&/g')"
|
||||
if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then
|
||||
# Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg)
|
||||
_tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")"
|
||||
sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE"
|
||||
else
|
||||
echo "${KEY}: ${SAFE_VALUE}" >> "$CONFIG_FILE"
|
||||
fi
|
||||
# Report only when another root still overrides the value just written.
|
||||
gstack_config_select "$KEY"
|
||||
if [ -n "$_gstack_cfg_root" ] && [ "$_gstack_cfg_root" != "$STATE_DIR" ] && [ "$_gstack_cfg_value" != "$SAFE_VALUE" ]; then
|
||||
@@ -607,6 +775,21 @@ case "${1:-}" in
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
unset)
|
||||
KEY="${2:?Usage: gstack-config unset <key>}"
|
||||
if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
|
||||
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$CONFIG_FILE" ] || exit 0
|
||||
config_lock
|
||||
config_edit_begin
|
||||
_cfg_del "$KEY"
|
||||
# Clearing the allowlist drops the +repos marker in the same write.
|
||||
if [ "$KEY" = "transcript_repos" ]; then transcript_marker_sync; fi
|
||||
config_edit_commit
|
||||
config_unlock
|
||||
;;
|
||||
list)
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
cat "$CONFIG_FILE"
|
||||
@@ -744,7 +927,7 @@ case "${1:-}" in
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]"
|
||||
echo "Usage: gstack-config {get|has|set|unset|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -47,13 +47,13 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("gstack-config key validation is locale-independent", () => {
|
||||
test("get, has, and set all validate ASCII ranges under the C locale", () => {
|
||||
test("get, has, set and unset all validate ASCII ranges under the C locale", () => {
|
||||
const source = fs.readFileSync(CONFIG, "utf8");
|
||||
const guardedValidators = source.match(
|
||||
/LC_ALL=C grep -qE '\^\[a-zA-Z0-9_\]\+\(@\[a-zA-Z0-9\]\+\)\?\$'/g,
|
||||
);
|
||||
|
||||
expect(guardedValidators).toHaveLength(3);
|
||||
expect(guardedValidators).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("round-trips existing keys that contain i", () => {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user