Phase 5.1 of the CJS↔SDK hard-seam migration (parent #3524). Migrates the bin/lib/state-command-router.cjs handlers map to delegate every canonical state subcommand through the executeForCjs synchronous primitive (shipped in Phase 5.0, PR #3558). ## Bundled fix for Phase 5.0 worker defect Discovered during Phase 5.1 implementation that the Phase 5.0 worker drops projectDir and workstream from RuntimeBridgeExecuteInput. The dispatch closure at sdk/src/runtime-bridge-sync/worker.ts:41-42 hardcoded projectDir to '', so registry handlers that read .planning/ from projectDir (every state.* handler) saw an empty path and failed. Phase 5.0's pinning tests passed because they exercised commands that don't depend on projectDir (generate-slug takes its arg directly; unknown_command doesn't dispatch). Maintainer authorized bundling the fix into this PR. Fix: moved QueryNativeDirectAdapter construction inside the dispatchNative lambda so request.projectDir and request.workstream close over the per-request values. Per-request adapter construction adds <1ms overhead; correctness wins. Regression test at sdk/src/runtime-bridge-sync/projectdir-regression.test.ts demonstrates RED before fix → GREEN after. Phase 5.0's index.test.ts native_failure fixture was passing because of the bug — it relied on projectDir = '' producing a specific error path. Updated to use a /nonexistent-... path that triggers ENOENT under realpath, producing native_failure as intended. ## What landed for Phase 5.1 - bin/lib/state-command-router.cjs migrated. Every subcommand entry in the handlers map dispatches via executeForCjs when SDK is available, with transparent fallback to the existing CJS handlers in state.cjs if (a) SDK is not built / not present, or (b) GSD_WORKSTREAM is set (the sync-bridge worker cannot serve workstream-scoped commands per the SDK transport architecture). - Special cases preserved: - load --raw: SDK data formatted into key=value lines matching cmdStateLoad's exact format. - complete-phase: CJS-only (no SDK counterpart yet). - add-roadmap-evolution: stays on the unsupported list (SDK-only). - Golden parity tests added for 12 previously-uncovered state subcommands: advance-plan, record-metric, update-progress, add-decision, add-blocker, resolve-blocker, record-session, signal-waiting, signal-resume, planned-phase, milestone-switch, prune. ## Design decisions worth reviewer visibility 1. Lazy SDK loading with CJS fallback. The migration routes via executeForCjs only when the SDK is loadable; otherwise falls back to the existing CJS handlers. Conservative for rollback — if the SDK build is broken on a deploy, state commands keep working via the CJS path. Trade-off: drift surface is not structurally eliminated yet — the CJS handlers remain reachable. 2. Workstream → CJS fallback. The SDK transport forces subprocess for workstream commands, but subprocess is disabled in the sync bridge. When GSD_WORKSTREAM is set, the entire state command falls back to CJS rather than failing. Workstream users continue running the CJS handlers; the SDK path is exercised only in the default (no workstream) case. 3. Two documented parity divergences. state.record-metric: CJS auto-creates ## Performance Metrics section when absent; SDK returns {recorded: false, reason}. Test requires fixture with the section present. state.prune: CJS counts phases from disk; SDK reads from frontmatter fields. Test asserts structural shape rather than exact equality. ## Numbers - Full CJS suite: 9323/9323 pass (baseline 9323; +0 net because the 12 new parity tests are SDK-side vitest, not CJS-side). - SDK vitest sync-bridge: 10/10 pass. - Regression test: 3/3 pass (proved RED before fix, GREEN after). - tests/state.test.cjs (the safety net): 104/104 pass unchanged. ## Performance gsd-tools state load via the SDK path: 49ms first call (Worker startup), 43-44ms steady-state median. Slower than the Phase 5.0-measured 0.1ms because state.load does fs reads on top of the bridge overhead. Still well within the budget for CJS dispatcher overhead. Closes #3567.
152 lines
6.1 KiB
TypeScript
152 lines
6.1 KiB
TypeScript
/**
|
|
* Regression test for the Phase 5.0 worker bug: projectDir and workstream were
|
|
* dropped from RuntimeBridgeExecuteInput before being forwarded to
|
|
* registry.dispatch(). The worker constructed a module-scoped
|
|
* QueryNativeDirectAdapter with a hardcoded projectDir='' — meaning any handler
|
|
* that reads .planning/ (e.g. state.*) would either fail silently or read from
|
|
* the process CWD rather than the requested project directory.
|
|
*
|
|
* Fix (Phase 5.1): the adapter is now constructed per-request inside
|
|
* dispatchNative so request.projectDir and request.workstream close over the
|
|
* correct values.
|
|
*
|
|
* These tests must:
|
|
* - FAIL against the unfixed worker (projectDir='', handler sees wrong dir).
|
|
* - PASS against the fixed worker (projectDir threaded correctly).
|
|
*
|
|
* NOTE: executeForCjs uses a compiled dist/ worker (see index.ts comments).
|
|
* The tests here call executeForCjs, which requires the worker to be rebuilt
|
|
* before the fix is observable. Run `npm run build` in sdk/ first.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { executeForCjs } from './index.js';
|
|
|
|
// ─── Fixture STATE.md with parseable frontmatter ──────────────────────────
|
|
|
|
const FIXTURE_STATE = `---
|
|
gsd_state_version: 1.0
|
|
milestone: v9.1
|
|
milestone_name: Regression Test Milestone
|
|
status: executing
|
|
---
|
|
|
|
# Project State
|
|
|
|
## Current Position
|
|
|
|
Phase: 9 (Regression Tests) — EXECUTING
|
|
Plan: 1 of 2
|
|
Status: Executing Phase 9
|
|
Last activity: 2026-05-15 -- Regression test started
|
|
|
|
Progress: [█████░░░░░] 50%
|
|
`;
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = join(
|
|
tmpdir(),
|
|
`gsd-projectdir-regression-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
|
await writeFile(join(tmpDir, '.planning', 'STATE.md'), FIXTURE_STATE, 'utf-8');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('executeForCjs projectDir regression (Phase 5.0 bug)', () => {
|
|
it('threads projectDir to the handler: state.json returns frontmatter data from the tmpdir fixture', () => {
|
|
// This test FAILS against the unfixed worker because projectDir='' causes
|
|
// the handler to look for .planning/STATE.md relative to '' (process CWD),
|
|
// which does not have a STATE.md fixture. The handler returns { error: 'STATE.md not found' }.
|
|
//
|
|
// With the fix, projectDir=tmpDir is forwarded and the handler reads the fixture.
|
|
const result = executeForCjs({
|
|
registryCommand: 'state.json',
|
|
registryArgs: [],
|
|
legacyCommand: 'state',
|
|
legacyArgs: ['json'],
|
|
mode: 'json',
|
|
projectDir: tmpDir,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return; // narrow for TS
|
|
|
|
const data = result.data as Record<string, unknown>;
|
|
|
|
// The handler should have found the fixture and returned parsed frontmatter.
|
|
// Key assertions: these fields come from FIXTURE_STATE and are absent from
|
|
// any STATE.md that might exist at ''.
|
|
expect(data).not.toHaveProperty('error');
|
|
expect(data.milestone).toBe('v9.1');
|
|
expect(data.milestone_name).toBe('Regression Test Milestone');
|
|
expect(data.status).toBe('executing');
|
|
});
|
|
|
|
it('negative: nonexistent projectDir returns ok:true with {error} (handler-level not-found)', () => {
|
|
// A completely nonexistent directory: handler cannot find .planning/STATE.md
|
|
// and returns a structured error payload rather than throwing. This is the
|
|
// expected "soft failure" shape for state.json on a missing project.
|
|
const result = executeForCjs({
|
|
registryCommand: 'state.json',
|
|
registryArgs: [],
|
|
legacyCommand: 'state',
|
|
legacyArgs: ['json'],
|
|
mode: 'json',
|
|
projectDir: '/nonexistent-gsd-project-regression-test-dir',
|
|
});
|
|
|
|
// The handler returns { data: { error: 'STATE.md not found' } } — ok:true
|
|
// because it is a domain-level not-found, not a dispatch error.
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data).toHaveProperty('error');
|
|
expect(String(data.error)).toMatch(/STATE\.md not found/i);
|
|
});
|
|
|
|
it('workstream transport contract: GSDTransport forces subprocess for workstream requests (subprocess disabled in worker → ok:false)', () => {
|
|
// This test documents an architectural constraint, not a bug.
|
|
//
|
|
// GSDTransport.subprocessReason() returns 'workstream_forced' when
|
|
// request.workstream is set (gsd-transport.ts line ~72). The worker has
|
|
// subprocess disabled (allowFallbackToSubprocess=false), so a workstream
|
|
// request always surfaces as ok:false / internal_error.
|
|
//
|
|
// This is the expected contract for the sync bridge worker: workstream
|
|
// scoped commands cannot run natively in the worker and must be invoked
|
|
// via the async bridge or gsd-tools.cjs subprocess fallback instead.
|
|
//
|
|
// This test is here to document + pin the behavior, not to assert a fix.
|
|
const result = executeForCjs({
|
|
registryCommand: 'state.json',
|
|
registryArgs: [],
|
|
legacyCommand: 'state',
|
|
legacyArgs: ['json'],
|
|
mode: 'json',
|
|
projectDir: tmpDir,
|
|
workstream: 'some-workstream',
|
|
});
|
|
|
|
// Workstream forces subprocess; subprocess disabled → ok:false.
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
// The error surfaces as internal_error because 'Subprocess fallback disabled'
|
|
// does not match the unknown_command classifier pattern.
|
|
expect(['internal_error', 'unknown_command']).toContain(result.errorKind);
|
|
});
|
|
});
|