diff --git a/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts b/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts index 3d084c9dd..fccb0736f 100644 --- a/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts +++ b/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts @@ -467,5 +467,76 @@ Active step. expect(normalizeOutputPath(result.stdout)).toMatch(/^\.work\/\d{4}-\d{2}-\d{2}-plan\.json$/); expect(result.stderr).not.toContain('Invalid id'); }); + + it('soft-fails legacy session ownership format when RD_WORK_PATH is set', async () => { + // SessionService.loadSession throws 'Legacy session ownership format + // detected' when session.json contains 'ownedRunbooks' (or + // 'stashedRunbookOwnership'). isRecoverableActiveStateLookupError must + // recognize this so rdpath assembles a path without a context segment. + await fs.mkdir(path.join(testDir, '.rundown'), { recursive: true }); + await fs.writeFile( + path.join(testDir, '.rundown', 'session.json'), + JSON.stringify({ ownedRunbooks: { 'wf-old-run': { runbookPath: 'foo.md' } } }, null, 2), + ); + + const result = await runRdpath( + ['--file', 'plan.json'], + { + RD_WORK_PATH: '.work', + RD_CONTEXT_ID: undefined, + }, + testDir, + ); + + expect(result.exitCode).toBe(0); + expect(normalizeOutputPath(result.stdout)).toMatch(/^\.work\/\d{4}-\d{2}-\d{2}-plan\.json$/); + expect(result.stderr).toBe(''); + }); + + it('soft-fails legacy stacks session format when RD_WORK_PATH is set', async () => { + // Second variant of the legacy ownership branch: 'stacks' key triggers + // the same recoverable error path. + await fs.mkdir(path.join(testDir, '.rundown'), { recursive: true }); + await fs.writeFile( + path.join(testDir, '.rundown', 'session.json'), + JSON.stringify({ stacks: { default: [] } }, null, 2), + ); + + const result = await runRdpath( + ['--file', 'plan.json'], + { + RD_WORK_PATH: '.work', + RD_CONTEXT_ID: undefined, + }, + testDir, + ); + + expect(result.exitCode).toBe(0); + expect(normalizeOutputPath(result.stdout)).toMatch(/^\.work\/\d{4}-\d{2}-\d{2}-plan\.json$/); + expect(result.stderr).toBe(''); + }); + + it('soft-fails session.json that fails schema validation when RD_WORK_PATH is set', async () => { + // 'Session file contains invalid runbook targeting data' is thrown when + // session.json parses as JSON but fails SessionDataSchema validation. + await fs.mkdir(path.join(testDir, '.rundown'), { recursive: true }); + await fs.writeFile( + path.join(testDir, '.rundown', 'session.json'), + JSON.stringify({ defaultStack: [{ not: 'a-string' }] }, null, 2), + ); + + const result = await runRdpath( + ['--file', 'plan.json'], + { + RD_WORK_PATH: '.work', + RD_CONTEXT_ID: undefined, + }, + testDir, + ); + + expect(result.exitCode).toBe(0); + expect(normalizeOutputPath(result.stdout)).toMatch(/^\.work\/\d{4}-\d{2}-\d{2}-plan\.json$/); + expect(result.stderr).toBe(''); + }); }); }); diff --git a/packages/core/__tests__/runbook/run-id.test.ts b/packages/core/__tests__/runbook/run-id.test.ts new file mode 100644 index 000000000..0c6e7c03a --- /dev/null +++ b/packages/core/__tests__/runbook/run-id.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from '@jest/globals'; +import { RUN_ID_PATTERN, RUN_ID_PREFIX, assertRunId, isRunId } from '../../src/runbook/run-id.js'; + +describe('isRunId', () => { + it('accepts canonical rd_ + 32 lowercase hex chars', () => { + expect(isRunId('rd_4b7f0c2d9e1a4b7f0c2d9e1a4b7f0c2d')).toBe(true); + }); + + it('accepts all-zero hex segment', () => { + expect(isRunId('rd_00000000000000000000000000000000')).toBe(true); + }); + + it('accepts all-f hex segment', () => { + expect(isRunId('rd_ffffffffffffffffffffffffffffffff')).toBe(true); + }); + + it('rejects uppercase hex characters', () => { + expect(isRunId('rd_ABCDEF00000000000000000000000000')).toBe(false); + }); + + it('rejects uppercase RD_ prefix', () => { + expect(isRunId('RD_00000000000000000000000000000000')).toBe(false); + }); + + it('rejects missing prefix', () => { + expect(isRunId('00000000000000000000000000000000')).toBe(false); + }); + + it('rejects 31-char hex segment', () => { + expect(isRunId('rd_0000000000000000000000000000000')).toBe(false); + }); + + it('rejects 33-char hex segment', () => { + expect(isRunId('rd_000000000000000000000000000000000')).toBe(false); + }); + + it('rejects non-hex character in segment', () => { + expect(isRunId('rd_g0000000000000000000000000000000')).toBe(false); + }); + + it('rejects hyphenated UUID-style body', () => { + expect(isRunId('rd_550e8400-e29b-41d4-a716-446655440000')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isRunId('')).toBe(false); + }); + + it('rejects bare prefix with no body', () => { + expect(isRunId('rd_')).toBe(false); + }); + + it('rejects legacy wf-YYYY-MM-DD-... format', () => { + expect(isRunId('wf-2024-01-07-abc123')).toBe(false); + }); + + it('rejects non-string values', () => { + expect(isRunId(undefined)).toBe(false); + expect(isRunId(null)).toBe(false); + expect(isRunId(123)).toBe(false); + expect(isRunId({})).toBe(false); + }); +}); + +describe('assertRunId', () => { + it('returns the input when valid', () => { + const raw = 'rd_4b7f0c2d9e1a4b7f0c2d9e1a4b7f0c2d'; + expect(assertRunId(raw)).toBe(raw); + }); + + it('throws on invalid id with a message naming the expected format', () => { + expect(() => assertRunId('not-a-run-id')).toThrow(/rd_/); + }); + + it('throws on uppercase hex', () => { + expect(() => assertRunId('rd_ABCDEF00000000000000000000000000')).toThrow(); + }); + + it('throws on empty string', () => { + expect(() => assertRunId('')).toThrow(); + }); + + it('throws on bare prefix', () => { + expect(() => assertRunId('rd_')).toThrow(); + }); + + it('is idempotent for valid ids', () => { + const raw = 'rd_22222222222222222222222222222222'; + expect(assertRunId(assertRunId(raw))).toBe(raw); + }); +}); + +describe('RUN_ID_PATTERN / RUN_ID_PREFIX', () => { + it('exports the rd_ prefix constant', () => { + expect(RUN_ID_PREFIX).toBe('rd_'); + }); + + it('exposes a pattern that anchors both ends', () => { + expect(RUN_ID_PATTERN.source.startsWith('^')).toBe(true); + expect(RUN_ID_PATTERN.source.endsWith('$')).toBe(true); + }); + + it('matches values accepted by isRunId', () => { + const id = 'rd_11111111111111111111111111111111'; + expect(RUN_ID_PATTERN.test(id)).toBe(true); + expect(isRunId(id)).toBe(true); + }); + + it.each([ + ['missing rd_ prefix', '00000000000000000000000000000000'], + ['uppercase hex', 'rd_ABCDEF00000000000000000000000000'], + ['31-char body (too short)', 'rd_0000000000000000000000000000000'], + ['33-char body (too long)', 'rd_000000000000000000000000000000000'], + ['non-hex character in body', 'rd_g0000000000000000000000000000000'], + ['hyphenated UUID body', 'rd_550e8400-e29b-41d4-a716-446655440000'], + ])('rejects %s in both RUN_ID_PATTERN and isRunId', (_label, value) => { + expect(RUN_ID_PATTERN.test(value)).toBe(false); + expect(isRunId(value)).toBe(false); + }); +});