diff --git a/packages/boxel-ui/addon/src/components/tabbed-header/index.gts b/packages/boxel-ui/addon/src/components/tabbed-header/index.gts index c4ebb226897..332486c04f4 100644 --- a/packages/boxel-ui/addon/src/components/tabbed-header/index.gts +++ b/packages/boxel-ui/addon/src/components/tabbed-header/index.gts @@ -10,7 +10,7 @@ interface Signature { Args: { activeTabId?: string; headerBackgroundColor?: string; - headerTitle: string; + headerTitle?: string; setActiveTab: (tabId: string) => void; tabs: Array<{ displayName: string; @@ -31,17 +31,19 @@ export default class TabbedHeader extends Component {
-
- {{#if (has-block 'headerIcon')}} - {{yield to='headerIcon'}} - {{/if}} -

{{@headerTitle}}

-
+ {{#if @headerTitle}} +
+ {{#if (has-block 'headerIcon')}} + {{yield to='headerIcon'}} + {{/if}} +

{{@headerTitle}}

+
+ {{/if}}
+; + +export { EmptyState }; diff --git a/packages/software-factory/realm/overview.gts b/packages/software-factory/realm/overview.gts new file mode 100644 index 00000000000..472a2e681d0 --- /dev/null +++ b/packages/software-factory/realm/overview.gts @@ -0,0 +1,894 @@ +import GlimmerComponent from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; +import { get } from '@ember/helper'; + +import { + type CardContext, + type FieldsTypeFor, + type PartialBaseInstanceType, +} from 'https://cardstack.com/base/card-api'; + +import { + codeRef, + realmURL, + searchEntryWireQueryFromQuery, + type SearchEntryWireQuery, +} from '@cardstack/runtime-common'; + +import { ProgressBar } from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; + +import BookOpen from '@cardstack/boxel-icons/book-open'; +import CircleAlert from '@cardstack/boxel-icons/circle-alert'; +import CircleCheck from '@cardstack/boxel-icons/circle-check'; +import CircleDashed from '@cardstack/boxel-icons/circle-dashed'; +import CircleDot from '@cardstack/boxel-icons/circle-dot'; +import Rocket from '@cardstack/boxel-icons/rocket'; + +import { + findOptionColor, + issuePriorityOptions, + issueStatusOptions, + issueTypeOptions, +} from './kanban-config.gts'; +import { EmptyState } from './empty-state.gts'; +import { StatusPill } from './status-pill.gts'; +import type { RealmDashboard } from './realm-dashboard.gts'; + +// @ts-expect-error this is not a CJS file, import.meta is allowed +const importMetaUrl: string = import.meta.url; + +// The five validation-result card types the factory writes under +// `Validations/` after every agent turn. A `type` filter matches each +// card and its subclasses, so the tab surfaces the whole pipeline. +const VALIDATION_TYPES = [ + codeRef(importMetaUrl, './parse-result', 'ParseResult'), + codeRef(importMetaUrl, './lint-result', 'LintResult'), + codeRef(importMetaUrl, './eval-result', 'EvalResult'), + codeRef(importMetaUrl, './instantiate-result', 'InstantiateResult'), + codeRef(importMetaUrl, './test-results', 'TestRun'), +]; + +const KNOWLEDGE_TYPE = codeRef( + importMetaUrl, + './knowledge-article', + 'KnowledgeArticle', +); + +interface FunnelRow { + value: string; + label: string; + color: string | undefined; + count: number; +} + +interface Option { + value: string; + label: string; + color?: string; +} + +type SetupStatus = 'done' | 'active' | 'upcoming'; + +interface SetupStep { + label: string; + description: string; + status: SetupStatus; +} + +interface OverviewSignature { + Element: HTMLElement; + Args: { + model: PartialBaseInstanceType; + fields: FieldsTypeFor; + context?: CardContext; + }; +} + +// The realm-index Overview as a standalone component so the index card stays a +// thin tab shell. It reads everything off the index card's model and fields, +// which the caller forwards verbatim. +export class Overview extends GlimmerComponent { + @cached + get project() { + return this.args.model.board?.project; + } + + @cached + get issues() { + return this.project?.issues ?? []; + } + + get knowledge() { + return this.project?.knowledgeBase ?? []; + } + + get totalIssues(): number { + return this.issues.length; + } + + @cached + get doneIssues(): number { + return this.issues.filter((issue) => issue?.status === 'done').length; + } + + get progressPct(): number { + if (!this.totalIssues) { + return 0; + } + return Math.round((this.doneIssues / this.totalIssues) * 100); + } + + countFor(options: Option[], values: (string | undefined)[]): FunnelRow[] { + return options + .map((option) => ({ + value: option.value, + label: option.label, + color: findOptionColor(options, option.value), + count: values.filter((value) => value === option.value).length, + })) + .filter((row) => row.count > 0); + } + + @cached + get statusFunnel(): FunnelRow[] { + return this.countFor( + issueStatusOptions, + this.issues.map((issue) => issue?.status), + ); + } + + @cached + get priorityFunnel(): FunnelRow[] { + return this.countFor( + issuePriorityOptions, + this.issues.map((issue) => issue?.priority), + ); + } + + @cached + get typeFunnel(): FunnelRow[] { + return this.countFor( + issueTypeOptions, + this.issues.map((issue) => issue?.issueType), + ); + } + + // The filtered/sorted subsets are computed `linksToMany` fields on the card + // so the rows can render through `<@fields>`. These getters read those + // fields, keeping the model side index-aligned with the field side for the + // per-row decorations. + get blockedIssues() { + return this.args.model.blockedIssues ?? []; + } + + get recentIssues() { + return this.args.model.recentIssues ?? []; + } + + get projectObjective(): string | undefined { + return this.project?.objective; + } + + @cached + get validationRealms(): string[] { + let url = this.args.model[realmURL]; + return url ? [url.href] : []; + } + + // Validation results link *to* an issue, so to group them we run one + // query per issue. Each of the five result types has its own `issue` + // field, so the `issue.id` constraint is scoped per type via `on`. + validationQueryForIssue = ( + issueId: string | undefined, + ): SearchEntryWireQuery => { + return { + ...searchEntryWireQueryFromQuery({ + filter: { + any: VALIDATION_TYPES.map((ref) => ({ + on: ref, + eq: { 'issue.id': issueId ?? '' }, + })), + }, + }), + realms: this.validationRealms, + }; + }; + + // Knowledge articles live in the same realm as the index card. A nested + // `<@fields.board.project.knowledgeBase>` path can't render them — a + // `linksToMany` two `linksTo` hops down isn't carried into the field graph — + // so we surface them as fitted cards via the same search surface the + // validation widget uses. + get knowledgeQuery(): SearchEntryWireQuery { + return { + ...searchEntryWireQueryFromQuery({ + filter: { type: KNOWLEDGE_TYPE }, + }), + realms: this.validationRealms, + }; + } + + get realmName(): string | undefined { + return this.args.model.cardTitle; + } + + get setupTitle(): string { + return `${this.realmName ?? 'Your factory realm'} is getting set up`; + } + + // Bootstrap roadmap whose statuses are derived from live model state, so it + // ticks forward on its own as the realm re-indexes: a step is `done` once its + // signal is present, the first not-yet-done step is `active`, and the rest are + // `upcoming`. The linked fields these read are reactive, so the panel updates + // without any polling while the host keeps the card subscribed to its realm. + @cached + get setupSteps(): SetupStep[] { + let steps = [ + { + label: 'Realm created', + activeLabel: 'Creating realm…', + description: 'Your workspace is live and indexing.', + done: this.validationRealms.length > 0, + }, + { + label: 'Bootstrap project & board', + activeLabel: 'Bootstrapping project & board…', + description: + 'Reading the brief to set up the project and issue tracker.', + done: Boolean(this.project), + }, + { + label: 'Seed the knowledge base', + activeLabel: 'Seeding the knowledge base…', + description: this.knowledge.length + ? `${this.knowledge.length} knowledge ${ + this.knowledge.length === 1 ? 'article' : 'articles' + } captured.` + : 'Capturing architecture, decisions, and runbooks as articles.', + done: this.knowledge.length > 0, + }, + { + label: 'Generate the issue backlog', + activeLabel: 'Generating the issue backlog…', + description: + this.totalIssues > 1 + ? `${this.totalIssues} issues on the board.` + : 'Breaking the work into issues with priorities and dependencies.', + done: this.totalIssues > 1, + }, + ]; + let firstActive = steps.findIndex((step) => !step.done); + return steps.map(({ label, activeLabel, description, done }, index) => { + let status: SetupStatus = done + ? 'done' + : index === firstActive + ? 'active' + : 'upcoming'; + return { + label: status === 'active' ? activeLabel : label, + description, + status, + }; + }); + } + + // The run is finished setting up once every roadmap step is done; at that + // point the panel retires and the Overview shows just the live widgets. + get setupComplete(): boolean { + return this.setupSteps.every((step) => step.status === 'done'); + } + + statusColor = (status: string | undefined): string | undefined => { + return findOptionColor(issueStatusOptions, status); + }; + + statusLabel = (status: string | undefined): string => { + return ( + issueStatusOptions.find((option) => option.value === status)?.label ?? + status ?? + '—' + ); + }; + + +} diff --git a/packages/software-factory/realm/realm-dashboard.gts b/packages/software-factory/realm/realm-dashboard.gts new file mode 100644 index 00000000000..91b8987e776 --- /dev/null +++ b/packages/software-factory/realm/realm-dashboard.gts @@ -0,0 +1,246 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { tracked } from '@glimmer/tracking'; + +import { + CardDef, + Component, + type CardOrFieldTypeIcon, + field, + contains, + linksTo, + linksToMany, + StringField, + realmInfo, +} from 'https://cardstack.com/base/card-api'; +import { CardsGrid } from 'https://cardstack.com/base/cards-grid'; +import setBackgroundImage from 'https://cardstack.com/base/helpers/set-background-image'; + +import { TabbedHeader } from '@cardstack/boxel-ui/components'; +import { cn, eq, not } from '@cardstack/boxel-ui/helpers'; + +import LayoutGridPlus from '@cardstack/boxel-icons/layout-grid-plus'; +import SquareKanban from '@cardstack/boxel-icons/square-kanban'; +import SquareStack from '@cardstack/boxel-icons/square-stack'; + +import { Overview } from './overview.gts'; +import { EmptyState } from './empty-state.gts'; +import { Issue, IssueTracker, Project } from './issue-tracker.gts'; + +const TABS = [ + { displayName: 'Overview', tabId: 'overview' }, + { displayName: 'Board', tabId: 'board' }, + { displayName: 'Artifacts', tabId: 'artifacts' }, +]; + +interface TabEmptyStateSignature { + Element: HTMLDivElement; + Args: { + icon: CardOrFieldTypeIcon; + title: string; + // The lede paragraph; the "watch setup progress" hint is shared. + lede: string; + }; +} + +// Shown on a tab before the factory's bootstrap populates it, so the realm +// doesn't open onto a blank panel. Owns the scrollable shell and the spacing +// that keeps the hero aligned with the Overview tab — defined once here so the +// Board and Artifacts tabs can't drift apart. +const TabEmptyState: TemplateOnlyComponent = ; + +class RealmDashboardIsolated extends Component { + @tracked activeTabId = 'overview'; + + setActiveTab = (tabId: string): void => { + this.activeTabId = tabId; + }; + + +} + +// Index page for the new realm created for a factory run +export class RealmDashboard extends CardDef { + static icon = LayoutGridPlus; + static displayName = 'Overview'; + static prefersWideFormat = true; + + @field cardTitle = contains(StringField, { + computeVia: function (this: RealmDashboard) { + return this.cardInfo.name ?? this[realmInfo]?.name; + }, + }); + @field cardsGrid = linksTo(() => CardsGrid); + @field board = linksTo(() => IssueTracker); + + // The board's project, surfaced as a direct computed link so the Overview can + // render it as a clickable card atom via `<@fields.project>`. The nested + // `<@fields.board.project>` path can't render it — a `linksTo` two hops down + // isn't carried into the field graph — but reading it here (through the model) + // loads the instance onto this card's own field. + @field project = linksTo(() => Project, { + computeVia: function (this: RealmDashboard) { + return this.board?.project; + }, + }); + + // Filtered/sorted projections of the board's issues, materialized as computed + // links so the Overview widgets can render them through `<@fields>`. Reading + // the instances here (via the model) loads them, which the nested + // `<@fields.board.project.issues>` path can't do on its own. + @field blockedIssues = linksToMany(() => Issue, { + computeVia: function (this: RealmDashboard) { + let issues = this.board?.project?.issues ?? []; + return issues.filter( + (issue) => + issue?.status === 'blocked' || (issue?.blockedBy?.length ?? 0) > 0, + ); + }, + }); + + @field recentIssues = linksToMany(() => Issue, { + computeVia: function (this: RealmDashboard) { + let issues = this.board?.project?.issues ?? []; + return [...issues] + .sort((a, b) => { + let aTime = a?.updatedAt ? new Date(a.updatedAt).getTime() : 0; + let bTime = b?.updatedAt ? new Date(b.updatedAt).getTime() : 0; + return bTime - aTime; + }) + .slice(0, 8); + }, + }); + + static isolated = RealmDashboardIsolated; +} diff --git a/packages/software-factory/realm/realm-dashboard.test.gts b/packages/software-factory/realm/realm-dashboard.test.gts new file mode 100644 index 00000000000..e0880832549 --- /dev/null +++ b/packages/software-factory/realm/realm-dashboard.test.gts @@ -0,0 +1,528 @@ +import { module, test } from 'qunit'; +import { click, waitFor } from '@ember/test-helpers'; + +import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + visitOperatorMode, +} from '@cardstack/host/tests/helpers'; +import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; + +// @ts-expect-error import.meta is ESM, not CJS +const importMetaUrl = import.meta.url; +const realmDashboardModule: string = new URL('./realm-dashboard', importMetaUrl) + .href; +const issueTrackerModule: string = new URL('./issue-tracker', importMetaUrl) + .href; + +const projectId = `${testRealmURL}Projects/test-project`; +const boardId = `${testRealmURL}Boards/test-board`; +const cardsGridId = `${testRealmURL}cards-grid`; +const overviewId = `${testRealmURL}overview`; + +function makeProject( + projectStatus?: string, +): Record> { + return { + 'Projects/test-project.json': { + data: { + type: 'card', + attributes: { + projectCode: 'PF', + projectName: 'Platform Factory', + ...(projectStatus !== undefined ? { projectStatus } : {}), + }, + meta: { adoptsFrom: { module: issueTrackerModule, name: 'Project' } }, + }, + }, + }; +} + +function makeBoard(): Record> { + return { + 'Boards/test-board.json': { + data: { + type: 'card', + attributes: { boardTitle: 'Test Board' }, + relationships: { project: { links: { self: projectId } } }, + meta: { + adoptsFrom: { module: issueTrackerModule, name: 'IssueTracker' }, + }, + }, + }, + }; +} + +function makeIssue( + issueId: string, + attrs: Record, + filename: string, + // Extra relationships (e.g. blocked-by links) merged onto the issue. + extraRelationships: Record = {}, +): Record> { + return { + [filename]: { + data: { + type: 'card', + attributes: { issueId, summary: `${issueId} issue`, ...attrs }, + relationships: { + project: { links: { self: projectId } }, + ...extraRelationships, + }, + meta: { adoptsFrom: { module: issueTrackerModule, name: 'Issue' } }, + }, + }, + }; +} + +const knowledgeArticleModule = issueTrackerModule.replace( + 'issue-tracker', + 'knowledge-article', +); + +function makeKnowledgeArticle( + filename: string, +): Record> { + return { + [filename]: { + data: { + type: 'card', + attributes: { articleTitle: 'Architecture', content: 'Notes.' }, + meta: { + adoptsFrom: { + module: knowledgeArticleModule, + name: 'KnowledgeArticle', + }, + }, + }, + }, + }; +} + +// A Project that links one knowledge article into its knowledgeBase, used to +// drive the "Seed the knowledge base" setup step to done. +function makeProjectWithKnowledge( + projectStatus: string, + knowledgeArticleId: string, +): Record> { + return { + 'Projects/test-project.json': { + data: { + type: 'card', + attributes: { + projectCode: 'PF', + projectName: 'Platform Factory', + projectStatus, + }, + relationships: { + 'knowledgeBase.0': { links: { self: knowledgeArticleId } }, + }, + meta: { adoptsFrom: { module: issueTrackerModule, name: 'Project' } }, + }, + }, + }; +} + +function makeCardsGrid(): Record> { + return { + 'cards-grid.json': { + data: { + type: 'card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/cards-grid', + name: 'CardsGrid', + }, + }, + }, + }, + }; +} + +// The realm-index card under test. Pass `board`/`cardsGrid: false` to omit the +// link and exercise the empty states. +function makeRealmIndex( + opts: { board?: boolean; cardsGrid?: boolean } = {}, +): Record> { + let { board = true, cardsGrid = true } = opts; + return { + 'overview.json': { + data: { + type: 'card', + attributes: {}, + relationships: { + ...(board ? { board: { links: { self: boardId } } } : {}), + ...(cardsGrid ? { cardsGrid: { links: { self: cardsGridId } } } : {}), + }, + meta: { + adoptsFrom: { + module: realmDashboardModule, + name: 'RealmDashboard', + }, + }, + }, + }, + }; +} + +export function runTests() { + module('Target Realm Index', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + }); + + // ── Populated overview ──────────────────────────────────────────────────── + // One realm with a representative issue set drives the KPI, Needs-Attention, + // and funnel assertions so they share a single (expensive) realm setup. + // PF-1 backlog / high / feature + // PF-2 in_progress / high / bug + // PF-3 done / low / feature + // PF-4 blocked + // PF-5 backlog, blocked by PF-4 (dependency-blocked, not status-blocked) + module('populated overview', function (hooks) { + hooks.beforeEach(async function () { + await setupAcceptanceTestRealm({ + realmURL: testRealmURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeProject('active'), + ...makeBoard(), + ...makeCardsGrid(), + ...makeIssue( + 'PF-1', + { status: 'backlog', priority: 'high', issueType: 'feature' }, + 'Issues/issue-1.json', + ), + ...makeIssue( + 'PF-2', + { status: 'in_progress', priority: 'high', issueType: 'bug' }, + 'Issues/issue-2.json', + ), + ...makeIssue( + 'PF-3', + { status: 'done', priority: 'low', issueType: 'feature' }, + 'Issues/issue-3.json', + ), + ...makeIssue('PF-4', { status: 'blocked' }, 'Issues/issue-4.json'), + ...makeIssue('PF-5', { status: 'backlog' }, 'Issues/issue-5.json', { + 'blockedBy.0': { + links: { self: `${testRealmURL}Issues/issue-4` }, + }, + }), + ...makeRealmIndex(), + }, + }); + }); + + // Regression test for the Status KPI bug: the status was read through a + // nested two-hop `linksTo` field path that never resolved, so it always + // fell through to the hardcoded "Planning". It must now show the real + // project status sourced through the loaded `project` link. + test('Status KPI shows the real project status, not the "Planning" fallback', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: overviewId, format: 'isolated' }]], + }); + await waitFor('[data-test-status-kpi]'); + + assert + .dom('[data-test-status-kpi]') + .hasText('active', 'Status KPI reflects the linked project status'); + assert + .dom('[data-test-status-kpi]') + .doesNotContainText( + 'Planning', + 'does not fall through to the hardcoded placeholder', + ); + }); + + // KPIs, the Needs-Attention list, and the funnels all read the same + // rendered overview, so they share one realm setup. + test('overview widgets reflect the linked issues (KPIs, Needs Attention, funnels)', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: overviewId, format: 'isolated' }]], + }); + await waitFor('[data-test-issues-kpi]'); + + assert + .dom('[data-test-issues-kpi]') + .hasText('5', 'all five linked issues are counted'); + assert + .dom('[data-test-done-kpi]') + .hasText('1', 'one done issue is counted'); + assert + .dom('[data-test-blocked-kpi]') + .hasText('2', 'status-blocked and dependency-blocked issues counted'); + + // blockedIssues filters on status === 'blocked' OR blockedBy.length > 0. + assert + .dom('[data-test-blocked-issue]') + .exists({ count: 2 }, 'two issues appear in Needs Attention'); + assert + .dom('[data-test-blocked-issue="PF-4"]') + .exists('blocked-by-status issue is listed'); + assert + .dom('[data-test-blocked-issue="PF-5"]') + .exists('issue with a blockedBy dependency is listed'); + assert + .dom('[data-test-blocked-issue="PF-2"]') + .doesNotExist('unblocked issue is excluded'); + + // Funnels: count each bucket and omit buckets with zero issues. + assert + .dom( + '[data-test-status-funnel] [data-test-funnel-row="backlog"] [data-test-funnel-count]', + ) + .hasText('2', 'two backlog issues counted'); + assert + .dom( + '[data-test-status-funnel] [data-test-funnel-row="done"] [data-test-funnel-count]', + ) + .hasText('1', 'one done issue counted'); + assert + .dom('[data-test-status-funnel] [data-test-funnel-row="review"]') + .doesNotExist('statuses with zero issues are omitted (count > 0)'); + + assert + .dom( + '[data-test-priority-funnel] [data-test-funnel-row="high"] [data-test-funnel-count]', + ) + .hasText('2', 'two high-priority issues counted'); + assert + .dom( + '[data-test-type-funnel] [data-test-funnel-row="feature"] [data-test-funnel-count]', + ) + .hasText('2', 'two feature issues counted'); + assert + .dom( + '[data-test-type-funnel] [data-test-funnel-row="bug"] [data-test-funnel-count]', + ) + .hasText('1', 'one bug issue counted'); + }); + }); + + // ── Before bootstrap (no board / no cards grid) ─────────────────────────── + // The setup roadmap and both tab empty states share the same bare realm, so + // a single test exercises all three against one setup. + module('before bootstrap', function (hooks) { + hooks.beforeEach(async function () { + await setupAcceptanceTestRealm({ + realmURL: testRealmURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeRealmIndex({ board: false, cardsGrid: false }), + }, + }); + }); + + test('Overview shows the roadmap (no KPIs); Board and Artifacts tabs show empty states', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: overviewId, format: 'isolated' }]], + }); + await waitFor('[data-test-setup-steps]'); + + assert + .dom('[data-test-setup-steps]') + .exists('the setup roadmap is shown while no project exists'); + assert + .dom('[data-test-status-kpi]') + .doesNotExist('KPIs are hidden until the project is bootstrapped'); + assert + .dom('[data-test-setup-step="active"]') + .exists('one step is marked active'); + + await click('[data-test-tab-label="Board"]'); + await waitFor('[data-test-board-empty]'); + assert + .dom('[data-test-board-empty]') + .containsText('No board yet', 'board empty state is shown'); + + await click('[data-test-tab-label="Artifacts"]'); + await waitFor('[data-test-artifacts-empty]'); + assert + .dom('[data-test-artifacts-empty]') + .containsText('No artifacts yet', 'artifacts empty state is shown'); + }); + }); + + // ── Recent activity (recentIssues sort + cap) ───────────────────────────── + module('recent activity', function (hooks) { + hooks.beforeEach(async function () { + // Eight issues with descending updatedAt, plus one with no updatedAt. + // recentIssues sorts by updatedAt desc and caps at 8 — the undated + // issue falls back to time 0, sorts last, and is dropped by the cap. + let issues = {}; + for (let day = 1; day <= 8; day++) { + let id = `PF-${String(day).padStart(2, '0')}`; + Object.assign( + issues, + makeIssue( + id, + { + status: 'in_progress', + updatedAt: `2026-02-${String(day).padStart(2, '0')}T00:00:00.000Z`, + }, + `Issues/issue-${day}.json`, + ), + ); + } + Object.assign( + issues, + makeIssue( + 'PF-UNDATED', + { status: 'backlog' }, + 'Issues/issue-undated.json', + ), + ); + + await setupAcceptanceTestRealm({ + realmURL: testRealmURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeProject('active'), + ...makeBoard(), + ...makeCardsGrid(), + ...issues, + ...makeRealmIndex(), + }, + }); + }); + + test('Recent Activity lists the 8 newest issues, newest first, dropping the undated one', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: overviewId, format: 'isolated' }]], + }); + await waitFor('[data-test-recent-list]'); + + assert + .dom('[data-test-recent-issue]') + .exists({ count: 8 }, 'recentIssues is capped at 8 rows'); + + let renderedOrder = [ + ...document.querySelectorAll('[data-test-recent-issue]'), + ].map((el) => el.getAttribute('data-test-recent-issue')); + assert.deepEqual( + renderedOrder, + [ + 'PF-08', + 'PF-07', + 'PF-06', + 'PF-05', + 'PF-04', + 'PF-03', + 'PF-02', + 'PF-01', + ], + 'issues are ordered by updatedAt descending', + ); + + assert + .dom('[data-test-recent-issue="PF-UNDATED"]') + .doesNotExist( + 'the issue with no updatedAt sorts last and is dropped by the cap', + ); + }); + }); + + // ── Setup roadmap state machine ─────────────────────────────────────────── + module('setup roadmap', function () { + // Project exists but the knowledge base and backlog are still empty, so + // the roadmap should read: realm + project done, knowledge active, backlog + // upcoming. + module('mid-bootstrap', function (hooks) { + hooks.beforeEach(async function () { + await setupAcceptanceTestRealm({ + realmURL: testRealmURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeProject('active'), + ...makeBoard(), + ...makeRealmIndex({ cardsGrid: false }), + }, + }); + }); + + test('derives done/active/upcoming from live model state', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: overviewId, format: 'isolated' }]], + }); + await waitFor('[data-test-setup-steps]'); + + assert + .dom('[data-test-setup-step="done"]') + .exists( + { count: 2 }, + 'realm-created and bootstrap-project steps are done', + ); + assert + .dom('[data-test-setup-step="active"]') + .exists( + { count: 1 }, + 'exactly one step is active (seed knowledge)', + ); + assert + .dom('[data-test-setup-step="upcoming"]') + .exists({ count: 1 }, 'the backlog step is still upcoming'); + }); + }); + + // Project, knowledge, and issues all exist — every step is done, so the + // whole setup panel retires even though not all issues are done. This + // pins that setup is considered complete once the backlog is generated, + // not once every issue is done. + module('complete once backlog exists', function (hooks) { + hooks.beforeEach(async function () { + await setupAcceptanceTestRealm({ + realmURL: testRealmURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeProjectWithKnowledge( + 'active', + `${testRealmURL}Knowledge/architecture`, + ), + ...makeKnowledgeArticle('Knowledge/architecture.json'), + ...makeBoard(), + ...makeCardsGrid(), + // Backlog generated but NOT all done (one open, one done). + ...makeIssue( + 'PF-1', + { status: 'backlog' }, + 'Issues/issue-1.json', + ), + ...makeIssue('PF-2', { status: 'done' }, 'Issues/issue-2.json'), + ...makeRealmIndex(), + }, + }); + }); + + test('setup panel retires once the backlog is generated, even with open issues', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: overviewId, format: 'isolated' }]], + }); + await waitFor('[data-test-overview]'); + await waitFor('[data-test-status-kpi]'); + + assert + .dom('[data-test-setup-steps]') + .doesNotExist('the setup roadmap is gone once every step is done'); + assert + .dom('[data-test-issues-kpi]') + .hasText('2', 'KPIs render in the live overview'); + }); + }); + }); + }); +} diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index e12b30a5a1d..1f613ef09ea 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -2,6 +2,11 @@ import { parseArgs as parseNodeArgs } from 'node:util'; import { BoxelCLIClient } from '@cardstack/boxel-cli/api'; +import { + linkBoardToRealmIndex, + writeRealmDashboardCard, + type LinkBoardToRealmIndexOptions, +} from './factory-realm-index.ts'; import { inferDarkfactoryModuleUrl } from './factory-seed.ts'; import { parseAgentFlag, @@ -14,7 +19,12 @@ import { runFactoryIssueLoop, type IssueLoopWiringConfig, } from './factory-issue-loop-wiring.ts'; -import { createSeedIssue, type SeedIssueResult } from './factory-seed.ts'; +import { + createSeedIssue, + linkProjectToSeedIssue, + type LinkProjectToSeedIssueOptions, + type SeedIssueResult, +} from './factory-seed.ts'; import { bootstrapFactoryTargetRealm, resolveFactoryTargetRealm, @@ -33,6 +43,11 @@ import { let log = logger('factory-entrypoint'); +// Retries the post-loop backstop spends polling the realm index for the +// bootstrap board/Project before giving up. At the default ~1s delay this +// covers a few seconds of indexer lag after a fire-and-forget board sync. +const BOOTSTRAP_LINK_SEARCH_RETRIES = 5; + export interface FactoryEntrypointOptions { briefUrl: string; targetRealm: string | null; @@ -141,6 +156,30 @@ export interface RunFactoryEntrypointDependencies { realmUrl: string, workspaceDir: string, ) => Promise; + /** + * Write the realm's index page. Defaults to + * `writeRealmDashboardCard`, which makes a freshly-created realm open + * to the `RealmDashboard` dashboard. Tests stub this out. + */ + writeRealmIndex?: (workspaceDir: string, realmUrl: string) => Promise; + /** + * Link the realm index's `board` relationship to the IssueTracker the + * bootstrap issue created. Defaults to `linkBoardToRealmIndex`. Returns + * `true` when it modified the index so the entrypoint syncs. Tests stub + * this out. + */ + linkRealmIndexBoard?: ( + options: LinkBoardToRealmIndexOptions, + ) => Promise; + /** + * Link the bootstrap seed issue's `project` relationship to the Project the + * bootstrap issue created. Defaults to `linkProjectToSeedIssue`. Returns + * `true` when it modified the seed issue so the entrypoint syncs. Tests stub + * this out. + */ + linkBootstrapIssueProject?: ( + options: LinkProjectToSeedIssueOptions, + ) => Promise; } export { FactoryEntrypointUsageError } from './factory-entrypoint-errors.ts'; @@ -338,6 +377,15 @@ export async function runFactoryEntrypoint( let pullTargetRealm = dependencies?.pullTargetRealm ?? defaultPullTargetRealm; await pullTargetRealm(client, targetRealm.url, workspaceDir); + // For a realm the factory just created, replace the default CardsGrid + // index page with a RealmDashboard instance so the realm opens to the + // factory dashboard. A pre-existing realm keeps its current index page. + if (targetRealm.createdRealm) { + let writeRealmIndex = + dependencies?.writeRealmIndex ?? writeRealmDashboardCard; + await writeRealmIndex(workspaceDir, targetRealm.url); + } + // Create the seed issue locally let seedResult = await (dependencies?.createSeed ?? createSeedIssue)(brief, { darkfactoryModuleUrl, @@ -354,6 +402,38 @@ export async function runFactoryEntrypoint( dependencies?.syncWorkspaceToRealm ?? defaultSyncWorkspaceToRealm; await syncWorkspaceToRealm(client, targetRealm.url, workspaceDir); + // Wire the artifacts the bootstrap issue creates into the realm. The + // bootstrap agent makes an IssueTracker board and a Project; the index card + // and seed issue were both written before those existed, so they start with + // no `board` / `project` link. These find the board/Project and patch + // `index.json` and the seed issue in place. Each reports whether it changed + // its card; sync once if either did. Idempotent — a no-op once the links + // are set or while nothing is indexed. Only freshly-created realms get a + // RealmDashboard page, so callers gate this on `createdRealm`. + let linkBoard = dependencies?.linkRealmIndexBoard ?? linkBoardToRealmIndex; + let linkSeedProject = + dependencies?.linkBootstrapIssueProject ?? linkProjectToSeedIssue; + let wireBootstrapArtifacts = async ({ waitForIndex = false } = {}) => { + // The board/Project are pushed to the realm fire-and-forget (no + // waitForIndex), so a search right after can race the indexer. The + // backstop is the last chance to wire the links, so it retries on an + // empty result; the in-loop hook stays opportunistic (no retries) since + // the backstop covers anything it misses. + let searchRetries = waitForIndex ? BOOTSTRAP_LINK_SEARCH_RETRIES : 0; + let linkArgs = { + client, + realmUrl: targetRealm.url, + workspaceDir, + darkfactoryModuleUrl, + searchRetries, + }; + let boardLinked = await linkBoard(linkArgs); + let projectLinked = await linkSeedProject(linkArgs); + if (boardLinked || projectLinked) { + await syncWorkspaceToRealm(client, targetRealm.url, workspaceDir); + } + }; + let summary = buildFactoryEntrypointSummary( options, brief, @@ -376,6 +456,14 @@ export async function runFactoryEntrypoint( debug: options.debug, retryBlocked: options.retryBlocked, enableBoxelUiDiscovery: options.enableBoxelUiDiscovery, + // Wire the board and the seed issue's project the moment the bootstrap + // issue finishes, rather than after the whole loop returns — so a run + // whose later issues stall or get interrupted still ends up with the + // dashboard and seed issue wired up. The post-loop call below is an + // idempotent backstop for runs that complete normally. + onBootstrapComplete: targetRealm.createdRealm + ? wireBootstrapArtifacts + : undefined, }); summary.issueLoop = { @@ -389,6 +477,22 @@ export async function runFactoryEntrypoint( })), }; + // Backstop after the loop returns. The bootstrap-complete hook above is the + // primary trigger (it fires even when the loop never reaches here), but this + // re-wires idempotently for runs that complete normally and covers the case + // where the hook's search briefly raced the realm index. A no-op when the + // board and project are already linked. Best-effort, like the hook: a wiring + // failure here must not turn an otherwise-successful run into a failure. + if (targetRealm.createdRealm) { + try { + await wireBootstrapArtifacts({ waitForIndex: true }); + } catch (err) { + log.warn( + `wireBootstrapArtifacts backstop failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + let succeeded = loopResult.outcome === 'all_issues_done'; summary.result = { status: succeeded ? 'completed' : 'failed', diff --git a/packages/software-factory/src/factory-issue-loop-wiring.ts b/packages/software-factory/src/factory-issue-loop-wiring.ts index 8eb3eb9b002..a2722d7aa96 100644 --- a/packages/software-factory/src/factory-issue-loop-wiring.ts +++ b/packages/software-factory/src/factory-issue-loop-wiring.ts @@ -103,6 +103,13 @@ export interface IssueLoopWiringConfig { * awareness of boxel-ui components. See CS-10527. */ enableBoxelUiDiscovery?: boolean; + /** + * Invoked once, right after the bootstrap issue completes. The entrypoint + * uses this to link the realm index's `board` relationship as soon as the + * IssueTracker exists, instead of waiting for the entire loop to return. + * Passed straight through to {@link runIssueLoop}. + */ + onBootstrapComplete?: () => Promise; } // --------------------------------------------------------------------------- @@ -276,6 +283,7 @@ export async function runFactoryIssueLoop( maxOuterCycles: config.maxOuterCycles, debug: config.debug, getSyncElapsedMs, + onBootstrapComplete: config.onBootstrapComplete, }; try { diff --git a/packages/software-factory/src/factory-realm-index.ts b/packages/software-factory/src/factory-realm-index.ts new file mode 100644 index 00000000000..d21d9ac5b6f --- /dev/null +++ b/packages/software-factory/src/factory-realm-index.ts @@ -0,0 +1,173 @@ +/** + * Index-page bootstrap for a freshly-created factory target realm. + * + * `create-realm` seeds every new realm with a default `index.json` that + * adopts `CardsGrid`. For realms the factory creates, we replace that + * with an instance of `RealmDashboard` (defined in the software-factory + * realm at `realm/realm-dashboard.gts`) so the realm opens to the + * factory dashboard — project KPIs, the issue board, and validation runs + * — instead of a bare card grid. + * + * This only runs for realms the factory just created; a pre-existing + * realm keeps whatever index page it already has. + */ + +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + +import { logger } from './logger.ts'; +import { + inferIssueTrackerModuleUrl, + linkRelationshipToCard, + toRealmRelativePath, +} from './realm-operations.ts'; +import { writeCard } from './workspace-fs.ts'; + +let log = logger('factory-realm-index'); + +const INDEX_CARD_FILE = 'index.json'; +const CARDS_GRID_FILE = 'cards-grid.json'; +// Realm-relative link from index.json to the CardsGrid instance, without +// the `.json` extension (links reference card ids, not file paths). +const CARDS_GRID_LINK = './cards-grid'; + +/** + * Infer the `RealmDashboard` module URL from a target realm URL. The + * card lives in the software-factory realm, which is mounted at + * `/software-factory/`, mirroring `inferDarkfactoryModuleUrl`. + */ +export function inferRealmDashboardModuleUrl(targetRealm: string): string { + let parsed = new URL(targetRealm); + return new URL('software-factory/realm-dashboard', parsed.origin + '/').href; +} + +/** + * Set the realm's index page to a `RealmDashboard` instance, overwriting + * the default `CardsGrid` index that `create-realm` seeded. + * + * Writes two files: a sibling `cards-grid.json` holding the `CardsGrid` + * instance (the same empty grid `create-realm` would have made), and + * `index.json` adopting `RealmDashboard` with its `cardsGrid` link + * pointing at that instance — so the dashboard's catalog tab shows the + * realm's cards. The `board` link is left for the bootstrap agent to wire + * once it creates the IssueTracker. The caller syncs the workspace to the + * realm afterwards. + */ +export async function writeRealmDashboardCard( + workspaceDir: string, + targetRealm: string, +): Promise { + let cardsGridDocument = { + data: { + type: 'card' as const, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/cards-grid', + name: 'CardsGrid', + }, + }, + }, + }; + + await write(workspaceDir, CARDS_GRID_FILE, cardsGridDocument); + + let moduleUrl = inferRealmDashboardModuleUrl(targetRealm); + let indexDocument = { + data: { + type: 'card' as const, + relationships: { + cardsGrid: { + links: { + self: CARDS_GRID_LINK, + }, + }, + }, + meta: { + adoptsFrom: { + module: moduleUrl, + name: 'RealmDashboard', + }, + }, + }, + }; + + log.info(`Setting realm index page to RealmDashboard (${moduleUrl})`); + await write(workspaceDir, INDEX_CARD_FILE, indexDocument); +} + +async function write( + workspaceDir: string, + path: string, + document: unknown, +): Promise { + let writeResult = await writeCard( + workspaceDir, + path, + JSON.stringify(document, null, 2), + ); + + if (!writeResult.ok) { + throw new Error( + `Failed to write ${path}: ${writeResult.error ?? 'unknown error'}`, + ); + } +} + +export interface LinkBoardToRealmIndexOptions { + client: BoxelCLIClient; + realmUrl: string; + workspaceDir: string; + /** From `inferDarkfactoryModuleUrl(realmUrl)`. */ + darkfactoryModuleUrl: string; + /** + * How many times to retry the IssueTracker search when it comes back + * empty. The board is synced to the realm fire-and-forget (no + * `waitForIndex`), so an empty result can just mean the indexer hasn't + * caught up. The post-loop backstop sets this so a fast run (bootstrap is + * the last work) doesn't permanently leave the index board link unset; the + * in-loop hook leaves it at the default 0 — the backstop is its safety net. + */ + searchRetries?: number; + /** Delay between empty-result retries. Defaults to `SEARCH_RETRY_DELAY_MS`. */ + searchRetryDelayMs?: number; +} + +/** + * Point the realm index's `board` relationship at the IssueTracker the + * bootstrap agent created, once it exists in the realm. + * + * The index card is written before the issue loop runs, when no board + * exists yet, so its `board` link starts empty. After the bootstrap issue + * creates and syncs an IssueTracker, this finds it and patches the + * workspace `index.json` in place (preserving the `cardsGrid` link). + * Returns `true` when it modified the index so the caller can sync; a + * no-op (no board indexed, or the link is already correct) returns + * `false`. + */ +export async function linkBoardToRealmIndex( + options: LinkBoardToRealmIndexOptions, +): Promise { + let { client, realmUrl, workspaceDir, darkfactoryModuleUrl } = options; + let issueTrackerModuleUrl = inferIssueTrackerModuleUrl(darkfactoryModuleUrl); + + return linkRelationshipToCard({ + client, + realmUrl, + workspaceDir, + cardFile: INDEX_CARD_FILE, + relationshipKey: 'board', + targetLabel: 'IssueTracker board', + search: () => + client.search(realmUrl, { + filter: { + type: { module: issueTrackerModuleUrl, name: 'IssueTracker' }, + }, + }), + // Bootstrap creates one board per project; if a run produced more than + // one, link the lexicographically-first id deterministically. + selectId: (ids) => [...ids].sort()[0], + buildLink: (id, realm) => `./${toRealmRelativePath(id, realm)}`, + log, + searchRetries: options.searchRetries, + searchRetryDelayMs: options.searchRetryDelayMs, + }); +} diff --git a/packages/software-factory/src/factory-seed.ts b/packages/software-factory/src/factory-seed.ts index 95ef883d111..6e0b027650d 100644 --- a/packages/software-factory/src/factory-seed.ts +++ b/packages/software-factory/src/factory-seed.ts @@ -8,9 +8,18 @@ * cards, then marks the seed issue as done. */ +import { posix } from 'node:path'; + +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + import type { FactoryBrief } from './factory-brief.ts'; import { logger } from './logger.ts'; +import { + inferIssueTrackerModuleUrl, + linkRelationshipToCard, + toRealmRelativePath, +} from './realm-operations.ts'; import { readCard, writeCard } from './workspace-fs.ts'; /** @@ -102,6 +111,74 @@ export async function createSeedIssue( return { issueId: SEED_ISSUE_PATH, status: 'created' }; } +// --------------------------------------------------------------------------- +// Post-bootstrap project link +// --------------------------------------------------------------------------- + +export interface LinkProjectToSeedIssueOptions { + client: BoxelCLIClient; + realmUrl: string; + workspaceDir: string; + /** From `inferDarkfactoryModuleUrl(realmUrl)`. */ + darkfactoryModuleUrl: string; + /** + * How many times to retry the Project search when it comes back empty. + * The Project is synced to the realm fire-and-forget (no `waitForIndex`), + * so an empty result can just mean the indexer hasn't caught up. The + * post-loop backstop sets this so a fast run doesn't permanently leave the + * seed issue's project link unset; the in-loop hook leaves it at the + * default 0 — the backstop is its safety net. + */ + searchRetries?: number; + /** Delay between empty-result retries. Defaults to `SEARCH_RETRY_DELAY_MS`. */ + searchRetryDelayMs?: number; +} + +/** + * Point the bootstrap seed issue's `project` relationship at the Project the + * bootstrap issue created, once it exists in the realm. + * + * The seed issue is written before the loop runs, when no Project exists yet, + * so it starts with no `project` link. After the bootstrap issue creates and + * syncs a Project, this finds it and patches the workspace seed issue in + * place. Returns `true` when it modified the issue so the caller can sync; a + * no-op (no Project indexed, the seed issue missing, or the link already + * correct) returns `false`. + */ +export async function linkProjectToSeedIssue( + options: LinkProjectToSeedIssueOptions, +): Promise { + let { client, realmUrl, workspaceDir, darkfactoryModuleUrl } = options; + let issueTrackerModuleUrl = inferIssueTrackerModuleUrl(darkfactoryModuleUrl); + + return linkRelationshipToCard({ + client, + realmUrl, + workspaceDir, + cardFile: SEED_ISSUE_FILE, + relationshipKey: 'project', + targetLabel: 'Project', + search: () => + client.search(realmUrl, { + filter: { type: { module: issueTrackerModuleUrl, name: 'Project' } }, + // One Project per bootstrapped realm; newest-first so a re-run that + // somehow produced more than one links the most recently created + // (the default first-id selection then takes the newest). + sort: [{ by: 'lastModified', direction: 'desc' as const }], + }), + // The `self` link is relative to the seed issue's directory, matching how + // the agent encodes implementation-issue project links (`../Projects/`). + buildLink: (id, realm) => + posix.relative( + posix.dirname(SEED_ISSUE_PATH), + toRealmRelativePath(id, realm), + ), + log, + searchRetries: options.searchRetries, + searchRetryDelayMs: options.searchRetryDelayMs, + }); +} + // --------------------------------------------------------------------------- // Document builder // --------------------------------------------------------------------------- diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts index edadb007492..684bfcff470 100644 --- a/packages/software-factory/src/issue-loop.ts +++ b/packages/software-factory/src/issue-loop.ts @@ -150,6 +150,17 @@ export interface IssueLoopConfig { * in the debug timing summary. Defaults to a no-op (0) when not wired. */ getSyncElapsedMs?: () => number; + /** + * Invoked once, right after the `bootstrap` issue's cycle completes — the + * earliest point at which the IssueTracker board exists on the realm. The + * entrypoint wires this to link the realm index's `board` relationship + * here, so the link no longer waits for the whole backlog to drain: a run + * that is interrupted, crashes, or whose implementation issues stall would + * otherwise leave the board unlinked even though it has existed since the + * first outer cycle. Best-effort — a thrown error is logged, not + * propagated, so a link failure never aborts the loop. + */ + onBootstrapComplete?: () => Promise; } export type IssueLoopOutcome = @@ -267,6 +278,7 @@ export async function runIssueLoop( maxOuterCycles = DEFAULT_MAX_OUTER_CYCLES, debug = false, getSyncElapsedMs = () => 0, + onBootstrapComplete, } = config; let scheduler = new IssueScheduler(issueStore); @@ -652,6 +664,22 @@ export async function runIssueLoop( timing: issueTiming, }); + // The bootstrap issue is what creates (and syncs) the IssueTracker + // board. Fire the hook the moment its cycle finishes so the realm index + // can be linked to the board now — rather than after the whole backlog + // drains, which a stalled or interrupted implementation issue may never + // reach. There is only ever one bootstrap issue, so this fires at most + // once. The link is best-effort and must not abort the loop. + if (onBootstrapComplete && issue.issueType === 'bootstrap') { + try { + await onBootstrapComplete(); + } catch (err) { + log.warn( + `onBootstrapComplete hook failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + // Reload issues to pick up new issues the agent may have created await scheduler.loadIssues(); } diff --git a/packages/software-factory/src/issue-scheduler.ts b/packages/software-factory/src/issue-scheduler.ts index 7991799b3fa..ab4750f95ff 100644 --- a/packages/software-factory/src/issue-scheduler.ts +++ b/packages/software-factory/src/issue-scheduler.ts @@ -18,6 +18,7 @@ import type { import { addCommentToIssue, ensureJsonExtension, + inferIssueTrackerModuleUrl, toRealmRelativePath, } from './realm-operations.ts'; import { readCard, writeCard } from './workspace-fs.ts'; @@ -205,13 +206,9 @@ export class RealmIssueStore implements IssueStore { constructor(config: RealmIssueStoreConfig) { this.realmUrl = config.realmUrl; - // Tracker types (Issue/Project/IssueTracker) are defined in the - // `issue-tracker` module and re-exported by `darkfactory`. Derive the - // canonical `issue-tracker` URL from the darkfactory URL by swapping the - // final path segment, tolerating a trailing slash. - this.issueTrackerModuleUrl = config.darkfactoryModuleUrl - .replace(/\/+$/, '') - .replace(/[^/]+$/, 'issue-tracker'); + this.issueTrackerModuleUrl = inferIssueTrackerModuleUrl( + config.darkfactoryModuleUrl, + ); this.client = config.client; this.workspaceDir = config.workspaceDir; } diff --git a/packages/software-factory/src/realm-operations.ts b/packages/software-factory/src/realm-operations.ts index 1b09400a415..5c691f7598c 100644 --- a/packages/software-factory/src/realm-operations.ts +++ b/packages/software-factory/src/realm-operations.ts @@ -8,10 +8,11 @@ * realm via `client.sync` orchestrated by the loop. */ -import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; +import type { BoxelCLIClient, SearchResult } from '@cardstack/boxel-cli/api'; import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import type { Logger } from './logger.ts'; import { readCard, writeCard } from './workspace-fs.ts'; /** @@ -37,6 +38,195 @@ export function toRealmRelativePath(id: string, realmUrl: string): string { return id.startsWith(base) ? id.slice(base.length) : id; } +/** Default delay between empty-result retries in {@link searchUntilNonEmpty}. */ +export const SEARCH_RETRY_DELAY_MS = 1000; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Run a realm-index search, retrying while the result set is empty. + * + * Cards the bootstrap agent creates (the IssueTracker board, the Project) + * are pushed to the realm with a fire-and-forget sync (no `waitForIndex`), + * so a search issued moments later can race the indexer and come back empty + * even though the card exists. A caller that must not treat a transient + * empty result as final passes `retries > 0` to poll until the card is + * indexed or the budget is exhausted; the default of 0 is a single search + * with no behavior change. + * + * Returns the first result that is a failure or non-empty, otherwise the + * last (empty) result once retries run out. `onEmptyRetry` fires before each + * re-search so the caller can log the wait. + */ +export async function searchUntilNonEmpty< + T extends { ok: boolean; data?: unknown[] | null }, +>( + doSearch: () => Promise, + options?: { + retries?: number; + retryDelayMs?: number; + onEmptyRetry?: (attempt: number, retries: number) => void; + }, +): Promise { + let retries = options?.retries ?? 0; + let retryDelayMs = options?.retryDelayMs ?? SEARCH_RETRY_DELAY_MS; + + let result = await doSearch(); + for (let attempt = 1; attempt <= retries; attempt++) { + if (!result.ok || (result.data?.length ?? 0) > 0) { + return result; + } + options?.onEmptyRetry?.(attempt, retries); + await delay(retryDelayMs); + result = await doSearch(); + } + return result; +} + +/** + * Derive the canonical `issue-tracker` module URL from a darkfactory module + * URL by swapping the final path segment (tolerating a trailing slash). + * + * The tracker types (Issue/Project/IssueTracker) are defined in the + * `issue-tracker` module and re-exported by `darkfactory`; index searches + * must filter on the canonical `issue-tracker` URL. Shared by + * `RealmIssueStore` and the post-bootstrap link helpers so the mapping from + * realm module layout lives in one place. + */ +export function inferIssueTrackerModuleUrl( + darkfactoryModuleUrl: string, +): string { + return darkfactoryModuleUrl + .replace(/\/+$/, '') + .replace(/[^/]+$/, 'issue-tracker'); +} + +export interface LinkRelationshipToCardOptions { + client: BoxelCLIClient; + realmUrl: string; + workspaceDir: string; + /** Workspace-relative file of the card to patch (e.g. `index.json`). */ + cardFile: string; + /** Relationship key to set on the card (e.g. `board`, `project`). */ + relationshipKey: string; + /** + * Human-readable name of the searched card, used in log lines + * (e.g. `IssueTracker board`, `Project`). + */ + targetLabel: string; + /** + * Build the search query for the target card. Run through + * {@link searchUntilNonEmpty}, so it may be invoked more than once. + */ + search: () => Promise; + /** Build the relationship `self` link from the found card's id. */ + buildLink: (targetId: string, realmUrl: string) => string; + /** + * Choose which result's id to link when the search returns more than one. + * Receives the truthy ids in result order; defaults to the first (let the + * search's own `sort` decide). Pure id ordering — return a member of `ids`. + */ + selectId?: (ids: string[]) => string; + /** Logger for the calling module. */ + log: Logger; + /** Retry an empty search this many times. See {@link searchUntilNonEmpty}. */ + searchRetries?: number; + /** Delay between empty-result retries. Defaults to `SEARCH_RETRY_DELAY_MS`. */ + searchRetryDelayMs?: number; +} + +/** + * Search the realm index for a card and patch a relationship on a workspace + * card to point at it, once it exists. + * + * The target card (the IssueTracker board, the seed issue's Project) is + * created by the bootstrap agent after the linking card is written, so the + * link starts empty and gets wired here. Returns `true` when it modified the + * card so the caller can sync; a no-op (search failed, nothing indexed, no + * usable id, the linking card missing, or the link already correct) returns + * `false`. + */ +export async function linkRelationshipToCard( + options: LinkRelationshipToCardOptions, +): Promise { + let { + realmUrl, + workspaceDir, + cardFile, + relationshipKey, + targetLabel, + search, + buildLink, + selectId, + log, + } = options; + + let result = await searchUntilNonEmpty(search, { + retries: options.searchRetries ?? 0, + retryDelayMs: options.searchRetryDelayMs, + onEmptyRetry: (attempt, retries) => + log.info( + `No ${targetLabel} indexed yet; retrying search (${attempt}/${retries})`, + ), + }); + + if (!result.ok) { + log.warn( + `Could not search for ${targetLabel} (${result.status}): ${result.error}`, + ); + return false; + } + + let ids = (result.data ?? []) + .map((card) => (card as { id?: string }).id) + .filter((id): id is string => Boolean(id)); + if (ids.length === 0) { + log.info( + `No ${targetLabel} found yet; leaving ${relationshipKey} link unset`, + ); + return false; + } + let targetId = selectId ? selectId(ids) : ids[0]; + if (ids.length > 1) { + log.warn(`Found ${ids.length} ${targetLabel}(s); linking ${targetId}`); + } + let link = buildLink(targetId, realmUrl); + + let read = await readCard(workspaceDir, cardFile); + if (!read.ok || !read.document) { + log.warn( + `Cannot link ${relationshipKey} — ${cardFile} missing from workspace (${read.status ?? read.error})`, + ); + return false; + } + + let document = read.document as { + data: { + relationships?: Record; + }; + }; + let relationships = (document.data.relationships ??= {}); + if (relationships[relationshipKey]?.links?.self === link) { + return false; + } + relationships[relationshipKey] = { links: { self: link } }; + + log.info(`Linking ${cardFile} ${relationshipKey} relationship to ${link}`); + let writeResult = await writeCard( + workspaceDir, + cardFile, + JSON.stringify(document, null, 2), + ); + if (!writeResult.ok) { + throw new Error( + `Failed to write ${cardFile}: ${writeResult.error ?? 'unknown error'}`, + ); + } + return true; +} + // --------------------------------------------------------------------------- // Issue Comments (read-patch-write) // --------------------------------------------------------------------------- diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index edbd2edf6af..77506511695 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -465,4 +465,282 @@ module('factory-entrypoint', function (hooks) { 'https://realms.example.test/software-factory/darkfactory', ); }); + + function briefFetch(): typeof globalThis.fetch { + return (async () => + new Response( + JSON.stringify({ + data: { + attributes: { + content: 'Brief content', + cardInfo: { name: 'Sticky Note', summary: 'summary' }, + tags: [], + }, + }, + }), + { status: 200, headers: { 'content-type': SupportedMimeType.JSON } }, + )) as unknown as typeof globalThis.fetch; + } + + test('runFactoryEntrypoint sets the RealmDashboard index page for a freshly-created realm', async function (assert) { + useTestProfile(); + + let capturedWorkspaceDir: string | undefined; + let capturedRealmUrl: string | undefined; + + await runFactoryEntrypoint( + { briefUrl, targetRealm, realmServerUrl: null, agent: 'claude' }, + { + bootstrapTargetRealm: async (resolution) => ({ + ...bootstrappedTargetRealm, + url: resolution.url, + serverUrl: resolution.serverUrl, + createdRealm: true, + }), + createSeed: async () => mockSeedResult, + pullTargetRealm: async () => {}, + syncWorkspaceToRealm: async () => {}, + writeRealmIndex: async (workspaceDir, realmUrl) => { + capturedWorkspaceDir = workspaceDir; + capturedRealmUrl = realmUrl; + }, + linkRealmIndexBoard: async () => false, + linkBootstrapIssueProject: async () => false, + runIssueLoop: async () => ({ + outcome: 'all_issues_done' as const, + outerCycles: 0, + issueResults: [], + }), + fetch: briefFetch(), + }, + ); + + assert.ok( + capturedWorkspaceDir, + 'writeRealmIndex runs for a freshly-created realm', + ); + assert.strictEqual(capturedRealmUrl, targetRealm); + }); + + test('runFactoryEntrypoint links the index board after the loop and re-syncs when a board was found', async function (assert) { + useTestProfile(); + + let linkBoardCalled = false; + let syncCount = 0; + + await runFactoryEntrypoint( + { briefUrl, targetRealm, realmServerUrl: null, agent: 'claude' }, + { + bootstrapTargetRealm: async (resolution) => ({ + ...bootstrappedTargetRealm, + url: resolution.url, + serverUrl: resolution.serverUrl, + createdRealm: true, + }), + createSeed: async () => mockSeedResult, + pullTargetRealm: async () => {}, + syncWorkspaceToRealm: async () => { + syncCount++; + }, + writeRealmIndex: async () => {}, + linkRealmIndexBoard: async (options) => { + linkBoardCalled = true; + assert.strictEqual( + options.realmUrl, + targetRealm, + 'board linker receives the target realm URL', + ); + assert.strictEqual( + options.darkfactoryModuleUrl, + 'https://realms.example.test/software-factory/darkfactory', + 'board linker receives the darkfactory module URL', + ); + // Report a modification so the entrypoint re-syncs the index. + return true; + }, + // No project change here — this test isolates the board re-sync. + linkBootstrapIssueProject: async () => false, + runIssueLoop: async () => ({ + outcome: 'all_issues_done' as const, + outerCycles: 0, + issueResults: [], + }), + fetch: briefFetch(), + }, + ); + + assert.true(linkBoardCalled, 'linkRealmIndexBoard runs after the loop'); + // One sync for the seed/index push, one for the board-linked index. + assert.strictEqual(syncCount, 2, 'the board-linked index is re-synced'); + }); + + test('runFactoryEntrypoint links the seed issue project and re-syncs when a project was found', async function (assert) { + useTestProfile(); + + let linkProjectCalled = false; + let syncCount = 0; + + await runFactoryEntrypoint( + { briefUrl, targetRealm, realmServerUrl: null, agent: 'claude' }, + { + bootstrapTargetRealm: async (resolution) => ({ + ...bootstrappedTargetRealm, + url: resolution.url, + serverUrl: resolution.serverUrl, + createdRealm: true, + }), + createSeed: async () => mockSeedResult, + pullTargetRealm: async () => {}, + syncWorkspaceToRealm: async () => { + syncCount++; + }, + writeRealmIndex: async () => {}, + // No board change — this test isolates the seed-issue project link. + linkRealmIndexBoard: async () => false, + linkBootstrapIssueProject: async (options) => { + linkProjectCalled = true; + assert.strictEqual( + options.realmUrl, + targetRealm, + 'project linker receives the target realm URL', + ); + assert.strictEqual( + options.darkfactoryModuleUrl, + 'https://realms.example.test/software-factory/darkfactory', + 'project linker receives the darkfactory module URL', + ); + // Report a modification so the entrypoint re-syncs the seed issue. + return true; + }, + runIssueLoop: async () => ({ + outcome: 'all_issues_done' as const, + outerCycles: 0, + issueResults: [], + }), + fetch: briefFetch(), + }, + ); + + assert.true( + linkProjectCalled, + 'linkBootstrapIssueProject runs for a freshly-created realm', + ); + // One sync for the seed/index push, one for the project-linked seed issue. + assert.strictEqual( + syncCount, + 2, + 'the project-linked seed issue is re-synced', + ); + }); + + test('runFactoryEntrypoint links the board as soon as the bootstrap issue completes, before the loop returns', async function (assert) { + useTestProfile(); + + let events: string[] = []; + + await runFactoryEntrypoint( + { briefUrl, targetRealm, realmServerUrl: null, agent: 'claude' }, + { + bootstrapTargetRealm: async (resolution) => ({ + ...bootstrappedTargetRealm, + url: resolution.url, + serverUrl: resolution.serverUrl, + createdRealm: true, + }), + createSeed: async () => mockSeedResult, + pullTargetRealm: async () => {}, + syncWorkspaceToRealm: async () => { + events.push('sync'); + }, + writeRealmIndex: async () => {}, + linkRealmIndexBoard: async () => { + events.push('link'); + return true; + }, + linkBootstrapIssueProject: async () => false, + // The bootstrap issue finishes mid-run; a later implementation issue + // never completes, so the loop returns a non-complete outcome. The + // board must still be linked — via the bootstrap-complete hook the + // entrypoint passes in — before the loop returns. + runIssueLoop: async (config) => { + await config.onBootstrapComplete?.(); + events.push('loop-returns'); + return { + outcome: 'no_unblocked_issues' as const, + outerCycles: 2, + issueResults: [ + { + issueId: 'Issues/bootstrap-seed', + issueSummary: 'bootstrap', + exitReason: 'done' as const, + innerIterations: 1, + toolCallLog: [], + }, + ], + }; + }, + fetch: briefFetch(), + }, + ); + + let firstLink = events.indexOf('link'); + assert.notStrictEqual(firstLink, -1, 'the board was linked'); + assert.ok( + firstLink < events.indexOf('loop-returns'), + 'board linked via the bootstrap-complete hook, before the loop finished', + ); + }); + + test('runFactoryEntrypoint leaves the index page untouched for a pre-existing realm', async function (assert) { + useTestProfile(); + + let writeRealmIndexCalled = false; + let linkBoardCalled = false; + let linkProjectCalled = false; + + await runFactoryEntrypoint( + { briefUrl, targetRealm, realmServerUrl: null, agent: 'claude' }, + { + bootstrapTargetRealm: async (resolution) => ({ + ...bootstrappedTargetRealm, + url: resolution.url, + serverUrl: resolution.serverUrl, + createdRealm: false, + }), + createSeed: async () => mockSeedResult, + pullTargetRealm: async () => {}, + syncWorkspaceToRealm: async () => {}, + writeRealmIndex: async () => { + writeRealmIndexCalled = true; + }, + linkRealmIndexBoard: async () => { + linkBoardCalled = true; + return false; + }, + linkBootstrapIssueProject: async () => { + linkProjectCalled = true; + return false; + }, + runIssueLoop: async () => ({ + outcome: 'all_issues_done' as const, + outerCycles: 0, + issueResults: [], + }), + fetch: briefFetch(), + }, + ); + + assert.false( + writeRealmIndexCalled, + 'writeRealmIndex must not run for a pre-existing realm', + ); + assert.false( + linkBoardCalled, + 'linkRealmIndexBoard must not run for a pre-existing realm', + ); + assert.false( + linkProjectCalled, + 'linkBootstrapIssueProject must not run for a pre-existing realm', + ); + }); }); diff --git a/packages/software-factory/tests/factory-realm-index.test.ts b/packages/software-factory/tests/factory-realm-index.test.ts new file mode 100644 index 00000000000..47cb55ab63b --- /dev/null +++ b/packages/software-factory/tests/factory-realm-index.test.ts @@ -0,0 +1,225 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; + +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + +import { + inferRealmDashboardModuleUrl, + linkBoardToRealmIndex, + writeRealmDashboardCard, +} from '../src/factory-realm-index.ts'; +import { createTestWorkspace } from './helpers/workspace-fixture.ts'; + +const REALM = 'https://realms.example.test/me/proj/'; +const DARKFACTORY = 'https://realms.example.test/software-factory/darkfactory'; + +function clientFindingBoards( + boards: Record[], + capture?: (realmUrls: unknown, query: Record) => void, +): BoxelCLIClient { + return { + search: async (realmUrls: unknown, query: Record) => { + capture?.(realmUrls, query); + return { ok: true, data: boards }; + }, + } as unknown as BoxelCLIClient; +} + +module('factory-realm-index', function (hooks) { + let workspace: ReturnType; + + hooks.beforeEach(function () { + workspace = createTestWorkspace(); + }); + + hooks.afterEach(function () { + workspace.cleanup(); + }); + + test('inferRealmDashboardModuleUrl resolves against the realm origin', function (assert) { + assert.strictEqual( + inferRealmDashboardModuleUrl('https://realms.example.test/me/proj/'), + 'https://realms.example.test/software-factory/realm-dashboard', + ); + // Realm endpoint and path are ignored — only the origin matters. + assert.strictEqual( + inferRealmDashboardModuleUrl('http://localhost:4201/user/deep/realm/'), + 'http://localhost:4201/software-factory/realm-dashboard', + ); + }); + + test('writeRealmDashboardCard writes an index.json adopting RealmDashboard linked to a CardsGrid', async function (assert) { + await writeRealmDashboardCard( + workspace.dir, + 'https://realms.example.test/me/proj/', + ); + + let index = JSON.parse(workspace.read('index.json')); + assert.deepEqual(index, { + data: { + type: 'card', + relationships: { + cardsGrid: { + links: { + self: './cards-grid', + }, + }, + }, + meta: { + adoptsFrom: { + module: + 'https://realms.example.test/software-factory/realm-dashboard', + name: 'RealmDashboard', + }, + }, + }, + }); + + let cardsGrid = JSON.parse(workspace.read('cards-grid.json')); + assert.deepEqual(cardsGrid, { + data: { + type: 'card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/cards-grid', + name: 'CardsGrid', + }, + }, + }, + }); + }); + + test('linkBoardToRealmIndex patches the index board link to the found IssueTracker', async function (assert) { + await writeRealmDashboardCard(workspace.dir, REALM); + + let capturedQuery: Record | undefined; + let modified = await linkBoardToRealmIndex({ + client: clientFindingBoards( + [{ id: `${REALM}Boards/proj-board` }], + (_realms, query) => { + capturedQuery = query; + }, + ), + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + }); + + assert.true(modified, 'reports the index was modified'); + // Searches for IssueTracker in the sibling issue-tracker module. + assert.deepEqual(capturedQuery?.filter, { + type: { + module: 'https://realms.example.test/software-factory/issue-tracker', + name: 'IssueTracker', + }, + }); + + let index = JSON.parse(workspace.read('index.json')); + assert.deepEqual( + index.data.relationships.board, + { links: { self: './Boards/proj-board' } }, + 'board link points at the found board', + ); + // The cardsGrid link written at bootstrap is preserved. + assert.deepEqual(index.data.relationships.cardsGrid, { + links: { self: './cards-grid' }, + }); + }); + + test('linkBoardToRealmIndex is a no-op when no board exists yet', async function (assert) { + await writeRealmDashboardCard(workspace.dir, REALM); + + let modified = await linkBoardToRealmIndex({ + client: clientFindingBoards([]), + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + }); + + assert.false(modified, 'reports no modification'); + let index = JSON.parse(workspace.read('index.json')); + assert.strictEqual( + index.data.relationships.board, + undefined, + 'board link stays unset', + ); + }); + + test('linkBoardToRealmIndex retries an empty search and links once the board is indexed', async function (assert) { + await writeRealmDashboardCard(workspace.dir, REALM); + + // The board sync is fire-and-forget, so the first searches can race the + // indexer and come back empty before the board appears. + let searchCount = 0; + let raceyClient = { + search: async () => { + searchCount++; + return searchCount < 3 + ? { ok: true, data: [] } + : { ok: true, data: [{ id: `${REALM}Boards/proj-board` }] }; + }, + } as unknown as BoxelCLIClient; + + let modified = await linkBoardToRealmIndex({ + client: raceyClient, + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + searchRetries: 5, + searchRetryDelayMs: 0, + }); + + assert.true(modified, 'links once the board shows up on a later search'); + assert.strictEqual(searchCount, 3, 'polled until the board was indexed'); + let index = JSON.parse(workspace.read('index.json')); + assert.deepEqual(index.data.relationships.board, { + links: { self: './Boards/proj-board' }, + }); + }); + + test('linkBoardToRealmIndex gives up after exhausting retries', async function (assert) { + await writeRealmDashboardCard(workspace.dir, REALM); + + let searchCount = 0; + let emptyClient = { + search: async () => { + searchCount++; + return { ok: true, data: [] }; + }, + } as unknown as BoxelCLIClient; + + let modified = await linkBoardToRealmIndex({ + client: emptyClient, + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + searchRetries: 3, + searchRetryDelayMs: 0, + }); + + assert.false( + modified, + 'reports no modification when the board never appears', + ); + assert.strictEqual(searchCount, 4, 'one initial search plus three retries'); + let index = JSON.parse(workspace.read('index.json')); + assert.strictEqual(index.data.relationships.board, undefined); + }); + + test('linkBoardToRealmIndex is idempotent when the board link is already correct', async function (assert) { + await writeRealmDashboardCard(workspace.dir, REALM); + + let options = { + client: clientFindingBoards([{ id: `${REALM}Boards/proj-board` }]), + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + }; + + assert.true(await linkBoardToRealmIndex(options), 'first run links'); + assert.false( + await linkBoardToRealmIndex(options), + 'second run is a no-op once the link is set', + ); + }); +}); diff --git a/packages/software-factory/tests/factory-seed.test.ts b/packages/software-factory/tests/factory-seed.test.ts new file mode 100644 index 00000000000..29efabe7c32 --- /dev/null +++ b/packages/software-factory/tests/factory-seed.test.ts @@ -0,0 +1,119 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; + +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + +import { linkProjectToSeedIssue } from '../src/factory-seed.ts'; +import { createTestWorkspace } from './helpers/workspace-fixture.ts'; + +const REALM = 'https://realms.example.test/me/proj/'; +const DARKFACTORY = 'https://realms.example.test/software-factory/darkfactory'; +const SEED_ISSUE_FILE = 'Issues/bootstrap-seed.json'; + +function seedIssueDocument() { + return { + data: { + type: 'card', + attributes: { + issueId: 'BOOT-1', + status: 'done', + issueType: 'bootstrap', + }, + meta: { adoptsFrom: { module: DARKFACTORY, name: 'Issue' } }, + }, + }; +} + +function clientFindingProjects( + projects: Record[], + capture?: (realmUrls: unknown, query: Record) => void, +): BoxelCLIClient { + return { + search: async (realmUrls: unknown, query: Record) => { + capture?.(realmUrls, query); + return { ok: true, data: projects }; + }, + } as unknown as BoxelCLIClient; +} + +module('factory-seed > linkProjectToSeedIssue', function (hooks) { + let workspace: ReturnType; + + hooks.beforeEach(function () { + workspace = createTestWorkspace(); + workspace.write( + SEED_ISSUE_FILE, + JSON.stringify(seedIssueDocument(), null, 2), + ); + }); + + hooks.afterEach(function () { + workspace.cleanup(); + }); + + test('patches the seed issue project link to the found Project', async function (assert) { + let capturedQuery: Record | undefined; + let modified = await linkProjectToSeedIssue({ + client: clientFindingProjects( + [{ id: `${REALM}Projects/sticky-note` }], + (_realms, query) => { + capturedQuery = query; + }, + ), + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + }); + + assert.true(modified, 'reports the seed issue was modified'); + // Searches for Project in the sibling issue-tracker module. + assert.deepEqual(capturedQuery?.filter, { + type: { + module: 'https://realms.example.test/software-factory/issue-tracker', + name: 'Project', + }, + }); + + let seed = JSON.parse(workspace.read(SEED_ISSUE_FILE)); + assert.deepEqual( + seed.data.relationships.project, + { links: { self: '../Projects/sticky-note' } }, + 'project link is relative to the seed issue directory', + ); + // Existing attributes are preserved. + assert.strictEqual(seed.data.attributes.issueId, 'BOOT-1'); + assert.strictEqual(seed.data.attributes.status, 'done'); + }); + + test('is a no-op when no Project exists yet', async function (assert) { + let modified = await linkProjectToSeedIssue({ + client: clientFindingProjects([]), + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + }); + + assert.false(modified, 'reports no modification'); + let seed = JSON.parse(workspace.read(SEED_ISSUE_FILE)); + assert.strictEqual( + seed.data.relationships, + undefined, + 'project link stays unset', + ); + }); + + test('is idempotent when the project link is already correct', async function (assert) { + let options = { + client: clientFindingProjects([{ id: `${REALM}Projects/sticky-note` }]), + realmUrl: REALM, + workspaceDir: workspace.dir, + darkfactoryModuleUrl: DARKFACTORY, + }; + + assert.true(await linkProjectToSeedIssue(options), 'first run links'); + assert.false( + await linkProjectToSeedIssue(options), + 'second run is a no-op once the link is set', + ); + }); +}); diff --git a/packages/software-factory/tests/index.ts b/packages/software-factory/tests/index.ts index 0024040813f..0bfebe19e38 100644 --- a/packages/software-factory/tests/index.ts +++ b/packages/software-factory/tests/index.ts @@ -7,6 +7,8 @@ import './factory-entrypoint.test.ts'; import './factory-entrypoint.integration.test.ts'; import './factory-skill-loader.test.ts'; import './factory-target-realm.test.ts'; +import './factory-realm-index.test.ts'; +import './factory-seed.test.ts'; import './factory-test-realm.test.ts'; import './factory-tool-executor.test.ts'; import './factory-tool-executor.integration.test.ts'; diff --git a/packages/software-factory/tests/issue-loop.test.ts b/packages/software-factory/tests/issue-loop.test.ts index a5968f7a546..6a0138a5228 100644 --- a/packages/software-factory/tests/issue-loop.test.ts +++ b/packages/software-factory/tests/issue-loop.test.ts @@ -773,6 +773,125 @@ module('issue-loop > NoOpValidator', function () { }); }); +// --------------------------------------------------------------------------- +// 8b. onBootstrapComplete hook +// --------------------------------------------------------------------------- + +module('issue-loop > onBootstrapComplete hook', function () { + function bootstrapSeedStore(): MockIssueStore { + return new MockIssueStore([ + makeIssue({ + id: 'seed', + status: 'backlog', + priority: 'high', + order: 1, + issueType: 'bootstrap', + summary: 'Process brief and create project artifacts', + }), + ]); + } + + function boardWritingAgent(store: MockIssueStore): MockLoopAgent { + return new MockLoopAgent( + [ + { + toolCalls: [ + { + tool: 'write_file', + args: { path: 'Boards/board.json', content: '{}' }, + }, + ], + updateIssue: { id: 'seed', status: 'done' }, + }, + ], + store, + ); + } + + test('fires once after the bootstrap issue completes', async function (assert) { + let store = bootstrapSeedStore(); + let agent = boardWritingAgent(store); + + let hookCalls = 0; + let result = await runIssueLoop( + makeLoopConfig({ + agent, + issueStore: store, + createValidator: () => new NoOpValidator(), + onBootstrapComplete: async () => { + hookCalls++; + }, + }), + ); + + assert.strictEqual(result.issueResults[0].exitReason, 'done'); + assert.strictEqual( + hookCalls, + 1, + 'hook fires exactly once, after the bootstrap issue', + ); + }); + + test('does not fire for a non-bootstrap issue', async function (assert) { + let store = new MockIssueStore([ + makeIssue({ + id: 'iss-1', + status: 'backlog', + priority: 'high', + order: 1, + issueType: 'feature', + }), + ]); + let agent = new MockLoopAgent( + [ + { + toolCalls: [ + { tool: 'write_file', args: { path: 'card.gts', content: 'v1' } }, + ], + updateIssue: { id: 'iss-1', status: 'done' }, + }, + ], + store, + ); + + let hookCalls = 0; + await runIssueLoop( + makeLoopConfig({ + agent, + issueStore: store, + onBootstrapComplete: async () => { + hookCalls++; + }, + }), + ); + + assert.strictEqual(hookCalls, 0, 'hook only fires for the bootstrap issue'); + }); + + test('a throwing hook is swallowed and does not abort the loop', async function (assert) { + let store = bootstrapSeedStore(); + let agent = boardWritingAgent(store); + + let result = await runIssueLoop( + makeLoopConfig({ + agent, + issueStore: store, + createValidator: () => new NoOpValidator(), + onBootstrapComplete: async () => { + throw new Error('link failed'); + }, + }), + ); + + assert.strictEqual( + result.outcome, + 'all_issues_done', + 'loop still completes even though the hook threw', + ); + assert.strictEqual(result.issueResults[0].exitReason, 'done'); + }); +}); + // --------------------------------------------------------------------------- // 9. Context threading // ---------------------------------------------------------------------------