mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* fix(timeline): reject a missing or malformed --limit with exit 2 (#1723) gstack-timeline-read parsed --limit with parseInt and fell back to 20, so '--limit 1abc' became 1, 'nope', 0 and -5 silently became the default, and a bare --limit crashed on an unbound $2. Validate the argument as a positive integer before the no-timeline early exit and exit 2 with an example. No flag still means 20. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(skill-start): echo SESSIONS, the live session-marker count (#1651) The sessions directory is touched and expired at skill start, but the count was never printed, so CLAUDE.md rules that read the preamble's session count had nothing to read. Echo SESSIONS: N (markers fresher than 120 minutes, this session included). Idea from @0xDevNinja's PR #1747, redone in the consolidated skill-start script. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): pick the closing tier from the profile read before this session is logged (#2801) Phase 4.5 appended this session to the builder profile and Phase 6 then read the profile to choose its closing, so every run saw itself as history: a first-time user got welcome_back and was asked how the assignment issued minutes earlier went. Phase 4.5 now reads the profile before --log-session and records the prior tier, count and last-session fields in the transcript; Phase 6 uses those values (this session is prior + 1) and re-reads only the running totals. A failed read proceeds as a first session and says so. Tier headers now name the session numbers each tier actually covers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): print the full design doc inline before asking for approval (#879) Tool output collapses in Claude Code, so users were asked to approve a doc they could not see. Port of @RyanAlberts's PR #1116 into the carved design-and-handoff section. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): report success only when this run wrote its design doc (#1049) Before telemetry, check the design doc this run wrote, by its exact path under the state root, never the newest file in the folder. Success needs a non-empty doc there; otherwise the outcome is abort or error with failed step design_doc. Rewrite of @walton-chris's PR #1049, which matched the newest global session marker and hardcoded ~/.gstack. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan reviews): skip the /office-hours prerequisite offer in spawned and headless sessions (#1958) The BENEFITS_FROM offer had no recommended option, so a spawned run had nothing defined to auto-pick and a headless run could only block. Skip it there and continue with the standard review. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: regenerate SKILL.md files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: office-hours tier order, failed-read fallback, inline doc, design-doc check and offer skip Rendered-block tests run the Phase 4.5 read and log on a temp state root (first session reads introduction, failed read is marked) and the pre-telemetry check against this run's doc, an empty doc, another session's doc and a path outside the state root. Parity caps take the measured growth. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(release): v1.91.37.0 changelog and TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): run watchdog test servers under a private HOME The headed-mode case starts a real server with BROWSE_HEADED=1, and its headed launch writes ~/.gstack/.auth.json for the extension. With the caller's HOME, a slow kill let that write reach the real home and failed the free runner's home-containment check (free-suite shard 5 on #3075). Give each spawned server a private HOME and GSTACK_HOME, keeping Playwright's browser cache path. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): name the office-hours-auto-mode case so the PR lane can select it The PR lane runs each short probe with a Bun name pattern ending in its case id. The office-hours AskUserQuestion-blocked smoke had a descriptive test name and no CASE_TEST_NAMES label, so whenever office-hours changed its shard matched 0 tests and failed in 0 s (E2E Evals slice 6 on #3075). Name the test after its id, keep the description on the describe, and check that every literal-named PR probe has a test the pattern selects. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): skip a last-session greeting clause whose value is empty A profile backfilled from the timeline can carry a welcome_back or regular tier with empty LAST_ASSIGNMENT and LAST_PROJECT. The welcome_back and regular greetings now drop the "last time" clause and its question when the value it names is empty, and say only "Welcome back." 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>
289 lines
14 KiB
TypeScript
289 lines
14 KiB
TypeScript
/**
|
|
* Case and trial shard keys for the paid lane: which files shard per case, how a case shard and its trials are named, and expansion of files into case/trial shards. Moved from scripts/test-paid-shards.ts.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { normalizeRelativePath } from './shard-engine';
|
|
import { CASE_CI_EXCLUDE, CASE_QUARANTINE, EVAL_POLICY } from '../../test/helpers/periodic-exclude-data';
|
|
import type { EvalCaseKind, PanelShape } from '../../test/helpers/eval-store';
|
|
import { E2E_KINDS } from '../../test/helpers/touchfiles-data';
|
|
import { E2E_TOUCHFILES, E2E_TIERS } from '../../test/helpers/touchfiles';
|
|
import { type PaidTier, ROOT } from './paid-types';
|
|
|
|
/**
|
|
* The E2E ids a paid file registers: the touchfile registrations that list the
|
|
* file. `known` is true only when those ids are complete: no computed
|
|
* registration (testName, *IfSelected, describeIfSelected with a non-literal
|
|
* argument) and every literal registration argument is among them
|
|
* (`unregistered` lists the literal ids that are not). Quoted strings
|
|
* elsewhere (comments, skill paths) never count.
|
|
*/
|
|
export function fileCaseRegistration(
|
|
file: string, source: string,
|
|
touchfiles: Record<string, string[]> = E2E_TOUCHFILES,
|
|
tiers: Record<string, string> = E2E_TIERS,
|
|
): { registered: string[]; known: boolean; computed: boolean; unregistered: string[] } {
|
|
const rel = normalizeRelativePath(file);
|
|
const registered = Object.keys(touchfiles).filter(key => touchfiles[key]!.includes(rel));
|
|
const computed = /testName\s*:\s*(?!string\b)(?:`[^`]*\$\{|[A-Za-z_$])/.test(source)
|
|
|| /\btest(?:Concurrent)?IfSelected\s*\(\s*(?:`[^`]*\$\{|[A-Za-z_$])/.test(source)
|
|
|| /\bdescribeIfSelected\s*\([^,]*,(?!\s*\[)/.test(source)
|
|
|| [...source.matchAll(/\bdescribeIfSelected\s*\([^,]*,\s*\[([^\]]*)\]/g)].some(m => m[1]!.split(',')
|
|
.map(item => item.trim()).some(item => item && !/^(['"`])[^'"`$]*\1$/.test(item)));
|
|
const literal = [
|
|
...[...source.matchAll(/testName\s*:\s*(['"`])([^'"`]+)\1/g)].map(m => m[2]!),
|
|
...[...source.matchAll(/\btest(?:Concurrent)?IfSelected\s*\(\s*(['"`])([^'"`]+)\1/g)].map(m => m[2]!),
|
|
...[...source.matchAll(/\bdescribeIfSelected\s*\([^,]*,\s*\[([^\]]*)\]/g)]
|
|
.flatMap(m => [...m[1]!.matchAll(/(['"`])([^'"`]+)\1/g)].map(n => n[2]!)),
|
|
].filter(id => id in tiers);
|
|
const unregistered = [...new Set(literal.filter(id => !registered.includes(id)))];
|
|
return { registered, known: registered.length > 0 && !computed && unregistered.length === 0, computed, unregistered };
|
|
}
|
|
|
|
|
|
/**
|
|
* Files whose cases run in separate processes, one shard per registered E2E
|
|
* case (`<file>#<case id>`): the file's lane wall exceeds one runner's budget
|
|
* while every case is short. Separate processes also give each case its own
|
|
* SDK semaphore, so shared-libs(-paths) capture waves never queue inside a
|
|
* sibling case's wall (the reason paths runs test.serial in one process).
|
|
* Every case must be a registered, literal E2E id whose Bun test name is the
|
|
* id or its CASE_TEST_NAMES label (test/paid-shards.test.ts scans the sources).
|
|
*/
|
|
export const CASE_SHARDED_FILES: readonly string[] = [
|
|
// W5a: one carved skill per case shard (was 15 one-line wrapper files under one case id).
|
|
'test/carve-section-loading.test.ts',
|
|
'test/skill-e2e-deploy.test.ts',
|
|
'test/skill-e2e-design.test.ts',
|
|
'test/skill-e2e-plan.test.ts',
|
|
'test/skill-e2e-review-army.test.ts',
|
|
'test/skill-e2e-shared-libs-paths.test.ts',
|
|
'test/skill-e2e-shared-libs.test.ts',
|
|
'test/skill-e2e-ship-docsync.test.ts',
|
|
'test/skill-e2e-qa-callers.test.ts',
|
|
// W5c: its gate cases (qa-quick, qa-only-no-fix, qa-bootstrap) no longer share one ~7-minute runner.
|
|
'test/skill-e2e-qa-workflow.test.ts',
|
|
];
|
|
|
|
/** Bun test names that differ from their E2E id. */
|
|
export const CASE_TEST_NAMES: Record<string, string> = {
|
|
'plan-review-report': '/plan-eng-review writes GSTACK REVIEW REPORT to plan file',
|
|
'auq-format-gate': "/plan-ceo-review's first AskUserQuestion is a compliant decision brief (7/7 + substance)",
|
|
'autoplan-dual-voice': 'both Claude + Codex voices produce output in Phase 1 (within timeout)',
|
|
'cso-full-audit': '/cso persists supported tenant-boundary findings with redacted evidence',
|
|
'cso-diff-mode': '/cso --diff records its base and investigates changed security paths',
|
|
'cso-infra-scope': '/cso --infra finds an attacker-to-credential execution path',
|
|
'plan-ceo-review-plan-mode': 'first terminal outcome is asked (Step 0 fires before any plan write)',
|
|
'plan-eng-review-artifact': 'an interactive review writes one QA test plan about the reviewed change',
|
|
'plan-eng-review-artifact-full': 'a fresh interactive review reaches Test review and writes one QA test plan',
|
|
};
|
|
|
|
export const CASE_KEY_SEPARATOR = '#';
|
|
const TRIAL_SUFFIX = /~t([1-9][0-9]*)$/;
|
|
|
|
/** The test file behind a shard key (`<file>`, `<file>#<case id>` or `<file>#<case id>~t<N>`). */
|
|
export function shardFile(key: string): string {
|
|
return normalizeRelativePath(key).split(CASE_KEY_SEPARATOR)[0]!;
|
|
}
|
|
|
|
/** The E2E case id of a case or trial shard key, else null. */
|
|
export function shardCaseId(key: string): string | null {
|
|
const [, id] = normalizeRelativePath(key).split(CASE_KEY_SEPARATOR);
|
|
return id === undefined ? null : id.replace(TRIAL_SUFFIX, '');
|
|
}
|
|
|
|
/** The 1-based trial index of an isolated trial shard key, else null. */
|
|
export function shardTrial(key: string): number | null {
|
|
const [, id] = normalizeRelativePath(key).split(CASE_KEY_SEPARATOR);
|
|
const match = id === undefined ? null : TRIAL_SUFFIX.exec(id);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
/** Shard key of one trial of an isolated case. */
|
|
export function trialShardKey(file: string, id: string, trial: number): string {
|
|
return `${normalizeRelativePath(file)}${CASE_KEY_SEPARATOR}${id}~t${trial}`;
|
|
}
|
|
|
|
/** Trial policy of one case, fixed from the registries before the run. */
|
|
export interface CaseTrialPlan { kind: EvalCaseKind; panel: PanelShape; quarantined: boolean }
|
|
|
|
/**
|
|
* `behavior` cases run EVAL_POLICY.panel; a quarantined case runs a full panel
|
|
* whose k keeps its kind's meaning (k = n for rule); everything else runs one
|
|
* trial. Only behavior and quarantined cases are isolated into trial shards.
|
|
*/
|
|
export function caseTrialPlan(id: string, kinds: Record<string, EvalCaseKind> = E2E_KINDS,
|
|
quarantine: Record<string, unknown> = CASE_QUARANTINE): CaseTrialPlan {
|
|
const kind = kinds[id] ?? 'rule';
|
|
const quarantined = Object.hasOwn(quarantine, id);
|
|
if (kind === 'behavior') return { kind, panel: { ...EVAL_POLICY.panel }, quarantined };
|
|
if (quarantined) return { kind, panel: { n: EVAL_POLICY.panel.n, k: EVAL_POLICY.panel.n }, quarantined };
|
|
return { kind, panel: { n: 1, k: 1 }, quarantined };
|
|
}
|
|
|
|
export function isIsolatedCase(plan: CaseTrialPlan): boolean {
|
|
return plan.kind === 'behavior' || plan.quarantined;
|
|
}
|
|
|
|
export function sameTrialPlan(a: CaseTrialPlan | undefined, b: CaseTrialPlan | undefined): boolean {
|
|
return !!a && !!b && a.kind === b.kind && a.quarantined === b.quarantined && a.panel?.n === b.panel?.n && a.panel?.k === b.panel?.k;
|
|
}
|
|
|
|
/** Bun name pattern that runs every case of a file except `ids` (their trial shards run them). */
|
|
export function excludedCasesNamePattern(ids: string[]): string {
|
|
const escaped = ids.map(id => (CASE_TEST_NAMES[id] ?? id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
return `^(?!.*(?:^|\\s)(?:${escaped.join('|')})$)`;
|
|
}
|
|
|
|
/** Exact Bun name pattern for a set of case ids (labels where the test name differs). */
|
|
export function caseTestNamePattern(ids: string[]): string {
|
|
const escaped = ids.map(id => (CASE_TEST_NAMES[id] ?? id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
return `(?:^|\\s)(?:${escaped.join('|')})$`;
|
|
}
|
|
|
|
/**
|
|
* Replace each case-sharded file with one key per registered case of `tier`.
|
|
* Throws when such a file's registration is not statically complete: an
|
|
* unregistered case would otherwise silently never run.
|
|
*/
|
|
export function expandCaseShards(files: string[], tier: PaidTier, rootDir = ROOT,
|
|
touchfiles: Record<string, string[]> = E2E_TOUCHFILES, tiers: Record<string, string> = E2E_TIERS): string[] {
|
|
return files.flatMap(file => {
|
|
const rel = normalizeRelativePath(file);
|
|
if (!CASE_SHARDED_FILES.includes(rel)) return [file];
|
|
const { registered, known } = fileCaseRegistration(rel, fs.readFileSync(path.join(rootDir, rel), 'utf8'), touchfiles, tiers);
|
|
if (!known) throw new Error(`Case-sharded ${rel} needs a complete literal case registration`);
|
|
return registered.filter(id => tiers[id] === tier).sort().map(id => `${rel}${CASE_KEY_SEPARATOR}${id}`);
|
|
});
|
|
}
|
|
|
|
export interface TrialExpansion {
|
|
keys: string[];
|
|
/** Trial policy per trial shard key. */
|
|
trials: Record<string, CaseTrialPlan>;
|
|
/** File shard key -> isolated case ids its name pattern excludes. */
|
|
excludeCases: Record<string, string[]>;
|
|
}
|
|
|
|
/**
|
|
* Isolate every behavior or quarantined case of `tier` into its panel of trial
|
|
* shards (`<file>#<id>~t1..tn`), each selected by EVALS_SELECTION_JSON=[id] and
|
|
* its exact test name. The file shard keeps the remaining ids of the tier and
|
|
* excludes the isolated ones by name; with none remaining it is dropped. A case
|
|
* may be isolated only when its file's registration is statically known.
|
|
*/
|
|
export function expandTrialShards(keys: string[], tier: PaidTier, rootDir = ROOT, opts: {
|
|
kinds?: Record<string, EvalCaseKind>; quarantine?: Record<string, unknown>;
|
|
touchfiles?: Record<string, string[]>; tiers?: Record<string, string>;
|
|
} = {}): TrialExpansion {
|
|
const touchfiles = opts.touchfiles ?? E2E_TOUCHFILES;
|
|
const tiers = opts.tiers ?? E2E_TIERS;
|
|
const planOf = (id: string) => caseTrialPlan(id, opts.kinds, opts.quarantine);
|
|
const out: TrialExpansion = { keys: [], trials: {}, excludeCases: {} };
|
|
const addPanel = (file: string, id: string) => {
|
|
const plan = planOf(id);
|
|
for (let trial = 1; trial <= plan.panel.n; trial++) {
|
|
const key = trialShardKey(file, id, trial);
|
|
out.keys.push(key);
|
|
out.trials[key] = plan;
|
|
}
|
|
};
|
|
for (const key of keys) {
|
|
const file = shardFile(key);
|
|
const caseId = shardCaseId(key);
|
|
if (caseId !== null) {
|
|
if (isIsolatedCase(planOf(caseId))) addPanel(file, caseId);
|
|
else out.keys.push(key);
|
|
continue;
|
|
}
|
|
const { registered, known } = fileCaseRegistration(file, fs.readFileSync(path.join(rootDir, file), 'utf8'), touchfiles, tiers);
|
|
const inTier = registered.filter(id => tiers[id] === tier);
|
|
const isolated = inTier.filter(id => isIsolatedCase(planOf(id)));
|
|
if (isolated.length === 0) { out.keys.push(key); continue; }
|
|
if (!known) {
|
|
throw new Error(`${file}: behavior or quarantined case(s) ${isolated.join(', ')} need a statically known case registration`);
|
|
}
|
|
for (const id of isolated) addPanel(file, id);
|
|
if (inTier.length > isolated.length) {
|
|
out.keys.push(key);
|
|
out.excludeCases[normalizeRelativePath(key)] = [...isolated].sort();
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Split expanded shard keys into runnable keys and CI-unrunnable cases
|
|
* (CASE_CI_EXCLUDE), each with its surfaced reason; never an empty shard.
|
|
*/
|
|
export function partitionCaseExclusions(keys: string[]): { runnable: string[]; excluded: Array<{ file: string; reason: string }> } {
|
|
const excluded: Array<{ file: string; reason: string }> = [];
|
|
const runnable = keys.filter(key => {
|
|
const exclusion = CASE_CI_EXCLUDE[normalizeRelativePath(key)];
|
|
if (exclusion) excluded.push({ file: key, reason: `excluded: ${exclusion.reason} [${exclusion.tracking}]` });
|
|
return !exclusion;
|
|
});
|
|
return { runnable, excluded };
|
|
}
|
|
|
|
/**
|
|
* Codex access in the CI eval image, scoped per paid shard.
|
|
*
|
|
* The image installs the pinned Codex CLI off PATH (GSTACK_CI_CODEX_BIN_DIR)
|
|
* and the eval workflows log it in under a non-default home
|
|
* (GSTACK_CI_CODEX_HOME). A gate shard never sees either. A non-gate shard
|
|
* whose every file exercises Codex gets the CLI on PATH and the home as
|
|
* CODEX_HOME. A file that mixes one Codex case with other cases keeps only
|
|
* the two variables, and that case opts in on its own session; hermetic
|
|
* child environments drop GSTACK_* names, so its sibling sessions see no
|
|
* Codex. Every other shard sees neither, so a skill's outside-voice probe
|
|
* (`command -v codex`) reports not_installed exactly as it did before Codex
|
|
* entered the image. Outside CI neither variable is set and nothing changes.
|
|
*/
|
|
export const CODEX_CI_ENV = { binDir: 'GSTACK_CI_CODEX_BIN_DIR', home: 'GSTACK_CI_CODEX_HOME' } as const;
|
|
|
|
/** Paid files whose every case exercises the real Codex CLI. */
|
|
export const CODEX_CI_FILES: readonly string[] = [
|
|
'test/codex-e2e.test.ts',
|
|
'test/codex-e2e-sol-scope.test.ts',
|
|
'test/codex-e2e-shared-libs.test.ts',
|
|
'test/codex-e2e-recommendation-substance.test.ts',
|
|
'test/skill-e2e-outside-voice.test.ts',
|
|
'test/skill-e2e-outside-plan-disabled.test.ts',
|
|
'test/skill-e2e-safety-codex-boundary.test.ts',
|
|
'test/codex-e2e-multiblock-live.test.ts',
|
|
];
|
|
|
|
/**
|
|
* Codex cases inside files whose other cases must not see Codex. Each listed
|
|
* case opts in by putting $GSTACK_CI_CODEX_BIN_DIR on its own session PATH
|
|
* (inline, so the file's other touchfile keys do not grow a helper).
|
|
*/
|
|
export const CODEX_CI_CASES: Readonly<Record<string, readonly string[]>> = {
|
|
'test/skill-e2e-workflow.test.ts': ['codex-review'],
|
|
};
|
|
|
|
export type CodexShardAccess = 'none' | 'case-opt-in' | 'path';
|
|
|
|
/** What one shard, given the test files behind its keys, may see of the CI Codex install in `tier`. */
|
|
export function codexShardAccess(files: readonly string[], tier: string | undefined): CodexShardAccess {
|
|
if (tier === 'gate' || files.length === 0) return 'none';
|
|
if (files.every(file => CODEX_CI_FILES.includes(file))) return 'path';
|
|
return files.every(file => file in CODEX_CI_CASES) ? 'case-opt-in' : 'none';
|
|
}
|
|
|
|
/** Scope one shard's child environment in place; `files` are the shard keys' test files. */
|
|
export function scopeCodexAccess(env: NodeJS.ProcessEnv, files: readonly string[]): void {
|
|
const binDir = env[CODEX_CI_ENV.binDir];
|
|
if (!binDir) return;
|
|
const home = env[CODEX_CI_ENV.home];
|
|
const access = codexShardAccess(files, env.EVALS_TIER);
|
|
delete env.CODEX_HOME;
|
|
if (access === 'case-opt-in') return;
|
|
delete env[CODEX_CI_ENV.binDir];
|
|
delete env[CODEX_CI_ENV.home];
|
|
if (access !== 'path') return;
|
|
env.PATH = env.PATH ? `${binDir}${path.delimiter}${env.PATH}` : binDir;
|
|
if (home) env.CODEX_HOME = home;
|
|
}
|