diff --git a/packages/software-factory/realm/test-results.gts b/packages/software-factory/realm/test-results.gts index 4e05ca8ad6..ec0e1c7acc 100644 --- a/packages/software-factory/realm/test-results.gts +++ b/packages/software-factory/realm/test-results.gts @@ -234,6 +234,9 @@ export class TestRun extends CardDef { #{{@model.sequenceNumber}} {{@model.status}} + {{#if @model.issue}} +
{{@model.issue.summary}}
+ {{/if}}
{{@model.passedCount}}/{{this.total}} passed @@ -281,6 +284,13 @@ export class TestRun extends CardDef { color: var(--boxel-blue, #2563eb); background: #eff6ff; } + .issue-name { + font-size: 0.8rem; + color: var(--muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .counts { font-size: 0.85rem; color: var(--muted-foreground); diff --git a/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts b/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts index 79dcb63bf4..bbdb5f11df 100644 --- a/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts +++ b/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts @@ -271,7 +271,7 @@ async function scenarioSingleIssue(): Promise { contextBuilder: new StubContextBuilder(), tools: TOOLS, issueStore: store, - validator: new MockValidator([makePassingValidation()]), + createValidator: () => new MockValidator([makePassingValidation()]), targetRealmUrl: 'https://example.test/target/', }); @@ -328,10 +328,7 @@ async function scenarioDependencyCascade(): Promise { contextBuilder: new StubContextBuilder(), tools: TOOLS, issueStore: store, - validator: new MockValidator([ - makePassingValidation(), - makePassingValidation(), - ]), + createValidator: () => new MockValidator([makePassingValidation()]), targetRealmUrl: 'https://example.test/target/', }); @@ -402,11 +399,7 @@ async function scenarioPriorityOrdering(): Promise { contextBuilder: new StubContextBuilder(), tools: TOOLS, issueStore: store, - validator: new MockValidator([ - makePassingValidation(), - makePassingValidation(), - makePassingValidation(), - ]), + createValidator: () => new MockValidator([makePassingValidation()]), targetRealmUrl: 'https://example.test/target/', }); @@ -461,7 +454,7 @@ async function scenarioMaxIterations(): Promise { contextBuilder: new StubContextBuilder(), tools: TOOLS, issueStore: store, - validator: new MockValidator(validations), + createValidator: () => new MockValidator(validations), targetRealmUrl: 'https://example.test/target/', maxIterationsPerIssue: 3, }); @@ -507,7 +500,7 @@ async function scenarioBlockedIssue(): Promise { contextBuilder: new StubContextBuilder(), tools: TOOLS, issueStore: store, - validator: new MockValidator([makePassingValidation()]), + createValidator: () => new MockValidator([makePassingValidation()]), targetRealmUrl: 'https://example.test/target/', }); @@ -535,7 +528,7 @@ async function scenarioEmptyProject(): Promise { contextBuilder: new StubContextBuilder(), tools: TOOLS, issueStore: store, - validator: new NoOpValidator(), + createValidator: () => new NoOpValidator(), targetRealmUrl: 'https://example.test/target/', }); @@ -572,21 +565,23 @@ async function scenarioValidationPipeline(): Promise { store, ); - // Use a real ValidationPipeline with all NoOp steps (no server needed) - let pipeline = new ValidationPipeline([ - new NoOpStepRunner('parse'), - new NoOpStepRunner('lint'), - new NoOpStepRunner('evaluate'), - new NoOpStepRunner('instantiate'), - new NoOpStepRunner('test'), - ]); + // Use a real ValidationPipeline with all NoOp steps (no server needed). + // The factory creates a fresh pipeline per issue, as in production. + let createPipeline = () => + new ValidationPipeline([ + new NoOpStepRunner('parse'), + new NoOpStepRunner('lint'), + new NoOpStepRunner('evaluate'), + new NoOpStepRunner('instantiate'), + new NoOpStepRunner('test'), + ]); let result = await runIssueLoop({ agent, contextBuilder: new StubContextBuilder(), tools: TOOLS, issueStore: store, - validator: pipeline, + createValidator: createPipeline, targetRealmUrl: 'https://example.test/target/', }); @@ -604,8 +599,9 @@ async function scenarioValidationPipeline(): Promise { // Verify formatForContext works let lastValidation = result.issueResults[0]?.lastValidation; + let pipelineForFormat = createPipeline(); let formatted = lastValidation - ? pipeline.formatForContext(lastValidation) + ? pipelineForFormat.formatForContext(lastValidation) : ''; check( 'formatForContext reports all passed', diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts index 7052bfc0fb..426e505565 100644 --- a/packages/software-factory/src/issue-loop.ts +++ b/packages/software-factory/src/issue-loop.ts @@ -91,7 +91,12 @@ export interface IssueLoopConfig { contextBuilder: IssueContextBuilderLike; tools: FactoryTool[]; issueStore: IssueStore; - validator: Validator; + /** + * Factory that creates a fresh Validator for each issue. + * Receives the issue ID so the validator can scope artifacts (e.g. TestRun + * slugs) to the specific issue being validated. + */ + createValidator: (issueId: string) => Validator; targetRealmUrl: string; briefUrl?: string; /** Maximum inner-loop iterations per issue. Default: 5. */ @@ -199,7 +204,7 @@ export async function runIssueLoop( contextBuilder, tools, issueStore, - validator, + createValidator, targetRealmUrl, briefUrl, maxIterationsPerIssue = DEFAULT_MAX_ITERATIONS_PER_ISSUE, @@ -254,6 +259,10 @@ export async function runIssueLoop( `Outer cycle ${outerCycles}: picked issue ${issueSummaryLabel(issue)} (status=${issue.status}, priority=${issue.priority})`, ); + // Create a fresh validator scoped to this issue so that artifacts + // (e.g. TestRun cards) are named per-issue rather than shared. + let validator = createValidator(issue.id); + // ----------------------------------------------------------------------- // Inner loop: iterate on a single issue with validation // ----------------------------------------------------------------------- diff --git a/packages/software-factory/src/test-run-execution.ts b/packages/software-factory/src/test-run-execution.ts index ebc22f5427..306d5802e3 100644 --- a/packages/software-factory/src/test-run-execution.ts +++ b/packages/software-factory/src/test-run-execution.ts @@ -58,6 +58,7 @@ export async function resolveTestRun( } let sequenceNumber = await getNextSequenceNumber( + options.slug, realmOptions, options.lastSequenceNumber, ); @@ -142,7 +143,13 @@ async function findResumableTestRun( }; } +/** + * Get the next sequence number for a given slug by searching existing + * TestRun cards in the realm and filtering by card ID. Each slug (issue) + * gets its own independent sequence starting from 1. + */ async function getNextSequenceNumber( + slug: string, options: TestRunRealmOptions, minSequenceNumber = 0, ): Promise { @@ -153,18 +160,34 @@ async function getNextSequenceNumber( on: { module: options.testResultsModuleUrl, name: 'TestRun' }, }, sort: [{ by: 'sequenceNumber', direction: 'desc' }], - page: { size: 1 }, }, { authorization: options.authorization, fetch: options.fetch }, ); - let latest = result?.ok - ? (result.data?.[0] as - | { attributes?: { sequenceNumber?: number } } - | undefined) - : undefined; - let fromIndex = latest?.attributes?.sequenceNumber ?? 0; - return Math.max(fromIndex, minSequenceNumber) + 1; + if (!result?.ok || !result.data) { + return minSequenceNumber + 1; + } + + let targetRealmUrl = ensureTrailingSlash(options.targetRealmUrl); + let prefix = `Test Runs/${slug}-`; + let maxSeq = 0; + + for (let card of result.data) { + let cardId = (card as { id?: string }).id ?? ''; + let relativePath = cardId.startsWith(targetRealmUrl) + ? cardId.slice(targetRealmUrl.length) + : cardId; + if (relativePath.startsWith(prefix)) { + let attrs = (card as { attributes?: { sequenceNumber?: number } }) + .attributes; + let seq = attrs?.sequenceNumber ?? 0; + if (seq > maxSeq) { + maxSeq = seq; + } + } + } + + return Math.max(maxSeq, minSequenceNumber) + 1; } // --------------------------------------------------------------------------- @@ -190,6 +213,8 @@ function buildQunitTestPageHtml(opts: { targetRealmUrl: string; /** Browser-accessible URL of the realm server (compat proxy) */ realmProxyUrl: string; + /** Optional slug identifying the issue under test — shown in the page title. */ + slug?: string; }): string { let host = opts.assetServerUrl.replace(/\/$/, ''); // Ember config URLs must use the browser-accessible realm proxy, @@ -268,7 +293,7 @@ function buildQunitTestPageHtml(opts: { ${metaTags.join('\n ')} - Software Factory Card Tests + Software Factory Card Tests${opts.slug ? ` — ${opts.slug}` : ''} ${linkTags.join('\n ')} @@ -466,6 +491,7 @@ export async function executeTestRunFromRealm( hostDistDir, targetRealmUrl: options.targetRealmUrl, realmProxyUrl: options.hostAppUrl, + slug: options.slug, }); setHtml(html); diff --git a/packages/software-factory/src/validators/test-step.ts b/packages/software-factory/src/validators/test-step.ts index a9c8e9b87f..e1ae9f613e 100644 --- a/packages/software-factory/src/validators/test-step.ts +++ b/packages/software-factory/src/validators/test-step.ts @@ -138,6 +138,12 @@ export class TestValidationStep implements ValidationStepRunner { ? deriveIssueSlug(this.config.issueId) : 'validation'; + // Build the issue card URL for the TestRun → Issue linksTo relationship. + // issueId is a realm-relative path like "Issues/sticky-note-define-core". + let issueURL = this.config.issueId + ? new URL(this.config.issueId, targetRealmUrl).href + : undefined; + handle = await this.executeTestRunFn({ targetRealmUrl, testResultsModuleUrl: this.config.testResultsModuleUrl, @@ -149,6 +155,7 @@ export class TestValidationStep implements ValidationStepRunner { hostAppUrl: this.config.hostAppUrl, forceNew: true, lastSequenceNumber: this.lastSequenceNumber, + issueURL, }); if (handle.sequenceNumber != null) { diff --git a/packages/software-factory/tests/factory-test-realm.test.ts b/packages/software-factory/tests/factory-test-realm.test.ts index e311ccc395..1fe682b96e 100644 --- a/packages/software-factory/tests/factory-test-realm.test.ts +++ b/packages/software-factory/tests/factory-test-realm.test.ts @@ -492,7 +492,7 @@ module('factory-test-realm > resolveTestRun', function () { let urlStr = String(url); let method = init?.method ?? 'GET'; - // Search endpoint + // Search endpoint (used by findResumableTestRun and getNextSequenceNumber) if (urlStr.includes('_search') && method === 'QUERY') { return new Response( JSON.stringify({ diff --git a/packages/software-factory/tests/issue-loop.test.ts b/packages/software-factory/tests/issue-loop.test.ts index b5f1f72336..59a406907d 100644 --- a/packages/software-factory/tests/issue-loop.test.ts +++ b/packages/software-factory/tests/issue-loop.test.ts @@ -238,7 +238,7 @@ function makeLoopConfig( return { contextBuilder: new StubIssueContextBuilder(), tools: DEFAULT_TOOLS, - validator: new MockValidator([makePassingValidation()]), + createValidator: () => new MockValidator([makePassingValidation()]), targetRealmUrl: 'https://example.test/target/', maxIterationsPerIssue: 5, maxOuterCycles: 50, @@ -272,7 +272,7 @@ module('issue-loop > happy path', function () { makeLoopConfig({ agent, issueStore: store, - validator: new MockValidator([makePassingValidation()]), + createValidator: () => new MockValidator([makePassingValidation()]), }), ); @@ -324,10 +324,7 @@ module('issue-loop > multiple issues', function () { makeLoopConfig({ agent, issueStore: store, - validator: new MockValidator([ - makePassingValidation(), - makePassingValidation(), - ]), + createValidator: () => new MockValidator([makePassingValidation()]), }), ); @@ -379,10 +376,8 @@ module('issue-loop > validation failure', function () { agent, issueStore: store, contextBuilder, - validator: new MockValidator([ - makeFailingValidation(), - makePassingValidation(), - ]), + createValidator: () => + new MockValidator([makeFailingValidation(), makePassingValidation()]), }), ); @@ -436,10 +431,7 @@ module('issue-loop > blocked issue', function () { makeLoopConfig({ agent, issueStore: store, - validator: new MockValidator([ - makePassingValidation(), - makePassingValidation(), - ]), + createValidator: () => new MockValidator([makePassingValidation()]), }), ); @@ -484,7 +476,7 @@ module('issue-loop > max inner iterations', function () { makeLoopConfig({ agent, issueStore: store, - validator: new MockValidator(validations), + createValidator: () => new MockValidator(validations), maxIterationsPerIssue: 3, }), ); @@ -524,7 +516,7 @@ module('issue-loop > max inner iterations', function () { makeLoopConfig({ agent, issueStore: store, - validator: new MockValidator(validations), + createValidator: () => new MockValidator(validations), maxIterationsPerIssue: 3, }), ); @@ -557,7 +549,7 @@ module('issue-loop > max inner iterations', function () { makeLoopConfig({ agent, issueStore: store, - validator: new MockValidator(validations), + createValidator: () => new MockValidator(validations), maxIterationsPerIssue: 2, }), ); @@ -653,7 +645,7 @@ module('issue-loop > NoOpValidator', function () { makeLoopConfig({ agent, issueStore: store, - validator: new NoOpValidator(), + createValidator: () => new NoOpValidator(), briefUrl: 'https://example.test/brief/', }), ); @@ -703,7 +695,8 @@ module('issue-loop > context threading', function () { agent, issueStore: store, contextBuilder, - validator: new MockValidator([failValidation, makePassingValidation()]), + createValidator: () => + new MockValidator([failValidation, makePassingValidation()]), }), ); @@ -749,7 +742,7 @@ module('issue-loop > brief URL threading', function () { agent, issueStore: store, contextBuilder, - validator: new MockValidator([makePassingValidation()]), + createValidator: () => new MockValidator([makePassingValidation()]), briefUrl: 'https://example.test/brief/', }), ); @@ -822,10 +815,7 @@ module('issue-loop > new issues mid-loop', function () { makeLoopConfig({ agent, issueStore: store, - validator: new MockValidator([ - makePassingValidation(), - makePassingValidation(), - ]), + createValidator: () => new MockValidator([makePassingValidation()]), }), ); @@ -835,3 +825,70 @@ module('issue-loop > new issues mid-loop', function () { assert.strictEqual(result.issueResults[1].issueId, 'new-1'); }); }); + +// --------------------------------------------------------------------------- +// 12. createValidator receives issue ID +// --------------------------------------------------------------------------- + +module('issue-loop > createValidator receives issue ID', function () { + test('createValidator is called with the current issue ID', async function (assert) { + let store = new MockIssueStore([ + makeIssue({ + id: 'Issues/sticky-note-define-core', + status: 'backlog', + priority: 'high', + order: 1, + }), + makeIssue({ + id: 'Issues/sticky-note-catalog-spec', + status: 'backlog', + priority: 'medium', + order: 2, + }), + ]); + + let agent = new MockLoopAgent( + [ + { + toolCalls: [ + { tool: 'write_file', args: { path: 'a.gts', content: '' } }, + ], + updateIssue: { + id: 'Issues/sticky-note-define-core', + status: 'done', + }, + }, + { + toolCalls: [ + { tool: 'write_file', args: { path: 'b.gts', content: '' } }, + ], + updateIssue: { + id: 'Issues/sticky-note-catalog-spec', + status: 'done', + }, + }, + ], + store, + ); + + let receivedIssueIds: string[] = []; + + let result = await runIssueLoop( + makeLoopConfig({ + agent, + issueStore: store, + createValidator: (issueId: string) => { + receivedIssueIds.push(issueId); + return new MockValidator([makePassingValidation()]); + }, + }), + ); + + assert.strictEqual(result.outcome, 'all_issues_done'); + assert.deepEqual( + receivedIssueIds, + ['Issues/sticky-note-define-core', 'Issues/sticky-note-catalog-spec'], + 'createValidator received the correct issue IDs in order', + ); + }); +});