mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
fix: pre-landing review fixes (prune only owned, unused folders; full root from the canonical Claude install; fallback reason and source) (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
bb539ba4d5
commit
12bb281db2
5 files changed
+57
-21
No files matched your search
+14
-7
@@ -75,7 +75,8 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$MODEL" ] || MODEL=${GSTACK_MODEL:-}
|
||||
MODEL_SOURCE=model-flag
|
||||
[ -n "$MODEL" ] || { MODEL=${GSTACK_MODEL:-}; MODEL_SOURCE=GSTACK_MODEL; }
|
||||
[ -n "$FOR" ] && [ -n "$REQUIRE" ] && { echo "gstack-capy-install: --for and --require are exclusive" >&2; exit 2; }
|
||||
[ -z "$FOR" ] && [ -z "$REQUIRE" ] && REQUIRE="claude,codex,patch"
|
||||
|
||||
@@ -722,8 +723,12 @@ fi
|
||||
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
|
||||
LOADOUT_LINES=""
|
||||
if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
|
||||
_lo_full="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"
|
||||
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
|
||||
_lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)")
|
||||
if [ "$MODEL_SOURCE" = model-flag ]; then
|
||||
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
|
||||
else
|
||||
_lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1)
|
||||
fi
|
||||
_lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ')
|
||||
_lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1)
|
||||
_lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ')
|
||||
@@ -731,15 +736,17 @@ if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_F
|
||||
_lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p')
|
||||
if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then
|
||||
_lo_file="${VERDICT%.json}.loadout-$_lo_name-$_lo_digest.env"
|
||||
_lo_tmp=$(umask 077 && mktemp "$_lo_file.tmp.XXXXXX") \
|
||||
[ -n "$_lo_digest" ] && [ -d "$_lo_root" ] \
|
||||
&& _lo_tmp=$(umask 077 && mktemp "$_lo_file.tmp.XXXXXX") \
|
||||
&& { cat "$LAUNCH_FILE" && echo "export GSTACK_LOADOUT_SKILL_ROOT=$(shq "$_lo_root")"; } > "$_lo_tmp" \
|
||||
&& chmod 0600 "$_lo_tmp" && mv -f "$_lo_tmp" "$_lo_file" \
|
||||
&& [ -n "$_lo_digest" ] && [ -d "$_lo_root" ] && LAUNCH_FILE=$_lo_file \
|
||||
&& chmod 0600 "$_lo_tmp" && mv -f "$_lo_tmp" "$_lo_file" && LAUNCH_FILE=$_lo_file \
|
||||
|| { rm -f "${_lo_tmp:-}" 2>/dev/null; _lo_notes="${_lo_notes:+$_lo_notes
|
||||
}LOADOUT_FALLBACK: could not publish the $_lo_name launch file; serving full (docs/troubleshooting.md#loadout-fallback)"; _lo_name=""; }
|
||||
fi
|
||||
if [ -z "$_lo_name" ] || [ -z "$_lo_root" ]; then
|
||||
_lo_line="LOADOUT: full model=none source=model-flag reason=render-failed" _lo_root=$_lo_full
|
||||
[ -n "$_lo_line" ] || _lo_notes="${_lo_notes:+$_lo_notes
|
||||
}LOADOUT_FALLBACK: $(printf '%s\n' "$_lo_out" | tail -1 | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)"
|
||||
_lo_line="LOADOUT: full model=none source=$MODEL_SOURCE reason=render-failed" _lo_root=$_lo_full
|
||||
fi
|
||||
LOADOUT_LINES="${_lo_notes:+$_lo_notes
|
||||
}$_lo_line
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ SKILL_ROOT: /home/user/.gstack/loadouts/lite-3f2a9c01d4e7
|
||||
|
||||
Read skills from `SKILL_ROOT` (`<root>/gstack/SKILL.md` routes, `<root>/<skill>/SKILL.md` runs) and source the printed `GSTACK_LAUNCH_FILE`, which adds `GSTACK_LOADOUT_SKILL_ROOT`. For `full`, `SKILL_ROOT` is `~/.claude/skills` and the launch file is the install verdict's own.
|
||||
|
||||
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. The digest covers the selection, the bound model, the full root, host, overlay, plan mode and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced; the newest three per loadout are kept. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
|
||||
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. The digest covers the selection, the bound model, the full root, host, overlay, plan mode and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced. Pruning removes only folders it published (a `<loadout>-<digest12>` name with an install lock) beyond the three most recently used per loadout, and never one used in the last day. When `--full-root` is not `~/.claude/skills`, the tree's command paths point at that install's `gstack` folder. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
|
||||
|
||||
### The start line carries the model
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@
|
||||
* skill's own SKILL.md), add gstack/SKILL.md for the router, write
|
||||
* loadout.lock.json last, then rename the staging folder into place. A rename
|
||||
* that loses a race to the same identity reuses the winner; a published folder
|
||||
* is never replaced. Folders of the same loadout beyond the newest KEEP are
|
||||
* removed.
|
||||
* is never replaced. Folders this installer published for the same loadout
|
||||
* (name, digest and an install lock) beyond the newest KEEP by last use are
|
||||
* removed once unused for PRUNE_GRACE_MS.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { runGeneration } from './gen-skill-docs';
|
||||
import { resolveModel } from './models';
|
||||
@@ -32,6 +34,7 @@ import type { Host } from './resolvers/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const KEEP = 3;
|
||||
const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface InstallRequest {
|
||||
selection: ModuleSelection;
|
||||
@@ -121,12 +124,22 @@ function copyRuntimeAssets(source: string, stage: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function ownedInstall(dir: string): boolean {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')).kind === 'install';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prune(store: string, loadout: string, keep: string): void {
|
||||
const name = new RegExp(`^${loadout}-[0-9a-f]{12}$`);
|
||||
const cutoff = Date.now() - PRUNE_GRACE_MS;
|
||||
const siblings = fs.readdirSync(store, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith(`${loadout}-`) && !e.name.includes('.stage-') && path.join(store, e.name) !== keep)
|
||||
.filter(e => e.isDirectory() && name.test(e.name) && path.join(store, e.name) !== keep && ownedInstall(path.join(store, e.name)))
|
||||
.map(e => ({ dir: path.join(store, e.name), mtime: fs.statSync(path.join(store, e.name)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
for (const { dir } of siblings.slice(KEEP - 1)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
for (const { dir, mtime } of siblings.slice(KEEP - 1)) if (mtime < cutoff) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function installLoadout(request: InstallRequest): Promise<InstallResult> {
|
||||
@@ -161,7 +174,7 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
|
||||
}
|
||||
const stage = `${final}.stage-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
try {
|
||||
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: null, model, planMode, modules: selection });
|
||||
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: request.fullRoot && request.fullRoot !== path.join(os.homedir(), '.claude/skills') ? path.join(request.fullRoot, 'gstack') : null, model, planMode, modules: selection });
|
||||
const errors = result.diagnostics.filter(d => d.kind === 'error').map(d => d.message);
|
||||
if (result.exitCode !== 0 || errors.length) {
|
||||
throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry');
|
||||
|
||||
@@ -50,7 +50,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
|
||||
const log = path.join(base, 'calls.log');
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(stub, { recursive: true });
|
||||
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-node-runtime.sh']) {
|
||||
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-claude-root.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-node-runtime.sh']) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
|
||||
fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode);
|
||||
@@ -530,7 +530,7 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
const f = fixture();
|
||||
script(path.join(f.root, 'bin/gstack-modules'), [
|
||||
'echo "modules $*" >> "$CAPY_LOG"',
|
||||
'model="" full=""; while [ $# -gt 0 ]; do case "$1" in --model) model="$2"; shift ;; --full-root) full="$2"; shift ;; esac; shift; done',
|
||||
'model="${GSTACK_MODEL:-}" full=""; while [ $# -gt 0 ]; do case "$1" in --model) model="$2"; shift ;; --full-root) full="$2"; shift ;; esac; shift; done',
|
||||
'case "$model" in',
|
||||
' claude-opus-5-5) mkdir -p "$HOME/lite-0123456789ab"; echo "LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=0123456789ab reused=no"; echo "SKILL_ROOT: $HOME/lite-0123456789ab" ;;',
|
||||
' broken) echo "boom" >&2; exit 1 ;;',
|
||||
@@ -556,7 +556,7 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
expect(sonnet.stdout).toContain(`LOADOUT: full model=none source=model-flag reason=unmapped\nSKILL_ROOT: ${f.home}/.claude/skills\n`);
|
||||
expect(launchOf(sonnet.stdout)).toBe(base);
|
||||
const broken = run(f, ['--for', 'ship', '--model', 'broken']);
|
||||
expect(broken.stdout).toContain(`LOADOUT: full model=none source=model-flag reason=render-failed\nSKILL_ROOT: ${f.home}/.claude/skills\n`);
|
||||
expect(broken.stdout).toContain(`LOADOUT_FALLBACK: boom; serving full (docs/troubleshooting.md#loadout-fallback)\nLOADOUT: full model=none source=model-flag reason=render-failed\nSKILL_ROOT: ${f.home}/.claude/skills\n`);
|
||||
expect(launchOf(broken.stdout)).toBe(base);
|
||||
const plain = run(f, ['--for', 'ship']);
|
||||
expect(plain.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
|
||||
@@ -564,6 +564,7 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
const fromEnv = run(f, ['--for', 'ship'], { GSTACK_MODEL: 'claude-opus-5-5' });
|
||||
expect(fromEnv.status, fromEnv.stdout + fromEnv.stderr).toBe(0);
|
||||
expect(launchOf(fromEnv.stdout)).toBe(lite);
|
||||
expect(f.calls().filter(l => l.startsWith('modules ')).at(-1)).toBe(`modules install --full-root ${f.home}/.claude/skills`);
|
||||
expect(verdicts(f)).toEqual([verdict]);
|
||||
expect(fs.existsSync(lite)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -61,6 +61,8 @@ describe('gstack-modules install', () => {
|
||||
const landing = fs.readFileSync(path.join(root, 'land-and-deploy/SKILL.md'), 'utf-8');
|
||||
const full = fs.readFileSync(path.join(ROOT, 'land-and-deploy/SKILL.md'), 'utf-8');
|
||||
expect(landing.length).toBeLessThan(full.length);
|
||||
expect(landing).toContain(`${FULL_ROOT}/gstack/bin/`);
|
||||
expect(landing).not.toContain('~/.claude/skills/gstack/bin/');
|
||||
expect(lock).toMatchObject({ kind: 'install', loadout: 'lite', loadout_model: 'claude-opus-5-5', full_root: FULL_ROOT });
|
||||
expect(lock.excluded).toHaveLength(11);
|
||||
expect(lock.excluded).not.toContain('preamble:upgrade-check');
|
||||
@@ -88,18 +90,31 @@ describe('gstack-modules install', () => {
|
||||
await expect(installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })).rejects.toThrow(/RENDER_DEST_NOT_OWNED/);
|
||||
}, 240_000);
|
||||
|
||||
test('pruning keeps the three most recently used folders of a loadout: a reused folder counts as used', async () => {
|
||||
test('pruning removes only its own folders past the three most recently used, and none used in the last day', async () => {
|
||||
const store = path.join(tmp, 'prune');
|
||||
const install = (fullRoot: string) => installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot, store });
|
||||
const age = (dir: string, days: number) => { const t = new Date(Date.now() - days * 86_400_000); fs.utimesSync(dir, t, t); };
|
||||
const published = () => fs.readdirSync(store).filter(n => /^lite-[0-9a-f]{12}$/.test(n));
|
||||
const inUse = await install('/r/0');
|
||||
const oldest = await install('/r/1');
|
||||
await install('/r/2');
|
||||
const old = await install('/r/2');
|
||||
age(inUse.root, 3);
|
||||
age(oldest.root, 3);
|
||||
age(old.root, 2);
|
||||
const foreign = path.join(store, 'lite-project');
|
||||
fs.mkdirSync(foreign);
|
||||
fs.writeFileSync(path.join(foreign, 'notes.txt'), 'not gstack\n');
|
||||
age(foreign, 9);
|
||||
expect((await install('/r/0')).reused).toBe(true);
|
||||
const newest = await install('/r/3');
|
||||
expect(fs.existsSync(inUse.root)).toBe(true);
|
||||
expect(fs.existsSync(newest.root)).toBe(true);
|
||||
expect([inUse.root, old.root, newest.root].every(dir => fs.existsSync(dir))).toBe(true);
|
||||
expect(fs.existsSync(oldest.root)).toBe(false);
|
||||
expect(fs.readdirSync(store).filter(n => n.startsWith('lite-'))).toHaveLength(3);
|
||||
expect(fs.readFileSync(path.join(foreign, 'notes.txt'), 'utf8')).toBe('not gstack\n');
|
||||
await install('/r/4');
|
||||
await install('/r/5');
|
||||
expect(fs.existsSync(old.root)).toBe(false);
|
||||
expect(fs.existsSync(inUse.root)).toBe(true);
|
||||
expect(published()).toHaveLength(4);
|
||||
}, 300_000);
|
||||
|
||||
test('the source tree identity changes with a commit and with uncommitted edits', () => {
|
||||
|
||||
Reference in new issue
Block a user