From 120a074cee577aba024dd1ae557ce55eff38648e Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 20 Mar 2026 12:36:22 -0400 Subject: [PATCH 01/13] Implement project artifact bootstrap from a brief (CS-10449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `bootstrapProjectArtifacts()` that creates Project, KnowledgeArticle, and Ticket cards in a target realm from a normalized brief. Content is derived deterministically from the brief's sections, tags, and summary. Stable slug-based IDs ensure idempotency — rerunning skips existing cards. - New module `src/factory-bootstrap.ts` with full card generation logic - Wired into `runFactoryEntrypoint` after target realm bootstrap - 21 hermetic QUnit tests for artifact generation and idempotency - 3 Playwright specs testing live realm card creation and rendering - E2e subprocess test for full Matrix auth → realm creation → bootstrap flow - CLI entrypoint now calls `process.exit()` to prevent hanging on open handles Co-Authored-By: Claude Opus 4.6 (1M context) --- .../docs/software-factory-testing-strategy.md | 48 ++ .../src/cli/factory-entrypoint.ts | 3 +- .../software-factory/src/factory-bootstrap.ts | 554 ++++++++++++++++++ .../src/factory-entrypoint.ts | 50 +- .../bootstrap-target/.realm.json | 5 + .../test-fixtures/bootstrap-target/home.gts | 9 + .../test-fixtures/bootstrap-target/index.json | 12 + .../tests/factory-bootstrap.spec.ts | 176 ++++++ .../tests/factory-bootstrap.test.ts | 507 ++++++++++++++++ .../factory-entrypoint.integration.test.ts | 40 ++ .../tests/factory-entrypoint.test.ts | 27 + .../tests/factory-target-realm.spec.ts | 154 +++++ .../tests/helpers/matrix-auth.ts | 133 +++++ .../tests/helpers/run-command.ts | 59 ++ packages/software-factory/tests/index.ts | 1 + 15 files changed, 1776 insertions(+), 2 deletions(-) create mode 100644 packages/software-factory/src/factory-bootstrap.ts create mode 100644 packages/software-factory/test-fixtures/bootstrap-target/.realm.json create mode 100644 packages/software-factory/test-fixtures/bootstrap-target/home.gts create mode 100644 packages/software-factory/test-fixtures/bootstrap-target/index.json create mode 100644 packages/software-factory/tests/factory-bootstrap.spec.ts create mode 100644 packages/software-factory/tests/factory-bootstrap.test.ts create mode 100644 packages/software-factory/tests/factory-target-realm.spec.ts create mode 100644 packages/software-factory/tests/helpers/matrix-auth.ts create mode 100644 packages/software-factory/tests/helpers/run-command.ts diff --git a/packages/software-factory/docs/software-factory-testing-strategy.md b/packages/software-factory/docs/software-factory-testing-strategy.md index 08025fbc2cc..e377731bcd0 100644 --- a/packages/software-factory/docs/software-factory-testing-strategy.md +++ b/packages/software-factory/docs/software-factory-testing-strategy.md @@ -56,6 +56,54 @@ Instead, split testing into layers: The more logic we can move into deterministic code, the less fragile the overall system becomes. +## How to Run Tests + +### Node-side tests (`tests/*.test.ts`) + +No prerequisites. Run directly: + +```bash +pnpm test:node +``` + +### Playwright tests (`tests/*.spec.ts`) + +Playwright tests are hermetically sealed. They start their own Postgres, Synapse, prerender server, and isolated realm server. They do not depend on any externally running realm server (e.g. `localhost:4201`). + +Prerequisites: + +1. Docker must be running (for Synapse) +2. Host app assets must be served at `http://localhost:4200/`: + ```bash + cd packages/host && pnpm serve:dist + ``` +3. Run `pnpm cache:prepare` to build or reuse the cached template database: + ```bash + pnpm cache:prepare + ``` + +Then run the Playwright tests: + +```bash +pnpm test:playwright +``` + +To run a specific spec file: + +```bash +pnpm test:playwright --grep "bootstrap" +``` + +The `cache:prepare` step is a one-time setup that builds a Postgres template database from the test fixtures. It only needs to be rerun when the fixture content changes. The global setup for `pnpm test:playwright` will also run `cache:prepare` automatically if the cache is stale, but running it explicitly first avoids delays during test execution. + +### All tests + +```bash +pnpm test +``` + +This runs Node-side tests first, then Playwright tests sequentially. + ## Test Location Rule All package tests should live under `packages/software-factory/tests/`. diff --git a/packages/software-factory/src/cli/factory-entrypoint.ts b/packages/software-factory/src/cli/factory-entrypoint.ts index b3c51c490cb..14310d29198 100644 --- a/packages/software-factory/src/cli/factory-entrypoint.ts +++ b/packages/software-factory/src/cli/factory-entrypoint.ts @@ -17,6 +17,7 @@ async function main(): Promise { let options = parseFactoryEntrypointArgs(process.argv.slice(2)); let summary = await runFactoryEntrypoint(options); console.log(JSON.stringify(summary, null, 2)); + process.exit(0); } catch (error) { if (error instanceof FactoryEntrypointUsageError) { console.error(error.message); @@ -28,7 +29,7 @@ async function main(): Promise { console.error(error); } - process.exitCode = 1; + process.exit(1); } } diff --git a/packages/software-factory/src/factory-bootstrap.ts b/packages/software-factory/src/factory-bootstrap.ts new file mode 100644 index 00000000000..501a0ce39d8 --- /dev/null +++ b/packages/software-factory/src/factory-bootstrap.ts @@ -0,0 +1,554 @@ +import type { FactoryBrief } from './factory-brief'; + +const cardSourceMimeType = 'application/vnd.card+source'; + +export interface FactoryBootstrapResult { + project: FactoryBootstrapArtifact; + knowledgeArticles: FactoryBootstrapArtifact[]; + tickets: FactoryBootstrapArtifact[]; + activeTicket: FactoryBootstrapArtifact; +} + +export interface FactoryBootstrapArtifact { + id: string; + status: 'created' | 'existing'; +} + +export interface FactoryBootstrapOptions { + fetch?: typeof globalThis.fetch; + darkfactoryModuleUrl?: string; +} + +interface CardDocument { + data: { + type: 'card'; + attributes: Record; + relationships?: Record; + meta: { + adoptsFrom: { + module: string; + name: string; + }; + }; + }; +} + +export async function bootstrapProjectArtifacts( + brief: FactoryBrief, + targetRealmUrl: string, + options?: FactoryBootstrapOptions, +): Promise { + let fetchImpl = options?.fetch ?? globalThis.fetch; + + if (typeof fetchImpl !== 'function') { + throw new Error('Global fetch is not available'); + } + + let darkfactoryModuleUrl = + options?.darkfactoryModuleUrl ?? inferDarkfactoryModuleUrl(targetRealmUrl); + let slug = deriveSlug(brief.title); + let projectCode = deriveProjectCode(brief.title); + let now = new Date().toISOString(); + let sections = extractSections(brief.content); + + let projectPath = `Project/${slug}-mvp`; + let knowledgePaths = [ + `KnowledgeArticle/${slug}-brief-context`, + `KnowledgeArticle/${slug}-agent-onboarding`, + ]; + let ticketPaths = [ + `Ticket/${slug}-define-core`, + `Ticket/${slug}-design-views`, + `Ticket/${slug}-add-integration`, + ]; + + let projectDoc = buildProjectDocument(brief, { + darkfactoryModuleUrl, + projectCode, + slug, + sections, + }); + let knowledgeDocs = buildKnowledgeDocuments(brief, { + darkfactoryModuleUrl, + now, + }); + let ticketDocs = buildTicketDocuments(brief, { + darkfactoryModuleUrl, + projectCode, + slug, + sections, + now, + }); + + let project = await createCardIfMissing( + targetRealmUrl, + projectPath, + projectDoc, + fetchImpl, + ); + let knowledgeArticles = await Promise.all( + knowledgePaths.map((path, i) => + createCardIfMissing(targetRealmUrl, path, knowledgeDocs[i], fetchImpl), + ), + ); + let tickets = await Promise.all( + ticketPaths.map((path, i) => + createCardIfMissing(targetRealmUrl, path, ticketDocs[i], fetchImpl), + ), + ); + + let activeTicket = tickets[0]; + + if (activeTicket.status === 'existing') { + let hasInProgress = await hasInProgressTicket( + targetRealmUrl, + ticketPaths, + fetchImpl, + ); + if (!hasInProgress) { + await patchTicketStatus( + targetRealmUrl, + ticketPaths[0], + 'in_progress', + darkfactoryModuleUrl, + fetchImpl, + ); + } + } + + return { + project, + knowledgeArticles, + tickets, + activeTicket, + }; +} + +export function deriveSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function deriveProjectCode(title: string): string { + let words = title.trim().split(/\s+/); + if (words.length === 1) { + return words[0].slice(0, 2).toUpperCase(); + } + return words + .map((w) => w[0]) + .join('') + .toUpperCase() + .slice(0, 4); +} + +export function inferDarkfactoryModuleUrl(targetRealmUrl: string): string { + let parsed = new URL(targetRealmUrl); + return new URL('software-factory/darkfactory', parsed.origin + '/').href; +} + +export function extractSections( + content: string, +): { heading: string; body: string }[] { + let lines = content.split('\n'); + let sections: { heading: string; body: string }[] = []; + let currentHeading = ''; + let currentBody: string[] = []; + + for (let line of lines) { + let headingMatch = line.match(/^##\s+(.+)/); + if (headingMatch) { + if (currentHeading || currentBody.length > 0) { + sections.push({ + heading: currentHeading, + body: currentBody.join('\n').trim(), + }); + } + currentHeading = headingMatch[1].trim(); + currentBody = []; + } else { + currentBody.push(line); + } + } + + if (currentHeading || currentBody.length > 0) { + sections.push({ + heading: currentHeading, + body: currentBody.join('\n').trim(), + }); + } + + return sections; +} + +function buildProjectDocument( + brief: FactoryBrief, + context: { + darkfactoryModuleUrl: string; + projectCode: string; + slug: string; + sections: { heading: string; body: string }[]; + }, +): CardDocument { + let scope = context.sections + .filter((s) => s.heading) + .map((s) => `## ${s.heading}\n\n${s.body}`) + .join('\n\n'); + if (!scope) { + scope = brief.content || brief.contentSummary; + } + + let successCriteria = buildSuccessCriteria(context.sections); + + return { + data: { + type: 'card', + attributes: { + projectCode: context.projectCode, + projectName: `${brief.title} MVP`, + projectStatus: 'active', + objective: brief.contentSummary, + scope, + technicalContext: `Generated by factory:go from brief at ${brief.sourceUrl}`, + successCriteria, + }, + relationships: { + 'knowledgeBase.0': { + links: { + self: `../KnowledgeArticle/${context.slug}-brief-context`, + }, + }, + 'knowledgeBase.1': { + links: { + self: `../KnowledgeArticle/${context.slug}-agent-onboarding`, + }, + }, + }, + meta: { + adoptsFrom: { + module: context.darkfactoryModuleUrl, + name: 'Project', + }, + }, + }, + }; +} + +function buildKnowledgeDocuments( + brief: FactoryBrief, + context: { + darkfactoryModuleUrl: string; + now: string; + }, +): CardDocument[] { + let briefContextTags = [...brief.tags, 'brief-context'].filter(Boolean); + + return [ + { + data: { + type: 'card', + attributes: { + articleTitle: `${brief.title} — Brief Context`, + articleType: 'context', + content: brief.content || brief.contentSummary, + tags: briefContextTags, + updatedAt: context.now, + }, + meta: { + adoptsFrom: { + module: context.darkfactoryModuleUrl, + name: 'KnowledgeArticle', + }, + }, + }, + }, + { + data: { + type: 'card', + attributes: { + articleTitle: `${brief.title} — Agent Onboarding`, + articleType: 'onboarding', + content: buildOnboardingContent(brief), + tags: ['onboarding', ...brief.tags].filter(Boolean), + updatedAt: context.now, + }, + meta: { + adoptsFrom: { + module: context.darkfactoryModuleUrl, + name: 'KnowledgeArticle', + }, + }, + }, + }, + ]; +} + +function buildTicketDocuments( + brief: FactoryBrief, + context: { + darkfactoryModuleUrl: string; + projectCode: string; + slug: string; + sections: { heading: string; body: string }[]; + now: string; + }, +): CardDocument[] { + let ticketTemplates = deriveTicketContent(brief, context.sections); + + return ticketTemplates.map((template, i) => ({ + data: { + type: 'card' as const, + attributes: { + ticketId: `${context.projectCode}-${i + 1}`, + summary: template.summary, + description: template.description, + ticketType: 'feature', + status: i === 0 ? 'in_progress' : 'backlog', + priority: i === 0 ? 'high' : 'medium', + acceptanceCriteria: template.acceptanceCriteria, + createdAt: context.now, + updatedAt: context.now, + }, + relationships: { + project: { + links: { self: `../Project/${context.slug}-mvp` }, + }, + }, + meta: { + adoptsFrom: { + module: context.darkfactoryModuleUrl, + name: 'Ticket', + }, + }, + }, + })); +} + +interface TicketTemplate { + summary: string; + description: string; + acceptanceCriteria: string; +} + +function deriveTicketContent( + brief: FactoryBrief, + sections: { heading: string; body: string }[], +): TicketTemplate[] { + let namedSections = sections.filter((s) => s.heading); + + let coreMechanicsSection = namedSections.find((s) => + /core|mechanic|structure|fundamentals/i.test(s.heading), + ); + let viewsSection = namedSections.find((s) => + /view|design|ui|layout|display|render/i.test(s.heading), + ); + let integrationSection = namedSections.find((s) => + /integrat|link|connect|automat|workflow/i.test(s.heading), + ); + + let coreDescription = coreMechanicsSection + ? `${coreMechanicsSection.body}\n\nDerived from the "${coreMechanicsSection.heading}" section of the brief.` + : `Create the card definition with required fields and basic structure.\n\n${brief.contentSummary}`; + + let viewsDescription = viewsSection + ? `${viewsSection.body}\n\nDerived from the "${viewsSection.heading}" section of the brief.` + : `Design and implement the card views (fitted, isolated, embedded) for display in different contexts.\n\n${brief.contentSummary}`; + + let integrationDescription = integrationSection + ? `${integrationSection.body}\n\nDerived from the "${integrationSection.heading}" section of the brief.` + : `Add linking, automation, and workflow integration points.\n\n${brief.contentSummary}`; + + let coreCriteria = extractChecklistItems(coreMechanicsSection?.body); + let viewsCriteria = extractChecklistItems(viewsSection?.body); + let integrationCriteria = extractChecklistItems(integrationSection?.body); + + return [ + { + summary: `Define the core ${brief.title} card`, + description: coreDescription, + acceptanceCriteria: + coreCriteria || + `- [ ] Card definition exists\n- [ ] Core fields are defined\n- [ ] Card renders in isolated view`, + }, + { + summary: `Design ${brief.title} card views`, + description: viewsDescription, + acceptanceCriteria: + viewsCriteria || + `- [ ] Fitted view renders correctly\n- [ ] Isolated view renders correctly\n- [ ] Embedded view renders correctly`, + }, + { + summary: `Add ${brief.title} integration points`, + description: integrationDescription, + acceptanceCriteria: + integrationCriteria || + `- [ ] Linked cards resolve correctly\n- [ ] Automation hooks are functional\n- [ ] Workflow integration works end-to-end`, + }, + ]; +} + +function extractChecklistItems(body: string | undefined): string { + if (!body) { + return ''; + } + + let bullets = body.match(/^\s*[-*+]\s+\*?\*?(.+)/gm); + if (!bullets || bullets.length === 0) { + return ''; + } + + return bullets + .slice(0, 6) + .map((b) => { + let text = b + .replace(/^\s*[-*+]\s+/, '') + .replace(/\*\*/g, '') + .trim(); + return `- [ ] ${text}`; + }) + .join('\n'); +} + +function buildSuccessCriteria( + sections: { heading: string; body: string }[], +): string { + let headings = sections.filter((s) => s.heading).map((s) => s.heading); + + if (headings.length === 0) { + return '- [ ] Core card definition renders\n- [ ] Card views display correctly\n- [ ] Integration points are functional'; + } + + return headings + .slice(0, 5) + .map((h) => `- [ ] ${h} implementation complete`) + .join('\n'); +} + +function buildOnboardingContent(brief: FactoryBrief): string { + let lines = [ + `# ${brief.title} — Agent Onboarding`, + '', + `This project implements **${brief.title}**: ${brief.contentSummary}`, + '', + '## How to Work on This Project', + '', + '- Use the Project card for scope and success criteria', + '- Use Ticket cards for execution — pick the active ticket and implement it', + '- Update agent notes on each ticket as you make progress', + '- Create or update Knowledge Articles when meaningful decisions occur', + ]; + + if (brief.tags.length > 0) { + lines.push('', `## Tags`, '', `${brief.tags.join(', ')}`); + } + + lines.push('', '## Source Brief', '', `Original brief: ${brief.sourceUrl}`); + + return lines.join('\n'); +} + +async function createCardIfMissing( + realmUrl: string, + cardPath: string, + document: CardDocument, + fetchImpl: typeof globalThis.fetch, +): Promise { + let cardUrl = new URL(cardPath, realmUrl).href; + let writeUrl = new URL(`${cardPath}.json`, realmUrl).href; + + let existsResponse = await fetchImpl(cardUrl, { + method: 'GET', + headers: { Accept: cardSourceMimeType }, + }); + + if (existsResponse.ok) { + return { id: cardPath, status: 'existing' }; + } + + let writeResponse = await fetchImpl(writeUrl, { + method: 'POST', + headers: { + Accept: cardSourceMimeType, + 'Content-Type': cardSourceMimeType, + }, + body: JSON.stringify(document), + }); + + if (!writeResponse.ok) { + let text = await writeResponse.text(); + throw new Error( + `Failed to create card ${cardPath} in ${realmUrl}: HTTP ${writeResponse.status} ${text}`.trim(), + ); + } + + return { id: cardPath, status: 'created' }; +} + +async function hasInProgressTicket( + realmUrl: string, + ticketPaths: string[], + fetchImpl: typeof globalThis.fetch, +): Promise { + for (let path of ticketPaths) { + let url = new URL(path, realmUrl).href; + let response = await fetchImpl(url, { + method: 'GET', + headers: { Accept: cardSourceMimeType }, + }); + + if (!response.ok) { + continue; + } + + let json = (await response.json()) as { + data?: { attributes?: { status?: string } }; + }; + if (json.data?.attributes?.status === 'in_progress') { + return true; + } + } + return false; +} + +async function patchTicketStatus( + realmUrl: string, + ticketPath: string, + status: string, + darkfactoryModuleUrl: string, + fetchImpl: typeof globalThis.fetch, +): Promise { + let url = new URL(ticketPath, realmUrl).href; + let writeUrl = new URL(`${ticketPath}.json`, realmUrl).href; + + let getResponse = await fetchImpl(url, { + method: 'GET', + headers: { Accept: cardSourceMimeType }, + }); + + if (!getResponse.ok) { + return; + } + + let existing = (await getResponse.json()) as CardDocument; + existing.data.attributes.status = status; + existing.data.meta = { + adoptsFrom: { module: darkfactoryModuleUrl, name: 'Ticket' }, + }; + + let patchResponse = await fetchImpl(writeUrl, { + method: 'POST', + headers: { + Accept: cardSourceMimeType, + 'Content-Type': cardSourceMimeType, + }, + body: JSON.stringify(existing), + }); + + if (!patchResponse.ok) { + let text = await patchResponse.text(); + throw new Error( + `Failed to patch ticket status for ${ticketPath}: HTTP ${patchResponse.status} ${text}`.trim(), + ); + } +} diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 733d4a468c5..6c881b71efc 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -1,5 +1,11 @@ import { parseArgs as parseNodeArgs } from 'node:util'; +import { + bootstrapProjectArtifacts, + inferDarkfactoryModuleUrl, + type FactoryBootstrapOptions, + type FactoryBootstrapResult, +} from './factory-bootstrap'; import { loadFactoryBrief, type FactoryBrief } from './factory-brief'; import { FactoryEntrypointUsageError } from './factory-entrypoint-errors'; import { @@ -32,6 +38,16 @@ export interface FactoryEntrypointBriefSummary extends FactoryBrief { url: string; } +export interface FactoryEntrypointBootstrapSummary { + createdProject: string; + createdKnowledgeArticles: string[]; + createdTickets: string[]; + activeTicket: { + id: string; + status: string; + }; +} + export interface FactoryEntrypointSummary { command: 'factory:go'; mode: FactoryEntrypointMode; @@ -40,6 +56,7 @@ export interface FactoryEntrypointSummary { url: string; ownerUsername: string; }; + bootstrap: FactoryEntrypointBootstrapSummary; actions: FactoryEntrypointAction[]; result: { status: 'ready'; @@ -55,6 +72,11 @@ export interface RunFactoryEntrypointDependencies { bootstrapTargetRealm?: ( resolution: FactoryTargetRealmResolution, ) => Promise; + bootstrapArtifacts?: ( + brief: FactoryBrief, + targetRealmUrl: string, + options?: FactoryBootstrapOptions, + ) => Promise; } export { FactoryEntrypointUsageError } from './factory-entrypoint-errors'; @@ -168,12 +190,24 @@ export async function runFactoryEntrypoint( dependencies?.bootstrapTargetRealm ?? bootstrapFactoryTargetRealm )(targetRealmResolution); - return buildFactoryEntrypointSummary(options, brief, targetRealm); + let realmFetch = createBoxelRealmFetch(targetRealm.url, { + fetch: dependencies?.fetch, + }); + + let artifacts = await ( + dependencies?.bootstrapArtifacts ?? bootstrapProjectArtifacts + )(brief, targetRealm.url, { + fetch: realmFetch, + darkfactoryModuleUrl: inferDarkfactoryModuleUrl(targetRealm.url), + }); + + return buildFactoryEntrypointSummary(options, brief, targetRealm, artifacts); } export function buildFactoryEntrypointSummary( options: FactoryEntrypointOptions, brief: FactoryBrief, targetRealm: FactoryTargetRealmBootstrapResult, + artifacts: FactoryBootstrapResult, ): FactoryEntrypointSummary { let actions: FactoryEntrypointAction[] = [ { @@ -208,6 +242,11 @@ export function buildFactoryEntrypointSummary( ? 'created realm via realm server API' : 'target realm already existed', }, + { + name: 'bootstrapped-project-artifacts', + status: 'ok', + detail: `project=${artifacts.project.status} tickets=${artifacts.tickets.map((t) => t.status).join(',')}`, + }, ]; return { @@ -221,6 +260,15 @@ export function buildFactoryEntrypointSummary( url: targetRealm.url, ownerUsername: targetRealm.ownerUsername, }, + bootstrap: { + createdProject: artifacts.project.id, + createdKnowledgeArticles: artifacts.knowledgeArticles.map((ka) => ka.id), + createdTickets: artifacts.tickets.map((t) => t.id), + activeTicket: { + id: artifacts.activeTicket.id, + status: 'in_progress', + }, + }, actions, result: { status: 'ready', diff --git a/packages/software-factory/test-fixtures/bootstrap-target/.realm.json b/packages/software-factory/test-fixtures/bootstrap-target/.realm.json new file mode 100644 index 00000000000..e33d6fc5a7a --- /dev/null +++ b/packages/software-factory/test-fixtures/bootstrap-target/.realm.json @@ -0,0 +1,5 @@ +{ + "name": "Bootstrap Target Test Realm", + "iconURL": null, + "backgroundURL": null +} diff --git a/packages/software-factory/test-fixtures/bootstrap-target/home.gts b/packages/software-factory/test-fixtures/bootstrap-target/home.gts new file mode 100644 index 00000000000..0a949719afa --- /dev/null +++ b/packages/software-factory/test-fixtures/bootstrap-target/home.gts @@ -0,0 +1,9 @@ +import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + +export class Home extends CardDef { + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/software-factory/test-fixtures/bootstrap-target/index.json b/packages/software-factory/test-fixtures/bootstrap-target/index.json new file mode 100644 index 00000000000..f20df53720a --- /dev/null +++ b/packages/software-factory/test-fixtures/bootstrap-target/index.json @@ -0,0 +1,12 @@ +{ + "data": { + "type": "card", + "attributes": {}, + "meta": { + "adoptsFrom": { + "module": "./home.gts", + "name": "Home" + } + } + } +} diff --git a/packages/software-factory/tests/factory-bootstrap.spec.ts b/packages/software-factory/tests/factory-bootstrap.spec.ts new file mode 100644 index 00000000000..42f3670dd3c --- /dev/null +++ b/packages/software-factory/tests/factory-bootstrap.spec.ts @@ -0,0 +1,176 @@ +import { resolve } from 'node:path'; + +import { bootstrapProjectArtifacts } from '../src/factory-bootstrap'; +import type { FactoryBrief } from '../src/factory-brief'; +import { expect, test } from './fixtures'; +import { buildAuthenticatedFetch } from './helpers/matrix-auth'; + +const bootstrapTargetDir = resolve( + process.cwd(), + 'test-fixtures', + 'bootstrap-target', +); + +const stickyNoteBrief: FactoryBrief = { + title: 'Sticky Note', + sourceUrl: 'https://briefs.example.test/software-factory/Wiki/sticky-note', + content: [ + '## Overview', + '', + 'The Sticky Note card gives the workspace a structured home for colorful, short-form notes.', + '', + '## Core Mechanics', + '', + 'Sticky Note usually evolves through drafting, review, and reuse.', + '- The card keeps its core content structured', + '- It can be surfaced in different views', + '', + '## Integration Points', + '', + '- **Document** -- link to longer-form content.', + '- **Workflow Playbook** -- one step inside a repeatable workflow.', + ].join('\n'), + contentSummary: + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + tags: ['documents-content', 'sticky', 'note'], +}; + +const cardSourceMimeType = 'application/vnd.card+source'; + +test.use({ realmDir: bootstrapTargetDir }); +test.use({ realmServerMode: 'isolated' }); + +test('bootstrap creates actual card instances in a live realm', async ({ + realm, +}) => { + let darkfactoryModuleUrl = `${realm.realmURL.origin}/software-factory/darkfactory`; + let authenticatedFetch = buildAuthenticatedFetch( + realm.ownerBearerToken, + fetch, + ); + + let result = await bootstrapProjectArtifacts( + stickyNoteBrief, + realm.realmURL.href, + { fetch: authenticatedFetch, darkfactoryModuleUrl }, + ); + + expect(result.project.id).toBe('Project/sticky-note-mvp'); + expect(result.project.status).toBe('created'); + expect(result.knowledgeArticles).toHaveLength(2); + expect(result.tickets).toHaveLength(3); + expect(result.activeTicket.id).toBe('Ticket/sticky-note-define-core'); + + let projectResponse = await authenticatedFetch( + realm.cardURL('Project/sticky-note-mvp'), + { headers: { Accept: cardSourceMimeType } }, + ); + expect(projectResponse.ok).toBe(true); + let projectJson = (await projectResponse.json()) as { + data: { + attributes: { projectName: string; projectCode: string }; + meta: { adoptsFrom: { module: string; name: string } }; + }; + }; + expect(projectJson.data.attributes.projectName).toBe('Sticky Note MVP'); + expect(projectJson.data.attributes.projectCode).toBe('SN'); + expect(projectJson.data.meta.adoptsFrom.module).toBe(darkfactoryModuleUrl); + expect(projectJson.data.meta.adoptsFrom.name).toBe('Project'); + + let ticketResponse = await authenticatedFetch( + realm.cardURL('Ticket/sticky-note-define-core'), + { headers: { Accept: cardSourceMimeType } }, + ); + expect(ticketResponse.ok).toBe(true); + let ticketJson = (await ticketResponse.json()) as { + data: { + attributes: { ticketId: string; status: string; summary: string }; + meta: { adoptsFrom: { module: string; name: string } }; + }; + }; + expect(ticketJson.data.attributes.ticketId).toBe('SN-1'); + expect(ticketJson.data.attributes.status).toBe('in_progress'); + expect(ticketJson.data.attributes.summary).toContain('Sticky Note'); + expect(ticketJson.data.meta.adoptsFrom.name).toBe('Ticket'); + + let ticket2Response = await authenticatedFetch( + realm.cardURL('Ticket/sticky-note-design-views'), + { headers: { Accept: cardSourceMimeType } }, + ); + expect(ticket2Response.ok).toBe(true); + let ticket2Json = (await ticket2Response.json()) as { + data: { attributes: { status: string } }; + }; + expect(ticket2Json.data.attributes.status).toBe('backlog'); + + let contextResponse = await authenticatedFetch( + realm.cardURL('KnowledgeArticle/sticky-note-brief-context'), + { headers: { Accept: cardSourceMimeType } }, + ); + expect(contextResponse.ok).toBe(true); + let contextJson = (await contextResponse.json()) as { + data: { attributes: { articleTitle: string; articleType: string } }; + }; + expect(contextJson.data.attributes.articleTitle).toBe( + 'Sticky Note — Brief Context', + ); + expect(contextJson.data.attributes.articleType).toBe('context'); +}); + +test('bootstrap is idempotent — rerun does not duplicate cards', async ({ + realm, +}) => { + let darkfactoryModuleUrl = `${realm.realmURL.origin}/software-factory/darkfactory`; + let authenticatedFetch = buildAuthenticatedFetch( + realm.ownerBearerToken, + fetch, + ); + let bootstrapOptions = { fetch: authenticatedFetch, darkfactoryModuleUrl }; + + let result1 = await bootstrapProjectArtifacts( + stickyNoteBrief, + realm.realmURL.href, + bootstrapOptions, + ); + expect(result1.project.status).toBe('created'); + expect(result1.tickets[0].status).toBe('created'); + + let result2 = await bootstrapProjectArtifacts( + stickyNoteBrief, + realm.realmURL.href, + bootstrapOptions, + ); + expect(result2.project.status).toBe('existing'); + expect(result2.knowledgeArticles[0].status).toBe('existing'); + expect(result2.knowledgeArticles[1].status).toBe('existing'); + expect(result2.tickets[0].status).toBe('existing'); + expect(result2.tickets[1].status).toBe('existing'); + expect(result2.tickets[2].status).toBe('existing'); +}); + +test('bootstrapped project card renders correctly in the browser', async ({ + realm, + authedPage, +}) => { + let darkfactoryModuleUrl = `${realm.realmURL.origin}/software-factory/darkfactory`; + let authenticatedFetch = buildAuthenticatedFetch( + realm.ownerBearerToken, + fetch, + ); + + await bootstrapProjectArtifacts(stickyNoteBrief, realm.realmURL.href, { + fetch: authenticatedFetch, + darkfactoryModuleUrl, + }); + + await authedPage.goto(realm.cardURL('Project/sticky-note-mvp'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'Sticky Note MVP' }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Objective' }), + ).toBeVisible(); +}); diff --git a/packages/software-factory/tests/factory-bootstrap.test.ts b/packages/software-factory/tests/factory-bootstrap.test.ts new file mode 100644 index 00000000000..5425ea8ea07 --- /dev/null +++ b/packages/software-factory/tests/factory-bootstrap.test.ts @@ -0,0 +1,507 @@ +import { module, test } from 'qunit'; + +import { + bootstrapProjectArtifacts, + deriveProjectCode, + deriveSlug, + extractSections, + inferDarkfactoryModuleUrl, +} from '../src/factory-bootstrap'; +import type { FactoryBrief } from '../src/factory-brief'; + +const targetRealmUrl = 'https://realms.example.test/hassan/personal/'; +const darkfactoryModuleUrl = + 'https://realms.example.test/software-factory/darkfactory'; + +const stickyNoteBrief: FactoryBrief = { + title: 'Sticky Note', + sourceUrl: 'https://briefs.example.test/software-factory/Wiki/sticky-note', + content: [ + '## Overview', + '', + 'The Sticky Note card gives the workspace a structured home for colorful, short-form notes.', + '', + '## Core Mechanics', + '', + 'Sticky Note usually evolves through drafting, review, and reuse.', + '- The card keeps its core content structured', + '- It can be surfaced in different views', + '- Updated by people or automation without losing provenance', + '', + '## Integration Points', + '', + '- **Document** -- Sticky Note can link to longer-form supporting content.', + '- **Note** -- Note can provide adjacent context.', + '- **Workflow Playbook** -- Sticky Note can be created as one step inside a repeatable workflow.', + ].join('\n'), + contentSummary: + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + tags: ['documents-content', 'sticky', 'note'], +}; + +const minimalBrief: FactoryBrief = { + title: 'My Widget', + sourceUrl: 'https://briefs.example.test/Widget/my-widget', + content: '', + contentSummary: 'A simple widget card.', + tags: [], +}; + +module('factory-bootstrap', function () { + module('deriveSlug', function () { + test('converts title to kebab-case', function (assert) { + assert.strictEqual(deriveSlug('Sticky Note'), 'sticky-note'); + }); + + test('handles special characters', function (assert) { + assert.strictEqual(deriveSlug('My Cool App!'), 'my-cool-app'); + assert.strictEqual( + deriveSlug('My App (v2.0) — Beta!'), + 'my-app-v2-0-beta', + ); + }); + + test('strips leading and trailing dashes', function (assert) { + assert.strictEqual(deriveSlug('--hello--'), 'hello'); + assert.strictEqual(deriveSlug(' spaces '), 'spaces'); + }); + + test('handles single word', function (assert) { + assert.strictEqual(deriveSlug('Widget'), 'widget'); + }); + }); + + module('deriveProjectCode', function () { + test('uses initials for multi-word titles', function (assert) { + assert.strictEqual(deriveProjectCode('Sticky Note'), 'SN'); + assert.strictEqual(deriveProjectCode('My App'), 'MA'); + }); + + test('uses first two characters for single word titles', function (assert) { + assert.strictEqual(deriveProjectCode('Widget'), 'WI'); + }); + + test('caps at 4 characters', function (assert) { + assert.strictEqual(deriveProjectCode('One Two Three Four Five'), 'OTTF'); + }); + }); + + module('inferDarkfactoryModuleUrl', function () { + test('derives from target realm URL origin', function (assert) { + assert.strictEqual( + inferDarkfactoryModuleUrl( + 'https://realms.example.test/hassan/personal/', + ), + 'https://realms.example.test/software-factory/darkfactory', + ); + }); + + test('works with localhost URLs', function (assert) { + assert.strictEqual( + inferDarkfactoryModuleUrl('http://localhost:4201/hassan/personal/'), + 'http://localhost:4201/software-factory/darkfactory', + ); + }); + }); + + module('extractSections', function () { + test('extracts h2 sections from markdown', function (assert) { + let sections = extractSections(stickyNoteBrief.content); + assert.strictEqual(sections.length, 3); + assert.strictEqual(sections[0].heading, 'Overview'); + assert.strictEqual(sections[1].heading, 'Core Mechanics'); + assert.strictEqual(sections[2].heading, 'Integration Points'); + assert.true(sections[1].body.includes('drafting, review, and reuse')); + }); + + test('returns single section for content without headings', function (assert) { + let sections = extractSections('Just plain text content.'); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].heading, ''); + assert.strictEqual(sections[0].body, 'Just plain text content.'); + }); + + test('handles empty content', function (assert) { + let sections = extractSections(''); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].heading, ''); + }); + }); + + module('bootstrapProjectArtifacts', function () { + test('creates all starter artifacts when none exist', async function (assert) { + let fetchCalls: { url: string; method: string }[] = []; + + let result = await bootstrapProjectArtifacts( + stickyNoteBrief, + targetRealmUrl, + { + darkfactoryModuleUrl, + fetch: buildMockFetch(fetchCalls, { allMissing: true }), + }, + ); + + assert.strictEqual(result.project.id, 'Project/sticky-note-mvp'); + assert.strictEqual(result.project.status, 'created'); + assert.strictEqual(result.knowledgeArticles.length, 2); + assert.strictEqual(result.knowledgeArticles[0].status, 'created'); + assert.strictEqual(result.knowledgeArticles[1].status, 'created'); + assert.strictEqual(result.tickets.length, 3); + assert.strictEqual(result.tickets[0].status, 'created'); + assert.strictEqual(result.tickets[1].status, 'created'); + assert.strictEqual(result.tickets[2].status, 'created'); + assert.strictEqual( + result.activeTicket.id, + 'Ticket/sticky-note-define-core', + ); + }); + + test('created tickets have correct IDs and structure', async function (assert) { + let writtenBodies: Record = {}; + + let result = await bootstrapProjectArtifacts( + stickyNoteBrief, + targetRealmUrl, + { + darkfactoryModuleUrl, + fetch: buildMockFetch([], { + allMissing: true, + captureWrites: writtenBodies, + }), + }, + ); + + assert.strictEqual( + result.tickets[0].id, + 'Ticket/sticky-note-define-core', + ); + assert.strictEqual( + result.tickets[1].id, + 'Ticket/sticky-note-design-views', + ); + assert.strictEqual( + result.tickets[2].id, + 'Ticket/sticky-note-add-integration', + ); + + let ticket1 = writtenBodies['Ticket/sticky-note-define-core'] as { + data: { + attributes: { ticketId: string; status: string; summary: string }; + }; + }; + assert.strictEqual(ticket1.data.attributes.ticketId, 'SN-1'); + assert.strictEqual(ticket1.data.attributes.status, 'in_progress'); + assert.true(ticket1.data.attributes.summary.includes('Sticky Note')); + + let ticket2 = writtenBodies['Ticket/sticky-note-design-views'] as { + data: { attributes: { ticketId: string; status: string } }; + }; + assert.strictEqual(ticket2.data.attributes.ticketId, 'SN-2'); + assert.strictEqual(ticket2.data.attributes.status, 'backlog'); + + let ticket3 = writtenBodies['Ticket/sticky-note-add-integration'] as { + data: { attributes: { ticketId: string; status: string } }; + }; + assert.strictEqual(ticket3.data.attributes.ticketId, 'SN-3'); + assert.strictEqual(ticket3.data.attributes.status, 'backlog'); + }); + + test('created project has correct content from brief', async function (assert) { + let writtenBodies: Record = {}; + + await bootstrapProjectArtifacts(stickyNoteBrief, targetRealmUrl, { + darkfactoryModuleUrl, + fetch: buildMockFetch([], { + allMissing: true, + captureWrites: writtenBodies, + }), + }); + + let project = writtenBodies['Project/sticky-note-mvp'] as { + data: { + attributes: { + projectName: string; + projectCode: string; + objective: string; + scope: string; + technicalContext: string; + }; + meta: { adoptsFrom: { module: string; name: string } }; + }; + }; + assert.strictEqual( + project.data.attributes.projectName, + 'Sticky Note MVP', + ); + assert.strictEqual(project.data.attributes.projectCode, 'SN'); + assert.strictEqual( + project.data.attributes.objective, + stickyNoteBrief.contentSummary, + ); + assert.true(project.data.attributes.scope.includes('Core Mechanics')); + assert.true( + project.data.attributes.technicalContext.includes( + stickyNoteBrief.sourceUrl, + ), + ); + assert.strictEqual( + project.data.meta.adoptsFrom.module, + darkfactoryModuleUrl, + ); + assert.strictEqual(project.data.meta.adoptsFrom.name, 'Project'); + }); + + test('created knowledge articles have correct content', async function (assert) { + let writtenBodies: Record = {}; + + await bootstrapProjectArtifacts(stickyNoteBrief, targetRealmUrl, { + darkfactoryModuleUrl, + fetch: buildMockFetch([], { + allMissing: true, + captureWrites: writtenBodies, + }), + }); + + let briefContext = writtenBodies[ + 'KnowledgeArticle/sticky-note-brief-context' + ] as { + data: { + attributes: { + articleTitle: string; + articleType: string; + content: string; + tags: string[]; + }; + }; + }; + assert.strictEqual( + briefContext.data.attributes.articleTitle, + 'Sticky Note — Brief Context', + ); + assert.strictEqual(briefContext.data.attributes.articleType, 'context'); + assert.true( + briefContext.data.attributes.content.includes('Core Mechanics'), + ); + assert.true(briefContext.data.attributes.tags.includes('brief-context')); + assert.true( + briefContext.data.attributes.tags.includes('documents-content'), + ); + + let onboarding = writtenBodies[ + 'KnowledgeArticle/sticky-note-agent-onboarding' + ] as { + data: { + attributes: { + articleTitle: string; + articleType: string; + content: string; + }; + }; + }; + assert.strictEqual( + onboarding.data.attributes.articleTitle, + 'Sticky Note — Agent Onboarding', + ); + assert.strictEqual(onboarding.data.attributes.articleType, 'onboarding'); + assert.true( + onboarding.data.attributes.content.includes(stickyNoteBrief.sourceUrl), + ); + }); + + test('ticket descriptions derive from brief sections', async function (assert) { + let writtenBodies: Record = {}; + + await bootstrapProjectArtifacts(stickyNoteBrief, targetRealmUrl, { + darkfactoryModuleUrl, + fetch: buildMockFetch([], { + allMissing: true, + captureWrites: writtenBodies, + }), + }); + + let ticket1 = writtenBodies['Ticket/sticky-note-define-core'] as { + data: { attributes: { description: string } }; + }; + assert.true( + ticket1.data.attributes.description.includes( + 'drafting, review, and reuse', + ), + 'first ticket description derived from Core Mechanics section', + ); + + let ticket3 = writtenBodies['Ticket/sticky-note-add-integration'] as { + data: { attributes: { description: string } }; + }; + assert.true( + ticket3.data.attributes.description.includes('Document'), + 'third ticket description derived from Integration Points section', + ); + }); + + test('skips existing artifacts on rerun', async function (assert) { + let fetchCalls: { url: string; method: string }[] = []; + + let result = await bootstrapProjectArtifacts( + stickyNoteBrief, + targetRealmUrl, + { + darkfactoryModuleUrl, + fetch: buildMockFetch(fetchCalls, { + allExist: true, + existingTicketStatus: 'in_progress', + }), + }, + ); + + assert.strictEqual(result.project.status, 'existing'); + assert.strictEqual(result.knowledgeArticles[0].status, 'existing'); + assert.strictEqual(result.knowledgeArticles[1].status, 'existing'); + assert.strictEqual(result.tickets[0].status, 'existing'); + assert.strictEqual(result.tickets[1].status, 'existing'); + assert.strictEqual(result.tickets[2].status, 'existing'); + + let writeCalls = fetchCalls.filter((c) => c.method === 'POST'); + assert.strictEqual(writeCalls.length, 0, 'no write calls made'); + }); + + test('creates only missing artifacts on partial rerun', async function (assert) { + let existingPaths = new Set([ + 'Project/sticky-note-mvp', + 'KnowledgeArticle/sticky-note-brief-context', + ]); + + let result = await bootstrapProjectArtifacts( + stickyNoteBrief, + targetRealmUrl, + { + darkfactoryModuleUrl, + fetch: buildMockFetch([], { existingPaths }), + }, + ); + + assert.strictEqual(result.project.status, 'existing'); + assert.strictEqual(result.knowledgeArticles[0].status, 'existing'); + assert.strictEqual(result.knowledgeArticles[1].status, 'created'); + assert.strictEqual(result.tickets[0].status, 'created'); + assert.strictEqual(result.tickets[1].status, 'created'); + assert.strictEqual(result.tickets[2].status, 'created'); + }); + + test('handles brief with minimal content gracefully', async function (assert) { + let writtenBodies: Record = {}; + + let result = await bootstrapProjectArtifacts( + minimalBrief, + targetRealmUrl, + { + darkfactoryModuleUrl, + fetch: buildMockFetch([], { + allMissing: true, + captureWrites: writtenBodies, + }), + }, + ); + + assert.strictEqual(result.project.id, 'Project/my-widget-mvp'); + assert.strictEqual(result.project.status, 'created'); + assert.strictEqual(result.tickets.length, 3); + + let project = writtenBodies['Project/my-widget-mvp'] as { + data: { attributes: { projectName: string; objective: string } }; + }; + assert.strictEqual(project.data.attributes.projectName, 'My Widget MVP'); + assert.strictEqual( + project.data.attributes.objective, + 'A simple widget card.', + ); + }); + + test('handles brief with special characters in title', async function (assert) { + let specialBrief: FactoryBrief = { + title: 'My App (v2.0) — Beta!', + sourceUrl: 'https://briefs.example.test/app', + content: 'Some content.', + contentSummary: 'A beta app.', + tags: [], + }; + + let result = await bootstrapProjectArtifacts( + specialBrief, + targetRealmUrl, + { + darkfactoryModuleUrl, + fetch: buildMockFetch([], { allMissing: true }), + }, + ); + + assert.strictEqual(result.project.id, 'Project/my-app-v2-0-beta-mvp'); + assert.strictEqual( + result.tickets[0].id, + 'Ticket/my-app-v2-0-beta-define-core', + ); + }); + }); +}); + +type MockFetchOptions = { + allMissing?: boolean; + allExist?: boolean; + existingPaths?: Set; + existingTicketStatus?: string; + captureWrites?: Record; +}; + +function buildMockFetch( + calls: { url: string; method: string }[], + options: MockFetchOptions, +): typeof globalThis.fetch { + let existingCards = options.existingPaths ?? new Set(); + + return (async (input: RequestInfo | URL, init?: RequestInit) => { + let request = new Request(input, init); + let url = request.url; + let method = request.method; + + calls.push({ url, method }); + + let cardPath = url.replace(targetRealmUrl, '').replace(/\.json$/, ''); + + if (method === 'GET') { + let exists = + options.allExist || + (!options.allMissing && existingCards.has(cardPath)); + + if (exists) { + let ticketStatus = options.existingTicketStatus ?? 'backlog'; + let mockAttributes: Record = { status: ticketStatus }; + return new Response( + JSON.stringify({ + data: { + type: 'card', + attributes: mockAttributes, + meta: { + adoptsFrom: { + module: darkfactoryModuleUrl, + name: 'Ticket', + }, + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + return new Response('Not found', { status: 404 }); + } + + if (method === 'POST') { + if (options.captureWrites) { + let body = await request.text(); + options.captureWrites[cardPath] = JSON.parse(body); + } + return new Response(null, { status: 204 }); + } + + return new Response('Unexpected', { status: 500 }); + }) as typeof globalThis.fetch; +} diff --git a/packages/software-factory/tests/factory-entrypoint.integration.test.ts b/packages/software-factory/tests/factory-entrypoint.integration.test.ts index 39764dc69b1..f8f58373ad5 100644 --- a/packages/software-factory/tests/factory-entrypoint.integration.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.integration.test.ts @@ -23,6 +23,12 @@ interface FactoryEntrypointIntegrationSummary { url: string; ownerUsername: string; }; + bootstrap: { + createdProject: string; + createdKnowledgeArticles: string[]; + createdTickets: string[]; + activeTicket: { id: string; status: string }; + }; result: Record; } @@ -98,6 +104,30 @@ module('factory-entrypoint integration', function () { }, }), ); + } else if ( + request.url === '/hassan/personal/_session' && + request.method === 'POST' + ) { + // Realm session for target realm auth + response.writeHead(201, { + 'content-type': 'application/json', + Authorization: 'Bearer target-realm-token', + }); + response.end(''); + } else if ( + request.url?.startsWith('/hassan/personal/') && + request.method === 'GET' + ) { + // Card existence check — return 404 for first run + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end('not found'); + } else if ( + request.url?.startsWith('/hassan/personal/') && + request.method === 'POST' + ) { + // Card creation — accept it + response.writeHead(204); + response.end(); } else { response.writeHead(404, { 'content-type': 'text/plain' }); response.end(`Unexpected request: ${request.method} ${request.url}`); @@ -165,6 +195,16 @@ module('factory-entrypoint integration', function () { ]); assert.strictEqual(summary.targetRealm.url, canonicalTargetRealmUrl); assert.strictEqual(summary.targetRealm.ownerUsername, 'hassan'); + assert.strictEqual( + summary.bootstrap.createdProject, + 'Project/sticky-note-mvp', + ); + assert.strictEqual(summary.bootstrap.createdTickets.length, 3); + assert.strictEqual( + summary.bootstrap.activeTicket.id, + 'Ticket/sticky-note-define-core', + ); + assert.strictEqual(summary.bootstrap.activeTicket.status, 'in_progress'); assert.deepEqual(summary.result, { status: 'ready', nextStep: 'bootstrap-and-select-active-ticket', diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index 8f9ae2f23cf..03524f3326f 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -8,6 +8,7 @@ import { runFactoryEntrypoint, wantsFactoryEntrypointHelp, } from '../src/factory-entrypoint'; +import type { FactoryBootstrapResult } from '../src/factory-bootstrap'; import type { FactoryBrief } from '../src/factory-brief'; import type { FactoryTargetRealmBootstrapResult } from '../src/factory-target-realm'; @@ -29,6 +30,19 @@ const bootstrappedTargetRealm: FactoryTargetRealmBootstrapResult = { ownerUsername: 'hassan', createdRealm: true, }; +const mockBootstrapResult: FactoryBootstrapResult = { + project: { id: 'Project/sticky-note-mvp', status: 'created' }, + knowledgeArticles: [ + { id: 'KnowledgeArticle/sticky-note-brief-context', status: 'created' }, + { id: 'KnowledgeArticle/sticky-note-agent-onboarding', status: 'created' }, + ], + tickets: [ + { id: 'Ticket/sticky-note-define-core', status: 'created' }, + { id: 'Ticket/sticky-note-design-views', status: 'created' }, + { id: 'Ticket/sticky-note-add-integration', status: 'created' }, + ], + activeTicket: { id: 'Ticket/sticky-note-define-core', status: 'created' }, +}; module('factory-entrypoint', function (hooks) { let originalMatrixUsername = process.env.MATRIX_USERNAME; @@ -91,6 +105,7 @@ module('factory-entrypoint', function (hooks) { }, normalizedBrief, bootstrappedTargetRealm, + mockBootstrapResult, ); assert.strictEqual(summary.command, 'factory:go'); @@ -113,8 +128,19 @@ module('factory-entrypoint', function (hooks) { 'normalized-brief', 'resolved-target-realm', 'bootstrapped-target-realm', + 'bootstrapped-project-artifacts', ], ); + assert.strictEqual( + summary.bootstrap.createdProject, + 'Project/sticky-note-mvp', + ); + assert.strictEqual(summary.bootstrap.createdTickets.length, 3); + assert.strictEqual( + summary.bootstrap.activeTicket.id, + 'Ticket/sticky-note-define-core', + ); + assert.strictEqual(summary.bootstrap.activeTicket.status, 'in_progress'); assert.deepEqual(summary.result, { status: 'ready', nextStep: 'bootstrap-target-realm', @@ -162,6 +188,7 @@ module('factory-entrypoint', function (hooks) { serverUrl: resolution.serverUrl, createdRealm: false, }), + bootstrapArtifacts: async () => mockBootstrapResult, fetch: async (_input, init) => { assert.strictEqual( new Headers(init?.headers).get('Authorization'), diff --git a/packages/software-factory/tests/factory-target-realm.spec.ts b/packages/software-factory/tests/factory-target-realm.spec.ts new file mode 100644 index 00000000000..c0a77010b7c --- /dev/null +++ b/packages/software-factory/tests/factory-target-realm.spec.ts @@ -0,0 +1,154 @@ +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { AddressInfo } from 'node:net'; + +import { expect, test } from './fixtures'; +import { + getRealmToken, + readSupportMetadata, + registerMatrixUser, +} from './helpers/matrix-auth'; +import { runCommand } from './helpers/run-command'; + +const bootstrapTargetDir = resolve( + process.cwd(), + 'test-fixtures', + 'bootstrap-target', +); +const packageRoot = resolve(process.cwd()); +const stickyNoteFixture = readFileSync( + resolve(packageRoot, 'realm/Wiki/sticky-note.json'), + 'utf8', +); + +test.use({ realmDir: bootstrapTargetDir }); +test.use({ realmServerMode: 'isolated' }); +test.setTimeout(180_000); + +// Known issue: This test hangs when run in the same Playwright suite after +// other specs that start isolated realm servers (e.g. factory-bootstrap.spec). +// The subprocess's auth middleware hangs during Matrix auth → realm _session +// when prior isolated realm server teardowns leave residual state. The test +// passes reliably when run in isolation: +// pnpm exec playwright test tests/factory-target-realm.spec.ts +test.fixme('factory:go creates a target realm and bootstraps project artifacts end-to-end', async ({ + realm, +}) => { + let supportMetadata = readSupportMetadata(); + let { matrixURL, matrixRegistrationSecret } = supportMetadata; + + let targetUsername = `factory-target-${Date.now()}`; + let targetPassword = 'password'; + + await registerMatrixUser( + matrixURL, + matrixRegistrationSecret, + targetUsername, + targetPassword, + ); + + // Serve the brief from a local HTTP server since the harness source realm + // uses a fixture that doesn't include Wiki cards + let briefServer = createServer((request, response) => { + if (request.url === '/brief/sticky-note') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(stickyNoteFixture); + } else { + response.writeHead(404); + response.end('not found'); + } + }); + await new Promise((r) => briefServer.listen(0, '127.0.0.1', r)); + let briefPort = (briefServer.address() as AddressInfo).port; + let briefUrl = `http://127.0.0.1:${briefPort}/brief/sticky-note`; + + let serverOrigin = realm.realmURL.origin; + let newEndpoint = `e2e-realm-${Date.now()}`; + let targetRealmUrl = `${serverOrigin}/${targetUsername}/${newEndpoint}/`; + + try { + let result = await runCommand( + 'node', + [ + '--no-warnings', + '--require', + require.resolve('ts-node/register/transpile-only'), + resolve(packageRoot, 'src/cli/factory-entrypoint.ts'), + '--brief-url', + briefUrl, + '--target-realm-url', + targetRealmUrl, + '--realm-server-url', + `${serverOrigin}/`, + '--mode', + 'implement', + ], + { + cwd: packageRoot, + env: { + ...process.env, + MATRIX_USERNAME: targetUsername, + MATRIX_PASSWORD: targetPassword, + MATRIX_URL: matrixURL, + REALM_SERVER_URL: `${serverOrigin}/`, + }, + timeoutMs: 120_000, + }, + ); + + expect( + result.status, + `factory:go failed (status=${result.status}).\nstderr:\n${result.stderr}\nstdout:\n${result.stdout}`, + ).toBe(0); + + let summary = JSON.parse(result.stdout) as { + command: string; + targetRealm: { url: string; ownerUsername: string }; + bootstrap: { + createdProject: string; + createdTickets: string[]; + createdKnowledgeArticles: string[]; + activeTicket: { id: string; status: string }; + }; + }; + + expect(summary.command).toBe('factory:go'); + expect(summary.targetRealm.ownerUsername).toBe(targetUsername); + expect(summary.bootstrap.createdProject).toBe('Project/sticky-note-mvp'); + expect(summary.bootstrap.createdTickets).toHaveLength(3); + expect(summary.bootstrap.createdKnowledgeArticles).toHaveLength(2); + expect(summary.bootstrap.activeTicket.id).toBe( + 'Ticket/sticky-note-define-core', + ); + expect(summary.bootstrap.activeTicket.status).toBe('in_progress'); + + // Verify the project card actually exists in the newly created target realm + // by authenticating as the target user who owns the realm + let targetRealmToken = await getRealmToken( + matrixURL, + targetUsername, + targetPassword, + summary.targetRealm.url, + ); + + let projectUrl = new URL('Project/sticky-note-mvp', summary.targetRealm.url) + .href; + let projectResponse = await fetch(projectUrl, { + headers: { + Accept: 'application/vnd.card+source', + Authorization: targetRealmToken, + }, + }); + + expect(projectResponse.ok).toBe(true); + let projectJson = (await projectResponse.json()) as { + data: { attributes: { projectName: string } }; + }; + expect(projectJson.data.attributes.projectName).toBe('Sticky Note MVP'); + } finally { + await new Promise((r, reject) => + briefServer.close((err) => (err ? reject(err) : r())), + ); + } +}); diff --git a/packages/software-factory/tests/helpers/matrix-auth.ts b/packages/software-factory/tests/helpers/matrix-auth.ts new file mode 100644 index 00000000000..cbeacb4a36c --- /dev/null +++ b/packages/software-factory/tests/helpers/matrix-auth.ts @@ -0,0 +1,133 @@ +import { createHmac } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; + +import { defaultSupportMetadataFile } from '../../src/runtime-metadata'; + +export interface SupportMetadata { + matrixURL: string; + matrixRegistrationSecret: string; +} + +export function readSupportMetadata(): SupportMetadata { + if (!existsSync(defaultSupportMetadataFile)) { + throw new Error( + `Support metadata not found at ${defaultSupportMetadataFile}. Run pnpm cache:prepare first.`, + ); + } + + let raw = readFileSync(defaultSupportMetadataFile, 'utf8'); + let parsed = JSON.parse(raw) as { + context?: { + matrixURL?: string; + matrixRegistrationSecret?: string; + }; + }; + + let matrixURL = parsed.context?.matrixURL; + let matrixRegistrationSecret = parsed.context?.matrixRegistrationSecret; + + if (!matrixURL || !matrixRegistrationSecret) { + throw new Error( + 'Support metadata is missing matrixURL or matrixRegistrationSecret', + ); + } + + return { matrixURL, matrixRegistrationSecret }; +} + +export async function registerMatrixUser( + matrixURL: string, + registrationSecret: string, + username: string, + password: string, +): Promise { + let baseUrl = matrixURL.endsWith('/') ? matrixURL : `${matrixURL}/`; + let registerUrl = `${baseUrl}_synapse/admin/v1/register`; + + let nonceResponse = await fetch(registerUrl, { method: 'GET' }); + let { nonce } = (await nonceResponse.json()) as { nonce: string }; + + let mac = createHmac('sha1', registrationSecret) + .update(`${nonce}\0${username}\0${password}\0notadmin`) + .digest('hex'); + + let registerResponse = await fetch(registerUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + nonce, + username, + password, + mac, + admin: false, + }), + }); + + if (!registerResponse.ok) { + let text = await registerResponse.text(); + throw new Error( + `Failed to register Matrix user ${username}: HTTP ${registerResponse.status} ${text}`, + ); + } +} + +export async function getRealmToken( + matrixURL: string, + username: string, + password: string, + realmUrl: string, +): Promise { + let baseUrl = matrixURL.endsWith('/') ? matrixURL : `${matrixURL}/`; + + let loginResponse = await fetch(`${baseUrl}_matrix/client/v3/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'm.login.password', + identifier: { type: 'm.id.user', user: username }, + password, + }), + }); + let { access_token, user_id } = (await loginResponse.json()) as { + access_token: string; + user_id: string; + }; + + let openIdResponse = await fetch( + `${baseUrl}_matrix/client/v3/user/${encodeURIComponent(user_id)}/openid/request_token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${access_token}`, + }, + body: '{}', + }, + ); + let openId = (await openIdResponse.json()) as { access_token: string }; + + let sessionResponse = await fetch(new URL('_session', realmUrl).href, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ access_token: openId.access_token }), + }); + + let realmToken = sessionResponse.headers.get('Authorization'); + if (!realmToken) { + throw new Error('Failed to get realm session token'); + } + return realmToken; +} + +export function buildAuthenticatedFetch( + bearerToken: string, + baseFetch: typeof globalThis.fetch, +): typeof globalThis.fetch { + return async (input: RequestInfo | URL, init?: RequestInit) => { + let headers = new Headers(init?.headers); + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${bearerToken}`); + } + return baseFetch(input, { ...init, headers }); + }; +} diff --git a/packages/software-factory/tests/helpers/run-command.ts b/packages/software-factory/tests/helpers/run-command.ts new file mode 100644 index 00000000000..6b0e3684786 --- /dev/null +++ b/packages/software-factory/tests/helpers/run-command.ts @@ -0,0 +1,59 @@ +import { spawn } from 'node:child_process'; + +export interface RunCommandResult { + status: number | null; + stdout: string; + stderr: string; +} + +export async function runCommand( + command: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }, +): Promise { + return await new Promise((resolvePromise, reject) => { + let child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + child.once('close', (status) => { + if (!settled) { + settled = true; + resolvePromise({ status, stdout, stderr }); + } + }); + + if (options.timeoutMs) { + setTimeout(() => { + if (!settled) { + settled = true; + child.kill('SIGTERM'); + resolvePromise({ + status: null, + stdout, + stderr: `${stderr}\n[runCommand] killed after ${options.timeoutMs}ms timeout`, + }); + } + }, options.timeoutMs); + } + }); +} diff --git a/packages/software-factory/tests/index.ts b/packages/software-factory/tests/index.ts index 835a7af4363..6b44f49008e 100644 --- a/packages/software-factory/tests/index.ts +++ b/packages/software-factory/tests/index.ts @@ -1,3 +1,4 @@ +import './factory-bootstrap.test'; import './factory-brief.test'; import './factory-entrypoint.test'; import './factory-entrypoint.integration.test'; From 40d6401dafc33dcae33cc93fe422f381f0cecba5 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 20 Mar 2026 12:57:49 -0400 Subject: [PATCH 02/13] Reference CS-10472 in fixme'd e2e test comment Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/factory-target-realm.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/software-factory/tests/factory-target-realm.spec.ts b/packages/software-factory/tests/factory-target-realm.spec.ts index c0a77010b7c..42e3f0fd18b 100644 --- a/packages/software-factory/tests/factory-target-realm.spec.ts +++ b/packages/software-factory/tests/factory-target-realm.spec.ts @@ -26,11 +26,11 @@ test.use({ realmDir: bootstrapTargetDir }); test.use({ realmServerMode: 'isolated' }); test.setTimeout(180_000); -// Known issue: This test hangs when run in the same Playwright suite after -// other specs that start isolated realm servers (e.g. factory-bootstrap.spec). -// The subprocess's auth middleware hangs during Matrix auth → realm _session -// when prior isolated realm server teardowns leave residual state. The test -// passes reliably when run in isolation: +// Known issue (CS-10472): This test hangs when run in the same Playwright +// suite after other specs that start isolated realm servers. The subprocess's +// auth middleware hangs during Matrix auth → realm _session when prior +// isolated realm server teardowns leave orphaned processes. Passes reliably +// when run in isolation: // pnpm exec playwright test tests/factory-target-realm.spec.ts test.fixme('factory:go creates a target realm and bootstraps project artifacts end-to-end', async ({ realm, From c25be5af3386c9f3342dcbf0e68e74e53c54f0aa Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 20 Mar 2026 15:19:43 -0400 Subject: [PATCH 03/13] Address PR review feedback for CS-10449 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix activeTicket to reflect actual in-progress ticket instead of always using tickets[0]; hasInProgressTicket now returns the active path - Rename bootstrap summary fields: createdProject → projectId, createdTickets → ticketIds, createdKnowledgeArticles → knowledgeArticleIds - Use actual activeTicket status instead of hardcoded 'in_progress' - Wait for stdout flush before process.exit to prevent truncated output - Add response status checks in matrix-auth test helpers - Clear timeout timer in run-command when child exits - Split e2e test into separate playwright config (pnpm test:playwright-e2e) with shared config extracted to playwright.shared.ts - Remove test.fixme — e2e test runs via dedicated command Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/software-factory/package.json | 1 + .../software-factory/playwright.config.ts | 23 +++--------- .../software-factory/playwright.e2e.config.ts | 9 +++++ .../software-factory/playwright.shared.ts | 23 ++++++++++++ .../src/cli/factory-entrypoint.ts | 8 +++-- .../software-factory/src/factory-bootstrap.ts | 35 ++++++++++--------- .../src/factory-entrypoint.ts | 14 ++++---- .../factory-entrypoint.integration.test.ts | 12 +++---- .../tests/factory-entrypoint.test.ts | 9 ++--- .../tests/factory-target-realm.spec.ts | 16 ++++----- .../tests/helpers/matrix-auth.ts | 27 +++++++++++++- .../tests/helpers/run-command.ts | 10 +++++- 12 files changed, 122 insertions(+), 65 deletions(-) create mode 100644 packages/software-factory/playwright.e2e.config.ts create mode 100644 packages/software-factory/playwright.shared.ts diff --git a/packages/software-factory/package.json b/packages/software-factory/package.json index d41ea9cfd76..a923f23d874 100644 --- a/packages/software-factory/package.json +++ b/packages/software-factory/package.json @@ -24,6 +24,7 @@ "test:all": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/test.ts", "test:node": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/test.ts --node-only", "test:playwright": "playwright test", + "test:playwright-e2e": "playwright test --config playwright.e2e.config.ts", "test:playwright:headed": "playwright test --headed", "test:realm": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/run-realm-tests.ts" }, diff --git a/packages/software-factory/playwright.config.ts b/packages/software-factory/playwright.config.ts index bcdaf90189b..6b1621abebf 100644 --- a/packages/software-factory/playwright.config.ts +++ b/packages/software-factory/playwright.config.ts @@ -1,25 +1,12 @@ import { defineConfig } from '@playwright/test'; -const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4205); -const realmURL = - process.env.SOFTWARE_FACTORY_REALM_URL ?? - `http://localhost:${realmPort}/test/`; +import { sharedConfig } from './playwright.shared'; export default defineConfig({ - testDir: './tests', + ...sharedConfig, testMatch: ['**/*.spec.ts'], - fullyParallel: false, - reporter: process.env.CI ? [['list']] : undefined, - workers: 1, + // factory-target-realm.spec.ts is excluded here and run separately + // via `pnpm test:playwright-e2e` (see CS-10472 for context) + testIgnore: ['**/factory-target-realm.spec.ts'], timeout: 60_000, - expect: { - timeout: 15_000, - }, - use: { - baseURL: realmURL, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - }, - globalSetup: './playwright.global-setup.ts', - globalTeardown: './playwright.global-teardown.ts', }); diff --git a/packages/software-factory/playwright.e2e.config.ts b/packages/software-factory/playwright.e2e.config.ts new file mode 100644 index 00000000000..1b5c77c0eb6 --- /dev/null +++ b/packages/software-factory/playwright.e2e.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@playwright/test'; + +import { sharedConfig } from './playwright.shared'; + +export default defineConfig({ + ...sharedConfig, + testMatch: ['**/factory-target-realm.spec.ts'], + timeout: 180_000, +}); diff --git a/packages/software-factory/playwright.shared.ts b/packages/software-factory/playwright.shared.ts new file mode 100644 index 00000000000..c404a57dfa6 --- /dev/null +++ b/packages/software-factory/playwright.shared.ts @@ -0,0 +1,23 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4205); +const realmURL = + process.env.SOFTWARE_FACTORY_REALM_URL ?? + `http://localhost:${realmPort}/test/`; + +export const sharedConfig: PlaywrightTestConfig = { + testDir: './tests', + fullyParallel: false, + reporter: process.env.CI ? [['list']] : undefined, + workers: 1, + expect: { + timeout: 15_000, + }, + use: { + baseURL: realmURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + globalSetup: './playwright.global-setup.ts', + globalTeardown: './playwright.global-teardown.ts', +}; diff --git a/packages/software-factory/src/cli/factory-entrypoint.ts b/packages/software-factory/src/cli/factory-entrypoint.ts index 14310d29198..fb6105ba0ff 100644 --- a/packages/software-factory/src/cli/factory-entrypoint.ts +++ b/packages/software-factory/src/cli/factory-entrypoint.ts @@ -16,8 +16,12 @@ async function main(): Promise { let options = parseFactoryEntrypointArgs(process.argv.slice(2)); let summary = await runFactoryEntrypoint(options); - console.log(JSON.stringify(summary, null, 2)); - process.exit(0); + let output = JSON.stringify(summary, null, 2) + '\n'; + if (!process.stdout.write(output)) { + process.stdout.once('drain', () => process.exit(0)); + } else { + process.exit(0); + } } catch (error) { if (error instanceof FactoryEntrypointUsageError) { console.error(error.message); diff --git a/packages/software-factory/src/factory-bootstrap.ts b/packages/software-factory/src/factory-bootstrap.ts index 501a0ce39d8..bec9f3636d9 100644 --- a/packages/software-factory/src/factory-bootstrap.ts +++ b/packages/software-factory/src/factory-bootstrap.ts @@ -97,23 +97,26 @@ export async function bootstrapProjectArtifacts( ), ); - let activeTicket = tickets[0]; + let inProgressPath = await hasInProgressTicket( + targetRealmUrl, + ticketPaths, + fetchImpl, + ); - if (activeTicket.status === 'existing') { - let hasInProgress = await hasInProgressTicket( + let activeTicket: FactoryBootstrapArtifact; + + if (inProgressPath) { + let idx = ticketPaths.indexOf(inProgressPath); + activeTicket = idx >= 0 ? tickets[idx] : tickets[0]; + } else { + await patchTicketStatus( targetRealmUrl, - ticketPaths, + ticketPaths[0], + 'in_progress', + darkfactoryModuleUrl, fetchImpl, ); - if (!hasInProgress) { - await patchTicketStatus( - targetRealmUrl, - ticketPaths[0], - 'in_progress', - darkfactoryModuleUrl, - fetchImpl, - ); - } + activeTicket = tickets[0]; } return { @@ -489,7 +492,7 @@ async function hasInProgressTicket( realmUrl: string, ticketPaths: string[], fetchImpl: typeof globalThis.fetch, -): Promise { +): Promise { for (let path of ticketPaths) { let url = new URL(path, realmUrl).href; let response = await fetchImpl(url, { @@ -505,10 +508,10 @@ async function hasInProgressTicket( data?: { attributes?: { status?: string } }; }; if (json.data?.attributes?.status === 'in_progress') { - return true; + return path; } } - return false; + return null; } async function patchTicketStatus( diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 6c881b71efc..198d7f34181 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -39,9 +39,9 @@ export interface FactoryEntrypointBriefSummary extends FactoryBrief { } export interface FactoryEntrypointBootstrapSummary { - createdProject: string; - createdKnowledgeArticles: string[]; - createdTickets: string[]; + projectId: string; + knowledgeArticleIds: string[]; + ticketIds: string[]; activeTicket: { id: string; status: string; @@ -261,12 +261,12 @@ export function buildFactoryEntrypointSummary( ownerUsername: targetRealm.ownerUsername, }, bootstrap: { - createdProject: artifacts.project.id, - createdKnowledgeArticles: artifacts.knowledgeArticles.map((ka) => ka.id), - createdTickets: artifacts.tickets.map((t) => t.id), + projectId: artifacts.project.id, + knowledgeArticleIds: artifacts.knowledgeArticles.map((ka) => ka.id), + ticketIds: artifacts.tickets.map((t) => t.id), activeTicket: { id: artifacts.activeTicket.id, - status: 'in_progress', + status: artifacts.activeTicket.status, }, }, actions, diff --git a/packages/software-factory/tests/factory-entrypoint.integration.test.ts b/packages/software-factory/tests/factory-entrypoint.integration.test.ts index f8f58373ad5..06ed6643332 100644 --- a/packages/software-factory/tests/factory-entrypoint.integration.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.integration.test.ts @@ -24,9 +24,9 @@ interface FactoryEntrypointIntegrationSummary { ownerUsername: string; }; bootstrap: { - createdProject: string; - createdKnowledgeArticles: string[]; - createdTickets: string[]; + projectId: string; + knowledgeArticleIds: string[]; + ticketIds: string[]; activeTicket: { id: string; status: string }; }; result: Record; @@ -196,15 +196,15 @@ module('factory-entrypoint integration', function () { assert.strictEqual(summary.targetRealm.url, canonicalTargetRealmUrl); assert.strictEqual(summary.targetRealm.ownerUsername, 'hassan'); assert.strictEqual( - summary.bootstrap.createdProject, + summary.bootstrap.projectId, 'Project/sticky-note-mvp', ); - assert.strictEqual(summary.bootstrap.createdTickets.length, 3); + assert.strictEqual(summary.bootstrap.ticketIds.length, 3); assert.strictEqual( summary.bootstrap.activeTicket.id, 'Ticket/sticky-note-define-core', ); - assert.strictEqual(summary.bootstrap.activeTicket.status, 'in_progress'); + assert.strictEqual(summary.bootstrap.activeTicket.status, 'created'); assert.deepEqual(summary.result, { status: 'ready', nextStep: 'bootstrap-and-select-active-ticket', diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index 03524f3326f..a5a9a4a7363 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -131,16 +131,13 @@ module('factory-entrypoint', function (hooks) { 'bootstrapped-project-artifacts', ], ); - assert.strictEqual( - summary.bootstrap.createdProject, - 'Project/sticky-note-mvp', - ); - assert.strictEqual(summary.bootstrap.createdTickets.length, 3); + assert.strictEqual(summary.bootstrap.projectId, 'Project/sticky-note-mvp'); + assert.strictEqual(summary.bootstrap.ticketIds.length, 3); assert.strictEqual( summary.bootstrap.activeTicket.id, 'Ticket/sticky-note-define-core', ); - assert.strictEqual(summary.bootstrap.activeTicket.status, 'in_progress'); + assert.strictEqual(summary.bootstrap.activeTicket.status, 'created'); assert.deepEqual(summary.result, { status: 'ready', nextStep: 'bootstrap-target-realm', diff --git a/packages/software-factory/tests/factory-target-realm.spec.ts b/packages/software-factory/tests/factory-target-realm.spec.ts index 42e3f0fd18b..2d858f8c302 100644 --- a/packages/software-factory/tests/factory-target-realm.spec.ts +++ b/packages/software-factory/tests/factory-target-realm.spec.ts @@ -32,7 +32,7 @@ test.setTimeout(180_000); // isolated realm server teardowns leave orphaned processes. Passes reliably // when run in isolation: // pnpm exec playwright test tests/factory-target-realm.spec.ts -test.fixme('factory:go creates a target realm and bootstraps project artifacts end-to-end', async ({ +test('factory:go creates a target realm and bootstraps project artifacts end-to-end', async ({ realm, }) => { let supportMetadata = readSupportMetadata(); @@ -106,22 +106,22 @@ test.fixme('factory:go creates a target realm and bootstraps project artifacts e command: string; targetRealm: { url: string; ownerUsername: string }; bootstrap: { - createdProject: string; - createdTickets: string[]; - createdKnowledgeArticles: string[]; + projectId: string; + ticketIds: string[]; + knowledgeArticleIds: string[]; activeTicket: { id: string; status: string }; }; }; expect(summary.command).toBe('factory:go'); expect(summary.targetRealm.ownerUsername).toBe(targetUsername); - expect(summary.bootstrap.createdProject).toBe('Project/sticky-note-mvp'); - expect(summary.bootstrap.createdTickets).toHaveLength(3); - expect(summary.bootstrap.createdKnowledgeArticles).toHaveLength(2); + expect(summary.bootstrap.projectId).toBe('Project/sticky-note-mvp'); + expect(summary.bootstrap.ticketIds).toHaveLength(3); + expect(summary.bootstrap.knowledgeArticleIds).toHaveLength(2); expect(summary.bootstrap.activeTicket.id).toBe( 'Ticket/sticky-note-define-core', ); - expect(summary.bootstrap.activeTicket.status).toBe('in_progress'); + expect(summary.bootstrap.activeTicket.status).toBe('created'); // Verify the project card actually exists in the newly created target realm // by authenticating as the target user who owns the realm diff --git a/packages/software-factory/tests/helpers/matrix-auth.ts b/packages/software-factory/tests/helpers/matrix-auth.ts index cbeacb4a36c..6252aebf0bd 100644 --- a/packages/software-factory/tests/helpers/matrix-auth.ts +++ b/packages/software-factory/tests/helpers/matrix-auth.ts @@ -45,6 +45,12 @@ export async function registerMatrixUser( let registerUrl = `${baseUrl}_synapse/admin/v1/register`; let nonceResponse = await fetch(registerUrl, { method: 'GET' }); + if (!nonceResponse.ok) { + let text = await nonceResponse.text(); + throw new Error( + `Failed to fetch registration nonce from ${registerUrl}: HTTP ${nonceResponse.status} ${text}`, + ); + } let { nonce } = (await nonceResponse.json()) as { nonce: string }; let mac = createHmac('sha1', registrationSecret) @@ -88,6 +94,12 @@ export async function getRealmToken( password, }), }); + if (!loginResponse.ok) { + let text = await loginResponse.text(); + throw new Error( + `Failed to login to Matrix as ${username}: HTTP ${loginResponse.status} ${text}`, + ); + } let { access_token, user_id } = (await loginResponse.json()) as { access_token: string; user_id: string; @@ -104,13 +116,26 @@ export async function getRealmToken( body: '{}', }, ); + if (!openIdResponse.ok) { + let text = await openIdResponse.text(); + throw new Error( + `Failed to get OpenID token for ${user_id}: HTTP ${openIdResponse.status} ${text}`, + ); + } let openId = (await openIdResponse.json()) as { access_token: string }; - let sessionResponse = await fetch(new URL('_session', realmUrl).href, { + let sessionUrl = new URL('_session', realmUrl).href; + let sessionResponse = await fetch(sessionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: openId.access_token }), }); + if (!sessionResponse.ok) { + let text = await sessionResponse.text(); + throw new Error( + `Failed to create realm session at ${sessionUrl}: HTTP ${sessionResponse.status} ${text}`, + ); + } let realmToken = sessionResponse.headers.get('Authorization'); if (!realmToken) { diff --git a/packages/software-factory/tests/helpers/run-command.ts b/packages/software-factory/tests/helpers/run-command.ts index 6b0e3684786..851c793ffec 100644 --- a/packages/software-factory/tests/helpers/run-command.ts +++ b/packages/software-factory/tests/helpers/run-command.ts @@ -20,6 +20,7 @@ export async function runCommand( let stdout = ''; let stderr = ''; let settled = false; + let timer: ReturnType | undefined; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); @@ -32,18 +33,24 @@ export async function runCommand( child.once('error', (error) => { if (!settled) { settled = true; + if (timer) { + clearTimeout(timer); + } reject(error); } }); child.once('close', (status) => { if (!settled) { settled = true; + if (timer) { + clearTimeout(timer); + } resolvePromise({ status, stdout, stderr }); } }); if (options.timeoutMs) { - setTimeout(() => { + timer = setTimeout(() => { if (!settled) { settled = true; child.kill('SIGTERM'); @@ -54,6 +61,7 @@ export async function runCommand( }); } }, options.timeoutMs); + timer.unref(); } }); } From 27b26a31016978a72e4d278367785b81ce49262b Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sat, 21 Mar 2026 13:37:35 -0400 Subject: [PATCH 04/13] Update Matrix account data with new realm URL after creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After _create-realm succeeds, append the new realm URL to the Matrix user's app.boxel.realms account data. This mirrors what the host app does when creating a realm through the UI — without this step, the realm won't appear in the user's realm list. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/factory-target-realm.ts | 49 +++++++++++++++++++ .../factory-entrypoint.integration.test.ts | 14 ++++++ .../tests/factory-target-realm.test.ts | 21 +++++++- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/software-factory/src/factory-target-realm.ts b/packages/software-factory/src/factory-target-realm.ts index 1e2cf6ae7fd..079ea820daa 100644 --- a/packages/software-factory/src/factory-target-realm.ts +++ b/packages/software-factory/src/factory-target-realm.ts @@ -1,4 +1,5 @@ import { getMatrixUsername } from '@cardstack/runtime-common/matrix-client'; +import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { SupportedMimeType } from '@cardstack/runtime-common/router'; @@ -7,6 +8,7 @@ import { getRealmServerToken, matrixLogin, type ActiveBoxelProfile, + type MatrixAuth, } from '../scripts/lib/boxel'; import { FactoryEntrypointUsageError } from './factory-entrypoint-errors'; @@ -120,6 +122,12 @@ async function createRealm( resolution.url, ); + await appendRealmToMatrixAccountData( + matrixAuth, + canonicalRealmUrl, + fetchImpl, + ); + return { createdRealm: true, url: canonicalRealmUrl, @@ -140,6 +148,47 @@ async function createRealm( ); } +async function appendRealmToMatrixAccountData( + matrixAuth: MatrixAuth, + realmUrl: string, + fetchImpl: typeof globalThis.fetch, +): Promise { + let accountDataUrl = new URL( + `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`, + matrixAuth.credentials.matrixUrl, + ).href; + + let existingRealms: string[] = []; + + let getResponse = await fetchImpl(accountDataUrl, { + headers: { Authorization: `Bearer ${matrixAuth.accessToken}` }, + }); + if (getResponse.ok) { + let data = (await getResponse.json()) as { realms?: string[] }; + existingRealms = Array.isArray(data.realms) ? [...data.realms] : []; + } + + if (!existingRealms.includes(realmUrl)) { + existingRealms.push(realmUrl); + } + + let putResponse = await fetchImpl(accountDataUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: JSON.stringify({ realms: existingRealms }), + }); + + if (!putResponse.ok) { + let text = await putResponse.text(); + throw new Error( + `Failed to update Matrix account data with realm ${realmUrl}: HTTP ${putResponse.status} ${text}`.trim(), + ); + } +} + function resolveRealmServerProfile( ownerUsername: string, serverUrl: string, diff --git a/packages/software-factory/tests/factory-entrypoint.integration.test.ts b/packages/software-factory/tests/factory-entrypoint.integration.test.ts index 06ed6643332..87220f4000f 100644 --- a/packages/software-factory/tests/factory-entrypoint.integration.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.integration.test.ts @@ -104,6 +104,20 @@ module('factory-entrypoint integration', function () { }, }), ); + } else if ( + request.url === + '/_matrix/client/v3/user/%40hassan%3Alocalhost/account_data/app.boxel.realms' && + request.method === 'GET' + ) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ realms: [] })); + } else if ( + request.url === + '/_matrix/client/v3/user/%40hassan%3Alocalhost/account_data/app.boxel.realms' && + request.method === 'PUT' + ) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{}'); } else if ( request.url === '/hassan/personal/_session' && request.method === 'POST' diff --git a/packages/software-factory/tests/factory-target-realm.test.ts b/packages/software-factory/tests/factory-target-realm.test.ts index 4c127be6086..b2d3854e943 100644 --- a/packages/software-factory/tests/factory-target-realm.test.ts +++ b/packages/software-factory/tests/factory-target-realm.test.ts @@ -142,7 +142,7 @@ module('factory-target-realm', function (hooks) { }); test('bootstrapFactoryTargetRealm sends the realm-server JWT to create-realm', async function (assert) { - assert.expect(6); + assert.expect(8); process.env.MATRIX_URL = 'https://matrix.example.test/'; process.env.MATRIX_USERNAME = 'hassan'; @@ -154,6 +154,9 @@ module('factory-target-realm', function (hooks) { realmServerUrl: null, }); + let accountDataUrl = + 'https://matrix.example.test/_matrix/client/v3/user/%40hassan%3Alocalhost/account_data/app.boxel.realms'; + globalThis.fetch = (async (input, init) => { let request = new Request(input, init); let response: Response; @@ -236,8 +239,22 @@ module('factory-target-realm', function (hooks) { }, }, ); + } else if (request.url === accountDataUrl && request.method === 'GET') { + // Return empty account data (no realms yet) + response = new Response(JSON.stringify({ realms: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } else if (request.url === accountDataUrl && request.method === 'PUT') { + let body = (await request.json()) as { realms: string[] }; + assert.deepEqual(body.realms, [targetRealmUrl]); + assert.strictEqual( + request.headers.get('Authorization'), + 'Bearer matrix-access-token', + ); + response = new Response('{}', { status: 200 }); } else { - throw new Error(`Unexpected url: ${request.url}`); + throw new Error(`Unexpected url: ${request.method} ${request.url}`); } return response; From 278b794e4967d450b6fefc44fe053de7684a82dd Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sat, 21 Mar 2026 13:51:10 -0400 Subject: [PATCH 05/13] Set realm icon and background on creation, format card JSON - Set iconURL from first letter of realm name (Letter-{x}.png from boxel CDN) - Set backgroundURL to a random curated image from boxel CDN - Mirrors iconURLFor() and getRandomBackgroundURL() from host app - Format card artifact JSON with 2-space indentation for readability Co-Authored-By: Claude Opus 4.6 (1M context) --- .../software-factory/src/factory-bootstrap.ts | 4 +- .../src/factory-target-realm.ts | 69 +++++++++++++++++++ .../tests/factory-target-realm.test.ts | 29 +++++--- 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/packages/software-factory/src/factory-bootstrap.ts b/packages/software-factory/src/factory-bootstrap.ts index bec9f3636d9..eb98bfbb7da 100644 --- a/packages/software-factory/src/factory-bootstrap.ts +++ b/packages/software-factory/src/factory-bootstrap.ts @@ -475,7 +475,7 @@ async function createCardIfMissing( Accept: cardSourceMimeType, 'Content-Type': cardSourceMimeType, }, - body: JSON.stringify(document), + body: JSON.stringify(document, null, 2), }); if (!writeResponse.ok) { @@ -545,7 +545,7 @@ async function patchTicketStatus( Accept: cardSourceMimeType, 'Content-Type': cardSourceMimeType, }, - body: JSON.stringify(existing), + body: JSON.stringify(existing, null, 2), }); if (!patchResponse.ok) { diff --git a/packages/software-factory/src/factory-target-realm.ts b/packages/software-factory/src/factory-target-realm.ts index 079ea820daa..31a57837ccb 100644 --- a/packages/software-factory/src/factory-target-realm.ts +++ b/packages/software-factory/src/factory-target-realm.ts @@ -105,6 +105,8 @@ async function createRealm( attributes: { endpoint, name: endpoint, + iconURL: iconURLForRealmName(endpoint), + backgroundURL: randomBackgroundURL(), }, }, }), @@ -375,3 +377,70 @@ function normalizeOptionalString( let trimmed = value.trim(); return trimmed === '' ? undefined : trimmed; } + +// Mirrors iconURLFor() from packages/host/app/lib/utils.ts +function iconURLForRealmName(name: string): string { + let letter = name + .toLowerCase() + .replace(/[^a-z]/g, '') + .charAt(0); + if (!letter) { + letter = 'b'; // fallback for names starting with numbers/symbols + } + return `https://boxel-images.boxel.ai/icons/Letter-${letter}.png`; +} + +// Mirrors getRandomBackgroundURL() from packages/host/app/lib/utils.ts +const backgroundURLs = [ + 'https://boxel-images.boxel.ai/background-images/4k-arabic-teal.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-arrow-weave.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-atmosphere-curvature.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-brushed-slabs.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-coral-reefs.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-crescent-lake.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-curvilinear-stairs.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-desert-dunes.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-doodle-board.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-fallen-leaves.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-flowing-mesh.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-glass-reflection.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-glow-cells.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-granite-peaks.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-green-wormhole.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-joshua-dawn.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-lava-river.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-leaves-moss.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-light-streaks.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-lowres-glitch.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-marble-shimmer.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-metallic-leather.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-microscopic-crystals.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-moon-face.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-mountain-runway.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-origami-flock.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-paint-swirl.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-pastel-triangles.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-perforated-sheet.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-plastic-ripples.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-powder-puff.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-radiant-crystal.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-redrock-canyon.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-rock-portal.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-rolling-hills.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-sand-stone.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-silver-fur.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-spa-pool.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-stained-glass.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-stone-veins.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-tangerine-plains.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-techno-floor.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-thick-frost.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-water-surface.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-watercolor-splashes.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-wildflower-field.jpg', + 'https://boxel-images.boxel.ai/background-images/4k-wood-grain.jpg', +] as const; + +function randomBackgroundURL(): string { + return backgroundURLs[Math.floor(Math.random() * backgroundURLs.length)]; +} diff --git a/packages/software-factory/tests/factory-target-realm.test.ts b/packages/software-factory/tests/factory-target-realm.test.ts index b2d3854e943..76c038ab129 100644 --- a/packages/software-factory/tests/factory-target-realm.test.ts +++ b/packages/software-factory/tests/factory-target-realm.test.ts @@ -142,7 +142,7 @@ module('factory-target-realm', function (hooks) { }); test('bootstrapFactoryTargetRealm sends the realm-server JWT to create-realm', async function (assert) { - assert.expect(8); + assert.expect(11); process.env.MATRIX_URL = 'https://matrix.example.test/'; process.env.MATRIX_USERNAME = 'hassan'; @@ -216,15 +216,28 @@ module('factory-target-realm', function (hooks) { request.headers.get('Authorization'), 'Bearer realm-server-token', ); - assert.deepEqual(await request.json(), { + let body = (await request.json()) as { data: { - type: 'realm', + type: string; attributes: { - endpoint: 'personal', - name: 'personal', - }, - }, - }); + endpoint: string; + name: string; + iconURL: string; + backgroundURL: string; + }; + }; + }; + assert.strictEqual(body.data.attributes.endpoint, 'personal'); + assert.strictEqual(body.data.attributes.name, 'personal'); + assert.strictEqual( + body.data.attributes.iconURL, + 'https://boxel-images.boxel.ai/icons/Letter-p.png', + ); + assert.true( + body.data.attributes.backgroundURL.startsWith( + 'https://boxel-images.boxel.ai/background-images/', + ), + ); response = new Response( JSON.stringify({ data: { From 33474f62ba5c3eba7fd6a65a2fe89645da7494d4 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sat, 21 Mar 2026 14:01:36 -0400 Subject: [PATCH 06/13] Merge darkfactory schema and UI into single darkfactory.gts The split module pattern (schema in one file, UI side-effects in another) caused custom templates to not render when cards adopted from the public module. The realm server's module loader could resolve the schema without executing the UI side-effects, leaving cards with default edit views. Combining everything into darkfactory.gts ensures the fitted/isolated/ embedded templates are always co-located with their card class definitions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../realm/darkfactory-schema.gts | 184 ------ .../software-factory/realm/darkfactory-ui.gts | 418 ------------ .../software-factory/realm/darkfactory.gts | 600 +++++++++++++++++- .../software-factory/scripts/pick-ticket.ts | 2 +- .../darkfactory-schema.gts | 173 ----- .../darkfactory-ui.gts | 418 ------------ .../darkfactory.gts | 600 +++++++++++++++++- 7 files changed, 1185 insertions(+), 1210 deletions(-) delete mode 100644 packages/software-factory/realm/darkfactory-schema.gts delete mode 100644 packages/software-factory/realm/darkfactory-ui.gts delete mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-schema.gts delete mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-ui.gts diff --git a/packages/software-factory/realm/darkfactory-schema.gts b/packages/software-factory/realm/darkfactory-schema.gts deleted file mode 100644 index a4a1d90e4db..00000000000 --- a/packages/software-factory/realm/darkfactory-schema.gts +++ /dev/null @@ -1,184 +0,0 @@ -import { - CardDef, - field, - contains, - containsMany, - linksTo, - linksToMany, -} from 'https://cardstack.com/base/card-api'; -import StringField from 'https://cardstack.com/base/string'; -import NumberField from 'https://cardstack.com/base/number'; -import DateTimeField from 'https://cardstack.com/base/datetime'; -import DateField from 'https://cardstack.com/base/date'; -import MarkdownField from 'https://cardstack.com/base/markdown'; -import TextAreaField from 'https://cardstack.com/base/text-area'; -import enumField from 'https://cardstack.com/base/enum'; - -export const TicketStatusField = enumField(StringField, { - options: [ - { value: 'backlog', label: 'Backlog' }, - { value: 'in_progress', label: 'In Progress' }, - { value: 'blocked', label: 'Blocked' }, - { value: 'review', label: 'In Review' }, - { value: 'done', label: 'Done' }, - ], -}); - -export const TicketPriorityField = enumField(StringField, { - options: [ - { value: 'critical', label: 'Critical' }, - { value: 'high', label: 'High' }, - { value: 'medium', label: 'Medium' }, - { value: 'low', label: 'Low' }, - ], -}); - -export const TicketTypeField = enumField(StringField, { - options: [ - { value: 'feature', label: 'Feature' }, - { value: 'bug', label: 'Bug' }, - { value: 'task', label: 'Task' }, - { value: 'research', label: 'Research' }, - { value: 'infrastructure', label: 'Infrastructure' }, - ], -}); - -export const ProjectStatusField = enumField(StringField, { - options: [ - { value: 'planning', label: 'Planning' }, - { value: 'active', label: 'Active' }, - { value: 'on_hold', label: 'On Hold' }, - { value: 'completed', label: 'Completed' }, - { value: 'archived', label: 'Archived' }, - ], -}); - -export const KnowledgeTypeField = enumField(StringField, { - options: [ - { value: 'architecture', label: 'Architecture' }, - { value: 'decision', label: 'Decision (ADR)' }, - { value: 'runbook', label: 'Runbook' }, - { value: 'context', label: 'Context' }, - { value: 'api', label: 'API Reference' }, - { value: 'onboarding', label: 'Onboarding' }, - ], -}); - -export class AgentProfile extends CardDef { - static displayName = 'Agent Profile'; - - @field agentId = contains(StringField); - @field capabilities = containsMany(StringField); - @field specialization = contains(StringField); - @field notes = contains(MarkdownField); - - @field title = contains(StringField, { - computeVia: function (this: AgentProfile) { - return this.cardInfo.name?.trim()?.length - ? this.cardInfo.name - : (this.agentId ?? 'Unnamed Agent'); - }, - }); -} - -export class KnowledgeArticle extends CardDef { - static displayName = 'Knowledge Article'; - - @field articleTitle = contains(StringField); - @field articleType = contains(KnowledgeTypeField); - @field content = contains(MarkdownField); - @field tags = containsMany(StringField); - @field lastUpdatedBy = linksTo(() => AgentProfile); - @field updatedAt = contains(DateTimeField); - - @field title = contains(StringField, { - computeVia: function (this: KnowledgeArticle) { - return this.cardInfo.name?.trim()?.length - ? this.cardInfo.name - : (this.articleTitle ?? 'Untitled Article'); - }, - }); -} - -export class Ticket extends CardDef { - static displayName = 'Ticket'; - - @field ticketId = contains(StringField); - @field summary = contains(StringField); - @field description = contains(MarkdownField); - @field ticketType = contains(TicketTypeField); - @field status = contains(TicketStatusField); - @field priority = contains(TicketPriorityField); - @field project = linksTo(() => Project); - @field assignedAgent = linksTo(() => AgentProfile); - @field relatedTickets = linksToMany(() => Ticket); - @field relatedKnowledge = linksToMany(() => KnowledgeArticle); - @field acceptanceCriteria = contains(MarkdownField); - @field agentNotes = contains(MarkdownField); - @field estimatedHours = contains(NumberField); - @field actualHours = contains(NumberField); - @field createdAt = contains(DateTimeField); - @field updatedAt = contains(DateTimeField); - - @field title = contains(StringField, { - computeVia: function (this: Ticket) { - return this.cardInfo.name?.trim()?.length - ? this.cardInfo.name - : (this.summary ?? 'Untitled Ticket'); - }, - }); -} - -export class Project extends CardDef { - static displayName = 'Project'; - static prefersWideFormat = true; - - @field projectCode = contains(StringField); - @field projectName = contains(StringField); - @field projectStatus = contains(ProjectStatusField); - @field deadline = contains(DateField); - @field objective = contains(TextAreaField); - @field scope = contains(MarkdownField); - @field technicalContext = contains(MarkdownField); - @field tickets = linksToMany(() => Ticket, { - query: { - filter: { - on: { - // @ts-ignore this is not a CJS file, import.meta is allowed - module: new URL('./darkfactory', import.meta.url).href, - name: 'Ticket', - }, - eq: { 'project.id': '$this.id' }, - }, - }, - }); - @field knowledgeBase = linksToMany(() => KnowledgeArticle); - @field teamAgents = linksToMany(() => AgentProfile); - @field successCriteria = contains(MarkdownField); - @field risks = contains(MarkdownField); - @field createdAt = contains(DateTimeField); - - @field title = contains(StringField, { - computeVia: function (this: Project) { - return this.cardInfo.name?.trim()?.length - ? this.cardInfo.name - : (this.projectName ?? 'Untitled Project'); - }, - }); -} - -export class DarkFactory extends CardDef { - static displayName = 'Dark Factory'; - - @field factoryName = contains(StringField); - @field description = contains(MarkdownField); - @field activeProjects = linksToMany(() => Project); - - @field title = contains(StringField, { - computeVia: function (this: DarkFactory) { - return this.cardInfo.name?.trim()?.length - ? this.cardInfo.name - : (this.factoryName ?? 'Dark Factory'); - }, - }); -} diff --git a/packages/software-factory/realm/darkfactory-ui.gts b/packages/software-factory/realm/darkfactory-ui.gts deleted file mode 100644 index 3a9cbcdc831..00000000000 --- a/packages/software-factory/realm/darkfactory-ui.gts +++ /dev/null @@ -1,418 +0,0 @@ -import { Component } from 'https://cardstack.com/base/card-api'; - -import { - AgentProfile, - KnowledgeArticle, - Ticket, - Project as ProjectSchema, - DarkFactory as DarkFactorySchema, -} from './darkfactory-schema'; - -AgentProfile.fitted = class Fitted extends Component { - -}; - -AgentProfile.embedded = AgentProfile.fitted; - -AgentProfile.isolated = class Isolated extends Component { - -}; - -KnowledgeArticle.fitted = class Fitted extends Component< - typeof KnowledgeArticle -> { - -}; - -KnowledgeArticle.embedded = KnowledgeArticle.fitted; - -KnowledgeArticle.isolated = class Isolated extends Component< - typeof KnowledgeArticle -> { - -}; - -Ticket.fitted = class Fitted extends Component { - -}; - -Ticket.embedded = Ticket.fitted; - -Ticket.isolated = class Isolated extends Component { - -}; - -ProjectSchema.fitted = class Fitted extends Component { - -}; - -ProjectSchema.embedded = ProjectSchema.fitted; - -ProjectSchema.isolated = class Isolated extends Component< - typeof ProjectSchema -> { - -}; - -DarkFactorySchema.fitted = class Fitted extends Component< - typeof DarkFactorySchema -> { - -}; - -DarkFactorySchema.embedded = DarkFactorySchema.fitted; - -DarkFactorySchema.isolated = class Isolated extends Component< - typeof DarkFactorySchema -> { - -}; - -export { - AgentProfile, - KnowledgeArticle, - Ticket, - ProjectSchema as Project, - DarkFactorySchema as DarkFactory, -}; diff --git a/packages/software-factory/realm/darkfactory.gts b/packages/software-factory/realm/darkfactory.gts index e1b5868473a..2d57a4f6f78 100644 --- a/packages/software-factory/realm/darkfactory.gts +++ b/packages/software-factory/realm/darkfactory.gts @@ -1,8 +1,592 @@ -// Public barrel for the DarkFactory tracker types. -export { - AgentProfile, - KnowledgeArticle, - Ticket, - Project, - DarkFactory, -} from './darkfactory-ui'; +import { + CardDef, + Component, + field, + contains, + containsMany, + linksTo, + linksToMany, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import DateField from 'https://cardstack.com/base/date'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import enumField from 'https://cardstack.com/base/enum'; + +export const TicketStatusField = enumField(StringField, { + options: [ + { value: 'backlog', label: 'Backlog' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'blocked', label: 'Blocked' }, + { value: 'review', label: 'In Review' }, + { value: 'done', label: 'Done' }, + ], +}); + +export const TicketPriorityField = enumField(StringField, { + options: [ + { value: 'critical', label: 'Critical' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, + ], +}); + +export const TicketTypeField = enumField(StringField, { + options: [ + { value: 'feature', label: 'Feature' }, + { value: 'bug', label: 'Bug' }, + { value: 'task', label: 'Task' }, + { value: 'research', label: 'Research' }, + { value: 'infrastructure', label: 'Infrastructure' }, + ], +}); + +export const ProjectStatusField = enumField(StringField, { + options: [ + { value: 'planning', label: 'Planning' }, + { value: 'active', label: 'Active' }, + { value: 'on_hold', label: 'On Hold' }, + { value: 'completed', label: 'Completed' }, + { value: 'archived', label: 'Archived' }, + ], +}); + +export const KnowledgeTypeField = enumField(StringField, { + options: [ + { value: 'architecture', label: 'Architecture' }, + { value: 'decision', label: 'Decision (ADR)' }, + { value: 'runbook', label: 'Runbook' }, + { value: 'context', label: 'Context' }, + { value: 'api', label: 'API Reference' }, + { value: 'onboarding', label: 'Onboarding' }, + ], +}); + +export class AgentProfile extends CardDef { + static displayName = 'Agent Profile'; + + @field agentId = contains(StringField); + @field capabilities = containsMany(StringField); + @field specialization = contains(StringField); + @field notes = contains(MarkdownField); + + @field title = contains(StringField, { + computeVia: function (this: AgentProfile) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.agentId ?? 'Unnamed Agent'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class KnowledgeArticle extends CardDef { + static displayName = 'Knowledge Article'; + + @field articleTitle = contains(StringField); + @field articleType = contains(KnowledgeTypeField); + @field content = contains(MarkdownField); + @field tags = containsMany(StringField); + @field lastUpdatedBy = linksTo(() => AgentProfile); + @field updatedAt = contains(DateTimeField); + + @field title = contains(StringField, { + computeVia: function (this: KnowledgeArticle) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.articleTitle ?? 'Untitled Article'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class Ticket extends CardDef { + static displayName = 'Ticket'; + + @field ticketId = contains(StringField); + @field summary = contains(StringField); + @field description = contains(MarkdownField); + @field ticketType = contains(TicketTypeField); + @field status = contains(TicketStatusField); + @field priority = contains(TicketPriorityField); + @field project = linksTo(() => Project); + @field assignedAgent = linksTo(() => AgentProfile); + @field relatedTickets = linksToMany(() => Ticket); + @field relatedKnowledge = linksToMany(() => KnowledgeArticle); + @field acceptanceCriteria = contains(MarkdownField); + @field agentNotes = contains(MarkdownField); + @field estimatedHours = contains(NumberField); + @field actualHours = contains(NumberField); + @field createdAt = contains(DateTimeField); + @field updatedAt = contains(DateTimeField); + + @field title = contains(StringField, { + computeVia: function (this: Ticket) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.summary ?? 'Untitled Ticket'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class Project extends CardDef { + static displayName = 'Project'; + static prefersWideFormat = true; + + @field projectCode = contains(StringField); + @field projectName = contains(StringField); + @field projectStatus = contains(ProjectStatusField); + @field deadline = contains(DateField); + @field objective = contains(TextAreaField); + @field scope = contains(MarkdownField); + @field technicalContext = contains(MarkdownField); + @field tickets = linksToMany(() => Ticket, { + query: { + filter: { + on: { + // @ts-ignore this is not a CJS file, import.meta is allowed + module: new URL('./darkfactory', import.meta.url).href, + name: 'Ticket', + }, + eq: { 'project.id': '$this.id' }, + }, + }, + }); + @field knowledgeBase = linksToMany(() => KnowledgeArticle); + @field teamAgents = linksToMany(() => AgentProfile); + @field successCriteria = contains(MarkdownField); + @field risks = contains(MarkdownField); + @field createdAt = contains(DateTimeField); + + @field title = contains(StringField, { + computeVia: function (this: Project) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.projectName ?? 'Untitled Project'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class DarkFactory extends CardDef { + static displayName = 'Dark Factory'; + + @field factoryName = contains(StringField); + @field description = contains(MarkdownField); + @field activeProjects = linksToMany(() => Project); + + @field title = contains(StringField, { + computeVia: function (this: DarkFactory) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.factoryName ?? 'Dark Factory'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/software-factory/scripts/pick-ticket.ts b/packages/software-factory/scripts/pick-ticket.ts index 43ad89f06d9..7892b347ca6 100644 --- a/packages/software-factory/scripts/pick-ticket.ts +++ b/packages/software-factory/scripts/pick-ticket.ts @@ -73,7 +73,7 @@ async function main(): Promise { let moduleUrl = typeof args.module === 'string' ? args.module - : `${realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`}darkfactory-schema`; + : `${realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`}darkfactory`; let matrixAuth = await matrixLogin(); let realmTokens = await getAccessibleRealmTokens(matrixAuth); diff --git a/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-schema.gts b/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-schema.gts deleted file mode 100644 index feb0b0d35b5..00000000000 --- a/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-schema.gts +++ /dev/null @@ -1,173 +0,0 @@ -import { - CardDef, - field, - contains, - containsMany, - linksTo, - linksToMany, -} from 'https://cardstack.com/base/card-api'; -import StringField from 'https://cardstack.com/base/string'; -import NumberField from 'https://cardstack.com/base/number'; -import DateTimeField from 'https://cardstack.com/base/datetime'; -import DateField from 'https://cardstack.com/base/date'; -import MarkdownField from 'https://cardstack.com/base/markdown'; -import TextAreaField from 'https://cardstack.com/base/text-area'; -import enumField from 'https://cardstack.com/base/enum'; - -export const TicketStatusField = enumField(StringField, { - options: [ - { value: 'backlog', label: 'Backlog' }, - { value: 'in_progress', label: 'In Progress' }, - { value: 'blocked', label: 'Blocked' }, - { value: 'review', label: 'In Review' }, - { value: 'done', label: 'Done' }, - ], -}); - -export const TicketPriorityField = enumField(StringField, { - options: [ - { value: 'critical', label: 'Critical' }, - { value: 'high', label: 'High' }, - { value: 'medium', label: 'Medium' }, - { value: 'low', label: 'Low' }, - ], -}); - -export const TicketTypeField = enumField(StringField, { - options: [ - { value: 'feature', label: 'Feature' }, - { value: 'bug', label: 'Bug' }, - { value: 'task', label: 'Task' }, - { value: 'research', label: 'Research' }, - { value: 'infrastructure', label: 'Infrastructure' }, - ], -}); - -export const ProjectStatusField = enumField(StringField, { - options: [ - { value: 'planning', label: 'Planning' }, - { value: 'active', label: 'Active' }, - { value: 'on_hold', label: 'On Hold' }, - { value: 'completed', label: 'Completed' }, - { value: 'archived', label: 'Archived' }, - ], -}); - -export const KnowledgeTypeField = enumField(StringField, { - options: [ - { value: 'architecture', label: 'Architecture' }, - { value: 'decision', label: 'Decision (ADR)' }, - { value: 'runbook', label: 'Runbook' }, - { value: 'context', label: 'Context' }, - { value: 'api', label: 'API Reference' }, - { value: 'onboarding', label: 'Onboarding' }, - ], -}); - -export class AgentProfile extends CardDef { - static displayName = 'Agent Profile'; - - @field agentId = contains(StringField); - @field capabilities = containsMany(StringField); - @field specialization = contains(StringField); - @field notes = contains(MarkdownField); - - @field title = contains(StringField, { - computeVia: function (this: AgentProfile) { - return this.cardInfo?.title ?? this.agentId ?? 'Unnamed Agent'; - }, - }); -} - -export class KnowledgeArticle extends CardDef { - static displayName = 'Knowledge Article'; - - @field articleTitle = contains(StringField); - @field articleType = contains(KnowledgeTypeField); - @field content = contains(MarkdownField); - @field tags = containsMany(StringField); - @field lastUpdatedBy = linksTo(() => AgentProfile); - @field updatedAt = contains(DateTimeField); - - @field title = contains(StringField, { - computeVia: function (this: KnowledgeArticle) { - return this.cardInfo?.title ?? this.articleTitle ?? 'Untitled Article'; - }, - }); -} - -export class Ticket extends CardDef { - static displayName = 'Ticket'; - - @field ticketId = contains(StringField); - @field summary = contains(StringField); - @field description = contains(MarkdownField); - @field ticketType = contains(TicketTypeField); - @field status = contains(TicketStatusField); - @field priority = contains(TicketPriorityField); - @field project = linksTo(() => Project); - @field assignedAgent = linksTo(() => AgentProfile); - @field relatedTickets = linksToMany(() => Ticket); - @field relatedKnowledge = linksToMany(() => KnowledgeArticle); - @field acceptanceCriteria = contains(MarkdownField); - @field agentNotes = contains(MarkdownField); - @field estimatedHours = contains(NumberField); - @field actualHours = contains(NumberField); - @field createdAt = contains(DateTimeField); - @field updatedAt = contains(DateTimeField); - - @field title = contains(StringField, { - computeVia: function (this: Ticket) { - return this.cardInfo?.title ?? this.summary ?? 'Untitled Ticket'; - }, - }); -} - -export class Project extends CardDef { - static displayName = 'Project'; - static prefersWideFormat = true; - - @field projectCode = contains(StringField); - @field projectName = contains(StringField); - @field projectStatus = contains(ProjectStatusField); - @field deadline = contains(DateField); - @field objective = contains(TextAreaField); - @field scope = contains(MarkdownField); - @field technicalContext = contains(MarkdownField); - @field tickets = linksToMany(() => Ticket, { - query: { - filter: { - on: { - module: new URL('./darkfactory', import.meta.url).href, - name: 'Ticket', - }, - eq: { 'project.id': '$this.id' }, - }, - }, - }); - @field knowledgeBase = linksToMany(() => KnowledgeArticle); - @field teamAgents = linksToMany(() => AgentProfile); - @field successCriteria = contains(MarkdownField); - @field risks = contains(MarkdownField); - @field createdAt = contains(DateTimeField); - - @field title = contains(StringField, { - computeVia: function (this: Project) { - return this.cardInfo?.title ?? this.projectName ?? 'Untitled Project'; - }, - }); -} - -export class DarkFactory extends CardDef { - static displayName = 'Dark Factory'; - - @field factoryName = contains(StringField); - @field description = contains(MarkdownField); - @field activeProjects = linksToMany(() => Project); - - @field title = contains(StringField, { - computeVia: function (this: DarkFactory) { - return this.cardInfo?.title ?? this.factoryName ?? 'Dark Factory'; - }, - }); -} diff --git a/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-ui.gts b/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-ui.gts deleted file mode 100644 index 3a9cbcdc831..00000000000 --- a/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-ui.gts +++ /dev/null @@ -1,418 +0,0 @@ -import { Component } from 'https://cardstack.com/base/card-api'; - -import { - AgentProfile, - KnowledgeArticle, - Ticket, - Project as ProjectSchema, - DarkFactory as DarkFactorySchema, -} from './darkfactory-schema'; - -AgentProfile.fitted = class Fitted extends Component { - -}; - -AgentProfile.embedded = AgentProfile.fitted; - -AgentProfile.isolated = class Isolated extends Component { - -}; - -KnowledgeArticle.fitted = class Fitted extends Component< - typeof KnowledgeArticle -> { - -}; - -KnowledgeArticle.embedded = KnowledgeArticle.fitted; - -KnowledgeArticle.isolated = class Isolated extends Component< - typeof KnowledgeArticle -> { - -}; - -Ticket.fitted = class Fitted extends Component { - -}; - -Ticket.embedded = Ticket.fitted; - -Ticket.isolated = class Isolated extends Component { - -}; - -ProjectSchema.fitted = class Fitted extends Component { - -}; - -ProjectSchema.embedded = ProjectSchema.fitted; - -ProjectSchema.isolated = class Isolated extends Component< - typeof ProjectSchema -> { - -}; - -DarkFactorySchema.fitted = class Fitted extends Component< - typeof DarkFactorySchema -> { - -}; - -DarkFactorySchema.embedded = DarkFactorySchema.fitted; - -DarkFactorySchema.isolated = class Isolated extends Component< - typeof DarkFactorySchema -> { - -}; - -export { - AgentProfile, - KnowledgeArticle, - Ticket, - ProjectSchema as Project, - DarkFactorySchema as DarkFactory, -}; diff --git a/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory.gts b/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory.gts index e1b5868473a..2d57a4f6f78 100644 --- a/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory.gts +++ b/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory.gts @@ -1,8 +1,592 @@ -// Public barrel for the DarkFactory tracker types. -export { - AgentProfile, - KnowledgeArticle, - Ticket, - Project, - DarkFactory, -} from './darkfactory-ui'; +import { + CardDef, + Component, + field, + contains, + containsMany, + linksTo, + linksToMany, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import DateField from 'https://cardstack.com/base/date'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import enumField from 'https://cardstack.com/base/enum'; + +export const TicketStatusField = enumField(StringField, { + options: [ + { value: 'backlog', label: 'Backlog' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'blocked', label: 'Blocked' }, + { value: 'review', label: 'In Review' }, + { value: 'done', label: 'Done' }, + ], +}); + +export const TicketPriorityField = enumField(StringField, { + options: [ + { value: 'critical', label: 'Critical' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, + ], +}); + +export const TicketTypeField = enumField(StringField, { + options: [ + { value: 'feature', label: 'Feature' }, + { value: 'bug', label: 'Bug' }, + { value: 'task', label: 'Task' }, + { value: 'research', label: 'Research' }, + { value: 'infrastructure', label: 'Infrastructure' }, + ], +}); + +export const ProjectStatusField = enumField(StringField, { + options: [ + { value: 'planning', label: 'Planning' }, + { value: 'active', label: 'Active' }, + { value: 'on_hold', label: 'On Hold' }, + { value: 'completed', label: 'Completed' }, + { value: 'archived', label: 'Archived' }, + ], +}); + +export const KnowledgeTypeField = enumField(StringField, { + options: [ + { value: 'architecture', label: 'Architecture' }, + { value: 'decision', label: 'Decision (ADR)' }, + { value: 'runbook', label: 'Runbook' }, + { value: 'context', label: 'Context' }, + { value: 'api', label: 'API Reference' }, + { value: 'onboarding', label: 'Onboarding' }, + ], +}); + +export class AgentProfile extends CardDef { + static displayName = 'Agent Profile'; + + @field agentId = contains(StringField); + @field capabilities = containsMany(StringField); + @field specialization = contains(StringField); + @field notes = contains(MarkdownField); + + @field title = contains(StringField, { + computeVia: function (this: AgentProfile) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.agentId ?? 'Unnamed Agent'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class KnowledgeArticle extends CardDef { + static displayName = 'Knowledge Article'; + + @field articleTitle = contains(StringField); + @field articleType = contains(KnowledgeTypeField); + @field content = contains(MarkdownField); + @field tags = containsMany(StringField); + @field lastUpdatedBy = linksTo(() => AgentProfile); + @field updatedAt = contains(DateTimeField); + + @field title = contains(StringField, { + computeVia: function (this: KnowledgeArticle) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.articleTitle ?? 'Untitled Article'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class Ticket extends CardDef { + static displayName = 'Ticket'; + + @field ticketId = contains(StringField); + @field summary = contains(StringField); + @field description = contains(MarkdownField); + @field ticketType = contains(TicketTypeField); + @field status = contains(TicketStatusField); + @field priority = contains(TicketPriorityField); + @field project = linksTo(() => Project); + @field assignedAgent = linksTo(() => AgentProfile); + @field relatedTickets = linksToMany(() => Ticket); + @field relatedKnowledge = linksToMany(() => KnowledgeArticle); + @field acceptanceCriteria = contains(MarkdownField); + @field agentNotes = contains(MarkdownField); + @field estimatedHours = contains(NumberField); + @field actualHours = contains(NumberField); + @field createdAt = contains(DateTimeField); + @field updatedAt = contains(DateTimeField); + + @field title = contains(StringField, { + computeVia: function (this: Ticket) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.summary ?? 'Untitled Ticket'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class Project extends CardDef { + static displayName = 'Project'; + static prefersWideFormat = true; + + @field projectCode = contains(StringField); + @field projectName = contains(StringField); + @field projectStatus = contains(ProjectStatusField); + @field deadline = contains(DateField); + @field objective = contains(TextAreaField); + @field scope = contains(MarkdownField); + @field technicalContext = contains(MarkdownField); + @field tickets = linksToMany(() => Ticket, { + query: { + filter: { + on: { + // @ts-ignore this is not a CJS file, import.meta is allowed + module: new URL('./darkfactory', import.meta.url).href, + name: 'Ticket', + }, + eq: { 'project.id': '$this.id' }, + }, + }, + }); + @field knowledgeBase = linksToMany(() => KnowledgeArticle); + @field teamAgents = linksToMany(() => AgentProfile); + @field successCriteria = contains(MarkdownField); + @field risks = contains(MarkdownField); + @field createdAt = contains(DateTimeField); + + @field title = contains(StringField, { + computeVia: function (this: Project) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.projectName ?? 'Untitled Project'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} + +export class DarkFactory extends CardDef { + static displayName = 'Dark Factory'; + + @field factoryName = contains(StringField); + @field description = contains(MarkdownField); + @field activeProjects = linksToMany(() => Project); + + @field title = contains(StringField, { + computeVia: function (this: DarkFactory) { + return this.cardInfo.name?.trim()?.length + ? this.cardInfo.name + : (this.factoryName ?? 'Dark Factory'); + }, + }); + + static fitted = class Fitted extends Component { + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + + }; +} From 868bb429a3bb867b2ba6c33c9d00a3de9fd3f465 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Mar 2026 21:37:38 +0100 Subject: [PATCH 07/13] ci: Add workflow for weekday PR review summaries (#4226) --- .github/workflows/pr-review-reminder.yml | 115 +++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/pr-review-reminder.yml diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml new file mode 100644 index 00000000000..223b76aa829 --- /dev/null +++ b/.github/workflows/pr-review-reminder.yml @@ -0,0 +1,115 @@ +name: PR Review Reminder + +on: + schedule: + - cron: "0 13 * * 1-5" # 9 AM EDT (summer) + - cron: "0 14 * * 1-5" # 9 AM EST (winter) + workflow_dispatch: + +permissions: + pull-requests: read + +jobs: + notify: + name: Post awaiting-review PRs to Discord + runs-on: ubuntu-latest + steps: + - name: Gather PRs and notify Discord + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DISCORD_WEBHOOK: ${{ secrets.PR_REVIEW_DISCORD_WEBHOOK }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + if [ -z "$DISCORD_WEBHOOK" ]; then + echo "::error::PR_REVIEW_DISCORD_WEBHOOK secret is not set" + exit 1 + fi + + # Only run at 9 AM ET — skip the trigger that doesn't match current DST + current_et_hour="$(TZ=America/New_York date +%H)" + if [ "$current_et_hour" != "09" ]; then + echo "Current ET hour is $current_et_hour, not 9 AM. Skipping." + exit 0 + fi + + prs="$(gh api graphql -f query=' + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + number + title + url + isDraft + author { login } + reviewRequests(first: 100) { + nodes { + requestedReviewer { + ... on User { login } + ... on Team { name } + } + } + } + timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 1) { + nodes { + ... on ReviewRequestedEvent { + createdAt + } + } + } + } + } + } + } + ' -f owner="${REPO%/*}" -f repo="${REPO#*/}")" + + filtered="$(echo "$prs" | jq -c ' + [.data.repository.pullRequests.nodes[] + | select(.isDraft == false) + | select((.reviewRequests.nodes | length) > 0) + | { + number, + title, + url, + author: .author.login, + waitingSince: (.timelineItems.nodes[0].createdAt // "unknown"), + reviewers: [.reviewRequests.nodes[].requestedReviewer | (.login // .name // empty)] | map(select(. != "")) + } + ] | sort_by(.waitingSince)')" + + count="$(echo "$filtered" | jq 'length')" + + if [ "$count" -eq 0 ]; then + echo "No PRs awaiting review. Skipping Discord notification." + exit 0 + fi + + fields="$(echo "$filtered" | jq -c '[.[] | { + name: ("#\(.number) by \(.author) — waiting since \(.waitingSince | split("T")[0])"), + value: (["[\(.title)](\(.url))"] + (if (.reviewers | length) > 0 then ["Reviewers: \(.reviewers | join(", "))"] else [] end) | join("\n")), + inline: false + }]')" + + # Discord embeds support max 25 fields + if [ "$count" -gt 25 ]; then + extra=$((count - 24)) + fields="$(echo "$fields" | jq -c ".[:24] + [{\"name\": \"... and ${extra} more\", \"value\": \"Additional PRs not shown\", \"inline\": false}]")" + fi + + payload="$(jq -n --argjson fields "$fields" --arg count "$count" '{ + embeds: [{ + title: ("📋 " + $count + " PR(s) awaiting review"), + color: 16750848, + fields: $fields, + footer: { text: "Sorted by oldest review request first" } + }] + }')" + + curl -sSf -X POST \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "$DISCORD_WEBHOOK" + + echo "Posted $count PR(s) to Discord." From 745c2bf267e0f08277e34be156817c60854282d2 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sat, 21 Mar 2026 14:48:24 -0400 Subject: [PATCH 08/13] Fix flaky code patch acceptance test caused by Monaco diff race (#4225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix flaky "Accept All" code patch test caused by Monaco diff editor race During test teardown, Monaco's WorkerBasedDocumentDiffProvider.computeDiff can receive a null result from the editor worker when models are disposed mid-computation. This caused an unhandled "no diff result available" error that surfaced as a flaky QUnit global failure in CI. Extend the existing Monaco patch to return an empty diff result instead of throwing — matching the pattern Monaco already uses for disposed models. Co-Authored-By: Claude Opus 4.6 (1M context) * Narrow Monaco patch to only suppress error when models are disposed Address review feedback: instead of unconditionally returning an empty diff when the worker returns null, only suppress the error when the models are confirmed disposed (the teardown race). Genuine worker failures with live models still throw. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- patches/monaco-editor@0.52.2.patch | 22 ++++++++++++++++++++++ pnpm-lock.yaml | 14 +++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/patches/monaco-editor@0.52.2.patch b/patches/monaco-editor@0.52.2.patch index 39d2f403ce7..814f6ede732 100644 --- a/patches/monaco-editor@0.52.2.patch +++ b/patches/monaco-editor@0.52.2.patch @@ -19,3 +19,25 @@ index 5a5588e64135d87ac643aa75dd339f96ec3613a8..350ab291a325a21b69de9ab6e0be267f this.completionPromise = null; } } +diff --git a/esm/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.js b/esm/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.js +index ed54a358226406a60a407324d501f9d8c327d9ca..22c70310ab9b3d15ef2f82aef7acc9565222db53 100644 +--- a/esm/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.js ++++ b/esm/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.js +@@ -107,6 +107,17 @@ let WorkerBasedDocumentDiffProvider = class WorkerBasedDocumentDiffProvider { + }; + } + if (!result) { ++ // When models are disposed mid-computation the worker returns null. ++ // Only suppress the error in that case — a genuine worker failure with ++ // live models should still surface. ++ if (original.isDisposed() || modified.isDisposed()) { ++ return { ++ changes: [], ++ identical: true, ++ quitEarly: false, ++ moves: [], ++ }; ++ } + throw new Error('no diff result available'); + } + // max 10 items in cache diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd701f8cd39..c6d1e6a48a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,7 +688,7 @@ patchedDependencies: hash: 0472d34281d936a5dcdacff67d2851d88c1df9593cd06b7725ee8414c12aa1d5 path: patches/matrix-js-sdk@38.3.0.patch monaco-editor@0.52.2: - hash: a09898c89392828a4df910a8b4b952fd774a69b779906e9fa5742cb9c05c2ecf + hash: bdefe071221b87c7c15e1add6739d24d486f543dbad0d714ae586085204626cd path: patches/monaco-editor@0.52.2.patch openai: hash: 6833f1bcf56cde16edb25881777c31d2745994c6ea992d1e2051d1248d213449 @@ -2158,10 +2158,10 @@ importers: version: 1.2.0(moment@2.30.1)(webpack@5.104.1) monaco-editor: specifier: 'catalog:' - version: 0.52.2(patch_hash=a09898c89392828a4df910a8b4b952fd774a69b779906e9fa5742cb9c05c2ecf) + version: 0.52.2(patch_hash=bdefe071221b87c7c15e1add6739d24d486f543dbad0d714ae586085204626cd) monaco-editor-webpack-plugin: specifier: 'catalog:' - version: 7.1.1(monaco-editor@0.52.2(patch_hash=a09898c89392828a4df910a8b4b952fd774a69b779906e9fa5742cb9c05c2ecf))(webpack@5.104.1) + version: 7.1.1(monaco-editor@0.52.2(patch_hash=bdefe071221b87c7c15e1add6739d24d486f543dbad0d714ae586085204626cd))(webpack@5.104.1) ms: specifier: 'catalog:' version: 2.1.3 @@ -2851,7 +2851,7 @@ importers: version: 6.3.0 monaco-editor: specifier: 'catalog:' - version: 0.52.2(patch_hash=a09898c89392828a4df910a8b4b952fd774a69b779906e9fa5742cb9c05c2ecf) + version: 0.52.2(patch_hash=bdefe071221b87c7c15e1add6739d24d486f543dbad0d714ae586085204626cd) statuses: specifier: 'catalog:' version: 2.0.2 @@ -24413,13 +24413,13 @@ snapshots: moment@2.30.1: {} - monaco-editor-webpack-plugin@7.1.1(monaco-editor@0.52.2(patch_hash=a09898c89392828a4df910a8b4b952fd774a69b779906e9fa5742cb9c05c2ecf))(webpack@5.104.1): + monaco-editor-webpack-plugin@7.1.1(monaco-editor@0.52.2(patch_hash=bdefe071221b87c7c15e1add6739d24d486f543dbad0d714ae586085204626cd))(webpack@5.104.1): dependencies: loader-utils: 2.0.4 - monaco-editor: 0.52.2(patch_hash=a09898c89392828a4df910a8b4b952fd774a69b779906e9fa5742cb9c05c2ecf) + monaco-editor: 0.52.2(patch_hash=bdefe071221b87c7c15e1add6739d24d486f543dbad0d714ae586085204626cd) webpack: 5.104.1 - monaco-editor@0.52.2(patch_hash=a09898c89392828a4df910a8b4b952fd774a69b779906e9fa5742cb9c05c2ecf): {} + monaco-editor@0.52.2(patch_hash=bdefe071221b87c7c15e1add6739d24d486f543dbad0d714ae586085204626cd): {} morgan@1.10.1: dependencies: From 19ae87b267818f19b5cf85d654cab7c26d34fdb3 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sat, 21 Mar 2026 14:48:39 -0400 Subject: [PATCH 09/13] Move prerender reproduce URL to its own log category (#4224) Separate the "manually visit prerendered url" debug message into a dedicated `prerenderer-reproduce` log category so it can be enabled independently of the noisy `prerenderer` logs. Co-authored-by: Claude Opus 4.6 (1M context) --- packages/realm-server/prerender/render-runner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 9214bac5c4d..8f64c43cb20 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -37,6 +37,7 @@ import { import { randomUUID } from 'crypto'; const log = logger('prerenderer'); +const reproduceLog = logger('prerenderer-reproduce'); const commandRequestStorageKeyPrefix = 'boxel-command-request:'; const CLEAR_CACHE_RETRY_SIGNATURES: readonly (readonly string[])[] = [ @@ -197,7 +198,7 @@ export class RenderRunner { }; // please leave the auth token in the debug log so that we can debug timed out prerenders - log.debug( + reproduceLog.debug( `manually visit prerendered url ${url} at: ${this.#boxelHostURL}/render/${encodeURIComponent(url)}/${this.#nonce}/${optionsSegment}/html/isolated/0 with boxel-session = ${auth}`, ); From 7c726089ab21cb9fc55ae649073309eea1127c52 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sat, 21 Mar 2026 15:16:32 -0400 Subject: [PATCH 10/13] Optimize prerender perf: eliminate URL() construction in dependency tracking hot path (#4223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Optimize prerender performance: eliminate URL() construction in dependency tracking hot path Flame chart profiling revealed that 98% of active CPU per frame during card prerendering was spent in the runtime dependency tracking system, primarily constructing URL objects. The render produced 22 identical ~9-second long tasks (one per card deserialization), totaling ~200 seconds of blocked main thread for a card with 23 linksToMany relationships. Three optimizations applied: 1. trimModuleIdentifier (loader.ts): Replace `new URL(id).href` with string slice operations + a Map cache. Module identifiers are already full URL strings, so extension trimming only needs string ops. This was the single largest CPU consumer at 52.8% of active time (~5s per card). 2. collectKnownModuleDependencies (loader.ts): Cache the flattened dependency set per module identifier. Once a module is evaluated its consumedModules never change, so repeated graph walks for the same module return the cached result. This turns O(cards × modules) into O(modules). 3. trackRuntimeRelationshipModuleDependencies (card-api.gts): Track which modules have already had their full dep trees tracked and skip redundant getKnownConsumedModules() calls. This function was called on every linksTo field getter access during rendering, each time walking the full module dependency graph. Additionally, normalizeModuleURL/normalizeInstanceURL/canonicalURL in dependency-tracker.ts now use string operations instead of URL construction, eliminating another hot source of URL() calls in the tracking pipeline. Closes CS-10473 Co-Authored-By: Claude Opus 4.6 (1M context) * Fix cached Set mutation and remove session-scoping issue in dep tracking Address review feedback: - getKnownConsumedModules: filter instead of delete to avoid mutating the cached Set returned by collectKnownModuleDependencies - Remove trackedRelationshipModules skip cache from card-api.gts — it was process-global and not cleared between dependency tracking sessions, which could cause subsequent renders to under-report module deps. The Loader-level caching in collectKnownModuleDependencies already makes getKnownConsumedModules fast enough without a caller-side skip. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/base/card-api.gts | 4 ++ packages/runtime-common/dependency-tracker.ts | 47 +++++++++++------- packages/runtime-common/loader.ts | 49 +++++++++++++++++-- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 9a8696c74c4..a9878d7d903 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -3007,6 +3007,10 @@ function trackRuntimeRelationshipModuleDependencies( return; } + // getKnownConsumedModules is fast now: the Loader caches the dependency + // graph traversal result in collectKnownModuleDependencies, and + // trimModuleIdentifier uses string ops + a cache instead of URL + // construction. No need for a caller-side skip cache here. for (let dep of loader.getKnownConsumedModules(identity.module)) { trackRuntimeModuleDependency(dep, dependencyTrackingContext); } diff --git a/packages/runtime-common/dependency-tracker.ts b/packages/runtime-common/dependency-tracker.ts index 7c2abfe5dfa..6ab15061eec 100644 --- a/packages/runtime-common/dependency-tracker.ts +++ b/packages/runtime-common/dependency-tracker.ts @@ -1,5 +1,5 @@ import { logger } from './log'; -import { trimExecutableExtension } from './index'; +import { executableExtensions } from './index'; export type RuntimeDependencyNodeKind = 'module' | 'instance' | 'file'; export type RuntimeDependencyContextMode = 'query' | 'non-query'; @@ -47,23 +47,30 @@ interface ContextStackEntry { context: RuntimeDependencyTrackingContext; } +// String-based URL normalization to avoid expensive URL constructor calls. +// These are called on every dependency tracking operation (field getter access) +// so performance is critical. + function canonicalURL(url: string): string | undefined { - try { - let parsed = new URL(url); - parsed.search = ''; - parsed.hash = ''; - return parsed.href; - } catch (_err) { + if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) { return undefined; } + // Strip query string and hash using string ops instead of new URL() + let hashIdx = url.indexOf('#'); + if (hashIdx !== -1) { + url = url.slice(0, hashIdx); + } + let searchIdx = url.indexOf('?'); + if (searchIdx !== -1) { + url = url.slice(0, searchIdx); + } + return url; } -function hasPathExtension(pathname: string): boolean { - let segment = pathname.split('/').pop() ?? ''; - if (segment.length === 0) { - return false; - } - return segment.includes('.'); +function hasPathExtension(url: string): boolean { + let lastSlash = url.lastIndexOf('/'); + let segment = lastSlash !== -1 ? url.slice(lastSlash + 1) : url; + return segment.length > 0 && segment.includes('.'); } function normalizeModuleURL(url: string): string | undefined { @@ -71,7 +78,12 @@ function normalizeModuleURL(url: string): string | undefined { if (!canonical) { return undefined; } - return trimExecutableExtension(new URL(canonical)).href; + for (let ext of executableExtensions) { + if (canonical.endsWith(ext)) { + return canonical.slice(0, -ext.length); + } + } + return canonical; } function normalizeInstanceURL(url: string): string | undefined { @@ -79,11 +91,10 @@ function normalizeInstanceURL(url: string): string | undefined { if (!canonical) { return undefined; } - let parsed = new URL(canonical); - if (!hasPathExtension(parsed.pathname)) { - parsed.pathname = `${parsed.pathname}.json`; + if (!hasPathExtension(canonical)) { + return `${canonical}.json`; } - return parsed.href; + return canonical; } function normalizeFileURL(url: string): string | undefined { diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 2f73200628c..da6c65e3bdf 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -2,7 +2,7 @@ import TransformModulesAmdPlugin from 'transform-modules-amd-plugin'; import { transformAsync } from '@babel/core'; import { Deferred } from './deferred'; import { cachedFetch, type MaybeCachedResponse } from './cached-fetch'; -import { trimExecutableExtension, logger } from './index'; +import { executableExtensions, logger } from './index'; import { CardError } from './error'; import flatMap from 'lodash/flatMap'; @@ -103,6 +103,11 @@ export class Loader { private moduleShims = new Map>(); private moduleCanonicalURLs = new Map(); + // Cache the flattened dependency sets for evaluated modules. Once a module is + // evaluated its consumedModules never change, so the result of + // collectKnownModuleDependencies is stable and can be reused across repeated + // loader.import() calls (e.g. when deserializing 22 cards of the same type). + private knownDepsCache = new Map>(); private identities = new WeakMap< Function, { module: string; name: string } @@ -253,8 +258,10 @@ export class Loader { let knownDependencies = this.collectKnownModuleDependencies( resolvedModuleIdentifier, ); - knownDependencies.delete(resolvedModuleIdentifier); - return [...knownDependencies]; + // Filter rather than delete to avoid mutating the cached Set + return [...knownDependencies].filter( + (dep) => dep !== resolvedModuleIdentifier, + ); } private trackKnownModuleDependencies( @@ -276,6 +283,11 @@ export class Loader { private collectKnownModuleDependencies( rootModuleIdentifier: string, ): Set { + let cached = this.knownDepsCache.get(rootModuleIdentifier); + if (cached) { + return cached; + } + let pending = [rootModuleIdentifier]; let visited = new Set(); @@ -286,6 +298,16 @@ export class Loader { } visited.add(moduleIdentifier); + // If we already computed the full dep set for this subtree, merge it + // in and skip traversing its children. + let cachedSubtree = this.knownDepsCache.get(moduleIdentifier); + if (cachedSubtree) { + for (let dep of cachedSubtree) { + visited.add(dep); + } + continue; + } + let module = this.getModule(moduleIdentifier); if (!module) { continue; @@ -321,6 +343,7 @@ export class Loader { } } + this.knownDepsCache.set(rootModuleIdentifier, visited); return visited; } @@ -562,7 +585,7 @@ export class Loader { module: any, moduleIdentifier: string, ) { - let moduleId = trimExecutableExtension(new URL(moduleIdentifier)).href; + let moduleId = trimModuleIdentifier(moduleIdentifier); for (let propName of Object.keys(module)) { let exportedEntity = module[propName]; if ( @@ -850,8 +873,24 @@ function assertNever(value: never) { throw new Error(`should never happen ${value}`); } +// Cache and use string operations to avoid expensive URL construction on every +// getModule/setModule call. Module identifiers are always full URL strings so +// we only need to strip executable extensions from the end. +const trimCache = new Map(); function trimModuleIdentifier(moduleIdentifier: string): string { - return trimExecutableExtension(new URL(moduleIdentifier)).href; + let cached = trimCache.get(moduleIdentifier); + if (cached !== undefined) { + return cached; + } + let result = moduleIdentifier; + for (let ext of executableExtensions) { + if (moduleIdentifier.endsWith(ext)) { + result = moduleIdentifier.slice(0, -ext.length); + break; + } + } + trimCache.set(moduleIdentifier, result); + return result; } type ModuleState = Module['state']; From 7b1947e6a0cbe491f834a19421f07b776e0cba48 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sun, 22 Mar 2026 14:24:19 -0400 Subject: [PATCH 11/13] Software factory plan refinements (#4227) * Software factory plan refinements * updated with note of orchestrator vs agent calling boxel apis * more refinement * lint --- .../docs/one-shot-factory-go-plan.md | 1306 ++++++++++++++++- .../docs/software-factory-testing-strategy.md | 29 +- 2 files changed, 1292 insertions(+), 43 deletions(-) diff --git a/packages/software-factory/docs/one-shot-factory-go-plan.md b/packages/software-factory/docs/one-shot-factory-go-plan.md index 0241cb7fde5..e717727b840 100644 --- a/packages/software-factory/docs/one-shot-factory-go-plan.md +++ b/packages/software-factory/docs/one-shot-factory-go-plan.md @@ -24,19 +24,25 @@ This document covers: ## Realm Roles -The software factory uses three different realm roles that should stay distinct: +The software factory uses four different realm roles that should stay distinct: - source realm - `packages/software-factory/realm` - publishes shared modules, source cards, briefs, templates, and other driver content - target realm - the user-specified realm passed to `factory:go` - - receives the generated `Project`, `Ticket`, `KnowledgeArticle`, tests, and implementation artifacts + - receives the generated `Project`, `Ticket`, `KnowledgeArticle`, and implementation artifacts +- test realm + - a dedicated realm created by the factory alongside the target realm + - receives AI-generated test code, test fixtures, and test result artifacts + - the test harness executes tests from this realm against cards in the target realm + - test output saved here is fed back into the agentic loop as verification evidence + - naming convention: `-tests` (e.g., `personal-tests`) - fixture realm - - disposable test input used only for verification + - disposable test input used only for development-time verification of the factory itself - may adopt from the public source realm but should not be treated as user output -Normal factory output should land in the target realm, not in `packages/software-factory/realm`. +Normal factory output should land in the target realm, not in `packages/software-factory/realm`. AI-generated tests and their execution results should land in the test realm, keeping implementation artifacts and verification artifacts separated. If we intentionally include output-like examples in the source realm, they should be clearly labeled as examples and live in an obviously non-canonical location such as `SampleOutput/` or `Examples/`. @@ -128,11 +134,13 @@ Required behavior: - require `MATRIX_USERNAME` so the target realm owner is explicit before bootstrap starts - infer the target realm server URL from the target realm URL by default, but allow an explicit override when the realm server lives under a subdirectory and the URL shape is ambiguous - create missing target realms through the realm server `/_create-realm` API rather than by creating local directories directly -- treat the successful `/_create-realm` response as the readiness boundary for the new realm +- create the companion test realm (`-tests`) through the same `/_create-realm` API +- treat the successful `/_create-realm` responses for both realms as the readiness boundary Minimum requirement: - the target realm must be self-contained enough that `Project`, `Ticket`, and `KnowledgeArticle` cards resolve locally +- the test realm must be able to adopt from the target realm and execute tests against cards hosted there ### Phase 3: Bootstrap Project Artifacts @@ -160,29 +168,53 @@ Required behavior: 1. pick the active or next available ticket 2. inspect related project and knowledge cards 3. implement the ticket in the target realm -4. verify the result -5. update `agentNotes`, `updatedAt`, and `status` -6. create or update knowledge cards when meaningful decisions occur -7. continue until: - - the MVP is done - - a blocker requires user input - - verification cannot proceed +4. generate tests for the implemented work in the test realm +5. execute tests via the test harness against the target realm +6. save test results as artifacts in the test realm +7. if tests fail, feed test output back to the agent and return to step 3 +8. if tests pass, update `agentNotes`, `updatedAt`, and `status` +9. create or update knowledge cards when meaningful decisions occur +10. continue until: + - the MVP is done + - a blocker requires user input + - verification cannot proceed + +Test generation rule: + +- the agent must produce at least one test per ticket before a ticket can be marked as done +- tests live in the test realm, not the target realm, to keep implementation and verification separate +- test artifacts include both the test source code and the structured test execution results +- failed test output is the primary feedback signal that drives the implement-verify loop ### Phase 5: Verification -Default verification policy: +Verification is mandatory. Every ticket must have AI-generated tests before it can be marked done. -- if project tests already exist, use `test:realm` -- if no tests exist yet, create the smallest meaningful verification surface -- for early Boxel card work, successful rendering of a concrete instance in the host app is a valid first verification step +Test generation policy: -Implementation note: +- the agent creates test files in the test realm that exercise the cards and behavior implemented for the current ticket +- for Boxel card work, tests should at minimum verify that card instances render correctly in fitted, isolated, and embedded views +- additional tests should cover card-specific behavior, field values, relationships, and interactions +- the agent should start with the smallest meaningful test and expand coverage if the first test passes trivially + +Test execution policy: -- the Playwright harness in `packages/software-factory` can also be reused to generate and run automated card-rendering tests for artifacts created by the factory -- this is useful when the factory needs a real browser-level verification path for generated cards -- it is not necessarily the most efficient default for every ticket, so the first verification move should still prefer the smallest verification surface that proves the change +- the test harness runs tests from the test realm against the target realm +- test results (pass/fail, error messages, screenshots if applicable) are saved as structured artifacts in the test realm +- on failure, the full test output is fed back to the agent as context for the next implementation attempt +- on success, the test artifact serves as durable proof that the ticket was verified -The flow must not stall just because full test infrastructure does not yet exist. +Test realm artifact structure: + +- `TestSpec/.spec.ts` — the generated test source +- `TestResult/.json` — structured test execution output (status, duration, failures, stack traces) +- `TestResult/-screenshot.png` — optional visual evidence when Playwright captures are available + +Implementation note: + +- the Playwright harness in `packages/software-factory` is reused to execute AI-generated tests +- this gives the factory a real browser-level verification path for generated cards +- the test harness output format should match what the agent needs to diagnose failures and iterate ### Phase 6: Stop Conditions @@ -279,6 +311,8 @@ CLI parameters for the first version: - Optional. Explicit realm server URL for target-realm bootstrap when it should not be inferred from the target realm URL. - `--mode` - Optional. `bootstrap`, `implement`, or `resume`. Default should be `implement`. +- `--model` + - Optional. OpenRouter model ID (e.g., `anthropic/claude-sonnet-4.6`, `openai/gpt-4o`). Can also be set via `FACTORY_LLM_MODEL` environment variable. Falls back to the Boxel default coding model. - `--help` - Optional. Prints command usage and exits without running the flow. @@ -332,8 +366,9 @@ New helper module for target realm preparation. Responsibilities: - validate the explicit target realm URL -- create the realm through `POST /_create-realm` when needed -- return the target realm bootstrap result +- create the target realm through `POST /_create-realm` when needed +- create the companion test realm (`-tests`) through the same API +- return both the target realm and test realm bootstrap results This isolates the realm bootstrapping concern from the orchestration logic. @@ -366,9 +401,26 @@ Responsibilities: - if no active ticket, use the first eligible backlog ticket - gather related knowledge and project context - call the implementation backend -- update ticket state and notes after verification +- invoke test generation for the completed work +- run tests via the test harness and capture results +- feed test failures back to the agent for iteration +- update ticket state and notes after tests pass + +For the first version, this does not need to be a general autonomous system. It only needs to perform one ticket deeply and leave the realm in a coherent state. However, it must complete the full implement → generate tests → run tests → iterate cycle before marking a ticket done. -For the first version, this does not need to be a general autonomous system. It only needs to perform one ticket deeply and leave the realm in a coherent state. +### F. `scripts/lib/factory-test-realm.ts` + +New helper module for managing AI-generated tests in the test realm. + +Responsibilities: + +- create or update test spec files in the test realm (`TestSpec/.spec.ts`) +- invoke the Playwright test harness against the target realm using tests from the test realm +- capture structured test results (pass/fail, errors, durations, screenshots) +- save test result artifacts in the test realm (`TestResult/.json`) +- provide a structured summary of test results that can be fed back to the agent as context + +The test realm acts as durable verification evidence. Each ticket gets at least one test spec and one test result artifact. Failed test output is the primary feedback signal driving the implement-verify loop. ## Implementation Backend Choice @@ -408,24 +460,1173 @@ Recommendation: Start with Option 1. Build a one-shot orchestrator that prepares state and makes the next action deterministic for the agent. Do not try to encode general product implementation logic in plain scripts yet. +## Agent Interface + +The factory must be model-agnostic. The underlying LLM (Claude, GPT, Gemini, etc.) should be interchangeable without changing the orchestration logic. + +### Routing Layer + +The factory uses OpenRouter (`https://openrouter.ai/api/v1/chat/completions`) as its model routing layer. This is consistent with the existing Boxel host infrastructure, which already routes all LLM calls through OpenRouter via the realm server's `_request-forward` proxy. + +Model identifiers follow the OpenRouter format: `/` (e.g., `anthropic/claude-sonnet-4.6`, `openai/gpt-4o`, `google/gemini-2.5-pro`). + +### Configuration + +The factory accepts model configuration through: + +- `--model` CLI flag (e.g., `--model anthropic/claude-sonnet-4.6`) +- `FACTORY_LLM_MODEL` environment variable +- falls back to the Boxel default (`DEFAULT_CODING_LLM` from `runtime-common/matrix-constants.ts`) + +For the first version, a single model handles all factory tasks. Later versions may use different models for different tasks (e.g., a cheaper model for test generation, a stronger model for implementation). + +### `FactoryAgent` Interface + +The orchestration loop communicates with the LLM through a `FactoryAgent` interface. This interface defines the contract between the deterministic orchestration code and the nondeterministic AI backend. + +```typescript +interface FactoryAgentConfig { + model: string; // OpenRouter model ID + realmServerUrl: string; // for proxied API calls + authorization?: string; // realm server JWT +} + +interface AgentContext { + project: ProjectCard; // current project state + ticket: TicketCard; // active ticket + knowledge: KnowledgeArticle[]; // relevant knowledge cards + skills: ResolvedSkill[]; // active skills for this ticket (see Skills Integration) + tools: ToolManifest[]; // available tools for this ticket (see Tools Integration) + testResults?: TestResult; // previous test run output (if iterating) + targetRealmUrl: string; + testRealmUrl: string; +} + +interface ResolvedSkill { + name: string; // e.g., 'boxel-development' + content: string; // full markdown content of the skill + references?: string[]; // loaded reference file contents (for skills with references/) +} + +// Every AgentAction is a tool call. The `type` field selects which tool +// the orchestrator executes. High-level action types like `create_file` +// are convenience aliases that the orchestrator maps to the underlying +// realm-api tool calls (e.g., `create_file` → `realm-write`). +// The agent can also use `invoke_tool` directly for any registered tool. + +interface AgentAction { + type: + | 'create_file' // convenience: realm-write to target realm + | 'update_file' // convenience: realm-write to target realm + | 'create_test' // convenience: realm-write to test realm + | 'update_test' // convenience: realm-write to test realm + | 'update_ticket' // convenience: realm-write to update ticket card + | 'create_knowledge' // convenience: realm-write to create/update knowledge card + | 'invoke_tool' // invoke any registered tool directly + | 'request_clarification' // signal: stop and ask the user + | 'done'; // signal: ticket is complete + path?: string; // realm-relative path for file actions + content?: string; // file content or message + realm?: 'target' | 'test'; // which realm the action targets + tool?: string; // tool name for invoke_tool actions + toolArgs?: Record; // arguments for the tool +} + +interface FactoryAgent { + // Given context, produce the next set of actions for one implementation step. + // The orchestrator calls this in a loop until the agent returns a 'done' action + // or a 'request_clarification' action. + plan(context: AgentContext): Promise; +} +``` + +### How the Orchestrator Uses the Agent + +The execution loop in `factory-loop.ts` drives the agent: + +``` +1. orchestrator resolves skills for the current ticket (via SkillResolver) +2. orchestrator loads resolved skills from .agents/skills/ (via SkillLoader) +3. orchestrator builds tool manifest from registry (via ToolRegistry) +4. orchestrator assembles AgentContext from realm state + skills + tools +5. orchestrator calls agent.plan(context) +6. agent returns AgentAction[] — each action is a tool call the orchestrator executes +7. orchestrator validates each action against the tool registry and safety constraints +8. orchestrator executes actions via the appropriate ToolExecutor, captures ToolResults +9. orchestrator runs test harness against test realm +10. if tests fail: + a. orchestrator reads test results + b. orchestrator updates AgentContext with testResults + toolResults + c. go to step 5 +11. if tests pass: + a. orchestrator saves test results in test realm + b. orchestrator updates ticket status + c. orchestrator moves to next ticket (skills + tools re-resolved) +``` + +The agent never directly produces side effects. Every `AgentAction` — whether it creates a file, writes a test, searches a realm, or calls a management API — is a tool call that the orchestrator validates and executes on the agent's behalf. Tool calls are the mechanism by which the orchestrator owns all side effects while still letting the agent decide what operations to perform. + +### `FactoryAgent` Implementation + +The first implementation wraps OpenRouter's chat completions API: + +```typescript +class OpenRouterFactoryAgent implements FactoryAgent { + constructor(private config: FactoryAgentConfig) {} + + async plan(context: AgentContext): Promise { + const messages = this.buildMessages(context); + const response = await this.callOpenRouter(messages); + return this.parseActions(response); + } + + private async callOpenRouter(messages: Message[]): Promise { + // POST to https://openrouter.ai/api/v1/chat/completions + // via realm server _request-forward proxy + // model: this.config.model + // Returns structured JSON response with actions + } + + private buildMessages(context: AgentContext): Message[] { + // Assembles the full prompt from templates + context. + // See "Prompt Architecture" section below for the full structure. + } + + private parseActions(response: string): AgentAction[] { + // Parse and validate the structured JSON response + // Reject actions that violate constraints (e.g., writing outside allowed realms) + } +} +``` + +### Prompt Architecture + +The agent interface communicates with the LLM through a structured prompt assembled from templates and runtime context. This section specifies how prompts are built, what the LLM sees at each stage of the loop, and how the output format is enforced. + +#### Prompt Templates + +Prompts are assembled from Markdown template files stored in `packages/software-factory/prompts/`. Templates use simple `{{variable}}` interpolation — no template engine dependency. The orchestrator reads these files at startup and caches them. + +``` +packages/software-factory/prompts/ +├── system.md # role, rules, and output schema +├── ticket-implement.md # instructions for implementing a ticket +├── ticket-test.md # instructions for generating tests +├── ticket-iterate.md # instructions for fixing after test failure +├── action-schema.md # AgentAction[] JSON schema reference +└── examples/ + ├── create-card.md # example: creating a card definition + instance + ├── create-test.md # example: generating a test spec + └── iterate-fix.md # example: fixing code after test failure +``` + +Keeping prompts as standalone Markdown files (not embedded in TypeScript) means they can be iterated on without code changes, reviewed in PRs as prose, and tested with different models by swapping only the template text. + +#### Message Structure + +Each `plan()` call is a **one-shot LLM request**: one system message and one user message. The orchestrator assembles everything the agent needs into a single prompt. There is no multi-turn conversation — the agent is not having a dialogue with anyone. It receives a complete description of the current state and responds with actions. + +```typescript +[ + { role: 'system', content: systemPrompt }, + { role: 'user', content: ticketPrompt }, +]; +``` + +The **system prompt** is the same for every call within a ticket: role definition, output schema, skills, and tools. + +The **user prompt** changes depending on where the orchestrator is in the execution loop: + +- **First pass** — uses `ticket-implement.md`: project context, knowledge articles, ticket description, and instructions to implement + write tests. +- **Iteration pass** — uses `ticket-iterate.md`: everything from the first pass, plus the actions the agent already took, the test results from the last run, and instructions to fix what failed. + +Each call is self-contained. The orchestrator packs whatever history is relevant (previous actions, test output) into the single user message rather than replaying a growing conversation. + +#### System Prompt + +The system prompt is assembled once per ticket and stays constant across iterations. It defines who the agent is, what it can do, and how it must respond. + +Template: `prompts/system.md` + +```markdown +# Role + +You are a software factory agent. You implement Boxel cards and tests in +target realms based on ticket descriptions and project context. + +# Output Format + +You must respond with a JSON array of actions. Each action matches this schema: + +{{action-schema}} + +Respond with ONLY the JSON array. No prose, no explanation, no markdown fences +around the JSON. The orchestrator parses your response as JSON directly. + +# Rules + +- Every ticket must include at least one `create_test` or `update_test` action. +- Test specs go in the test realm. Implementation goes in the target realm. +- Use `invoke_tool` to search for existing cards, check realm state, or run + commands before creating files. Do not guess at existing state. +- If you cannot proceed, return a single `request_clarification` action + explaining what is blocked. +- When all work for the ticket is complete and tests are passing, return a + single `done` action. + +# Realms + +- Target realm: {{targetRealmUrl}} +- Test realm: {{testRealmUrl}} + +# Skills + +{{#each skills}} + +## Skill: {{name}} + +{{content}} + +{{#each references}} + +### Reference: {{referenceName}} + +{{referenceContent}} +{{/each}} +{{/each}} + +# Tools + +You may invoke any of the following tools by returning an `invoke_tool` action. + +{{#each tools}} + +## Tool: {{name}} + +{{description}} + +Category: {{category}} +Output format: {{outputFormat}} + +Arguments: +{{#each args}} + +- {{name}} ({{type}}, {{#if required}}required{{else}}optional{{/if}}): {{description}}{{#if default}} (default: {{default}}){{/if}} + {{/each}} + {{/each}} +``` + +The `{{action-schema}}` variable is replaced with the contents of `prompts/action-schema.md`, which contains the full JSON schema for `AgentAction[]`. This is the canonical reference the LLM uses to produce valid output. + +#### Ticket Implementation Prompt + +Sent as the first user message when beginning work on a ticket. + +Template: `prompts/ticket-implement.md` + +```markdown +# Project + +{{project.objective}} + +Success criteria: +{{#each project.successCriteria}} + +- {{this}} + {{/each}} + +# Knowledge + +{{#each knowledge}} + +## {{title}} + +{{content}} +{{/each}} + +# Current Ticket + +ID: {{ticket.id}} +Summary: {{ticket.summary}} +Status: {{ticket.status}} +Priority: {{ticket.priority}} + +Description: +{{ticket.description}} + +{{#if ticket.checklist}} +Checklist: +{{#each ticket.checklist}} + +- [ ] {{this}} + {{/each}} + {{/if}} + +# Instructions + +Implement this ticket. Return actions that: + +1. Create or update card definitions (.gts) and/or card instances (.json) in the target realm +2. Create test specs (.spec.ts) in the test realm that verify your implementation +3. Use `invoke_tool` actions to inspect existing realm state before creating files + +Start with the smallest working implementation, then add the test. +``` + +#### Test Generation Prompt + +When the orchestrator wants the agent to generate tests separately from implementation (e.g., if the first pass only produced implementation files and no tests), it sends this as a follow-up. + +Template: `prompts/ticket-test.md` + +```markdown +# Test Generation + +You implemented the following files for ticket {{ticket.id}}: + +{{#each implementedFiles}} + +## {{path}} ({{realm}} realm) +``` + +{{content}} + +``` +{{/each}} + +Now generate Playwright test specs that verify this implementation. + +Tests must: +- Live in the test realm as `TestSpec/{{ticket.slug}}.spec.ts` +- Import from the test fixtures and use the factory test harness +- Verify that card instances render correctly (fitted, isolated, embedded views) +- Verify card-specific behavior, field values, and relationships +- Be runnable by the `run-realm-tests` tool + +Return only `create_test` actions. +``` + +#### Test Iteration Prompt + +Sent as the user message after a test failure. This is a **self-contained one-shot prompt** — it includes everything the agent needs: the original ticket context, what was already tried, and the test results. The agent does not need to "remember" a prior conversation because all relevant history is in this single message. + +Template: `prompts/ticket-iterate.md` + +```markdown +# Project + +{{project.objective}} + +# Current Ticket + +ID: {{ticket.id}} +Summary: {{ticket.summary}} +Description: +{{ticket.description}} + +# Previous Attempt (iteration {{iteration}}) + +You previously produced the following actions for this ticket: + +{{#each previousActions}} + +## {{type}}: {{path}} ({{realm}} realm) +``` + +{{content}} + +``` +{{/each}} + +# Test Results + +The orchestrator applied your actions and ran tests. They failed. + +Status: {{testResults.status}} +Passed: {{testResults.passed}} +Failed: {{testResults.failed}} +Duration: {{testResults.durationMs}}ms + +{{#each testResults.failures}} +## Failure: {{testName}} + +``` + +{{error}} + +``` + +{{#if stackTrace}} +Stack trace: +``` + +{{stackTrace}} + +```` +{{/if}} +{{/each}} + +{{#if toolResults}} +# Tool Results From Previous Iteration + +{{#each toolResults}} +## {{tool}} (exit code: {{exitCode}}) + +```json +{{output}} +```` + +{{/each}} +{{/if}} + +# Instructions + +Fix the failing tests. You may: + +- Update implementation files (use `update_file` actions) +- Update test specs (use `update_test` actions) +- Invoke tools to inspect current realm state +- If the test expectation is wrong, fix the test +- If the implementation is wrong, fix the implementation + +Return the actions needed to make all tests pass. + +``` + +#### One-Shot Iteration Flow + +A single ticket may require multiple iterations. Each iteration is an independent one-shot call — the orchestrator packs everything into a single `[system, user]` message pair: + +``` + +Pass 1 (initial implementation): +system: [system prompt with skills, tools, schema] +user: [ticket-implement — project context, ticket description] +→ LLM responds: AgentAction[] — creates files + tests +→ orchestrator applies actions, runs tests, tests fail + +Pass 2 (first fix): +system: [same system prompt] +user: [ticket-iterate — ticket context + pass 1 actions + test failure output] +→ LLM responds: AgentAction[] — updates to fix failures +→ orchestrator applies actions, runs tests, tests fail again + +Pass 3 (second fix): +system: [same system prompt] +user: [ticket-iterate — ticket context + pass 2 actions + new test failure output] +→ LLM responds: AgentAction[] — further fixes +→ orchestrator applies actions, runs tests, tests pass → ticket done + +```` + +Each call is self-contained. The agent sees what it tried on the **previous** iteration (the actions and test results are in the user message), but it does not see the full history of all iterations. This keeps the prompt size bounded and each call independent. + +If the orchestrator needs to give the agent more history (e.g., "you've tried this three times and keep making the same mistake"), it can include a summary of prior attempts in the `ticket-iterate` prompt. But the default is: show only the most recent attempt and its results. + +#### Iteration Limits + +- `maxIterations` (default: 5) — maximum fix attempts before the orchestrator marks the ticket as blocked +- since each call is one-shot, there is no growing conversation to truncate — the prompt size is naturally bounded by the ticket context + one iteration's worth of actions and test results + +#### Output Parsing and Validation + +The agent must respond with a raw JSON array of `AgentAction` objects. The orchestrator parses the response with these rules: + +1. strip any markdown fences (` ```json ... ``` `) if present — some models add them despite instructions +2. parse the response as JSON +3. validate each action against the `AgentAction` schema: + - `type` must be a known action type + - file actions (`create_file`, `update_file`, `create_test`, `update_test`) must have `path`, `content`, and `realm` + - `invoke_tool` actions must have `tool` matching a registered manifest and valid `toolArgs` + - `realm` must be `'target'` or `'test'` — never anything else +4. reject the entire response if validation fails, log the raw response, and retry once with an error correction message: + +```markdown +Your previous response was not valid JSON or contained invalid actions. + +Parse error: {{parseError}} + +Please respond with ONLY a valid JSON array of AgentAction objects. +```` + +If the retry also fails, the orchestrator marks the ticket as blocked. + +#### Prompt File Location and Versioning + +All prompt templates live in `packages/software-factory/prompts/`. They are versioned alongside the code in git. This means: + +- prompt changes are reviewable in PRs +- prompts can be A/B tested by branching +- the `MockFactoryAgent` (for testing) can load the same templates to verify prompt assembly without calling an LLM + +The orchestrator loads templates via a `PromptLoader`: + +```typescript +interface PromptLoader { + // Load a prompt template by name and interpolate variables. + load(templateName: string, variables: Record): string; +} +``` + +The loader reads from `prompts/`, caches the raw templates, and performs `{{variable}}` interpolation at call time. For `{{#each}}` blocks, it uses a minimal mustache-like expansion — no full template engine, just enough to iterate over arrays. + +### Execution Log Persistence + +Each one-shot LLM call and its results are valuable artifacts — they are the audit trail, the debugging surface, and the resume state. Rather than treating them as ephemeral in-memory data, the factory persists them as DarkFactory cards in the target realm. + +#### `AgentExecutionLog` Card Type + +A new card type added to the DarkFactory schema (`darkfactory-schema.gts`): + +```typescript +class AgentExecutionLog extends CardDef { + @field logId = contains(StringField); // stable ID: -log- + @field ticket = linksTo(Ticket); // which ticket this log is for + @field model = contains(StringField); // OpenRouter model ID used + @field status = contains(ExecutionStatusField); // running, completed, failed, blocked + @field iterations = contains(MarkdownField); // serialized log of all one-shot calls (see below) + @field iterationCount = contains(NumberField); // number of plan() calls made + @field tokenUsage = contains(NumberField); // total tokens consumed across all calls + @field startedAt = contains(DateTimeField); + @field completedAt = contains(DateTimeField); + @field errorSummary = contains(StringField); // if failed/blocked, why +} + +// running → completed | failed | blocked +class ExecutionStatusField extends StringField { + // Enum: running, completed, failed, blocked +} +``` + +#### Why a Card, Not a File + +Persisting execution logs as DarkFactory cards (not raw JSON files) means: + +- they are **queryable** — the agent can search for past logs by ticket, status, or model +- they are **renderable** — the DarkFactory UI can display the execution history alongside tickets and projects +- they are **linkable** — tickets link to their execution logs +- they are **resumable** — if the factory restarts, it can load the log card and know what was already tried +- they follow the same realm API patterns as all other factory artifacts + +#### What Gets Persisted + +Each `AgentExecutionLog` card captures every one-shot call made for a ticket's implementation attempt. The `iterations` field is a structured Markdown document: + +```markdown +## Iteration 1 + +### Prompt + + + +### Response + + + +### Actions Applied + +- create_file: sticky-note.gts (target realm) +- create_test: TestSpec/define-sticky-note-core.spec.ts (test realm) + +### Test Results + +Status: failed +Passed: 0, Failed: 1 +Error: "Cannot find module './sticky-note'" + +--- + +## Iteration 2 + +### Prompt + + + +### Response + + + +### Actions Applied + +- update_file: sticky-note.gts (target realm) + +### Test Results + +Status: passed +Passed: 1, Failed: 0 +``` + +Each iteration records: what prompt was sent, what the LLM returned, what actions were applied, and what test results came back. This is a complete, self-contained log — since each call is one-shot, no "conversation" context is needed to make sense of individual entries. + +#### When the Log Is Written + +The orchestrator creates and updates `AgentExecutionLog` cards during the execution loop: + +1. **On ticket start** — create a new `AgentExecutionLog` card with `status: running`, linked to the active ticket +2. **After each one-shot call** — append the iteration (prompt, response, actions, test results) +3. **On ticket completion** — set `status: completed`, record `completedAt` +4. **On failure/block** — set `status: failed` or `blocked`, record `errorSummary` + +The card is written to the **target realm** (not the test realm), because it's a project artifact that tracks the implementation process — it belongs alongside the Project, Ticket, and KnowledgeArticle cards. + +#### Execution Logs and Resume + +When `factory:go --mode resume` runs: + +1. the orchestrator finds the active ticket +2. it searches for an existing `AgentExecutionLog` card linked to that ticket with `status: running` +3. if found, it reads the last iteration to know what was already tried and what failed +4. it assembles the next one-shot `ticket-iterate` prompt with that context +5. the execution loop continues from where it left off + +Since each call is one-shot, resume is straightforward — the orchestrator only needs the last iteration's actions and test results to construct the next prompt. There's no conversation state to reconstruct. + +#### Execution Logs as Agent Context + +Past logs are also useful as context for the agent. When starting work on a new ticket, the orchestrator can optionally include summaries of completed logs from related tickets in the `AgentContext.knowledge` field. This gives the agent awareness of what approaches worked (or didn't) on earlier tickets in the same project — without needing to replay any "conversation." + +#### Relationship to Existing DarkFactory Cards + +``` +Project +├── Ticket (linksToMany) +│ ├── AgentExecutionLog (linked via ticket field) +│ │ ├── iterations (one-shot call log) +│ │ │ ├── prompt sent +│ │ │ ├── actions returned +│ │ │ └── test results +│ │ └── status (running/completed/failed/blocked) +│ ├── relatedKnowledge (linksToMany → KnowledgeArticle) +│ └── assignedAgent (linksTo → AgentProfile) +└── knowledgeBase (linksToMany → KnowledgeArticle) +``` + +The `AgentExecutionLog` card fills the gap between the Ticket (what needs to be done) and the test results in the test realm (what was verified). It captures _how_ the agent got from one to the other — the full sequence of one-shot calls, actions, and results. + +### Swapping Models + +Because the agent interface is model-agnostic: + +- switching from Claude to GPT requires only changing the `--model` flag +- the system prompt and action schema stay the same +- the orchestrator behavior is identical regardless of model +- model-specific quirks (response format, token limits) are handled inside `OpenRouterFactoryAgent`, not in the orchestration loop +- prompt templates are designed to work across models — they use explicit JSON schema references rather than relying on model-specific features like tool-use APIs + +### Future: Multiple Agent Backends + +The `FactoryAgent` interface also supports non-OpenRouter backends: + +- a `ClaudeCodeFactoryAgent` that delegates to Claude Code's tool-use loop +- a `LocalModelFactoryAgent` for self-hosted models via Ollama or vLLM +- a `MockFactoryAgent` for deterministic testing (the fake executor from the testing strategy) + +The orchestrator does not care which backend is used. It only depends on the `FactoryAgent` interface. Each backend is responsible for translating the `AgentContext` into whatever prompt format its model expects — the prompt templates provide the canonical content, but a backend may restructure them (e.g., `ClaudeCodeFactoryAgent` might use native tool-use blocks instead of embedding tool manifests in the system prompt). + +### Skills Integration + +The factory has a library of skills in `.agents/skills/` that encode domain knowledge, best practices, and workflow patterns. These skills are the primary mechanism for giving the agent expertise about Boxel development, file structure conventions, Ember patterns, and factory operations. The agent interface must load and inject relevant skills into the LLM context so the agent produces correct, idiomatic output regardless of which model is used. + +#### Available Skills + +The factory currently has skills across several categories: + +Boxel development skills: + +- `boxel-development` — card definitions (`.gts`), card instances (`.json`), templates, styling, queries, commands. Includes a `references/` subdirectory with targeted guides for specific concerns (core concepts, template patterns, styling, theme design system, query systems, data management, etc.) +- `boxel-file-structure` — file naming conventions, module path rules, `adoptsFrom.module` resolution, `linksTo` vs `contains` distinction, JSON instance structure + +Boxel CLI operations skills: + +- `boxel-sync` — bidirectional sync strategies (`--prefer-local`, `--prefer-remote`, `--prefer-newest`) +- `boxel-track` — local file watching with automatic checkpoints +- `boxel-watch` — remote change monitoring +- `boxel-restore` — checkpoint restoration workflow +- `boxel-repair` — realm metadata and starter card repair +- `boxel-setup` — profile configuration and environment selection + +Factory workflow skills: + +- `software-factory-operations` — end-to-end delivery loop: search tickets, move to in_progress, implement, verify with Playwright, sync + +Framework skills: + +- `ember-best-practices` — 58 rules in 10 categories covering Ember.js performance, accessibility, and component patterns + +#### Skill File Format + +Each skill is a `SKILL.md` file with YAML frontmatter: + +```yaml +--- +name: boxel-development +description: For .gts card definitions, .json instances, templates, styling, queries, commands +--- +# Skill content (markdown) +... +``` + +Some skills have additional structure: + +- `references/` — subdirectory with targeted reference files loaded on demand (e.g., `boxel-development/references/dev-core-concept.md`) +- `rules/` — individual rule files with metadata (e.g., `ember-best-practices/rules/component-use-glimmer.md`) + +#### Skill Resolution + +The orchestrator resolves which skills to load based on the ticket's requirements. This happens in step 1 of the execution loop, when the orchestrator assembles `AgentContext`. + +Resolution rules: + +- `boxel-development` and `boxel-file-structure` are always loaded for tickets that involve creating or modifying card definitions or instances (the common case for factory work) +- `ember-best-practices` is loaded when the ticket involves `.gts` component code +- `software-factory-operations` is loaded for tickets that involve the factory's own delivery workflow +- CLI operation skills (`boxel-sync`, `boxel-track`, etc.) are loaded when the ticket involves realm synchronization or workspace management +- the project's `KnowledgeArticle` cards can specify additional skills to load via tags or explicit references + +For the first version, the orchestrator can use a simple tag-based matcher: + +```typescript +interface SkillResolver { + // Given a ticket and project context, return the list of skill names to load. + resolve(ticket: TicketCard, project: ProjectCard): string[]; +} +``` + +A default implementation loads `boxel-development` + `boxel-file-structure` for all Boxel card work, plus `ember-best-practices` when `.gts` files are involved. This covers the majority of factory tickets. + +#### Skill Loading + +The orchestrator reads skill files from disk at startup and caches them for the duration of the run: + +```typescript +interface SkillLoader { + // Load a skill by name from the .agents/skills/ directory. + // Returns the SKILL.md content plus any references/ files. + load(skillName: string): Promise; + + // Load all skills matching the resolved names. + loadAll(skillNames: string[]): Promise; +} +``` + +Loading behavior: + +- reads `SKILL.md` from the skill directory +- for skills with a `references/` subdirectory (like `boxel-development`), loads targeted reference files based on the ticket context rather than all references at once — this keeps the LLM context focused +- for skills with a `rules/` directory (like `ember-best-practices`), loads the compiled `AGENTS.md` rather than individual rule files +- skill content is included as-is in the agent's context — the markdown format is already designed to be LLM-readable + +#### How Skills Enter the LLM Context + +The `OpenRouterFactoryAgent.buildMessages()` method assembles the LLM prompt from the `AgentContext`. Skills are injected as part of the system message: + +``` +System prompt structure: +1. Role definition and output format (AgentAction[] schema) +2. Active skills (one section per resolved skill) +3. Project context (project card, knowledge articles) +4. Current ticket (description, acceptance criteria, checklist) +5. Previous test results (if iterating after failure) +``` + +Each skill becomes a labeled section in the system prompt: + +``` +## Skill: boxel-development + + + +### Reference: dev-core-concept + + + +## Skill: boxel-file-structure + + +``` + +This ensures the agent has the domain knowledge it needs to produce correct card definitions, follow naming conventions, and apply best practices — regardless of whether the underlying model is Claude, GPT, Gemini, or a local model. + +#### Skill Context Budget + +Skills can be large (e.g., `boxel-development` with all its references is substantial). The orchestrator should manage the skill context budget: + +- prioritize skills by relevance to the current ticket +- for skills with `references/`, load only the references relevant to the ticket (e.g., load `dev-styling-design.md` only if the ticket involves styling) +- if total skill content exceeds a configurable token budget, drop lower-priority skills and log a warning +- the `FactoryAgentConfig` should include an optional `maxSkillTokens` field + +```typescript +interface FactoryAgentConfig { + model: string; + realmServerUrl: string; + authorization?: string; + maxSkillTokens?: number; // optional cap on skill context size +} +``` + +#### Adding New Skills + +The skill system is file-based and extensible. To add a new skill: + +1. create a directory under `.agents/skills//` +2. add a `SKILL.md` with YAML frontmatter (`name`, `description`) and markdown content +3. optionally add `references/` for targeted sub-documents +4. update the skill resolver's tag mapping so the orchestrator knows when to load it + +No registration API, no manifest file — the skill directory structure is the registry. This keeps the system simple and lets skills be developed independently of the orchestration code. + +### Tools Integration + +Skills give the agent knowledge. Tools give the agent capabilities. The factory has two categories of tools that the agent can invoke through the orchestrator: **scripts** (standalone CLI tools in `packages/software-factory/scripts/`) and **boxel-cli commands** (the `boxel` CLI installed as a dependency). + +The agent does not execute tools directly. Instead, it returns `invoke_tool` actions that the orchestrator validates and executes on the agent's behalf, returning the tool output as context for the next `plan()` call. + +#### Tool Manifest + +Each tool is described by a manifest that the orchestrator includes in the `AgentContext`. The manifest tells the LLM what tools are available, what they do, and what arguments they accept. + +```typescript +interface ToolManifest { + name: string; // unique tool identifier + description: string; // what the tool does (LLM-readable) + category: 'script' | 'boxel-cli' | 'realm-api'; + args: ToolArg[]; // expected arguments + outputFormat: 'json' | 'text'; // what the tool returns +} + +interface ToolArg { + name: string; // argument name (e.g., 'realm', 'status') + description: string; // what the argument controls + required: boolean; + type: 'string' | 'number' | 'boolean'; + default?: string; // default value if not provided +} + +interface ToolResult { + tool: string; // tool name that was invoked + exitCode: number; // 0 = success + output: unknown; // parsed JSON or raw text + durationMs: number; +} +``` + +#### Available Script Tools + +These are the standalone scripts in `packages/software-factory/scripts/` that the agent can invoke. They all output structured JSON and use the shared auth library (`scripts/lib/boxel.ts`). + +##### `search-realm` + +Search for cards in a realm by type, field values, and sort criteria. + +- **Script**: `scripts/boxel-search.ts` +- **Args**: + - `--realm ` (required) — target realm URL + - `--type-name ` — filter by card type name + - `--type-module ` — filter by card type module + - `--eq field=value` (repeatable) — equality filters + - `--contains field=value` (repeatable) — contains filters + - `--sort field:direction` (repeatable) — sort criteria + - `--size ` — page size + - `--page ` — page number +- **Output**: JSON with search results (`data` array of card metadata) +- **Use by agent**: finding existing cards, checking for duplicates, querying project state + +##### `pick-ticket` + +Find tickets by status, priority, project, or assigned agent. + +- **Script**: `scripts/pick-ticket.ts` +- **Args**: + - `--realm ` (required) — target realm URL + - `--status ` — comma-separated status filter (default: `backlog,in_progress,review`) + - `--project ` — filter by project + - `--agent ` — filter by assigned agent + - `--module ` — ticket schema module URL +- **Output**: JSON with ticket count and compact ticket objects +- **Use by agent**: finding the next ticket to work on, checking ticket states + +##### `get-session` + +Generate authenticated browser session tokens for realm access. + +- **Script**: `scripts/boxel-session.ts` +- **Args**: + - `--realm ` (optional, repeatable) — specific realms to include +- **Output**: JSON with auth credentials and realm session tokens +- **Use by agent**: obtaining auth for realm API calls + +##### `run-realm-tests` + +Execute Playwright tests in an isolated scratch realm with fixture setup and teardown. + +- **Script**: `scripts/run-realm-tests.ts` +- **Args**: + - `--realm-path ` — source realm directory + - `--realm-url ` — source realm URL + - `--spec-dir ` — test directory (default: `tests`) + - `--fixtures-dir ` — fixtures directory (default: `tests/fixtures`) + - `--endpoint ` — realm endpoint name + - `--scratch-root ` — base dir for test realms +- **Output**: JSON with test stats (pass/fail counts, failures with details) +- **Use by agent**: running AI-generated tests, getting structured test failure output + +#### Available Boxel CLI Tools + +The `boxel` CLI (installed as a dependency, invoked via `npx boxel`) provides workspace management commands. These are relevant when the agent needs to interact with realms beyond simple HTTP API calls. + +##### `boxel sync` + +Bidirectional sync between local workspace and realm server. + +- **Command**: `npx boxel sync [--prefer-local|--prefer-remote|--prefer-newest] [--dry-run]` +- **Use by agent**: pushing implementation artifacts to the target realm, pulling current state + +##### `boxel push` + +One-way upload from local to realm. + +- **Command**: `npx boxel push [--delete] [--dry-run]` +- **Use by agent**: deploying generated files to target or test realm + +##### `boxel pull` + +One-way download from realm to local. + +- **Command**: `npx boxel pull [--delete] [--dry-run]` +- **Use by agent**: fetching current realm state before implementation + +##### `boxel status` + +Check sync status of a workspace. + +- **Command**: `npx boxel status [--all] [--pull]` +- **Use by agent**: verifying realm state before and after operations + +##### `boxel create` + +Create a new workspace/realm endpoint. + +- **Command**: `npx boxel create ` +- **Use by agent**: creating scratch realms for test execution + +##### `boxel history` + +View or create checkpoints. + +- **Command**: `npx boxel history [-m "message"]` +- **Use by agent**: creating checkpoints before destructive operations + +#### Available Realm Server APIs + +The realm server exposes HTTP endpoints that the agent can invoke directly through `invoke_tool` actions with `category: 'realm-api'`. Rather than hardcoding specific API calls in the orchestrator, the plan is to expose the full range of realm server capabilities as tools the agent can use. This means operations like realm creation, card CRUD, search, and batch mutations are all available to the agent — the orchestrator validates and executes them, but the agent decides when and how to use them. + +This is an important design principle: **any Boxel API call that the orchestrator might make on behalf of the agent should also be expressible as a tool the agent can invoke directly**. The orchestrator still owns safety constraints and execution, but the agent has the vocabulary to request any realm operation it needs. + +##### Card and File Operations + +###### `realm-read` + +Fetch a card or file from a realm. + +- **Endpoint**: `GET /` +- **Headers**: `Accept: application/vnd.card+source` or `application/vnd.api+json` +- **Use by agent**: reading existing card definitions, inspecting current state + +###### `realm-write` + +Create or update a card or file in a realm. + +- **Endpoint**: `POST /` +- **Headers**: `Content-Type: application/vnd.card+source` or `application/vnd.api+json` +- **Use by agent**: writing card definitions (`.gts`) and card instances (`.json`) + +###### `realm-delete` + +Delete a card or file from a realm. + +- **Endpoint**: `DELETE /` +- **Use by agent**: removing outdated artifacts + +###### `realm-atomic` + +Batch operations that succeed or fail atomically. + +- **Endpoint**: `POST /_atomic` +- **Body**: `{ "atomic:operations": [{ "op": "add"|"update"|"remove", "href": "...", "data": {...} }] }` +- **Use by agent**: creating multiple related files in a single transaction (e.g., card definition + instances) + +##### Query Operations + +###### `realm-search` + +Search for cards using structured queries. + +- **Endpoint**: `QUERY /_search` +- **Use by agent**: finding existing cards, checking for duplicates, querying project state + +###### `realm-mtimes` + +Get file modification times for a realm. + +- **Endpoint**: `GET /_mtimes` +- **Use by agent**: checking what files exist in a realm, detecting changes + +##### Realm Management Operations + +###### `realm-create` + +Create a new realm on the realm server. + +- **Endpoint**: `POST /_create-realm` +- **Auth**: realm server JWT (from `_server-session`) +- **Use by agent**: creating scratch realms for experimentation, creating additional test realms, bootstrapping new workspaces + +###### `realm-server-session` + +Obtain a realm server JWT for management operations. + +- **Endpoint**: `POST /_server-session` +- **Use by agent**: obtaining auth for realm management APIs that require server-level tokens + +###### `realm-reindex` + +Trigger a full reindex of a realm. + +- **Endpoint**: `POST /_reindex` +- **Use by agent**: forcing the realm server to re-process card definitions after updates + +##### Design Principle: APIs as Tools + +The boundary between "what the orchestrator does directly" and "what the agent invokes as a tool" is intentionally flexible. In Phase 2 (Target Realm Preparation), the orchestrator calls `/_create-realm` directly because realm creation is a deterministic prerequisite. But during Phase 4 (Execution Loop), the agent might invoke `realm-create` as a tool to spin up a scratch realm for an experiment, or `realm-atomic` to write multiple cards at once. + +The rule is: **if the orchestrator hardcodes an API call today, it should also be registered as a tool so the agent can invoke the same operation when the situation calls for it**. Over time, more of the deterministic orchestrator steps may migrate to being agent-driven, with the orchestrator only handling safety validation and execution. + +#### How the Orchestrator Exposes Tools to the Agent + +The orchestrator builds the `ToolManifest[]` list at startup and includes it in every `AgentContext`. The manifests are injected into the LLM prompt alongside skills: + +``` +System prompt structure: +1. Role definition and output format (AgentAction[] schema) +2. Active skills (domain knowledge) +3. Available tools (capability manifests with argument schemas) +4. Project context (project card, knowledge articles) +5. Current ticket (description, acceptance criteria, checklist) +6. Previous tool results / test results (if iterating) +``` + +Each tool manifest becomes a structured section: + +``` +## Tool: search-realm + +Search for cards in a realm by type, field values, and sort criteria. + +Category: script +Output: json + +Arguments: +- realm (string, required): target realm URL +- type-name (string, optional): filter by card type name +- eq (string, optional, repeatable): equality filter as "field=value" +- sort (string, optional, repeatable): sort as "field:direction" + +## Tool: boxel-sync + +Bidirectional sync between local workspace and realm server. + +Category: boxel-cli +Output: text + +Arguments: +- path (string, required): local workspace path +- prefer (string, optional): conflict strategy — "local", "remote", or "newest" +- dry-run (boolean, optional): preview only, no changes +``` + +#### How the Orchestrator Executes Tool Invocations + +When the agent returns an `invoke_tool` action, the orchestrator: + +1. validates the tool name against the registered manifest +2. validates the arguments against the manifest's arg schema +3. rejects tools or arguments that violate safety constraints (e.g., `--delete` on a non-scratch realm) +4. executes the tool as a subprocess (for scripts and CLI commands) or HTTP request (for realm APIs) +5. captures the output as a `ToolResult` +6. includes the `ToolResult` in the next `AgentContext` so the agent can use the output + +```typescript +interface ToolExecutor { + // Execute a validated tool invocation and return the result. + execute(action: AgentAction): Promise; +} + +class ScriptToolExecutor implements ToolExecutor { + // Runs: ts-node --transpileOnly scripts/