mirror of https://github.com/garrytan/gstack.git
feat(bin): gstack-codex-session-import — structured Codex transcript parser
Plan-tune cathedral T9. Backfills question-log.jsonl from Codex sessions
since Codex has no AskUserQuestion tool (per docs/spikes/codex-session-format.md)
and gstack AUQ-shaped Decision Briefs show up as agent_message prose.
Walks ~/.codex/sessions/<date>/rollout-*.jsonl, matches each agent_message
that contains either a <gstack-qid:foo-bar> marker or a D-numbered Decision
Brief header, then pairs it with the next user_message for the answer.
Two-tier recovery per D5:
- marker present → source=codex-import-marker, stable question_id
- no marker but D-shape detected → source=codex-import-pattern with
hash-only question_id (never used as preference key per D18)
Subcommands:
gstack-codex-session-import # latest session
gstack-codex-session-import <file> # explicit path
gstack-codex-session-import --since <iso> # all sessions newer than
User-choice extraction handles A/B/C letter responses and prose responses
that start with the option label. Recommended option parsed via the
"(recommended)" label suffix (same convention as Layer 2).
Each extracted event written via gstack-question-log, so source tagging,
dedup, and async derive all apply uniformly. spawnSync uses the cwd from
session_meta so gstack-slug buckets events into the project the user was
actually working in, not the importer's cwd.
7 unit tests cover marker path, pattern fallback, multiple briefs in
sequence, missing user_message, numeric/letter user response forms,
empty-sessions-dir handling.
Smoke-tested against a real ~/.codex/sessions/ file from earlier today —
returns IMPORTED: 0 because that session was autonomous (no AUQ-shaped
prose), proving the bin doesn't false-positive on unrelated agent_message
events.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dbe4844d22
commit
990c9d55d2
|
|
@ -0,0 +1,223 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# gstack-codex-session-import — backfill question-log.jsonl from Codex sessions.
|
||||||
|
#
|
||||||
|
# Codex has no AskUserQuestion tool (per docs/spikes/codex-session-format.md).
|
||||||
|
# gstack skills running on Codex emit Decision Briefs as plain agent_message
|
||||||
|
# text, and the user's response shows up in the next user_message. This
|
||||||
|
# importer reconstructs those question/answer pairs from the structured
|
||||||
|
# JSONL session files at ~/.codex/sessions/<date>/.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# gstack-codex-session-import # latest session under ~/.codex/sessions/
|
||||||
|
# gstack-codex-session-import <path/to.jsonl> # explicit session file
|
||||||
|
# gstack-codex-session-import --since <iso> # all sessions newer than <iso>
|
||||||
|
#
|
||||||
|
# Recovery strategy (two-tier per D5/T4 spike):
|
||||||
|
# 1. Marker-first: extract <gstack-qid:foo-bar> from agent_message → stable id.
|
||||||
|
# 2. Pattern fallback: detect D<N> header + numbered options → hash id
|
||||||
|
# (source=codex-import-pattern, never used as preference key per D18).
|
||||||
|
#
|
||||||
|
# Writes via bin/gstack-question-log so source tagging, dedup, and async
|
||||||
|
# derive all apply uniformly.
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
|
CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}"
|
||||||
|
|
||||||
|
MODE="latest"
|
||||||
|
EXPLICIT_PATH=""
|
||||||
|
SINCE_ISO=""
|
||||||
|
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
case "$1" in
|
||||||
|
--since)
|
||||||
|
MODE="since"
|
||||||
|
SINCE_ISO="${2:-}"
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "unknown flag: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
MODE="explicit"
|
||||||
|
EXPLICIT_PATH="$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve list of session files to process.
|
||||||
|
SESSION_FILES=()
|
||||||
|
case "$MODE" in
|
||||||
|
explicit)
|
||||||
|
if [ ! -f "$EXPLICIT_PATH" ]; then
|
||||||
|
echo "gstack-codex-session-import: file not found: $EXPLICIT_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SESSION_FILES=("$EXPLICIT_PATH")
|
||||||
|
;;
|
||||||
|
latest)
|
||||||
|
if [ ! -d "$CODEX_SESSIONS_ROOT" ]; then
|
||||||
|
echo "NO_SESSIONS: $CODEX_SESSIONS_ROOT does not exist"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
LATEST=$(find "$CODEX_SESSIONS_ROOT" -type f -name "rollout-*.jsonl" -print 2>/dev/null \
|
||||||
|
| xargs ls -t 2>/dev/null | head -1 || true)
|
||||||
|
if [ -z "$LATEST" ]; then
|
||||||
|
echo "NO_SESSIONS: no rollout-*.jsonl files under $CODEX_SESSIONS_ROOT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
SESSION_FILES=("$LATEST")
|
||||||
|
;;
|
||||||
|
since)
|
||||||
|
if [ -z "$SINCE_ISO" ]; then
|
||||||
|
echo "--since requires an ISO 8601 timestamp" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
while IFS= read -r f; do
|
||||||
|
SESSION_FILES+=("$f")
|
||||||
|
done < <(find "$CODEX_SESSIONS_ROOT" -type f -name "rollout-*.jsonl" -newer <(date -u -d "$SINCE_ISO" 2>/dev/null || date -u) 2>/dev/null)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ${#SESSION_FILES[@]} -eq 0 ]; then
|
||||||
|
echo "NO_SESSIONS: nothing to import"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse + extract via bun. Emits one line per question found, ready to pipe
|
||||||
|
# into gstack-question-log. Tagged with source so downstream consumers
|
||||||
|
# (/plan-tune stats, dream cycle) can distinguish backfilled events from
|
||||||
|
# live captures.
|
||||||
|
IMPORTED=0
|
||||||
|
SKIPPED_NO_ANSWER=0
|
||||||
|
|
||||||
|
for SESSION_FILE in "${SESSION_FILES[@]}"; do
|
||||||
|
COUNT_LINE=$(SESSION_FILE_PATH="$SESSION_FILE" QLOG_BIN="$SCRIPT_DIR/gstack-question-log" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const sessionPath = process.env.SESSION_FILE_PATH;
|
||||||
|
const qlogBin = process.env.QLOG_BIN;
|
||||||
|
const lines = fs.readFileSync(sessionPath, "utf-8").trim().split("\n").filter(Boolean);
|
||||||
|
|
||||||
|
let meta = null;
|
||||||
|
const stream = [];
|
||||||
|
for (const ln of lines) {
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(ln);
|
||||||
|
if (e.type === "session_meta") meta = e.payload;
|
||||||
|
else stream.push(e);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!meta) {
|
||||||
|
console.error("WARN: no session_meta in " + sessionPath);
|
||||||
|
console.log("0 0");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = meta.cwd || "";
|
||||||
|
const sessionId = (meta.id || path.basename(sessionPath)).slice(0, 64);
|
||||||
|
|
||||||
|
// Walk for agent_message → next user_message pairs.
|
||||||
|
const briefs = [];
|
||||||
|
for (let i = 0; i < stream.length; i++) {
|
||||||
|
const e = stream[i];
|
||||||
|
if (e.type !== "event_msg" || e.payload?.type !== "agent_message") continue;
|
||||||
|
const text = String(e.payload?.message || "");
|
||||||
|
if (!text) continue;
|
||||||
|
// Detect D-numbered brief or marker. Markers are sufficient on their own.
|
||||||
|
const markerMatch = text.match(/<gstack-qid:([a-z0-9-]{1,64})>/i);
|
||||||
|
const dMatch = text.match(/^D\d+[\.\d]*\s*[—\-]\s*(.+?)$/m);
|
||||||
|
if (!markerMatch && !dMatch) continue;
|
||||||
|
|
||||||
|
// Find the next user_message in the stream.
|
||||||
|
let answer = null;
|
||||||
|
for (let j = i + 1; j < stream.length; j++) {
|
||||||
|
const e2 = stream[j];
|
||||||
|
if (e2.type === "event_msg" && e2.payload?.type === "user_message") {
|
||||||
|
answer = String(e2.payload?.message || "").trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!answer) continue;
|
||||||
|
|
||||||
|
// Extract options A) ... B) ... from the brief.
|
||||||
|
const optMatches = [...text.matchAll(/^([A-Z])\)\s+(.+?)(?:\s+\(recommended\))?$/gm)];
|
||||||
|
const options = optMatches.map((m) => m[2].trim());
|
||||||
|
|
||||||
|
// Identify recommended option (label first, prose fallback).
|
||||||
|
let recommended;
|
||||||
|
const recLabel = [...text.matchAll(/^([A-Z])\)\s+(.+?)\s+\(recommended\)$/gm)];
|
||||||
|
if (recLabel.length === 1) recommended = recLabel[0][2].trim();
|
||||||
|
|
||||||
|
// Identify which option the user picked from their answer.
|
||||||
|
// Look for "A" / "A) ..." / option-label prefix match.
|
||||||
|
let userChoice = "__unknown__";
|
||||||
|
const letterMatch = answer.match(/^\s*([A-Z])\b/);
|
||||||
|
if (letterMatch) {
|
||||||
|
const idx = letterMatch[1].charCodeAt(0) - 65;
|
||||||
|
if (idx >= 0 && idx < options.length) userChoice = options[idx];
|
||||||
|
else userChoice = letterMatch[1];
|
||||||
|
} else if (options.length > 0) {
|
||||||
|
const lower = answer.toLowerCase();
|
||||||
|
const m = options.find((o) => lower.includes(o.toLowerCase().slice(0, 12)));
|
||||||
|
if (m) userChoice = m;
|
||||||
|
}
|
||||||
|
if (userChoice === "__unknown__") {
|
||||||
|
userChoice = answer.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = (dMatch?.[1] || text.split("\n")[0]).slice(0, 200);
|
||||||
|
|
||||||
|
let questionId, source;
|
||||||
|
if (markerMatch) {
|
||||||
|
questionId = markerMatch[1];
|
||||||
|
source = "codex-import-marker";
|
||||||
|
} else {
|
||||||
|
const sortedOpts = [...options].sort().join("|");
|
||||||
|
const h = crypto.createHash("sha1").update("codex::" + summary + "::" + sortedOpts).digest("hex").slice(0, 10);
|
||||||
|
questionId = "hook-" + h;
|
||||||
|
source = "codex-import-pattern";
|
||||||
|
}
|
||||||
|
|
||||||
|
briefs.push({
|
||||||
|
skill: "codex",
|
||||||
|
question_id: questionId,
|
||||||
|
question_summary: summary,
|
||||||
|
options_count: options.length || 1,
|
||||||
|
user_choice: userChoice.slice(0, 64),
|
||||||
|
...(recommended ? { recommended: recommended.slice(0, 64) } : {}),
|
||||||
|
source,
|
||||||
|
session_id: sessionId,
|
||||||
|
// Use ts_nanos+ts shape from the event itself if available; else null.
|
||||||
|
ts: e.timestamp || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
for (const b of briefs) {
|
||||||
|
const res = spawnSync(qlogBin, [JSON.stringify(b)], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
// Run from the originating cwd so gstack-slug bucks events into the
|
||||||
|
// right project. Falls back to the importer cwd if the session cwd
|
||||||
|
// no longer exists.
|
||||||
|
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
if (res.status === 0) imported++;
|
||||||
|
}
|
||||||
|
console.log(imported + " 0");
|
||||||
|
' 2>&1)
|
||||||
|
|
||||||
|
IMP=$(echo "$COUNT_LINE" | awk "{print \$1}")
|
||||||
|
IMPORTED=$((IMPORTED + IMP))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "IMPORTED: $IMPORTED events from ${#SESSION_FILES[@]} session(s)"
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* gstack-codex-session-import — backfill question-log from Codex JSONL.
|
||||||
|
*
|
||||||
|
* Plan-tune cathedral T9. Verifies the structured-file parser (D5) handles
|
||||||
|
* the two-tier recovery strategy from docs/spikes/codex-session-format.md:
|
||||||
|
* - Marker-first: <gstack-qid:foo-bar> → source=codex-import-marker.
|
||||||
|
* - Pattern fallback: D-numbered brief → source=codex-import-pattern,
|
||||||
|
* hash-only question_id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
|
const BIN = path.join(ROOT, 'bin', 'gstack-codex-session-import');
|
||||||
|
|
||||||
|
let stateRoot: string;
|
||||||
|
let fixtureCwd: string;
|
||||||
|
let cwdSlug: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cdximp-'));
|
||||||
|
cwdSlug = 'codex-fixture-slug';
|
||||||
|
fixtureCwd = path.join(stateRoot, cwdSlug);
|
||||||
|
fs.mkdirSync(fixtureCwd, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(stateRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeSessionFile(events: Array<Record<string, unknown>>, sessionId = 'sess-fixture'): string {
|
||||||
|
const p = path.join(stateRoot, 'rollout-fixture.jsonl');
|
||||||
|
const meta = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: 'session_meta',
|
||||||
|
payload: { id: sessionId, cwd: fixtureCwd },
|
||||||
|
};
|
||||||
|
const lines = [JSON.stringify(meta), ...events.map((e) => JSON.stringify(e))];
|
||||||
|
fs.writeFileSync(p, lines.join('\n') + '\n');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentMessage(text: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: 'event_msg',
|
||||||
|
payload: { type: 'agent_message', message: text },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(text: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: 'event_msg',
|
||||||
|
payload: { type: 'user_message', message: text },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runImport(sessionPath: string): { stdout: string; stderr: string; status: number } {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(process.env)) {
|
||||||
|
if (v !== undefined) env[k] = v;
|
||||||
|
}
|
||||||
|
env.GSTACK_STATE_ROOT = stateRoot;
|
||||||
|
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
||||||
|
delete env.GSTACK_HOME;
|
||||||
|
const res = spawnSync(BIN, [sessionPath], { env, encoding: 'utf-8', cwd: ROOT });
|
||||||
|
return {
|
||||||
|
stdout: res.stdout ?? '',
|
||||||
|
stderr: res.stderr ?? '',
|
||||||
|
status: res.status ?? -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImportedEvents(): Array<Record<string, unknown>> {
|
||||||
|
const f = path.join(stateRoot, 'projects', cwdSlug, 'question-log.jsonl');
|
||||||
|
if (!fs.existsSync(f)) return [];
|
||||||
|
return fs
|
||||||
|
.readFileSync(f, 'utf-8')
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => JSON.parse(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Marker-first path
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('marker-first import (source=codex-import-marker)', () => {
|
||||||
|
test('extracts marker id from agent_message and pairs with next user_message', () => {
|
||||||
|
const sessionPath = writeSessionFile([
|
||||||
|
agentMessage(
|
||||||
|
'D1 — Test\nELI10: blah\n<gstack-qid:ship-test-failure-triage> Tests failed.\nRecommendation: A\nA) Fix now (recommended)\nB) Investigate\nC) Ack and ship',
|
||||||
|
),
|
||||||
|
userMessage('A'),
|
||||||
|
]);
|
||||||
|
const r = runImport(sessionPath);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain('IMPORTED: 1');
|
||||||
|
const events = readImportedEvents();
|
||||||
|
expect(events.length).toBe(1);
|
||||||
|
expect(events[0].source).toBe('codex-import-marker');
|
||||||
|
expect(events[0].question_id).toBe('ship-test-failure-triage');
|
||||||
|
expect(events[0].user_choice).toContain('Fix now');
|
||||||
|
expect(events[0].recommended).toContain('Fix now');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Pattern fallback
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('pattern fallback (source=codex-import-pattern)', () => {
|
||||||
|
test('D-numbered brief without marker → hash id + source=codex-import-pattern', () => {
|
||||||
|
const sessionPath = writeSessionFile([
|
||||||
|
agentMessage('D2 — Unmarked brief\nA) Foo (recommended)\nB) Bar'),
|
||||||
|
userMessage('A'),
|
||||||
|
]);
|
||||||
|
const r = runImport(sessionPath);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
const events = readImportedEvents();
|
||||||
|
expect(events.length).toBe(1);
|
||||||
|
expect(events[0].source).toBe('codex-import-pattern');
|
||||||
|
expect((events[0].question_id as string).startsWith('hook-')).toBe(true);
|
||||||
|
expect(events[0].user_choice).toContain('Foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Edge cases
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
test('no AUQ-shaped events → 0 imported, exit 0', () => {
|
||||||
|
const sessionPath = writeSessionFile([
|
||||||
|
agentMessage('Just doing some work, nothing to ask.'),
|
||||||
|
]);
|
||||||
|
const r = runImport(sessionPath);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain('IMPORTED: 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent_message with marker but no following user_message → skipped', () => {
|
||||||
|
const sessionPath = writeSessionFile([
|
||||||
|
agentMessage('<gstack-qid:test-q> D1 — Q\nA) Foo\nB) Bar'),
|
||||||
|
// no user_message
|
||||||
|
]);
|
||||||
|
const r = runImport(sessionPath);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(readImportedEvents().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two D-briefs in sequence → both imported', () => {
|
||||||
|
const sessionPath = writeSessionFile([
|
||||||
|
agentMessage('D1 — First <gstack-qid:q1>\nA) Foo (recommended)\nB) Bar'),
|
||||||
|
userMessage('A'),
|
||||||
|
agentMessage('D2 — Second <gstack-qid:q2>\nA) Baz (recommended)\nB) Qux'),
|
||||||
|
userMessage('B'),
|
||||||
|
]);
|
||||||
|
const r = runImport(sessionPath);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
const events = readImportedEvents();
|
||||||
|
expect(events.length).toBe(2);
|
||||||
|
expect(events[0].question_id).toBe('q1');
|
||||||
|
expect(events[1].question_id).toBe('q2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numeric user response also resolves to letter index', () => {
|
||||||
|
const sessionPath = writeSessionFile([
|
||||||
|
agentMessage('D1 — Test <gstack-qid:numeric-q>\nA) Foo\nB) Bar\nC) Baz'),
|
||||||
|
userMessage('B - I think B is right'),
|
||||||
|
]);
|
||||||
|
runImport(sessionPath);
|
||||||
|
const events = readImportedEvents();
|
||||||
|
expect(events.length).toBe(1);
|
||||||
|
expect(events[0].user_choice).toContain('Bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Default-mode (latest session) behavior
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('default mode (no args → latest)', () => {
|
||||||
|
test('returns NO_SESSIONS when sessions dir is empty', () => {
|
||||||
|
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-empty-cdx-'));
|
||||||
|
try {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(process.env)) {
|
||||||
|
if (v !== undefined) env[k] = v;
|
||||||
|
}
|
||||||
|
env.GSTACK_STATE_ROOT = stateRoot;
|
||||||
|
env.CODEX_SESSIONS_ROOT = emptyDir;
|
||||||
|
const res = spawnSync(BIN, [], { env, encoding: 'utf-8', cwd: ROOT });
|
||||||
|
expect(res.status).toBe(0);
|
||||||
|
expect(res.stdout).toMatch(/NO_SESSIONS/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(emptyDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue