Files
gstack/test/skill-end-completion-durability.test.ts
T
Garry Tanandcapy-ai[bot] 97b796e5ae Stop learnings, timeline events and brain sync from storing Stripe, Slack, npm and database secrets (GST-2196) (#4247)
* Add failing tests: four HIGH secret families pass brain sync and the learnings and timeline writers (GST-2196)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Refuse HIGH-tier secrets in learnings and timeline rows and hold four more secret families in brain sync (GST-2196)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Give the shadow-bin test fixtures the lib directory the timeline logger now imports (GST-2196)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Copy lib/ into the four agent test fixtures that run the timeline and learnings loggers (GST-2196)

The loggers now import lib/redact-engine.ts, so the bws operational-learning, session-intelligence, context-skills and skillify E2E fixtures need lib/ beside their bin/. A copy, not a symlink, because a live agent runs inside each fixture.

Review-Finding: p1/specialist-batched#1 reader=specialist-batched
Review-Finding: p1/specialist-batched#2 reader=specialist-batched
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-09 23:06:04 +00:00

155 lines
8.3 KiB
TypeScript

/** Completion durability and privacy at the real skill-end boundary.
* All Git writes stay in disposable local fixtures; no live remote/API. */
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const BIN = path.resolve(import.meta.dir, '../bin');
const TIMELINE = 'projects/completion-order/timeline.jsonl';
let fixture: string, state: string, remote: string;
let env: Record<string, string>;
function run(bin: string, args: string[] = [], extra: Record<string, string> = {}) {
const r = spawnSync(path.join(BIN, bin), args, {
cwd: fixture, env: { ...env, ...extra }, encoding: 'utf-8', timeout: 30_000,
});
expect(r.error).toBeUndefined();
expect(r.status).toBe(0);
return r;
}
function git(args: string[], dir = state): string {
const r = spawnSync('git', args, { cwd: dir, env, encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(0);
return r.stdout.trim();
}
function write(rel: string, text: string) {
fs.mkdirSync(path.dirname(path.join(state, rel)), { recursive: true });
fs.writeFileSync(path.join(state, rel), text);
}
function remoteText(rel: string): string { return git(['show', `main:${rel}`], remote); }
function remotePaths(): string { return git(['ls-tree', '-r', '--name-only', 'main'], remote); }
function queue(): string {
const dir = path.join(state, '.brain-queue.d');
return fs.existsSync(dir) ? fs.readdirSync(dir).filter(x => x.endsWith('.json')).map(x => fs.readFileSync(path.join(dir, x), 'utf8')).join('') : '';
}
function seedTrackedTimeline() {
run('gstack-timeline-log', [JSON.stringify({ skill: 'qa', event: 'started' })]);
run('gstack-brain-sync', ['--discover-new']);
run('gstack-brain-sync', ['--once']);
expect(remoteText(TIMELINE)).toContain('started');
}
function end(extra: Record<string, string> = {}) {
return run('gstack-skill-end', ['--skill', 'qa', '--outcome', 'success', '--session-id', 'completion-fixture'], extra);
}
beforeEach(() => {
fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-completion-'));
state = path.join(fixture, 'state'); remote = path.join(fixture, 'remote.git');
fs.mkdirSync(state);
// Do not inherit live state-root overrides, git configuration or API keys.
const config = path.join(fixture, 'gitconfig');
fs.writeFileSync(config, '[user]\n name = Fixture\n email = fixture@example.invalid\n');
env = { PATH: process.env.PATH!, HOME: fixture, GSTACK_HOME: state, GSTACK_STATE_ROOT: state,
GSTACK_PROJECT_SLUG: 'completion-order', GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1',
GIT_TERMINAL_PROMPT: '0', GSTACK_SESSION_KIND: 'interactive' };
git(['init', '--bare', '-q', '-b', 'main', remote], fixture);
run('gstack-artifacts-init', ['--remote', remote]);
run('gstack-config', ['set', 'artifacts_sync_mode', 'full']);
run('gstack-config', ['set', 'telemetry', 'off']);
run('gstack-config', ['set', 'transcript_ingest_mode', 'off']);
});
afterEach(() => { fs.rmSync(fixture, { recursive: true, force: true }); });
describe('skill-end completion durability', () => {
test('full: the same final drain pushes a completion into an already tracked timeline', () => {
seedTrackedTimeline();
end();
expect(remoteText(TIMELINE)).toContain('completed');
expect(git(['status', '--porcelain'])).toBe('');
expect(queue()).toBe('');
}, 30_000);
test('full: a new completion timeline reaches the remote at its first end boundary', () => {
end();
expect(remotePaths()).toContain(TIMELINE);
expect(remoteText(TIMELINE)).toContain('completed');
expect(queue()).toBe('');
}, 30_000);
test('artifacts-only: a formerly tracked behavioral timeline stays local and privacy-held', () => {
seedTrackedTimeline();
const before = remoteText(TIMELINE);
run('gstack-config', ['set', 'artifacts_sync_mode', 'artifacts-only']);
end();
expect(remoteText(TIMELINE)).toBe(before);
expect(fs.readFileSync(path.join(state, TIMELINE), 'utf8')).toContain('completed');
expect(queue()).toContain(TIMELINE);
expect(JSON.parse(fs.readFileSync(path.join(state, '.brain-sync-status.json'), 'utf8')).message).toContain('privacy-held');
// Privacy does not promise a clean old full-tier checkout after downgrade.
expect(git(['diff', '--name-only'])).toBe(TIMELINE);
}, 30_000);
test('full: secret and unconsented transcript holds survive while completion metadata pushes', () => {
const held = 'projects/completion-order/learnings.jsonl';
const transcript = 'transcripts/run-fixture-1/session.md';
const synthetic = ['ghp', 'abcdefghij1234567890abcdef1234567890'].join('_');
write(held, JSON.stringify({ insight: synthetic }) + '\n');
write(transcript, '# Synthetic private fixture\n');
run('gstack-brain-enqueue', [held]); run('gstack-brain-enqueue', [transcript]);
end();
expect(remoteText(TIMELINE)).toContain('completed');
expect(remotePaths()).not.toContain(held); expect(remotePaths()).not.toContain(transcript);
expect(queue()).toContain(held); expect(queue()).toContain(transcript);
expect(fs.readFileSync(path.join(state, transcript), 'utf8')).toContain('Synthetic');
expect(JSON.parse(fs.readFileSync(path.join(state, '.brain-sync-status.json'), 'utf8')).status).toBe('held');
}, 30_000);
test('unattended: completion remains local; no drain, push or new queue entry', () => {
const rel = 'projects/completion-order/ceo-plans/fixture.md';
write(rel, '# Fixture\n'); run('gstack-brain-enqueue', [rel]);
const beforeQueue = queue(), head = git(['rev-parse', 'main'], remote);
expect(end({ GSTACK_SESSION_KIND: 'unattended' }).stdout).toContain('ARTIFACTS_SYNC: skipped');
expect(git(['rev-parse', 'main'], remote)).toBe(head); expect(queue()).toBe(beforeQueue);
expect(fs.readFileSync(path.join(state, TIMELINE), 'utf8')).toContain('completed');
expect(fs.existsSync(path.join(state, 'analytics/skill-usage.jsonl'))).toBe(false);
}, 30_000);
test('concurrent drain owner: completion remains queued and a later drain publishes it', async () => {
const holder = Bun.spawn(['bash', '-c', '. "$1"; gstack_brain_lock_acquire "$2" try || exit 9; echo locked; read -r release; gstack_brain_lock_release', '_', path.join(BIN, 'gstack-brain-lock.sh'), state],
{ env, cwd: fixture, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
try {
const reader = holder.stdout.getReader();
expect(new TextDecoder().decode((await reader.read()).value)).toContain('locked');
end();
expect(queue()).toContain(TIMELINE); expect(remotePaths()).not.toContain(TIMELINE);
} finally { holder.stdin.write('release\n'); holder.stdin.end(); expect(await holder.exited).toBe(0); }
run('gstack-brain-sync', ['--once']);
expect(remoteText(TIMELINE)).toContain('completed'); expect(queue()).toBe('');
}, 30_000);
test('sync off: completion is written locally with no queue entry and no push', () => {
seedTrackedTimeline();
const head = git(['rev-parse', 'main'], remote);
run('gstack-config', ['set', 'artifacts_sync_mode', 'off']);
end();
expect(fs.readFileSync(path.join(state, TIMELINE), 'utf8')).toContain('completed');
expect(git(['rev-parse', 'main'], remote)).toBe(head);
expect(queue()).toBe('');
}, 30_000);
test('logger returns only after a delayed enqueue makes its spool record durable', () => {
const shadow = path.join(fixture, 'bin'); fs.mkdirSync(shadow);
fs.symlinkSync(path.resolve(BIN, '../lib'), path.join(fixture, 'lib'));
for (const f of fs.readdirSync(BIN)) { fs.symlinkSync(path.join(BIN, f), path.join(shadow, f)); }
for (const f of ['gstack-timeline-log', 'gstack-brain-enqueue']) { fs.unlinkSync(path.join(shadow, f)); }
fs.copyFileSync(path.join(BIN, 'gstack-timeline-log'), path.join(shadow, 'gstack-timeline-log'));
const marker = path.join(fixture, 'enqueue-done');
fs.writeFileSync(path.join(shadow, 'gstack-brain-enqueue'), '#!/bin/sh\nsleep 0.4\n' + 'printf done > "$ENQUEUE_MARKER"\n', { mode: 0o755 });
const r = spawnSync(path.join(shadow, 'gstack-timeline-log'), [JSON.stringify({ skill: 'qa', event: 'completed' })],
{ cwd: fixture, env: { ...env, ENQUEUE_MARKER: marker }, stdio: 'ignore', timeout: 30_000 });
expect(r.status).toBe(0); expect(fs.existsSync(marker)).toBe(true);
}, 30_000);
});