From 16cc46a8883a808aaa3a6a0c08599104e5407e68 Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Thu, 12 Mar 2026 15:35:41 +0000 Subject: [PATCH 01/23] Experiment dump of e2e software construction and testing --- .../.agents/skills/boxel-development/SKILL.md | 66 + .../references/dev-command-development.md | 161 ++ .../references/dev-core-concept.md | 139 ++ .../references/dev-core-patterns.md | 346 ++++ .../references/dev-data-management.md | 156 ++ .../references/dev-defensive-programming.md | 118 ++ .../references/dev-delegated-rendering.md | 187 ++ .../references/dev-enumerations.md | 209 ++ .../references/dev-external-libraries.md | 96 + .../references/dev-file-def.md | 166 ++ .../references/dev-file-editing.md | 85 + .../references/dev-fitted-formats.md | 34 + .../references/dev-query-systems.md | 74 + .../references/dev-quick-reference.md | 104 + .../references/dev-replicate-ai.md | 111 + .../references/dev-spec-usage.md | 185 ++ .../references/dev-styling-design.md | 106 + .../references/dev-technical-rules.md | 87 + .../references/dev-template-patterns.md | 325 +++ .../references/dev-theme-design-system.md | 239 +++ .../skills/boxel-file-structure/SKILL.md | 305 +++ .../.agents/skills/boxel-repair/SKILL.md | 42 + .../.agents/skills/boxel-restore/SKILL.md | 76 + .../.agents/skills/boxel-setup/SKILL.md | 103 + .../.agents/skills/boxel-sync/SKILL.md | 80 + .../.agents/skills/boxel-track/SKILL.md | 117 ++ .../.agents/skills/boxel-watch/SKILL.md | 66 + .../software-factory-operations/SKILL.md | 62 + .../experiment_1/.claude/CLAUDE.md | 699 +++++++ .../experiment_1/.claude/skills | 1 + .../software-factory/experiment_1/AGENTS.md | 224 ++ .../experiment_1/package-lock.json | 72 + .../experiment_1/package.json | 15 + .../experiment_1/playwright.realm.config.mjs | 7 + .../adfdde24-d52d-4324-8f87-b46f42b2ea76.json | 24 + .../KnowledgeArticle/agent-onboarding.json | 37 + .../02ecd21b-fd62-4e75-83d7-97da3af78b21.json | 30 + .../guidance-tasks/Project/demo-project.json | 47 + .../guidance-tasks/Ticket/ticket-001.json | 59 + .../guidance-tasks/darkfactory-schema.gts | 183 ++ .../realms/guidance-tasks/darkfactory-ui.gts | 1833 +++++++++++++++++ .../realms/guidance-tasks/darkfactory.gts | 4 + .../realms/guidance-tasks/index.json | 12 + .../experiment_1/scripts/boxel-search.mjs | 71 + .../experiment_1/scripts/boxel-session.mjs | 21 + .../experiment_1/scripts/lib/boxel.mjs | 242 +++ .../experiment_1/scripts/pick-ticket.mjs | 82 + .../experiment_1/scripts/run-realm-tests.mjs | 204 ++ .../experiment_1/test-results/.last-run.json | 4 + .../experiment_1/tests/helpers/boxel-auth.mjs | 24 + .../experiment_1/tests/helpers/realm-test.mjs | 31 + .../experiment_1/tests/ticket-flow.spec.mjs | 26 + 52 files changed, 7797 insertions(+) create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-command-development.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-concept.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-patterns.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-data-management.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-defensive-programming.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-delegated-rendering.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-enumerations.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-external-libraries.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-def.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-editing.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-fitted-formats.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-query-systems.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-quick-reference.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-replicate-ai.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-spec-usage.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-styling-design.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-technical-rules.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-template-patterns.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-theme-design-system.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-file-structure/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-repair/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-restore/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-setup/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-sync/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-track/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/boxel-watch/SKILL.md create mode 100644 packages/software-factory/experiment_1/.agents/skills/software-factory-operations/SKILL.md create mode 100644 packages/software-factory/experiment_1/.claude/CLAUDE.md create mode 120000 packages/software-factory/experiment_1/.claude/skills create mode 100644 packages/software-factory/experiment_1/AGENTS.md create mode 100644 packages/software-factory/experiment_1/package-lock.json create mode 100644 packages/software-factory/experiment_1/package.json create mode 100644 packages/software-factory/experiment_1/playwright.realm.config.mjs create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/AgentProfile/adfdde24-d52d-4324-8f87-b46f42b2ea76.json create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/KnowledgeArticle/agent-onboarding.json create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/Project/02ecd21b-fd62-4e75-83d7-97da3af78b21.json create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/Project/demo-project.json create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/Ticket/ticket-001.json create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-schema.gts create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-ui.gts create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory.gts create mode 100644 packages/software-factory/experiment_1/realms/guidance-tasks/index.json create mode 100644 packages/software-factory/experiment_1/scripts/boxel-search.mjs create mode 100644 packages/software-factory/experiment_1/scripts/boxel-session.mjs create mode 100644 packages/software-factory/experiment_1/scripts/lib/boxel.mjs create mode 100644 packages/software-factory/experiment_1/scripts/pick-ticket.mjs create mode 100644 packages/software-factory/experiment_1/scripts/run-realm-tests.mjs create mode 100644 packages/software-factory/experiment_1/test-results/.last-run.json create mode 100644 packages/software-factory/experiment_1/tests/helpers/boxel-auth.mjs create mode 100644 packages/software-factory/experiment_1/tests/helpers/realm-test.mjs create mode 100644 packages/software-factory/experiment_1/tests/ticket-flow.spec.mjs diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/SKILL.md new file mode 100644 index 0000000000..f9d7696f8e --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/SKILL.md @@ -0,0 +1,66 @@ +--- +name: boxel-development +description: Use when working on Boxel card development, especially creating or editing `.gts` card definitions, `.json` card instances, Boxel commands, themes, queries, templates, or related Boxel patterns in a synced workspace. Read the targeted files in `references/` instead of loading broad guidance by default. +--- + +# Boxel Development + +Use this skill for Boxel card and app development. Keep the top-level guidance lean and load only the references needed for the task. + +## Core Workflow + +1. Confirm whether the task is about a card definition, card instance, query, command, theme, file asset, or styling. +2. Read only the specific reference files that match the task. +3. For file placement, naming, or `adoptsFrom.module` paths, also read `../boxel-file-structure/SKILL.md`. +4. Apply the rules from the relevant references exactly when they are marked critical. +5. Ignore Boxel in-app editor instructions unless you are explicitly operating inside that environment. In this repo, prefer normal filesystem edits and CLI workflows. + +## Always Load First + +- `references/dev-core-concept.md` +- `references/dev-technical-rules.md` +- `references/dev-quick-reference.md` + +These three files establish the data model, the `contains` vs `linksTo` rule, required formats, inherited fields, and common import patterns. + +## Load By Task + +- Card structure and safe patterns: + `references/dev-core-patterns.md` +- Templates, delegated rendering, and field access: + `references/dev-template-patterns.md` + `references/dev-delegated-rendering.md` +- Styling and themes: + `references/dev-theme-design-system.md` + `references/dev-styling-design.md` + `references/dev-fitted-formats.md` +- Queries and data linking: + `references/dev-query-systems.md` + `references/dev-data-management.md` +- File-backed content and file asset cards: + `references/dev-file-def.md` +- Enum fields: + `references/dev-enumerations.md` +- Defensive component logic: + `references/dev-defensive-programming.md` +- Third-party libraries: + `references/dev-external-libraries.md` +- Command implementation: + `references/dev-command-development.md` +- Spec usage: + `references/dev-spec-usage.md` +- Replicate integration: + `references/dev-replicate-ai.md` + +## Usually Ignore Unless Explicitly Relevant + +- `references/dev-file-editing.md` + This is primarily for Boxel's in-app AI editing flow, not normal terminal-based editing. + +## Key Reminders + +- `CardDef` and `FileDef` references use `linksTo` / `linksToMany`. +- `FieldDef` values use `contains` / `containsMany`. +- Modern cards should implement `isolated`, `embedded`, and `fitted`. +- Be precise with relative JSON module paths. +- Prefer loading one or two reference files over reading the whole reference set. diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-command-development.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-command-development.md new file mode 100644 index 0000000000..3a5acadd0d --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-command-development.md @@ -0,0 +1,161 @@ +## Command Development Essentials + +Commands extend `Command` and execute workflows through host APIs. + +### Core Structure + +```gts +import { Command } from '@cardstack/runtime-common'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +class MyInput extends CardDef { + @field targetRealm = contains(StringField); +} + +export class MyCommand extends Command { + static actionVerb = 'Process'; + async getInputType() { return MyInput; } + + protected async run(input: MyInput): Promise { + // Validation first + if (!input.targetRealm) throw new Error('Target realm required'); + + // Execute workflow + // Return result or undefined + } +} +``` + +### Host Commands (IO Operations) + +**Never use `fetch` directly - always use host commands:** + +```gts +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy'; +import SearchCardsByQueryCommand from '@cardstack/boxel-host/commands/search-cards-by-query'; + +// Save a card +await new SaveCardCommand(this.commandContext).execute({ + card: myCard, + realm: 'https://realm-url/' +}); + +// Get a card +const card = await new GetCardCommand(this.commandContext).execute({ + cardId: 'https://realm/Card/id' +}); + +// External API call +const response = await new SendRequestViaProxyCommand(this.commandContext).execute({ + url: 'https://api.example.com/endpoint', + method: 'POST', + requestBody: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' } +}); +``` + +### OpenRouter API Pattern + +```gts +const headers = { + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://realms-staging.stack.cards', + 'X-Title': 'Your App Name' +}; + +const response = await new SendRequestViaProxyCommand(ctx).execute({ + url: 'https://openrouter.ai/api/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'google/gemini-2.5-flash', + messages: [{ role: 'user', content: 'Your prompt' }] + }), + headers +}); + +if (!response.response.ok) throw new Error('API call failed'); +const data = await response.response.json(); +const text = data.choices?.[0]?.message?.content ?? ''; +``` + +### Catalog Command Delegation + +**Reuse existing commands instead of reimplementing:** + +```gts +import UploadImageCommand from 'https://realms-staging.stack.cards/catalog/commands/upload-image'; + +const result = await new UploadImageCommand(this.commandContext).execute({ + sourceImageUrl: dataUrl, + targetRealmUrl: input.realm +}); +``` + +### Query Pattern in Commands + +```gts +import SearchCardsByQueryCommand from '@cardstack/boxel-host/commands/search-cards-by-query'; + +const results = await new SearchCardsByQueryCommand(this.commandContext).execute({ + query: { + filter: { + on: { module: new URL('./product', import.meta.url).href, name: 'Product' }, + eq: { status: 'active' } + } + }, + realmURLs: [input.realm] +}); +``` + +### Progress Tracking + +```gts +import { tracked } from '@glimmer/tracking'; + +export class MyCommand extends Command { + @tracked step: 'idle' | 'processing' | 'completed' | 'error' = 'idle'; + + protected async run(input: Input): Promise { + this.step = 'processing'; + try { + // Do work + this.step = 'completed'; + } catch (e) { + this.step = 'error'; + throw e; + } + } +} +``` + +### Menu Integration + +```gts +import { getCardMenuItems } from '@cardstack/runtime-common'; + +[getCardMenuItems](params: GetCardMenuItemParams): MenuItemOptions[] { + return [{ + label: 'My Action', + icon: MyIcon, + action: async () => { + await new MyCommand(params.commandContext).execute({ + cardId: this.id, + realm: params.realmURL + }); + await params.saveCard(this); + } + }, ...super[getCardMenuItems](params)]; +} +``` + +### Critical Rules + +- ✅ **Validate inputs first** - fail early with clear errors +- ✅ **Use host commands for all IO** - never `fetch` directly +- ✅ **Include `on` in queries** - for eq/contains/range filters +- ✅ **Delegate to catalog commands** - don't reimplement uploads/services +- ✅ **Wrap JSON parsing in try-catch** - handle malformed responses +- ✅ **Track progress states** - use `@tracked` for UI feedback \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-concept.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-concept.md new file mode 100644 index 0000000000..2e8318cdb1 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-concept.md @@ -0,0 +1,139 @@ +## Foundational Concepts + +### The Boxel Universe + +Boxel is a composable card-based system where information lives in self-contained, reusable units. Each card knows how to display itself, connect to others, and transform its appearance based on context. + +- **Card:** The central unit of information and display + - **Definition (`CardDef` in `.gts`):** Defines the structure (fields) and presentation (templates) of a card type + - **Instance (`.json`):** Represents specific data conforming to a Card Definition + +- **Field:** Building blocks within a Card + - **Base Types:** System-provided fields (StringField, NumberField, etc.) + - **Custom Fields (`FieldDef`):** Reusable composite field types you define + +- **File (`FileDef` in `.gts`):** A card-like way of interacting with images, documents, and other assets stored in the realm + - **Instances:** Actual files (`.png`, `.md`, `.csv`, etc.) indexed automatically with metadata extracted + - **Subtypes:** `ImageDef`, `PngDef`, `MarkdownDef`, `CsvFileDef`, and others for specific formats + - **Referenced with `linksTo`**, never `contains` — FileDef instances have their own identity like cards + +- **Realm/Workspace:** Your project's root directory. All imports and paths are relative to this context + +- **Formats:** Different visual representations of the same card: + - `isolated`: Full detailed view (should be scrollable for long content) + - `embedded`: Compact view for inclusion in other cards + - `fitted`: **🚨 ESSENTIAL** - Fixed dimensions for grids/galleries/dashboards (parent sets both width AND height) + - `atom`: Minimal inline representation + - `edit`: Form for data modification (default provided, override only if needed) + +**🔴 CRITICAL:** Modern Boxel cards require ALL THREE display formats: isolated, embedded, AND fitted. Missing custom fitted format will fallback to basic fitted view that won't look very nice or have enough info to show in grids, choosers, galleries, or dashboards. + +## Decision Trees + +**Data Structure Choice:** + +``` +Needs own identity? → CardDef with linksTo +Referenced from multiple places? → CardDef with linksTo +Referencing a file (image, doc, etc.)? → FileDef subtype with linksTo +Just compound data? → FieldDef with contains +``` + +**Field Extension Choice:** + +``` +Want to customize a base field? → import BaseField, extend it +Creating new field type? → extends FieldDef directly +Adding to existing field? → extends BaseFieldName +``` + +**Value Setup:** + +``` +Computed from other fields? → computeVia +User-editable with default? → Field literal or computeVia +Simple one-time value? → Field literal +``` + +**Circular Dependencies?** + +``` +Use arrow function: () => Type +``` + +## ✅ Quick Mental Check Before Every Field + +Ask yourself: "Does this type extend CardDef or FieldDef?" + +- Extends **CardDef** → MUST use `linksTo` or `linksToMany` +- Extends **FieldDef** → MUST use `contains` or `containsMany` +- **No exceptions!** + +For computed fields, ask: "Am I keeping this simple and unidirectional?" + +- Only reference base fields, never self-reference +- No circular dependencies between computed fields +- Wrap in try-catch when accessing relationships +- If it feels complex, simplify it! + +## Foundation Quick Reference + +**Data Structure Choice:** + +- Needs own identity? → `CardDef` with `linksTo` +- Referenced from multiple places? → `CardDef` with `linksTo` +- Just compound data? → `FieldDef` with `contains` + +**Formats (what they are):** + +- `isolated` - Full detailed view (scrollable) +- `embedded` - Compact for inclusion in other cards +- `fitted` - Fixed dimensions for grids/galleries +- `atom` - Minimal inline representation +- `edit` - Form for data modification + +**Every CardDef inherits:** + +- `title`, `description`, `thumbnailURL` + +### Inherited Fields and CardInfo + +**IMPORTANT:** Every CardDef automatically inherits these base fields from the CardDef base class: + +#### Direct Inherited Fields (Read-Only) + +- `title` (StringField) - Computed pass-through from `cardInfo.title` +- `description` (StringField) - Computed pass-through from `cardInfo.description` +- `thumbnailURL` (StringField) - Computed pass-through from `cardInfo.thumbnailURL` + +#### CardInfo Field (User-Editable) + +Every card also inherits a `cardInfo` field which contains the actual user-editable values: + +- `cardInfo.title` (StringField) - User-editable card title +- `cardInfo.description` (StringField) - User-editable card description +- `cardInfo.thumbnailURL` (StringField) - User-editable thumbnail image URL +- `cardInfo.theme` (linksTo ThemeCard) - Optional theme card link +- `cardInfo.notes` (MarkdownField) - Optional internal notes + +**How It Works:** +The top-level `title`, `description`, and `thumbnailURL` fields are computed properties that automatically pass through the values from `cardInfo.title`, `cardInfo.description`, and `cardInfo.thumbnailURL` respectively. This means: + +- When you read `@model.title` in templates, you get the value from `cardInfo.title` +- Users edit values through the `cardInfo` field in edit mode +- Override to add custom logic that respects user input + +**Best Practice:** Define your own primary field and compute `title` to respect user's `cardInfo.title` choice: + +```gts +export class BlogPost extends CardDef { + @field headline = contains(StringField); // Your primary field + + // Override inherited title - respects user's cardInfo.title if set + @field title = contains(StringField, { + computeVia: function () { + return this.cardInfo?.title ?? this.headline ?? 'Untitled'; + }, + }); +} +``` diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-patterns.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-patterns.md new file mode 100644 index 0000000000..204e5e5736 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-core-patterns.md @@ -0,0 +1,346 @@ +**Card with computed title:** +```gts +export class BlogPost extends CardDef { + @field headline = contains(StringField); + + @field title = contains(StringField, { + computeVia: function(this: BlogPost) { + return this.headline ?? 'Untitled Post'; + } + }); +} +``` + +**Field definition:** +```gts +export class AddressField extends FieldDef { + @field street = contains(StringField); + @field city = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} +``` + +## Core Patterns + +### 1. Card Definition with Safe Computed Title +```gts +import { CardDef, field, contains, linksTo, containsMany, linksToMany, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import DateField from 'https://cardstack.com/base/date'; +import FileTextIcon from '@cardstack/boxel-icons/file-text'; +import { Author } from './author'; + +export class BlogPost extends CardDef { + static displayName = 'Blog Post'; + static icon = FileTextIcon; // ✅ CORRECT: Boxel icons for static card/field type icons + static prefersWideFormat = true; + + @field headline = contains(StringField); + @field publishDate = contains(DateField); + @field author = linksTo(Author); + @field tags = containsMany(TagField); + @field relatedPosts = linksToMany(() => BlogPost); + + @field title = contains(StringField, { + computeVia: function(this: BlogPost) { + try { + const baseTitle = this.headline ?? 'Untitled Post'; + const maxLength = 50; + if (baseTitle.length <= maxLength) return baseTitle; + return baseTitle.substring(0, maxLength - 3) + '...'; + } catch (e) { + console.error('BlogPost: Error computing title', e); + return 'Untitled Post'; + } + } + }); +} +``` + +### 2. Field Definition (Always Include Embedded Template) + +**CRITICAL:** Every FieldDef file must import FieldDef and MUST be exported: + +```gts +import { FieldDef, field, contains, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import LocationIcon from '@cardstack/boxel-icons/map-pin'; +import { concat } from '@ember/helper'; + +export class AddressField extends FieldDef { + static displayName = 'Address'; + static icon = LocationIcon; // ✅ CORRECT: Boxel icons for static card/field type icons + + @field street = contains(StringField); + @field city = contains(StringField); + @field postalCode = contains(StringField); + @field country = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} +``` + +### 3. Computed Properties with Safety + +**CRITICAL:** Avoid cycles and infinite recursion in computed fields. + +```gts +// ❌ DANGEROUS: Self-reference causes infinite recursion +@field title = contains(StringField, { + computeVia: function(this: BlogPost) { + return this.title || 'Untitled'; // STACK OVERFLOW! + } +}); + +// ✅ SAFE: Reference only base fields +@field fullName = contains(StringField, { + computeVia: function(this: Person) { + try { + const first = this.firstName ?? ''; + const last = this.lastName ?? ''; + const full = first + ' ' + last; + return full.trim() || 'Name not provided'; + } catch (e) { + console.error('Person: Error computing fullName', e); + return 'Name unavailable'; + } + } +}); +``` + +### 4. Templates with Proper Computation Patterns + +**Remember:** When implementing templates via SEARCH/REPLACE, track all major sections with ⁿ and include the post-block notation `╰ ⁿ⁻ᵐ` + +```gts +static isolated = class Isolated extends Component { // ³⁰ Isolated format + @tracked showComments = false; + + // ³¹ CRITICAL: Do ALL computation in functions, never in templates + get safeTitle() { + try { + return this.args?.model?.title ?? 'Untitled Post'; + } catch (e) { + console.error('BlogPost: Error accessing title', e); + return 'Untitled Post'; + } + } + + get commentButtonText() { + try { + const count = this.args?.model?.commentCount ?? 0; + return this.showComments ? `Hide Comments (${count})` : `Show Comments (${count})`; + } catch (e) { + console.error('BlogPost: Error computing comment button text', e); + return this.showComments ? 'Hide Comments' : 'Show Comments'; + } + } + + // methods referenced from templates must be defined with fat arrow (=>) so that they are properly bound when invoked + toggleComments = () => { + this.showComments = !this.showComments; + } + + +}; +``` + +### WARNING: Do NOT Use Constructors for Default Values + +**CRITICAL:** Constructors should NOT be used for setting default values in Boxel cards. Use template fallbacks (if field is editable) or computeVia (only if field is strictly read-only) instead. + +```gts +// ❌ WRONG - Never use constructors for defaults +export class Todo extends CardDef { + constructor(owner: unknown, args: {}) { + super(owner, args); + this.createdDate = new Date(); // DON'T DO THIS + this.isCompleted = false; // DON'T DO THIS + } +} +``` + +### **CRITICAL: NEVER Create JavaScript Objects in Templates** + +**Templates are for simple display logic only.** Never call constructors, create objects, or perform complex operations in template expressions. + +```hbs + +{{if @model.currentMonth @model.currentMonth (formatDateTime (new Date()) "MMMM YYYY")}} +
{{someFunction(@model.data)}}
+ + +{{if @model.currentMonth @model.currentMonth this.currentMonthDisplay}} +
{{this.processedData}}
+``` + +```gts +// ✅ CORRECT: Define logic in JavaScript +export class MyCard extends CardDef { + get currentMonthDisplay() { + return new Intl.DateTimeFormat('en-US', { + month: 'long', + year: 'numeric' + }).format(new Date()); + } + + get processedData() { + return this.args.model?.data ? this.processData(this.args.model.data) : 'No data'; + } + + private processData(data: any) { + // Complex processing logic here + return result; + } +} +``` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-data-management.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-data-management.md new file mode 100644 index 0000000000..d2780407cc --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-data-management.md @@ -0,0 +1,156 @@ +## File Organization + +### Single App Structure +``` +my-realm/ +├── blog-post.gts # Card definition (kebab-case) +├── author.gts # Another card +├── address-field.gts # Field definition (kebab-case-field) +├── BlogPost/ # Instance directory (PascalCase) +│ ├── hello-world.json # Instance (any-name) +│ └── second-post.json +└── Author/ + └── jane-doe.json +``` + +### Related Cards App Structure +**CRITICAL:** When creating apps with multiple related cards, organize them in common folders: + +``` +my-realm/ +├── ecommerce/ # Common folder for related cards +│ ├── product.gts # Card definitions +│ ├── order.gts +│ ├── customer.gts +│ ├── Product/ # Instance directories +│ │ └── laptop-pro.json +│ └── Order/ +│ └── order-001.json +├── blog/ # Another app's folder +│ ├── post.gts +│ ├── author.gts +│ └── Post/ +│ └── welcome.json +└── shared/ # Shared components + └── address-field.gts # Common field definitions +``` + +**Directory Discipline:** When creating files within a specific directory structure (e.g., `ecommerce/`), keep ALL related files within that structure. Don't create files outside the intended directory organization. + +**Relationship Path Tracking:** When creating related JSON instances, maintain a mental map of your file paths. Links between instances must use the exact relative paths you've created - consistency prevents broken relationships. + +## JSON Instance Format Quick Reference + +**When creating `.json` card instances via SEARCH/REPLACE, follow this structure:** + +**Naming:** Use natural names for JSON files (e.g., `Author/jane-doe.json`, `Product/laptop-pro.json`) - don't append `-sample-data` + +**Path Consistency:** When creating multiple related JSON instances, track the exact file paths you create. Relationship links must match these paths exactly - if you create `Author/dr-nakamura.json`, reference it as `"../Author/dr-nakamura"` from other instances. + +### Root Structure +All data wrapped in a `data` object with: +* `type`: Always `"card"` for instances +* `attributes`: Field values go here +* `relationships`: Links to other cards +* `meta.adoptsFrom`: Connection to GTS definition + +### Instance Template +```json +{ + "data": { + "type": "card", + "attributes": { + // Field values here + }, + "relationships": { + // Card links here + }, + "meta": { + "adoptsFrom": { + "module": "../path-to-gts-file", + "name": "CardDefClassName" + } + } + } +} +``` + +### Field Value Patterns + +**Simple fields** (`contains(StringField)`, etc.): +```json +"attributes": { + "title": "My Title", + "price": 29.99, + "isActive": true +} +``` + +**Compound fields** (`contains(AddressField)` - a FieldDef): +```json +"attributes": { + "address": { + "street": "4827 Riverside Terrace", + "city": "Portland", + "postalCode": "97205" + } +} +``` + +**Array fields** (`containsMany`): +```json +"attributes": { + "tags": ["urgent", "review", "frontend"], + "phoneNumbers": [ + { "number": "+1-503-555-0134", "type": "work" }, + { "number": "+1-971-555-0198", "type": "mobile" } + ] +} +``` + +### Relationship Patterns + +**Single link** (`linksTo`): +```json +"relationships": { + "author": { + "links": { + "self": "../Author/dr-nakamura" + } + } +} +``` + +**Multiple links** (`linksToMany`) - note the `.0`, `.1` pattern: +```json +"relationships": { + "teamMembers.0": { + "links": { "self": "../Person/kai-nakamura" } + }, + "teamMembers.1": { + "links": { "self": "../Person/esperanza-cruz" } + } +} +``` + +**Empty linksToMany** - when no relationships exist: +```json +"relationships": { + "nextLevels": { + "links": { + "self": null + } + } +} +``` +Note: Use `null`, not an empty array `[]` + +### Path Conventions +* **Module paths**: Relative to JSON location, no `.gts` extension + * Local: `"../author"` or `"../../shared/address-field"` + * Base: `"https://cardstack.com/base/string"` +* **Relationship paths**: Relative paths, no `.json` extension + * `"../Author/jane-doe"` not `"../Author/jane-doe.json"` +* **Date formats**: + * DateField: `"2024-11-15"` + * DateTimeField: `"2024-11-15T10:00:00Z"` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-defensive-programming.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-defensive-programming.md new file mode 100644 index 0000000000..361474c72f --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-defensive-programming.md @@ -0,0 +1,118 @@ +**Always use optional chaining:** +```js +// ❌ UNSAFE +if (this.args.model.items.includes(x)) { } + +// ✅ SAFE +if (this.args.model?.items?.includes?.(x)) { } +``` + +**Provide defaults:** +```js +return (this.args.model?.progress ?? 0) + 10; +``` + +**Wrap cross-card access in try-catch:** +```js +get authorName() { + try { + const author = this.args?.model?.author; + return author?.name ?? 'Unknown Author'; + } catch (e) { + console.error('Error accessing author', e); + return 'Author Unavailable'; + } +} +``` + +## Defensive Programming in Boxel Components + +**CRITICAL:** Prevent runtime errors by safely handling undefined/null values and malformed data. Cards boot with no data by default - every component must handle completely empty state gracefully. + +### Essential Defensive Patterns + +#### Always Use Optional Chaining (`?.`) +```js +// ❌ UNSAFE: Will throw if model is undefined +if (this.args.model.completedDays.includes(day)) { ... } + +// ✅ SAFE: Optional chaining prevents errors +if (this.args.model?.completedDays?.includes?.(day)) { ... } +``` + +#### Provide Default Values (`??`) +```js +// ❌ UNSAFE: May result in NaN +return this.args.model.progress + 10; + +// ✅ SAFE: Default value prevents NaN +return (this.args.model?.progress ?? 0) + 10; +``` + +#### Try-Catch for Network of Cards +When accessing data across card relationships, always wrap in try-catch to handle missing or malformed data: + +```js +// ³⁷ In computed properties or methods +get authorDisplayName() { + try { + const author = this.args?.model?.author; + if (!author) { + console.warn('BlogPost: No author assigned'); + return 'Unknown Author'; + } + + const name = author.name || author.title; + if (!name) { + console.warn('BlogPost: Author exists but has no name', { authorId: author.id }); + return 'Unnamed Author'; + } + + return name; + } catch (error) { + console.error('BlogPost: Error accessing author data', { + error, + postId: this.args.model?.id, + authorData: this.args.model?.author + }); + return 'Author Unavailable'; + } +} + +// ³⁸ In template getters +get relatedPostsSummary() { + try { + const posts = this.args.model?.relatedPosts; + if (!Array.isArray(posts)) { + return 'No related posts'; + } + + return posts + .filter(post => post?.title) // Skip malformed entries + .map(post => post.title) + .join(', ') || 'No related posts'; + + } catch (error) { + console.error('BlogPost: Failed to process related posts', error); + return 'Related posts unavailable'; + } +} +``` + +#### Validate Arrays Before Operations +```js +// ❌ UNSAFE: May throw if not an array +const sorted = this.completedDays.sort((a, b) => a - b); + +// ✅ SAFE: Check existence and type first +if (!Array.isArray(this.completedDays) || !this.completedDays.length) { + return []; +} +const sorted = [...this.completedDays].sort((a, b) => a - b); +``` + +**Key Principles:** +- Assume data might be missing, null, or the wrong type +- Provide meaningful fallbacks for user display +- Log errors with context for debugging (include IDs, data state) +- Never let malformed data crash your UI \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-delegated-rendering.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-delegated-rendering.md new file mode 100644 index 0000000000..d5329cd13b --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-delegated-rendering.md @@ -0,0 +1,187 @@ +**Delegated rendering:** +```hbs + +<@fields.author @format="embedded" /> +<@fields.items @format="embedded" /> +``` + +**Make cards clickable:** +```hbs + + + +``` + +**Avoid cycles:** +```gts +// Canonical links only +@field supervisor = linksTo(() => Employee); + +// Query for reverse +get directReportsQuery() { + return { + filter: { + on: { module: './employee', name: 'Employee' }, + eq: { supervisor: this.args.model.id } + } + }; +} +``` + +### BoxelSelect: Smart Dropdown Menus + +Regular HTML selects are limited to plain text. BoxelSelect lets you create rich, searchable dropdowns with custom rendering. + +#### Pattern: Rich Select with Custom Options + +```gts +export class OptionField extends FieldDef { // ⁴³ Option field for select + static displayName = 'Option'; + + @field key = contains(StringField); + @field label = contains(StringField); + @field description = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} + +export class ProductCategory extends CardDef { // ⁴⁴ Card using BoxelSelect + @field selectedCategory = contains(OptionField); + + static edit = class Edit extends Component { // ⁴⁵ Edit format + @tracked selectedOption = this.args.model?.selectedCategory; + + options = [ + { key: '1', label: 'Electronics', description: 'Phones, computers, and gadgets' }, + { key: '2', label: 'Clothing', description: 'Fashion and apparel' }, + { key: '3', label: 'Home & Garden', description: 'Furniture and decor' } + ]; + + updateSelection = (option: typeof this.options[0] | null) => { + this.selectedOption = option; + this.args.model.selectedCategory = option ? new OptionField(option) : null; + } + + + }; +} +``` + +### Custom Edit Controls + +Create user-friendly edit controls that accept natural input. Hide complexity in expandable sections while keeping ALL properties editable and inspectable. + +```gts +// Example: Natural language time period input +static edit = class Edit extends Component { + @tracked showDetails = false; + + parseInput = (value: string) => { + // Parse "Q1 2025" → quarter: 1, year: 2025, startDate: Jan 1, endDate: Mar 31 + // Parse "April 2025" → month: 4, year: 2025, startDate: Apr 1, endDate: Apr 30 + } + + +}; +``` + +### Alternative: Using the viewCard API + +Instead of making entire cards clickable, you can create custom buttons or links that use the `viewCard` API to open cards in specific formats. + +#### Basic Implementation + +```javascript +viewOrder = (order: ProductOrder) => { + // Open order in isolated view + this.args.viewCard(order, 'isolated'); +}; + +editOrder = (order: ProductOrder) => { + // Open card in rightmost stack for side-by-side reference + // Useful for: 1) reference lookup, 2) edit panel on right while previewing on left + this.args.viewCard(order, 'edit', { + openCardInRightMostStack: true + }); +}; + +viewReturnPolicy = () => { + // Open card using URL + const returnPolicyURL = new URL('https://app.boxel.ai/markinc/storefront/ReturnPolicy/return-policy-0525.json'); + this.args.viewCard(returnPolicyURL, 'isolated'); +}; +``` + +#### Template Example + +```hbs +
+ +
+ + + +
+ + +
+``` + +#### Available Formats + +- `'isolated'` - Read-oriented mode, may have some editable forms or interactive widgets +- `'edit'` - Open card for full editing + +#### Use Cases +- Multiple direct call-to-actions per card (view, edit) +- More control over user interactions +- Link to any card via a card URL \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-enumerations.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-enumerations.md new file mode 100644 index 0000000000..20cdae1abb --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-enumerations.md @@ -0,0 +1,209 @@ +## Enum Field Essentials + +**CRITICAL Import Syntax:** +```gts +import enumField from 'https://cardstack.com/base/enum'; // Default import, not { enumField } +``` + +**Quick Start:** +```gts +const StatusField = enumField(StringField, { options: ['Open', 'Closed'] }); +@field status = contains(StatusField); +``` + +**Template:** `<@fields.status />` renders a BoxelSelect in edit mode. + +**Rich options with labels/icons:** +```gts +enumField(StringField, { + options: [ + { value: 'high', label: 'High Priority', icon: ArrowUpIcon }, + { value: 'low', label: 'Low Priority', icon: ArrowDownIcon } + ] +}) +``` + +**Key helpers:** +- `enumValues(card, 'fieldName')` → array of primitive values +- `enumOptions(card, 'fieldName')` → normalized `{ value, label, icon? }` + + + +# Enum Fields + +## Purpose + +Use `enumField(BaseField, { options })` to create a `FieldDef` with constrained values and a default dropdown editor. Works with primitive bases (e.g., `StringField`, `NumberField`). + +## Import Syntax + +**CRITICAL:** Use default import, not destructured import: + +```gts +// ✅ CORRECT +import enumField from 'https://cardstack.com/base/enum'; + +// ❌ WRONG +import { enumField } from 'https://cardstack.com/base/enum'; +``` + +## Quick Start + +**Define:** +```gts +const StatusField = enumField(StringField, { options: ['Open', 'Closed'] }); +``` + +**Use:** +```gts +@field status = contains(StatusField); +``` + +**Template:** +```hbs +<@fields.status /> {{! Renders a BoxelSelect in edit mode }} +``` + +## Rich Options (Labels/Icons) + +```gts +enumField(StringField, { + options: [ + { value: 'high', label: 'High', icon: ArrowUpIcon }, + { value: 'medium', label: 'Medium', icon: MinusIcon }, + { value: 'low', label: 'Low', icon: ArrowDownIcon } + ] +}) +``` + +Editor shows labels/icons; stored value is the primitive `value`. + +## Dynamic Options + +**Provide a function:** +```gts +enumField(StringField, { + options: function() { + return this.someList; + } +}) +``` + +**Per-usage override:** +```gts +contains(Field, { + configuration: enumConfig(function() { + return { options: this.someList }; + }) +}) +``` + +**Note:** `this` is the containing card or field + +## Helpers + +**enumValues** - Get array of primitive values: +```gts +enumValues(card, 'enumFieldName') // → ['High', 'Medium', 'Low'] +``` + +**enumOptions** - Get normalized option objects: +```gts +enumOptions(card, 'enumFieldName') // → [{ value, label, icon? }, ...] +``` + +## Null Handling + +If current value is `null` and `null` isn't in options, placeholder uses `unsetLabel` or "Choose…". + +To make `null` selectable: +```gts +{ value: null, label: 'None' } +``` + +## Limitations + +- **Compound field values:** Not yet supported +- **Card values:** Not yet supported + +## Validation and Behavior + +- Duplicate values throw during option normalization +- Query and serialization follow the base field +- Enum wrapping does not change data shape + +## Minimal Example + +**Define:** +```gts +import enumField from 'https://cardstack.com/base/enum'; +const Priority = enumField(StringField, { options: ['High', 'Medium', 'Low'] }); +``` + +**Use:** +```gts +class Task extends CardDef { + @field priority = contains(Priority); +} +``` + +**Template:** +```hbs +<@fields.priority /> +{{enumValues @model 'priority'}} {{! ['High','Medium','Low'] }} +``` + +## Factory vs Usage (Clarity) + +**Factory defaults:** +```gts +enumField(Base, { options }) // For simple/static defaults +``` + +**Usage overrides:** +```gts +contains(Field, { + configuration: enumConfig(function() { + return { options }; + }) +}) // For per-instance behavior +``` + +Both resolve to `@configuration.enum.options` for templates/formats. + +## Callback Context + +`computeVia`, `enumField` options functions, and `enumConfig` usage callbacks all receive the containing instance as `this`. + +**Prefer `function() { ... }` (not arrow)** to ensure `this` is bound to the parent instance. + +**Guidance:** Keep callbacks side-effect free; derive options synchronously from `this`. + +## Complete Example + +```gts +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import enumField from 'https://cardstack.com/base/enum'; +import ArrowUpIcon from '@cardstack/boxel-icons/arrow-up'; +import ArrowDownIcon from '@cardstack/boxel-icons/arrow-down'; + +const PriorityField = enumField(StringField, { + options: [ + { value: 'high', label: 'High Priority', icon: ArrowUpIcon }, + { value: 'medium', label: 'Medium Priority' }, + { value: 'low', label: 'Low Priority', icon: ArrowDownIcon } + ] +}); + +export class Task extends CardDef { + @field taskName = contains(StringField); + @field priority = contains(PriorityField); + + @field title = contains(StringField, { + computeVia: function(this: Task) { + return this.taskName ?? 'Untitled Task'; + } + }); +} +``` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-external-libraries.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-external-libraries.md new file mode 100644 index 0000000000..c1b91012c3 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-external-libraries.md @@ -0,0 +1,96 @@ +**Async loading pattern:** +```gts +import { task, restartableTask, timeout } from 'ember-concurrency'; +import Modifier from 'ember-modifier'; + +private loadLibrary = task(async () => { + const script = document.createElement('script'); + script.src = 'https://cdn.../library.js'; + await new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +}); +``` + +**Key Rules:** +1. Use Modifiers for DOM access +2. Use ember-concurrency tasks for async +3. Bind external data to model fields +4. Provide loading states + +**Task types:** +- `task` - Concurrent execution +- `restartableTask` - Cancel previous, start new +- `enqueueTask` - Sequential queue +- `dropTask` - Ignore new while running + +## Async loading from within components + +For fetching data from external APIs, use `ember-concurrency`. The core of this principle are "tasks", which are a cancelable alternative to promises. The most used ones are `task`, and `restartableTask`: + +- task: Tasks run concurrently without any coordination, allowing multiple instances to execute simultaneously. +- restartableTask: Cancels any running task and immediately starts a new one when performed, ensuring only the latest task runs. +- enqueueTask: Queues tasks to run sequentially one after another, ensuring no overlap but preserving all tasks. +- dropTask: Ignores new task requests while one is already running, preventing any additional instances from starting. +- keepLatest: Drops intermediate queued tasks but keeps the most recent one to run after the current task completes. + +Here is an example where we are: +- loading data when component is first rendered, +- reloading it when user clicks on a button, +- adding some artificial delay using `await timeout(ms)` from `ember-concurrency`. Caution: do not use `setTimeout`. + +``` +import { CardDef, field, contains, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import { tracked } from '@glimmer/tracking'; +import { restartableTask, timeout } from 'ember-concurrency'; +import { Button } from '@cardstack/boxel-ui/components'; +import { on } from '@ember/modifier'; +import perform from 'ember-concurrency/helpers/perform'; + +export class CurrencyLoader extends CardDef { + static displayName = 'Currency Loader'; + + @field loadingStatus = contains(StringField); + @field currencies = contains(StringField); + + static isolated = class Isolated extends Component { + constructor(owner: any, args: any) { + super(owner, args); + this.loadCurrencies.perform(); + } + + private loadCurrencies = restartableTask(async () => { + this.args.model.loadingStatus = 'Loading...'; + const response = await fetch('/api/currencies'); + await timeout(1000); // Visual feedback + + this.args.model.currencies = await response.json(); + this.args.model.loadingStatus = ""; + }); + + + }; +} +``` + +## External Libraries: Bringing Third-Party Power to Boxel + +**When to Use External Libraries:** Sometimes you need specialized functionality like 3D graphics (Three.js), data visualization (D3), or charts. Boxel plays well with external libraries when you follow the right patterns. + +**Key Rules:** +1. **Always use Modifiers for DOM access** - Never manipulate DOM directly +2. **Use ember-concurrency tasks** for async operations like loading libraries +3. **Bind external data to model fields** for reactive updates +4. **Use proper loading states** while libraries initialize \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-def.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-def.md new file mode 100644 index 0000000000..7282f1bcc4 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-def.md @@ -0,0 +1,166 @@ +## FileDef — First-Class File Support + +`FileDef` is a **third kind of "def"** in Boxel, alongside `CardDef` and `FieldDef`. A FileDef instance represents a file that lives in the realm — an image, document, or other asset — with metadata automatically extracted during indexing. + +### Key Rules + +- **FileDef instances have their own identity** (like cards), so you reference them with `linksTo`, never `contains` +- **Render them using the same display formats** — FileDefs have `isolated`, `embedded`, `fitted`, and `atom` templates +- **Files are not editable via the card edit interface** — users replace them by uploading a new file + +--- + +## Type Hierarchy + +``` +FileDef → any file + ├── ImageDef → any image (adds width, height) + │ ├── PngDef → .png files + │ ├── JpgDef → .jpg / .jpeg files + │ ├── SvgDef → .svg files + │ ├── GifDef → .gif files + │ ├── WebpDef → .webp files + │ └── AvifDef → .avif files + ├── MarkdownDef → .md / .markdown (adds title, excerpt, content) + ├── TextFileDef → .txt (adds title, excerpt, content) + ├── TsFileDef → .ts (adds title, excerpt, content) + ├── GtsFileDef → .gts (extends TsFileDef) + ├── JsonFileDef → .json (adds title, excerpt, content) + └── CsvFileDef → .csv (adds title, excerpt, content, columns, columnCount, rowCount) +``` + +**Use the most specific type that fits.** Prefer `PngDef` over `ImageDef` when you specifically need PNG; prefer `ImageDef` over `FileDef` when any image format is acceptable. +**This set is not extensible by Boxel users (currently).** The Boxel project provides these types and only new releases of boxel can add new ones. This may change in the future. + +--- + +## Import Paths + +```gts +import FileDef from 'https://cardstack.com/base/file-api'; + +// Image types +import ImageDef from 'https://cardstack.com/base/image-file-def'; +import PngDef from 'https://cardstack.com/base/png-image-def'; +import JpgDef from 'https://cardstack.com/base/jpg-image-def'; +import SvgDef from 'https://cardstack.com/base/svg-image-def'; +import GifDef from 'https://cardstack.com/base/gif-image-def'; +import WebpDef from 'https://cardstack.com/base/webp-image-def'; +import AvifDef from 'https://cardstack.com/base/avif-image-def'; + +// Document / text types +import MarkdownDef from 'https://cardstack.com/base/markdown-file-def'; +import TextFileDef from 'https://cardstack.com/base/text-file-def'; +import TsFileDef from 'https://cardstack.com/base/ts-file-def'; +import GtsFileDef from 'https://cardstack.com/base/gts-file-def'; +import JsonFileDef from 'https://cardstack.com/base/json-file-def'; +import CsvFileDef from 'https://cardstack.com/base/csv-file-def'; +``` + +--- + +## Available Fields + +Every FileDef instance exposes these base fields: + +| Field | Type | Description | +| ------------- | ------ | ---------------------------- | +| `id` | string | URL identifier of the file | +| `url` | string | Current URL of the file | +| `sourceUrl` | string | Original source URL | +| `name` | string | Filename (e.g. `photo.png`) | +| `contentType` | string | MIME type (e.g. `image/png`) | +| `contentHash` | string | MD5 hash of file content | +| `contentSize` | number | File size in bytes | + +Additional fields added by subtype: + +| Type | Extra Fields | +| ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `ImageDef` + all image subtypes | `width` (px), `height` (px) | +| `MarkdownDef`, `TextFileDef`, `TsFileDef`, `GtsFileDef`, `JsonFileDef` | `title`, `excerpt`, `content` (full text) | +| `CsvFileDef` | `title`, `excerpt`, `content`, `columns` (array), `columnCount`, `rowCount` | + +--- + +## Using FileDef in Cards + +```gts +import { CardDef, field, linksTo } from 'https://cardstack.com/base/card-api'; +import ImageDef from 'https://cardstack.com/base/image-file-def'; +import PngDef from 'https://cardstack.com/base/png-image-def'; +import FileDef from 'https://cardstack.com/base/file-api'; +import MarkdownDef from 'https://cardstack.com/base/markdown-file-def'; + +export class ProductListing extends CardDef { + @field photo = linksTo(PngDef); // Specifically PNG + @field banner = linksTo(ImageDef); // Any image format + @field attachment = linksTo(FileDef); // Any file type + @field readme = linksTo(MarkdownDef); // Markdown document +} +``` + +--- + +## Rendering File Fields in Templates + +Use `<@fields.fieldName />` exactly as with any other field. The built-in display components handle rendering automatically. + +```gts +static isolated = class Isolated extends Component { + +}; +``` + +**Image built-in formats:** + +- `isolated` → full-size image + filename + dimensions footer +- `embedded` → responsive `` that fills its container width +- `fitted` → `background-image: cover` for fixed-size grid cells +- `atom` → 20 px thumbnail + filename inline + +--- + +## MarkdownDef vs MarkdownField + +These are completely different and are **not interchangeable**: + +| | `MarkdownDef` | `MarkdownField` | +| ------------------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **Kind** | FileDef — a `.md` file in the realm | FieldDef — inline text stored in the card's JSON | +| **Import** | `https://cardstack.com/base/markdown-file-def` | `https://cardstack.com/base/markdown` | +| **Declaration** | `@field notes = linksTo(MarkdownDef)` | `@field notes = contains(MarkdownField)` | +| **Stored as** | Separate `.md` file referenced by URL | String embedded in the card's `.json` | +| **Has own URL?** | ✅ Yes — shareable and reusable | ❌ No — owned by the containing card | +| **Editable in card UI?** | ❌ No — replaced by uploading a new file | ✅ Yes — inline markdown editor | +| **Extra fields** | `title`, `excerpt`, `content` auto-extracted | Raw markdown string only | +| **Use when** | Stand-alone documents, content shared across cards, files managed outside Boxel | Inline rich text that belongs to the card, like a description or body field | + +--- + +## FileDef vs Base64ImageField + +**🚨 Do NOT use `Base64ImageField` for images.** Use an image FileDef type instead. + +| | FileDef (`ImageDef`, `PngDef`, etc.) | `Base64ImageField` | +| ------------------- | ------------------------------------ | ---------------------------------------- | +| **Storage** | Separate file in the realm | Base64 data embedded in the card's JSON | +| **AI context cost** | ✅ Minimal — just a URL reference | ❌ Extremely large — can exhaust context | +| **Shareable** | ✅ Yes — has its own URL | ❌ No — embedded in one card | +| **Performance** | ✅ Standard HTTP caching | ❌ Bloated JSON payloads | +| **Use** | ✅ Always prefer this | ⚠️ Avoid | diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-editing.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-editing.md new file mode 100644 index 0000000000..ff9476438c --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-file-editing.md @@ -0,0 +1,85 @@ +### SEARCH/REPLACE Essentials + +**Every .gts file line 1:** +```gts +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +``` + +**Creating new file:** +```gts +http://realm/card.gts (new) +╔═══ SEARCH ════╗ +╠═══════════════╣ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +import { CardDef } from '...'; // ¹ +export class MyCard extends CardDef { } // ² +╚═══ REPLACE ═══╝ +``` +╰ ¹⁻² + +**Modifying existing:** +```gts +https://realm/card.gts +╔═══ SEARCH ════╗ +existing code with tracking markers +╠═══════════════╣ +modified code with new markers // ⁵ +╚═══ REPLACE ═══╝ +``` +⁰ ⁵ + +## File Editing System + +### Tracking Mode + +**MANDATORY for .gts Files:** +1. All `.gts` files require tracking mode indicator on line 1: + ```gts + // ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ + ``` +2. Format: `// ⁿ description` using sequential superscripts: ¹, ², ³... +3. Both SEARCH and REPLACE blocks must contain tracking markers + +### SEARCH/REPLACE Patterns + +#### Creating New File +```gts +http://realm/recipe-card.gts (new) +╔═══ SEARCH ════╗ +╠═══════════════╣ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +import { CardDef } from 'https://cardstack.com/base/card-api'; // ¹ +export class RecipeCard extends CardDef { // ² + static displayName = 'Recipe'; +} +╚═══ REPLACE ═══╝ +``` +⁰ ¹⁻² + +#### Modifying Existing File +```gts +https://example.com/recipe-card.gts +╔═══ SEARCH ════╗ +export class RecipeCard extends CardDef { + static displayName = 'Recipe'; + @field recipeName = contains(StringField); +╠═══════════════╣ +export class RecipeCard extends CardDef { + static displayName = 'Recipe'; + @field recipeName = contains(StringField); + @field servings = contains(NumberField); // ¹⁸ Added servings +╚═══ REPLACE ═══╝ +``` +⁰ ¹⁸ + +### File Type Rules + +- **`.gts` files** → ALWAYS require tracking mode and markers +- **`.json` files** → Never use tracking comments + +### Best Practices + +- Keep search blocks small and precise +- Include tracking comments in SEARCH blocks for uniqueness +- Search text must match EXACTLY +- Use placeholder comments for easy insertion points \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-fitted-formats.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-fitted-formats.md new file mode 100644 index 0000000000..ecd0d95558 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-fitted-formats.md @@ -0,0 +1,34 @@ +## Fitted Format Essentials + +**Four sub-formats strategy:** +- **Badge** (≤150px width, <170px height) - Exportable graphics +- **Strip** (>150px width, <170px height) - Dropdown/chooser panels +- **Tile** (<400px width, ≥170px height) - Grid viewing +- **Card** (≥400px width, ≥170px height) - Full layout + +**Container query skeleton:** +```css +.fitted-container { + container-type: size; + width: 100%; + height: 100%; +} + +/* Hide all by default */ +.badge, .strip, .tile, .card { + display: none; + padding: clamp(0.25rem, 2%, 0.5rem); +} + +/* Activate by size - NO GAPS! */ +@container (max-width: 150px) and (max-height: 169px) { + .badge { display: flex; } +} +``` + +**Content priority:** +1. Title/Name +2. Image +3. Short ID +4. Key info +5. Status badges \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-query-systems.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-query-systems.md new file mode 100644 index 0000000000..50788c7609 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-query-systems.md @@ -0,0 +1,74 @@ +## Query Essentials + +**The 'on' Rule (MEMORIZE THIS!):** +```ts +// ❌ WRONG - Missing 'on' +{ range: { price: { lte: 100 } } } + +// ✅ CORRECT - Include 'on' for filters +{ + on: { module: new URL('./product', import.meta.url).href, name: 'Product' }, + range: { price: { lte: 100 } } +} +``` + +**⚠️ CRITICAL Path Rule:** +- **In .gts files (queries):** Use `./` - you're in the same directory as the module +- **In JSON files (`adoptsFrom`):** Use `../` - instances live in folders, need to navigate up +- `./` means "same directory" when used with `import.meta.url` + +**Filter types needing 'on':** +- `eq`, `contains`, `range` (except after type filter) +- Sort on type-specific fields + +**Filter composition types:** +- `any`: allows an "OR" union of other filters +- `every`: allows an "AND" union of other filters +- `not`: allow negating another filter + +**Basic query pattern:** +```ts +const query = { + filter: { + every: [ + { type: { module: new URL('./product', import.meta.url).href, name: 'Product' } }, + { on: { module: new URL('./product', import.meta.url).href, name: 'Product' }, eq: { status: 'active' } } + ] + } +}; +``` + +**Defining query-backed fields:** +```ts +@field shirts = linksToMany(Shirt, { + query: { + filter: { + // implicit clause merged during execution: on: { module: Shirt.module, name: 'Shirt' } + eq: { size: '$this.profile.shirtSize' }, + }, + realm: '$thisRealm', + sort: [ + { + by: 'updatedAt', + direction: 'desc', + }, + ], + page: { size: 12 }, + }, +}); + +@field profile = linksTo(Profile, { + query: { + filter: { + eq: { primary: true }, + }, + // `linksTo` takes the first matching card (post-sort) or null when no results. + }, +}); +``` + +**When to use what to query cards:** +- Efficient display-only → `PrerenderedCardSearch` +- Need data manipulation → `getCards` +- Treat query result as a field → query-backed fields +``` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-quick-reference.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-quick-reference.md new file mode 100644 index 0000000000..85cbc63240 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-quick-reference.md @@ -0,0 +1,104 @@ +**Core imports:** +```gts +import { CardDef, FieldDef, Component, field, contains, linksTo } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +``` + +**UI Components:** +```gts +import { Button, Pill, BoxelSelect } from '@cardstack/boxel-ui/components'; +``` + +**Helpers:** +```gts +import { eq, gt, and, or, not } from '@cardstack/boxel-ui/helpers'; +import { formatDateTime, formatCurrency } from '@cardstack/boxel-ui/helpers'; +``` + +## Quick Reference + +**File Types:** `.gts` (definitions) | `.json` (instances) +**Core Pattern:** CardDef/FieldDef → contains/linksTo → Templates → Instances +**Essential Formats:** Every CardDef MUST implement `isolated`, `embedded`, AND `fitted` formats + +```gts +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +// ¹ Core imports - ALWAYS needed for definitions +import { CardDef, FieldDef, Component, field, contains, containsMany, linksTo, linksToMany } from 'https://cardstack.com/base/card-api'; + +// ² Base field imports (only what you use) +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import DateField from 'https://cardstack.com/base/date'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import BigIntegerField from 'https://cardstack.com/base/big-integer'; +import CodeRefField from 'https://cardstack.com/base/code-ref'; +import Base64ImageField from 'https://cardstack.com/base/base64-image'; // 🚨 NEVER USE - embeds binary in JSON, crashes AI context; use FileDef types instead + +// ⁸ FileDef imports - for file fields (use linksTo, never contains) +import FileDef from 'https://cardstack.com/base/file-api'; +import ImageDef from 'https://cardstack.com/base/image-file-def'; // any image +import PngDef from 'https://cardstack.com/base/png-image-def'; // .png +import JpgDef from 'https://cardstack.com/base/jpg-image-def'; // .jpg/.jpeg +import SvgDef from 'https://cardstack.com/base/svg-image-def'; // .svg +import GifDef from 'https://cardstack.com/base/gif-image-def'; // .gif +import WebpDef from 'https://cardstack.com/base/webp-image-def'; // .webp +import AvifDef from 'https://cardstack.com/base/avif-image-def'; // .avif +import MarkdownDef from 'https://cardstack.com/base/markdown-file-def'; // .md (NOT same as MarkdownField) +import TextFileDef from 'https://cardstack.com/base/text-file-def'; // .txt +import TsFileDef from 'https://cardstack.com/base/ts-file-def'; // .ts +import GtsFileDef from 'https://cardstack.com/base/gts-file-def'; // .gts +import JsonFileDef from 'https://cardstack.com/base/json-file-def'; // .json +import CsvFileDef from 'https://cardstack.com/base/csv-file-def'; // .csv +import ColorField from 'https://cardstack.com/base/color'; +import EmailField from 'https://cardstack.com/base/email'; +import PercentageField from 'https://cardstack.com/base/percentage'; +import PhoneNumberField from 'https://cardstack.com/base/phone-number'; +import UrlField from 'https://cardstack.com/base/url'; +import AddressField from 'https://cardstack.com/base/address'; + +// ⚠️ EXTENDING BASE FIELDS: To customize a base field, import it and extend: +// import BaseAddressField from 'https://cardstack.com/base/address'; +// export class FancyAddressField extends BaseAddressField { } +// Never import and define the same field name - it causes conflicts! + +// ³ UI Component imports +import { Button, Pill, Avatar, FieldContainer, CardContainer, BoxelSelect, ViewSelector } from '@cardstack/boxel-ui/components'; + +// ⁴ Helper imports +import { eq, gt, gte, lt, lte, and, or, not, cn, add, subtract, multiply, divide } from '@cardstack/boxel-ui/helpers'; +import { currencyFormat, formatDateTime, optional, pick } from '@cardstack/boxel-ui/helpers'; +import { concat, fn } from '@ember/helper'; +import { get } from '@ember/helper'; +import { on } from '@ember/modifier'; +import Modifier from 'ember-modifier'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { task, restartableTask } from 'ember-concurrency'; +// NOTE: 'if' is built into Glimmer templates - DO NOT import it + +// ⁶ TIMING RULE: NEVER use requestAnimationFrame +// - DOM timing: Use Glimmer modifiers with cleanup +// - Async coordination: Use task/restartableTask from ember-concurrency +// - Delays: Use await timeout(ms) from ember-concurrency, not setTimeout + +// ⁵ Icon imports +import EmailIcon from '@cardstack/boxel-icons/mail'; +import PhoneIcon from '@cardstack/boxel-icons/phone'; +import RocketIcon from '@cardstack/boxel-icons/rocket'; +// Available from Lucide, Lucide Labs, and Tabler icon sets +// NOTE: Only use for static card/field type icons, NOT in templates + +// CRITICAL IMPORT RULES: +// ⚠️ If you don't see an import in the approved lists above, DO NOT assume it exists! +// ⚠️ Only use imports explicitly shown in this guide - no exceptions! +// - Verify any import exists in the approved lists before using +// - Do NOT assume similar imports exist (e.g., don't assume IntegerField exists because NumberField does) +// - If needed functionality isn't in approved imports, define it directly with a comment: +// // Defining custom helper - not yet available in Boxel environment +// function customHelper() { ... } +``` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-replicate-ai.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-replicate-ai.md new file mode 100644 index 0000000000..0499d701c3 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-replicate-ai.md @@ -0,0 +1,111 @@ +### Replicate API Essentials + +**Gateway URL Pattern:** +``` +https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/replicate/v1/models/{owner}/{model}/predictions +``` + +**Required Headers:** +```typescript +{ + 'Content-Type': 'application/json', + 'Prefer': 'wait' // CRITICAL: Synchronous response +} +``` + +**Request Structure:** +```typescript +{ + input: { + prompt: string, + // Model-specific parameters (see API docs) + } +} +``` + +### Enum Fields for API Parameters + +**CRITICAL:** Enum `value` must exactly match API spec: + +```gts +import enumField from 'https://cardstack.com/base/enum'; + +const SizeField = enumField(StringField, { + options: [ + { value: '1K', label: '1K (1024px)' }, + { value: '2K', label: '2K (2048px)' }, + { value: 'custom', label: 'Custom' } + ] +}); +``` + +### API Call Pattern + +```gts +import SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy'; +import UploadImageCommand from 'https://realms-staging.stack.cards/catalog/commands/upload-image'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import { CloudflareImage } from 'https://realms-staging.stack.cards/catalog/cloudflare-image'; + +// Build request +const requestBody = { + input: { prompt: input.prompt } +}; + +// Add conditional parameters +if (input.size) requestBody.input.size = input.size; +if (input.aspectRatio && input.size !== 'custom') { + requestBody.input.aspect_ratio = input.aspectRatio; +} + +// Call API +const response = await new SendRequestViaProxyCommand(ctx).execute({ + url: 'https://gateway.ai.cloudflare.com/v1/.../replicate/v1/models/{owner}/{model}/predictions', + method: 'POST', + requestBody: JSON.stringify(requestBody), + headers: { 'Content-Type': 'application/json', 'Prefer': 'wait' } +}); + +// Parse response +const data = await response.response.json(); +let imageUrl = Array.isArray(data.output) ? data.output[0] : data.output; + +// Upload result +const uploaded = await new UploadImageCommand(ctx).execute({ + sourceImageUrl: imageUrl, + targetRealmUrl: input.realm +}); + +return await new GetCardCommand(ctx).execute({ cardId: uploaded.cardId }); +``` + +### Response Parsing + +```typescript +// Handle multiple formats +let imageUrl: string | undefined; + +if (data.output && Array.isArray(data.output)) { + imageUrl = data.output[0]; +} else if (typeof data.output === 'string') { + imageUrl = data.output; +} else if (data.output?.url) { + imageUrl = data.output.url; +} + +if (!imageUrl) throw new Error('No image URL in response'); +``` + +### Common Mistakes + +❌ Missing `Prefer: wait` header → async URL instead of result +❌ Enum value mismatch → API rejects request +❌ Always sending optional params → API validation errors +❌ String booleans in API → Use actual `true`/`false` + +### Finding Model Schemas + +1. Visit `https://replicate.com/{owner}/{model}` +2. Check API tab for exact schema +3. Note required vs optional parameters +4. Match enum values exactly \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-spec-usage.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-spec-usage.md new file mode 100644 index 0000000000..aa93279855 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-spec-usage.md @@ -0,0 +1,185 @@ +**Card specs (linksTo/linksToMany):** +```gts +import { Author } from './author'; +@field author = linksTo(Author); +@field contributors = linksToMany(Author); +``` + +**Field specs (contains/containsMany):** +```gts +import StringField from 'https://cardstack.com/base/string'; +import AddressField from 'https://cardstack.com/base/address-field'; +@field name = contains(StringField); +@field addresses = containsMany(AddressField); +``` + +**Component specs (direct template usage):** +```hbs + + +``` + +**Command specs (programmatic execution):** +```ts +const cmd = new MyCommand(commandContext); +const result = await cmd.execute(input); +``` + +## Spec Usage Examples + +A Spec is a comprehensive documentation and metadata container for code within the Boxel ecosystem. + +This document provides real-world usage examples for each spec type based on actual implementations found in the Boxel repository. + +### Card Specs (`specType: 'card'`) +(Cards are linked to using `linksTo` and `linksToMany` within consuming cards...) + +#### Import +```typescript +import { Author } from './author'; +import { Country } from 'https://cardstack.com/base/country'; +import { Skill } from 'https://cardstack.com/base/skill'; +``` + +#### Usage as a Field +```typescript +export class BlogPost extends CardDef { + // Single card reference + @field author = linksTo(Author); + @field country = linksTo(Country); + + // Multiple card references + @field enabledSkills = linksToMany(Skill); + @field attachedCards = linksToMany(CardDef); +} +``` + +#### Template Usage +```handlebars +{{! Display linked card in different formats }} +<@fields.author @format="embedded" /> +<@fields.author @format="atom" /> + +{{! Display collection of linked cards }} +
+ <@fields.enabledSkills @format="embedded" /> +
+``` + +### Field Specs (`specType: 'field'`) +(Fields are embedded using `contains` and `containsMany` within cards.) + +#### Import +```typescript +import StringField from 'https://cardstack.com/base/string'; +import DateField from 'https://cardstack.com/base/date'; +import { SocialMediaLink } from './social-media-link'; +``` + +#### Usage as a Field +```typescript +export class MinecraftInvite extends CardDef { + // Basic field types + @field celebrantName = contains(StringField); + @field age = contains(StringField); + @field date = contains(DateField); + + // Custom field types + @field socialLinks = containsMany(SocialMediaLink); +} +``` + +#### Template Usage +```handlebars +{{! Display contained fields }} +<@fields.celebrantName /> +<@fields.date @format="atom" /> + +{{! Display collection of contained fields }} + +``` + +### Component Specs (`specType: 'component'`) +(Components are used directly in templates, extending GlimmerComponent...) + +#### Import +```typescript +import { BoxelSelect, Pill } from '@cardstack/boxel-ui/components'; +import { FilterDropdown } from './filter-dropdown'; +import { CardsGrid } from './cards-grid'; +``` + +#### Usage in Templates +```handlebars +{{! Basic component usage }} + + +{{! Custom components }} + + + +{{! Component with content }} + + Active Status + +``` + +### App Specs (`specType: 'app'`) +(Apps extend AppCard and are typically linked to like regular cards...) + +#### Import +```typescript +import { AppCard } from '/experiments/app-card'; +import { GardenAppCard } from './garden-app'; +``` + +#### Usage as a Field +```typescript +export class Dashboard extends CardDef { + @field primaryApp = linksTo(GardenAppCard); + @field availableApps = linksToMany(AppCard); +} +``` + +#### Template Usage +```handlebars +{{! Display app in card context }} +<@fields.primaryApp @format="fitted" /> + +{{! App navigation }} +
+ <@fields.availableApps @format="embedded" /> +
+``` + +### Command Specs (`specType: 'command'`) +(Commands are instantiated and executed programmatically.) + +#### Import +```typescript +import { GenerateReadmeSpecCommand } from './generate-readme-spec'; +import { SwitchSubmodeCommand } from './switch-submode'; +import { UpdatePlaygroundSelectionCommand } from './update-playground-selection'; +``` + +#### Template Usage + +When you need to execute commands in response to user interactions, you can just access the commandContext and invoke it as how you would a simple async function in javascript + +```typescript +let commandContext = this.args.context?.commandContext; +if (!commandContext) { + console.error('Command context not available'); + return; +} + +const someCommandInput = new CommandInput({...args}) +const myCommand = new MyCommand(commandContext); +const result = await myCommand.execute(someCommandInput); +``` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-styling-design.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-styling-design.md new file mode 100644 index 0000000000..61fb4fda4a --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-styling-design.md @@ -0,0 +1,106 @@ +## CSS Safety Essentials + +**Always scoped:** +```gts + +``` + +**CSS comments (NEVER use //):** +```css +/* ✅ CORRECT: Block comments */ +.card { color: blue; } + +// ❌ WRONG: Single-line breaks parsing +``` + +**Never use global selectors:** +```css +/* ❌ WRONG */ +:root { --color: blue; } +body { margin: 0; } + +/* ✅ CORRECT */ +.my-component { + --color: blue; +} +``` + +**Formatters for display:** +```hbs +{{formatCurrency @model.price currency="USD"}} +{{formatDateTime @model.date size="medium"}} +{{formatNumber @model.count size="tiny"}} +``` + +## Design Philosophy and Competitive Styling + +Design and implement your stylesheet to fit the domain you are generating. Research the top 2 products/services in that area and design your card as if you are the 3rd competitor looking to one-up the market in terms of look and feel, functionality, and user-friendliness. + +Approach: Study the leading players' design patterns, then create something that feels more modern, intuitive, and polished. Focus on micro-interactions, thoughtful spacing, superior visual hierarchy, and removing friction from user workflows. + +Key Areas to Compete On: +- Visual polish: better typography, spacing, and color schemes +- Interaction design: smoother animations, better feedback, clearer affordances +- Information architecture: more logical organization, better progressive disclosure +- Accessibility: superior contrast, keyboard navigation, screen reader support +- Performance: faster loading, responsive design + +Typography Guidance (detailed): Choose modern, readable fonts that match your domain. For body text, consider Inter, Roboto, Open Sans, Source Sans Pro, DM Sans, Work Sans, Manrope, or Plus Jakarta Sans. For headings, Poppins, Montserrat, Space Grotesk, Raleway, Archivo Black, Oswald, Anton, Playfair Display, Lora, or Merriweather. Balance readability with character; ensure sufficient contrast and legible sizes across formats. + +## Design Token Foundation + +Dense professional layouts with thoughtful scaling: + +- Typography scale: start at 0.875rem base; headings 1rem–1.375rem; labels 0.75rem +- Spacing scale: 0.25rem increments; inline 0.25–0.5rem; sections 0.75–1rem; major 1.5–2rem +- Colors: define background, foreground, muted, muted-foreground, primary, primary-foreground, secondary, secondary-foreground, accent, accent-foreground, card, card-foreground, sidebar, sidebar-foreground, and border tokens +- Radius: match the aesthetic (sharp for technical, soft for friendly) +- Shadows: subtle elevation for interactive elements; keep z-index conservative (<10) + +Implementation tip: Define CSS variables at component root and use fallbacks. + +```css +.component { + --card-padding: var(--boxel-sp, 1rem); + --card-radius: var(--boxel-border-radius-sm, 0.5rem); + --card-shadow: var(--boxel-box-shadow, 0 2px 4px rgba(0,0,0,0.1)); + padding: var(--card-padding); + border-radius: var(--card-radius); + box-shadow: var(--card-shadow); +} +``` + +## Typography Guidance (Detailed) + +- Base size: 14px (0.875rem) for dense UIs; increase in larger formats +- Hierarchy cascade: each level 80–87% of the previous; adjust weight 100–200 units per level +- Line-height: 1.2–1.5 depending on density; tighter for tiles, looser for isolated +- Clamping: use `clamp()` for responsive sizes across fitted/embedded/isolated +- Accessibility: aim for WCAG AA contrast; avoid ultra-light weights below 16px +- Numbers: tabular-nums for data tables and metrics when available + +Example: +```css +.title { font-size: clamp(1rem, 2.5vw, 1.25rem); font-weight: 700; } +.subtle { font-size: 0.75rem; opacity: 0.8; } +``` + +## Format Dimensions Comparison + +| Format | Width | Height | Parent Sets | Key Behavior | +|----------|------------------|------------------|-------------|-------------| +| Isolated | Max-width, center| Natural + scroll | No | Full detail, scrollable content | +| Embedded | Fills container | Natural | Width only | Truncation/expand controls handled by parent | +| Fitted | Fills exactly | Fills exactly | Both | Must adapt to fixed grid slots | +| Atom | Inline | Inline | No | Minimal inline representation | +| Edit | Fills container | Natural form | Width only | Form layout, grows with fields | + +Notes: +- Fitted requires internal subformats (badge, strip, tile, card) via container queries +- Embedded should be height-flexible; parents may clamp and offer "view more" +- Isolated should ensure comfortable reading with scrollable mat and generous padding \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-technical-rules.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-technical-rules.md new file mode 100644 index 0000000000..3c4f958a00 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-technical-rules.md @@ -0,0 +1,87 @@ +### The Cardinal Rule + +**MOST CRITICAL RULE:** +```gts +// ✅ CORRECT +@field author = linksTo(Author); // CardDef +@field address = contains(AddressField); // FieldDef + +// ❌ WRONG - Will break everything +@field author = contains(Author); // NEVER! +@field address = linksTo(AddressField); // NEVER! +``` + +**Must export ALL classes:** +```gts +export class MyCard extends CardDef { } // ✅ +class MyCard extends CardDef { } // ❌ Missing export +``` + +**Computed fields:** +- Keep simple and unidirectional +- No self-reference or cycles +- Wrap cross-card access in try-catch + +## Technical Rules + +### THE CARDINAL RULE: contains vs linksTo + +**THIS IS THE #1 MOST CRITICAL RULE IN BOXEL:** + +| Type | MUST Use | NEVER Use | Why | +|------|----------|-----------|-----| +| **Extends CardDef** | `linksTo` / `linksToMany` | ❌ `contains` / `containsMany` | CardDef = independent entity with own JSON file | +| **Extends FieldDef** | `contains` / `containsMany` | ❌ `linksTo` / `linksToMany` | FieldDef = embedded data, no separate identity | + +```gts +// ✅ CORRECT +@field author = linksTo(Author); // Author extends CardDef +@field address = contains(AddressField); // AddressField extends FieldDef + +// ❌ WRONG +@field author = contains(Author); // NEVER! +@field address = linksTo(AddressField); // NEVER! +``` + +### MANDATORY TECHNICAL REQUIREMENTS + +1. **Always use SEARCH/REPLACE with tracking for .gts files** +2. **Export ALL CardDef and FieldDef classes inline** +3. **Never use reserved words as field names** +4. **Keep computed fields simple and unidirectional** +5. **No JavaScript in templates** +6. **Wrap delegated collections with spacing containers** + +### TECHNICAL VALIDATION CHECKLIST + +Before generating ANY code: +- [ ] SEARCH/REPLACE blocks with tracking markers +- [ ] Every CardDef field uses `linksTo`/`linksToMany` +- [ ] Every FieldDef field uses `contains`/`containsMany` +- [ ] All classes have `export` keyword inline +- [ ] No reserved words as field names +- [ ] No duplicate field definitions +- [ ] Computed fields are simple (no cycles!) +- [ ] Try-catch blocks wrap cross-card data access +- [ ] No JavaScript operations in templates +- [ ] ALL THREE FORMATS: isolated, embedded, fitted + +### Common Mistakes + +#### Using contains with CardDef +```gts +// ❌ WRONG +@field items = containsMany(Item); // Item is CardDef + +// ✅ CORRECT +@field items = linksToMany(Item); +``` + +#### Missing Exports +```gts +// ❌ WRONG +class BlogPost extends CardDef { } + +// ✅ CORRECT +export class BlogPost extends CardDef { } +``` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-template-patterns.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-template-patterns.md new file mode 100644 index 0000000000..d5cae494a2 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-template-patterns.md @@ -0,0 +1,325 @@ +### Template Essentials + +**Field access patterns:** +```hbs +{{@model.title}} +<@fields.title /> +<@fields.phone @format="atom" /> +<@fields.items @format="embedded" /> +``` + +For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: Theme-First Design System. + +#### ⚠️ CRITICAL: @model Iteration vs @fields Delegation + +**Once you iterate with @model, you CANNOT delegate to @fields within that iteration.** + +```hbs + +{{#each @model.teamMembers as |member|}} + <@fields.member @format="embedded" /> +{{/each}} + + +<@fields.teamMembers @format="embedded" /> + + +{{#each @model.teamMembers as |member|}} +
{{member.name}}
+{{/each}} + + + +``` + +**Why this breaks:** @fields provides field-level components. Once you're iterating with @model, you're working with raw data, not field components. + +**Decision Rule:** Before iterating, decide: +- Need composability? → Use delegated rendering +- Need filtering? → Use query patterns (PrerenderedCardSearch/getCards) +- Need custom control? → Use @model but handle ALL rendering yourself + +### Accessing @fields by Index: The Bridge Pattern + +**Use Case:** You need to use `@model` data to find specific items in a `containsMany` or `linksToMany` collection, then render those items using their field templates for proper delegated rendering. + +**Key Concept:** The `get` helper allows you to access `@fields` array elements by index, creating a bridge between data-driven iteration and component-based rendering. + +#### When to Use This Pattern + +- **Filtering:** Show only items matching certain criteria +- **Conditional rendering:** Display items based on model data +- **Custom ordering:** Reorder items based on computed logic +- **Highlighted selection:** Emphasize specific items in a collection + +#### Basic Pattern + +```hbs +{{! Access a specific field by index }} +{{#let (get @fields.shoppingList 0) as |firstItem|}} + {{#if firstItem}} + + {{else}} +
No first item
+ {{/if}} +{{/let}} + +{{! Access last item using subtract helper }} +{{#let (get @fields.items (subtract @model.items.length 1)) as |lastItem|}} + {{#if lastItem}} + + {{/if}} +{{/let}} +``` + +#### Displaying Compound Fields + +**CRITICAL:** When displaying compound fields (FieldDef types) like `PhoneNumberField`, `AddressField`, or custom field definitions, you must use their format templates, not raw model access: + +```hbs + +

Phone: {{@model.phone}}

+ + +

Phone: <@fields.phone @format="atom" />

+ + +
+ <@fields.phone @format="embedded" /> +
+``` + +**💡 Line-saving tip:** Keep self-closing tags compact: +```hbs + +<@fields.author @format="embedded" /> +<@fields.phone @format="atom" /> +``` + +#### @fields Delegation Rule + +**CRITICAL:** When delegating to embedded/fitted formats, you must iterate through `@fields`, not `@model`. Always use `@fields` for delegation, even for singular fields. + +```hbs + +<@fields.author @format="embedded" /> +<@fields.items @format="embedded" /> +{{#each @fields.items as |item|}} + +{{/each}} + + +{{#each @model.items as |item|}} + <@fields.??? @format="embedded" /> +{{/each}} +``` + +**containsMany Spacing Pattern:** Due to an additional wrapper div, target `.containsMany-field`: +```css +/* For grids */ +.products-grid > .containsMany-field { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; +} + +/* For lists */ +.items-list > .containsMany-field { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +``` + +### Template Fallback Value Patterns + +**CRITICAL:** Boxel cards boot with no data by default. Templates must gracefully handle null, undefined, and empty string values at ALL levels of data access to prevent runtime errors and provide meaningful visual fallbacks. + +#### Three Primary Patterns for Fallbacks + +**1. Inline if/else (for simple display fallbacks):** +```hbs +{{if @model.eventTime (formatDateTime @model.eventTime "MMM D, h:mm A") "Event time to be announced"}} +

{{if @model.title @model.title "Untitled Document"}}

+

Status: {{if @model.status @model.status "Status pending"}}

+``` + +**2. Block-based if/else (for complex content):** +```hbs +
+ {{#if @model.eventTime}} + {{formatDateTime @model.eventTime "MMM D, h:mm A"}} + {{else}} + Event time to be announced + {{/if}} +
+ +{{#if @model.description}} +
+ <@fields.description /> +
+{{else}} +
+

No description provided yet. Click to add one.

+
+{{/if}} +``` + +**3. Unless for safety/validation checks (composed with other helpers):** +```hbs +{{unless (and @model.isValid @model.hasPermission) "⚠️ Cannot proceed - missing validation or permission"}} +{{unless (or @model.email @model.phone) "Contact information required"}} +{{unless (gt @model.items.length 0) "No items available"}} +{{unless (eq @model.status "active") "Service unavailable"}} +``` + +**Best Practices:** Use descriptive placeholder text rather than generic "N/A", style placeholder text differently (lighter color, italic), use `unless` for safety checks and `if` for display fallbacks. + +**Icon Usage:** Avoid emoji in templates (unless the application specifically calls for it) due to OS/platform variations that cause legibility issues. Use Boxel icons only for static card/field type icons (`static icon` property). In templates, use inline SVG instead since we can't be sure which Boxel icons exist. + +### Template Array Handling Patterns + +**CRITICAL:** Templates must gracefully handle all array states to prevent errors. Arrays can be undefined, null, empty, or populated. + +#### The Three Array States + +Your templates must handle: +1. **Completely undefined arrays** - Field doesn't exist or is null +2. **Empty arrays** - Field exists but has no items (`[]`) +3. **Arrays with actual data** - Field has one or more items + +#### Array Logic Pattern + +**❌ WRONG - Only checks for existence:** +```hbs +{{#if @model.goals}} +
    + {{#each @model.goals as |goal|}} +
  • {{goal}}
  • + {{/each}} +
+{{/if}} +``` + +**✅ CORRECT - Checks for length and provides empty state:** +```hbs +{{#if @model.goals.length}} +
+

+ + + + + + Daily Goals +

+
    + {{#each @model.goals as |goal|}} +
  • {{goal}}
  • + {{/each}} +
+
+{{else}} +
+

+ + + + + + Daily Goals +

+

No goals set yet. What would you like to accomplish?

+
+{{/if}} +``` + +**Remember:** When implementing templates via SEARCH/REPLACE, include tracking markers ⁿ for style blocks + +### Real-World Example: Shopping List with Featured Items + +```gts +import { CardDef, FieldDef, field, contains, containsMany, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import { get } from '@ember/helper'; +import { subtract } from '@cardstack/boxel-ui/helpers'; + +export class FruitItem extends FieldDef { + static displayName = 'Fruit'; + @field title = contains(StringField); + @field quantity = contains(NumberField); + + static embedded = class Embedded extends Component { + + }; +} + +export class ShoppingList extends CardDef { + static displayName = 'Shopping List'; + @field items = containsMany(FruitItem); + + static isolated = class Isolated extends Component { + + }; +} +``` + +#### Important Notes + +**CRITICAL Safety Checks:** +- Always wrap `get` results in `{{#if}}` to handle undefined indices +- Use `subtract` helper for negative indexing (e.g., last item) +- Validate array length before accessing by index + +**When NOT to Use:** +- If you need to iterate all items → use `<@fields.items />` delegation +- If you need custom rendering for each → use `{{#each @model.items}}` pattern +- For simple filtering → use query patterns with PrerenderedCardSearch + +**Performance Consideration:** +The `get` helper is efficient for accessing specific indices. For complex filtering or transformation, consider using query patterns or computed properties instead. \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-theme-design-system.md b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-theme-design-system.md new file mode 100644 index 0000000000..d397afc338 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-development/references/dev-theme-design-system.md @@ -0,0 +1,239 @@ +### Theme-First Principle + +- Always link a Theme before generating code or styling. (See 3.1 Theme Linking Rules) +- All CSS in card templates must use theme variables (no hardcoded colors/spacing/fonts). (See 3.2 Canonical Theme Variables) +- Theme linkage lives at `relationships.cardInfo.theme` on the card instance. + +### 3.1 Theme Linking Rules +- Set this as the Default Theme for all new, non-ThemeCard instances: + +``` +"relationships": { + "cardInfo.theme": { + "links": { + "self": "https://app.boxel.ai/catalog/Theme/cardstack" + } + } +} +``` +- You must also set the remaining cardInfo properties in the card data attributes. Example: +``` +"attributes": { + "cardInfo": { + "notes": null, + "title": "[card title here]", + "description": "[brief card description here]", + "thumbnailURL": "[card thumbnail url here]" + }, +} +``` +- IMPORTANT: Never set `cardInfo.theme` on ThemeCards (cards adopting from `https://cardstack.com/base/theme/default` or its subclasses) to avoid cycles. + +#### ThemeCard Types + +A ThemeCard is an instance of a card definition that inherits from `https://cardstack.com/base/theme/default` or from one of its subclasses. + +- Base: `https://cardstack.com/base/theme/default` +- Subclasses: + - `https://cardstack.com/base/structured-theme/default` + - `https://cardstack.com/base/detailed-style-reference/default` + - `https://cardstack.com/base/style-reference/default` + - `https://cardstack.com/base/brand-guide/default` + +### 3.2 Canonical Theme Variables +Use the variables directly (do not wrap with `hsl(var(...))`). Pair backgrounds with their foregrounds for contrast. + +Our design system is compatible with shadcn css variables. + +- Background Colors: +``` +--background +--card +--popover +--primary +--secondary +--muted +--accent +--destructive +--input +--sidebar +--sidebar-primary +--sidebar-accent +``` + +- Foreground Colors: +``` +--foreground +--card-foreground +--popover-foreground +--primary-foreground +--secondary-foreground +--muted-foreground +--accent-foreground +--destructive-foreground +--sidebar-foreground +--sidebar-primary-foreground +--sidebar-accent-foreground +``` +- Border Colors: +``` +--border +--sidebar-border +``` +- Css Outline Colors: +``` +--ring +--sidebar-ring +``` +- Chart Colors: +``` +--chart-1 +--chart-2 +--chart-3 +--chart-4 +--chart-5 +``` + +- Fonts: (`font-family`) +``` +--font-sans +--font-serif +--font-mono +``` +- Radius: (`border-radius`) +``` +--radius +--boxel-border-radius-xxs +--boxel-border-radius-xs +--boxel-border-radius-sm +--boxel-border-radius +--boxel-border-radius-lg +--boxel-border-radius-xl +--boxel-border-radius-xxl +``` +- Spacing: +``` +--spacing +--boxel-sp-6xs +--boxel-sp-5xs +--boxel-sp-4xs +--boxel-sp-3xs +--boxel-sp-2xs +--boxel-sp-xs +--boxel-sp-sm +--boxel-sp +--boxel-sp-lg +--boxel-sp-xl +--boxel-sp-2xl +--boxel-sp-3xl +--boxel-sp-4xl +--boxel-sp-5xl +--boxel-sp-6xl +``` +- Letter-spacing: +``` +--tracking-normal +--boxel-lsp-xxl +--boxel-lsp-xl +--boxel-lsp-lg +--boxel-lsp +--boxel-lsp-sm +--boxel-lsp-xs +--boxel-lsp-xxs +``` +- Shadows: (`box-shadow`) +``` +--shadow-2xs +--shadow-xs +--shadow-sm +--shadow +--shadow-md +--shadow-lg +--shadow-xl +--shadow-2xl +--boxel-box-shadow +--boxel-box-shadow-hover +--boxel-deep-box-shadow +``` + +- Font Sizes: (`font-size`) +``` +--boxel-font-size-2xl +--boxel-font-size-xl +--boxel-font-size-lg +--boxel-font-size-md +--boxel-font-size +--boxel-font-size-sm +--boxel-font-size-xs +--boxel-heading-font-size +--boxel-section-heading-font-size +--boxel-subheading-font-size +--boxel-body-font-size +--boxel-caption-font-size +``` + +#### CSS Usage Examples: + +✅ Correct: +``` +background-color: var(--card); +color: var(--card-foreground); +border-color: var(--border); +font-family: var(--font-serif); +border-radius: var(--radius); +padding: var(--spacing); +margin-top: calc(var(--spacing) * 2); +box-shadow: var(--shadow-lg); +``` +❌ Incorrect: +``` +background-color: hsl(var(--background)); /* Do not wrap in hsl() */ +``` + +### CSS Safety (All Formats) +- Always use ` + +``` \ No newline at end of file diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-file-structure/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-file-structure/SKILL.md new file mode 100644 index 0000000000..b155e83fb9 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-file-structure/SKILL.md @@ -0,0 +1,305 @@ +--- +name: boxel-file-structure +description: Use when organizing files in a Boxel workspace, choosing filenames or directories for card definitions and instances, or validating JSON `adoptsFrom.module` paths and relationship links. +--- + +# Boxel File Structure Rules + +Rules for organizing files in a Boxel workspace when working locally with boxel-cli. + +## URL Structure + +``` +https://[realm-domain]/[username]/[workspace]/[path].[extension] +Example: https://app.boxel.ai/sarah/pet-rescue/animals/dog.gts +``` + +## File Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Card definitions | `kebab-case.gts` | `blog-post.gts`, `grammy-award.gts` | +| Instance directories | `PascalCase/` | `BlogPost/`, `GrammyAward/` | +| Instance files | `kebab-case.json` | `my-first-post.json` | + +## Directory Structure + +``` +workspace/ +├── .realm.json # Workspace config +├── index.json # Workspace index +├── cards-grid.json # Default cards grid +├── blog-post.gts # Card definition (kebab-case) +├── BlogPost/ # Instance directory (PascalCase) +│ ├── my-first-post.json +│ └── another-post.json +├── author.gts +└── Author/ + └── jane-doe.json +``` + +## Module Paths in JSON (CRITICAL) + +**The `adoptsFrom.module` path is relative to the JSON file location.** + +### ✅ Correct: Instance in subdirectory +``` +grammy-award.gts # Definition at root +GrammyAward/ # Instances in PascalCase directory +└── record-of-the-year.json +``` + +**In `GrammyAward/record-of-the-year.json`:** +```json +{ + "meta": { + "adoptsFrom": { + "module": "../grammy-award", // ← Go UP to parent, then to file + "name": "GrammyAward" + } + } +} +``` + +### ❌ Wrong: Forgetting the relative path +```json +{ + "meta": { + "adoptsFrom": { + "module": "./grammy-award", // ← WRONG! This looks in GrammyAward/ + "name": "GrammyAward" + } + } +} +``` + +## Path Rules Summary + +| JSON Location | Definition Location | Module Path | +|--------------|---------------------|-------------| +| `root/Instance.json` | `root/card.gts` | `"./card"` | +| `root/Card/instance.json` | `root/card.gts` | `"../card"` | +| `root/Card/Sub/instance.json` | `root/card.gts` | `"../../card"` | +| `root/Card/instance.json` | `root/other/card.gts` | `"../other/card"` | + +## Instance JSON Structure (Full) + +```json +{ + "data": { + "type": "card", + "attributes": { + "fieldName": "value", + "numberField": 123, + "boolField": true + }, + "relationships": { + "author": { + "links": { + "self": "../Author/jane-doe" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../card-definition", + "name": "CardClassName" + } + } + } +} +``` + +## linksToMany Relationships (CRITICAL) + +**🔴 For `linksToMany` fields, use numbered keys like `fieldName.0`, `fieldName.1`, etc.** + +```json +{ + "data": { + "relationships": { + "tags.0": { + "links": { + "self": "../Tag/tech" + } + }, + "tags.1": { + "links": { + "self": "../Tag/news" + } + }, + "tags.2": { + "links": { + "self": "../Tag/tutorial" + } + } + } + } +} +``` + +### ❌ Wrong: Array syntax (does NOT work) +```json +{ + "relationships": { + "tags": { + "links": { + "self": ["../Tag/tech", "../Tag/news"] + } + } + } +} +``` +``` + +### JSON Structure Rules + +| Section | Purpose | Required | +|---------|---------|----------| +| `data.type` | Always `"card"` | Yes | +| `data.attributes` | Scalar field values (string, number, bool) | Yes | +| `data.relationships` | Links to other cards (`linksTo`/`linksToMany`) | Only if has links | +| `data.meta.adoptsFrom` | References the card definition | Yes | + +### Attributes vs Relationships + +**Use `attributes` for:** +- StringField, NumberField, BooleanField values +- FieldDef instances (embedded via `contains`) +- Any non-card data + +**Use `relationships` for:** +- CardDef references (`linksTo` → single link) +- CardDef arrays (`linksToMany` → array of links) + +## The Cardinal Rule (linksTo vs contains) + +**🔴 CRITICAL - memorize this:** + +| Field Type | Definition uses | Instance uses | +|------------|-----------------|---------------| +| Extends `CardDef` | `linksTo` / `linksToMany` | `relationships` | +| Extends `FieldDef` | `contains` / `containsMany` | `attributes` | + +```gts +// In .gts definition: +@field author = linksTo(Author); // Author extends CardDef → relationships +@field address = contains(AddressField); // AddressField extends FieldDef → attributes +``` + +```json +// In .json instance: +{ + "attributes": { + "address": { "street": "123 Main", "city": "NYC" } + }, + "relationships": { + "author": { "links": { "self": "../Author/jane" } } + } +} +``` + +## Links Between Cards + +When linking to other cards, use the card's URL without `.json`: + +```json +{ + "data": { + "relationships": { + "author": { + "links": { + "self": "../Author/jane-doe" + } + } + } + } +} +``` + +## Base Realms (Read-Only) + +These realms contain shared definitions you can import from: + +**Production:** +- `https://cardstack.com/base/` - Core types (CardDef, FieldDef, etc.) +- `https://app.boxel.ai/catalog/` - Catalog cards +- `https://app.boxel.ai/skills/` - Skill cards + +**Staging:** +- `https://cardstack.com/base/` - Same core types +- `https://realms-staging.stack.cards/catalog/` +- `https://realms-staging.stack.cards/skills/` + +## Common Import Patterns + +```gts +// Core imports (always from cardstack.com/base) +import { + CardDef, + FieldDef, + field, + contains, + linksTo, + containsMany, + linksToMany, + StringField, + NumberField, + BooleanField, + Component, +} from 'https://cardstack.com/base/card-api'; + +// Import from same workspace +import { Author } from './author'; + +// Import from base realm +import { Skill } from 'https://cardstack.com/base/skill'; +``` + +## Query Structure (for API searches) + +When using the `/_search` API endpoint: + +```json +{ + "filter": { + "type": { + "module": "https://realm-url/card-name", + "name": "CardClassName" + } + } +} +``` + +**With field filters:** +```json +{ + "filter": { + "on": { "module": "https://realm-url/product", "name": "Product" }, + "contains": { "name": "laptop" } + } +} +``` + +**Operations:** `eq`, `contains`, `range`, `not`, `type`, `every` (AND), `any` (OR) + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| `"module": "./card"` from subdirectory | Use `"../card"` | +| `contains(CardDef)` | Use `linksTo(CardDef)` | +| `linksTo(FieldDef)` | Use `contains(FieldDef)` | +| Link in `attributes` | Move to `relationships` | +| FieldDef in `relationships` | Move to `attributes` | +| Missing `data` wrapper in JSON | Wrap everything in `{"data": {...}}` | +| PascalCase for `.gts` files | Use `kebab-case.gts` | +| kebab-case for instance dirs | Use `PascalCase/` | +| `linksToMany` as array | Use numbered keys: `field.0`, `field.1`, etc. | + +## Essential Formats + +Every CardDef should implement these templates: +- `isolated` - Full detail view (scrollable) +- `embedded` - Compact summary for lists +- `fitted` - Fixed dimensions for grids/dashboards (CRITICAL for good UX) diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-repair/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-repair/SKILL.md new file mode 100644 index 0000000000..5bb67bafcd --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-repair/SKILL.md @@ -0,0 +1,42 @@ +--- +name: boxel-repair +description: Use when a Boxel workspace has broken realm metadata, missing icons or backgrounds, bad `index.json` or `cards-grid.json` links, or stale Matrix realm metadata that needs `boxel repair-realm` or `boxel repair-realms`. +--- + +# Boxel Repair + +Use this workflow when a workspace has any of these symptoms: +- Missing icon/background in workspace tiles +- Display name is `Unknown Workspace` or mismatched +- Opening a workspace fails due to missing `cards-grid` relationship +- Matrix workspace list (`app.boxel.realms`) is stale/inconsistent + +## Commands + +```bash +# Inspect one realm without mutating +boxel repair-realm --dry-run + +# Repair one realm +boxel repair-realm + +# Repair all realms owned by active profile user +boxel repair-realms +``` + +## Behavior + +`repair-realm` and `repair-realms` perform these repairs: +- `.realm.json`: normalize `name`, `iconURL`, `backgroundURL` +- `index.json`: ensure `relationships.cardsGrid.links.self` = `./cards-grid` +- `cards-grid.json`: restore default cards-grid card if missing/corrupt +- Before replacing `index.json`/`cards-grid.json`, preserve existing content as timestamped backup cards in the same realm +- `index.json`: write `data.meta._touched` timestamp to break cache +- Matrix `app.boxel.realms`: reconcile list to match repaired, accessible realms + +## Important Defaults + +- `personal` realm is excluded unless `--include-personal` is provided. +- Batch repair defaults to active profile owner. +- Use `--no-reconcile-matrix` when you want file/card repair only. +- Use `--no-fix-index`/`--no-touch-index` when debugging minimal metadata-only fixes. diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-restore/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-restore/SKILL.md new file mode 100644 index 0000000000..15885c19e7 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-restore/SKILL.md @@ -0,0 +1,76 @@ +--- +name: boxel-restore +description: Use when restoring a Boxel workspace to a previous checkpoint and syncing deletions back to the server safely, including stopping watch first and running `boxel sync . --prefer-local` after restore. +--- + +# Boxel Restore + +Restore workspace to a previous checkpoint and sync deletions to server. + +## Workflow + +1. **Stop watch if running** - Prevents re-pulling deleted files +2. **Show history** - Display recent checkpoints with numbers +3. **Confirm target** - Ask user which checkpoint (or accept from command) +4. **Restore locally** - Run `boxel history . -r ` +5. **Sync to server** - Run `boxel sync . --prefer-local` to push deletions +6. **Restart watch** - Optionally restart watch if it was running + +## Usage + +``` +Use the `boxel-restore` skill interactively +Restore checkpoint `3` +Restore checkpoint `abc123` +``` + +## Commands Used + +```bash +# Stop any running watch first +# (check /tasks and stop if needed) + +# View history +boxel history . + +# Restore to checkpoint (auto-confirm) +echo "y" | boxel history . -r + +# ESSENTIAL: Push deletions to server +boxel sync . --prefer-local + +# Optionally restart watch +boxel watch . -i -d +``` + +## Response Format + +1. Show the checkpoint being restored to (hash, message, date, source) +2. List files that will be deleted (if any new files since checkpoint) +3. Execute restore +4. Execute sync with --prefer-local +5. Confirm completion + +## Critical Notes + +- **Always stop watch before restoring** - Otherwise it re-pulls deleted files +- **Always use --prefer-local after restore** - This syncs deletions to server +- After restore, workspace matches checkpoint exactly (files added later are gone) + +## Example Output + +``` +Restoring to checkpoint #3: abc1234 + Message: Pull: Update knicks-vip-ticket.gts + Source: SERVER (external change) + Date: 5 minutes ago + +Files that will be deleted: + - KnicksVipTicket/knicks-vs-magic.json + - KnicksVipTicket/knicks-vs-thunder.json + +Restoring... ✓ +Syncing deletions to server... ✓ + +Restore complete. Server now matches checkpoint #3. +``` diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-setup/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-setup/SKILL.md new file mode 100644 index 0000000000..d8019c9a4e --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-setup/SKILL.md @@ -0,0 +1,103 @@ +--- +name: boxel-setup +description: Use for Boxel CLI onboarding, profile setup, verifying login, listing workspaces, switching profiles, or helping a new user perform their first sync. +--- + +# Boxel Setup + +Guide new users through Boxel CLI setup. + +## Trigger +Run this automatically when: +- User first opens the repo +- No profile is configured (`npx boxel profile` shows nothing) +- User asks about setup or getting started + +## Flow + +### 1. Check Current State +```bash +npx boxel profile +``` + +If no profile exists, proceed with setup. + +### 2. Add a Profile + +**Option A: Interactive (recommended)** +```bash +npx boxel profile add +``` + +This wizard will: +1. Ask for environment (Production or Staging) +2. Ask for username and password +3. Create the profile automatically + +**Option B: Non-interactive (CI/automation)** + +Ask the user for: +- **Environment**: Production (app.boxel.ai) or Staging (realms-staging.stack.cards) +- **Username**: Their Boxel handle (e.g., `aallen90`, `ctse`). Found in Account panel as `@username:stack.cards` or in workspace URLs like `app.boxel.ai/username/workspace-name` +- **Password**: Same as Boxel web login + +Then run (using environment variable for security): + +**Production:** +```bash +BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "Production" +``` + +**Staging:** +```bash +BOXEL_PASSWORD="password" npx boxel profile add -u @username:stack.cards -n "Staging" +``` + +> **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history. + +### 3. Verify +```bash +npx boxel list +``` + +### 4. First Sync +Help them sync a workspace: +```bash +npx boxel sync @username/workspace ./workspace-name +``` + +## Profile Management + +**List profiles:** +```bash +npx boxel profile list +``` + +**Switch profile:** +```bash +npx boxel profile switch +``` + +**Migrate from old .env:** +```bash +npx boxel profile migrate +``` + +## Success Message +``` +Setup complete! You can now: +- `npx boxel list` - See your workspaces +- `npx boxel sync @username/workspace` - Sync a workspace +- `npx boxel watch .` - Monitor for changes +- `npx boxel history .` - View/restore checkpoints + +Profile management: +- `npx boxel profile` - Show active profile +- `npx boxel profile list` - List all profiles +- `npx boxel profile switch ` - Switch profiles + +For AI-assisted development, try: +- `boxel-watch` - Smart watch with auto intervals +- `boxel-sync` - Context-aware sync +- `boxel-restore` - Undo changes +``` diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-sync/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-sync/SKILL.md new file mode 100644 index 0000000000..9496478dab --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-sync/SKILL.md @@ -0,0 +1,80 @@ +--- +name: boxel-sync +description: Use when deciding how to sync a Boxel workspace after local edits, server changes, or a restore, including choosing between interactive sync, `--prefer-local`, `--prefer-remote`, or `--prefer-newest`. +--- + +# Boxel Sync + +Smart bidirectional sync with context-aware conflict resolution. + +## Context Detection + +Analyze the situation to choose the right sync strategy: + +### After Local Edits +When Claude has been editing files locally: +- Use `--prefer-local` to push changes +- Creates checkpoint for the push + +### After Server Activity +When watch detected server changes or user mentions UI edits: +- Use `--prefer-remote` or default (interactive) +- Pull changes first + +### After Restore +When a restore was just performed: +- Use `--prefer-local` to sync deletions to server +- Essential for completing the restore workflow + +### Conflict Detected +When both sides have changes: +- Show status first +- Ask user preference or use `--prefer-newest` + +## Commands + +```bash +# Check status first +boxel status . + +# Standard sync (interactive conflicts) +boxel sync . + +# Push local changes +boxel sync . --prefer-local + +# Pull remote changes +boxel sync . --prefer-remote + +# Auto-resolve by timestamp +boxel sync . --prefer-newest + +# Include deletions +boxel sync . --delete + +# Preview only +boxel sync . --dry-run +``` + +## Response Format + +1. Brief status check (what changed where) +2. Chosen strategy and why +3. Execute sync +4. Report results (files pushed/pulled/deleted) + +## Example Output + +``` +Checking status... + Local: 2 files modified + Remote: No changes + +Using --prefer-local since you have local edits. + +Syncing... + Pushed: card-definition.gts, instance.json + Checkpoint: abc1234 [MAJOR] Push: 2 files + +Sync complete! +``` diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-track/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-track/SKILL.md new file mode 100644 index 0000000000..965f26377f --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-track/SKILL.md @@ -0,0 +1,117 @@ +--- +name: boxel-track +description: Use when starting or explaining `boxel track` for local file watching, automatic checkpoints, or optional real-time push with `--push` during Boxel development. +--- + +# Boxel Track + +Start `boxel track` to monitor local file changes and create checkpoints automatically. + +## When to Use Track + +Use **track** when you're editing files locally (in IDE, with AI agent, etc.) and want automatic backups: +- Working in VS Code, Cursor, or other IDE +- AI agent is editing files +- You want checkpoint history of your work + +**Track vs Watch:** +| Command | Symbol | Direction | Purpose | +|---------|--------|-----------|---------| +| `track` | ⇆ | Local edits → Checkpoints | Backup your work as you edit | +| `watch` | ⇅ | Server → Local | Pull external changes from Boxel UI | + +## Commands + +```bash +# Start tracking (default: 3s debounce, 10s min interval) +boxel track . + +# Track AND auto-push to server (real-time sync) +boxel track . --push + +# Custom timing (5s debounce, 30s between checkpoints) +boxel track . -d 5 -i 30 + +# Quiet mode (only show checkpoints) +boxel track . -q + +# Verbose mode (debug output) +boxel track . -v + +# Stop all track/watch processes +boxel stop +``` + +## The Track → Sync Workflow + +### Option 1: Manual Sync (Default) +Track creates local checkpoints only. Push to server when ready: + +```bash +# 1. Track creates checkpoints as you edit +boxel track . + +# 2. When ready to push to server, sync with --prefer-local +boxel sync . --prefer-local +``` + +This lets you: +- Work offline with local backups +- Batch multiple edits before pushing +- Review changes before they go live + +### Option 2: Real-Time Sync (--push) +Auto-push changes to server as you edit: + +```bash +# Track AND push changes automatically +boxel track . --push +``` + +Uses batch upload via `/_atomic` endpoint for efficient multi-file uploads. Definitions (.gts) are uploaded before instances (.json) to ensure proper indexing. + +## Context Detection + +When invoked, consider: + +### Standard Development (3s debounce, 10s interval) +- Normal editing workflow +- Balanced between checkpoint frequency and overhead + +### Fast Iteration (2s debounce, 5s interval) +- Rapid prototyping +- User says "track closely" or "capture everything" + +### Background Tracking (5s debounce, 30s interval) +- Long editing sessions +- User says "just backup" or "light tracking" + +## Response Format + +When invoked: +1. Confirm workspace directory +2. Start track with appropriate settings +3. **Remind user about sync options** + +Example (without --push): +``` +Starting track in the current workspace (3s debounce, 10s interval). +Checkpoints will be created automatically as you save files. + +Remember: Track creates LOCAL checkpoints only. +When ready to push changes to Boxel server: + boxel sync . --prefer-local + +Or restart with --push for real-time sync: + boxel track . --push + +Use Ctrl+C to stop tracking, or `boxel stop` from another terminal. +``` + +Example (with --push): +``` +Starting track with auto-push (3s debounce, 10s interval). +Changes will be checkpointed AND pushed to server automatically. + +Use Ctrl+C to stop, or `boxel stop` from another terminal. +``` diff --git a/packages/software-factory/experiment_1/.agents/skills/boxel-watch/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/boxel-watch/SKILL.md new file mode 100644 index 0000000000..a733ef7eb5 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/boxel-watch/SKILL.md @@ -0,0 +1,66 @@ +--- +name: boxel-watch +description: Use when starting or choosing settings for `boxel watch` to monitor remote Boxel changes, including active-development, quick-feedback, and background-monitoring intervals. +--- + +# Boxel Watch + +Start `boxel watch` with intelligent interval settings based on context. + +## Context Detection + +Analyze the conversation and recent activity to determine the appropriate watch settings: + +### Active Development Mode (5s interval, 3s debounce) +Use when: +- User is actively editing .gts or .json files +- User mentions "editing", "working on", "changing", "updating" +- Recent file writes or edits in the workspace +- User asks to "watch while I work" + +### Monitoring Mode (30s interval, 10s debounce) +Use when: +- User wants to "keep an eye on" changes +- User is doing research, reading, or planning +- No recent edits to workspace files +- User says "background", "monitor", or "check occasionally" + +### Quick Feedback Mode (10s interval, 5s debounce) +Use when: +- User is testing changes in Boxel UI +- User mentions "testing", "trying", "see if it works" +- Balanced between responsiveness and efficiency + +## Execution + +1. Determine the workspace directory (default: current synced workspace) +2. Determine the mode based on context +3. Explain the chosen settings briefly +4. Start watch in background with appropriate flags +5. Inform user how to stop (Ctrl+C or task stop) + +## Commands + +```bash +# Active development +boxel watch . -i 5 -d 3 + +# Monitoring +boxel watch . -i 30 -d 10 + +# Quick feedback +boxel watch . -i 10 -d 5 + +# Quiet mode (any interval) +boxel watch . -i -d -q +``` + +## Response Format + +When invoked, respond with: +1. Detected mode and reasoning (1 sentence) +2. The watch command being run +3. How to stop or adjust + +Example: +"Starting watch in **active development mode** (5s interval) since you're editing card files. Run in background - use `/tasks` to check status or Ctrl+C to stop." diff --git a/packages/software-factory/experiment_1/.agents/skills/software-factory-operations/SKILL.md b/packages/software-factory/experiment_1/.agents/skills/software-factory-operations/SKILL.md new file mode 100644 index 0000000000..7fa9b72234 --- /dev/null +++ b/packages/software-factory/experiment_1/.agents/skills/software-factory-operations/SKILL.md @@ -0,0 +1,62 @@ +--- +name: software-factory-operations +description: Use when building or extending an application through the Boxel software-factory workflow in this repo, especially when the task should be broken into tickets, stored in Boxel, implemented in a target realm, verified with Playwright, and synced/checkpointed incrementally. +--- + +# Software Factory Operations + +Use this skill when the objective is not just to write code, but to run the full Boxel software-factory loop successfully. + +## Read First + +- `AGENTS.md` +- `.boxel-workspaces.json` + +## Realm Map + +- `./realms/guidance-tasks` + Shared tracker schema and demo cards. Import tracker modules from here. +- `./realms/software-factory-demo` + Default implementation realm for the current demo and the place to build new artifacts. + +Use `boxel realms --llm` whenever file placement is unclear. + +## Working Commands + +- Search a realm: + `npm run boxel:search -- --realm --size 20` +- Pick backlog tickets: + `npm run boxel:pick-ticket -- --realm --module http://localhost:4201/factory/guidance-tasks/darkfactory-schema` +- Get browser auth payloads: + `npm run boxel:session -- --realm ` +- Run browser verification: + `npm run test:ticket-flow` +- Run Boxel-hosted project tests: + `npm run test:realm -- --realm-path ./realms/` +- Sync implementation realm: + `boxel sync ./realms/software-factory-demo --prefer-local` +- Create manual checkpoints: + `boxel history ./realms/software-factory-demo -m ""` + +## Required Flow + +1. Search for backlog tickets in the target implementation realm. +2. Move the chosen ticket to `in_progress` before implementation. +3. Build the requested Boxel files in the implementation realm. +4. Keep product-specific Playwright specs and fixture files in the implementation realm when they should persist with the project. +5. Prefer fixture-driven verification through a fresh scratch realm created by `npm run test:realm`. +6. Verify the resulting card URL with Playwright. +7. Update ticket notes, acceptance criteria, and related knowledge. +8. Sync to Boxel and create meaningful checkpoints. +9. Commit repo-side tooling or instruction changes in git. + +## Important Gotchas + +- For tracker searches, use the schema module URL: + `http://localhost:4201/factory/guidance-tasks/darkfactory-schema` +- If a card in one private realm imports definitions from another private realm, seed browser auth for both realms. +- Realm-hosted test fixtures should usually be stored as final realm-relative paths under `tests/fixtures/`. +- Scratch realms should be checked out under the canonical Boxel workspace path layout, not ad hoc folders, so `boxel` commands do not keep reporting legacy workspace locations. +- If a fixture card instance is meant to run in a scratch realm, use an absolute `adoptsFrom.module` URL whenever the backing definition lives in the source realm. +- Boxel host pages keep long-lived network activity. In Playwright, do not wait for `networkidle`; use `domcontentloaded` plus visible assertions. +- `guidance-tasks` is a shared schema realm, not the place to build product-specific implementation files. diff --git a/packages/software-factory/experiment_1/.claude/CLAUDE.md b/packages/software-factory/experiment_1/.claude/CLAUDE.md new file mode 100644 index 0000000000..83247d1ca1 --- /dev/null +++ b/packages/software-factory/experiment_1/.claude/CLAUDE.md @@ -0,0 +1,699 @@ +# Boxel CLI - Claude Code Integration + +## GitHub Repository + +**Official repo:** https://github.com/cardstack/boxel-cli + +--- + +## How to Run Boxel Commands + +After `npm install && npm run build`, use `npx boxel`: + +```bash +npx boxel sync . +npx boxel history ./workspace +npx boxel profile add +``` + +Or use `boxel` directly after `npm link`. + +**For development** (no rebuild needed after code changes): +```bash +npm run dev -- +``` + +All documentation below shows `boxel ` for brevity. + +--- + +## Auto-Activate Boxel Development Skill + +**IMPORTANT:** When the user is doing ANY of the following, automatically read and follow `.claude/skills/boxel-development/SKILL.md`: + +- Creating or editing `.gts` files (card definitions) +- Creating or editing `.json` card instances +- Asking about Boxel patterns, cards, or components +- "Vibe coding" or prototyping Boxel cards +- Working in a synced Boxel workspace (has `.boxel-sync.json`) +- Asking to create, build, or design anything in Boxel + +**How to activate:** Read the skill file at the start of the task: +``` +Read .claude/skills/boxel-development/SKILL.md +``` + +The skill contains comprehensive Boxel development guidance including CardDef/FieldDef patterns, templates, styling, and best practices. + +--- + +**When a user opens this repo, check if they need onboarding first!** + +## Onboarding Flow + +When you detect a new user (no profile configured), guide them through setup: + +### Step 1: Check Profile +```bash +npx boxel profile +``` + +If no profile exists, run the interactive setup: + +### Step 2: Add a Profile +```bash +npx boxel profile add +``` + +This launches an interactive wizard that: +1. Asks for environment (Production or Staging) +2. Asks for username and password +3. Creates the profile in `~/.boxel-cli/profiles.json` + +**Non-interactive option (CI/automation only):** +```bash +# Use environment variable to avoid exposing password in shell history +BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "My Prod Account" +``` + +> **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history and process listings. Use the interactive wizard or `BOXEL_PASSWORD` environment variable. + +### Step 3: Verify & List Workspaces +```bash +npx boxel list +``` + +### Step 4: First Sync +Help them sync their first workspace: +```bash +npx boxel sync @username/workspace ./workspace-name +``` + +### Switching Between Profiles +```bash +npx boxel profile list # See all profiles (★ = active) +npx boxel profile switch username # Switch by partial match +``` + +--- + +## Local Workspace Organization + +When syncing multiple workspaces locally, organize them by **domain/username/realm** to mirror the Matrix ID structure (`@username:domain`): + +``` +boxel-workspaces/ +├── boxel.ai/ # Production domain +│ └── acme-corp/ # Username +│ ├── personal/ # Realm +│ ├── project-atlas/ +│ └── inventory-tracker/ +└── stack.cards/ # Staging domain + └── acme-corp/ + └── sandbox/ +``` + +**Benefits:** +- Clear separation between production and staging environments +- Matches the `@username:domain` profile ID format +- Easy to identify which profile/environment a workspace belongs to +- Supports multiple users on the same machine + +**First-time sync to this structure:** +```bash +# Production workspace +boxel pull https://app.boxel.ai/acme-corp/project-atlas/ ./boxel-workspaces/boxel.ai/acme-corp/project-atlas + +# Staging workspace +boxel pull https://realms-staging.stack.cards/acme-corp/sandbox/ ./boxel-workspaces/stack.cards/acme-corp/sandbox +``` + +--- + +## Available Skills + +Shared repo-local skills live in `.agents/skills/`. +`.claude/skills/` should be a symlink to that directory so Claude and Codex read the same files. + +### `boxel-track` - Track Local Edits +Use this skill when starting `boxel track` for local file watching and checkpoints: +- Creates checkpoints as you save files in IDE +- Use `--push` flag to automatically push changes to server (batch upload) +- Without `--push`: Run `boxel sync . --prefer-local` to push to server + +### `boxel-watch` - Smart Watch +Use this skill when starting `boxel watch` with context-aware timing: +- **Active development** (5s interval, 3s debounce): When editing files +- **Monitoring** (30s interval, 10s debounce): Background observation +- **Quick feedback** (10s interval, 5s debounce): Testing changes + +### `boxel-restore` - Restore Checkpoint +Use this skill for the full restore workflow: +1. Shows history +2. Restores to checkpoint (properly deletes newer files) +3. Syncs deletions to server with `--prefer-local` +4. Optionally restarts watch + +### `boxel-sync` - Smart Sync +Use this skill for context-aware bidirectional sync: +- After local edits or track → `--prefer-local` +- After server changes → `--prefer-remote` +- After restore → `--prefer-local` (essential for syncing deletions) + +### `boxel-repair` - Realm Metadata/Card Repair +Use when workspaces show missing icon/background, wrong display name, or fail to open due to broken `index.json`/`cards-grid.json` links. +- Read `.claude/skills/boxel-repair/SKILL.md` for the step-by-step repair flow. +- `boxel repair-realm ` repairs one realm +- `boxel repair-realms` repairs all owned realms (excluding `personal` by default) +- Also reconciles Matrix account data (`app.boxel.realms`) unless disabled + +### `software-factory-operations` - End-to-End Delivery Loop +Use this skill when the task is to break work into Boxel tickets, implement in an assigned realm, verify with Playwright, and keep knowledge plus progress checkpoints as durable factory memory. + +--- + +## Commands Reference + +### Status & Checking +```bash +boxel status . # Check sync status +boxel status --all # Check all workspaces +boxel status . --pull # Auto-pull remote changes +boxel check ./file.json --sync # Check single file +``` + +### Pull, Push, Sync (Command Relationship) + +| Command | Direction | Purpose | Deletes Local | Deletes Remote | +|---------|-----------|---------|---------------|----------------| +| `pull` | Remote → Local | Fresh download | with `--delete` | never | +| `push` | Local → Remote | Deploy changes | never | with `--delete` | +| `sync` | Both ways | Stay in sync | with `--prefer-remote` | with `--prefer-local` | + +```bash +boxel sync . # Interactive sync +boxel sync . --prefer-local # Keep local + sync deletions +boxel sync . --prefer-remote # Keep remote +boxel sync . --prefer-newest # Keep newest version +boxel sync . --delete # Sync deletions both ways +boxel sync . --dry-run # Preview only + +boxel push ./local # One-way push (local → remote) +boxel push ./local --delete # Push and remove orphaned remote files +boxel pull ./local # One-way pull (remote → local) +``` + +**Failed download cleanup:** When `sync` encounters files that return 500 errors (broken/corrupted on server), it will prompt you to delete them: +``` +⚠️ 3 file(s) failed to download (server error): + - Staff/broken-card.json + - Student/corrupted.json + +These files may be broken on the server. Delete them from remote? [y/N] +``` + +> **Safety tip:** Before any destructive operation, create a checkpoint with a descriptive message: +> ```bash +> boxel history . -m "Before cleanup: removing broken server files" +> ``` + +### Track ⇆ (Local File Watching) +```bash +boxel track . # Track local edits, auto-checkpoint as you save +boxel track . --push # Track AND push changes to server (batch upload) +boxel track . -d 5 -i 30 # 5s debounce, 30s min between checkpoints +boxel track . -q # Quiet mode +boxel track . -v # Verbose mode (debug output) +``` + +**Use track when:** Editing locally in IDE/VS Code. Creates checkpoints as you save files. +**Symbol:** ⇆ (horizontal arrows = local changes) +**With --push:** Real-time sync to server using batch upload via `/_atomic` endpoint. + +### Watch ⇅ (Remote Server Watching) +```bash +boxel watch # Watch all configured realms (from .boxel-workspaces.json) +boxel watch . # Watch single workspace +boxel watch . ./other-realm # Watch multiple realms simultaneously +boxel watch . -i 5 -d 3 # Active: 5s interval, 3s debounce +boxel watch . -q # Quiet mode +``` + +**Use watch when:** Others are editing in Boxel web UI. Pulls their changes and creates checkpoints. +**Symbol:** ⇅ (vertical arrows = remote server changes) + +### Stop +```bash +boxel stop # Stop all running watch (⇅) and track (⇆) processes +``` + +**Multi-realm watching:** Useful when code lives in one realm and data in another. Each realm gets its own checkpoint tracking and debouncing. + +### Realms (Multi-Realm Configuration) +```bash +boxel realms # List configured realms +boxel realms --init # Create .boxel-workspaces.json +boxel realms --add ./path # Add a realm +boxel realms --add ./code --purpose "Card definitions" --patterns "*.gts" --default +boxel realms --add ./data --purpose "Data instances" --card-types "BlogPost,Product" +boxel realms --llm # Output LLM guidance for file placement +boxel realms --remove ./path # Remove a realm +``` + +**File placement guidance:** The `--llm` output tells Claude which realm to use for different file types and card types. + +### History & Restore +```bash +boxel history . # View checkpoints +boxel history . -r # Interactive restore +boxel history . -r 3 # Quick restore to #3 +boxel history . -r abc123 # Restore by hash +boxel history . -m "Message" # Create checkpoint with custom message +``` + +### Skills +```bash +boxel skills --refresh # Fetch skills from Boxel +boxel skills --list # List all available skills +boxel skills --enable "Name" # Enable a skill +boxel skills --disable "Name" # Disable a skill +boxel skills --export ./project # Export as Claude commands +``` + +### Profile (Authentication) +```bash +boxel profile # Show current active profile +boxel profile list # List all saved profiles (★ = active) +boxel profile add # Interactive wizard to add profile (recommended) +# Non-interactive: use BOXEL_PASSWORD env var instead of -p flag for security +boxel profile switch # Switch profile (partial match OK) +boxel profile remove # Remove a profile +boxel profile migrate # Migrate from old .env file +``` + +**Profile IDs:** Use Matrix format `@username:domain` +- Production: `@username:boxel.ai` +- Staging: `@username:stack.cards` + +**Storage:** Profiles stored in `~/.boxel-cli/profiles.json` (permissions: 0600) + +### Other +```bash +boxel list # List workspaces +boxel create endpoint "Name" # Create workspace +boxel consolidate-workspaces . # Move legacy local dirs into domain/owner/realm +boxel repair-realm # Repair one realm metadata/starter cards +boxel repair-realms # Batch repair all owned realms +boxel pull ./local # One-way pull +boxel push ./local # One-way push +``` + +### Share & Gather (GitHub Workflow) +```bash +boxel share . -t /path/to/repo -b branch-name --no-pr # Share to GitHub repo +boxel gather . -s /path/to/repo # Pull from GitHub repo +``` + +**Share** copies workspace state to a GitHub repo branch: +- Preserves repo-level files (package.json, LICENSE, README, etc.) +- Skips realm-specific files (.realm.json, index.json, cards-grid.json) +- Creates branch and commits changes + +**Gather** pulls changes from GitHub back to workspace: +- Symmetric to share +- Preserves workspace's realm-specific files + +**Pushing to GitHub:** Use GitHub Desktop to push branches (no CLI auth configured). +After share creates the branch locally, open GitHub Desktop and push. + +### `/boxel-development` - Default Vibe Coding Skill +The **Boxel Development** skill is auto-enabled for vibe coding. It provides comprehensive guidance for: +- Card definitions (.gts files) +- Card instances (.json files) +- Boxel patterns and best practices + +### `/boxel-file-structure` - File Organization Rules +Reference for local file organization: +- Directory naming: definitions (`kebab-case.gts`), instances (`PascalCase/`) +- Module paths: relative to JSON location (`../card` from subdirectory) +- JSON structure for card instances + +### `boxel skills` - Manage Additional Skills +Fetch and manage AI instruction cards from Boxel: +```bash +boxel skills --refresh # Fetch latest from Boxel +boxel skills --list # See available skills +boxel skills --enable "X" # Enable additional skills +boxel skills --export . # Re-export to .agents/skills/ (shared with .claude/skills/) +``` + +--- + +## Key Workflows + +### Local Development with Track (IDE/Agent Editing) +```bash +boxel track . # Start tracking local edits (auto-checkpoints) +# ... edit files in IDE or with Claude ... +# Track creates LOCAL checkpoints as you save + +# IMPORTANT: When ready to push changes to Boxel server: +boxel sync . --prefer-local # Push your local changes to server +``` + +**Remember:** Track does NOT sync to server automatically - it only creates local checkpoints. Always run `sync --prefer-local` when you want your changes live on the server. + +### Real-Time Sync with Track --push +```bash +boxel track . --push # Track AND auto-push to server +# ... edit files in IDE or with Claude ... +# Changes are checkpointed AND pushed to server automatically +``` + +**With --push:** Uses batch upload via `/_atomic` endpoint for efficient multi-file uploads. Definitions (.gts) are uploaded before instances (.json) to ensure proper indexing. + +### Active Development Session (Watching Server) +```bash +boxel watch . -i 5 -d 3 # Active development settings +# ... edit in Boxel UI or locally ... +boxel sync . # Push/pull changes +``` + +### Undo Server Changes (Restore) +```bash +boxel history . # Find checkpoint +boxel history . -r 3 # Restore to #3 +boxel sync . --prefer-local # ESSENTIAL: sync deletions to server +``` + +### Share Milestone to GitHub +```bash +boxel share . -t /path/to/boxel-home -b boxel/feature-name --no-pr +# Then push via GitHub Desktop +``` + +**URL Portability:** Share automatically converts absolute realm URLs in `index.json` and `cards-grid.json` to relative URLs, making the content portable across different realms. + +### Gather Updates from GitHub +```bash +boxel gather . -s /path/to/boxel-home +boxel sync . --prefer-local # Push gathered changes to Boxel server +``` + +**URL Portability:** Gather includes `index.json` and `cards-grid.json`, transforming any absolute URLs to relative paths for portability. + +Or simply: +``` +consult boxel-restore and restore checkpoint 3 +``` + +### Monitor Server While Working +```bash +boxel watch . -i 30 -d 10 # Monitoring settings +# Checkpoints created automatically +boxel history . # View what changed +``` + +### Multi-Realm Development +When working with multiple realms (e.g., code + data separation): + +```bash +# Configure realms once +boxel realms --add ./code-realm --purpose "Card definitions" --patterns "*.gts" --default +boxel realms --add ./data-realm --purpose "Content instances" --card-types "BlogPost,Product" + +# Watch all configured realms +boxel watch + +# Check where to put a new file +boxel realms --llm +``` + +**File placement heuristics:** +- `.gts` files → realm with `*.gts` pattern (usually code realm) +- Card instances → realm configured for that card type +- Ambiguous → use the default realm + +--- + +## Critical Patterns + +### ⚠️ SAFETY FIRST: Checkpoint Before Destructive Operations +**Always create a checkpoint with a descriptive message before:** +- Deleting files from server (`--prefer-local`, `push --delete`) +- Restoring to an earlier checkpoint +- Bulk cleanup operations +- Removing card definitions or instances + +```bash +boxel history . -m "Before cleanup: removing sample data and unused definitions" +# Now safe to proceed with destructive operation +boxel sync . --prefer-local +``` + +This ensures you can always recover if something goes wrong. The checkpoint message helps identify what state to restore to. + +### 0. ALWAYS Write Source Code, Never Compiled Output +When editing `.gts` files, **always write clean idiomatic source code**: +```gts +// CORRECT - Clean source +export class MyCard extends CardDef { + static fitted = class Fitted extends Component { + + }; +} +``` + +**NEVER** write or edit: +- Compiled JSON blocks (`"block": "[[[10,0]..."`) +- Base64-encoded CSS imports (`./file.gts.CiAg...`) +- Wire format template arrays + +The server compiles source to these formats. If you see them, the file was pulled from server - rewrite it as clean source. + +### 0.5. Edit Lock Before Modifying Files +When editing files locally while watch is running, use edit lock to prevent watch from overwriting your changes: +```bash +boxel edit . grammy-gallery.gts # Lock file before editing +# ... make your edits ... +boxel sync . --prefer-local # Push your changes +boxel touch . Instance/file.json # Force re-index +boxel edit . --done grammy-gallery.gts # Release lock +``` + +**Quick commands:** +```bash +boxel edit . --list # See what's locked +boxel edit . --clear # Clear all locks +boxel edit . --done # Release all locks +``` + +**Why:** Watch mode pulls remote changes which can overwrite local edits. Edit lock tells watch to skip those files. + +### 0.5. Touch Instance After Remote .gts Update +When you update a `.gts` card definition file remotely (via sync/push), touch an instance file to force re-indexing: +```bash +boxel touch . CardName/instance.json # Touch specific instance +boxel touch . # Or touch all files +``` +**Why:** The realm server may not re-index the definition until an instance using it is touched. + +### 1. Stop Watch Before Restore +Watch will re-pull deleted files if running during restore: +```bash +# Stop watch first (Ctrl+C or kill process) +boxel history . -r 3 +boxel sync . --prefer-local +``` + +### 2. Always Use --prefer-local After Restore +This syncs local deletions to the server: +```bash +boxel history . -r 3 # Deletes files locally +boxel sync . --prefer-local # Deletes files on server +``` + +### 3. Debouncing Groups Rapid Changes +Watch waits for changes to settle: +- Change detected → timer starts +- More changes → timer resets +- Timer expires → single checkpoint with all changes + +### 4. Checkpoint Classification +- `[MAJOR]` - New files, deleted files, .gts changes, >3 files +- `[minor]` - Small updates to existing .json files +- `LOCAL` ⇆ - Changes from local edits (track command) +- `SERVER` ⇅ - External changes from web UI (watch command) + +--- + +## File Structure + +``` +workspace/ +├── .boxel-sync.json # Sync manifest (hashes, mtimes) +├── .boxel-history/ # Git-based checkpoint history +├── .realm.json # Workspace config +├── index.json # Workspace index +├── *.gts # Card definitions +└── CardName/ + └── *.json # Card instances +``` + +--- + +## Workspace References + +Commands accept: +- `.` - Current directory (needs `.boxel-sync.json`) +- `./path` - Local path +- `@user/workspace` - e.g., `@username/personal` +- `https://...` - Full URL + +--- + +## Understanding Boxel URLs (Card IDs) + +When a user shares a URL like: +``` +https://app.boxel.ai/tribecaprep/employee-handbook/Document/d8341312-f3a0-442b-a2e5-49c5cdd84695 +``` + +**This is a Card ID, not a fetchable URL!** + +### How to Parse Boxel URLs + +| URL Part | Meaning | +|----------|---------| +| `app.boxel.ai` | Production server | +| `tribecaprep` | User/organization | +| `employee-handbook` | Realm/workspace name | +| `Document/d8341312-...` | Card type and instance path | + +### NEVER Use WebFetch on Boxel URLs + +- Boxel realms are **usually private** and require Matrix authentication +- WebFetch will fail with 401/403 errors +- The user is referencing content **they expect you to have locally** + +### Finding the Local Copy + +If the user references a Boxel URL, the file is likely already synced to the local workspace: + +1. **Parse the path**: `Document/d8341312-f3a0-442b-a2e5-49c5cdd84695` → local path is `Document/d8341312-f3a0-442b-a2e5-49c5cdd84695.json` + +2. **Search the workspace**: +```bash +# Find by card ID +find . -name "d8341312-f3a0-442b-a2e5-49c5cdd84695*" + +# Or search for the card type folder +ls ./Document/ +``` + +3. **Read the local file** using the Read tool + +### Example Workflow + +User says: "Check the handbook at https://app.boxel.ai/tribecaprep/employee-handbook/Document/abc123" + +**Do this:** +``` +# Look for local file +Read ./Document/abc123.json +``` + +**NOT this:** +``` +# This will FAIL - private realm +WebFetch https://app.boxel.ai/tribecaprep/employee-handbook/Document/abc123 +``` + +--- + +## API Reference + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/_mtimes` | GET | File modification times | +| `/` | GET | Download file | +| `/` | POST | Upload file | +| `/` | DELETE | Delete file | +| `/_atomic` | POST | Batch atomic operations | + +Headers: +- `Authorization`: JWT from Matrix auth +- `Accept`: `application/vnd.card+source` or `application/vnd.api+json` + +### Atomic Batch Operations + +The `/_atomic` endpoint supports batch file operations that succeed or fail atomically: + +```json +{ + "atomic:operations": [ + { "op": "add", "href": "./path/to/new.json", "data": { "data": {...} } }, + { "op": "update", "href": "./path/to/existing.gts", "data": { "data": { "type": "module", "attributes": { "content": "..." } } } }, + { "op": "remove", "href": "./path/to/delete.json" } + ] +} +``` + +| Operation | Behavior | +|-----------|----------| +| `add` | Create new file (fails 409 if exists) | +| `update` | Update existing file (fails 404 if missing) | +| `remove` | Delete file | + +**Content-Type:** `application/vnd.api+json` + +--- + +## Conflict Resolution + +| Local | Remote | Action | +|-------|--------|--------| +| Changed | Unchanged | Push | +| Unchanged | Changed | Pull | +| Changed | Changed | Conflict → use strategy | +| Deleted | Changed | `--prefer-local` deletes remote | +| Changed | Deleted | `--prefer-remote` deletes local | + +--- + +## Troubleshooting + +### "Authentication failed" +- Check active profile: `boxel profile` +- Verify credentials: `boxel profile list` +- Verify you can log into Boxel web with same credentials +- For staging: ensure profile uses `@username:stack.cards` + +### "No workspace found" +- Run `boxel list` to see workspaces +- Use full URL for first sync +- Ensure correct profile is active for the environment + +### Files keep reverting after restore +- Stop watch before restoring +- Use `boxel sync . --prefer-local` after + +### Watch not detecting changes +- Check interval setting +- Verify server URL +- Check active profile: `boxel profile` + +### Switching environments (prod/staging) +- Add profiles for each environment +- Switch with: `boxel profile switch ` + +### "500 Internal Server Error" on specific files +- These files are broken/corrupted on the server +- Sync will prompt you to delete them after completion +- Or use `boxel push . --delete` to remove all orphaned remote files +- Check if card definitions have errors in Boxel web UI diff --git a/packages/software-factory/experiment_1/.claude/skills b/packages/software-factory/experiment_1/.claude/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/packages/software-factory/experiment_1/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/packages/software-factory/experiment_1/AGENTS.md b/packages/software-factory/experiment_1/AGENTS.md new file mode 100644 index 0000000000..10b6560671 --- /dev/null +++ b/packages/software-factory/experiment_1/AGENTS.md @@ -0,0 +1,224 @@ +# AGENTS.md - Boxel CLI Codex Guidance + +## High-Priority Safety Rules + +1. Create a checkpoint before destructive operations: + - `boxel history . -m "Before destructive operation"` +2. After restore, always sync with local preference: + - `boxel history . -r ` + - `boxel sync . --prefer-local` +3. Stop watch before restore to avoid re-pulling deleted files. +4. When watch is running and you edit files locally, use edit locks: + - `boxel edit . ` before editing + - `boxel edit . --done ` after sync +5. Write clean source code, never compiled wire-format output for `.gts` files. + +## Mission + +This repo is part of a software factory where humans do not inspect or hand-edit code directly. + +The goal is to: + +- Accept a user request +- Break it down into persistent tasks +- Implement the work incrementally +- Test each step +- Commit or checkpoint progress continuously +- Store the plan, tickets, and knowledge base in Boxel so future runs become more reliable + +Code and project state should live in Boxel realms. Tickets and knowledge cards are the persistent memory for the factory. + +## Current Environment + +- Active Boxel CLI user: `factory` +- Do not put passwords directly into shell commands. If re-auth is needed, use `BOXEL_PASSWORD` or interactive login. +- Realm server: `http://localhost:4201/` +- Matrix server: `http://localhost:8008` +- `boxel list` currently shows access to: + - `http://localhost:4201/factory/guidance-tasks/` +- Check out Boxel workspaces into the local `realms/` subfolder under this repo +- Ignore `dark-factory`; it was an earlier iteration and should not be used as the current target +- Task tracker modules live in the `guidance-tasks` workspace under module `darkfactory` +- There are demo instances in that workspace that should be inspected before inventing new task structures +- Tickets may live in a dedicated task realm or in the realm created for a specific solution, but they should reuse the tracker module instead of duplicating it + +## Factory Execution Model + +When given a product request, the default operating loop is: + +1. Discover the relevant realms, modules, and existing task or knowledge cards +2. Choose or create the target implementation realm +3. Break the request into tickets, milestones, or task cards in Boxel +4. Add or update knowledge-base cards that capture decisions, constraints, and reusable procedures +5. Implement work one task at a time +6. Test after each meaningful change +7. Checkpoint with Boxel history and commit in git when a git repo exists +8. Sync changes back to Boxel and continue iterating until the request is demonstrably working + +Persistent Boxel artifacts are part of the deliverable, not just code. + +## Immediate Demo Priority + +There is one hour to produce an end-to-end demo of this workflow. + +Bias toward: + +- A small but complete project +- Visible task breakdown into tickets +- Clear knowledge-base entries +- Repeated task -> implement -> test -> checkpoint loops +- Fast feedback over architectural perfection + +## Auth and Testing Notes + +- Prefer Boxel CLI capabilities over rebuilding the same behavior from scratch +- Additional tooling is allowed when the CLI does not cover the task cleanly +- You will likely need auth helpers that can obtain JWTs for accessible realms +- The `_server-session` endpoint can provide JWTs for realms the current user can access +- Authenticated card URLs open directly into interact mode, which is the preferred surface for browser testing and Playwright-based verification +- Explore search and query options in the tracker and workspace data model before creating new structures +- Project tests should live in Boxel realms when they are part of the product's persistent memory +- Preferred convention: + - realm-local Playwright specs live under `tests/**/*.spec.mjs` + - files copied into disposable verification realms live under `tests/fixtures/**` + - fixture contents are copied to the scratch realm root preserving paths, so `tests/fixtures/DeliveryBrief/example.json` becomes `DeliveryBrief/example.json` in the scratch realm +- Run realm-hosted tests with: + - `npm run test:realm -- --realm-path ./realms/` +- The default runner flow is: + 1. create a fresh scratch realm + 2. pull it locally under the canonical workspace path `realms////` + 3. copy fixture files from the source realm into the scratch realm + 4. sync the scratch realm + 5. run the source realm's Playwright specs against the scratch realm URL + 6. report failures and keep the scratch realm available for inspection +- When fixture instances depend on card definitions from the source realm, prefer absolute `meta.adoptsFrom.module` URLs so the scratch realm only needs the copied instances + +## Boxel Development Trigger + +When tasks involve Boxel card development, automatically consult: + +- `.agents/skills/boxel-development/SKILL.md` +- `.agents/skills/software-factory-operations/SKILL.md` when the task is about ticket-driven application delivery, realm coordination, or the end-to-end factory loop + +The shared repo-local skills live in `.agents/skills/`. +Claude should read them through `.claude/skills/`, which should point at the same directory to avoid duplicate instructions. + +Trigger examples: + +- Editing `.gts` card definitions +- Editing card instance `.json` +- Asking for Boxel card patterns/components +- Working in a synced workspace (`.boxel-sync.json` present) + +## Core Command Semantics + +- `pull`: remote -> local +- `push`: local -> remote +- `sync`: bidirectional conflict resolution +- `track`: local file watching with auto-checkpoints (use `--push` for real-time server sync) +- `watch`: remote change watching (pulls server changes) +- `repair-realm`: repair one realm metadata + starter cards + optional Matrix reconciliation +- `repair-realms`: batch repair all owned realms and reconcile Matrix realm list + +After local edits tracked with `track`, push to server with: + +- `boxel sync . --prefer-local` +- Or use `boxel track . --push` for automatic real-time sync + +## Onboarding Flow (When Needed) + +If user has no profile configured: + +1. `npx boxel profile` +2. `npx boxel profile add` (interactive preferred) +3. `npx boxel list` +4. First sync/pull into local workspace + +If `boxel list` already works, treat onboarding as complete and move on to workspace discovery. + +Security note: + +- Prefer interactive password entry or `BOXEL_PASSWORD` env var. +- Avoid plain `-p` password usage in shell history. + +## Multi-Realm Guidance + +- Configure realms with `boxel realms --add ...` +- Use `boxel realms --llm` for file-placement guidance. +- This repo already includes `.boxel-workspaces.json` mapping `guidance-tasks` as the shared tracker realm and `software-factory-demo` as the default implementation realm. +- Heuristic: + - `.gts` -> code realm (`*.gts` pattern) + - instances -> realm mapped for card type + - ambiguous -> default realm + +## Boxel URL Handling + +Boxel app URLs usually reference private, authenticated content. + +- Do not fetch them from the public web. +- Parse card path from URL and locate local synced file instead. + Example: +- URL segment `Document/` maps to local `Document/.json` + +## Useful Workflows + +### Local dev loop (manual sync) + +1. `boxel track .` +2. edit files +3. `boxel sync . --prefer-local` + +### Local dev loop (real-time sync) + +1. `boxel track . --push` +2. edit files (changes auto-pushed via batch upload) + +### Monitor server changes + +1. `boxel watch .` +2. inspect checkpoints with `boxel history .` + +### Restore workflow + +1. stop watch +2. `boxel history . -r ` +3. `boxel sync . --prefer-local` + +### Software Factory Loop + +1. `boxel list` +2. sync the relevant realm locally +3. inspect existing task and knowledge cards +4. create or update tickets for the requested outcome +5. implement in the target realm +6. store or update project tests inside the target realm when they are part of the deliverable +7. test using CLI plus authenticated browser flows where useful +8. prefer a disposable scratch realm for fixture-driven browser verification +9. report issues back into tickets or knowledge cards +10. sync to the realm +11. checkpoint and sync +12. update knowledge cards with what was learned + +## Related References + +- `.claude/CLAUDE.md` +- `.agents/skills/boxel-development/SKILL.md` +- `.agents/skills/boxel-file-structure/SKILL.md` +- `.agents/skills/boxel-repair/SKILL.md` +- `.agents/skills/boxel-sync/SKILL.md` +- `.agents/skills/boxel-watch/SKILL.md` +- `.agents/skills/boxel-track/SKILL.md` +- `.agents/skills/boxel-restore/SKILL.md` +- `.agents/skills/boxel-setup/SKILL.md` +- `.agents/skills/software-factory-operations/SKILL.md` + +## Share & Gather (GitHub Workflow) + +Share workspace to GitHub repo, gather changes back: + +```bash +boxel share . -t /path/to/repo -b branch-name --no-pr +boxel gather . -s /path/to/repo +``` + +**URL Portability:** Share/gather automatically convert absolute realm URLs in `index.json` and `cards-grid.json` to relative paths, making content portable across different realms. diff --git a/packages/software-factory/experiment_1/package-lock.json b/packages/software-factory/experiment_1/package-lock.json new file mode 100644 index 0000000000..7169485128 --- /dev/null +++ b/packages/software-factory/experiment_1/package-lock.json @@ -0,0 +1,72 @@ +{ + "name": "software-factory", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "software-factory", + "devDependencies": { + "@playwright/test": "^1.57.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/packages/software-factory/experiment_1/package.json b/packages/software-factory/experiment_1/package.json new file mode 100644 index 0000000000..89a61b82a9 --- /dev/null +++ b/packages/software-factory/experiment_1/package.json @@ -0,0 +1,15 @@ +{ + "name": "software-factory", + "private": true, + "type": "module", + "scripts": { + "boxel:session": "node scripts/boxel-session.mjs", + "boxel:search": "node scripts/boxel-search.mjs", + "boxel:pick-ticket": "node scripts/pick-ticket.mjs", + "test:ticket-flow": "playwright test tests/ticket-flow.spec.mjs", + "test:realm": "node scripts/run-realm-tests.mjs" + }, + "devDependencies": { + "@playwright/test": "^1.57.0" + } +} diff --git a/packages/software-factory/experiment_1/playwright.realm.config.mjs b/packages/software-factory/experiment_1/playwright.realm.config.mjs new file mode 100644 index 0000000000..cb560e09e9 --- /dev/null +++ b/packages/software-factory/experiment_1/playwright.realm.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: ['**/*.spec.mjs'], + testIgnore: ['**/.boxel-history/**', '**/node_modules/**'], +}); diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/AgentProfile/adfdde24-d52d-4324-8f87-b46f42b2ea76.json b/packages/software-factory/experiment_1/realms/guidance-tasks/AgentProfile/adfdde24-d52d-4324-8f87-b46f42b2ea76.json new file mode 100644 index 0000000000..bfb329849b --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/AgentProfile/adfdde24-d52d-4324-8f87-b46f42b2ea76.json @@ -0,0 +1,24 @@ +{ + "data": { + "type": "card", + "attributes": { + "agentId": null, + "capabilities": [], + "specialization": null, + "notes": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../darkfactory", + "name": "AgentProfile" + }, + "_touched": 1773327307252 + } + } +} diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/KnowledgeArticle/agent-onboarding.json b/packages/software-factory/experiment_1/realms/guidance-tasks/KnowledgeArticle/agent-onboarding.json new file mode 100644 index 0000000000..570c7ab327 --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/KnowledgeArticle/agent-onboarding.json @@ -0,0 +1,37 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardInfo": { + "title": "Agent Onboarding Guide", + "description": "How to get started as an agent in the Dark Factory", + "thumbnailURL": null, + "notes": null + }, + "articleTitle": "Agent Onboarding Guide", + "articleType": "onboarding", + "tags": [ + "onboarding", + "agents", + "getting-started", + "workflow" + ], + "updatedAt": "2026-03-12T10:00:00Z", + "content": "# Agent Onboarding Guide\n\nWelcome to the Dark Factory. This guide explains how to operate effectively as an agent in this system.\n\n## Your Tools\n\nYou have access to the Boxel AI assistant with the following capabilities:\n- Read and write card data (Tickets, Knowledge Articles, Projects)\n- Navigate between linked cards\n- Search for cards by type and content\n- Update ticket status, notes, and links\n\n## Starting a Session\n\n1. **Find your assigned ticket** - Search for Ticket cards assigned to you, or check the Project dashboard\n2. **Read the full ticket** - Description, acceptance criteria, and agent notes from previous agents\n3. **Review linked knowledge** - Check the Related tab for knowledge articles\n4. **Check the project scope** - Open the Project card and read the Scope tab\n\n## Working a Ticket\n\n```\nBacklog → In Progress → (Review) → Done\n ↓\n Blocked (if stuck)\n```\n\n1. Set status to `in_progress` when you start\n2. Update `agentNotes` with your findings and decisions AS YOU WORK\n3. Link any `relatedTickets` you discover\n4. Link relevant `relatedKnowledge` articles\n5. If blocked, set status to `blocked` and document the blocker in agent notes\n6. Set status to `done` when acceptance criteria are met\n\n## Building Knowledge\n\nWhen you discover something important:\n- Create a `KnowledgeArticle` card\n- Choose the appropriate type (architecture, decision, runbook, context, api, onboarding)\n- Add relevant tags for discoverability\n- Link it to relevant tickets\n\n## Key Principles\n\n- **Leave the system better than you found it** - document everything\n- **Agent notes are for agents** - write as if briefing your replacement\n- **Links are navigation** - use them liberally to connect related work\n- **Status accuracy matters** - humans watch the dashboard" + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": "https://app.boxel.ai/catalog/Theme/cardstack" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../darkfactory", + "name": "KnowledgeArticle" + }, + "_touched": 1773327309668 + } + } +} diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/Project/02ecd21b-fd62-4e75-83d7-97da3af78b21.json b/packages/software-factory/experiment_1/realms/guidance-tasks/Project/02ecd21b-fd62-4e75-83d7-97da3af78b21.json new file mode 100644 index 0000000000..ff5e92c584 --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/Project/02ecd21b-fd62-4e75-83d7-97da3af78b21.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "projectCode": null, + "projectName": null, + "projectStatus": null, + "deadline": null, + "objective": null, + "scope": null, + "technicalContext": null, + "successCriteria": null, + "risks": null, + "createdAt": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../darkfactory-schema", + "name": "Project" + }, + "_touched": 1773327314450 + } + } +} diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/Project/demo-project.json b/packages/software-factory/experiment_1/realms/guidance-tasks/Project/demo-project.json new file mode 100644 index 0000000000..fa52643679 --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/Project/demo-project.json @@ -0,0 +1,47 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "Project", + "module": "../darkfactory" + }, + "_touched": 1773327316874 + }, + "type": "card", + "attributes": { + "risks": "- **Demo timeline:** Very tight - scope must be controlled\n- **Agent context loss:** Mitigate with rich knowledge base\n- **Scope creep:** New features deferred to Phase 2", + "scope": "## Scope\n\n### In Scope\n- Agent task assignment and tracking\n- Project knowledge base management\n- Ticket lifecycle management (backlog → done)\n- Agent onboarding via knowledge articles\n- Human-readable dashboard\n\n### Out of Scope\n- External CI/CD integration (Phase 2)\n- Agent spawning/orchestration engine (Phase 2)\n- Real-time agent communication (Phase 2)\n\n### Constraints\n- Must be demonstrable by demo deadline\n- All data must be structured for agent consumption\n- Knowledge must be transferable between agent sessions", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "deadline": "2026-03-20", + "createdAt": "2026-03-12T10:00:00.000Z", + "objective": "Build and demonstrate a fully operational agent-driven software factory platform capable of autonomous project management, ticket resolution, and knowledge accumulation.", + "projectCode": "DFX-001", + "projectName": "Dark Factory Platform", + "projectStatus": "active", + "successCriteria": "- [ ] Agent can be assigned a ticket and complete it autonomously\n- [ ] Knowledge articles accessible and useful to new agents\n- [ ] Human can see project health at a glance on dashboard\n- [ ] Tickets can navigate to related knowledge and tickets\n- [ ] Project scope is clear and discoverable", + "technicalContext": "## Technical Context\n\n### Stack\n- **Platform:** Boxel (card-based data system)\n- **Agent Interface:** AI assistant with tool access\n- **Storage:** Boxel realm (JSON:API)\n\n### Key Concepts\n- **Tickets** track discrete work items with status, priority, and agent assignment\n- **Knowledge Articles** store durable context for incoming agents\n- **Agent Notes** on tickets preserve decision trail\n- **Linked entities** allow navigation between related work\n\n### Agent Workflow\n1. Agent opens assigned ticket\n2. Agent reads description + acceptance criteria\n3. Agent reviews linked knowledge articles\n4. Agent documents findings in Agent Notes\n5. Agent links related tickets\n6. Agent updates status and marks done" + }, + "relationships": { + "teamAgents": { + "links": { + "self": null + } + }, + "knowledgeBase.0": { + "links": { + "self": "../KnowledgeArticle/agent-onboarding" + } + }, + "cardInfo.theme": { + "links": { + "self": "https://app.boxel.ai/catalog/Theme/cardstack" + } + } + } + } +} diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/Ticket/ticket-001.json b/packages/software-factory/experiment_1/realms/guidance-tasks/Ticket/ticket-001.json new file mode 100644 index 0000000000..848564a075 --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/Ticket/ticket-001.json @@ -0,0 +1,59 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "Ticket", + "module": "../darkfactory" + }, + "_touched": 1773327322050 + }, + "type": "card", + "attributes": { + "status": "done", + "summary": "Build core card schema for dark factory task tracking", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "priority": "critical", + "ticketId": "DFX-001", + "createdAt": "2026-03-12T10:00:00.000Z", + "updatedAt": "2026-03-12T10:30:00.000Z", + "agentNotes": "## Agent Notes\n\n**Status:** COMPLETED\n\nImplemented full schema in `darkfactory.gts`. Key decisions:\n- Used `linksToMany` for all cross-card relationships\n- Enum fields for status, priority, type\n- Tabbed UI pattern for ticket isolation format\n- Dashboard with status grid and progress bar on Project isolated view\n- `concat` helper used for dynamic inline styles", + "ticketType": "feature", + "actualHours": 3, + "description": "## Description\n\nDesign and implement the foundational CardDef schema for the Dark Factory platform. This includes:\n\n- `AgentProfile` - Agent identity and capabilities\n- `KnowledgeArticle` - Project knowledge base\n- `Ticket` - Work item tracking\n- `Project` - Top-level project with dashboard\n- `DarkFactory` - Workspace index card\n\n## Notes\nAll cards must support isolated, embedded, and fitted formats for full Boxel compatibility.", + "estimatedHours": 4, + "acceptanceCriteria": "- [x] All card definitions compile without errors\n- [x] Each card has isolated, embedded, and fitted formats\n- [x] Tickets can link to related tickets and knowledge articles\n- [x] Project dashboard shows live ticket status counts\n- [x] Dark factory theme applied consistently" + }, + "relationships": { + "assignedAgent": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": "https://app.boxel.ai/catalog/Theme/cardstack" + } + }, + "project": { + "links": { + "self": "../Project/demo-project" + } + }, + "relatedTickets": { + "links": { + "self": null + } + }, + "relatedKnowledge": { + "links": { + "self": null + } + } + } + } +} diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-schema.gts b/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-schema.gts new file mode 100644 index 0000000000..0dc242f2bf --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-schema.gts @@ -0,0 +1,183 @@ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +// ¹ Schema-only file: field definitions, enums, card schemas — no UI templates + +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'; + +// ² Enum field definitions +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' }, + ], +}); + +// ³ AgentProfile — schema only +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.agentId ?? 'Unnamed Agent'; + }, + }); +} + +// ⁴ KnowledgeArticle — schema only +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'; + }, + }); +} + +// ⁵ Ticket — schema only +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'; + }, + }); +} + +// ⁶ Project — schema only +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-schema', 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'; + }, + }); +} + +// ⁷ DarkFactory — schema only +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'; + }, + }); +} +// touched for re-index diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-ui.gts b/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-ui.gts new file mode 100644 index 0000000000..5772075a41 --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory-ui.gts @@ -0,0 +1,1833 @@ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +// ¹ UI file: all Component templates — imports schemas, adds fitted/embedded/isolated views + +import { Component } from 'https://cardstack.com/base/card-api'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn, concat } from '@ember/helper'; +import { eq, gt, and, not } from '@cardstack/boxel-ui/helpers'; +import { formatDateTime } from '@cardstack/boxel-ui/helpers'; + +import { + AgentProfile, + KnowledgeArticle, + Ticket, + Project as ProjectSchema, + DarkFactory, +} from './darkfactory-schema'; + +// ² AgentProfile UI +AgentProfile.fitted = class Fitted extends Component { + +}; + +AgentProfile.embedded = class Embedded extends Component { + +}; + +AgentProfile.isolated = class Isolated extends Component { + +}; + +// ³ KnowledgeArticle UI +KnowledgeArticle.fitted = class Fitted extends Component< + typeof KnowledgeArticle +> { + get typeLabel() { + const map: Record = { + architecture: 'ARCH', + decision: 'ADR', + runbook: 'RUN', + context: 'CTX', + api: 'API', + onboarding: 'OBD', + }; + return map[this.args.model?.articleType ?? ''] ?? 'DOC'; + } + +}; + +KnowledgeArticle.embedded = class Embedded extends Component< + typeof KnowledgeArticle +> { + get typeLabel() { + const map: Record = { + architecture: 'Architecture', + decision: 'ADR', + runbook: 'Runbook', + context: 'Context', + api: 'API Ref', + onboarding: 'Onboarding', + }; + return map[this.args.model?.articleType ?? ''] ?? 'Document'; + } + +}; + +KnowledgeArticle.isolated = class Isolated extends Component< + typeof KnowledgeArticle +> { + +}; + +// ⁴ Ticket UI +Ticket.fitted = class Fitted extends Component { + get statusAccent() { + const map: Record = { + backlog: '#6b7280', + in_progress: '#3b82f6', + blocked: '#ef4444', + review: '#f59e0b', + done: '#22c55e', + }; + return map[this.args.model?.status ?? ''] ?? '#6b7280'; + } + get statusEmoji() { + const m: Record = { + backlog: '📋', + in_progress: '⚙️', + blocked: '🔴', + review: '👁️', + done: '✅', + }; + return m[this.args.model?.status ?? ''] ?? '📋'; + } + get priorityDot() { + const m: Record = { + critical: '#ff4444', + high: '#ff8800', + medium: '#ffcc00', + low: '#44ff44', + }; + return m[this.args.model?.priority ?? ''] ?? '#888888'; + } + +}; + +Ticket.embedded = class Embedded extends Component { + get statusEmoji() { + const m: Record = { + backlog: '📋', + in_progress: '⚙️', + blocked: '🔴', + review: '👁️', + done: '✅', + }; + return m[this.args.model?.status ?? ''] ?? '📋'; + } + get priorityDot() { + const m: Record = { + critical: '#ff4444', + high: '#ff8800', + medium: '#ffcc00', + low: '#44ff44', + }; + return m[this.args.model?.priority ?? ''] ?? '#888888'; + } + +}; + +Ticket.isolated = class Isolated extends Component { + @tracked activeTab: 'overview' | 'context' | 'related' = 'overview'; + setTab = (tab: 'overview' | 'context' | 'related') => { + this.activeTab = tab; + }; + get statusEmoji() { + const m: Record = { + backlog: '📋', + in_progress: '⚙️', + blocked: '🔴', + review: '👁️', + done: '✅', + }; + return m[this.args.model?.status ?? ''] ?? '📋'; + } + get priorityColor() { + const m: Record = { + critical: '#ff4444', + high: '#ff8800', + medium: '#ffcc00', + low: '#44ff44', + }; + return m[this.args.model?.priority ?? ''] ?? '#888888'; + } + +}; + +export class Project extends ProjectSchema { + +static fitted = class Fitted extends Component { + get statusAccent() { + const m: Record = { + planning: '#f59e0b', + active: '#22c55e', + on_hold: '#6b7280', + completed: '#3b82f6', + archived: '#9ca3af', + }; + return m[this.args.model?.projectStatus ?? ''] ?? '#6b7280'; + } + get statusEmoji() { + const m: Record = { + planning: '🗺️', + active: '⚡', + on_hold: '⏸️', + completed: '🎯', + archived: '📦', + }; + return m[this.args.model?.projectStatus ?? ''] ?? '📁'; + } + get daysUntilDeadline() { + try { + if (!this.args.model?.deadline) return null; + const days = Math.ceil( + (new Date(this.args.model.deadline).getTime() - Date.now()) / 86400000, + ); + return days; + } catch { + return null; + } + } + +}; + +static embedded = class Embedded extends Component { + get statusEmoji() { + const m: Record = { + planning: '🗺️', + active: '⚡', + on_hold: '⏸️', + completed: '🎯', + archived: '📦', + }; + return m[this.args.model?.projectStatus ?? ''] ?? '📁'; + } + +}; + + + static isolated = class Isolated extends Component { + @tracked activeTab: 'dashboard' | 'tickets' | 'knowledge' | 'scope' | 'team' = + 'dashboard'; + setTab = (tab: typeof this.activeTab) => { + this.activeTab = tab; + }; + get statusEmoji() { + const m: Record = { + planning: '🗺️', + active: '⚡', + on_hold: '⏸️', + completed: '🎯', + archived: '📦', + }; + return m[this.args.model?.projectStatus ?? ''] ?? '📁'; + } + get daysUntilDeadline() { + try { + if (!this.args.model?.deadline) return null; + return Math.ceil( + (new Date(this.args.model.deadline).getTime() - Date.now()) / 86400000, + ); + } catch { + return null; + } + } + get deadlineColor() { + const d = this.daysUntilDeadline; + if (d === null) return 'var(--muted-foreground)'; + if (d < 0) return '#ff4444'; + if (d <= 3) return '#ff8800'; + if (d <= 7) return '#ffcc00'; + return '#44ff44'; + } + get ticketsByStatus() { + try { + const tickets = this.args.model?.tickets ?? []; + const groups: Record = { + backlog: 0, + in_progress: 0, + blocked: 0, + review: 0, + done: 0, + }; + for (const t of tickets) { + const s = t.status ?? 'backlog'; + if (s in groups) groups[s]++; + } + return groups; + } catch { + return { backlog: 0, in_progress: 0, blocked: 0, review: 0, done: 0 }; + } + } + get totalTickets() { + return this.args.model?.tickets?.length ?? 0; + } + get completionPercent() { + const total = this.totalTickets; + return total === 0 + ? 0 + : Math.round((this.ticketsByStatus.done / total) * 100); + } + get progressBarWidth() { + return `${this.completionPercent}%`; + } + + +}; +} +// ⁶ DarkFactory UI +DarkFactory.fitted = class Fitted extends Component { + +}; + +DarkFactory.embedded = class Embedded extends Component { + +}; + +DarkFactory.isolated = class Isolated extends Component { + +}; + +export { AgentProfile, KnowledgeArticle, Ticket, DarkFactory }; +// touched for re-index diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory.gts b/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory.gts new file mode 100644 index 0000000000..4e1e1e0425 --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/darkfactory.gts @@ -0,0 +1,4 @@ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +// ¹ Barrel re-export — instances reference this module; schema + UI live in separate files +export { AgentProfile, KnowledgeArticle, Ticket, Project, DarkFactory } from './darkfactory-ui'; // ² re-export all card types (with templates) from UI file +// touched for re-index diff --git a/packages/software-factory/experiment_1/realms/guidance-tasks/index.json b/packages/software-factory/experiment_1/realms/guidance-tasks/index.json new file mode 100644 index 0000000000..7361bc9c4b --- /dev/null +++ b/packages/software-factory/experiment_1/realms/guidance-tasks/index.json @@ -0,0 +1,12 @@ +{ + "data": { + "type": "card", + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/cards-grid", + "name": "CardsGrid" + }, + "_touched": 1773327352703 + } + } +} diff --git a/packages/software-factory/experiment_1/scripts/boxel-search.mjs b/packages/software-factory/experiment_1/scripts/boxel-search.mjs new file mode 100644 index 0000000000..368042c164 --- /dev/null +++ b/packages/software-factory/experiment_1/scripts/boxel-search.mjs @@ -0,0 +1,71 @@ +import { + fieldPairs, + forceArray, + getAccessibleRealmTokens, + matrixLogin, + parseArgs, + printJson, + searchRealm, +} from './lib/boxel.mjs'; + +let args = parseArgs(process.argv.slice(2)); +if (!args.realm) { + throw new Error('Usage: npm run boxel:search -- --realm [--type-name Ticket --type-module ] [--eq field=value] [--contains field=value]'); +} + +let matrixAuth = await matrixLogin(); +let realmTokens = await getAccessibleRealmTokens(matrixAuth); +let realmUrl = Array.isArray(args.realm) ? args.realm[0] : args.realm; +let jwt = realmTokens[realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`]; + +let query = {}; +let filter = {}; + +if (args['type-name'] && args['type-module']) { + filter.type = { + module: args['type-module'], + name: args['type-name'], + }; +} + +let eq = fieldPairs(args.eq); +if (Object.keys(eq).length > 0) { + filter.eq = eq; +} + +let contains = fieldPairs(args.contains); +if (Object.keys(contains).length > 0) { + filter.contains = contains; +} + +if (Object.keys(filter).length > 0) { + query.filter = filter; +} + +let sortValues = forceArray(args.sort); +if (sortValues.length > 0) { + query.sort = sortValues.map((entry) => { + let [by, direction = 'asc'] = entry.split(':'); + let sort = { by, direction }; + if (args['type-name'] && args['type-module']) { + sort.on = { + module: args['type-module'], + name: args['type-name'], + }; + } + return sort; + }); +} + +if (args.size || args.page) { + query.page = {}; + if (args.size) { + query.page.size = Number(args.size); + } + if (args.page) { + query.page.number = Number(args.page); + } +} + +let results = await searchRealm({ realmUrl, jwt, query }); +printJson(results); diff --git a/packages/software-factory/experiment_1/scripts/boxel-session.mjs b/packages/software-factory/experiment_1/scripts/boxel-session.mjs new file mode 100644 index 0000000000..4022379ca5 --- /dev/null +++ b/packages/software-factory/experiment_1/scripts/boxel-session.mjs @@ -0,0 +1,21 @@ +import { + buildBrowserAuth, + buildBrowserSession, + getAccessibleRealmTokens, + matrixLogin, + parseArgs, + printJson, +} from './lib/boxel.mjs'; + +let args = parseArgs(process.argv.slice(2)); +let matrixAuth = await matrixLogin(); +let realmTokens = await getAccessibleRealmTokens(matrixAuth); +let requestedRealms = args.realm ? (Array.isArray(args.realm) ? args.realm : [args.realm]) : []; +let session = buildBrowserSession(realmTokens, requestedRealms); + +printJson({ + profileId: matrixAuth.credentials.profileId, + username: matrixAuth.credentials.username, + auth: buildBrowserAuth(matrixAuth), + boxelSession: session, +}); diff --git a/packages/software-factory/experiment_1/scripts/lib/boxel.mjs b/packages/software-factory/experiment_1/scripts/lib/boxel.mjs new file mode 100644 index 0000000000..4abf831a5d --- /dev/null +++ b/packages/software-factory/experiment_1/scripts/lib/boxel.mjs @@ -0,0 +1,242 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const PROFILES_FILE = path.join(os.homedir(), '.boxel-cli', 'profiles.json'); + +function ensureTrailingSlash(url) { + return url.endsWith('/') ? url : `${url}/`; +} + +function parseProfilesConfig() { + if (!fs.existsSync(PROFILES_FILE)) { + return { profiles: {}, activeProfile: null }; + } + + return JSON.parse(fs.readFileSync(PROFILES_FILE, 'utf8')); +} + +export function getActiveProfile() { + let config = parseProfilesConfig(); + let activeProfileId = config.activeProfile; + if (activeProfileId && config.profiles[activeProfileId]) { + let profile = config.profiles[activeProfileId]; + return { + profileId: activeProfileId, + username: activeProfileId.replace(/^@/, '').replace(/:.*$/, ''), + matrixUrl: profile.matrixUrl, + realmServerUrl: ensureTrailingSlash(profile.realmServerUrl), + password: profile.password, + }; + } + + let matrixUrl = process.env.MATRIX_URL; + let username = process.env.MATRIX_USERNAME; + let password = process.env.MATRIX_PASSWORD; + let realmServerUrl = process.env.REALM_SERVER_URL; + if (!matrixUrl || !username || !password || !realmServerUrl) { + throw new Error( + 'No active Boxel profile found and MATRIX_URL/MATRIX_USERNAME/MATRIX_PASSWORD/REALM_SERVER_URL are not fully set', + ); + } + + return { + profileId: null, + username, + matrixUrl, + realmServerUrl: ensureTrailingSlash(realmServerUrl), + password, + }; +} + +export async function matrixLogin(credentials = getActiveProfile()) { + let response = await fetch(new URL('_matrix/client/v3/login', credentials.matrixUrl), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: { + type: 'm.id.user', + user: credentials.username, + }, + password: credentials.password, + type: 'm.login.password', + }), + }); + + let json = await response.json(); + if (!response.ok) { + throw new Error(`Matrix login failed: ${response.status} ${JSON.stringify(json)}`); + } + + return { + accessToken: json.access_token, + deviceId: json.device_id, + userId: json.user_id, + homeServer: new URL(credentials.matrixUrl).host, + credentials, + }; +} + +export async function getOpenIdToken(matrixAuth) { + let response = await fetch( + new URL( + `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/openid/request_token`, + matrixAuth.credentials.matrixUrl, + ), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: '{}', + }, + ); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`OpenID token request failed: ${response.status} ${text}`); + } + + return response.json(); +} + +export async function getRealmServerToken(matrixAuth) { + let openIdToken = await getOpenIdToken(matrixAuth); + let response = await fetch(new URL('_server-session', matrixAuth.credentials.realmServerUrl), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(openIdToken), + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Realm server session request failed: ${response.status} ${text}`); + } + + let token = response.headers.get('Authorization'); + if (!token) { + throw new Error('Realm server session response did not include an Authorization header'); + } + return token; +} + +export async function getAccessibleRealmTokens(matrixAuth) { + let serverToken = await getRealmServerToken(matrixAuth); + let response = await fetch(new URL('_realm-auth', matrixAuth.credentials.realmServerUrl), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: serverToken, + }, + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Realm auth lookup failed: ${response.status} ${text}`); + } + + return response.json(); +} + +export function buildBrowserAuth(matrixAuth) { + return { + access_token: matrixAuth.accessToken, + user_id: matrixAuth.userId, + device_id: matrixAuth.deviceId, + home_server: matrixAuth.homeServer, + }; +} + +export function buildBrowserSession(realmTokens, realmUrls) { + if (!realmUrls || realmUrls.length === 0) { + return realmTokens; + } + + let result = {}; + for (let realmUrl of realmUrls) { + let normalized = ensureTrailingSlash(realmUrl); + if (realmTokens[normalized]) { + result[normalized] = realmTokens[normalized]; + } + } + return result; +} + +export async function searchRealm({ realmUrl, jwt, query }) { + let response = await fetch(new URL('./_search', ensureTrailingSlash(realmUrl)), { + method: 'QUERY', + headers: { + Accept: 'application/vnd.card+json', + 'Content-Type': 'application/json', + ...(jwt ? { Authorization: jwt } : {}), + }, + body: JSON.stringify(query), + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Search failed: ${response.status} ${text}`); + } + + return response.json(); +} + +export function parseArgs(argv) { + let args = { _: [] }; + + for (let i = 0; i < argv.length; i++) { + let token = argv[i]; + if (!token.startsWith('--')) { + args._.push(token); + continue; + } + + let key = token.slice(2); + let next = argv[i + 1]; + if (!next || next.startsWith('--')) { + args[key] = true; + continue; + } + + if (args[key] === undefined) { + args[key] = next; + } else if (Array.isArray(args[key])) { + args[key].push(next); + } else { + args[key] = [args[key], next]; + } + i++; + } + + return args; +} + +export function forceArray(value) { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +export function fieldPairs(values) { + let result = {}; + for (let entry of forceArray(values)) { + let index = entry.indexOf('='); + if (index === -1) { + throw new Error(`Expected field pair in the form field=value, received: ${entry}`); + } + result[entry.slice(0, index)] = entry.slice(index + 1); + } + return result; +} + +export function printJson(value) { + console.log(JSON.stringify(value, null, 2)); +} diff --git a/packages/software-factory/experiment_1/scripts/pick-ticket.mjs b/packages/software-factory/experiment_1/scripts/pick-ticket.mjs new file mode 100644 index 0000000000..708312ffd4 --- /dev/null +++ b/packages/software-factory/experiment_1/scripts/pick-ticket.mjs @@ -0,0 +1,82 @@ +import { + getAccessibleRealmTokens, + matrixLogin, + parseArgs, + printJson, + searchRealm, +} from './lib/boxel.mjs'; + +let args = parseArgs(process.argv.slice(2)); +let realmUrl = args.realm; +if (!realmUrl) { + throw new Error('Usage: npm run boxel:pick-ticket -- --realm [--module ]'); +} + +let statusList = (args.status ? String(args.status) : 'backlog,in_progress,review') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +let moduleUrl = args.module ?? `${realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`}darkfactory-schema`; + +let matrixAuth = await matrixLogin(); +let realmTokens = await getAccessibleRealmTokens(matrixAuth); +let jwt = realmTokens[realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`]; + +let query = { + filter: { + type: { + module: moduleUrl, + name: 'Ticket', + }, + any: statusList.map((status) => ({ + eq: { status }, + })), + }, + sort: [ + { + by: 'priority', + direction: 'asc', + on: { + module: moduleUrl, + name: 'Ticket', + }, + }, + { + by: 'updatedAt', + direction: 'asc', + on: { + module: moduleUrl, + name: 'Ticket', + }, + }, + ], +}; + +if (args.project) { + query.filter.eq = { + ...(query.filter.eq ?? {}), + 'project.id': args.project, + }; +} + +if (args.agent) { + query.filter.eq = { + ...(query.filter.eq ?? {}), + 'assignedAgent.id': args.agent, + }; +} + +let results = await searchRealm({ realmUrl, jwt, query }); +let compact = (results.data ?? []).map((card) => ({ + id: card.id, + ticketId: card.attributes?.ticketId, + summary: card.attributes?.summary, + status: card.attributes?.status, + priority: card.attributes?.priority, + project: card.relationships?.project?.links?.self ?? null, +})); + +printJson({ + count: compact.length, + tickets: compact, +}); diff --git a/packages/software-factory/experiment_1/scripts/run-realm-tests.mjs b/packages/software-factory/experiment_1/scripts/run-realm-tests.mjs new file mode 100644 index 0000000000..407fb1006b --- /dev/null +++ b/packages/software-factory/experiment_1/scripts/run-realm-tests.mjs @@ -0,0 +1,204 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { getActiveProfile, parseArgs } from './lib/boxel.mjs'; + +function ensureTrailingSlash(url) { + return url.endsWith('/') ? url : `${url}/`; +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'z').toLowerCase(); +} + +function runCommand(command, args, options = {}) { + let result = spawnSync(command, args, { + cwd: options.cwd ?? process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...(options.env ?? {}) }, + }); + + if (result.status !== 0) { + let details = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + throw new Error(`Command failed: ${command} ${args.join(' ')}\n${details}`); + } + + return result.stdout; +} + +function readWorkspaceUrl(realmPath) { + let syncFile = path.join(realmPath, '.boxel-sync.json'); + if (!fs.existsSync(syncFile)) { + throw new Error(`Expected synced realm at ${realmPath}; missing .boxel-sync.json`); + } + + let { workspaceUrl } = JSON.parse(fs.readFileSync(syncFile, 'utf8')); + if (!workspaceUrl) { + throw new Error(`No workspaceUrl found in ${syncFile}`); + } + return ensureTrailingSlash(workspaceUrl); +} + +function walkFiles(rootDir) { + let results = []; + + function visit(currentDir) { + for (let entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + let fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + } + + if (fs.existsSync(rootDir)) { + visit(rootDir); + } + + return results; +} + +function findSpecFiles(specRoot) { + return walkFiles(specRoot) + .filter((filePath) => filePath.endsWith('.spec.mjs')) + .sort(); +} + +function copyTreeContents(sourceDir, destinationDir) { + if (!fs.existsSync(sourceDir)) { + return []; + } + + let copied = []; + for (let sourceFile of walkFiles(sourceDir)) { + let relativePath = path.relative(sourceDir, sourceFile); + let destinationFile = path.join(destinationDir, relativePath); + fs.mkdirSync(path.dirname(destinationFile), { recursive: true }); + fs.copyFileSync(sourceFile, destinationFile); + copied.push(relativePath); + } + + return copied.sort(); +} + +function summarizeFailures(report) { + let failures = []; + + function visitSuite(suite, titlePath = []) { + let nextTitlePath = suite.title ? [...titlePath, suite.title] : titlePath; + for (let spec of suite.specs ?? []) { + let specPath = spec.title ? [...nextTitlePath, spec.title] : nextTitlePath; + for (let test of spec.tests ?? []) { + let results = test.results ?? []; + let failedResults = results.filter((result) => result.status !== 'passed' && result.status !== 'skipped'); + if (failedResults.length === 0) { + continue; + } + let errorText = failedResults + .flatMap((result) => result.errors ?? []) + .map((error) => error.message ?? error.value ?? '') + .filter(Boolean) + .join('\n'); + failures.push({ + title: specPath.join(' > '), + outcome: test.outcome ?? failedResults[0]?.status ?? 'failed', + error: errorText || 'No error text captured', + }); + } + } + for (let child of suite.suites ?? []) { + visitSuite(child, nextTitlePath); + } + } + + for (let suite of report.suites ?? []) { + visitSuite(suite); + } + + return failures; +} + +let args = parseArgs(process.argv.slice(2)); +let sourceRealmPath = path.resolve(args['realm-path'] ?? args._[0] ?? 'realms/software-factory-demo'); +let sourceRealmUrl = ensureTrailingSlash(args['realm-url'] ?? readWorkspaceUrl(sourceRealmPath)); +let specRoot = path.resolve(sourceRealmPath, args['spec-dir'] ?? 'tests'); +let fixturesRoot = path.resolve(sourceRealmPath, args['fixtures-dir'] ?? 'tests/fixtures'); +let sourceRealmName = path.basename(sourceRealmPath); +let endpoint = args.endpoint ?? `${sourceRealmName}-test-${timestampSlug()}`; +let credentials = getActiveProfile(); +let scratchRoot = path.resolve( + args['scratch-root'] ?? + path.join('realms', new URL(credentials.realmServerUrl).hostname, credentials.username), +); +let scratchPath = path.join(scratchRoot, endpoint); + +if (fs.existsSync(scratchPath)) { + throw new Error(`Scratch realm path already exists: ${scratchPath}`); +} + +let specFiles = findSpecFiles(specRoot); +if (specFiles.length === 0) { + throw new Error(`No realm-hosted spec files were found under ${specRoot}`); +} + +let scratchRealmUrl = ensureTrailingSlash( + args['scratch-url'] ?? new URL(`${credentials.username}/${endpoint}/`, credentials.realmServerUrl).href, +); +let scratchName = args.name ?? `${sourceRealmName} Test ${new Date().toISOString()}`; + +fs.mkdirSync(scratchRoot, { recursive: true }); + +runCommand('boxel', ['create', endpoint, scratchName]); +runCommand('boxel', ['pull', scratchRealmUrl, scratchPath]); + +let copiedFixtures = copyTreeContents(fixturesRoot, scratchPath); +runCommand('boxel', ['sync', scratchPath, scratchRealmUrl, '--prefer-local']); + +let reportFile = path.join(os.tmpdir(), `${endpoint}-playwright-report.json`); +let playwrightConfig = path.resolve(process.cwd(), 'playwright.realm.config.mjs'); +let playwrightEnv = { + BOXEL_SOURCE_REALM_PATH: sourceRealmPath, + BOXEL_SOURCE_REALM_URL: sourceRealmUrl, + BOXEL_TEST_REALM_PATH: scratchPath, + BOXEL_TEST_REALM_URL: scratchRealmUrl, + PLAYWRIGHT_JSON_OUTPUT_FILE: reportFile, +}; +let relativeSpecFiles = specFiles.map((filePath) => path.relative(sourceRealmPath, filePath)); + +let testRun = spawnSync( + 'npx', + ['playwright', 'test', '--config', playwrightConfig, '--reporter=line,json', ...relativeSpecFiles], + { + cwd: sourceRealmPath, + encoding: 'utf8', + env: { ...process.env, ...playwrightEnv }, + }, +); + +let report = fs.existsSync(reportFile) + ? JSON.parse(fs.readFileSync(reportFile, 'utf8')) + : { stats: {}, suites: [] }; +let failures = summarizeFailures(report); + +let summary = { + sourceRealmPath, + sourceRealmUrl, + scratchPath, + scratchRealmUrl, + specFiles: specFiles.map((filePath) => path.relative(process.cwd(), filePath)), + copiedFixtures, + expected: report.stats?.expected ?? 0, + unexpected: report.stats?.unexpected ?? failures.length, + skipped: report.stats?.skipped ?? 0, + failures, +}; + +console.log(JSON.stringify(summary, null, 2)); + +if (testRun.status !== 0) { + process.exit(testRun.status ?? 1); +} diff --git a/packages/software-factory/experiment_1/test-results/.last-run.json b/packages/software-factory/experiment_1/test-results/.last-run.json new file mode 100644 index 0000000000..cbcc1fbac1 --- /dev/null +++ b/packages/software-factory/experiment_1/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/packages/software-factory/experiment_1/tests/helpers/boxel-auth.mjs b/packages/software-factory/experiment_1/tests/helpers/boxel-auth.mjs new file mode 100644 index 0000000000..a3346abf42 --- /dev/null +++ b/packages/software-factory/experiment_1/tests/helpers/boxel-auth.mjs @@ -0,0 +1,24 @@ +import { + buildBrowserAuth, + buildBrowserSession, + getAccessibleRealmTokens, + matrixLogin, +} from '../../scripts/lib/boxel.mjs'; + +export async function boxelBrowserState(realmUrls) { + let matrixAuth = await matrixLogin(); + let realmTokens = await getAccessibleRealmTokens(matrixAuth); + + return { + auth: buildBrowserAuth(matrixAuth), + boxelSession: buildBrowserSession(realmTokens, realmUrls), + }; +} + +export async function seedBoxelLocalStorage(page, realmUrls) { + let state = await boxelBrowserState(realmUrls); + await page.addInitScript((payload) => { + window.localStorage.setItem('auth', JSON.stringify(payload.auth)); + window.localStorage.setItem('boxel-session', JSON.stringify(payload.boxelSession)); + }, state); +} diff --git a/packages/software-factory/experiment_1/tests/helpers/realm-test.mjs b/packages/software-factory/experiment_1/tests/helpers/realm-test.mjs new file mode 100644 index 0000000000..7dc448113e --- /dev/null +++ b/packages/software-factory/experiment_1/tests/helpers/realm-test.mjs @@ -0,0 +1,31 @@ +import { seedBoxelLocalStorage } from './boxel-auth.mjs'; + +function requiredEnv(name) { + let value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +export function getRealmTestConfig() { + return { + sourceRealmPath: requiredEnv('BOXEL_SOURCE_REALM_PATH'), + sourceRealmUrl: requiredEnv('BOXEL_SOURCE_REALM_URL'), + testRealmPath: requiredEnv('BOXEL_TEST_REALM_PATH'), + testRealmUrl: requiredEnv('BOXEL_TEST_REALM_URL'), + }; +} + +export async function seedRealmTestAuth(page, extraRealmUrls = []) { + let config = getRealmTestConfig(); + await seedBoxelLocalStorage(page, unique([config.sourceRealmUrl, config.testRealmUrl, ...extraRealmUrls])); +} + +export function realmCardUrl(cardPath, realmUrl = getRealmTestConfig().testRealmUrl) { + return new URL(cardPath, realmUrl).href; +} diff --git a/packages/software-factory/experiment_1/tests/ticket-flow.spec.mjs b/packages/software-factory/experiment_1/tests/ticket-flow.spec.mjs new file mode 100644 index 0000000000..c3fe9f1eee --- /dev/null +++ b/packages/software-factory/experiment_1/tests/ticket-flow.spec.mjs @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; +import { seedBoxelLocalStorage } from './helpers/boxel-auth.mjs'; + +const guidanceTasksRealm = 'http://localhost:4201/factory/guidance-tasks/'; +const demoProjectCard = `${guidanceTasksRealm}Project/demo-project`; +const softwareFactoryDemoRealm = 'http://localhost:4201/factory/software-factory-demo/'; +const deliveryBriefCard = `${softwareFactoryDemoRealm}DeliveryBrief/factory-flow-check`; + +test('authenticated browser can open a Boxel card directly in interact mode', async ({ page }) => { + await seedBoxelLocalStorage(page, [guidanceTasksRealm]); + await page.goto(demoProjectCard, { waitUntil: 'domcontentloaded' }); + + await expect(page.getByRole('heading', { name: 'Untitled Project' })).toBeVisible(); + await expect(page.getByText('Dark Factory Platform').first()).toBeVisible(); + await expect(page.getByText(/Tickets/i).first()).toBeVisible(); + await expect(page.getByText(/Knowledge Base/i).first()).toBeVisible(); +}); + +test('authenticated browser can open the demo realm DeliveryBrief card', async ({ page }) => { + await seedBoxelLocalStorage(page, [guidanceTasksRealm, softwareFactoryDemoRealm]); + await page.goto(deliveryBriefCard, { waitUntil: 'domcontentloaded' }); + + await expect(page.getByRole('heading', { name: 'Authenticated Delivery Flow' })).toBeVisible(); + await expect(page.getByText('Verified').first()).toBeVisible(); + await expect(page.getByText(/first end-to-end software factory pass/i)).toBeVisible(); +}); From 078a1f2de5f7f474d4d0aabe2f69aefbc1878e13 Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Fri, 13 Mar 2026 09:34:52 +0000 Subject: [PATCH 02/23] Add software factory realm test harness --- packages/realm-server/tests/helpers/index.ts | 4 +- packages/software-factory/.eslintignore | 3 + packages/software-factory/.gitignore | 5 + packages/software-factory/.prettierignore | 3 + packages/software-factory/README.md | 51 + .../software-factory/demo-realm/.realm.json | 5 + packages/software-factory/demo-realm/home.gts | 9 + .../software-factory/demo-realm/index.json | 12 + .../software-factory/demo-realm/person-1.json | 14 + .../software-factory/demo-realm/person.gts | 25 + packages/software-factory/package.json | 20 +- .../software-factory/playwright.config.ts | 21 + .../playwright.global-setup.ts | 70 ++ .../playwright.global-teardown.ts | 49 + .../software-factory/src/cli/cache-realm.ts | 26 + .../software-factory/src/cli/serve-realm.ts | 32 + .../software-factory/src/cli/smoke-realm.ts | 27 + packages/software-factory/src/harness.ts | 902 ++++++++++++++++++ packages/software-factory/src/index.ts | 10 +- .../software-factory/tests/demo-realm.spec.ts | 15 + packages/software-factory/tests/fixtures.ts | 29 + .../tests/helpers/browser-auth.ts | 205 ++++ packages/software-factory/tsconfig.json | 5 +- pnpm-lock.yaml | 40 +- 24 files changed, 1577 insertions(+), 5 deletions(-) create mode 100644 packages/software-factory/.eslintignore create mode 100644 packages/software-factory/.gitignore create mode 100644 packages/software-factory/.prettierignore create mode 100644 packages/software-factory/README.md create mode 100644 packages/software-factory/demo-realm/.realm.json create mode 100644 packages/software-factory/demo-realm/home.gts create mode 100644 packages/software-factory/demo-realm/index.json create mode 100644 packages/software-factory/demo-realm/person-1.json create mode 100644 packages/software-factory/demo-realm/person.gts create mode 100644 packages/software-factory/playwright.config.ts create mode 100644 packages/software-factory/playwright.global-setup.ts create mode 100644 packages/software-factory/playwright.global-teardown.ts create mode 100644 packages/software-factory/src/cli/cache-realm.ts create mode 100644 packages/software-factory/src/cli/serve-realm.ts create mode 100644 packages/software-factory/src/cli/smoke-realm.ts create mode 100644 packages/software-factory/src/harness.ts create mode 100644 packages/software-factory/tests/demo-realm.spec.ts create mode 100644 packages/software-factory/tests/fixtures.ts create mode 100644 packages/software-factory/tests/helpers/browser-auth.ts diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 8de43bd0f5..76b7b1665a 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -209,7 +209,9 @@ export const realmServerSecretSeed = "mum's the word"; export const realmSecretSeed = `shhh! it's a secret`; export const grafanaSecret = `shhh! it's a secret`; export const matrixRegistrationSecret: string = - getSynapseConfig()!.registration_shared_secret; // as long as synapse has been started at least once, this will always exist + getSynapseConfig()?.registration_shared_secret ?? + process.env.MATRIX_REGISTRATION_SHARED_SECRET ?? + 'software-factory-no-matrix'; export const testCreatePrerenderAuth = buildCreatePrerenderAuth(realmSecretSeed); diff --git a/packages/software-factory/.eslintignore b/packages/software-factory/.eslintignore new file mode 100644 index 0000000000..6ab7d7de98 --- /dev/null +++ b/packages/software-factory/.eslintignore @@ -0,0 +1,3 @@ +experiment_1/ +playwright-report/ +test-results/ diff --git a/packages/software-factory/.gitignore b/packages/software-factory/.gitignore new file mode 100644 index 0000000000..15c33b0d08 --- /dev/null +++ b/packages/software-factory/.gitignore @@ -0,0 +1,5 @@ +playwright-report/ +test-results/ +.software-factory-cache/ +.playwright-server.json +playwright-server.log diff --git a/packages/software-factory/.prettierignore b/packages/software-factory/.prettierignore new file mode 100644 index 0000000000..6ab7d7de98 --- /dev/null +++ b/packages/software-factory/.prettierignore @@ -0,0 +1,3 @@ +experiment_1/ +playwright-report/ +test-results/ diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md new file mode 100644 index 0000000000..5090fd909e --- /dev/null +++ b/packages/software-factory/README.md @@ -0,0 +1,51 @@ +# Software Factory + +Local card-development harness for fast Boxel iteration. + +This package gives you a cached local realm fixture, a realm server boot path +that mirrors the test harness, and a Playwright loop that exercises the card in +the real browser app shell. + +## Prerequisites + +- Docker running for the cached test Postgres on `127.0.0.1:55436` +- Host app assets available at `http://localhost:4200/` +- Base realm available at `http://localhost:4201/base/` +- Matrix available at `http://localhost:8008/` + +Those are the same local services the realm-server tests expect. + +## Commands + +- `pnpm cache:prepare` + - Builds or reuses the cached template database for `demo-realm/` +- `pnpm serve:realm` + - Starts the realm server on `http://127.0.0.1:4444/` +- `pnpm smoke:realm` + - Boots the realm server, fetches `person-1` as card JSON, and exits +- `pnpm test:playwright` + - Runs the browser test against the cached local realm + +All commands accept an optional realm directory argument: + +```bash +pnpm cache:prepare ./my-realm +pnpm serve:realm ./my-realm +pnpm smoke:realm ./my-realm Person/example-card +``` + +## Layout + +- `demo-realm/` + - Example card definitions and instances +- `src/harness.ts` + - Cached template DB creation and realm server startup +- `tests/` + - Playwright fixtures and browser specs + +## Notes + +- Template DBs are intentionally reused across runs while the realm-server + codebase stays stable. +- The browser test seeds a deterministic local Matrix user + (`software-factory-browser`) so it does not depend on a human-managed profile. diff --git a/packages/software-factory/demo-realm/.realm.json b/packages/software-factory/demo-realm/.realm.json new file mode 100644 index 0000000000..f4a1b5e27a --- /dev/null +++ b/packages/software-factory/demo-realm/.realm.json @@ -0,0 +1,5 @@ +{ + "name": "Software Factory Demo Realm", + "iconURL": null, + "backgroundURL": null +} diff --git a/packages/software-factory/demo-realm/home.gts b/packages/software-factory/demo-realm/home.gts new file mode 100644 index 0000000000..9bded29b86 --- /dev/null +++ b/packages/software-factory/demo-realm/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/demo-realm/index.json b/packages/software-factory/demo-realm/index.json new file mode 100644 index 0000000000..f20df53720 --- /dev/null +++ b/packages/software-factory/demo-realm/index.json @@ -0,0 +1,12 @@ +{ + "data": { + "type": "card", + "attributes": {}, + "meta": { + "adoptsFrom": { + "module": "./home.gts", + "name": "Home" + } + } + } +} diff --git a/packages/software-factory/demo-realm/person-1.json b/packages/software-factory/demo-realm/person-1.json new file mode 100644 index 0000000000..e380b1c0e1 --- /dev/null +++ b/packages/software-factory/demo-realm/person-1.json @@ -0,0 +1,14 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Mango" + }, + "meta": { + "adoptsFrom": { + "module": "./person.gts", + "name": "Person" + } + } + } +} diff --git a/packages/software-factory/demo-realm/person.gts b/packages/software-factory/demo-realm/person.gts new file mode 100644 index 0000000000..1ce05804f9 --- /dev/null +++ b/packages/software-factory/demo-realm/person.gts @@ -0,0 +1,25 @@ +import { + contains, + field, + Component, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class Person extends CardDef { + static displayName = 'Person'; + + @field firstName = contains(StringField); + + @field cardTitle = contains(StringField, { + computeVia: function (this: Person) { + return this.firstName; + }, + }); + + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/software-factory/package.json b/packages/software-factory/package.json index 9ce37547b4..8415fe40b8 100644 --- a/packages/software-factory/package.json +++ b/packages/software-factory/package.json @@ -1,22 +1,40 @@ { "name": "@cardstack/software-factory", "private": true, + "type": "module", "version": "1.0.0", "license": "MIT", "description": "Software Factory workspace package", "scripts": { + "cache:prepare": "ts-node --transpileOnly --esm src/cli/cache-realm.ts", "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"", "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\"", "lint:js": "eslint . --report-unused-disable-directives --cache", "lint:js:fix": "eslint . --report-unused-disable-directives --fix", "lint:format": "prettier --check .", "lint:format:fix": "prettier --write .", - "lint:types": "tsc --noEmit" + "lint:types": "tsc --noEmit src/index.ts", + "serve:realm": "ts-node --transpileOnly --esm src/cli/serve-realm.ts", + "smoke:realm": "ts-node --transpileOnly --esm src/cli/smoke-realm.ts", + "test:playwright": "playwright test", + "test:playwright:headed": "playwright test --headed" }, "devDependencies": { + "@playwright/test": "catalog:", + "@cardstack/postgres": "workspace:*", + "@cardstack/runtime-common": "workspace:*", "@cardstack/local-types": "workspace:*", + "@types/fs-extra": "catalog:", "@types/node": "catalog:", + "@types/pg": "catalog:", + "@types/tmp": "catalog:", "concurrently": "catalog:", + "content-tag": "catalog:", + "decorator-transforms": "catalog:", + "fs-extra": "catalog:", + "pg": "catalog:", + "tmp": "catalog:", + "ts-node": "^10.9.1", "typescript": "catalog:" }, "volta": { diff --git a/packages/software-factory/playwright.config.ts b/packages/software-factory/playwright.config.ts new file mode 100644 index 0000000000..a655980d9d --- /dev/null +++ b/packages/software-factory/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; + +const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4444); +const realmURL = + process.env.SOFTWARE_FACTORY_REALM_URL ?? `http://127.0.0.1:${realmPort}/`; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + 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.global-setup.ts b/packages/software-factory/playwright.global-setup.ts new file mode 100644 index 0000000000..eef64efd6b --- /dev/null +++ b/packages/software-factory/playwright.global-setup.ts @@ -0,0 +1,70 @@ +import { openSync, writeFileSync } from 'node:fs'; +import { spawnSync, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +const packageRoot = resolve(fileURLToPath(new URL('.', import.meta.url))); +const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4444); +const realmURL = + process.env.SOFTWARE_FACTORY_REALM_URL ?? `http://127.0.0.1:${realmPort}/`; +const realmDir = resolve( + packageRoot, + process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', +); +const stateFile = resolve(packageRoot, '.playwright-server.json'); +const logFile = resolve(packageRoot, 'playwright-server.log'); + +async function waitForServer(url: string, timeoutMs = 120_000) { + let startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + let response = await fetch(new URL('_info', url), { method: 'HEAD' }); + if (response.ok) { + return; + } + } catch { + // keep waiting for the child process to come up + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for software-factory realm at ${url}`); +} + +export default async function globalSetup() { + try { + let response = await fetch(new URL('_info', realmURL), { method: 'HEAD' }); + if (response.ok) { + writeFileSync(stateFile, JSON.stringify({ reusedExistingServer: true })); + return; + } + } catch { + // no reusable server is running + } + + let cacheResult = spawnSync('pnpm', ['cache:prepare', realmDir], { + cwd: packageRoot, + stdio: 'inherit', + env: process.env, + }); + if (cacheResult.status !== 0) { + throw new Error( + `Failed to prepare software-factory cache (exit ${cacheResult.status})`, + ); + } + + let logFd = openSync(logFile, 'a'); + let child = spawn('pnpm', ['serve:realm', realmDir], { + cwd: packageRoot, + env: process.env, + detached: true, + stdio: ['ignore', logFd, logFd], + }); + child.unref(); + + writeFileSync( + stateFile, + JSON.stringify({ pid: child.pid, reusedExistingServer: false }), + ); + + await waitForServer(realmURL); +} diff --git a/packages/software-factory/playwright.global-teardown.ts b/packages/software-factory/playwright.global-teardown.ts new file mode 100644 index 0000000000..249b0303c4 --- /dev/null +++ b/packages/software-factory/playwright.global-teardown.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +const packageRoot = resolve(fileURLToPath(new URL('.', import.meta.url))); +const stateFile = resolve(packageRoot, '.playwright-server.json'); + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export default async function globalTeardown() { + if (!existsSync(stateFile)) { + return; + } + + let state = JSON.parse(readFileSync(stateFile, 'utf8')) as { + pid?: number; + reusedExistingServer?: boolean; + }; + + if (!state.reusedExistingServer && state.pid) { + try { + process.kill(-state.pid, 'SIGTERM'); + } catch { + // best effort cleanup + } + + let startedAt = Date.now(); + while (processExists(state.pid) && Date.now() - startedAt < 10_000) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + if (processExists(state.pid)) { + try { + process.kill(-state.pid, 'SIGKILL'); + } catch { + // best effort cleanup + } + } + } + + rmSync(stateFile, { force: true }); +} diff --git a/packages/software-factory/src/cli/cache-realm.ts b/packages/software-factory/src/cli/cache-realm.ts new file mode 100644 index 0000000000..c872cd6815 --- /dev/null +++ b/packages/software-factory/src/cli/cache-realm.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +import { resolve } from 'node:path'; + +import { ensureFactoryRealmTemplate } from '../harness.ts'; + +let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); +try { + let template = await ensureFactoryRealmTemplate({ realmDir }); + console.log( + JSON.stringify( + { + realmDir, + cacheKey: template.cacheKey, + templateDatabaseName: template.templateDatabaseName, + fixtureHash: template.fixtureHash, + cacheHit: template.cacheHit, + }, + null, + 2, + ), + ); + process.exit(0); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/software-factory/src/cli/serve-realm.ts b/packages/software-factory/src/cli/serve-realm.ts new file mode 100644 index 0000000000..a96ae0b69f --- /dev/null +++ b/packages/software-factory/src/cli/serve-realm.ts @@ -0,0 +1,32 @@ +// @ts-nocheck +import { resolve } from 'node:path'; + +import { startFactoryRealmServer } from '../harness.ts'; + +let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); +try { + let runtime = await startFactoryRealmServer({ realmDir }); + + console.log( + JSON.stringify( + { + realmDir, + realmURL: runtime.realmURL.href, + sampleCardURL: runtime.cardURL('person-1'), + }, + null, + 2, + ), + ); + + let stop = async () => { + await runtime.stop(); + process.exit(0); + }; + + process.on('SIGINT', stop); + process.on('SIGTERM', stop); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/software-factory/src/cli/smoke-realm.ts b/packages/software-factory/src/cli/smoke-realm.ts new file mode 100644 index 0000000000..49105fdafe --- /dev/null +++ b/packages/software-factory/src/cli/smoke-realm.ts @@ -0,0 +1,27 @@ +// @ts-nocheck +import { resolve } from 'node:path'; + +import { fetchRealmCardJson } from '../harness.ts'; + +let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); +let cardPath = process.argv[3] ?? 'person-1'; +try { + let response = await fetchRealmCardJson(cardPath, { realmDir }); + console.log( + JSON.stringify( + { + realmDir, + cardPath, + status: response.status, + url: response.url, + body: JSON.parse(response.body), + }, + null, + 2, + ), + ); + process.exit(0); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/software-factory/src/harness.ts b/packages/software-factory/src/harness.ts new file mode 100644 index 0000000000..b9f68a91d3 --- /dev/null +++ b/packages/software-factory/src/harness.ts @@ -0,0 +1,902 @@ +// @ts-nocheck +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createServer as createNetServer } from 'node:net'; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; + +import { Client as PgClient } from 'pg'; + +const require = createRequire(import.meta.url); +require('decorator-transforms/globals'); +const ContentTagGlobal = require('content-tag'); +if (!(globalThis as any).ContentTagGlobal) { + (globalThis as any).ContentTagGlobal = ContentTagGlobal; +} +if (!(globalThis as any).__environment) { + (globalThis as any).__environment = 'test'; +} +const { + PgQueuePublisher, + PgQueueRunner, +} = require('../../postgres/pg-queue.ts'); +const { + CachingDefinitionLookup, +} = require('../../runtime-common/definition-lookup.ts'); +const { IndexWriter } = require('../../runtime-common/index-writer.ts'); +const { Worker } = require('../../runtime-common/worker.ts'); +const { + MatrixClient, + passwordFromSeed, +} = require('../../runtime-common/matrix-client.ts'); +const { RealmServer } = require('../../realm-server/server.ts'); +const { registerUser } = require('../../realm-server/synapse.ts'); +const { + createRemotePrerenderer, +} = require('../../realm-server/prerender/remote-prerenderer.ts'); +const { + createPrerenderHttpServer, +} = require('../../realm-server/prerender/prerender-app.ts'); +const { + closeServer, + createRealm, + createTestPgAdapter, + createVirtualNetwork, + getIndexHTML, + grafanaSecret, + matrixRegistrationSecret, + matrixURL, + realmSecretSeed, + realmServerSecretSeed, + testCreatePrerenderAuth, + waitUntil, +} = require('../../realm-server/tests/helpers/index.ts'); + +type LooseSingleCardDocument = any; +type QueuePublisher = any; +type QueueRunner = any; +type RealmPermissions = Record; + +const DEFAULT_REALM_PORT = Number( + process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4444, +); +const DEFAULT_REALM_URL = new URL( + process.env.SOFTWARE_FACTORY_REALM_URL ?? + `http://127.0.0.1:${DEFAULT_REALM_PORT}/`, +); +const DEFAULT_REALM_DIR = resolve( + process.cwd(), + process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', +); +const DEFAULT_REALM_OWNER = '@software-factory-owner:localhost'; +const DEFAULT_HOST_URL = process.env.HOST_URL ?? 'http://localhost:4200/'; +const DEFAULT_MATRIX_URL = new URL(process.env.MATRIX_URL ?? matrixURL.href); +const DEFAULT_MATRIX_USERNAME = + process.env.SOFTWARE_FACTORY_MATRIX_USERNAME ?? 'software-factory-backend'; +const DEFAULT_MATRIX_SERVER_USERNAME = + process.env.SOFTWARE_FACTORY_MATRIX_SERVER_USERNAME ?? + 'software-factory-realm-server'; +const DEFAULT_MATRIX_BROWSER_USERNAME = + process.env.SOFTWARE_FACTORY_BROWSER_MATRIX_USERNAME ?? + 'software-factory-browser'; +const DEFAULT_PERMISSIONS: RealmPermissions = { + '*': ['read'], + [DEFAULT_REALM_OWNER]: ['read', 'write', 'realm-owner'], +}; +const TEST_PG_PORT = process.env.PGPORT ?? '55436'; +const TEST_PG_HOST = process.env.PGHOST ?? '127.0.0.1'; +const TEST_PG_USER = process.env.PGUSER ?? 'postgres'; +const CACHE_VERSION = 1; + +let prepareTestPgPromise: Promise | undefined; +let ensureMatrixUsersPromise: Promise | undefined; + +export interface FactoryRealmOptions { + realmDir?: string; + realmURL?: URL; + permissions?: RealmPermissions; + useCache?: boolean; + cacheSalt?: string; +} + +export interface FactoryRealmTemplate { + cacheKey: string; + templateDatabaseName: string; + fixtureHash: string; + cacheHit: boolean; +} + +export interface StartedFactoryRealm { + realmDir: string; + realmURL: URL; + cardURL(path: string): string; + stop(): Promise; +} + +function applyTestPgEnv() { + process.env.PGHOST = TEST_PG_HOST; + process.env.PGPORT = TEST_PG_PORT; + process.env.PGUSER = TEST_PG_USER; +} + +function pgAdminConnectionConfig(database = 'postgres') { + return { + host: TEST_PG_HOST, + port: Number(TEST_PG_PORT), + user: TEST_PG_USER, + password: process.env.PGPASSWORD || undefined, + database, + }; +} + +function quotePgIdentifier(identifier: string): string { + if (!/^[a-zA-Z0-9_]+$/.test(identifier)) { + throw new Error(`unsafe postgres identifier: ${identifier}`); + } + return `"${identifier}"`; +} + +async function runCommand(command: string, args: string[], cwd: string) { + await new Promise((resolve, reject) => { + let child = spawn(command, args, { + cwd, + stdio: 'inherit', + env: { + ...process.env, + PGHOST: TEST_PG_HOST, + PGPORT: TEST_PG_PORT, + PGUSER: TEST_PG_USER, + }, + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error(`command failed: ${command} ${args.join(' ')} (${code})`), + ); + } + }); + }); +} + +async function canConnectToTestPg(): Promise { + let client = new PgClient({ + ...pgAdminConnectionConfig(), + connectionTimeoutMillis: 1000, + }); + try { + await client.connect(); + await client.query('SELECT 1'); + return true; + } catch { + return false; + } finally { + try { + await client.end(); + } catch { + // best effort cleanup + } + } +} + +async function ensureTestPgPrepared() { + applyTestPgEnv(); + if (!prepareTestPgPromise) { + prepareTestPgPromise = (async () => { + if (await canConnectToTestPg()) { + return; + } + let script = resolve( + process.cwd(), + '../realm-server/tests/scripts/prepare-test-pg.sh', + ); + await runCommand('bash', [script], process.cwd()); + })().catch((error) => { + prepareTestPgPromise = undefined; + throw error; + }); + } + await prepareTestPgPromise; +} + +async function ensureFactoryMatrixUser(username: string): Promise { + let password = await passwordFromSeed(username, realmSecretSeed); + let loginResponse = await fetch( + new URL('_matrix/client/v3/login', DEFAULT_MATRIX_URL), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: { + type: 'm.id.user', + user: username, + }, + password, + type: 'm.login.password', + }), + }, + ); + + if (loginResponse.ok) { + return; + } + + if (loginResponse.status !== 403) { + throw new Error( + `Unable to probe matrix user ${username}: ${loginResponse.status} ${await loginResponse.text()}`, + ); + } + + try { + await registerUser({ + matrixURL: DEFAULT_MATRIX_URL, + displayname: username, + username, + password, + registrationSecret: matrixRegistrationSecret, + }); + } catch (error) { + let message = String(error); + if ( + !message.includes('M_USER_IN_USE') && + !message.includes('User ID already taken') && + !message.includes('already taken') + ) { + throw error; + } + } + + let registeredClient = new MatrixClient({ + matrixURL: DEFAULT_MATRIX_URL, + username, + seed: realmSecretSeed, + }); + await registeredClient.login(); +} + +async function ensureFactoryMatrixUsers(): Promise { + if ( + !matrixRegistrationSecret || + matrixRegistrationSecret === 'software-factory-no-matrix' + ) { + return; + } + if (!ensureMatrixUsersPromise) { + ensureMatrixUsersPromise = (async () => { + await ensureFactoryMatrixUser(DEFAULT_MATRIX_USERNAME); + await ensureFactoryMatrixUser(DEFAULT_MATRIX_SERVER_USERNAME); + await ensureFactoryMatrixUser(DEFAULT_MATRIX_BROWSER_USERNAME); + })().catch((error) => { + ensureMatrixUsersPromise = undefined; + throw error; + }); + } + await ensureMatrixUsersPromise; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + let record = value as Record; + let keys = Object.keys(record).sort(); + return `{${keys + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; +} + +function hashString(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function shouldIgnoreFixturePath(relativePath: string): boolean { + if (relativePath === '.DS_Store') { + return true; + } + return relativePath + .split('/') + .some((segment) => + [ + 'node_modules', + '.git', + '.boxel-history', + 'playwright-report', + 'test-results', + ].includes(segment), + ); +} + +function readRealmFixture( + realmDir: string, +): Record { + let fileSystem: Record = {}; + + function visit(currentDir: string) { + for (let entry of readdirSync(currentDir, { withFileTypes: true })) { + let absolutePath = join(currentDir, entry.name); + let relativePath = relative(realmDir, absolutePath).replace(/\\/g, '/'); + if (shouldIgnoreFixturePath(relativePath)) { + continue; + } + if (entry.isDirectory()) { + visit(absolutePath); + continue; + } + if (!entry.isFile()) { + continue; + } + let raw = readFileSync(absolutePath, 'utf8'); + if (relativePath.endsWith('.json')) { + try { + fileSystem[relativePath] = JSON.parse(raw) as LooseSingleCardDocument; + continue; + } catch { + // fall back to a plain text file if JSON parsing fails + } + } + fileSystem[relativePath] = raw; + } + } + + visit(realmDir); + return fileSystem; +} + +function hashRealmFixture(realmDir: string): string { + let entries: string[] = []; + + function visit(currentDir: string) { + for (let entry of readdirSync(currentDir, { withFileTypes: true })) { + let absolutePath = join(currentDir, entry.name); + let relativePath = relative(realmDir, absolutePath).replace(/\\/g, '/'); + if (shouldIgnoreFixturePath(relativePath)) { + continue; + } + if (entry.isDirectory()) { + visit(absolutePath); + continue; + } + if (!entry.isFile()) { + continue; + } + let stats = statSync(absolutePath); + let contentsHash = createHash('sha256') + .update(readFileSync(absolutePath)) + .digest('hex'); + entries.push(`${relativePath}:${stats.size}:${contentsHash}`); + } + } + + visit(realmDir); + entries.sort(); + return hashString(entries.join('|')); +} + +function templateDatabaseNameForCacheKey(cacheKey: string): string { + return `sf_tpl_${cacheKey.slice(0, 24)}`; +} + +function builderDatabaseNameForCacheKey(cacheKey: string): string { + return `sf_bld_${process.pid}_${cacheKey.slice(0, 16)}`; +} + +function runtimeDatabaseName(): string { + return `sf_run_${process.pid}_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +async function databaseExists(databaseName: string): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + let result = await client.query<{ exists: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1) AS exists', + [databaseName], + ); + return Boolean(result.rows[0]?.exists); + } finally { + await client.end(); + } +} + +async function dropDatabase(databaseName: string): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [databaseName], + ); + await client.query( + `DROP DATABASE IF EXISTS ${quotePgIdentifier(databaseName)}`, + ); + } finally { + await client.end(); + } +} + +async function createTemplateSnapshot( + sourceDatabaseName: string, + templateDatabaseName: string, +): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [templateDatabaseName], + ); + await client.query( + `DROP DATABASE IF EXISTS ${quotePgIdentifier(templateDatabaseName)}`, + ); + await client.query( + `CREATE DATABASE ${quotePgIdentifier(templateDatabaseName)} TEMPLATE ${quotePgIdentifier( + sourceDatabaseName, + )}`, + ); + await client.query( + `ALTER DATABASE ${quotePgIdentifier(templateDatabaseName)} WITH IS_TEMPLATE true`, + ); + } finally { + await client.end(); + } +} + +async function waitForQueueIdle( + databaseName: string, + timeout = 30000, +): Promise { + await waitUntil( + async () => { + let client = new PgClient(pgAdminConnectionConfig(databaseName)); + try { + await client.connect(); + let { + rows: [{ count: unfulfilledJobs }], + } = await client.query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM jobs WHERE status = 'unfulfilled'`, + ); + let { + rows: [{ count: activeReservations }], + } = await client.query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM job_reservations WHERE completed_at IS NULL`, + ); + return unfulfilledJobs === 0 && activeReservations === 0; + } finally { + await client.end(); + } + }, + { + timeout, + interval: 50, + timeoutMessage: 'waiting for queue to become idle', + }, + ); +} + +async function buildTemplate( + options: Required< + Pick + > & { + cacheKey: string; + templateDatabaseName: string; + }, +): Promise { + let builderDatabaseName = builderDatabaseNameForCacheKey(options.cacheKey); + let runtime = await startFactoryRealmServer({ + realmDir: options.realmDir, + realmURL: options.realmURL, + permissions: options.permissions, + useCache: false, + databaseName: builderDatabaseName, + }); + + try { + await waitForQueueIdle(builderDatabaseName); + } finally { + await runtime.stop({ preserveDatabase: true }); + } + + await createTemplateSnapshot( + builderDatabaseName, + options.templateDatabaseName, + ); + await dropDatabase(builderDatabaseName); +} + +export async function ensureFactoryRealmTemplate( + options: FactoryRealmOptions = {}, +): Promise { + let realmDir = resolve(options.realmDir ?? DEFAULT_REALM_DIR); + let realmURL = new URL((options.realmURL ?? DEFAULT_REALM_URL).href); + let permissions = options.permissions ?? DEFAULT_PERMISSIONS; + let fixtureHash = hashRealmFixture(realmDir); + let cacheKey = hashString( + stableStringify({ + version: CACHE_VERSION, + realmURL: realmURL.href, + permissions, + fixtureHash, + cacheSalt: + options.cacheSalt ?? process.env.SOFTWARE_FACTORY_CACHE_SALT ?? null, + }), + ); + let templateDatabaseName = templateDatabaseNameForCacheKey(cacheKey); + + await ensureTestPgPrepared(); + if (await databaseExists(templateDatabaseName)) { + return { + cacheKey, + templateDatabaseName, + fixtureHash, + cacheHit: true, + }; + } + + await buildTemplate({ + realmDir, + realmURL, + permissions, + cacheKey, + templateDatabaseName, + }); + + return { + cacheKey, + templateDatabaseName, + fixtureHash, + cacheHit: false, + }; +} + +async function buildStartedRealm( + options: Required< + Pick + > & { + databaseName: string; + templateDatabase?: string; + }, +) { + applyTestPgEnv(); + await ensureFactoryMatrixUsers(); + + let fileSystem = readRealmFixture(options.realmDir); + let runtimeRoot = mkdtempSync(join(tmpdir(), 'software-factory-realm-')); + let realmPath = join(runtimeRoot, 'realm'); + mkdirSync(realmPath, { recursive: true }); + + let dbAdapter = await createTestPgAdapter({ + databaseName: options.databaseName, + templateDatabase: options.templateDatabase, + }); + let publisher: QueuePublisher | undefined; + let runner: QueueRunner | undefined; + let prerenderer: any; + let prerenderServer: any; + let testRealmServer: RealmServer | undefined; + let httpServer; + + try { + publisher = new PgQueuePublisher(dbAdapter); + runner = new PgQueueRunner({ + adapter: dbAdapter, + workerId: `software-factory-${process.pid}`, + }); + ({ prerenderer, server: prerenderServer } = await startPrerenderServer()); + let virtualNetwork = createVirtualNetwork(); + let definitionLookup = new CachingDefinitionLookup( + dbAdapter, + prerenderer, + virtualNetwork, + testCreatePrerenderAuth, + ); + let worker = new Worker({ + indexWriter: new IndexWriter(dbAdapter), + queue: runner, + dbAdapter, + queuePublisher: publisher, + virtualNetwork, + matrixURL: DEFAULT_MATRIX_URL, + secretSeed: realmSecretSeed, + realmServerMatrixUsername: DEFAULT_MATRIX_SERVER_USERNAME, + prerenderer, + createPrerenderAuth: testCreatePrerenderAuth, + }); + await worker.run(); + + let { realm } = await createRealm({ + dir: realmPath, + definitionLookup, + fileSystem, + realmURL: options.realmURL.href, + permissions: options.permissions, + virtualNetwork, + publisher, + dbAdapter, + cardSizeLimitBytes: undefined, + fileSizeLimitBytes: undefined, + }); + + virtualNetwork.mount(realm.handle); + + testRealmServer = new RealmServer({ + realms: [realm], + virtualNetwork, + matrixClient: new MatrixClient({ + matrixURL: DEFAULT_MATRIX_URL, + username: DEFAULT_MATRIX_USERNAME, + seed: realmSecretSeed, + }), + realmServerSecretSeed, + realmSecretSeed, + matrixRegistrationSecret, + realmsRootPath: runtimeRoot, + dbAdapter, + queue: publisher, + getIndexHTML, + grafanaSecret, + serverURL: new URL(options.realmURL.origin), + assetsURL: new URL(DEFAULT_HOST_URL), + definitionLookup, + prerenderer, + }); + + httpServer = testRealmServer.listen(Number(options.realmURL.port)); + await testRealmServer.start(); + + return { + stop: async ({ + preserveDatabase = false, + }: { preserveDatabase?: boolean } = {}) => { + let cleanupError: unknown; + + try { + if (httpServer?.listening) { + await closeServer(httpServer); + } + } catch (error) { + cleanupError ??= error; + } + + try { + await publisher?.destroy(); + } catch (error) { + cleanupError ??= error; + } + + try { + await runner?.destroy(); + } catch (error) { + cleanupError ??= error; + } + + try { + await dbAdapter.close(); + } catch (error) { + cleanupError ??= error; + } + + try { + if ( + prerenderServer && + typeof prerenderServer.__stopPrerenderer === 'function' + ) { + await prerenderServer.__stopPrerenderer(); + } + } catch (error) { + cleanupError ??= error; + } + + try { + if (prerenderServer?.listening) { + await closeServer(prerenderServer); + } + } catch (error) { + cleanupError ??= error; + } + + try { + await (prerenderer as { stop?: () => Promise })?.stop?.(); + } catch (error) { + cleanupError ??= error; + } + + if (!preserveDatabase) { + try { + await dropDatabase(options.databaseName); + } catch (error) { + cleanupError ??= error; + } + } + + try { + rmSync(runtimeRoot, { recursive: true, force: true }); + } catch (error) { + cleanupError ??= error; + } + + if (cleanupError) { + throw cleanupError; + } + }, + }; + } catch (error) { + try { + await publisher?.destroy(); + } catch { + // best effort cleanup + } + try { + await runner?.destroy(); + } catch { + // best effort cleanup + } + try { + await dbAdapter.close(); + } catch { + // best effort cleanup + } + try { + if ( + prerenderServer && + typeof prerenderServer.__stopPrerenderer === 'function' + ) { + await prerenderServer.__stopPrerenderer(); + } + } catch { + // best effort cleanup + } + try { + if (prerenderServer?.listening) { + await closeServer(prerenderServer); + } + } catch { + // best effort cleanup + } + try { + await (prerenderer as { stop?: () => Promise })?.stop?.(); + } catch { + // best effort cleanup + } + try { + await dropDatabase(options.databaseName); + } catch { + // best effort cleanup + } + rmSync(runtimeRoot, { recursive: true, force: true }); + throw error; + } +} + +async function startFactoryRealmServer( + options: FactoryRealmOptions & { + databaseName?: string; + } = {}, +): Promise< + StartedFactoryRealm & { + stop(args?: { preserveDatabase?: boolean }): Promise; + } +> { + let realmDir = resolve(options.realmDir ?? DEFAULT_REALM_DIR); + let realmURL = new URL((options.realmURL ?? DEFAULT_REALM_URL).href); + let permissions = options.permissions ?? DEFAULT_PERMISSIONS; + let databaseName = options.databaseName ?? runtimeDatabaseName(); + + await ensureTestPgPrepared(); + + let templateDatabase: string | undefined; + if (options.useCache !== false) { + templateDatabase = ( + await ensureFactoryRealmTemplate({ + realmDir, + realmURL, + permissions, + cacheSalt: options.cacheSalt, + }) + ).templateDatabaseName; + } + + let runtime = await buildStartedRealm({ + realmDir, + realmURL, + permissions, + databaseName, + templateDatabase, + }); + + return { + realmDir, + realmURL, + cardURL(path: string) { + return new URL(path, realmURL).href; + }, + stop: runtime.stop, + }; +} + +export { startFactoryRealmServer }; + +export async function fetchRealmCardJson( + path: string, + options: FactoryRealmOptions = {}, +) { + let runtime = await startFactoryRealmServer(options); + try { + let response = await fetch(runtime.cardURL(path), { + headers: { + Accept: 'application/vnd.card+json', + }, + }); + return { + status: response.status, + body: await response.text(), + url: response.url, + }; + } finally { + await runtime.stop(); + } +} + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + let server = createNetServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + let address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('unable to determine free port'))); + return; + } + let { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +async function startPrerenderServer(): Promise<{ + prerenderer: any; + server: any; +}> { + let port = await getFreePort(); + let server = createPrerenderHttpServer({ + silent: Boolean(process.env.SILENT_PRERENDERER), + maxPages: 2, + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve()); + }); + + return { + prerenderer: createRemotePrerenderer(`http://127.0.0.1:${port}`), + server, + }; +} diff --git a/packages/software-factory/src/index.ts b/packages/software-factory/src/index.ts index cb0ff5c3b5..50f22adf99 100644 --- a/packages/software-factory/src/index.ts +++ b/packages/software-factory/src/index.ts @@ -1 +1,9 @@ -export {}; +// @ts-nocheck +export { + ensureFactoryRealmTemplate, + fetchRealmCardJson, + startFactoryRealmServer, + type FactoryRealmOptions, + type FactoryRealmTemplate, + type StartedFactoryRealm, +} from './harness.ts'; diff --git a/packages/software-factory/tests/demo-realm.spec.ts b/packages/software-factory/tests/demo-realm.spec.ts new file mode 100644 index 0000000000..6e9e740cbd --- /dev/null +++ b/packages/software-factory/tests/demo-realm.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from './fixtures'; + +test('renders a local card instance through the cached realm server', async ({ + authedPage, + cardURL, +}) => { + await authedPage.goto(cardURL('person-1'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'Mango' }), + ).toBeVisible(); + await expect(authedPage.getByText('Mango').first()).toBeVisible(); +}); diff --git a/packages/software-factory/tests/fixtures.ts b/packages/software-factory/tests/fixtures.ts new file mode 100644 index 0000000000..e6e2801554 --- /dev/null +++ b/packages/software-factory/tests/fixtures.ts @@ -0,0 +1,29 @@ +import { test as base, expect } from '@playwright/test'; +import { seedBrowserSession } from './helpers/browser-auth'; + +export type FactoryRealmFixtures = { + realmURL: URL; + cardURL: (path: string) => string; + authedPage: import('@playwright/test').Page; +}; + +const defaultRealmURL = new URL( + process.env.SOFTWARE_FACTORY_REALM_URL ?? 'http://127.0.0.1:4444/', +); + +export const test = base.extend({ + realmURL: async ({ baseURL: _baseURL }, use) => { + await use(new URL(defaultRealmURL.href)); + }, + + cardURL: async ({ realmURL }, use) => { + await use((path: string) => new URL(path, realmURL).href); + }, + + authedPage: async ({ page, realmURL }, use) => { + await seedBrowserSession(page, realmURL.href); + await use(page); + }, +}); + +export { expect }; diff --git a/packages/software-factory/tests/helpers/browser-auth.ts b/packages/software-factory/tests/helpers/browser-auth.ts new file mode 100644 index 0000000000..ca8a581fc8 --- /dev/null +++ b/packages/software-factory/tests/helpers/browser-auth.ts @@ -0,0 +1,205 @@ +import { createHash } from 'node:crypto'; +import type { Page } from '@playwright/test'; + +type BrowserAuth = { + access_token: string; + user_id: string; + device_id: string; + home_server: string; +}; + +type BrowserSession = Record; + +type BoxelProfile = { + username: string; + matrixUrl: string; + password: string; +}; + +const defaultMatrixUrl = ensureTrailingSlash( + process.env.SOFTWARE_FACTORY_BROWSER_MATRIX_URL ?? + process.env.MATRIX_URL ?? + 'http://localhost:8008/', +); +const defaultUsername = + process.env.SOFTWARE_FACTORY_BROWSER_MATRIX_USERNAME ?? + 'software-factory-browser'; +const defaultSeed = + process.env.SOFTWARE_FACTORY_BROWSER_SECRET_SEED ?? "shhh! it's a secret"; + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + +function getBrowserProfile(): BoxelProfile { + let username = ( + process.env.SOFTWARE_FACTORY_BROWSER_USERNAME ?? defaultUsername + ) + .replace(/^@/, '') + .replace(/:.*$/, ''); + + let password = + process.env.SOFTWARE_FACTORY_BROWSER_PASSWORD ?? + createHash('sha256').update(username).update(defaultSeed).digest('hex'); + + return { + matrixUrl: defaultMatrixUrl, + username, + password, + }; +} + +async function matrixLogin(profile = getBrowserProfile()) { + let response = await fetch( + new URL('_matrix/client/v3/login', profile.matrixUrl), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: { + type: 'm.id.user', + user: profile.username, + }, + password: profile.password, + type: 'm.login.password', + }), + }, + ); + + let json = await response.json(); + if (!response.ok) { + throw new Error( + `Matrix login failed: ${response.status} ${JSON.stringify(json)}`, + ); + } + + return { + accessToken: json.access_token as string, + deviceId: json.device_id as string, + userId: json.user_id as string, + homeServer: new URL(profile.matrixUrl).host, + matrixUrl: profile.matrixUrl, + }; +} + +async function getOpenIdToken(matrixAuth: { + accessToken: string; + userId: string; + matrixUrl: string; +}) { + let response = await fetch( + new URL( + `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/openid/request_token`, + matrixAuth.matrixUrl, + ), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: '{}', + }, + ); + + if (!response.ok) { + throw new Error( + `OpenID token request failed: ${response.status} ${await response.text()}`, + ); + } + + return await response.json(); +} + +async function getRealmServerToken( + matrixAuth: { + accessToken: string; + userId: string; + matrixUrl: string; + }, + realmURL: string, +) { + let openIdToken = await getOpenIdToken(matrixAuth); + let response = await fetch(new URL('_server-session', realmURL), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(openIdToken), + }); + + if (!response.ok) { + throw new Error( + `Realm server session request failed: ${response.status} ${await response.text()}`, + ); + } + + let token = response.headers.get('Authorization'); + if (!token) { + throw new Error( + 'Realm server session response did not include an Authorization header', + ); + } + return token; +} + +async function getRealmAuthTokens( + matrixAuth: { + accessToken: string; + userId: string; + matrixUrl: string; + }, + realmURL: string, +): Promise> { + let serverToken = await getRealmServerToken(matrixAuth, realmURL); + let response = await fetch(new URL('_realm-auth', realmURL), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: serverToken, + }, + }); + + if (!response.ok) { + throw new Error( + `Realm auth lookup failed: ${response.status} ${await response.text()}`, + ); + } + + return (await response.json()) as Record; +} + +export async function buildBrowserState( + realmURL: string, +): Promise<{ auth: BrowserAuth; boxelSession: BrowserSession }> { + let matrixAuth = await matrixLogin(); + let realmTokens = await getRealmAuthTokens(matrixAuth, realmURL); + + return { + auth: { + access_token: matrixAuth.accessToken, + user_id: matrixAuth.userId, + device_id: matrixAuth.deviceId, + home_server: matrixAuth.homeServer, + }, + boxelSession: { + [ensureTrailingSlash(realmURL)]: + realmTokens[ensureTrailingSlash(realmURL)] ?? '', + }, + }; +} + +export async function seedBrowserSession(page: Page, realmURL: string) { + let state = await buildBrowserState(realmURL); + await page.addInitScript((payload) => { + window.localStorage.setItem('auth', JSON.stringify(payload.auth)); + window.localStorage.setItem( + 'boxel-session', + JSON.stringify(payload.boxelSession), + ); + }, state); +} diff --git a/packages/software-factory/tsconfig.json b/packages/software-factory/tsconfig.json index c23ec33aa2..fee0953576 100644 --- a/packages/software-factory/tsconfig.json +++ b/packages/software-factory/tsconfig.json @@ -5,6 +5,9 @@ "module": "NodeNext", "moduleResolution": "nodenext", "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": false, + "useDefineForClassFields": false, "noImplicitAny": true, "noImplicitThis": true, "alwaysStrict": true, @@ -20,5 +23,5 @@ "strict": true, "types": ["@cardstack/local-types", "node"] }, - "include": ["./src/**/*.ts"] + "include": ["./src/**/*.ts", "./tests/**/*.ts", "./playwright.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68842cb575..d3674f1633 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2826,12 +2826,48 @@ importers: '@cardstack/local-types': specifier: workspace:* version: link:../local-types + '@cardstack/postgres': + specifier: workspace:* + version: link:../postgres + '@cardstack/runtime-common': + specifier: workspace:* + version: link:../runtime-common + '@playwright/test': + specifier: 'catalog:' + version: 1.57.0 + '@types/fs-extra': + specifier: 'catalog:' + version: 11.0.4 '@types/node': specifier: 'catalog:' version: 24.10.8 + '@types/pg': + specifier: 'catalog:' + version: 8.16.0 + '@types/tmp': + specifier: 'catalog:' + version: 0.2.6 concurrently: specifier: 'catalog:' version: 8.2.2 + content-tag: + specifier: 'catalog:' + version: 4.1.0 + decorator-transforms: + specifier: 'catalog:' + version: 2.3.1(@babel/core@7.28.6) + fs-extra: + specifier: 'catalog:' + version: 11.3.3 + pg: + specifier: 'catalog:' + version: 8.16.3 + tmp: + specifier: 'catalog:' + version: 0.2.5 + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@types/node@24.10.8)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -6638,6 +6674,7 @@ packages: aws-sdk@2.1693.0: resolution: {integrity: sha512-cJmb8xEnVLT+R6fBS5sn/EFJiX7tUnDaPtOPZ1vFbOJtd0fnZn/Ky2XGgsvvoeliWeH7mL3TWSX5zXXGSQV6gQ==} engines: {node: '>= 10.0.0'} + deprecated: The AWS SDK for JavaScript (v2) has reached end-of-support, and no longer receives updates. Please migrate your code to use AWS SDK for JavaScript (v3). More info https://a.co/cUPnyil axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} @@ -6831,6 +6868,7 @@ packages: basic-ftp@5.1.0: resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -9568,7 +9606,7 @@ packages: glob@5.0.15: resolution: {integrity: sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} From 077cb56bed3dc91355f5d413603655f42b45276d Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Fri, 13 Mar 2026 09:37:07 +0000 Subject: [PATCH 03/23] Harden software factory harness startup --- packages/software-factory/src/harness.ts | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/software-factory/src/harness.ts b/packages/software-factory/src/harness.ts index b9f68a91d3..5739c86e95 100644 --- a/packages/software-factory/src/harness.ts +++ b/packages/software-factory/src/harness.ts @@ -79,6 +79,8 @@ const DEFAULT_REALM_DIR = resolve( ); const DEFAULT_REALM_OWNER = '@software-factory-owner:localhost'; const DEFAULT_HOST_URL = process.env.HOST_URL ?? 'http://localhost:4200/'; +const DEFAULT_BASE_REALM_URL = + process.env.SOFTWARE_FACTORY_BASE_REALM_URL ?? 'http://localhost:4201/base/'; const DEFAULT_MATRIX_URL = new URL(process.env.MATRIX_URL ?? matrixURL.href); const DEFAULT_MATRIX_USERNAME = process.env.SOFTWARE_FACTORY_MATRIX_USERNAME ?? 'software-factory-backend'; @@ -99,6 +101,7 @@ const CACHE_VERSION = 1; let prepareTestPgPromise: Promise | undefined; let ensureMatrixUsersPromise: Promise | undefined; +let ensurePrerequisitesPromise: Promise | undefined; export interface FactoryRealmOptions { realmDir?: string; @@ -210,6 +213,66 @@ async function ensureTestPgPrepared() { await prepareTestPgPromise; } +async function ensureServiceReady( + name: string, + request: Promise, + url: string, +): Promise { + let response: Response; + try { + response = await request; + } catch (error) { + throw new Error( + `${name} is not reachable at ${url}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + if (!response.ok) { + throw new Error( + `${name} is not ready at ${url}: status ${response.status}`, + ); + } +} + +async function ensureFactoryPrerequisites(): Promise { + if (!ensurePrerequisitesPromise) { + ensurePrerequisitesPromise = (async () => { + await ensureServiceReady( + 'Host app', + fetch(DEFAULT_HOST_URL), + DEFAULT_HOST_URL, + ); + let baseInfoURL = new URL('_info', DEFAULT_BASE_REALM_URL).href; + await ensureServiceReady( + 'Base realm', + fetch(baseInfoURL, { + method: 'QUERY', + headers: { + Accept: 'application/vnd.api+json', + }, + }), + baseInfoURL, + ); + let matrixVersionsURL = new URL( + '_matrix/client/versions', + DEFAULT_MATRIX_URL, + ).href; + await ensureServiceReady( + 'Matrix server', + fetch(matrixVersionsURL), + matrixVersionsURL, + ); + })().catch((error) => { + ensurePrerequisitesPromise = undefined; + throw error; + }); + } + + await ensurePrerequisitesPromise; +} + async function ensureFactoryMatrixUser(username: string): Promise { let password = await passwordFromSeed(username, realmSecretSeed); let loginResponse = await fetch( @@ -545,6 +608,7 @@ export async function ensureFactoryRealmTemplate( let templateDatabaseName = templateDatabaseNameForCacheKey(cacheKey); await ensureTestPgPrepared(); + await ensureFactoryPrerequisites(); if (await databaseExists(templateDatabaseName)) { return { cacheKey, @@ -804,6 +868,7 @@ async function startFactoryRealmServer( let databaseName = options.databaseName ?? runtimeDatabaseName(); await ensureTestPgPrepared(); + await ensureFactoryPrerequisites(); let templateDatabase: string | undefined; if (options.useCache !== false) { From 9f857db00b862b0a91724c991d5db1f83b53c667 Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Fri, 13 Mar 2026 10:16:46 +0000 Subject: [PATCH 04/23] Reuse browser cache across software factory tests --- packages/software-factory/.gitignore | 2 - packages/software-factory/README.md | 10 +- .../software-factory/demo-realm/person-2.json | 14 + .../software-factory/playwright.config.ts | 4 +- .../playwright.global-setup.ts | 50 +-- .../playwright.global-teardown.ts | 49 --- .../software-factory/src/cli/cache-realm.ts | 28 +- .../software-factory/src/cli/serve-realm.ts | 33 +- packages/software-factory/src/harness.ts | 36 +- .../software-factory/tests/demo-realm.spec.ts | 78 +++++ packages/software-factory/tests/fixtures.ts | 313 +++++++++++++++++- .../tests/helpers/browser-auth.ts | 26 +- 12 files changed, 499 insertions(+), 144 deletions(-) create mode 100644 packages/software-factory/demo-realm/person-2.json delete mode 100644 packages/software-factory/playwright.global-teardown.ts diff --git a/packages/software-factory/.gitignore b/packages/software-factory/.gitignore index 15c33b0d08..187b732ef4 100644 --- a/packages/software-factory/.gitignore +++ b/packages/software-factory/.gitignore @@ -1,5 +1,3 @@ playwright-report/ test-results/ .software-factory-cache/ -.playwright-server.json -playwright-server.log diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index 5090fd909e..b457fc1345 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -24,7 +24,7 @@ Those are the same local services the realm-server tests expect. - `pnpm smoke:realm` - Boots the realm server, fetches `person-1` as card JSON, and exits - `pnpm test:playwright` - - Runs the browser test against the cached local realm + - Runs the browser test against a fresh per-test realm server cloned from the cached template All commands accept an optional realm directory argument: @@ -47,5 +47,13 @@ pnpm smoke:realm ./my-realm Person/example-card - Template DBs are intentionally reused across runs while the realm-server codebase stays stable. +- Playwright uses a single worker-scoped browser context so host assets and app + shell requests stay warm in the browser cache across tests. +- Each Playwright test still starts a fresh realm server and fresh runtime + database cloned from the cached template DB, so server-side mutations do not + leak across tests. +- Realm-origin requests are forced to revalidate between tests. That preserves + host asset caching without letting mutated card responses leak into the next + fresh realm runtime. - The browser test seeds a deterministic local Matrix user (`software-factory-browser`) so it does not depend on a human-managed profile. diff --git a/packages/software-factory/demo-realm/person-2.json b/packages/software-factory/demo-realm/person-2.json new file mode 100644 index 0000000000..6912b5b48e --- /dev/null +++ b/packages/software-factory/demo-realm/person-2.json @@ -0,0 +1,14 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Papaya" + }, + "meta": { + "adoptsFrom": { + "module": "./person.gts", + "name": "Person" + } + } + } +} diff --git a/packages/software-factory/playwright.config.ts b/packages/software-factory/playwright.config.ts index a655980d9d..90828a72f2 100644 --- a/packages/software-factory/playwright.config.ts +++ b/packages/software-factory/playwright.config.ts @@ -6,7 +6,8 @@ const realmURL = export default defineConfig({ testDir: './tests', - fullyParallel: true, + fullyParallel: false, + workers: 1, timeout: 60_000, expect: { timeout: 15_000, @@ -17,5 +18,4 @@ export default defineConfig({ screenshot: 'only-on-failure', }, globalSetup: './playwright.global-setup.ts', - globalTeardown: './playwright.global-teardown.ts', }); diff --git a/packages/software-factory/playwright.global-setup.ts b/packages/software-factory/playwright.global-setup.ts index eef64efd6b..bfa430c744 100644 --- a/packages/software-factory/playwright.global-setup.ts +++ b/packages/software-factory/playwright.global-setup.ts @@ -1,46 +1,14 @@ -import { openSync, writeFileSync } from 'node:fs'; -import { spawnSync, spawn } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; const packageRoot = resolve(fileURLToPath(new URL('.', import.meta.url))); -const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4444); -const realmURL = - process.env.SOFTWARE_FACTORY_REALM_URL ?? `http://127.0.0.1:${realmPort}/`; const realmDir = resolve( packageRoot, process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', ); -const stateFile = resolve(packageRoot, '.playwright-server.json'); -const logFile = resolve(packageRoot, 'playwright-server.log'); - -async function waitForServer(url: string, timeoutMs = 120_000) { - let startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - try { - let response = await fetch(new URL('_info', url), { method: 'HEAD' }); - if (response.ok) { - return; - } - } catch { - // keep waiting for the child process to come up - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for software-factory realm at ${url}`); -} export default async function globalSetup() { - try { - let response = await fetch(new URL('_info', realmURL), { method: 'HEAD' }); - if (response.ok) { - writeFileSync(stateFile, JSON.stringify({ reusedExistingServer: true })); - return; - } - } catch { - // no reusable server is running - } - let cacheResult = spawnSync('pnpm', ['cache:prepare', realmDir], { cwd: packageRoot, stdio: 'inherit', @@ -51,20 +19,4 @@ export default async function globalSetup() { `Failed to prepare software-factory cache (exit ${cacheResult.status})`, ); } - - let logFd = openSync(logFile, 'a'); - let child = spawn('pnpm', ['serve:realm', realmDir], { - cwd: packageRoot, - env: process.env, - detached: true, - stdio: ['ignore', logFd, logFd], - }); - child.unref(); - - writeFileSync( - stateFile, - JSON.stringify({ pid: child.pid, reusedExistingServer: false }), - ); - - await waitForServer(realmURL); } diff --git a/packages/software-factory/playwright.global-teardown.ts b/packages/software-factory/playwright.global-teardown.ts deleted file mode 100644 index 249b0303c4..0000000000 --- a/packages/software-factory/playwright.global-teardown.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { resolve } from 'node:path'; - -const packageRoot = resolve(fileURLToPath(new URL('.', import.meta.url))); -const stateFile = resolve(packageRoot, '.playwright-server.json'); - -function processExists(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -export default async function globalTeardown() { - if (!existsSync(stateFile)) { - return; - } - - let state = JSON.parse(readFileSync(stateFile, 'utf8')) as { - pid?: number; - reusedExistingServer?: boolean; - }; - - if (!state.reusedExistingServer && state.pid) { - try { - process.kill(-state.pid, 'SIGTERM'); - } catch { - // best effort cleanup - } - - let startedAt = Date.now(); - while (processExists(state.pid) && Date.now() - startedAt < 10_000) { - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - if (processExists(state.pid)) { - try { - process.kill(-state.pid, 'SIGKILL'); - } catch { - // best effort cleanup - } - } - } - - rmSync(stateFile, { force: true }); -} diff --git a/packages/software-factory/src/cli/cache-realm.ts b/packages/software-factory/src/cli/cache-realm.ts index c872cd6815..d9b857fa66 100644 --- a/packages/software-factory/src/cli/cache-realm.ts +++ b/packages/software-factory/src/cli/cache-realm.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { ensureFactoryRealmTemplate } from '../harness.ts'; @@ -6,19 +7,20 @@ import { ensureFactoryRealmTemplate } from '../harness.ts'; let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); try { let template = await ensureFactoryRealmTemplate({ realmDir }); - console.log( - JSON.stringify( - { - realmDir, - cacheKey: template.cacheKey, - templateDatabaseName: template.templateDatabaseName, - fixtureHash: template.fixtureHash, - cacheHit: template.cacheHit, - }, - null, - 2, - ), - ); + let payload = { + realmDir, + cacheKey: template.cacheKey, + templateDatabaseName: template.templateDatabaseName, + fixtureHash: template.fixtureHash, + cacheHit: template.cacheHit, + }; + if (process.env.SOFTWARE_FACTORY_METADATA_FILE) { + writeFileSync( + process.env.SOFTWARE_FACTORY_METADATA_FILE, + JSON.stringify(payload, null, 2), + ); + } + console.log(JSON.stringify(payload, null, 2)); process.exit(0); } catch (error) { console.error(error); diff --git a/packages/software-factory/src/cli/serve-realm.ts b/packages/software-factory/src/cli/serve-realm.ts index a96ae0b69f..a861dec1a7 100644 --- a/packages/software-factory/src/cli/serve-realm.ts +++ b/packages/software-factory/src/cli/serve-realm.ts @@ -1,23 +1,32 @@ // @ts-nocheck +import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { startFactoryRealmServer } from '../harness.ts'; let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); try { - let runtime = await startFactoryRealmServer({ realmDir }); + let runtime = await startFactoryRealmServer({ + realmDir, + templateDatabaseName: process.env.SOFTWARE_FACTORY_TEMPLATE_DATABASE_NAME, + }); - console.log( - JSON.stringify( - { - realmDir, - realmURL: runtime.realmURL.href, - sampleCardURL: runtime.cardURL('person-1'), - }, - null, - 2, - ), - ); + let payload = { + realmDir, + realmURL: runtime.realmURL.href, + databaseName: runtime.databaseName, + sampleCardURL: runtime.cardURL('person-1'), + ownerBearerToken: runtime.createBearerToken(), + }; + + if (process.env.SOFTWARE_FACTORY_METADATA_FILE) { + writeFileSync( + process.env.SOFTWARE_FACTORY_METADATA_FILE, + JSON.stringify(payload, null, 2), + ); + } + + console.log(JSON.stringify(payload, null, 2)); let stop = async () => { await runtime.stop(); diff --git a/packages/software-factory/src/harness.ts b/packages/software-factory/src/harness.ts index 5739c86e95..850e00a01f 100644 --- a/packages/software-factory/src/harness.ts +++ b/packages/software-factory/src/harness.ts @@ -109,6 +109,7 @@ export interface FactoryRealmOptions { permissions?: RealmPermissions; useCache?: boolean; cacheSalt?: string; + templateDatabaseName?: string; } export interface FactoryRealmTemplate { @@ -121,7 +122,13 @@ export interface FactoryRealmTemplate { export interface StartedFactoryRealm { realmDir: string; realmURL: URL; + databaseName: string; cardURL(path: string): string; + createBearerToken(user?: string, permissions?: string[]): string; + authorizationHeaders( + user?: string, + permissions?: string[], + ): Record; stop(): Promise; } @@ -730,6 +737,24 @@ async function buildStartedRealm( await testRealmServer.start(); return { + createBearerToken: ( + user = DEFAULT_REALM_OWNER, + permissions = DEFAULT_PERMISSIONS[DEFAULT_REALM_OWNER] ?? [ + 'read', + 'write', + 'realm-owner', + ], + ) => + realm.createJWT( + { + user, + realm: realm.url, + permissions, + sessionRoom: `software-factory-session-room-for-${user}`, + realmServerURL: options.realmURL.href, + }, + '7d', + ), stop: async ({ preserveDatabase = false, }: { preserveDatabase?: boolean } = {}) => { @@ -871,7 +896,9 @@ async function startFactoryRealmServer( await ensureFactoryPrerequisites(); let templateDatabase: string | undefined; - if (options.useCache !== false) { + if (options.templateDatabaseName) { + templateDatabase = options.templateDatabaseName; + } else if (options.useCache !== false) { templateDatabase = ( await ensureFactoryRealmTemplate({ realmDir, @@ -893,9 +920,16 @@ async function startFactoryRealmServer( return { realmDir, realmURL, + databaseName, cardURL(path: string) { return new URL(path, realmURL).href; }, + createBearerToken: runtime.createBearerToken, + authorizationHeaders(user?: string, permissions?: string[]) { + return { + Authorization: `Bearer ${runtime.createBearerToken(user, permissions)}`, + }; + }, stop: runtime.stop, }; } diff --git a/packages/software-factory/tests/demo-realm.spec.ts b/packages/software-factory/tests/demo-realm.spec.ts index 6e9e740cbd..fc0fc146e6 100644 --- a/packages/software-factory/tests/demo-realm.spec.ts +++ b/packages/software-factory/tests/demo-realm.spec.ts @@ -13,3 +13,81 @@ test('renders a local card instance through the cached realm server', async ({ ).toBeVisible(); await expect(authedPage.getByText('Mango').first()).toBeVisible(); }); + +test('opens another local card instance in the shared browser context', async ({ + authedPage, + cardURL, +}) => { + await authedPage.goto(cardURL('person-2'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'Papaya' }), + ).toBeVisible(); +}); + +test('allows one test to mutate data inside its fresh realm runtime', async ({ + authedPage, + realm, + cardURL, +}) => { + let response = await fetch(cardURL('person-1'), { + method: 'PATCH', + headers: { + Accept: 'application/vnd.card+json', + 'Content-Type': 'application/vnd.card+json', + ...realm.authorizationHeaders(), + }, + body: JSON.stringify({ + data: { + type: 'card', + attributes: { + firstName: 'Dragonfruit', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }), + }); + + expect(response.ok).toBeTruthy(); + + await authedPage.goto(cardURL('person-1'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'Dragonfruit' }), + ).toBeVisible(); +}); + +test('restores the cached template data for the next test run', async ({ + authedPage, + realm, + cardURL, +}) => { + let response = await fetch(cardURL('person-1'), { + headers: { + Accept: 'application/vnd.card+json', + ...realm.authorizationHeaders(), + 'Cache-Control': 'no-cache, no-store, max-age=0', + Pragma: 'no-cache', + }, + }); + let json = await response.json(); + + expect(json.data.attributes.firstName).toBe('Mango'); + + await authedPage.goto(cardURL('person-1'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'Mango' }), + ).toBeVisible(); +}); diff --git a/packages/software-factory/tests/fixtures.ts b/packages/software-factory/tests/fixtures.ts index e6e2801554..7b2f05f0b0 100644 --- a/packages/software-factory/tests/fixtures.ts +++ b/packages/software-factory/tests/fixtures.ts @@ -1,28 +1,321 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import type { BrowserContext, Page } from '@playwright/test'; import { test as base, expect } from '@playwright/test'; -import { seedBrowserSession } from './helpers/browser-auth'; + +import { buildBrowserState, installBrowserState } from './helpers/browser-auth'; + +type PreparedRealmTemplate = { + realmDir: string; + cacheKey: string; + templateDatabaseName: string; + fixtureHash: string; + cacheHit: boolean; +}; + +type StartedFactoryRealm = { + realmDir: string; + realmURL: URL; + ownerBearerToken: string; + cardURL(path: string): string; + authorizationHeaders(): Record; + stop(): Promise; +}; export type FactoryRealmFixtures = { + realm: StartedFactoryRealm; realmURL: URL; cardURL: (path: string) => string; - authedPage: import('@playwright/test').Page; + authedPage: Page; }; +type FactoryRealmWorkerFixtures = { + preparedRealmTemplate: PreparedRealmTemplate; + cachedContext: BrowserContext; +}; + +const packageRoot = resolve(process.cwd()); const defaultRealmURL = new URL( process.env.SOFTWARE_FACTORY_REALM_URL ?? 'http://127.0.0.1:4444/', ); +const defaultRealmDir = resolve( + packageRoot, + process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', +); + +type ChildResult = { + metadata: T; + logs: string; +}; + +function appendLog(buffer: string, chunk: string): string { + let combined = `${buffer}${chunk}`; + return combined.length > 20_000 ? combined.slice(-20_000) : combined; +} + +function killProcessGroup(pid: number, signal: NodeJS.Signals) { + try { + process.kill(-pid, signal); + } catch (error) { + let nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== 'ESRCH') { + throw error; + } + } +} + +async function waitForMetadataFile( + metadataFile: string, + child: ReturnType, + getLogs: () => string, + timeoutMs = 120_000, +): Promise { + let startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (existsSync(metadataFile)) { + return JSON.parse(readFileSync(metadataFile, 'utf8')) as T; + } + + if (child.exitCode !== null) { + throw new Error( + `software-factory child exited early with code ${child.exitCode}\n${getLogs()}`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error( + `timed out waiting for software-factory metadata file ${metadataFile}\n${getLogs()}`, + ); +} + +async function runCachePrepare(realmDir = defaultRealmDir) { + let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-cache-')); + let metadataFile = join(tempDir, 'template.json'); + let logs = ''; + + try { + let child = spawn('pnpm', ['cache:prepare', realmDir], { + cwd: packageRoot, + env: { + ...process.env, + SOFTWARE_FACTORY_METADATA_FILE: metadataFile, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + child.stderr?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + + let metadata = await waitForMetadataFile( + metadataFile, + child, + () => logs, + ); + + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `cache:prepare exited with code ${code ?? 'unknown'}\n${logs}`, + ), + ); + } + }); + }); + + return { + metadata, + logs, + } satisfies ChildResult; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function startRealmProcess( + templateDatabaseName: string, + realmDir = defaultRealmDir, +) { + let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-realm-')); + let metadataFile = join(tempDir, 'runtime.json'); + let logs = ''; + + let child = spawn('pnpm', ['serve:realm', realmDir], { + cwd: packageRoot, + detached: true, + env: { + ...process.env, + SOFTWARE_FACTORY_METADATA_FILE: metadataFile, + SOFTWARE_FACTORY_TEMPLATE_DATABASE_NAME: templateDatabaseName, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + child.stderr?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + + let metadata: { + realmDir: string; + realmURL: string; + sampleCardURL: string; + ownerBearerToken: string; + }; + + try { + metadata = await waitForMetadataFile<{ + realmDir: string; + realmURL: string; + sampleCardURL: string; + ownerBearerToken: string; + }>(metadataFile, child, () => logs); + } catch (error) { + killProcessGroup(child.pid!, 'SIGTERM'); + throw error; + } + + let stop = async () => { + try { + if (child.exitCode === null) { + killProcessGroup(child.pid!, 'SIGTERM'); + await new Promise((resolve, reject) => { + let timeout = setTimeout(() => { + killProcessGroup(child.pid!, 'SIGKILL'); + }, 15_000); + + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }; + + return { + realmDir: metadata.realmDir, + realmURL: new URL(metadata.realmURL), + ownerBearerToken: metadata.ownerBearerToken, + cardURL(path: string) { + return new URL(path, metadata.realmURL).href; + }, + authorizationHeaders() { + return { + Authorization: `Bearer ${metadata.ownerBearerToken}`, + }; + }, + stop, + } satisfies StartedFactoryRealm; +} + +export const test = base.extend< + FactoryRealmFixtures, + FactoryRealmWorkerFixtures +>({ + preparedRealmTemplate: [ + async ({ browserName: _browserName }, use) => { + let { metadata } = await runCachePrepare(); + await use(metadata); + }, + { scope: 'worker' }, + ], + + cachedContext: [ + async ({ browser, preparedRealmTemplate }, use) => { + let bootstrapRealm = await startRealmProcess( + preparedRealmTemplate.templateDatabaseName, + ); + let context = await browser.newContext({ + baseURL: defaultRealmURL.href, + }); + + try { + let browserState = await buildBrowserState( + bootstrapRealm.realmURL.href, + ); + await installBrowserState(context, browserState); + + // Warm the app shell once so later test pages can reuse browser cache. + let warmPage = await context.newPage(); + try { + await warmPage.goto(defaultRealmURL.href, { + waitUntil: 'domcontentloaded', + }); + } finally { + await warmPage.close(); + } + } finally { + await bootstrapRealm.stop(); + } + + try { + await use(context); + } finally { + await context.close(); + } + }, + { scope: 'worker' }, + ], + + realm: async ({ preparedRealmTemplate }, use) => { + let realm = await startRealmProcess( + preparedRealmTemplate.templateDatabaseName, + ); + try { + await use(realm); + } finally { + await realm.stop(); + } + }, -export const test = base.extend({ - realmURL: async ({ baseURL: _baseURL }, use) => { - await use(new URL(defaultRealmURL.href)); + realmURL: async ({ realm }, use) => { + await use(new URL(realm.realmURL.href)); }, - cardURL: async ({ realmURL }, use) => { - await use((path: string) => new URL(path, realmURL).href); + cardURL: async ({ realm }, use) => { + await use((path: string) => realm.cardURL(path)); }, - authedPage: async ({ page, realmURL }, use) => { - await seedBrowserSession(page, realmURL.href); - await use(page); + authedPage: async ({ cachedContext, realm: _realm }, use) => { + await cachedContext.clearCookies(); + let page = await cachedContext.newPage(); + try { + await page.route(`${_realm.realmURL.origin}/**/*`, async (route) => { + await route.continue({ + headers: { + ...route.request().headers(), + 'cache-control': 'no-cache, no-store, max-age=0', + pragma: 'no-cache', + }, + }); + }); + await use(page); + } finally { + await page.close(); + } }, }); diff --git a/packages/software-factory/tests/helpers/browser-auth.ts b/packages/software-factory/tests/helpers/browser-auth.ts index ca8a581fc8..9737507e46 100644 --- a/packages/software-factory/tests/helpers/browser-auth.ts +++ b/packages/software-factory/tests/helpers/browser-auth.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import type { Page } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; type BrowserAuth = { access_token: string; @@ -9,6 +9,10 @@ type BrowserAuth = { }; type BrowserSession = Record; +export type FactoryBrowserState = { + auth: BrowserAuth; + boxelSession: BrowserSession; +}; type BoxelProfile = { username: string; @@ -175,7 +179,7 @@ async function getRealmAuthTokens( export async function buildBrowserState( realmURL: string, -): Promise<{ auth: BrowserAuth; boxelSession: BrowserSession }> { +): Promise { let matrixAuth = await matrixLogin(); let realmTokens = await getRealmAuthTokens(matrixAuth, realmURL); @@ -193,9 +197,16 @@ export async function buildBrowserState( }; } -export async function seedBrowserSession(page: Page, realmURL: string) { - let state = await buildBrowserState(realmURL); - await page.addInitScript((payload) => { +type InitScriptTarget = + | Pick + | Pick; + +export async function installBrowserState( + target: InitScriptTarget, + state: FactoryBrowserState, +) { + await target.addInitScript((payload) => { + window.localStorage.clear(); window.localStorage.setItem('auth', JSON.stringify(payload.auth)); window.localStorage.setItem( 'boxel-session', @@ -203,3 +214,8 @@ export async function seedBrowserSession(page: Page, realmURL: string) { ); }, state); } + +export async function seedBrowserSession(page: Page, realmURL: string) { + let state = await buildBrowserState(realmURL); + await installBrowserState(page, state); +} From f8ede0f5e593daa1b44e624bf5733a4864225996 Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Fri, 13 Mar 2026 10:38:15 +0000 Subject: [PATCH 05/23] Share prerender server across software factory tests --- packages/software-factory/README.md | 2 + packages/software-factory/package.json | 1 + .../src/cli/serve-prerender.ts | 82 +++++++++++++ packages/software-factory/src/harness.ts | 32 ++++- packages/software-factory/tests/fixtures.ts | 109 ++++++++++++++++-- 5 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 packages/software-factory/src/cli/serve-prerender.ts diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index b457fc1345..5f96611600 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -49,6 +49,8 @@ pnpm smoke:realm ./my-realm Person/example-card codebase stays stable. - Playwright uses a single worker-scoped browser context so host assets and app shell requests stay warm in the browser cache across tests. +- The Playwright worker also keeps a shared prerender server alive so per-test + realm restarts do not relaunch Chromium for realm-side prerender work. - Each Playwright test still starts a fresh realm server and fresh runtime database cloned from the cached template DB, so server-side mutations do not leak across tests. diff --git a/packages/software-factory/package.json b/packages/software-factory/package.json index 8415fe40b8..24f1d8d993 100644 --- a/packages/software-factory/package.json +++ b/packages/software-factory/package.json @@ -14,6 +14,7 @@ "lint:format": "prettier --check .", "lint:format:fix": "prettier --write .", "lint:types": "tsc --noEmit src/index.ts", + "serve:prerender": "ts-node --transpileOnly --esm src/cli/serve-prerender.ts", "serve:realm": "ts-node --transpileOnly --esm src/cli/serve-realm.ts", "smoke:realm": "ts-node --transpileOnly --esm src/cli/smoke-realm.ts", "test:playwright": "playwright test", diff --git a/packages/software-factory/src/cli/serve-prerender.ts b/packages/software-factory/src/cli/serve-prerender.ts new file mode 100644 index 0000000000..266204fc1d --- /dev/null +++ b/packages/software-factory/src/cli/serve-prerender.ts @@ -0,0 +1,82 @@ +// @ts-nocheck +import { writeFileSync } from 'node:fs'; +import { createServer as createNetServer } from 'node:net'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +require('../../../realm-server/setup-logger.ts'); +const { + createPrerenderHttpServer, +} = require('../../../realm-server/prerender/prerender-app.ts'); + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + let server = createNetServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + let address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('unable to determine free port'))); + return; + } + let { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +async function closeServer(server: { + close(cb: (error?: Error) => void): void; +}) { + await new Promise((resolve, reject) => + server.close((error?: Error) => (error ? reject(error) : resolve())), + ); +} + +try { + let port = await getFreePort(); + let server = createPrerenderHttpServer({ + silent: Boolean(process.env.SILENT_PRERENDERER), + maxPages: Number(process.env.SOFTWARE_FACTORY_PRERENDER_MAX_PAGES ?? 2), + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve()); + }); + + let payload = { + url: `http://127.0.0.1:${port}`, + }; + + if (process.env.SOFTWARE_FACTORY_METADATA_FILE) { + writeFileSync( + process.env.SOFTWARE_FACTORY_METADATA_FILE, + JSON.stringify(payload, null, 2), + ); + } + + console.log(JSON.stringify(payload, null, 2)); + + let stop = async () => { + if (typeof server.__stopPrerenderer === 'function') { + await server.__stopPrerenderer(); + } + if (server.listening) { + await closeServer(server); + } + process.exit(0); + }; + + process.on('SIGINT', () => void stop()); + process.on('SIGTERM', () => void stop()); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/software-factory/src/harness.ts b/packages/software-factory/src/harness.ts index 850e00a01f..6995457009 100644 --- a/packages/software-factory/src/harness.ts +++ b/packages/software-factory/src/harness.ts @@ -665,6 +665,7 @@ async function buildStartedRealm( let runner: QueueRunner | undefined; let prerenderer: any; let prerenderServer: any; + let managedPrerenderServer = false; let testRealmServer: RealmServer | undefined; let httpServer; @@ -674,7 +675,11 @@ async function buildStartedRealm( adapter: dbAdapter, workerId: `software-factory-${process.pid}`, }); - ({ prerenderer, server: prerenderServer } = await startPrerenderServer()); + ({ + prerenderer, + server: prerenderServer, + managed: managedPrerenderServer, + } = await startPrerenderServer()); let virtualNetwork = createVirtualNetwork(); let definitionLookup = new CachingDefinitionLookup( dbAdapter, @@ -788,6 +793,7 @@ async function buildStartedRealm( try { if ( + managedPrerenderServer && prerenderServer && typeof prerenderServer.__stopPrerenderer === 'function' ) { @@ -798,7 +804,7 @@ async function buildStartedRealm( } try { - if (prerenderServer?.listening) { + if (managedPrerenderServer && prerenderServer?.listening) { await closeServer(prerenderServer); } } catch (error) { @@ -806,7 +812,9 @@ async function buildStartedRealm( } try { - await (prerenderer as { stop?: () => Promise })?.stop?.(); + if (managedPrerenderServer) { + await (prerenderer as { stop?: () => Promise })?.stop?.(); + } } catch (error) { cleanupError ??= error; } @@ -848,6 +856,7 @@ async function buildStartedRealm( } try { if ( + managedPrerenderServer && prerenderServer && typeof prerenderServer.__stopPrerenderer === 'function' ) { @@ -857,14 +866,16 @@ async function buildStartedRealm( // best effort cleanup } try { - if (prerenderServer?.listening) { + if (managedPrerenderServer && prerenderServer?.listening) { await closeServer(prerenderServer); } } catch { // best effort cleanup } try { - await (prerenderer as { stop?: () => Promise })?.stop?.(); + if (managedPrerenderServer) { + await (prerenderer as { stop?: () => Promise })?.stop?.(); + } } catch { // best effort cleanup } @@ -982,7 +993,17 @@ async function getFreePort(): Promise { async function startPrerenderServer(): Promise<{ prerenderer: any; server: any; + managed: boolean; }> { + if (process.env.SOFTWARE_FACTORY_PRERENDER_SERVER_URL) { + return { + prerenderer: createRemotePrerenderer( + process.env.SOFTWARE_FACTORY_PRERENDER_SERVER_URL, + ), + server: undefined, + managed: false, + }; + } let port = await getFreePort(); let server = createPrerenderHttpServer({ silent: Boolean(process.env.SILENT_PRERENDERER), @@ -997,5 +1018,6 @@ async function startPrerenderServer(): Promise<{ return { prerenderer: createRemotePrerenderer(`http://127.0.0.1:${port}`), server, + managed: true, }; } diff --git a/packages/software-factory/tests/fixtures.ts b/packages/software-factory/tests/fixtures.ts index 7b2f05f0b0..549cd88249 100644 --- a/packages/software-factory/tests/fixtures.ts +++ b/packages/software-factory/tests/fixtures.ts @@ -25,6 +25,11 @@ type StartedFactoryRealm = { stop(): Promise; }; +type SharedPrerenderProcess = { + url: string; + stop(): Promise; +}; + export type FactoryRealmFixtures = { realm: StartedFactoryRealm; realmURL: URL; @@ -34,6 +39,7 @@ export type FactoryRealmFixtures = { type FactoryRealmWorkerFixtures = { preparedRealmTemplate: PreparedRealmTemplate; + sharedPrerender: SharedPrerenderProcess; cachedContext: BrowserContext; }; @@ -116,13 +122,7 @@ async function runCachePrepare(realmDir = defaultRealmDir) { logs = appendLog(logs, String(chunk)); }); - let metadata = await waitForMetadataFile( - metadataFile, - child, - () => logs, - ); - - await new Promise((resolve, reject) => { + let exitPromise = new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (code) => { if (code === 0) { @@ -137,6 +137,14 @@ async function runCachePrepare(realmDir = defaultRealmDir) { }); }); + let metadata = await waitForMetadataFile( + metadataFile, + child, + () => logs, + ); + + await exitPromise; + return { metadata, logs, @@ -148,6 +156,7 @@ async function runCachePrepare(realmDir = defaultRealmDir) { async function startRealmProcess( templateDatabaseName: string, + prerenderServerURL: string, realmDir = defaultRealmDir, ) { let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-realm-')); @@ -160,6 +169,7 @@ async function startRealmProcess( env: { ...process.env, SOFTWARE_FACTORY_METADATA_FILE: metadataFile, + SOFTWARE_FACTORY_PRERENDER_SERVER_URL: prerenderServerURL, SOFTWARE_FACTORY_TEMPLATE_DATABASE_NAME: templateDatabaseName, }, stdio: ['ignore', 'pipe', 'pipe'], @@ -231,6 +241,73 @@ async function startRealmProcess( } satisfies StartedFactoryRealm; } +async function startPrerenderProcess() { + let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-prerender-')); + let metadataFile = join(tempDir, 'prerender.json'); + let logs = ''; + + let child = spawn('pnpm', ['serve:prerender'], { + cwd: packageRoot, + detached: true, + env: { + ...process.env, + SOFTWARE_FACTORY_METADATA_FILE: metadataFile, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + child.stderr?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + + let metadata: { + url: string; + }; + + try { + metadata = await waitForMetadataFile<{ url: string }>( + metadataFile, + child, + () => logs, + ); + } catch (error) { + killProcessGroup(child.pid!, 'SIGTERM'); + throw error; + } + + let stop = async () => { + try { + if (child.exitCode === null) { + killProcessGroup(child.pid!, 'SIGTERM'); + await new Promise((resolve, reject) => { + let timeout = setTimeout(() => { + killProcessGroup(child.pid!, 'SIGKILL'); + }, 15_000); + + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }; + + return { + url: metadata.url, + stop, + } satisfies SharedPrerenderProcess; +} + export const test = base.extend< FactoryRealmFixtures, FactoryRealmWorkerFixtures @@ -243,10 +320,23 @@ export const test = base.extend< { scope: 'worker' }, ], + sharedPrerender: [ + async ({ browserName: _browserName }, use) => { + let prerender = await startPrerenderProcess(); + try { + await use(prerender); + } finally { + await prerender.stop(); + } + }, + { scope: 'worker' }, + ], + cachedContext: [ - async ({ browser, preparedRealmTemplate }, use) => { + async ({ browser, preparedRealmTemplate, sharedPrerender }, use) => { let bootstrapRealm = await startRealmProcess( preparedRealmTemplate.templateDatabaseName, + sharedPrerender.url, ); let context = await browser.newContext({ baseURL: defaultRealmURL.href, @@ -280,9 +370,10 @@ export const test = base.extend< { scope: 'worker' }, ], - realm: async ({ preparedRealmTemplate }, use) => { + realm: async ({ preparedRealmTemplate, sharedPrerender }, use) => { let realm = await startRealmProcess( preparedRealmTemplate.templateDatabaseName, + sharedPrerender.url, ); try { await use(realm); From 28b5da5bf2108a8f0a3089e78e92d0c12f4b0572 Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Fri, 13 Mar 2026 13:16:36 +0000 Subject: [PATCH 06/23] Simplify software factory realm harness --- packages/matrix/docker/index.ts | 10 +- packages/matrix/docker/synapse/index.ts | 25 +- packages/software-factory/README.md | 41 +- packages/software-factory/package.json | 15 +- .../software-factory/playwright.config.ts | 5 +- .../playwright.global-setup.ts | 2 +- packages/software-factory/src/harness.ts | 1744 +++++++++-------- packages/software-factory/src/index.ts | 6 +- packages/software-factory/tests/fixtures.ts | 270 +-- .../tests/helpers/browser-auth.ts | 3 +- pnpm-lock.yaml | 13 +- pnpm-workspace.yaml | 186 +- 12 files changed, 1165 insertions(+), 1155 deletions(-) diff --git a/packages/matrix/docker/index.ts b/packages/matrix/docker/index.ts index 42a73d0fac..d6f3795b62 100644 --- a/packages/matrix/docker/index.ts +++ b/packages/matrix/docker/index.ts @@ -2,11 +2,7 @@ import * as os from 'os'; import * as childProcess from 'child_process'; import * as fse from 'fs-extra'; -function dockerPull( - image: string, - retries = 3, - delayMs = 5000, -): Promise { +function dockerPull(image: string, retries = 3, delayMs = 5000): Promise { return new Promise((resolve, reject) => { let attempt = 0; function tryPull() { @@ -151,8 +147,8 @@ export async function dockerLogs(args: { .once('close', resolve); }); - if (args.stdoutFile) await fse.close(stdoutFile); - if (args.stderrFile) await fse.close(stderrFile); + if (args.stdoutFile) await fse.close(stdoutFile as number); + if (args.stderrFile) await fse.close(stderrFile as number); } export function dockerStop(args: { containerId: string }): Promise { diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index 66f855d329..095de13bb6 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -129,7 +129,11 @@ export async function synapseStart( opts?.template ?? 'test', opts?.dataDir, ); - let containerName = opts?.containerName || (isEnvironmentMode() ? getSynapseContainerName() : path.basename(synCfg.configDir)); + let containerName = + opts?.containerName || + (isEnvironmentMode() + ? getSynapseContainerName() + : path.basename(synCfg.configDir)); console.log( `Starting synapse with config dir ${synCfg.configDir} in container ${containerName}...`, ); @@ -381,7 +385,13 @@ export interface UpdateUserOptions { export async function updateUser( adminAccessToken: string, userId: string, - { password, displayname, avatar_url, emailAddresses, matrixURL }: UpdateUserOptions, + { + password, + displayname, + avatar_url, + emailAddresses, + matrixURL, + }: UpdateUserOptions, ) { let url = matrixURL ? `${matrixURL}/_synapse/admin/v2/users/${userId}` @@ -506,14 +516,11 @@ export async function getRoomMembers(roomId: string, accessToken: string) { } export async function sync(accessToken: string) { - let response = await fetch( - `${getSynapseURL()}/_matrix/client/v3/sync`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + let response = await fetch(`${getSynapseURL()}/_matrix/client/v3/sync`, { + headers: { + Authorization: `Bearer ${accessToken}`, }, - ); + }); return await response.json(); } diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index 5f96611600..67447904d1 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -2,29 +2,31 @@ Local card-development harness for fast Boxel iteration. -This package gives you a cached local realm fixture, a realm server boot path -that mirrors the test harness, and a Playwright loop that exercises the card in -the real browser app shell. +This package gives you a cached local realm fixture, a fixed-port isolated realm +server, and a Playwright loop that exercises cards in the real browser app +shell. ## Prerequisites -- Docker running for the cached test Postgres on `127.0.0.1:55436` +- Docker running - Host app assets available at `http://localhost:4200/` -- Base realm available at `http://localhost:4201/base/` -- Matrix available at `http://localhost:8008/` + - use `cd packages/host && pnpm serve:dist` -Those are the same local services the realm-server tests expect. +The harness starts its own seeded test Postgres, Synapse, prerender server, and +isolated realm server. By default it serves the test realm and base realm from +the same fixed realm-server origin. The skills realm can be enabled when needed +with `SOFTWARE_FACTORY_INCLUDE_SKILLS=1`. ## Commands - `pnpm cache:prepare` - Builds or reuses the cached template database for `demo-realm/` - `pnpm serve:realm` - - Starts the realm server on `http://127.0.0.1:4444/` + - Starts the isolated realm server on `http://localhost:4205/test/` - `pnpm smoke:realm` - - Boots the realm server, fetches `person-1` as card JSON, and exits + - Boots the isolated realm server, fetches `person-1` as card JSON, and exits - `pnpm test:playwright` - - Runs the browser test against a fresh per-test realm server cloned from the cached template + - Runs the browser tests against a fresh per-test realm server cloned from the cached template All commands accept an optional realm directory argument: @@ -39,23 +41,18 @@ pnpm smoke:realm ./my-realm Person/example-card - `demo-realm/` - Example card definitions and instances - `src/harness.ts` - - Cached template DB creation and realm server startup + - Cached template DB creation and isolated realm server startup - `tests/` - Playwright fixtures and browser specs ## Notes -- Template DBs are intentionally reused across runs while the realm-server - codebase stays stable. -- Playwright uses a single worker-scoped browser context so host assets and app - shell requests stay warm in the browser cache across tests. -- The Playwright worker also keeps a shared prerender server alive so per-test - realm restarts do not relaunch Chromium for realm-side prerender work. +- Template DBs are reused across runs while the seeded Postgres container stays up. - Each Playwright test still starts a fresh realm server and fresh runtime database cloned from the cached template DB, so server-side mutations do not leak across tests. -- Realm-origin requests are forced to revalidate between tests. That preserves - host asset caching without letting mutated card responses leak into the next - fresh realm runtime. -- The browser test seeds a deterministic local Matrix user - (`software-factory-browser`) so it does not depend on a human-managed profile. +- The browser tests seed a deterministic local Matrix user + (`software-factory-browser`) so they do not depend on a human-managed profile. +- Host requests for the base realm URL are redirected to the isolated realm + server. Skills redirects are only enabled when + `SOFTWARE_FACTORY_INCLUDE_SKILLS=1`. diff --git a/packages/software-factory/package.json b/packages/software-factory/package.json index 24f1d8d993..b695521611 100644 --- a/packages/software-factory/package.json +++ b/packages/software-factory/package.json @@ -6,7 +6,7 @@ "license": "MIT", "description": "Software Factory workspace package", "scripts": { - "cache:prepare": "ts-node --transpileOnly --esm src/cli/cache-realm.ts", + "cache:prepare": "tsx src/cli/cache-realm.ts", "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"", "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\"", "lint:js": "eslint . --report-unused-disable-directives --cache", @@ -14,18 +14,19 @@ "lint:format": "prettier --check .", "lint:format:fix": "prettier --write .", "lint:types": "tsc --noEmit src/index.ts", - "serve:prerender": "ts-node --transpileOnly --esm src/cli/serve-prerender.ts", - "serve:realm": "ts-node --transpileOnly --esm src/cli/serve-realm.ts", - "smoke:realm": "ts-node --transpileOnly --esm src/cli/smoke-realm.ts", + "serve:prerender": "tsx src/cli/serve-prerender.ts", + "serve:realm": "tsx src/cli/serve-realm.ts", + "smoke:realm": "tsx src/cli/smoke-realm.ts", "test:playwright": "playwright test", "test:playwright:headed": "playwright test --headed" }, "devDependencies": { - "@playwright/test": "catalog:", + "@cardstack/local-types": "workspace:*", "@cardstack/postgres": "workspace:*", "@cardstack/runtime-common": "workspace:*", - "@cardstack/local-types": "workspace:*", + "@playwright/test": "catalog:", "@types/fs-extra": "catalog:", + "@types/jsonwebtoken": "catalog:", "@types/node": "catalog:", "@types/pg": "catalog:", "@types/tmp": "catalog:", @@ -33,9 +34,11 @@ "content-tag": "catalog:", "decorator-transforms": "catalog:", "fs-extra": "catalog:", + "jsonwebtoken": "catalog:", "pg": "catalog:", "tmp": "catalog:", "ts-node": "^10.9.1", + "tsx": "^4.21.0", "typescript": "catalog:" }, "volta": { diff --git a/packages/software-factory/playwright.config.ts b/packages/software-factory/playwright.config.ts index 90828a72f2..0b755adbea 100644 --- a/packages/software-factory/playwright.config.ts +++ b/packages/software-factory/playwright.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from '@playwright/test'; -const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4444); +const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4205); const realmURL = - process.env.SOFTWARE_FACTORY_REALM_URL ?? `http://127.0.0.1:${realmPort}/`; + process.env.SOFTWARE_FACTORY_REALM_URL ?? + `http://localhost:${realmPort}/test/`; export default defineConfig({ testDir: './tests', diff --git a/packages/software-factory/playwright.global-setup.ts b/packages/software-factory/playwright.global-setup.ts index bfa430c744..38e52b705c 100644 --- a/packages/software-factory/playwright.global-setup.ts +++ b/packages/software-factory/playwright.global-setup.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; const packageRoot = resolve(fileURLToPath(new URL('.', import.meta.url))); const realmDir = resolve( diff --git a/packages/software-factory/src/harness.ts b/packages/software-factory/src/harness.ts index 6995457009..eea770a1c3 100644 --- a/packages/software-factory/src/harness.ts +++ b/packages/software-factory/src/harness.ts @@ -1,107 +1,33 @@ -// @ts-nocheck -import { spawn } from 'node:child_process'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { createServer as createNetServer } from 'node:net'; import { - mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, } from 'node:fs'; -import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join, relative, resolve } from 'node:path'; +import fsExtra from 'fs-extra'; +import jwt from 'jsonwebtoken'; import { Client as PgClient } from 'pg'; +import type { RealmAction } from '../../runtime-common/index.ts'; -const require = createRequire(import.meta.url); -require('decorator-transforms/globals'); -const ContentTagGlobal = require('content-tag'); -if (!(globalThis as any).ContentTagGlobal) { - (globalThis as any).ContentTagGlobal = ContentTagGlobal; -} -if (!(globalThis as any).__environment) { - (globalThis as any).__environment = 'test'; -} -const { - PgQueuePublisher, - PgQueueRunner, -} = require('../../postgres/pg-queue.ts'); -const { - CachingDefinitionLookup, -} = require('../../runtime-common/definition-lookup.ts'); -const { IndexWriter } = require('../../runtime-common/index-writer.ts'); -const { Worker } = require('../../runtime-common/worker.ts'); -const { - MatrixClient, - passwordFromSeed, -} = require('../../runtime-common/matrix-client.ts'); -const { RealmServer } = require('../../realm-server/server.ts'); -const { registerUser } = require('../../realm-server/synapse.ts'); -const { - createRemotePrerenderer, -} = require('../../realm-server/prerender/remote-prerenderer.ts'); -const { - createPrerenderHttpServer, -} = require('../../realm-server/prerender/prerender-app.ts'); -const { - closeServer, - createRealm, - createTestPgAdapter, - createVirtualNetwork, - getIndexHTML, - grafanaSecret, - matrixRegistrationSecret, - matrixURL, - realmSecretSeed, - realmServerSecretSeed, - testCreatePrerenderAuth, - waitUntil, -} = require('../../realm-server/tests/helpers/index.ts'); - -type LooseSingleCardDocument = any; -type QueuePublisher = any; -type QueueRunner = any; -type RealmPermissions = Record; - -const DEFAULT_REALM_PORT = Number( - process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4444, -); -const DEFAULT_REALM_URL = new URL( - process.env.SOFTWARE_FACTORY_REALM_URL ?? - `http://127.0.0.1:${DEFAULT_REALM_PORT}/`, -); -const DEFAULT_REALM_DIR = resolve( - process.cwd(), - process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', -); -const DEFAULT_REALM_OWNER = '@software-factory-owner:localhost'; -const DEFAULT_HOST_URL = process.env.HOST_URL ?? 'http://localhost:4200/'; -const DEFAULT_BASE_REALM_URL = - process.env.SOFTWARE_FACTORY_BASE_REALM_URL ?? 'http://localhost:4201/base/'; -const DEFAULT_MATRIX_URL = new URL(process.env.MATRIX_URL ?? matrixURL.href); -const DEFAULT_MATRIX_USERNAME = - process.env.SOFTWARE_FACTORY_MATRIX_USERNAME ?? 'software-factory-backend'; -const DEFAULT_MATRIX_SERVER_USERNAME = - process.env.SOFTWARE_FACTORY_MATRIX_SERVER_USERNAME ?? - 'software-factory-realm-server'; -const DEFAULT_MATRIX_BROWSER_USERNAME = - process.env.SOFTWARE_FACTORY_BROWSER_MATRIX_USERNAME ?? - 'software-factory-browser'; -const DEFAULT_PERMISSIONS: RealmPermissions = { - '*': ['read'], - [DEFAULT_REALM_OWNER]: ['read', 'write', 'realm-owner'], +type RealmPermissions = Record; + +type FactorySupportContext = { + matrixURL: string; + matrixRegistrationSecret: string; + prerenderURL: string; }; -const TEST_PG_PORT = process.env.PGPORT ?? '55436'; -const TEST_PG_HOST = process.env.PGHOST ?? '127.0.0.1'; -const TEST_PG_USER = process.env.PGUSER ?? 'postgres'; -const CACHE_VERSION = 1; -let prepareTestPgPromise: Promise | undefined; -let ensureMatrixUsersPromise: Promise | undefined; -let ensurePrerequisitesPromise: Promise | undefined; +type SynapseInstance = { + synapseId: string; + port: number; + registrationSecret: string; +}; export interface FactoryRealmOptions { realmDir?: string; @@ -110,6 +36,7 @@ export interface FactoryRealmOptions { useCache?: boolean; cacheSalt?: string; templateDatabaseName?: string; + context?: FactoryTestContext | FactorySupportContext; } export interface FactoryRealmTemplate { @@ -119,243 +46,100 @@ export interface FactoryRealmTemplate { cacheHit: boolean; } +export interface FactoryTestContext extends FactorySupportContext { + cacheKey: string; + fixtureHash: string; + realmDir: string; + realmURL: string; + templateDatabaseName: string; +} + export interface StartedFactoryRealm { realmDir: string; realmURL: URL; databaseName: string; cardURL(path: string): string; - createBearerToken(user?: string, permissions?: string[]): string; + createBearerToken(user?: string, permissions?: RealmAction[]): string; authorizationHeaders( user?: string, - permissions?: string[], + permissions?: RealmAction[], ): Record; stop(): Promise; } -function applyTestPgEnv() { - process.env.PGHOST = TEST_PG_HOST; - process.env.PGPORT = TEST_PG_PORT; - process.env.PGUSER = TEST_PG_USER; -} - -function pgAdminConnectionConfig(database = 'postgres') { - return { - host: TEST_PG_HOST, - port: Number(TEST_PG_PORT), - user: TEST_PG_USER, - password: process.env.PGPASSWORD || undefined, - database, - }; -} - -function quotePgIdentifier(identifier: string): string { - if (!/^[a-zA-Z0-9_]+$/.test(identifier)) { - throw new Error(`unsafe postgres identifier: ${identifier}`); - } - return `"${identifier}"`; -} - -async function runCommand(command: string, args: string[], cwd: string) { - await new Promise((resolve, reject) => { - let child = spawn(command, args, { - cwd, - stdio: 'inherit', - env: { - ...process.env, - PGHOST: TEST_PG_HOST, - PGPORT: TEST_PG_PORT, - PGUSER: TEST_PG_USER, - }, - }); - child.on('error', reject); - child.on('exit', (code) => { - if (code === 0) { - resolve(); - } else { - reject( - new Error(`command failed: ${command} ${args.join(' ')} (${code})`), - ); - } - }); - }); -} - -async function canConnectToTestPg(): Promise { - let client = new PgClient({ - ...pgAdminConnectionConfig(), - connectionTimeoutMillis: 1000, - }); - try { - await client.connect(); - await client.query('SELECT 1'); - return true; - } catch { - return false; - } finally { - try { - await client.end(); - } catch { - // best effort cleanup - } - } -} - -async function ensureTestPgPrepared() { - applyTestPgEnv(); - if (!prepareTestPgPromise) { - prepareTestPgPromise = (async () => { - if (await canConnectToTestPg()) { - return; - } - let script = resolve( - process.cwd(), - '../realm-server/tests/scripts/prepare-test-pg.sh', - ); - await runCommand('bash', [script], process.cwd()); - })().catch((error) => { - prepareTestPgPromise = undefined; - throw error; - }); - } - await prepareTestPgPromise; -} - -async function ensureServiceReady( - name: string, - request: Promise, - url: string, -): Promise { - let response: Response; - try { - response = await request; - } catch (error) { - throw new Error( - `${name} is not reachable at ${url}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - if (!response.ok) { - throw new Error( - `${name} is not ready at ${url}: status ${response.status}`, - ); - } -} - -async function ensureFactoryPrerequisites(): Promise { - if (!ensurePrerequisitesPromise) { - ensurePrerequisitesPromise = (async () => { - await ensureServiceReady( - 'Host app', - fetch(DEFAULT_HOST_URL), - DEFAULT_HOST_URL, - ); - let baseInfoURL = new URL('_info', DEFAULT_BASE_REALM_URL).href; - await ensureServiceReady( - 'Base realm', - fetch(baseInfoURL, { - method: 'QUERY', - headers: { - Accept: 'application/vnd.api+json', - }, - }), - baseInfoURL, - ); - let matrixVersionsURL = new URL( - '_matrix/client/versions', - DEFAULT_MATRIX_URL, - ).href; - await ensureServiceReady( - 'Matrix server', - fetch(matrixVersionsURL), - matrixVersionsURL, - ); - })().catch((error) => { - ensurePrerequisitesPromise = undefined; - throw error; - }); - } - - await ensurePrerequisitesPromise; -} - -async function ensureFactoryMatrixUser(username: string): Promise { - let password = await passwordFromSeed(username, realmSecretSeed); - let loginResponse = await fetch( - new URL('_matrix/client/v3/login', DEFAULT_MATRIX_URL), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - identifier: { - type: 'm.id.user', - user: username, - }, - password, - type: 'm.login.password', - }), - }, - ); +type FactoryGlobalContextHandle = { + context: FactoryTestContext; + stop(): Promise; +}; - if (loginResponse.ok) { - return; - } +type SpawnedProcess = ChildProcess & { + send(message: string): boolean; +}; - if (loginResponse.status !== 403) { - throw new Error( - `Unable to probe matrix user ${username}: ${loginResponse.status} ${await loginResponse.text()}`, - ); - } +type RunningFactoryStack = { + realmServer: SpawnedProcess; + workerManager: SpawnedProcess; + rootDir: string; +}; - try { - await registerUser({ - matrixURL: DEFAULT_MATRIX_URL, - displayname: username, - username, - password, - registrationSecret: matrixRegistrationSecret, - }); - } catch (error) { - let message = String(error); - if ( - !message.includes('M_USER_IN_USE') && - !message.includes('User ID already taken') && - !message.includes('already taken') - ) { - throw error; - } - } +const packageRoot = resolve(process.cwd()); +const workspaceRoot = resolve(packageRoot, '..', '..'); +const realmServerDir = resolve(packageRoot, '..', 'realm-server'); +const baseRealmDir = resolve(packageRoot, '..', 'base'); +const skillsRealmDir = resolve(packageRoot, '..', 'skills-realm', 'contents'); +const boxelIconsDir = resolve(packageRoot, '..', 'boxel-icons'); +const prepareTestPgScript = resolve( + realmServerDir, + 'tests', + 'scripts', + 'prepare-test-pg.sh', +); - let registeredClient = new MatrixClient({ - matrixURL: DEFAULT_MATRIX_URL, - username, - seed: realmSecretSeed, - }); - await registeredClient.login(); -} +const CACHE_VERSION = 4; +const REALM_SERVER_PORT = Number( + process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4205, +); +const WORKER_MANAGER_PORT = Number( + process.env.SOFTWARE_FACTORY_WORKER_MANAGER_PORT ?? 4232, +); +const DEFAULT_REALM_URL = new URL( + process.env.SOFTWARE_FACTORY_REALM_URL ?? + `http://localhost:${REALM_SERVER_PORT}/test/`, +); +const DEFAULT_REALM_DIR = resolve( + packageRoot, + process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', +); +const DEFAULT_HOST_URL = process.env.HOST_URL ?? 'http://localhost:4200/'; +const DEFAULT_ICONS_URL = process.env.ICONS_URL ?? 'http://localhost:4206/'; +const DEFAULT_PG_PORT = process.env.SOFTWARE_FACTORY_PGPORT ?? '55436'; +const DEFAULT_PG_HOST = process.env.SOFTWARE_FACTORY_PGHOST ?? '127.0.0.1'; +const DEFAULT_PG_USER = process.env.SOFTWARE_FACTORY_PGUSER ?? 'postgres'; +const DEFAULT_MIGRATED_TEMPLATE_DB = + process.env.SOFTWARE_FACTORY_MIGRATED_TEMPLATE_DB ?? + 'boxel_migrated_template'; +const DEFAULT_REALM_OWNER = '@software-factory-owner:localhost'; +const REALM_SECRET_SEED = "shhh! it's a secret"; +const REALM_SERVER_SECRET_SEED = "mum's the word"; +const GRAFANA_SECRET = "shhh! it's a secret"; +const DEFAULT_MATRIX_SERVER_USERNAME = + process.env.SOFTWARE_FACTORY_MATRIX_SERVER_USERNAME ?? 'realm_server'; +const DEFAULT_MATRIX_BROWSER_USERNAME = + process.env.SOFTWARE_FACTORY_BROWSER_MATRIX_USERNAME ?? + 'software-factory-browser'; +const DEFAULT_BROWSER_MATRIX_URL = + process.env.SOFTWARE_FACTORY_BROWSER_MATRIX_URL ?? 'http://localhost:8008/'; +const INCLUDE_SKILLS = process.env.SOFTWARE_FACTORY_INCLUDE_SKILLS === '1'; +const DEFAULT_PERMISSIONS: RealmPermissions = { + '*': ['read'], + [DEFAULT_REALM_OWNER]: ['read', 'write', 'realm-owner'], +}; +const managedProcessStdio = + process.env.SOFTWARE_FACTORY_DEBUG_SERVER === '1' + ? ['ignore', 'inherit', 'inherit', 'ipc'] + : ['ignore', 'ignore', 'ignore', 'ipc']; -async function ensureFactoryMatrixUsers(): Promise { - if ( - !matrixRegistrationSecret || - matrixRegistrationSecret === 'software-factory-no-matrix' - ) { - return; - } - if (!ensureMatrixUsersPromise) { - ensureMatrixUsersPromise = (async () => { - await ensureFactoryMatrixUser(DEFAULT_MATRIX_USERNAME); - await ensureFactoryMatrixUser(DEFAULT_MATRIX_SERVER_USERNAME); - await ensureFactoryMatrixUser(DEFAULT_MATRIX_BROWSER_USERNAME); - })().catch((error) => { - ensureMatrixUsersPromise = undefined; - throw error; - }); - } - await ensureMatrixUsersPromise; -} +let preparePgPromise: Promise | undefined; function stableStringify(value: unknown): string { if (value === null || typeof value !== 'object') { @@ -392,42 +176,6 @@ function shouldIgnoreFixturePath(relativePath: string): boolean { ); } -function readRealmFixture( - realmDir: string, -): Record { - let fileSystem: Record = {}; - - function visit(currentDir: string) { - for (let entry of readdirSync(currentDir, { withFileTypes: true })) { - let absolutePath = join(currentDir, entry.name); - let relativePath = relative(realmDir, absolutePath).replace(/\\/g, '/'); - if (shouldIgnoreFixturePath(relativePath)) { - continue; - } - if (entry.isDirectory()) { - visit(absolutePath); - continue; - } - if (!entry.isFile()) { - continue; - } - let raw = readFileSync(absolutePath, 'utf8'); - if (relativePath.endsWith('.json')) { - try { - fileSystem[relativePath] = JSON.parse(raw) as LooseSingleCardDocument; - continue; - } catch { - // fall back to a plain text file if JSON parsing fails - } - } - fileSystem[relativePath] = raw; - } - } - - visit(realmDir); - return fileSystem; -} - function hashRealmFixture(realmDir: string): string { let entries: string[] = []; @@ -463,83 +211,401 @@ function templateDatabaseNameForCacheKey(cacheKey: string): string { } function builderDatabaseNameForCacheKey(cacheKey: string): string { - return `sf_bld_${process.pid}_${cacheKey.slice(0, 16)}`; + return `sf_bld_${cacheKey.slice(0, 16)}`; } function runtimeDatabaseName(): string { - return `sf_run_${process.pid}_${Date.now().toString(36)}_${Math.random() + return `sf_run_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; } -async function databaseExists(databaseName: string): Promise { - let client = new PgClient(pgAdminConnectionConfig()); - try { - await client.connect(); - let result = await client.query<{ exists: boolean }>( - 'SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1) AS exists', - [databaseName], - ); - return Boolean(result.rows[0]?.exists); - } finally { - await client.end(); +function pgAdminConnectionConfig(database = 'postgres') { + return { + host: DEFAULT_PG_HOST, + port: Number(DEFAULT_PG_PORT), + user: DEFAULT_PG_USER, + password: process.env.PGPASSWORD || undefined, + database, + }; +} + +function quotePgIdentifier(identifier: string): string { + if (!/^[a-zA-Z0-9_]+$/.test(identifier)) { + throw new Error(`unsafe postgres identifier: ${identifier}`); } + return `"${identifier}"`; } -async function dropDatabase(databaseName: string): Promise { - let client = new PgClient(pgAdminConnectionConfig()); - try { - await client.connect(); - await client.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [databaseName], - ); - await client.query( - `DROP DATABASE IF EXISTS ${quotePgIdentifier(databaseName)}`, - ); - } finally { - await client.end(); +async function waitUntil( + condition: () => Promise, + options: { + timeout?: number; + interval?: number; + timeoutMessage?: string; + } = {}, +): Promise { + let timeout = options.timeout ?? 30_000; + let interval = options.interval ?? 250; + let start = Date.now(); + while (Date.now() - start < timeout) { + let result = await condition(); + if (result) { + return result; + } + await new Promise((resolve) => setTimeout(resolve, interval)); } + throw new Error(options.timeoutMessage ?? 'Timed out waiting for condition'); } -async function createTemplateSnapshot( - sourceDatabaseName: string, - templateDatabaseName: string, -): Promise { - let client = new PgClient(pgAdminConnectionConfig()); +async function canConnectToPg(): Promise { + let client = new PgClient({ + ...pgAdminConnectionConfig(), + connectionTimeoutMillis: 1000, + }); try { await client.connect(); - await client.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [templateDatabaseName], - ); - await client.query( - `DROP DATABASE IF EXISTS ${quotePgIdentifier(templateDatabaseName)}`, - ); - await client.query( - `CREATE DATABASE ${quotePgIdentifier(templateDatabaseName)} TEMPLATE ${quotePgIdentifier( - sourceDatabaseName, - )}`, - ); - await client.query( - `ALTER DATABASE ${quotePgIdentifier(templateDatabaseName)} WITH IS_TEMPLATE true`, - ); + await client.query('SELECT 1'); + return true; + } catch { + return false; } finally { - await client.end(); + try { + await client.end(); + } catch { + // best effort cleanup + } } } -async function waitForQueueIdle( - databaseName: string, - timeout = 30000, -): Promise { - await waitUntil( - async () => { - let client = new PgClient(pgAdminConnectionConfig(databaseName)); +function runCommand(command: string, args: string[], cwd: string) { + let result = spawnSync(command, args, { + cwd, + stdio: 'inherit', + env: { + ...process.env, + PGHOST: DEFAULT_PG_HOST, + PGPORT: DEFAULT_PG_PORT, + PGUSER: DEFAULT_PG_USER, + }, + }); + if (result.status !== 0) { + throw new Error(`command failed: ${command} ${args.join(' ')}`); + } +} + +function cleanupStaleSynapseContainers() { + let result = spawnSync( + 'docker', + [ + 'ps', + '-aq', + '--filter', + 'name=synapsedocker-', + '--filter', + 'name=boxel-synapse', + ], + { + cwd: workspaceRoot, + encoding: 'utf8', + }, + ); + + if (result.status !== 0) { + return; + } + + let containerIds = result.stdout + .split(/\s+/) + .map((id) => id.trim()) + .filter(Boolean); + + if (containerIds.length === 0) { + return; + } + + spawnSync('docker', ['rm', '-f', ...containerIds], { + cwd: workspaceRoot, + stdio: 'ignore', + }); +} + +function maybeRequire(specifier: string) { + if (typeof require === 'function') { + return require(specifier); + } + return undefined; +} + +async function loadSynapseModule() { + return (maybeRequire('../../matrix/docker/synapse/index.ts') ?? + (await import('../../matrix/docker/synapse/index.ts'))) as { + registerUser: ( + synapse: SynapseInstance, + username: string, + password: string, + admin?: boolean, + displayName?: string, + ) => Promise; + synapseStart: ( + opts?: { suppressRegistrationSecretFile?: true }, + stopExisting?: boolean, + ) => Promise; + synapseStop: (id: string) => Promise; + }; +} + +async function loadIsolatedRealmServerModule() { + return (maybeRequire('../../matrix/helpers/isolated-realm-server.ts') ?? + (await import('../../matrix/helpers/isolated-realm-server'))) as { + startPrerenderServer: () => Promise<{ + url: string; + stop(): Promise; + }>; + }; +} + +async function ensureHostReady(): Promise { + let response: Response; + try { + response = await fetch(DEFAULT_HOST_URL); + } catch (error) { + throw new Error( + `Host app is not reachable at ${DEFAULT_HOST_URL}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (!response.ok) { + throw new Error( + `Host app is not ready at ${DEFAULT_HOST_URL}: status ${response.status}`, + ); + } +} + +async function ensureIconsReady(): Promise<{ + stop?: () => Promise; +}> { + try { + let response = await fetch(DEFAULT_ICONS_URL); + if (response.ok) { + return {}; + } + } catch { + // fall through and start the local icon server + } + + let child = spawn('pnpm', ['serve'], { + cwd: boxelIconsDir, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + + let logs = ''; + child.stdout?.on('data', (chunk) => { + logs = `${logs}${String(chunk)}`.slice(-20_000); + }); + child.stderr?.on('data', (chunk) => { + logs = `${logs}${String(chunk)}`.slice(-20_000); + }); + + await waitUntil( + async () => { + try { + let response = await fetch(DEFAULT_ICONS_URL); + return response.ok; + } catch { + return false; + } + }, + { + timeout: 30_000, + interval: 250, + timeoutMessage: `Timed out waiting for icons server at ${DEFAULT_ICONS_URL}\n${logs}`, + }, + ); + + return { + async stop() { + if (child.exitCode === null) { + try { + process.kill(-child.pid!, 'SIGTERM'); + } catch { + // best effort cleanup + } + } + }, + }; +} + +async function ensurePgReady(): Promise { + if (!preparePgPromise) { + preparePgPromise = (async () => { + if (await canConnectToPg()) { + return; + } + runCommand('bash', [prepareTestPgScript], workspaceRoot); + await waitUntil(() => canConnectToPg(), { + timeout: 30_000, + interval: 250, + timeoutMessage: `Timed out waiting for Postgres on ${DEFAULT_PG_HOST}:${DEFAULT_PG_PORT}`, + }); + })().catch((error) => { + preparePgPromise = undefined; + throw error; + }); + } + + await preparePgPromise; +} + +async function databaseExists(databaseName: string): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + let result = await client.query<{ exists: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1) AS exists', + [databaseName], + ); + return Boolean(result.rows[0]?.exists); + } finally { + await client.end(); + } +} + +async function dropDatabase(databaseName: string): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [databaseName], + ); + await client.query( + `DROP DATABASE IF EXISTS ${quotePgIdentifier(databaseName)}`, + ); + } finally { + await client.end(); + } +} + +async function cloneDatabaseFromTemplate( + templateDatabaseName: string, + databaseName: string, +): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `CREATE DATABASE ${quotePgIdentifier(databaseName)} TEMPLATE ${quotePgIdentifier( + templateDatabaseName, + )}`, + ); + } finally { + await client.end(); + } +} + +async function createTemplateSnapshot( + sourceDatabaseName: string, + templateDatabaseName: string, +): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [templateDatabaseName], + ); + await client.query( + `DROP DATABASE IF EXISTS ${quotePgIdentifier(templateDatabaseName)}`, + ); + await client.query( + `CREATE DATABASE ${quotePgIdentifier(templateDatabaseName)} TEMPLATE ${quotePgIdentifier( + sourceDatabaseName, + )}`, + ); + await client.query( + `ALTER DATABASE ${quotePgIdentifier(templateDatabaseName)} WITH IS_TEMPLATE true`, + ); + } finally { + await client.end(); + } +} + +async function seedRealmPermissions( + databaseName: string, + realmURL: URL, + permissions: RealmPermissions, +): Promise { + let client = new PgClient(pgAdminConnectionConfig(databaseName)); + try { + await client.connect(); + await client.query('BEGIN'); + + for (let [username, actions] of Object.entries(permissions)) { + if (!actions || actions.length === 0) { + await client.query( + `DELETE FROM realm_user_permissions + WHERE realm_url = $1 AND username = $2`, + [realmURL.href, username], + ); + continue; + } + + if (username !== '*') { + await client.query( + `INSERT INTO users (matrix_user_id) + VALUES ($1) + ON CONFLICT (matrix_user_id) DO NOTHING`, + [username], + ); + } + + await client.query( + `INSERT INTO realm_user_permissions ( + realm_url, + username, + read, + write, + realm_owner + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (realm_url, username) DO UPDATE + SET read = EXCLUDED.read, + write = EXCLUDED.write, + realm_owner = EXCLUDED.realm_owner`, + [ + realmURL.href, + username, + actions.includes('read'), + actions.includes('write'), + actions.includes('realm-owner'), + ], + ); + } + + await client.query('COMMIT'); + } catch (error) { + try { + await client.query('ROLLBACK'); + } catch { + // best effort cleanup + } + throw error; + } finally { + await client.end(); + } +} + +async function waitForQueueIdle(databaseName: string): Promise { + await waitUntil( + async () => { + let client = new PgClient(pgAdminConnectionConfig(databaseName)); try { await client.connect(); let { @@ -558,43 +624,418 @@ async function waitForQueueIdle( } }, { - timeout, - interval: 50, - timeoutMessage: 'waiting for queue to become idle', + timeout: 30_000, + interval: 100, + timeoutMessage: `Timed out waiting for queue to become idle in ${databaseName}`, }, ); } -async function buildTemplate( - options: Required< - Pick - > & { - cacheKey: string; - templateDatabaseName: string; - }, +function browserPassword(username: string): string { + let cleanUsername = username.replace(/^@/, '').replace(/:.*$/, ''); + return createHash('sha256') + .update(cleanUsername) + .update(REALM_SECRET_SEED) + .digest('hex'); +} + +async function ensureSupportUsers(synapse: SynapseInstance): Promise { + let { registerUser } = await loadSynapseModule(); + + await registerUser( + synapse, + DEFAULT_MATRIX_SERVER_USERNAME, + browserPassword(DEFAULT_MATRIX_SERVER_USERNAME), + ); + await registerUser( + synapse, + DEFAULT_MATRIX_BROWSER_USERNAME, + browserPassword(DEFAULT_MATRIX_BROWSER_USERNAME), + ); +} + +function parseFactoryContext(): FactoryTestContext | undefined { + let raw = process.env.SOFTWARE_FACTORY_CONTEXT; + if (!raw) { + return undefined; + } + return JSON.parse(raw) as FactoryTestContext; +} + +function hasTemplateDatabaseName( + context: FactorySupportContext | FactoryTestContext, +): context is FactoryTestContext { + return 'templateDatabaseName' in context; +} + +function buildRealmToken( + realmURL: URL, + user = DEFAULT_REALM_OWNER, + permissions = DEFAULT_PERMISSIONS[DEFAULT_REALM_OWNER] ?? [ + 'read', + 'write', + 'realm-owner', + ], +): string { + return jwt.sign( + { + user, + realm: realmURL.href, + permissions, + sessionRoom: `software-factory-session-room-for-${user}`, + realmServerURL: new URL(realmURL.origin).href, + }, + REALM_SECRET_SEED, + { expiresIn: '7d' }, + ); +} + +function createProcessExitPromise( + proc: SpawnedProcess, + label: string, +): Promise { + return new Promise((_, reject) => { + proc.once('exit', (code, signal) => { + reject( + new Error( + `${label} exited before it became ready (code: ${code}, signal: ${signal})`, + ), + ); + }); + proc.once('error', reject); + }); +} + +async function waitForReady( + proc: SpawnedProcess, + label: string, +): Promise { + let timedOut = await Promise.race([ + new Promise((resolve) => { + let onMessage = (message: unknown) => { + if (message === 'ready') { + proc.off('message', onMessage); + resolve(); + } + }; + proc.on('message', onMessage); + }), + createProcessExitPromise(proc, label), + new Promise((resolve) => setTimeout(() => resolve(true), 120_000)), + ]); + + if (timedOut) { + throw new Error(`Timed out waiting for ${label} to start`); + } +} + +async function stopManagedProcess(proc: SpawnedProcess): Promise { + if (proc.exitCode !== null) { + return; + } + let stopped = new Promise((resolve) => { + let onMessage = (message: unknown) => { + if (message === 'stopped') { + proc.off('message', onMessage); + resolve(); + } + }; + proc.on('message', onMessage); + }); + proc.send('stop'); + await Promise.race([ + stopped, + new Promise((resolve) => setTimeout(resolve, 15_000)), + ]); + proc.send('kill'); +} + +function copyRealmFixture(realmDir: string, destination: string): void { + fsExtra.copySync(realmDir, destination, { + preserveTimestamps: true, + filter(src) { + let relativePath = relative(realmDir, src).replace(/\\/g, '/'); + return relativePath === '' || !shouldIgnoreFixturePath(relativePath); + }, + }); +} + +async function startIsolatedRealmStack({ + realmDir, + realmURL, + databaseName, + context, + migrateDB, +}: { + realmDir: string; + realmURL: URL; + databaseName: string; + context: FactorySupportContext; + migrateDB: boolean; +}): Promise { + let rootDir = mkdtempSync(join(tmpdir(), 'software-factory-realms-')); + let testRealmDir = join(rootDir, 'test'); + fsExtra.ensureDirSync(testRealmDir); + copyRealmFixture(realmDir, testRealmDir); + + let env = { + ...process.env, + PGHOST: DEFAULT_PG_HOST, + PGPORT: DEFAULT_PG_PORT, + PGUSER: DEFAULT_PG_USER, + PGDATABASE: databaseName, + NODE_NO_WARNINGS: '1', + NODE_ENV: 'test', + REALM_SERVER_SECRET_SEED, + REALM_SECRET_SEED, + GRAFANA_SECRET, + MATRIX_URL: context.matrixURL, + MATRIX_REGISTRATION_SHARED_SECRET: context.matrixRegistrationSecret, + REALM_SERVER_MATRIX_USERNAME: DEFAULT_MATRIX_SERVER_USERNAME, + LOW_CREDIT_THRESHOLD: '2000', + PUBLISHED_REALM_BOXEL_SPACE_DOMAIN: `localhost:${REALM_SERVER_PORT}`, + PUBLISHED_REALM_BOXEL_SITE_DOMAIN: `localhost:${REALM_SERVER_PORT}`, + }; + + let workerArgs = [ + '--transpileOnly', + 'worker-manager', + `--port=${WORKER_MANAGER_PORT}`, + `--matrixURL=${context.matrixURL}`, + `--prerendererUrl=${context.prerenderURL}`, + `--fromUrl=${realmURL.href}`, + `--toUrl=${realmURL.href}`, + '--fromUrl=https://cardstack.com/base/', + `--toUrl=http://localhost:${REALM_SERVER_PORT}/base/`, + ]; + if (INCLUDE_SKILLS) { + workerArgs.push( + `--fromUrl=http://localhost:${REALM_SERVER_PORT}/skills/`, + `--toUrl=http://localhost:${REALM_SERVER_PORT}/skills/`, + ); + } + if (migrateDB) { + workerArgs.splice(5, 0, '--migrateDB'); + } + + let workerManager = spawn('ts-node', workerArgs, { + cwd: realmServerDir, + env, + stdio: managedProcessStdio, + }) as SpawnedProcess; + + let serverArgs = [ + '--transpileOnly', + 'main', + `--port=${REALM_SERVER_PORT}`, + `--matrixURL=${context.matrixURL}`, + `--realmsRootPath=${rootDir}`, + `--workerManagerPort=${WORKER_MANAGER_PORT}`, + `--prerendererUrl=${context.prerenderURL}`, + `--path=${testRealmDir}`, + '--username=test_realm', + `--fromUrl=${realmURL.href}`, + `--toUrl=${realmURL.href}`, + '--username=base_realm', + `--path=${baseRealmDir}`, + '--fromUrl=https://cardstack.com/base/', + `--toUrl=http://localhost:${REALM_SERVER_PORT}/base/`, + ]; + if (INCLUDE_SKILLS) { + serverArgs.splice( + 11, + 0, + '--username=skills_realm', + `--path=${skillsRealmDir}`, + `--fromUrl=http://localhost:${REALM_SERVER_PORT}/skills/`, + `--toUrl=http://localhost:${REALM_SERVER_PORT}/skills/`, + ); + } + + let realmServer = spawn('ts-node', serverArgs, { + cwd: realmServerDir, + env, + stdio: managedProcessStdio, + }) as SpawnedProcess; + + try { + await Promise.race([ + waitForReady(realmServer, 'realm server'), + createProcessExitPromise(workerManager, 'worker manager'), + ]); + } catch (error) { + try { + await stopManagedProcess(realmServer); + } catch { + // best effort cleanup + } + try { + await stopManagedProcess(workerManager); + } catch { + // best effort cleanup + } + rmSync(rootDir, { recursive: true, force: true }); + throw error; + } + + return { + realmServer, + workerManager, + rootDir, + }; +} + +async function stopIsolatedRealmStack( + stack: RunningFactoryStack, ): Promise { - let builderDatabaseName = builderDatabaseNameForCacheKey(options.cacheKey); - let runtime = await startFactoryRealmServer({ - realmDir: options.realmDir, - realmURL: options.realmURL, - permissions: options.permissions, - useCache: false, + let cleanupError: unknown; + + try { + await stopManagedProcess(stack.realmServer); + } catch (error) { + cleanupError ??= error; + } + + try { + await stopManagedProcess(stack.workerManager); + } catch (error) { + cleanupError ??= error; + } + + try { + rmSync(stack.rootDir, { recursive: true, force: true }); + } catch (error) { + cleanupError ??= error; + } + + if (cleanupError) { + throw cleanupError; + } +} + +async function buildTemplateDatabase({ + realmDir, + realmURL, + permissions, + context, + cacheKey, + templateDatabaseName, +}: { + realmDir: string; + realmURL: URL; + permissions: RealmPermissions; + context: FactorySupportContext; + cacheKey: string; + templateDatabaseName: string; +}): Promise { + let builderDatabaseName = builderDatabaseNameForCacheKey(cacheKey); + let hasMigratedTemplate = await databaseExists(DEFAULT_MIGRATED_TEMPLATE_DB); + + await dropDatabase(templateDatabaseName); + await dropDatabase(builderDatabaseName); + + if (hasMigratedTemplate) { + await cloneDatabaseFromTemplate( + DEFAULT_MIGRATED_TEMPLATE_DB, + builderDatabaseName, + ); + } + + await seedRealmPermissions(builderDatabaseName, realmURL, permissions); + + let stack = await startIsolatedRealmStack({ + realmDir, + realmURL, databaseName: builderDatabaseName, + context, + migrateDB: !hasMigratedTemplate, }); try { await waitForQueueIdle(builderDatabaseName); } finally { - await runtime.stop({ preserveDatabase: true }); + await stopIsolatedRealmStack(stack); } - await createTemplateSnapshot( - builderDatabaseName, - options.templateDatabaseName, - ); + await createTemplateSnapshot(builderDatabaseName, templateDatabaseName); await dropDatabase(builderDatabaseName); } +async function startFactorySupportServices(): Promise<{ + context: FactorySupportContext; + stop(): Promise; +}> { + await ensurePgReady(); + await ensureHostReady(); + let icons = await ensureIconsReady(); + cleanupStaleSynapseContainers(); + let { synapseStart, synapseStop } = await loadSynapseModule(); + let { startPrerenderServer } = await loadIsolatedRealmServerModule(); + + let synapse = await synapseStart( + { suppressRegistrationSecretFile: true }, + true, + ); + await ensureSupportUsers(synapse); + let prerender = await startPrerenderServer(); + let matrixURL = + process.env.SOFTWARE_FACTORY_MATRIX_URL ?? DEFAULT_BROWSER_MATRIX_URL; + + return { + context: { + matrixURL, + matrixRegistrationSecret: synapse.registrationSecret, + prerenderURL: prerender.url, + }, + async stop() { + await prerender.stop(); + await synapseStop(synapse.synapseId); + await icons.stop?.(); + }, + }; +} + +export function getFactoryTestContext(): FactoryTestContext { + let context = parseFactoryContext(); + if (!context) { + throw new Error('SOFTWARE_FACTORY_CONTEXT is not defined'); + } + return context; +} + +export async function startFactoryGlobalContext( + options: FactoryRealmOptions = {}, +): Promise { + let realmDir = resolve(options.realmDir ?? DEFAULT_REALM_DIR); + let realmURL = new URL((options.realmURL ?? DEFAULT_REALM_URL).href); + let support = await startFactorySupportServices(); + try { + let template = await ensureFactoryRealmTemplate({ + ...options, + realmDir, + realmURL, + context: support.context, + }); + + let context: FactoryTestContext = { + ...support.context, + cacheKey: template.cacheKey, + fixtureHash: template.fixtureHash, + realmDir, + realmURL: realmURL.href, + templateDatabaseName: template.templateDatabaseName, + }; + + return { + context, + stop: support.stop, + }; + } catch (error) { + await support.stop(); + throw error; + } +} + export async function ensureFactoryRealmTemplate( options: FactoryRealmOptions = {}, ): Promise { @@ -614,318 +1055,89 @@ export async function ensureFactoryRealmTemplate( ); let templateDatabaseName = templateDatabaseNameForCacheKey(cacheKey); - await ensureTestPgPrepared(); - await ensureFactoryPrerequisites(); - if (await databaseExists(templateDatabaseName)) { - return { - cacheKey, - templateDatabaseName, - fixtureHash, - cacheHit: true, - }; + let ownedSupport: + | { + context: FactorySupportContext; + stop(): Promise; + } + | undefined; + let context = options.context; + if (!context) { + ownedSupport = await startFactorySupportServices(); + context = ownedSupport.context; } - await buildTemplate({ - realmDir, - realmURL, - permissions, - cacheKey, - templateDatabaseName, - }); - - return { - cacheKey, - templateDatabaseName, - fixtureHash, - cacheHit: false, - }; -} - -async function buildStartedRealm( - options: Required< - Pick - > & { - databaseName: string; - templateDatabase?: string; - }, -) { - applyTestPgEnv(); - await ensureFactoryMatrixUsers(); - - let fileSystem = readRealmFixture(options.realmDir); - let runtimeRoot = mkdtempSync(join(tmpdir(), 'software-factory-realm-')); - let realmPath = join(runtimeRoot, 'realm'); - mkdirSync(realmPath, { recursive: true }); - - let dbAdapter = await createTestPgAdapter({ - databaseName: options.databaseName, - templateDatabase: options.templateDatabase, - }); - let publisher: QueuePublisher | undefined; - let runner: QueueRunner | undefined; - let prerenderer: any; - let prerenderServer: any; - let managedPrerenderServer = false; - let testRealmServer: RealmServer | undefined; - let httpServer; - try { - publisher = new PgQueuePublisher(dbAdapter); - runner = new PgQueueRunner({ - adapter: dbAdapter, - workerId: `software-factory-${process.pid}`, - }); - ({ - prerenderer, - server: prerenderServer, - managed: managedPrerenderServer, - } = await startPrerenderServer()); - let virtualNetwork = createVirtualNetwork(); - let definitionLookup = new CachingDefinitionLookup( - dbAdapter, - prerenderer, - virtualNetwork, - testCreatePrerenderAuth, - ); - let worker = new Worker({ - indexWriter: new IndexWriter(dbAdapter), - queue: runner, - dbAdapter, - queuePublisher: publisher, - virtualNetwork, - matrixURL: DEFAULT_MATRIX_URL, - secretSeed: realmSecretSeed, - realmServerMatrixUsername: DEFAULT_MATRIX_SERVER_USERNAME, - prerenderer, - createPrerenderAuth: testCreatePrerenderAuth, - }); - await worker.run(); - - let { realm } = await createRealm({ - dir: realmPath, - definitionLookup, - fileSystem, - realmURL: options.realmURL.href, - permissions: options.permissions, - virtualNetwork, - publisher, - dbAdapter, - cardSizeLimitBytes: undefined, - fileSizeLimitBytes: undefined, - }); + if (await databaseExists(templateDatabaseName)) { + return { + cacheKey, + templateDatabaseName, + fixtureHash, + cacheHit: true, + }; + } - virtualNetwork.mount(realm.handle); - - testRealmServer = new RealmServer({ - realms: [realm], - virtualNetwork, - matrixClient: new MatrixClient({ - matrixURL: DEFAULT_MATRIX_URL, - username: DEFAULT_MATRIX_USERNAME, - seed: realmSecretSeed, - }), - realmServerSecretSeed, - realmSecretSeed, - matrixRegistrationSecret, - realmsRootPath: runtimeRoot, - dbAdapter, - queue: publisher, - getIndexHTML, - grafanaSecret, - serverURL: new URL(options.realmURL.origin), - assetsURL: new URL(DEFAULT_HOST_URL), - definitionLookup, - prerenderer, + await buildTemplateDatabase({ + realmDir, + realmURL, + permissions, + context, + cacheKey, + templateDatabaseName, }); - httpServer = testRealmServer.listen(Number(options.realmURL.port)); - await testRealmServer.start(); - return { - createBearerToken: ( - user = DEFAULT_REALM_OWNER, - permissions = DEFAULT_PERMISSIONS[DEFAULT_REALM_OWNER] ?? [ - 'read', - 'write', - 'realm-owner', - ], - ) => - realm.createJWT( - { - user, - realm: realm.url, - permissions, - sessionRoom: `software-factory-session-room-for-${user}`, - realmServerURL: options.realmURL.href, - }, - '7d', - ), - stop: async ({ - preserveDatabase = false, - }: { preserveDatabase?: boolean } = {}) => { - let cleanupError: unknown; - - try { - if (httpServer?.listening) { - await closeServer(httpServer); - } - } catch (error) { - cleanupError ??= error; - } - - try { - await publisher?.destroy(); - } catch (error) { - cleanupError ??= error; - } - - try { - await runner?.destroy(); - } catch (error) { - cleanupError ??= error; - } - - try { - await dbAdapter.close(); - } catch (error) { - cleanupError ??= error; - } - - try { - if ( - managedPrerenderServer && - prerenderServer && - typeof prerenderServer.__stopPrerenderer === 'function' - ) { - await prerenderServer.__stopPrerenderer(); - } - } catch (error) { - cleanupError ??= error; - } - - try { - if (managedPrerenderServer && prerenderServer?.listening) { - await closeServer(prerenderServer); - } - } catch (error) { - cleanupError ??= error; - } - - try { - if (managedPrerenderServer) { - await (prerenderer as { stop?: () => Promise })?.stop?.(); - } - } catch (error) { - cleanupError ??= error; - } - - if (!preserveDatabase) { - try { - await dropDatabase(options.databaseName); - } catch (error) { - cleanupError ??= error; - } - } - - try { - rmSync(runtimeRoot, { recursive: true, force: true }); - } catch (error) { - cleanupError ??= error; - } - - if (cleanupError) { - throw cleanupError; - } - }, + cacheKey, + templateDatabaseName, + fixtureHash, + cacheHit: false, }; - } catch (error) { - try { - await publisher?.destroy(); - } catch { - // best effort cleanup - } - try { - await runner?.destroy(); - } catch { - // best effort cleanup - } - try { - await dbAdapter.close(); - } catch { - // best effort cleanup - } - try { - if ( - managedPrerenderServer && - prerenderServer && - typeof prerenderServer.__stopPrerenderer === 'function' - ) { - await prerenderServer.__stopPrerenderer(); - } - } catch { - // best effort cleanup - } - try { - if (managedPrerenderServer && prerenderServer?.listening) { - await closeServer(prerenderServer); - } - } catch { - // best effort cleanup - } - try { - if (managedPrerenderServer) { - await (prerenderer as { stop?: () => Promise })?.stop?.(); - } - } catch { - // best effort cleanup - } - try { - await dropDatabase(options.databaseName); - } catch { - // best effort cleanup - } - rmSync(runtimeRoot, { recursive: true, force: true }); - throw error; + } finally { + await ownedSupport?.stop(); } } -async function startFactoryRealmServer( - options: FactoryRealmOptions & { - databaseName?: string; - } = {}, -): Promise< - StartedFactoryRealm & { - stop(args?: { preserveDatabase?: boolean }): Promise; - } -> { +export async function startFactoryRealmServer( + options: FactoryRealmOptions = {}, +): Promise { let realmDir = resolve(options.realmDir ?? DEFAULT_REALM_DIR); let realmURL = new URL((options.realmURL ?? DEFAULT_REALM_URL).href); - let permissions = options.permissions ?? DEFAULT_PERMISSIONS; - let databaseName = options.databaseName ?? runtimeDatabaseName(); - - await ensureTestPgPrepared(); - await ensureFactoryPrerequisites(); - - let templateDatabase: string | undefined; - if (options.templateDatabaseName) { - templateDatabase = options.templateDatabaseName; - } else if (options.useCache !== false) { - templateDatabase = ( - await ensureFactoryRealmTemplate({ - realmDir, - realmURL, - permissions, - cacheSalt: options.cacheSalt, - }) - ).templateDatabaseName; - } - - let runtime = await buildStartedRealm({ + let templateDatabaseName = options.templateDatabaseName; + + let ownedGlobalContext: FactoryGlobalContextHandle | undefined; + let context = options.context ?? parseFactoryContext(); + if (!context) { + ownedGlobalContext = await startFactoryGlobalContext({ + ...options, + realmDir, + realmURL, + }); + context = ownedGlobalContext.context; + } + + if (!templateDatabaseName) { + templateDatabaseName = hasTemplateDatabaseName(context) + ? context.templateDatabaseName + : ( + await ensureFactoryRealmTemplate({ + ...options, + realmDir, + realmURL, + context, + }) + ).templateDatabaseName; + } + + let databaseName = runtimeDatabaseName(); + await dropDatabase(databaseName); + await cloneDatabaseFromTemplate(templateDatabaseName, databaseName); + + let stack = await startIsolatedRealmStack({ realmDir, realmURL, - permissions, databaseName, - templateDatabase, + context, + migrateDB: false, }); return { @@ -935,18 +1147,42 @@ async function startFactoryRealmServer( cardURL(path: string) { return new URL(path, realmURL).href; }, - createBearerToken: runtime.createBearerToken, - authorizationHeaders(user?: string, permissions?: string[]) { + createBearerToken(user = DEFAULT_REALM_OWNER, permissions?: RealmAction[]) { + return buildRealmToken(realmURL, user, permissions); + }, + authorizationHeaders(user?: string, permissions?: RealmAction[]) { return { - Authorization: `Bearer ${runtime.createBearerToken(user, permissions)}`, + Authorization: `Bearer ${buildRealmToken(realmURL, user, permissions)}`, }; }, - stop: runtime.stop, + async stop() { + let cleanupError: unknown; + + try { + await stopIsolatedRealmStack(stack); + } catch (error) { + cleanupError ??= error; + } + + try { + await dropDatabase(databaseName); + } catch (error) { + cleanupError ??= error; + } + + try { + await ownedGlobalContext?.stop(); + } catch (error) { + cleanupError ??= error; + } + + if (cleanupError) { + throw cleanupError; + } + }, }; } -export { startFactoryRealmServer }; - export async function fetchRealmCardJson( path: string, options: FactoryRealmOptions = {}, @@ -967,57 +1203,3 @@ export async function fetchRealmCardJson( await runtime.stop(); } } - -async function getFreePort(): Promise { - return await new Promise((resolve, reject) => { - let server = createNetServer(); - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - let address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('unable to determine free port'))); - return; - } - let { port } = address; - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(port); - }); - }); - }); -} - -async function startPrerenderServer(): Promise<{ - prerenderer: any; - server: any; - managed: boolean; -}> { - if (process.env.SOFTWARE_FACTORY_PRERENDER_SERVER_URL) { - return { - prerenderer: createRemotePrerenderer( - process.env.SOFTWARE_FACTORY_PRERENDER_SERVER_URL, - ), - server: undefined, - managed: false, - }; - } - let port = await getFreePort(); - let server = createPrerenderHttpServer({ - silent: Boolean(process.env.SILENT_PRERENDERER), - maxPages: 2, - }); - - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(port, '127.0.0.1', () => resolve()); - }); - - return { - prerenderer: createRemotePrerenderer(`http://127.0.0.1:${port}`), - server, - managed: true, - }; -} diff --git a/packages/software-factory/src/index.ts b/packages/software-factory/src/index.ts index 50f22adf99..7e8cb55ab2 100644 --- a/packages/software-factory/src/index.ts +++ b/packages/software-factory/src/index.ts @@ -1,9 +1,11 @@ -// @ts-nocheck export { ensureFactoryRealmTemplate, fetchRealmCardJson, + getFactoryTestContext, + startFactoryGlobalContext, startFactoryRealmServer, type FactoryRealmOptions, type FactoryRealmTemplate, + type FactoryTestContext, type StartedFactoryRealm, -} from './harness.ts'; +} from './harness'; diff --git a/packages/software-factory/tests/fixtures.ts b/packages/software-factory/tests/fixtures.ts index 549cd88249..2b370d3aa4 100644 --- a/packages/software-factory/tests/fixtures.ts +++ b/packages/software-factory/tests/fixtures.ts @@ -3,19 +3,11 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import type { BrowserContext, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { test as base, expect } from '@playwright/test'; import { buildBrowserState, installBrowserState } from './helpers/browser-auth'; -type PreparedRealmTemplate = { - realmDir: string; - cacheKey: string; - templateDatabaseName: string; - fixtureHash: string; - cacheHit: boolean; -}; - type StartedFactoryRealm = { realmDir: string; realmURL: URL; @@ -25,11 +17,6 @@ type StartedFactoryRealm = { stop(): Promise; }; -type SharedPrerenderProcess = { - url: string; - stop(): Promise; -}; - export type FactoryRealmFixtures = { realm: StartedFactoryRealm; realmURL: URL; @@ -37,26 +24,12 @@ export type FactoryRealmFixtures = { authedPage: Page; }; -type FactoryRealmWorkerFixtures = { - preparedRealmTemplate: PreparedRealmTemplate; - sharedPrerender: SharedPrerenderProcess; - cachedContext: BrowserContext; -}; - const packageRoot = resolve(process.cwd()); -const defaultRealmURL = new URL( - process.env.SOFTWARE_FACTORY_REALM_URL ?? 'http://127.0.0.1:4444/', -); const defaultRealmDir = resolve( packageRoot, process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', ); -type ChildResult = { - metadata: T; - logs: string; -}; - function appendLog(buffer: string, chunk: string): string { let combined = `${buffer}${chunk}`; return combined.length > 20_000 ? combined.slice(-20_000) : combined; @@ -100,65 +73,7 @@ async function waitForMetadataFile( ); } -async function runCachePrepare(realmDir = defaultRealmDir) { - let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-cache-')); - let metadataFile = join(tempDir, 'template.json'); - let logs = ''; - - try { - let child = spawn('pnpm', ['cache:prepare', realmDir], { - cwd: packageRoot, - env: { - ...process.env, - SOFTWARE_FACTORY_METADATA_FILE: metadataFile, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - child.stdout?.on('data', (chunk) => { - logs = appendLog(logs, String(chunk)); - }); - child.stderr?.on('data', (chunk) => { - logs = appendLog(logs, String(chunk)); - }); - - let exitPromise = new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code) => { - if (code === 0) { - resolve(); - } else { - reject( - new Error( - `cache:prepare exited with code ${code ?? 'unknown'}\n${logs}`, - ), - ); - } - }); - }); - - let metadata = await waitForMetadataFile( - metadataFile, - child, - () => logs, - ); - - await exitPromise; - - return { - metadata, - logs, - } satisfies ChildResult; - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} - -async function startRealmProcess( - templateDatabaseName: string, - prerenderServerURL: string, - realmDir = defaultRealmDir, -) { +async function startRealmProcess(realmDir = defaultRealmDir) { let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-realm-')); let metadataFile = join(tempDir, 'runtime.json'); let logs = ''; @@ -169,8 +84,6 @@ async function startRealmProcess( env: { ...process.env, SOFTWARE_FACTORY_METADATA_FILE: metadataFile, - SOFTWARE_FACTORY_PRERENDER_SERVER_URL: prerenderServerURL, - SOFTWARE_FACTORY_TEMPLATE_DATABASE_NAME: templateDatabaseName, }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -241,140 +154,42 @@ async function startRealmProcess( } satisfies StartedFactoryRealm; } -async function startPrerenderProcess() { - let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-prerender-')); - let metadataFile = join(tempDir, 'prerender.json'); - let logs = ''; - - let child = spawn('pnpm', ['serve:prerender'], { - cwd: packageRoot, - detached: true, - env: { - ...process.env, - SOFTWARE_FACTORY_METADATA_FILE: metadataFile, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - child.stdout?.on('data', (chunk) => { - logs = appendLog(logs, String(chunk)); - }); - child.stderr?.on('data', (chunk) => { - logs = appendLog(logs, String(chunk)); +async function registerRealmRedirect( + page: Page, + fromPrefix: string, + toPrefix: string, +) { + await page.route(`${fromPrefix}**`, async (route) => { + let url = route.request().url(); + let suffix = url.slice(fromPrefix.length); + await route.continue({ url: `${toPrefix}${suffix}` }); }); +} - let metadata: { - url: string; - }; - - try { - metadata = await waitForMetadataFile<{ url: string }>( - metadataFile, - child, - () => logs, +async function setRealmRedirects(page: Page) { + await registerRealmRedirect( + page, + 'http://localhost:4201/base/', + 'http://localhost:4205/base/', + ); + if (process.env.SOFTWARE_FACTORY_INCLUDE_SKILLS === '1') { + await registerRealmRedirect( + page, + 'http://localhost:4201/skills/', + 'http://localhost:4205/skills/', ); - } catch (error) { - killProcessGroup(child.pid!, 'SIGTERM'); - throw error; } - - let stop = async () => { - try { - if (child.exitCode === null) { - killProcessGroup(child.pid!, 'SIGTERM'); - await new Promise((resolve, reject) => { - let timeout = setTimeout(() => { - killProcessGroup(child.pid!, 'SIGKILL'); - }, 15_000); - - child.once('error', (error) => { - clearTimeout(timeout); - reject(error); - }); - child.once('exit', () => { - clearTimeout(timeout); - resolve(); - }); - }); - } - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }; - - return { - url: metadata.url, - stop, - } satisfies SharedPrerenderProcess; } -export const test = base.extend< - FactoryRealmFixtures, - FactoryRealmWorkerFixtures ->({ - preparedRealmTemplate: [ - async ({ browserName: _browserName }, use) => { - let { metadata } = await runCachePrepare(); - await use(metadata); - }, - { scope: 'worker' }, - ], - - sharedPrerender: [ - async ({ browserName: _browserName }, use) => { - let prerender = await startPrerenderProcess(); - try { - await use(prerender); - } finally { - await prerender.stop(); - } - }, - { scope: 'worker' }, - ], - - cachedContext: [ - async ({ browser, preparedRealmTemplate, sharedPrerender }, use) => { - let bootstrapRealm = await startRealmProcess( - preparedRealmTemplate.templateDatabaseName, - sharedPrerender.url, - ); - let context = await browser.newContext({ - baseURL: defaultRealmURL.href, - }); - - try { - let browserState = await buildBrowserState( - bootstrapRealm.realmURL.href, - ); - await installBrowserState(context, browserState); +export const test = base.extend({ + page: async ({ page }, use) => { + await setRealmRedirects(page); + await use(page); + }, - // Warm the app shell once so later test pages can reuse browser cache. - let warmPage = await context.newPage(); - try { - await warmPage.goto(defaultRealmURL.href, { - waitUntil: 'domcontentloaded', - }); - } finally { - await warmPage.close(); - } - } finally { - await bootstrapRealm.stop(); - } + realm: async ({ browserName: _browserName }, use) => { + let realm = await startRealmProcess(); - try { - await use(context); - } finally { - await context.close(); - } - }, - { scope: 'worker' }, - ], - - realm: async ({ preparedRealmTemplate, sharedPrerender }, use) => { - let realm = await startRealmProcess( - preparedRealmTemplate.templateDatabaseName, - sharedPrerender.url, - ); try { await use(realm); } finally { @@ -383,31 +198,28 @@ export const test = base.extend< }, realmURL: async ({ realm }, use) => { - await use(new URL(realm.realmURL.href)); + await use(realm.realmURL); }, cardURL: async ({ realm }, use) => { await use((path: string) => realm.cardURL(path)); }, - authedPage: async ({ cachedContext, realm: _realm }, use) => { - await cachedContext.clearCookies(); - let page = await cachedContext.newPage(); + authedPage: async ({ browser, realmURL }, use) => { + let state = await buildBrowserState(realmURL.href); + let context = await browser.newContext(); + await installBrowserState(context, state); + let page = await context.newPage(); + await setRealmRedirects(page); + try { - await page.route(`${_realm.realmURL.origin}/**/*`, async (route) => { - await route.continue({ - headers: { - ...route.request().headers(), - 'cache-control': 'no-cache, no-store, max-age=0', - pragma: 'no-cache', - }, - }); - }); await use(page); } finally { - await page.close(); + await context.close(); } }, }); +test.setTimeout(120_000); + export { expect }; diff --git a/packages/software-factory/tests/helpers/browser-auth.ts b/packages/software-factory/tests/helpers/browser-auth.ts index 9737507e46..692f83c635 100644 --- a/packages/software-factory/tests/helpers/browser-auth.ts +++ b/packages/software-factory/tests/helpers/browser-auth.ts @@ -179,9 +179,10 @@ async function getRealmAuthTokens( export async function buildBrowserState( realmURL: string, + realmServerURL = new URL('/', realmURL).href, ): Promise { let matrixAuth = await matrixLogin(); - let realmTokens = await getRealmAuthTokens(matrixAuth, realmURL); + let realmTokens = await getRealmAuthTokens(matrixAuth, realmServerURL); return { auth: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3674f1633..2462865df8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,7 +175,7 @@ catalogs: specifier: ^21.1.1 version: 21.1.7 '@types/jsonwebtoken': - specifier: ^9.0.5 + specifier: 9.0.10 version: 9.0.10 '@types/koa': specifier: ^2.13.5 @@ -469,7 +469,7 @@ catalogs: specifier: ^1.1.2 version: 1.1.2 jsonwebtoken: - specifier: ^9.0.2 + specifier: 9.0.3 version: 9.0.3 koa: specifier: ^2.14.1 @@ -2838,6 +2838,9 @@ importers: '@types/fs-extra': specifier: 'catalog:' version: 11.0.4 + '@types/jsonwebtoken': + specifier: 'catalog:' + version: 9.0.10 '@types/node': specifier: 'catalog:' version: 24.10.8 @@ -2859,6 +2862,9 @@ importers: fs-extra: specifier: 'catalog:' version: 11.3.3 + jsonwebtoken: + specifier: 'catalog:' + version: 9.0.3 pg: specifier: 'catalog:' version: 8.16.3 @@ -2868,6 +2874,9 @@ importers: ts-node: specifier: ^10.9.1 version: 10.9.2(@types/node@24.10.8)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: 'catalog:' version: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 87e15f278d..21619f6b86 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,98 +4,98 @@ packages: - vendor/* catalog: - "@actions/core": ^1.2.6 - "@actions/github": ^4.0.0 - "@aws-crypto/sha256-js": ^5.2.0 - "@babel/core": ^7.26.10 - "@babel/generator": ^7.17.8 - "@babel/helper-module-imports": ^7.18.2 - "@babel/helper-module-transforms": ^7.18.9 - "@babel/parser": 7.27.0 - "@babel/plugin-proposal-class-properties": ^7.18.6 - "@babel/plugin-proposal-decorators": ^7.23.2 - "@babel/plugin-syntax-class-properties": ^7.12.13 - "@babel/plugin-syntax-decorators": ^7.17.12 - "@babel/plugin-syntax-typescript": ^7.17.12 - "@babel/plugin-transform-class-properties": ^7.22.5 - "@babel/plugin-transform-class-static-block": ^7.22.11 - "@babel/plugin-transform-modules-amd": ^7.13.0 - "@babel/plugin-transform-typescript": ^7.16.8 - "@babel/preset-typescript": ^7.24.7 - "@babel/runtime": ^7.22.11 - "@babel/traverse": 7.27.0 - "@cardstack/requirejs-monaco-ember-polyfill": ^0.0.1 - "@cardstack/view-transitions": ^0.2.0 - "@ember/string": ^4.0.1 - "@ember/test-waiters": ^4.1.1 - "@eslint/eslintrc": ^2.1.4 - "@eslint/js": ^8.57.1 - "@floating-ui/dom": ^1.6.3 - "@glimmer/component": ^2.0.0 - "@glint/environment-ember-loose": ^1.5.2 - "@koa/cors": ^4.0.0 - "@koa/router": ^14.0.0 - "@lucide/lab": ^0.1.2 - "@lukeed/uuid": ^2.0.1 - "@octokit/rest": ^22.0.1 - "@percy/cli": ^1.31.1 - "@percy/ember": ^5.0.0 - "@playwright/test": ^1.54.0 - "@rollup/plugin-babel": ^6.0.4 - "@sentry/node": ^8.31.0 - "@simple-dom/interface": ^1.4.0 - "@simple-dom/parser": ^1.4.0 - "@simple-dom/serializer": ^1.4.0 - "@simple-dom/void-map": ^1.4.0 - "@sinonjs/fake-timers": ^11.2.2 - "@sqlite.org/sqlite-wasm": 3.45.1-build1 - "@tabler/icons": ^3.19.0 - "@types/archiver": ^7.0.0 - "@types/babel__core": ^7.1.19 - "@types/babel__generator": ^7.6.4 - "@types/babel__traverse": ^7.14.2 - "@types/diff": ^5.0.2 - "@types/dompurify": ^3.0.2 - "@types/eslint": 8.56.5 - "@types/flat": ^5.0.5 - "@types/fs-extra": ^11.0.4 - "@types/htmlbars-inline-precompile": ^3.0.3 - "@types/indefinite": ^2.3.4 - "@types/js-string-escape": ^1.0.1 - "@types/js-yaml": ^4.0.9 - "@types/jsdom": ^21.1.1 - "@types/jsonwebtoken": ^9.0.5 - "@types/koa": ^2.13.5 - "@types/koa-compose": ^3.2.5 - "@types/koa__cors": ^4.0.0 - "@types/koa__router": ^12.0.0 - "@types/line-column": ^1.0.0 - "@types/lodash": ^4.17.15 - "@types/matrix-js-sdk": ^11.0.1 - "@types/mime-types": ^2.1.1 - "@types/ms": ^2.1.0 - "@types/node": ^24.3.0 - "@types/pg": ^8.11.5 - "@types/pluralize": ^0.0.30 - "@types/qs": ^6.9.17 - "@types/qunit": ^2.19.12 - "@types/rsvp": ^4.0.9 - "@types/sane": ^2.0.1 - "@types/sinon": ^17.0.3 - "@types/sinonjs__fake-timers": ^8.1.5 - "@types/statuses": ^2.0.5 - "@types/stream-chain": ^2.0.1 - "@types/superagent": ^8.1.9 - "@types/stream-json": ^1.7.3 - "@types/string.prototype.matchall": ^4.0.1 - "@types/supertest": ^2.0.12 - "@types/tmp": ^0.2.3 - "@types/uuid": ^9.0.8 - "@types/yargs": ^17.0.10 - "@typescript-eslint/eslint-plugin": ^7.18.0 - "@typescript-eslint/parser": ^7.18.0 - "@universal-ember/test-support": ^0.5.1 - "@vscode/vsce": ^3.1.0 + '@actions/core': ^1.2.6 + '@actions/github': ^4.0.0 + '@aws-crypto/sha256-js': ^5.2.0 + '@babel/core': ^7.26.10 + '@babel/generator': ^7.17.8 + '@babel/helper-module-imports': ^7.18.2 + '@babel/helper-module-transforms': ^7.18.9 + '@babel/parser': 7.27.0 + '@babel/plugin-proposal-class-properties': ^7.18.6 + '@babel/plugin-proposal-decorators': ^7.23.2 + '@babel/plugin-syntax-class-properties': ^7.12.13 + '@babel/plugin-syntax-decorators': ^7.17.12 + '@babel/plugin-syntax-typescript': ^7.17.12 + '@babel/plugin-transform-class-properties': ^7.22.5 + '@babel/plugin-transform-class-static-block': ^7.22.11 + '@babel/plugin-transform-modules-amd': ^7.13.0 + '@babel/plugin-transform-typescript': ^7.16.8 + '@babel/preset-typescript': ^7.24.7 + '@babel/runtime': ^7.22.11 + '@babel/traverse': 7.27.0 + '@cardstack/requirejs-monaco-ember-polyfill': ^0.0.1 + '@cardstack/view-transitions': ^0.2.0 + '@ember/string': ^4.0.1 + '@ember/test-waiters': ^4.1.1 + '@eslint/eslintrc': ^2.1.4 + '@eslint/js': ^8.57.1 + '@floating-ui/dom': ^1.6.3 + '@glimmer/component': ^2.0.0 + '@glint/environment-ember-loose': ^1.5.2 + '@koa/cors': ^4.0.0 + '@koa/router': ^14.0.0 + '@lucide/lab': ^0.1.2 + '@lukeed/uuid': ^2.0.1 + '@octokit/rest': ^22.0.1 + '@percy/cli': ^1.31.1 + '@percy/ember': ^5.0.0 + '@playwright/test': ^1.54.0 + '@rollup/plugin-babel': ^6.0.4 + '@sentry/node': ^8.31.0 + '@simple-dom/interface': ^1.4.0 + '@simple-dom/parser': ^1.4.0 + '@simple-dom/serializer': ^1.4.0 + '@simple-dom/void-map': ^1.4.0 + '@sinonjs/fake-timers': ^11.2.2 + '@sqlite.org/sqlite-wasm': 3.45.1-build1 + '@tabler/icons': ^3.19.0 + '@types/archiver': ^7.0.0 + '@types/babel__core': ^7.1.19 + '@types/babel__generator': ^7.6.4 + '@types/babel__traverse': ^7.14.2 + '@types/diff': ^5.0.2 + '@types/dompurify': ^3.0.2 + '@types/eslint': 8.56.5 + '@types/flat': ^5.0.5 + '@types/fs-extra': ^11.0.4 + '@types/htmlbars-inline-precompile': ^3.0.3 + '@types/indefinite': ^2.3.4 + '@types/js-string-escape': ^1.0.1 + '@types/js-yaml': ^4.0.9 + '@types/jsdom': ^21.1.1 + '@types/jsonwebtoken': 9.0.10 + '@types/koa': ^2.13.5 + '@types/koa-compose': ^3.2.5 + '@types/koa__cors': ^4.0.0 + '@types/koa__router': ^12.0.0 + '@types/line-column': ^1.0.0 + '@types/lodash': ^4.17.15 + '@types/matrix-js-sdk': ^11.0.1 + '@types/mime-types': ^2.1.1 + '@types/ms': ^2.1.0 + '@types/node': ^24.3.0 + '@types/pg': ^8.11.5 + '@types/pluralize': ^0.0.30 + '@types/qs': ^6.9.17 + '@types/qunit': ^2.19.12 + '@types/rsvp': ^4.0.9 + '@types/sane': ^2.0.1 + '@types/sinon': ^17.0.3 + '@types/sinonjs__fake-timers': ^8.1.5 + '@types/statuses': ^2.0.5 + '@types/stream-chain': ^2.0.1 + '@types/stream-json': ^1.7.3 + '@types/string.prototype.matchall': ^4.0.1 + '@types/superagent': ^8.1.9 + '@types/supertest': ^2.0.12 + '@types/tmp': ^0.2.3 + '@types/uuid': ^9.0.8 + '@types/yargs': ^17.0.10 + '@typescript-eslint/eslint-plugin': ^7.18.0 + '@typescript-eslint/parser': ^7.18.0 + '@universal-ember/test-support': ^0.5.1 + '@vscode/vsce': ^3.1.0 ajv: ^8.17.1 archiver: ^7.0.0 awesome-phonenumber: ^7.2.0 @@ -167,7 +167,7 @@ catalog: js-yaml: ^4.1.0 jsdom: ^21.1.1 json-typescript: ^1.1.2 - jsonwebtoken: ^9.0.2 + jsonwebtoken: 9.0.3 koa: ^2.14.1 koa-compose: ^4.1.0 koa-proxies: ^0.12.3 From 80165048976c0121deb5806119b1adbb536c4ddd Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Fri, 13 Mar 2026 13:21:07 +0000 Subject: [PATCH 07/23] Allow cached realm boots to skip full reindex --- packages/realm-server/main.ts | 6 ++++-- packages/software-factory/src/harness.ts | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index bd36f1d16c..eda79d8558 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -89,6 +89,8 @@ if (process.env.DISABLE_MODULE_CACHING === 'true') { } const ENABLE_FILE_WATCHER = process.env.ENABLE_FILE_WATCHER === 'true'; +const FULL_INDEX_ON_STARTUP = + process.env.REALM_SERVER_FULL_INDEX_ON_STARTUP !== 'false'; let { port, @@ -240,7 +242,7 @@ let autoMigrate = migrateDB || undefined; log.info( `Realm server boot config: port=${port} serverURL=${serverURL} distURL=${distURL} matrixURL=${matrixURL} realmsRootPath=${realmsRootPath} migrateDB=${Boolean( migrateDB, - )} workerManagerPort=${workerManagerPort ?? 'none'} prerendererUrl=${prerendererUrl} enableFileWatcher=${ENABLE_FILE_WATCHER}`, + )} workerManagerPort=${workerManagerPort ?? 'none'} prerendererUrl=${prerendererUrl} enableFileWatcher=${ENABLE_FILE_WATCHER} fullIndexOnStartup=${FULL_INDEX_ON_STARTUP}`, ); log.info(`Realm paths: ${paths.map(String).join(', ')}`); @@ -323,7 +325,7 @@ const getIndexHTML = async () => { ), }, { - fullIndexOnStartup: true, + ...(FULL_INDEX_ON_STARTUP ? { fullIndexOnStartup: true as const } : {}), ...(process.env.DISABLE_MODULE_CACHING === 'true' ? { disableModuleCaching: true } : {}), diff --git a/packages/software-factory/src/harness.ts b/packages/software-factory/src/harness.ts index eea770a1c3..e305bfc604 100644 --- a/packages/software-factory/src/harness.ts +++ b/packages/software-factory/src/harness.ts @@ -766,12 +766,14 @@ async function startIsolatedRealmStack({ databaseName, context, migrateDB, + fullIndexOnStartup, }: { realmDir: string; realmURL: URL; databaseName: string; context: FactorySupportContext; migrateDB: boolean; + fullIndexOnStartup: boolean; }): Promise { let rootDir = mkdtempSync(join(tmpdir(), 'software-factory-realms-')); let testRealmDir = join(rootDir, 'test'); @@ -792,6 +794,7 @@ async function startIsolatedRealmStack({ MATRIX_URL: context.matrixURL, MATRIX_REGISTRATION_SHARED_SECRET: context.matrixRegistrationSecret, REALM_SERVER_MATRIX_USERNAME: DEFAULT_MATRIX_SERVER_USERNAME, + REALM_SERVER_FULL_INDEX_ON_STARTUP: String(fullIndexOnStartup), LOW_CREDIT_THRESHOLD: '2000', PUBLISHED_REALM_BOXEL_SPACE_DOMAIN: `localhost:${REALM_SERVER_PORT}`, PUBLISHED_REALM_BOXEL_SITE_DOMAIN: `localhost:${REALM_SERVER_PORT}`, @@ -949,6 +952,7 @@ async function buildTemplateDatabase({ databaseName: builderDatabaseName, context, migrateDB: !hasMigratedTemplate, + fullIndexOnStartup: true, }); try { @@ -1138,6 +1142,7 @@ export async function startFactoryRealmServer( databaseName, context, migrateDB: false, + fullIndexOnStartup: false, }); return { From 4dbb3dcf8b81408462f2004d15408ebc93039340 Mon Sep 17 00:00:00 2001 From: Ian Calvert Date: Fri, 13 Mar 2026 13:47:53 +0000 Subject: [PATCH 08/23] Reuse support services across factory test runs --- packages/software-factory/README.md | 5 ++ packages/software-factory/package.json | 1 + .../software-factory/playwright.config.ts | 1 + .../playwright.global-setup.ts | 89 +++++++++++++++++-- .../playwright.global-teardown.ts | 34 +++++++ .../software-factory/src/cli/serve-realm.ts | 9 ++ .../software-factory/src/cli/serve-support.ts | 38 ++++++++ .../software-factory/src/cli/smoke-realm.ts | 9 ++ .../software-factory/src/runtime-metadata.ts | 29 ++++++ packages/software-factory/tests/fixtures.ts | 11 +++ 10 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 packages/software-factory/playwright.global-teardown.ts create mode 100644 packages/software-factory/src/cli/serve-support.ts create mode 100644 packages/software-factory/src/runtime-metadata.ts diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index 67447904d1..568a340bd4 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -21,6 +21,8 @@ with `SOFTWARE_FACTORY_INCLUDE_SKILLS=1`. - `pnpm cache:prepare` - Builds or reuses the cached template database for `demo-realm/` +- `pnpm serve:support` + - Starts shared support services and prepares a reusable runtime context in the background - `pnpm serve:realm` - Starts the isolated realm server on `http://localhost:4205/test/` - `pnpm smoke:realm` @@ -48,9 +50,12 @@ pnpm smoke:realm ./my-realm Person/example-card ## Notes - Template DBs are reused across runs while the seeded Postgres container stays up. +- `serve:support` publishes a shared support context in `/tmp/software-factory-runtime/support.json`. +- When that shared support context exists, `serve:realm` and `smoke:realm` reuse the running Synapse and prerender services instead of restarting them. - Each Playwright test still starts a fresh realm server and fresh runtime database cloned from the cached template DB, so server-side mutations do not leak across tests. +- Playwright keeps the support services alive for the whole run and only restarts the realm server/runtime DB per test. - The browser tests seed a deterministic local Matrix user (`software-factory-browser`) so they do not depend on a human-managed profile. - Host requests for the base realm URL are redirected to the isolated realm diff --git a/packages/software-factory/package.json b/packages/software-factory/package.json index b695521611..d26867f49f 100644 --- a/packages/software-factory/package.json +++ b/packages/software-factory/package.json @@ -16,6 +16,7 @@ "lint:types": "tsc --noEmit src/index.ts", "serve:prerender": "tsx src/cli/serve-prerender.ts", "serve:realm": "tsx src/cli/serve-realm.ts", + "serve:support": "tsx src/cli/serve-support.ts", "smoke:realm": "tsx src/cli/smoke-realm.ts", "test:playwright": "playwright test", "test:playwright:headed": "playwright test --headed" diff --git a/packages/software-factory/playwright.config.ts b/packages/software-factory/playwright.config.ts index 0b755adbea..0334b26616 100644 --- a/packages/software-factory/playwright.config.ts +++ b/packages/software-factory/playwright.config.ts @@ -19,4 +19,5 @@ export default defineConfig({ screenshot: 'only-on-failure', }, globalSetup: './playwright.global-setup.ts', + globalTeardown: './playwright.global-teardown.ts', }); diff --git a/packages/software-factory/playwright.global-setup.ts b/packages/software-factory/playwright.global-setup.ts index 38e52b705c..b642681fd0 100644 --- a/packages/software-factory/playwright.global-setup.ts +++ b/packages/software-factory/playwright.global-setup.ts @@ -1,6 +1,17 @@ -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + defaultSupportMetadataFile, + sharedRuntimeDir, +} from './src/runtime-metadata.ts'; const packageRoot = resolve(fileURLToPath(new URL('.', import.meta.url))); const realmDir = resolve( @@ -8,15 +19,75 @@ const realmDir = resolve( process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', ); +function appendLog(buffer: string, chunk: string): string { + let combined = `${buffer}${chunk}`; + return combined.length > 20_000 ? combined.slice(-20_000) : combined; +} + +async function waitForMetadataFile( + metadataFile: string, + child: ReturnType, + getLogs: () => string, + timeoutMs = 120_000, +): Promise { + let startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (existsSync(metadataFile)) { + return JSON.parse(readFileSync(metadataFile, 'utf8')) as T; + } + + if (child.exitCode !== null) { + throw new Error( + `software-factory support exited early with code ${child.exitCode}\n${getLogs()}`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error( + `timed out waiting for software-factory support metadata ${metadataFile}\n${getLogs()}`, + ); +} + export default async function globalSetup() { - let cacheResult = spawnSync('pnpm', ['cache:prepare', realmDir], { + rmSync(sharedRuntimeDir, { recursive: true, force: true }); + mkdirSync(sharedRuntimeDir, { recursive: true }); + + let logs = ''; + let child = spawn('pnpm', ['serve:support', realmDir], { cwd: packageRoot, - stdio: 'inherit', - env: process.env, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + SOFTWARE_FACTORY_METADATA_FILE: defaultSupportMetadataFile, + SOFTWARE_FACTORY_SUPPORT_METADATA_FILE: defaultSupportMetadataFile, + }, }); - if (cacheResult.status !== 0) { - throw new Error( - `Failed to prepare software-factory cache (exit ${cacheResult.status})`, - ); - } + + child.stdout?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + child.stderr?.on('data', (chunk) => { + logs = appendLog(logs, String(chunk)); + }); + + let payload = await waitForMetadataFile<{ + realmDir: string; + context: Record; + }>(defaultSupportMetadataFile, child, () => logs); + + writeFileSync( + defaultSupportMetadataFile, + JSON.stringify( + { + ...payload, + pid: child.pid, + }, + null, + 2, + ), + ); } diff --git a/packages/software-factory/playwright.global-teardown.ts b/packages/software-factory/playwright.global-teardown.ts new file mode 100644 index 0000000000..f667d25519 --- /dev/null +++ b/packages/software-factory/playwright.global-teardown.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { + defaultSupportMetadataFile, + sharedRuntimeDir, +} from './src/runtime-metadata.ts'; + +function killProcessGroup(pid: number, signal: NodeJS.Signals) { + try { + process.kill(-pid, signal); + } catch (error) { + let nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== 'ESRCH') { + throw error; + } + } +} + +export default async function globalTeardown() { + try { + if (existsSync(defaultSupportMetadataFile)) { + let { pid } = JSON.parse( + readFileSync(defaultSupportMetadataFile, 'utf8'), + ) as { + pid?: number; + }; + + if (typeof pid === 'number') { + killProcessGroup(pid, 'SIGTERM'); + } + } + } finally { + rmSync(sharedRuntimeDir, { recursive: true, force: true }); + } +} diff --git a/packages/software-factory/src/cli/serve-realm.ts b/packages/software-factory/src/cli/serve-realm.ts index a861dec1a7..0791b5354f 100644 --- a/packages/software-factory/src/cli/serve-realm.ts +++ b/packages/software-factory/src/cli/serve-realm.ts @@ -1,10 +1,19 @@ // @ts-nocheck +import { readSupportContext } from '../runtime-metadata.ts'; import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { startFactoryRealmServer } from '../harness.ts'; let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); + +if (!process.env.SOFTWARE_FACTORY_CONTEXT) { + let supportContext = readSupportContext(); + if (supportContext) { + process.env.SOFTWARE_FACTORY_CONTEXT = JSON.stringify(supportContext); + } +} + try { let runtime = await startFactoryRealmServer({ realmDir, diff --git a/packages/software-factory/src/cli/serve-support.ts b/packages/software-factory/src/cli/serve-support.ts new file mode 100644 index 0000000000..730f952bde --- /dev/null +++ b/packages/software-factory/src/cli/serve-support.ts @@ -0,0 +1,38 @@ +// @ts-nocheck +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { startFactoryGlobalContext } from '../harness.ts'; +import { + defaultSupportMetadataFile, + sharedRuntimeDir, +} from '../runtime-metadata.ts'; + +let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); +let metadataFile = + process.env.SOFTWARE_FACTORY_METADATA_FILE ?? defaultSupportMetadataFile; + +try { + let support = await startFactoryGlobalContext({ realmDir }); + + let payload = { + realmDir, + context: support.context, + }; + + mkdirSync(sharedRuntimeDir, { recursive: true }); + writeFileSync(metadataFile, JSON.stringify(payload, null, 2)); + + console.log(JSON.stringify(payload, null, 2)); + + let stop = async () => { + await support.stop(); + process.exit(0); + }; + + process.on('SIGINT', stop); + process.on('SIGTERM', stop); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/software-factory/src/cli/smoke-realm.ts b/packages/software-factory/src/cli/smoke-realm.ts index 49105fdafe..3fb2ab5dfc 100644 --- a/packages/software-factory/src/cli/smoke-realm.ts +++ b/packages/software-factory/src/cli/smoke-realm.ts @@ -2,9 +2,18 @@ import { resolve } from 'node:path'; import { fetchRealmCardJson } from '../harness.ts'; +import { readSupportContext } from '../runtime-metadata.ts'; let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); let cardPath = process.argv[3] ?? 'person-1'; + +if (!process.env.SOFTWARE_FACTORY_CONTEXT) { + let supportContext = readSupportContext(); + if (supportContext) { + process.env.SOFTWARE_FACTORY_CONTEXT = JSON.stringify(supportContext); + } +} + try { let response = await fetchRealmCardJson(cardPath, { realmDir }); console.log( diff --git a/packages/software-factory/src/runtime-metadata.ts b/packages/software-factory/src/runtime-metadata.ts new file mode 100644 index 0000000000..a384249254 --- /dev/null +++ b/packages/software-factory/src/runtime-metadata.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const sharedRuntimeDir = join(tmpdir(), 'software-factory-runtime'); +export const defaultSupportMetadataFile = join( + sharedRuntimeDir, + 'support.json', +); + +export function getSupportMetadataFile() { + return ( + process.env.SOFTWARE_FACTORY_SUPPORT_METADATA_FILE ?? + defaultSupportMetadataFile + ); +} + +export function readSupportContext(): Record | undefined { + let metadataFile = getSupportMetadataFile(); + if (!existsSync(metadataFile)) { + return undefined; + } + + let metadata = JSON.parse(readFileSync(metadataFile, 'utf8')) as { + context?: Record; + }; + + return metadata.context; +} diff --git a/packages/software-factory/tests/fixtures.ts b/packages/software-factory/tests/fixtures.ts index 2b370d3aa4..525e170a04 100644 --- a/packages/software-factory/tests/fixtures.ts +++ b/packages/software-factory/tests/fixtures.ts @@ -6,6 +6,7 @@ import { join, resolve } from 'node:path'; import type { Page } from '@playwright/test'; import { test as base, expect } from '@playwright/test'; +import { defaultSupportMetadataFile } from '../src/runtime-metadata'; import { buildBrowserState, installBrowserState } from './helpers/browser-auth'; type StartedFactoryRealm = { @@ -77,6 +78,11 @@ async function startRealmProcess(realmDir = defaultRealmDir) { let tempDir = mkdtempSync(join(tmpdir(), 'software-factory-realm-')); let metadataFile = join(tempDir, 'runtime.json'); let logs = ''; + let supportMetadata = existsSync(defaultSupportMetadataFile) + ? (JSON.parse(readFileSync(defaultSupportMetadataFile, 'utf8')) as { + context?: Record; + }) + : undefined; let child = spawn('pnpm', ['serve:realm', realmDir], { cwd: packageRoot, @@ -84,6 +90,11 @@ async function startRealmProcess(realmDir = defaultRealmDir) { env: { ...process.env, SOFTWARE_FACTORY_METADATA_FILE: metadataFile, + ...(supportMetadata?.context + ? { + SOFTWARE_FACTORY_CONTEXT: JSON.stringify(supportMetadata.context), + } + : {}), }, stdio: ['ignore', 'pipe', 'pipe'], }); From be2ee8cceb2560a3353b0498099321ec11b42acc Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 13 Mar 2026 12:02:40 -0400 Subject: [PATCH 09/23] add orchestration plan and move factory into the shared realm --- .../docs/one-shot-factory-go-plan.md | 450 ++++++++++++++++++ packages/software-factory/prompts/.gitkeep | 1 - .../realm/darkfactory-schema.gts | 173 +++++++ .../software-factory/realm/darkfactory-ui.gts | 386 +++++++++++++++ .../software-factory/realm/darkfactory.gts | 8 + 5 files changed, 1017 insertions(+), 1 deletion(-) create mode 100644 packages/software-factory/docs/one-shot-factory-go-plan.md delete mode 100644 packages/software-factory/prompts/.gitkeep create mode 100644 packages/software-factory/realm/darkfactory-schema.gts create mode 100644 packages/software-factory/realm/darkfactory-ui.gts create mode 100644 packages/software-factory/realm/darkfactory.gts diff --git a/packages/software-factory/docs/one-shot-factory-go-plan.md b/packages/software-factory/docs/one-shot-factory-go-plan.md new file mode 100644 index 0000000000..296eb0f991 --- /dev/null +++ b/packages/software-factory/docs/one-shot-factory-go-plan.md @@ -0,0 +1,450 @@ +# One-Shot Software Factory Plan + +## Goal + +Turn the current `experiment_1` workflow from an agent-assisted toolbox into a single-entrypoint flow that can: + +1. accept a brief URL like `http://localhost:4201/software-factory/Wiki/sticky-note` +2. target a local Boxel realm such as `packages/realm-server/realms/localhost_4201/hassan1/personal` +3. bootstrap project artifacts in that target realm +4. immediately enter implementation and verification iterations +5. stop only when a clear completion or blocker condition is reached + +This document covers: + +- the desired one-shot flow +- what is currently missing +- the minimum implementation needed in `experiment_1` + +## Current State + +`experiment_1` already has useful primitives: + +- `scripts/boxel-session.mjs` + - gets browser-local auth/session payloads +- `scripts/boxel-search.mjs` + - searches a realm via `_search` +- `scripts/pick-ticket.mjs` + - finds candidate tickets +- `scripts/run-realm-tests.mjs` + - runs realm-hosted Playwright tests against a scratch realm +- `realms/guidance-tasks/darkfactory-schema.gts` + - defines `Project`, `Ticket`, `KnowledgeArticle`, `AgentProfile` +- `realms/guidance-tasks/darkfactory-ui.gts` + - renders those cards +- `AGENTS.md` and repo-local skills + - describe the intended software-factory loop + +What does not exist yet is a real orchestrator that binds these parts together. + +## Desired UX + +The target user experience is one command or one prompt: + +```bash +npm run factory:go -- \ + --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ + --target-realm-path /home/hassan/codez/boxel/packages/realm-server/realms/localhost_4201/hassan1/personal \ + --mode implement +``` + +Or agent-side: + +```text +Use the public brief at http://localhost:4201/software-factory/Wiki/sticky-note. +Bootstrap the project in my personal realm, then immediately start implementation and testing iterations until the MVP is done or blocked. +``` + +The important property is that the user should not need to manually: + +- create project cards +- create ticket cards +- decide the first ticket +- choose the first verification approach +- hand-hold the transition from planning into implementation + +## Required One-Shot Flow + +### Phase 1: Intake + +Inputs: + +- `brief-url` +- `target-realm-path` +- optional `target-realm-url` +- optional mode: + - `bootstrap` + - `implement` + - `resume` + +Required behavior: + +- fetch the brief card JSON +- normalize the brief into a concise internal representation +- detect whether the brief is vague +- if vague, automatically bias toward a thin MVP + +### Phase 2: Target Realm Preparation + +Required behavior: + +- resolve the target realm URL from `.boxel-sync.json` or CLI arguments +- ensure the target realm has the tracker module available +- ensure the target realm has a visible entry surface such as `cards-grid.json` + +Minimum requirement: + +- the target realm must be self-contained enough that `Project`, `Ticket`, and `KnowledgeArticle` cards resolve locally + +### Phase 3: Bootstrap Project Artifacts + +Required behavior: + +- create or update one `Project` +- create or update one or more `KnowledgeArticle` cards +- create starter `Ticket` cards +- mark exactly one starter ticket as `in_progress` + +Rules: + +- do not duplicate artifacts if they already exist +- derive stable identifiers from the brief intent where possible +- record assumptions explicitly when the brief is underspecified + +### Phase 4: Execution Loop + +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 + +### Phase 5: Verification + +Default verification policy: + +- 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 + +The flow must not stall just because full test infrastructure does not yet exist. + +### Phase 6: Stop Conditions + +The one-shot flow should stop only when one of these becomes true: + +- `Project.successCriteria` are satisfied for the intended MVP +- an explicit blocker requires human clarification +- auth, server availability, or realm integrity prevents further progress + +It should not stop simply because bootstrap is complete. + +## What Is Missing Today + +### 1. A Real Orchestrator + +There is no command that owns the full lifecycle from brief intake through repeated ticket execution. + +### 2. Deterministic Brief-to-Artifact Rules + +The bootstrap logic currently lives in agent judgment. It needs stable rules for: + +- project naming +- ticket count and order +- assumption capture +- idempotent updates on rerun + +### 3. Target Realm Bootstrap + +The target realm currently needs tracker support added manually or implicitly. That should be an explicit, reusable setup step. + +### 4. Resume Semantics + +The system needs to resume from existing state instead of recreating everything on rerun. + +### 5. Default Verification Policy + +The first verification move should be encoded so the runner knows what to do when there are no tests yet. + +### 6. Execution Policy + +The current behavior is described in prose, but not encoded as a decision engine. The one-shot flow needs explicit answers to: + +- when to keep implementing +- when to create new tickets +- when to capture knowledge +- when to ask the user a question + +## Minimal Implementation Plan For `experiment_1` + +This plan aims for the smallest change set that produces a believable `factory:go` flow. + +## Scope + +Add a new script and a small shared library layer. Do not attempt a fully autonomous general planner on the first pass. + +The first version should support: + +- one brief URL +- one target realm path +- local Boxel realms +- Boxel-card implementation workflows +- simple bootstrap and first-ticket execution + +## Proposed New Entry Point + +Add a script: + +```json +"factory:go": "node scripts/factory-go.mjs" +``` + +Expected usage: + +```bash +npm run factory:go -- \ + --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ + --target-realm-path /home/hassan/codez/boxel/packages/realm-server/realms/localhost_4201/hassan1/personal \ + --mode implement +``` + +## Proposed Implementation Pieces + +### A. `scripts/factory-go.mjs` + +This should be the top-level orchestrator. + +Responsibilities: + +- parse args +- fetch the brief +- resolve target realm path and URL +- ensure tracker files exist in target realm +- bootstrap or reconcile project artifacts +- pick the next ticket +- invoke the implementation loop +- print a structured summary at the end + +This file should stay thin and delegate to helpers. + +### B. `scripts/lib/factory-bootstrap.mjs` + +New helper module for creating or updating: + +- `Project` +- `KnowledgeArticle` +- `Ticket` +- `cards-grid.json` + +Responsibilities: + +- turn a fetched brief into an internal normalized shape +- generate stable filenames and IDs +- write JSON artifact files idempotently +- avoid duplicating cards on reruns + +For the first version, hard-code the bootstrap pattern: + +- one project +- two knowledge articles +- three tickets +- one active ticket + +That is enough for a thin MVP flow. + +### C. `scripts/lib/factory-target-realm.mjs` + +New helper module for target realm preparation. + +Responsibilities: + +- resolve target realm URL from `.boxel-sync.json` when available +- infer local workspace URL from path when possible +- ensure the `darkfactory` module files exist in the target realm +- ensure `cards-grid.json` exists + +This isolates the realm bootstrapping concern from the orchestration logic. + +### D. `scripts/lib/factory-brief.mjs` + +New helper module for brief intake. + +Responsibilities: + +- fetch a brief card by URL +- extract useful fields from card JSON +- normalize vague briefs into a simple planning shape +- emit metadata like: + - title + - summary + - content + - source URL + - ambiguity score or `isVague` flag + +For version one, the `isVague` check can be heuristic and simple. + +### E. `scripts/lib/factory-loop.mjs` + +New helper module for the first execution loop. + +Responsibilities: + +- find the active ticket +- 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 + +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. + +## Implementation Backend Choice + +This is the main architectural decision. + +There are two options: + +### Option 1: Agent-Assisted Orchestration + +The script performs bootstrap and loop setup, but the actual implementation still happens through the agent runtime. + +Pros: + +- smallest initial build +- matches the current system +- easiest to validate quickly + +Cons: + +- not a fully self-contained CLI runner + +### Option 2: Scripted Local Mutations Only + +The script itself creates and edits Boxel files without the agent. + +Pros: + +- deterministic +- easier to rerun + +Cons: + +- quickly turns into a brittle rule engine +- cannot generalize well from vague briefs + +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. + +## First-Version Execution Contract + +The first version of `factory:go` should do exactly this: + +1. fetch the brief +2. ensure the target realm contains the tracker module and visible entry surface +3. create or reconcile starter project artifacts +4. select the first actionable ticket +5. print a structured execution bundle for the agent or next stage + +If run in `--mode implement`, it should then: + +6. open the active ticket context +7. perform one implementation cycle +8. run verification +9. update ticket state + +It does not need to complete an entire multi-ticket product in version one. + +## File Changes For Minimal Version + +Files to add: + +- `packages/software-factory/experiment_1/scripts/factory-go.mjs` +- `packages/software-factory/experiment_1/scripts/lib/factory-bootstrap.mjs` +- `packages/software-factory/experiment_1/scripts/lib/factory-target-realm.mjs` +- `packages/software-factory/experiment_1/scripts/lib/factory-brief.mjs` +- `packages/software-factory/experiment_1/scripts/lib/factory-loop.mjs` + +Files to update: + +- `packages/software-factory/experiment_1/package.json` + - add `factory:go` +- `packages/software-factory/experiment_1/AGENTS.md` + - document the new one-shot flow + +Optional later additions: + +- `packages/software-factory/experiment_1/tests/factory-go.spec.mjs` + - verifies bootstrap behavior + +## Suggested Output Contract + +`factory:go` should emit machine-readable JSON at the end. Example shape: + +```json +{ + "brief": { + "url": "http://localhost:4201/software-factory/Wiki/sticky-note", + "title": "Sticky Note", + "isVague": true + }, + "targetRealm": { + "path": "/.../personal", + "url": "http://localhost:4201/hassan1/personal/" + }, + "bootstrap": { + "createdProject": "Project/sticky-note-mvp", + "createdTickets": [ + "Ticket/define-sticky-note-core", + "Ticket/design-board-ready-views", + "Ticket/add-linking-and-automation" + ] + }, + "activeTicket": { + "id": "Ticket/define-sticky-note-core", + "status": "in_progress" + }, + "verification": { + "strategy": "render-first" + } +} +``` + +This keeps the process inspectable and resumable. + +## Acceptance Criteria For The First `factory:go` + +- a user can point to a brief URL and a target realm path +- the target realm ends up with a coherent project bootstrap +- exactly one ticket becomes active +- rerunning does not create duplicate starter artifacts +- the flow can proceed directly into implementation work +- the system prefers a thin MVP when the brief is vague + +## Recommended Delivery Order + +1. add target realm bootstrap helpers +2. add brief fetch and normalization +3. add idempotent project artifact bootstrap +4. expose `factory:go` +5. add one-ticket implementation mode +6. add tests and stronger resume behavior + +## Practical Conclusion + +The missing piece is orchestration, not capability. The current project already has enough primitives to support a one-shot flow, but only after adding: + +- a formal entrypoint +- deterministic bootstrap rules +- target realm preparation +- a minimal implementation loop + +That is the smallest path to turning the current software-factory idea into something that feels like: + +“Point at a brief, say go, and watch it enter the delivery loop.” diff --git a/packages/software-factory/prompts/.gitkeep b/packages/software-factory/prompts/.gitkeep deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/software-factory/prompts/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/software-factory/realm/darkfactory-schema.gts b/packages/software-factory/realm/darkfactory-schema.gts new file mode 100644 index 0000000000..e78157c9a0 --- /dev/null +++ b/packages/software-factory/realm/darkfactory-schema.gts @@ -0,0 +1,173 @@ +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-schema', 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/realm/darkfactory-ui.gts b/packages/software-factory/realm/darkfactory-ui.gts new file mode 100644 index 0000000000..97ae536065 --- /dev/null +++ b/packages/software-factory/realm/darkfactory-ui.gts @@ -0,0 +1,386 @@ +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 { + +}; + +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 { + +}; + +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 new file mode 100644 index 0000000000..e1b5868473 --- /dev/null +++ b/packages/software-factory/realm/darkfactory.gts @@ -0,0 +1,8 @@ +// Public barrel for the DarkFactory tracker types. +export { + AgentProfile, + KnowledgeArticle, + Ticket, + Project, + DarkFactory, +} from './darkfactory-ui'; From 96dbd0261216618dd988400af5d6131291301c2a Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 13 Mar 2026 17:40:17 -0400 Subject: [PATCH 10/23] add testing strategy --- .../docs/software-factory-testing-strategy.md | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 packages/software-factory/docs/software-factory-testing-strategy.md diff --git a/packages/software-factory/docs/software-factory-testing-strategy.md b/packages/software-factory/docs/software-factory-testing-strategy.md new file mode 100644 index 0000000000..d2b7cbe8c2 --- /dev/null +++ b/packages/software-factory/docs/software-factory-testing-strategy.md @@ -0,0 +1,254 @@ +# Software Factory Testing Strategy + +## Goal + +Define a practical testing strategy for the software-factory work so that: + +1. normal Boxel artifacts are tested like normal software +2. the one-shot orchestration flow is tested deterministically +3. the agentic loop is tested as workflow behavior, not as "AI intelligence" +4. only a small end-to-end surface remains nondeterministic + +This document applies to: + +- the public `DarkFactory` module in `packages/software-factory/realm` +- the `factory:go` orchestration work in `packages/software-factory/experiment_1` + +## Core Principle + +Do not treat the agent loop as a single black box. + +Instead, split testing into layers: + +1. schema and UI tests +2. deterministic orchestration tests +3. loop simulation tests +4. thin end-to-end acceptance tests + +The more logic we can move into deterministic code, the less fragile the overall system becomes. + +## What We Are Actually Testing + +We are not trying to prove that a model "thinks well." + +We are trying to prove that: + +- briefs are normalized correctly +- project artifacts are created correctly +- ticket state transitions are correct +- verification gates are enforced +- reruns resume instead of duplicating work +- failure paths are handled predictably + +## Layer 1: DarkFactory Schema and UI + +This is the straightforward part. + +Test the `DarkFactory` cards like normal Boxel artifacts: + +- `Project` +- `Ticket` +- `KnowledgeArticle` +- `AgentProfile` + +Coverage should include: + +- public resolution from `http://localhost:4201/software-factory/darkfactory` +- rendering of the shared tracker cards +- cross-realm adoption by an external realm +- any card queries or embedded relations used by the tracker UI + +These tests should be deterministic and should not involve the agent loop. + +## Layer 2: Deterministic Orchestration Tests + +The `factory:go` command should mostly be testable without a real model. + +Focus areas: + +- argument parsing +- brief loading +- brief normalization +- target-realm bootstrap +- project artifact bootstrap +- verification-policy selection +- resume and idempotency behavior + +These should be covered with unit tests and focused integration tests. + +Examples: + +- a public wiki card becomes a normalized brief object +- a vague brief defaults to thin-MVP planning +- a temporary realm without tracker support gets bootstrapped correctly +- rerunning bootstrap does not create duplicate cards +- existing `in_progress` tickets are resumed instead of replaced + +## Layer 3: Loop Simulation Tests + +This is the main strategy for testing the agentic loop. + +Do not use a real LLM for most loop tests. + +Instead, introduce a fake executor that returns structured actions such as: + +- `create_file` +- `update_file` +- `create_card` +- `update_ticket` +- `run_verification` +- `record_knowledge` +- `request_clarification` +- `stop` + +Then test the loop as a state machine. + +Assertions should be about workflow behavior: + +- the right ticket is chosen +- the right state transitions occur +- failed verification keeps the ticket open +- successful verification advances the loop +- clarification paths stop correctly +- retries and resumes are handled correctly + +Do not assert exact natural-language output from the model. + +## Layer 4: Thin End-to-End Acceptance Tests + +Keep only a small number of true end-to-end tests. + +Suggested acceptance cases: + +1. Sticky Note bootstrap + - brief URL points to `software-factory/Wiki/sticky-note` + - target realm is a scratch or temp realm + - result is one project, starter knowledge cards, and starter tickets + +2. Sticky Note first implementation pass + - loop executes the first active ticket + - one implementation artifact is created + - one verification result is recorded + +3. Resume after partial progress + - rerun after partial state + - loop resumes instead of recreating artifacts + +These tests are slower and more brittle, so keep them few and high-signal. + +## What Not To Test Directly + +Avoid tests that depend on: + +- exact phrasing of generated text +- exact ticket wording +- exact `agentNotes` wording +- full open-ended model behavior + +Those tests will be noisy and hard to maintain. + +## Recommended Test Shape By Work Area + +### Public DarkFactory Module + +Use: + +- focused card rendering tests +- cross-realm adoption integration tests + +### `factory:go` Entry Point + +Use: + +- unit tests for CLI argument parsing +- integration tests for command startup and summary output + +### Brief Normalization + +Use: + +- pure unit tests with fixture brief payloads + +### Target Realm Bootstrap + +Use: + +- temporary-directory integration tests + +### Project Artifact Bootstrap + +Use: + +- temporary-realm integration tests +- rerun/idempotency tests + +### Verification Policy + +Use: + +- pure unit tests + +### Execution Loop + +Use: + +- fake-executor simulation tests + +### Resume and Idempotency + +Use: + +- integration tests plus loop simulation tests + +## Suggested First Test Milestones + +These are the highest-value early tests: + +1. public `DarkFactory` module resolves from an adopter realm +2. brief normalization handles the sticky-note wiki card +3. target realm bootstrap creates required surfaces in a temp realm +4. artifact bootstrap creates one project and one `in_progress` ticket +5. rerunning bootstrap does not duplicate artifacts +6. fake loop test covers success path +7. fake loop test covers failed verification path +8. one end-to-end sticky-note acceptance test + +## Ticket Mapping + +Testing is part of implementation and should stay attached to the current Linear tickets. + +The current mapping is: + +- `CS-10444` + - public module resolution and rendering coverage +- `CS-10445` + - adopter-realm integration verification +- `CS-10446` + - CLI entrypoint tests +- `CS-10447` + - brief-normalization unit tests +- `CS-10448` + - target-realm bootstrap integration tests +- `CS-10449` + - artifact-bootstrap and idempotency integration tests +- `CS-10451` + - verification-policy unit tests +- `CS-10450` + - fake-executor loop simulation tests +- `CS-10452` + - resume and rerun tests +- `CS-10453` + - docs accuracy validation + +## Summary + +The testing approach is: + +- test Boxel artifacts normally +- test orchestration deterministically +- test the loop with simulation +- keep real end-to-end coverage thin + +In short: + +test the software factory like workflow software, not like a general intelligence benchmark. From 4734ea3eef93b4c59e6971568e9f9265327c4be7 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 16 Mar 2026 09:44:06 -0400 Subject: [PATCH 11/23] normalizing software factory package and adding tests --- .github/workflows/ci.yaml | 55 +- .../.agents/skills/boxel-development/SKILL.md | 66 ++ .../references/dev-command-development.md | 161 ++++ .../references/dev-core-concept.md | 139 ++++ .../references/dev-core-patterns.md | 346 +++++++++ .../references/dev-data-management.md | 156 ++++ .../references/dev-defensive-programming.md | 118 +++ .../references/dev-delegated-rendering.md | 187 +++++ .../references/dev-enumerations.md | 209 ++++++ .../references/dev-external-libraries.md | 96 +++ .../references/dev-file-def.md | 166 +++++ .../references/dev-file-editing.md | 85 +++ .../references/dev-fitted-formats.md | 34 + .../references/dev-query-systems.md | 74 ++ .../references/dev-quick-reference.md | 104 +++ .../references/dev-replicate-ai.md | 111 +++ .../references/dev-spec-usage.md | 185 +++++ .../references/dev-styling-design.md | 106 +++ .../references/dev-technical-rules.md | 87 +++ .../references/dev-template-patterns.md | 325 ++++++++ .../references/dev-theme-design-system.md | 239 ++++++ .../skills/boxel-file-structure/SKILL.md | 305 ++++++++ .../.agents/skills/boxel-repair/SKILL.md | 42 ++ .../.agents/skills/boxel-restore/SKILL.md | 76 ++ .../.agents/skills/boxel-setup/SKILL.md | 103 +++ .../.agents/skills/boxel-sync/SKILL.md | 80 ++ .../.agents/skills/boxel-track/SKILL.md | 117 +++ .../.agents/skills/boxel-watch/SKILL.md | 66 ++ .../software-factory-operations/SKILL.md | 62 ++ packages/software-factory/.claude/CLAUDE.md | 699 ++++++++++++++++++ packages/software-factory/.claude/skills | 1 + packages/software-factory/AGENTS.md | 224 ++++++ packages/software-factory/README.md | 8 +- .../software-factory/demo-realm/person-1.json | 14 - .../software-factory/demo-realm/person-2.json | 14 - .../software-factory/demo-realm/person.gts | 25 - .../docs/one-shot-factory-go-plan.md | 49 +- .../docs/software-factory-testing-strategy.md | 36 + .../playwright.global-setup.ts | 16 +- .../realm/darkfactory-schema.gts | 2 +- .../software-factory/scripts/boxel-search.mjs | 71 ++ .../scripts/boxel-session.mjs | 21 + .../software-factory/scripts/lib/boxel.mjs | 242 ++++++ .../software-factory/scripts/pick-ticket.mjs | 82 ++ .../scripts/run-realm-tests.mjs | 204 +++++ .../software-factory/src/cli/cache-realm.ts | 5 +- .../software-factory/src/cli/serve-realm.ts | 7 +- .../software-factory/src/cli/serve-support.ts | 9 +- .../software-factory/src/cli/smoke-realm.ts | 7 +- packages/software-factory/src/harness.ts | 21 +- .../darkfactory-adopter}/.realm.json | 2 +- .../AgentProfile/demo-agent.json | 18 + .../DarkFactory/demo-factory.json | 22 + .../KnowledgeArticle/agent-onboarding.json | 25 + .../Project/demo-project.json | 33 + .../Ticket/ticket-001.json | 42 ++ .../darkfactory-adopter/agent-demo.json | 18 + .../darkfactory-adopter/factory-demo.json | 22 + .../darkfactory-adopter}/home.gts | 2 +- .../darkfactory-adopter}/index.json | 0 .../knowledge-article-demo.json | 25 + .../darkfactory-adopter/project-demo.json | 33 + .../darkfactory-adopter/ticket-demo.json | 42 ++ .../.realm.json | 5 + .../darkfactory-schema.gts | 173 +++++ .../darkfactory-ui.gts | 418 +++++++++++ .../darkfactory.gts | 8 + .../public-software-factory-source/home.gts | 9 + .../public-software-factory-source/index.json | 12 + .../tests/darkfactory.spec.ts | 114 +++ .../software-factory/tests/demo-realm.spec.ts | 93 --- packages/software-factory/tests/fixtures.ts | 35 +- 72 files changed, 6626 insertions(+), 182 deletions(-) create mode 100644 packages/software-factory/.agents/skills/boxel-development/SKILL.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-data-management.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-defensive-programming.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-delegated-rendering.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-enumerations.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-external-libraries.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-file-def.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-file-editing.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-fitted-formats.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-query-systems.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-quick-reference.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-replicate-ai.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-spec-usage.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-styling-design.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md create mode 100644 packages/software-factory/.agents/skills/boxel-development/references/dev-theme-design-system.md create mode 100644 packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md create mode 100644 packages/software-factory/.agents/skills/boxel-repair/SKILL.md create mode 100644 packages/software-factory/.agents/skills/boxel-restore/SKILL.md create mode 100644 packages/software-factory/.agents/skills/boxel-setup/SKILL.md create mode 100644 packages/software-factory/.agents/skills/boxel-sync/SKILL.md create mode 100644 packages/software-factory/.agents/skills/boxel-track/SKILL.md create mode 100644 packages/software-factory/.agents/skills/boxel-watch/SKILL.md create mode 100644 packages/software-factory/.agents/skills/software-factory-operations/SKILL.md create mode 100644 packages/software-factory/.claude/CLAUDE.md create mode 120000 packages/software-factory/.claude/skills create mode 100644 packages/software-factory/AGENTS.md delete mode 100644 packages/software-factory/demo-realm/person-1.json delete mode 100644 packages/software-factory/demo-realm/person-2.json delete mode 100644 packages/software-factory/demo-realm/person.gts create mode 100644 packages/software-factory/scripts/boxel-search.mjs create mode 100644 packages/software-factory/scripts/boxel-session.mjs create mode 100644 packages/software-factory/scripts/lib/boxel.mjs create mode 100644 packages/software-factory/scripts/pick-ticket.mjs create mode 100644 packages/software-factory/scripts/run-realm-tests.mjs rename packages/software-factory/{demo-realm => test-fixtures/darkfactory-adopter}/.realm.json (51%) create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/AgentProfile/demo-agent.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/DarkFactory/demo-factory.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/KnowledgeArticle/agent-onboarding.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/Project/demo-project.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/Ticket/ticket-001.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/agent-demo.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/factory-demo.json rename packages/software-factory/{demo-realm => test-fixtures/darkfactory-adopter}/home.gts (78%) rename packages/software-factory/{demo-realm => test-fixtures/darkfactory-adopter}/index.json (100%) create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/knowledge-article-demo.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/project-demo.json create mode 100644 packages/software-factory/test-fixtures/darkfactory-adopter/ticket-demo.json create mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/.realm.json create mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-schema.gts create mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-ui.gts create mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/darkfactory.gts create mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/home.gts create mode 100644 packages/software-factory/test-fixtures/public-software-factory-source/index.json create mode 100644 packages/software-factory/tests/darkfactory.spec.ts delete mode 100644 packages/software-factory/tests/demo-realm.spec.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49dd5cb6a5..9d3bbd1553 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,6 +29,7 @@ jobs: boxel-ui: ${{ steps.filter.outputs.boxel-ui }} matrix: ${{ steps.filter.outputs.matrix }} realm-server: ${{ steps.filter.outputs.realm-server }} + software-factory: ${{ steps.filter.outputs.software-factory }} vscode-boxel-tools: ${{ steps.filter.outputs.vscode-boxel-tools }} workspace-sync-cli: ${{ steps.filter.outputs.workspace-sync-cli }} # Force all tests to run when on ci-bisect* branches @@ -96,6 +97,15 @@ jobs: - 'packages/eslint-plugin-boxel/**' - 'packages/postgres/**' - 'packages/realm-server/**' + software-factory: + - *shared + - 'packages/base/**' + - 'packages/boxel-icons/**' + - 'packages/host/**' + - 'packages/matrix/**' + - 'packages/postgres/**' + - 'packages/realm-server/**' + - 'packages/software-factory/**' vscode-boxel-tools: - *shared - 'packages/vscode-boxel-tools/**' @@ -111,7 +121,7 @@ jobs: test-web-assets: name: Build test web assets needs: change-check - if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' + if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.software-factory == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' uses: ./.github/workflows/test-web-assets.yaml ai-bot-test: @@ -631,6 +641,49 @@ jobs: path: /tmp/test-realms.log retention-days: 30 + software-factory-test: + name: Software Factory Tests + needs: [change-check, test-web-assets] + if: needs.change-check.outputs.software-factory == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' + runs-on: ubuntu-latest + concurrency: + group: software-factory-test-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + - uses: ./.github/actions/init + - name: Download test web assets + uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 + with: + name: ${{ needs.test-web-assets.outputs.artifact_name }} + path: .test-web-assets-artifact + - name: Restore test web assets into workspace + shell: bash + run: | + shopt -s dotglob + cp -a .test-web-assets-artifact/. ./ + - name: Install Playwright Browsers + run: pnpm exec playwright install + working-directory: packages/software-factory + - name: Serve host dist (test assets) + uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # 1.0.7 + with: + run: pnpm serve:dist & + working-directory: packages/host + wait-for: 3m + wait-on: http-get://localhost:4200 + - name: Run Playwright tests + run: pnpm test:playwright + working-directory: packages/software-factory + - name: Upload Playwright traces + if: ${{ !cancelled() }} + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 + with: + name: software-factory-playwright-traces + path: packages/software-factory/test-results/**/trace.zip + retention-days: 30 + if-no-files-found: ignore + vscode-boxel-tools-package: name: Boxel Tools VS Code Extension package needs: [change-check, test-web-assets] diff --git a/packages/software-factory/.agents/skills/boxel-development/SKILL.md b/packages/software-factory/.agents/skills/boxel-development/SKILL.md new file mode 100644 index 0000000000..f9d7696f8e --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/SKILL.md @@ -0,0 +1,66 @@ +--- +name: boxel-development +description: Use when working on Boxel card development, especially creating or editing `.gts` card definitions, `.json` card instances, Boxel commands, themes, queries, templates, or related Boxel patterns in a synced workspace. Read the targeted files in `references/` instead of loading broad guidance by default. +--- + +# Boxel Development + +Use this skill for Boxel card and app development. Keep the top-level guidance lean and load only the references needed for the task. + +## Core Workflow + +1. Confirm whether the task is about a card definition, card instance, query, command, theme, file asset, or styling. +2. Read only the specific reference files that match the task. +3. For file placement, naming, or `adoptsFrom.module` paths, also read `../boxel-file-structure/SKILL.md`. +4. Apply the rules from the relevant references exactly when they are marked critical. +5. Ignore Boxel in-app editor instructions unless you are explicitly operating inside that environment. In this repo, prefer normal filesystem edits and CLI workflows. + +## Always Load First + +- `references/dev-core-concept.md` +- `references/dev-technical-rules.md` +- `references/dev-quick-reference.md` + +These three files establish the data model, the `contains` vs `linksTo` rule, required formats, inherited fields, and common import patterns. + +## Load By Task + +- Card structure and safe patterns: + `references/dev-core-patterns.md` +- Templates, delegated rendering, and field access: + `references/dev-template-patterns.md` + `references/dev-delegated-rendering.md` +- Styling and themes: + `references/dev-theme-design-system.md` + `references/dev-styling-design.md` + `references/dev-fitted-formats.md` +- Queries and data linking: + `references/dev-query-systems.md` + `references/dev-data-management.md` +- File-backed content and file asset cards: + `references/dev-file-def.md` +- Enum fields: + `references/dev-enumerations.md` +- Defensive component logic: + `references/dev-defensive-programming.md` +- Third-party libraries: + `references/dev-external-libraries.md` +- Command implementation: + `references/dev-command-development.md` +- Spec usage: + `references/dev-spec-usage.md` +- Replicate integration: + `references/dev-replicate-ai.md` + +## Usually Ignore Unless Explicitly Relevant + +- `references/dev-file-editing.md` + This is primarily for Boxel's in-app AI editing flow, not normal terminal-based editing. + +## Key Reminders + +- `CardDef` and `FileDef` references use `linksTo` / `linksToMany`. +- `FieldDef` values use `contains` / `containsMany`. +- Modern cards should implement `isolated`, `embedded`, and `fitted`. +- Be precise with relative JSON module paths. +- Prefer loading one or two reference files over reading the whole reference set. diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md new file mode 100644 index 0000000000..3a5acadd0d --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md @@ -0,0 +1,161 @@ +## Command Development Essentials + +Commands extend `Command` and execute workflows through host APIs. + +### Core Structure + +```gts +import { Command } from '@cardstack/runtime-common'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +class MyInput extends CardDef { + @field targetRealm = contains(StringField); +} + +export class MyCommand extends Command { + static actionVerb = 'Process'; + async getInputType() { return MyInput; } + + protected async run(input: MyInput): Promise { + // Validation first + if (!input.targetRealm) throw new Error('Target realm required'); + + // Execute workflow + // Return result or undefined + } +} +``` + +### Host Commands (IO Operations) + +**Never use `fetch` directly - always use host commands:** + +```gts +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy'; +import SearchCardsByQueryCommand from '@cardstack/boxel-host/commands/search-cards-by-query'; + +// Save a card +await new SaveCardCommand(this.commandContext).execute({ + card: myCard, + realm: 'https://realm-url/' +}); + +// Get a card +const card = await new GetCardCommand(this.commandContext).execute({ + cardId: 'https://realm/Card/id' +}); + +// External API call +const response = await new SendRequestViaProxyCommand(this.commandContext).execute({ + url: 'https://api.example.com/endpoint', + method: 'POST', + requestBody: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' } +}); +``` + +### OpenRouter API Pattern + +```gts +const headers = { + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://realms-staging.stack.cards', + 'X-Title': 'Your App Name' +}; + +const response = await new SendRequestViaProxyCommand(ctx).execute({ + url: 'https://openrouter.ai/api/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'google/gemini-2.5-flash', + messages: [{ role: 'user', content: 'Your prompt' }] + }), + headers +}); + +if (!response.response.ok) throw new Error('API call failed'); +const data = await response.response.json(); +const text = data.choices?.[0]?.message?.content ?? ''; +``` + +### Catalog Command Delegation + +**Reuse existing commands instead of reimplementing:** + +```gts +import UploadImageCommand from 'https://realms-staging.stack.cards/catalog/commands/upload-image'; + +const result = await new UploadImageCommand(this.commandContext).execute({ + sourceImageUrl: dataUrl, + targetRealmUrl: input.realm +}); +``` + +### Query Pattern in Commands + +```gts +import SearchCardsByQueryCommand from '@cardstack/boxel-host/commands/search-cards-by-query'; + +const results = await new SearchCardsByQueryCommand(this.commandContext).execute({ + query: { + filter: { + on: { module: new URL('./product', import.meta.url).href, name: 'Product' }, + eq: { status: 'active' } + } + }, + realmURLs: [input.realm] +}); +``` + +### Progress Tracking + +```gts +import { tracked } from '@glimmer/tracking'; + +export class MyCommand extends Command { + @tracked step: 'idle' | 'processing' | 'completed' | 'error' = 'idle'; + + protected async run(input: Input): Promise { + this.step = 'processing'; + try { + // Do work + this.step = 'completed'; + } catch (e) { + this.step = 'error'; + throw e; + } + } +} +``` + +### Menu Integration + +```gts +import { getCardMenuItems } from '@cardstack/runtime-common'; + +[getCardMenuItems](params: GetCardMenuItemParams): MenuItemOptions[] { + return [{ + label: 'My Action', + icon: MyIcon, + action: async () => { + await new MyCommand(params.commandContext).execute({ + cardId: this.id, + realm: params.realmURL + }); + await params.saveCard(this); + } + }, ...super[getCardMenuItems](params)]; +} +``` + +### Critical Rules + +- ✅ **Validate inputs first** - fail early with clear errors +- ✅ **Use host commands for all IO** - never `fetch` directly +- ✅ **Include `on` in queries** - for eq/contains/range filters +- ✅ **Delegate to catalog commands** - don't reimplement uploads/services +- ✅ **Wrap JSON parsing in try-catch** - handle malformed responses +- ✅ **Track progress states** - use `@tracked` for UI feedback \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md new file mode 100644 index 0000000000..2e8318cdb1 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md @@ -0,0 +1,139 @@ +## Foundational Concepts + +### The Boxel Universe + +Boxel is a composable card-based system where information lives in self-contained, reusable units. Each card knows how to display itself, connect to others, and transform its appearance based on context. + +- **Card:** The central unit of information and display + - **Definition (`CardDef` in `.gts`):** Defines the structure (fields) and presentation (templates) of a card type + - **Instance (`.json`):** Represents specific data conforming to a Card Definition + +- **Field:** Building blocks within a Card + - **Base Types:** System-provided fields (StringField, NumberField, etc.) + - **Custom Fields (`FieldDef`):** Reusable composite field types you define + +- **File (`FileDef` in `.gts`):** A card-like way of interacting with images, documents, and other assets stored in the realm + - **Instances:** Actual files (`.png`, `.md`, `.csv`, etc.) indexed automatically with metadata extracted + - **Subtypes:** `ImageDef`, `PngDef`, `MarkdownDef`, `CsvFileDef`, and others for specific formats + - **Referenced with `linksTo`**, never `contains` — FileDef instances have their own identity like cards + +- **Realm/Workspace:** Your project's root directory. All imports and paths are relative to this context + +- **Formats:** Different visual representations of the same card: + - `isolated`: Full detailed view (should be scrollable for long content) + - `embedded`: Compact view for inclusion in other cards + - `fitted`: **🚨 ESSENTIAL** - Fixed dimensions for grids/galleries/dashboards (parent sets both width AND height) + - `atom`: Minimal inline representation + - `edit`: Form for data modification (default provided, override only if needed) + +**🔴 CRITICAL:** Modern Boxel cards require ALL THREE display formats: isolated, embedded, AND fitted. Missing custom fitted format will fallback to basic fitted view that won't look very nice or have enough info to show in grids, choosers, galleries, or dashboards. + +## Decision Trees + +**Data Structure Choice:** + +``` +Needs own identity? → CardDef with linksTo +Referenced from multiple places? → CardDef with linksTo +Referencing a file (image, doc, etc.)? → FileDef subtype with linksTo +Just compound data? → FieldDef with contains +``` + +**Field Extension Choice:** + +``` +Want to customize a base field? → import BaseField, extend it +Creating new field type? → extends FieldDef directly +Adding to existing field? → extends BaseFieldName +``` + +**Value Setup:** + +``` +Computed from other fields? → computeVia +User-editable with default? → Field literal or computeVia +Simple one-time value? → Field literal +``` + +**Circular Dependencies?** + +``` +Use arrow function: () => Type +``` + +## ✅ Quick Mental Check Before Every Field + +Ask yourself: "Does this type extend CardDef or FieldDef?" + +- Extends **CardDef** → MUST use `linksTo` or `linksToMany` +- Extends **FieldDef** → MUST use `contains` or `containsMany` +- **No exceptions!** + +For computed fields, ask: "Am I keeping this simple and unidirectional?" + +- Only reference base fields, never self-reference +- No circular dependencies between computed fields +- Wrap in try-catch when accessing relationships +- If it feels complex, simplify it! + +## Foundation Quick Reference + +**Data Structure Choice:** + +- Needs own identity? → `CardDef` with `linksTo` +- Referenced from multiple places? → `CardDef` with `linksTo` +- Just compound data? → `FieldDef` with `contains` + +**Formats (what they are):** + +- `isolated` - Full detailed view (scrollable) +- `embedded` - Compact for inclusion in other cards +- `fitted` - Fixed dimensions for grids/galleries +- `atom` - Minimal inline representation +- `edit` - Form for data modification + +**Every CardDef inherits:** + +- `title`, `description`, `thumbnailURL` + +### Inherited Fields and CardInfo + +**IMPORTANT:** Every CardDef automatically inherits these base fields from the CardDef base class: + +#### Direct Inherited Fields (Read-Only) + +- `title` (StringField) - Computed pass-through from `cardInfo.title` +- `description` (StringField) - Computed pass-through from `cardInfo.description` +- `thumbnailURL` (StringField) - Computed pass-through from `cardInfo.thumbnailURL` + +#### CardInfo Field (User-Editable) + +Every card also inherits a `cardInfo` field which contains the actual user-editable values: + +- `cardInfo.title` (StringField) - User-editable card title +- `cardInfo.description` (StringField) - User-editable card description +- `cardInfo.thumbnailURL` (StringField) - User-editable thumbnail image URL +- `cardInfo.theme` (linksTo ThemeCard) - Optional theme card link +- `cardInfo.notes` (MarkdownField) - Optional internal notes + +**How It Works:** +The top-level `title`, `description`, and `thumbnailURL` fields are computed properties that automatically pass through the values from `cardInfo.title`, `cardInfo.description`, and `cardInfo.thumbnailURL` respectively. This means: + +- When you read `@model.title` in templates, you get the value from `cardInfo.title` +- Users edit values through the `cardInfo` field in edit mode +- Override to add custom logic that respects user input + +**Best Practice:** Define your own primary field and compute `title` to respect user's `cardInfo.title` choice: + +```gts +export class BlogPost extends CardDef { + @field headline = contains(StringField); // Your primary field + + // Override inherited title - respects user's cardInfo.title if set + @field title = contains(StringField, { + computeVia: function () { + return this.cardInfo?.title ?? this.headline ?? 'Untitled'; + }, + }); +} +``` diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md new file mode 100644 index 0000000000..204e5e5736 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md @@ -0,0 +1,346 @@ +**Card with computed title:** +```gts +export class BlogPost extends CardDef { + @field headline = contains(StringField); + + @field title = contains(StringField, { + computeVia: function(this: BlogPost) { + return this.headline ?? 'Untitled Post'; + } + }); +} +``` + +**Field definition:** +```gts +export class AddressField extends FieldDef { + @field street = contains(StringField); + @field city = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} +``` + +## Core Patterns + +### 1. Card Definition with Safe Computed Title +```gts +import { CardDef, field, contains, linksTo, containsMany, linksToMany, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import DateField from 'https://cardstack.com/base/date'; +import FileTextIcon from '@cardstack/boxel-icons/file-text'; +import { Author } from './author'; + +export class BlogPost extends CardDef { + static displayName = 'Blog Post'; + static icon = FileTextIcon; // ✅ CORRECT: Boxel icons for static card/field type icons + static prefersWideFormat = true; + + @field headline = contains(StringField); + @field publishDate = contains(DateField); + @field author = linksTo(Author); + @field tags = containsMany(TagField); + @field relatedPosts = linksToMany(() => BlogPost); + + @field title = contains(StringField, { + computeVia: function(this: BlogPost) { + try { + const baseTitle = this.headline ?? 'Untitled Post'; + const maxLength = 50; + if (baseTitle.length <= maxLength) return baseTitle; + return baseTitle.substring(0, maxLength - 3) + '...'; + } catch (e) { + console.error('BlogPost: Error computing title', e); + return 'Untitled Post'; + } + } + }); +} +``` + +### 2. Field Definition (Always Include Embedded Template) + +**CRITICAL:** Every FieldDef file must import FieldDef and MUST be exported: + +```gts +import { FieldDef, field, contains, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import LocationIcon from '@cardstack/boxel-icons/map-pin'; +import { concat } from '@ember/helper'; + +export class AddressField extends FieldDef { + static displayName = 'Address'; + static icon = LocationIcon; // ✅ CORRECT: Boxel icons for static card/field type icons + + @field street = contains(StringField); + @field city = contains(StringField); + @field postalCode = contains(StringField); + @field country = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} +``` + +### 3. Computed Properties with Safety + +**CRITICAL:** Avoid cycles and infinite recursion in computed fields. + +```gts +// ❌ DANGEROUS: Self-reference causes infinite recursion +@field title = contains(StringField, { + computeVia: function(this: BlogPost) { + return this.title || 'Untitled'; // STACK OVERFLOW! + } +}); + +// ✅ SAFE: Reference only base fields +@field fullName = contains(StringField, { + computeVia: function(this: Person) { + try { + const first = this.firstName ?? ''; + const last = this.lastName ?? ''; + const full = first + ' ' + last; + return full.trim() || 'Name not provided'; + } catch (e) { + console.error('Person: Error computing fullName', e); + return 'Name unavailable'; + } + } +}); +``` + +### 4. Templates with Proper Computation Patterns + +**Remember:** When implementing templates via SEARCH/REPLACE, track all major sections with ⁿ and include the post-block notation `╰ ⁿ⁻ᵐ` + +```gts +static isolated = class Isolated extends Component { // ³⁰ Isolated format + @tracked showComments = false; + + // ³¹ CRITICAL: Do ALL computation in functions, never in templates + get safeTitle() { + try { + return this.args?.model?.title ?? 'Untitled Post'; + } catch (e) { + console.error('BlogPost: Error accessing title', e); + return 'Untitled Post'; + } + } + + get commentButtonText() { + try { + const count = this.args?.model?.commentCount ?? 0; + return this.showComments ? `Hide Comments (${count})` : `Show Comments (${count})`; + } catch (e) { + console.error('BlogPost: Error computing comment button text', e); + return this.showComments ? 'Hide Comments' : 'Show Comments'; + } + } + + // methods referenced from templates must be defined with fat arrow (=>) so that they are properly bound when invoked + toggleComments = () => { + this.showComments = !this.showComments; + } + + +}; +``` + +### WARNING: Do NOT Use Constructors for Default Values + +**CRITICAL:** Constructors should NOT be used for setting default values in Boxel cards. Use template fallbacks (if field is editable) or computeVia (only if field is strictly read-only) instead. + +```gts +// ❌ WRONG - Never use constructors for defaults +export class Todo extends CardDef { + constructor(owner: unknown, args: {}) { + super(owner, args); + this.createdDate = new Date(); // DON'T DO THIS + this.isCompleted = false; // DON'T DO THIS + } +} +``` + +### **CRITICAL: NEVER Create JavaScript Objects in Templates** + +**Templates are for simple display logic only.** Never call constructors, create objects, or perform complex operations in template expressions. + +```hbs + +{{if @model.currentMonth @model.currentMonth (formatDateTime (new Date()) "MMMM YYYY")}} +
{{someFunction(@model.data)}}
+ + +{{if @model.currentMonth @model.currentMonth this.currentMonthDisplay}} +
{{this.processedData}}
+``` + +```gts +// ✅ CORRECT: Define logic in JavaScript +export class MyCard extends CardDef { + get currentMonthDisplay() { + return new Intl.DateTimeFormat('en-US', { + month: 'long', + year: 'numeric' + }).format(new Date()); + } + + get processedData() { + return this.args.model?.data ? this.processData(this.args.model.data) : 'No data'; + } + + private processData(data: any) { + // Complex processing logic here + return result; + } +} +``` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-data-management.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-data-management.md new file mode 100644 index 0000000000..d2780407cc --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-data-management.md @@ -0,0 +1,156 @@ +## File Organization + +### Single App Structure +``` +my-realm/ +├── blog-post.gts # Card definition (kebab-case) +├── author.gts # Another card +├── address-field.gts # Field definition (kebab-case-field) +├── BlogPost/ # Instance directory (PascalCase) +│ ├── hello-world.json # Instance (any-name) +│ └── second-post.json +└── Author/ + └── jane-doe.json +``` + +### Related Cards App Structure +**CRITICAL:** When creating apps with multiple related cards, organize them in common folders: + +``` +my-realm/ +├── ecommerce/ # Common folder for related cards +│ ├── product.gts # Card definitions +│ ├── order.gts +│ ├── customer.gts +│ ├── Product/ # Instance directories +│ │ └── laptop-pro.json +│ └── Order/ +│ └── order-001.json +├── blog/ # Another app's folder +│ ├── post.gts +│ ├── author.gts +│ └── Post/ +│ └── welcome.json +└── shared/ # Shared components + └── address-field.gts # Common field definitions +``` + +**Directory Discipline:** When creating files within a specific directory structure (e.g., `ecommerce/`), keep ALL related files within that structure. Don't create files outside the intended directory organization. + +**Relationship Path Tracking:** When creating related JSON instances, maintain a mental map of your file paths. Links between instances must use the exact relative paths you've created - consistency prevents broken relationships. + +## JSON Instance Format Quick Reference + +**When creating `.json` card instances via SEARCH/REPLACE, follow this structure:** + +**Naming:** Use natural names for JSON files (e.g., `Author/jane-doe.json`, `Product/laptop-pro.json`) - don't append `-sample-data` + +**Path Consistency:** When creating multiple related JSON instances, track the exact file paths you create. Relationship links must match these paths exactly - if you create `Author/dr-nakamura.json`, reference it as `"../Author/dr-nakamura"` from other instances. + +### Root Structure +All data wrapped in a `data` object with: +* `type`: Always `"card"` for instances +* `attributes`: Field values go here +* `relationships`: Links to other cards +* `meta.adoptsFrom`: Connection to GTS definition + +### Instance Template +```json +{ + "data": { + "type": "card", + "attributes": { + // Field values here + }, + "relationships": { + // Card links here + }, + "meta": { + "adoptsFrom": { + "module": "../path-to-gts-file", + "name": "CardDefClassName" + } + } + } +} +``` + +### Field Value Patterns + +**Simple fields** (`contains(StringField)`, etc.): +```json +"attributes": { + "title": "My Title", + "price": 29.99, + "isActive": true +} +``` + +**Compound fields** (`contains(AddressField)` - a FieldDef): +```json +"attributes": { + "address": { + "street": "4827 Riverside Terrace", + "city": "Portland", + "postalCode": "97205" + } +} +``` + +**Array fields** (`containsMany`): +```json +"attributes": { + "tags": ["urgent", "review", "frontend"], + "phoneNumbers": [ + { "number": "+1-503-555-0134", "type": "work" }, + { "number": "+1-971-555-0198", "type": "mobile" } + ] +} +``` + +### Relationship Patterns + +**Single link** (`linksTo`): +```json +"relationships": { + "author": { + "links": { + "self": "../Author/dr-nakamura" + } + } +} +``` + +**Multiple links** (`linksToMany`) - note the `.0`, `.1` pattern: +```json +"relationships": { + "teamMembers.0": { + "links": { "self": "../Person/kai-nakamura" } + }, + "teamMembers.1": { + "links": { "self": "../Person/esperanza-cruz" } + } +} +``` + +**Empty linksToMany** - when no relationships exist: +```json +"relationships": { + "nextLevels": { + "links": { + "self": null + } + } +} +``` +Note: Use `null`, not an empty array `[]` + +### Path Conventions +* **Module paths**: Relative to JSON location, no `.gts` extension + * Local: `"../author"` or `"../../shared/address-field"` + * Base: `"https://cardstack.com/base/string"` +* **Relationship paths**: Relative paths, no `.json` extension + * `"../Author/jane-doe"` not `"../Author/jane-doe.json"` +* **Date formats**: + * DateField: `"2024-11-15"` + * DateTimeField: `"2024-11-15T10:00:00Z"` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-defensive-programming.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-defensive-programming.md new file mode 100644 index 0000000000..361474c72f --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-defensive-programming.md @@ -0,0 +1,118 @@ +**Always use optional chaining:** +```js +// ❌ UNSAFE +if (this.args.model.items.includes(x)) { } + +// ✅ SAFE +if (this.args.model?.items?.includes?.(x)) { } +``` + +**Provide defaults:** +```js +return (this.args.model?.progress ?? 0) + 10; +``` + +**Wrap cross-card access in try-catch:** +```js +get authorName() { + try { + const author = this.args?.model?.author; + return author?.name ?? 'Unknown Author'; + } catch (e) { + console.error('Error accessing author', e); + return 'Author Unavailable'; + } +} +``` + +## Defensive Programming in Boxel Components + +**CRITICAL:** Prevent runtime errors by safely handling undefined/null values and malformed data. Cards boot with no data by default - every component must handle completely empty state gracefully. + +### Essential Defensive Patterns + +#### Always Use Optional Chaining (`?.`) +```js +// ❌ UNSAFE: Will throw if model is undefined +if (this.args.model.completedDays.includes(day)) { ... } + +// ✅ SAFE: Optional chaining prevents errors +if (this.args.model?.completedDays?.includes?.(day)) { ... } +``` + +#### Provide Default Values (`??`) +```js +// ❌ UNSAFE: May result in NaN +return this.args.model.progress + 10; + +// ✅ SAFE: Default value prevents NaN +return (this.args.model?.progress ?? 0) + 10; +``` + +#### Try-Catch for Network of Cards +When accessing data across card relationships, always wrap in try-catch to handle missing or malformed data: + +```js +// ³⁷ In computed properties or methods +get authorDisplayName() { + try { + const author = this.args?.model?.author; + if (!author) { + console.warn('BlogPost: No author assigned'); + return 'Unknown Author'; + } + + const name = author.name || author.title; + if (!name) { + console.warn('BlogPost: Author exists but has no name', { authorId: author.id }); + return 'Unnamed Author'; + } + + return name; + } catch (error) { + console.error('BlogPost: Error accessing author data', { + error, + postId: this.args.model?.id, + authorData: this.args.model?.author + }); + return 'Author Unavailable'; + } +} + +// ³⁸ In template getters +get relatedPostsSummary() { + try { + const posts = this.args.model?.relatedPosts; + if (!Array.isArray(posts)) { + return 'No related posts'; + } + + return posts + .filter(post => post?.title) // Skip malformed entries + .map(post => post.title) + .join(', ') || 'No related posts'; + + } catch (error) { + console.error('BlogPost: Failed to process related posts', error); + return 'Related posts unavailable'; + } +} +``` + +#### Validate Arrays Before Operations +```js +// ❌ UNSAFE: May throw if not an array +const sorted = this.completedDays.sort((a, b) => a - b); + +// ✅ SAFE: Check existence and type first +if (!Array.isArray(this.completedDays) || !this.completedDays.length) { + return []; +} +const sorted = [...this.completedDays].sort((a, b) => a - b); +``` + +**Key Principles:** +- Assume data might be missing, null, or the wrong type +- Provide meaningful fallbacks for user display +- Log errors with context for debugging (include IDs, data state) +- Never let malformed data crash your UI \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-delegated-rendering.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-delegated-rendering.md new file mode 100644 index 0000000000..d5329cd13b --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-delegated-rendering.md @@ -0,0 +1,187 @@ +**Delegated rendering:** +```hbs + +<@fields.author @format="embedded" /> +<@fields.items @format="embedded" /> +``` + +**Make cards clickable:** +```hbs + + + +``` + +**Avoid cycles:** +```gts +// Canonical links only +@field supervisor = linksTo(() => Employee); + +// Query for reverse +get directReportsQuery() { + return { + filter: { + on: { module: './employee', name: 'Employee' }, + eq: { supervisor: this.args.model.id } + } + }; +} +``` + +### BoxelSelect: Smart Dropdown Menus + +Regular HTML selects are limited to plain text. BoxelSelect lets you create rich, searchable dropdowns with custom rendering. + +#### Pattern: Rich Select with Custom Options + +```gts +export class OptionField extends FieldDef { // ⁴³ Option field for select + static displayName = 'Option'; + + @field key = contains(StringField); + @field label = contains(StringField); + @field description = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} + +export class ProductCategory extends CardDef { // ⁴⁴ Card using BoxelSelect + @field selectedCategory = contains(OptionField); + + static edit = class Edit extends Component { // ⁴⁵ Edit format + @tracked selectedOption = this.args.model?.selectedCategory; + + options = [ + { key: '1', label: 'Electronics', description: 'Phones, computers, and gadgets' }, + { key: '2', label: 'Clothing', description: 'Fashion and apparel' }, + { key: '3', label: 'Home & Garden', description: 'Furniture and decor' } + ]; + + updateSelection = (option: typeof this.options[0] | null) => { + this.selectedOption = option; + this.args.model.selectedCategory = option ? new OptionField(option) : null; + } + + + }; +} +``` + +### Custom Edit Controls + +Create user-friendly edit controls that accept natural input. Hide complexity in expandable sections while keeping ALL properties editable and inspectable. + +```gts +// Example: Natural language time period input +static edit = class Edit extends Component { + @tracked showDetails = false; + + parseInput = (value: string) => { + // Parse "Q1 2025" → quarter: 1, year: 2025, startDate: Jan 1, endDate: Mar 31 + // Parse "April 2025" → month: 4, year: 2025, startDate: Apr 1, endDate: Apr 30 + } + + +}; +``` + +### Alternative: Using the viewCard API + +Instead of making entire cards clickable, you can create custom buttons or links that use the `viewCard` API to open cards in specific formats. + +#### Basic Implementation + +```javascript +viewOrder = (order: ProductOrder) => { + // Open order in isolated view + this.args.viewCard(order, 'isolated'); +}; + +editOrder = (order: ProductOrder) => { + // Open card in rightmost stack for side-by-side reference + // Useful for: 1) reference lookup, 2) edit panel on right while previewing on left + this.args.viewCard(order, 'edit', { + openCardInRightMostStack: true + }); +}; + +viewReturnPolicy = () => { + // Open card using URL + const returnPolicyURL = new URL('https://app.boxel.ai/markinc/storefront/ReturnPolicy/return-policy-0525.json'); + this.args.viewCard(returnPolicyURL, 'isolated'); +}; +``` + +#### Template Example + +```hbs +
+ +
+ + + +
+ + +
+``` + +#### Available Formats + +- `'isolated'` - Read-oriented mode, may have some editable forms or interactive widgets +- `'edit'` - Open card for full editing + +#### Use Cases +- Multiple direct call-to-actions per card (view, edit) +- More control over user interactions +- Link to any card via a card URL \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-enumerations.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-enumerations.md new file mode 100644 index 0000000000..20cdae1abb --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-enumerations.md @@ -0,0 +1,209 @@ +## Enum Field Essentials + +**CRITICAL Import Syntax:** +```gts +import enumField from 'https://cardstack.com/base/enum'; // Default import, not { enumField } +``` + +**Quick Start:** +```gts +const StatusField = enumField(StringField, { options: ['Open', 'Closed'] }); +@field status = contains(StatusField); +``` + +**Template:** `<@fields.status />` renders a BoxelSelect in edit mode. + +**Rich options with labels/icons:** +```gts +enumField(StringField, { + options: [ + { value: 'high', label: 'High Priority', icon: ArrowUpIcon }, + { value: 'low', label: 'Low Priority', icon: ArrowDownIcon } + ] +}) +``` + +**Key helpers:** +- `enumValues(card, 'fieldName')` → array of primitive values +- `enumOptions(card, 'fieldName')` → normalized `{ value, label, icon? }` + + + +# Enum Fields + +## Purpose + +Use `enumField(BaseField, { options })` to create a `FieldDef` with constrained values and a default dropdown editor. Works with primitive bases (e.g., `StringField`, `NumberField`). + +## Import Syntax + +**CRITICAL:** Use default import, not destructured import: + +```gts +// ✅ CORRECT +import enumField from 'https://cardstack.com/base/enum'; + +// ❌ WRONG +import { enumField } from 'https://cardstack.com/base/enum'; +``` + +## Quick Start + +**Define:** +```gts +const StatusField = enumField(StringField, { options: ['Open', 'Closed'] }); +``` + +**Use:** +```gts +@field status = contains(StatusField); +``` + +**Template:** +```hbs +<@fields.status /> {{! Renders a BoxelSelect in edit mode }} +``` + +## Rich Options (Labels/Icons) + +```gts +enumField(StringField, { + options: [ + { value: 'high', label: 'High', icon: ArrowUpIcon }, + { value: 'medium', label: 'Medium', icon: MinusIcon }, + { value: 'low', label: 'Low', icon: ArrowDownIcon } + ] +}) +``` + +Editor shows labels/icons; stored value is the primitive `value`. + +## Dynamic Options + +**Provide a function:** +```gts +enumField(StringField, { + options: function() { + return this.someList; + } +}) +``` + +**Per-usage override:** +```gts +contains(Field, { + configuration: enumConfig(function() { + return { options: this.someList }; + }) +}) +``` + +**Note:** `this` is the containing card or field + +## Helpers + +**enumValues** - Get array of primitive values: +```gts +enumValues(card, 'enumFieldName') // → ['High', 'Medium', 'Low'] +``` + +**enumOptions** - Get normalized option objects: +```gts +enumOptions(card, 'enumFieldName') // → [{ value, label, icon? }, ...] +``` + +## Null Handling + +If current value is `null` and `null` isn't in options, placeholder uses `unsetLabel` or "Choose…". + +To make `null` selectable: +```gts +{ value: null, label: 'None' } +``` + +## Limitations + +- **Compound field values:** Not yet supported +- **Card values:** Not yet supported + +## Validation and Behavior + +- Duplicate values throw during option normalization +- Query and serialization follow the base field +- Enum wrapping does not change data shape + +## Minimal Example + +**Define:** +```gts +import enumField from 'https://cardstack.com/base/enum'; +const Priority = enumField(StringField, { options: ['High', 'Medium', 'Low'] }); +``` + +**Use:** +```gts +class Task extends CardDef { + @field priority = contains(Priority); +} +``` + +**Template:** +```hbs +<@fields.priority /> +{{enumValues @model 'priority'}} {{! ['High','Medium','Low'] }} +``` + +## Factory vs Usage (Clarity) + +**Factory defaults:** +```gts +enumField(Base, { options }) // For simple/static defaults +``` + +**Usage overrides:** +```gts +contains(Field, { + configuration: enumConfig(function() { + return { options }; + }) +}) // For per-instance behavior +``` + +Both resolve to `@configuration.enum.options` for templates/formats. + +## Callback Context + +`computeVia`, `enumField` options functions, and `enumConfig` usage callbacks all receive the containing instance as `this`. + +**Prefer `function() { ... }` (not arrow)** to ensure `this` is bound to the parent instance. + +**Guidance:** Keep callbacks side-effect free; derive options synchronously from `this`. + +## Complete Example + +```gts +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import enumField from 'https://cardstack.com/base/enum'; +import ArrowUpIcon from '@cardstack/boxel-icons/arrow-up'; +import ArrowDownIcon from '@cardstack/boxel-icons/arrow-down'; + +const PriorityField = enumField(StringField, { + options: [ + { value: 'high', label: 'High Priority', icon: ArrowUpIcon }, + { value: 'medium', label: 'Medium Priority' }, + { value: 'low', label: 'Low Priority', icon: ArrowDownIcon } + ] +}); + +export class Task extends CardDef { + @field taskName = contains(StringField); + @field priority = contains(PriorityField); + + @field title = contains(StringField, { + computeVia: function(this: Task) { + return this.taskName ?? 'Untitled Task'; + } + }); +} +``` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-external-libraries.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-external-libraries.md new file mode 100644 index 0000000000..c1b91012c3 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-external-libraries.md @@ -0,0 +1,96 @@ +**Async loading pattern:** +```gts +import { task, restartableTask, timeout } from 'ember-concurrency'; +import Modifier from 'ember-modifier'; + +private loadLibrary = task(async () => { + const script = document.createElement('script'); + script.src = 'https://cdn.../library.js'; + await new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +}); +``` + +**Key Rules:** +1. Use Modifiers for DOM access +2. Use ember-concurrency tasks for async +3. Bind external data to model fields +4. Provide loading states + +**Task types:** +- `task` - Concurrent execution +- `restartableTask` - Cancel previous, start new +- `enqueueTask` - Sequential queue +- `dropTask` - Ignore new while running + +## Async loading from within components + +For fetching data from external APIs, use `ember-concurrency`. The core of this principle are "tasks", which are a cancelable alternative to promises. The most used ones are `task`, and `restartableTask`: + +- task: Tasks run concurrently without any coordination, allowing multiple instances to execute simultaneously. +- restartableTask: Cancels any running task and immediately starts a new one when performed, ensuring only the latest task runs. +- enqueueTask: Queues tasks to run sequentially one after another, ensuring no overlap but preserving all tasks. +- dropTask: Ignores new task requests while one is already running, preventing any additional instances from starting. +- keepLatest: Drops intermediate queued tasks but keeps the most recent one to run after the current task completes. + +Here is an example where we are: +- loading data when component is first rendered, +- reloading it when user clicks on a button, +- adding some artificial delay using `await timeout(ms)` from `ember-concurrency`. Caution: do not use `setTimeout`. + +``` +import { CardDef, field, contains, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import { tracked } from '@glimmer/tracking'; +import { restartableTask, timeout } from 'ember-concurrency'; +import { Button } from '@cardstack/boxel-ui/components'; +import { on } from '@ember/modifier'; +import perform from 'ember-concurrency/helpers/perform'; + +export class CurrencyLoader extends CardDef { + static displayName = 'Currency Loader'; + + @field loadingStatus = contains(StringField); + @field currencies = contains(StringField); + + static isolated = class Isolated extends Component { + constructor(owner: any, args: any) { + super(owner, args); + this.loadCurrencies.perform(); + } + + private loadCurrencies = restartableTask(async () => { + this.args.model.loadingStatus = 'Loading...'; + const response = await fetch('/api/currencies'); + await timeout(1000); // Visual feedback + + this.args.model.currencies = await response.json(); + this.args.model.loadingStatus = ""; + }); + + + }; +} +``` + +## External Libraries: Bringing Third-Party Power to Boxel + +**When to Use External Libraries:** Sometimes you need specialized functionality like 3D graphics (Three.js), data visualization (D3), or charts. Boxel plays well with external libraries when you follow the right patterns. + +**Key Rules:** +1. **Always use Modifiers for DOM access** - Never manipulate DOM directly +2. **Use ember-concurrency tasks** for async operations like loading libraries +3. **Bind external data to model fields** for reactive updates +4. **Use proper loading states** while libraries initialize \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-file-def.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-file-def.md new file mode 100644 index 0000000000..7282f1bcc4 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-file-def.md @@ -0,0 +1,166 @@ +## FileDef — First-Class File Support + +`FileDef` is a **third kind of "def"** in Boxel, alongside `CardDef` and `FieldDef`. A FileDef instance represents a file that lives in the realm — an image, document, or other asset — with metadata automatically extracted during indexing. + +### Key Rules + +- **FileDef instances have their own identity** (like cards), so you reference them with `linksTo`, never `contains` +- **Render them using the same display formats** — FileDefs have `isolated`, `embedded`, `fitted`, and `atom` templates +- **Files are not editable via the card edit interface** — users replace them by uploading a new file + +--- + +## Type Hierarchy + +``` +FileDef → any file + ├── ImageDef → any image (adds width, height) + │ ├── PngDef → .png files + │ ├── JpgDef → .jpg / .jpeg files + │ ├── SvgDef → .svg files + │ ├── GifDef → .gif files + │ ├── WebpDef → .webp files + │ └── AvifDef → .avif files + ├── MarkdownDef → .md / .markdown (adds title, excerpt, content) + ├── TextFileDef → .txt (adds title, excerpt, content) + ├── TsFileDef → .ts (adds title, excerpt, content) + ├── GtsFileDef → .gts (extends TsFileDef) + ├── JsonFileDef → .json (adds title, excerpt, content) + └── CsvFileDef → .csv (adds title, excerpt, content, columns, columnCount, rowCount) +``` + +**Use the most specific type that fits.** Prefer `PngDef` over `ImageDef` when you specifically need PNG; prefer `ImageDef` over `FileDef` when any image format is acceptable. +**This set is not extensible by Boxel users (currently).** The Boxel project provides these types and only new releases of boxel can add new ones. This may change in the future. + +--- + +## Import Paths + +```gts +import FileDef from 'https://cardstack.com/base/file-api'; + +// Image types +import ImageDef from 'https://cardstack.com/base/image-file-def'; +import PngDef from 'https://cardstack.com/base/png-image-def'; +import JpgDef from 'https://cardstack.com/base/jpg-image-def'; +import SvgDef from 'https://cardstack.com/base/svg-image-def'; +import GifDef from 'https://cardstack.com/base/gif-image-def'; +import WebpDef from 'https://cardstack.com/base/webp-image-def'; +import AvifDef from 'https://cardstack.com/base/avif-image-def'; + +// Document / text types +import MarkdownDef from 'https://cardstack.com/base/markdown-file-def'; +import TextFileDef from 'https://cardstack.com/base/text-file-def'; +import TsFileDef from 'https://cardstack.com/base/ts-file-def'; +import GtsFileDef from 'https://cardstack.com/base/gts-file-def'; +import JsonFileDef from 'https://cardstack.com/base/json-file-def'; +import CsvFileDef from 'https://cardstack.com/base/csv-file-def'; +``` + +--- + +## Available Fields + +Every FileDef instance exposes these base fields: + +| Field | Type | Description | +| ------------- | ------ | ---------------------------- | +| `id` | string | URL identifier of the file | +| `url` | string | Current URL of the file | +| `sourceUrl` | string | Original source URL | +| `name` | string | Filename (e.g. `photo.png`) | +| `contentType` | string | MIME type (e.g. `image/png`) | +| `contentHash` | string | MD5 hash of file content | +| `contentSize` | number | File size in bytes | + +Additional fields added by subtype: + +| Type | Extra Fields | +| ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `ImageDef` + all image subtypes | `width` (px), `height` (px) | +| `MarkdownDef`, `TextFileDef`, `TsFileDef`, `GtsFileDef`, `JsonFileDef` | `title`, `excerpt`, `content` (full text) | +| `CsvFileDef` | `title`, `excerpt`, `content`, `columns` (array), `columnCount`, `rowCount` | + +--- + +## Using FileDef in Cards + +```gts +import { CardDef, field, linksTo } from 'https://cardstack.com/base/card-api'; +import ImageDef from 'https://cardstack.com/base/image-file-def'; +import PngDef from 'https://cardstack.com/base/png-image-def'; +import FileDef from 'https://cardstack.com/base/file-api'; +import MarkdownDef from 'https://cardstack.com/base/markdown-file-def'; + +export class ProductListing extends CardDef { + @field photo = linksTo(PngDef); // Specifically PNG + @field banner = linksTo(ImageDef); // Any image format + @field attachment = linksTo(FileDef); // Any file type + @field readme = linksTo(MarkdownDef); // Markdown document +} +``` + +--- + +## Rendering File Fields in Templates + +Use `<@fields.fieldName />` exactly as with any other field. The built-in display components handle rendering automatically. + +```gts +static isolated = class Isolated extends Component { + +}; +``` + +**Image built-in formats:** + +- `isolated` → full-size image + filename + dimensions footer +- `embedded` → responsive `` that fills its container width +- `fitted` → `background-image: cover` for fixed-size grid cells +- `atom` → 20 px thumbnail + filename inline + +--- + +## MarkdownDef vs MarkdownField + +These are completely different and are **not interchangeable**: + +| | `MarkdownDef` | `MarkdownField` | +| ------------------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **Kind** | FileDef — a `.md` file in the realm | FieldDef — inline text stored in the card's JSON | +| **Import** | `https://cardstack.com/base/markdown-file-def` | `https://cardstack.com/base/markdown` | +| **Declaration** | `@field notes = linksTo(MarkdownDef)` | `@field notes = contains(MarkdownField)` | +| **Stored as** | Separate `.md` file referenced by URL | String embedded in the card's `.json` | +| **Has own URL?** | ✅ Yes — shareable and reusable | ❌ No — owned by the containing card | +| **Editable in card UI?** | ❌ No — replaced by uploading a new file | ✅ Yes — inline markdown editor | +| **Extra fields** | `title`, `excerpt`, `content` auto-extracted | Raw markdown string only | +| **Use when** | Stand-alone documents, content shared across cards, files managed outside Boxel | Inline rich text that belongs to the card, like a description or body field | + +--- + +## FileDef vs Base64ImageField + +**🚨 Do NOT use `Base64ImageField` for images.** Use an image FileDef type instead. + +| | FileDef (`ImageDef`, `PngDef`, etc.) | `Base64ImageField` | +| ------------------- | ------------------------------------ | ---------------------------------------- | +| **Storage** | Separate file in the realm | Base64 data embedded in the card's JSON | +| **AI context cost** | ✅ Minimal — just a URL reference | ❌ Extremely large — can exhaust context | +| **Shareable** | ✅ Yes — has its own URL | ❌ No — embedded in one card | +| **Performance** | ✅ Standard HTTP caching | ❌ Bloated JSON payloads | +| **Use** | ✅ Always prefer this | ⚠️ Avoid | diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-file-editing.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-file-editing.md new file mode 100644 index 0000000000..ff9476438c --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-file-editing.md @@ -0,0 +1,85 @@ +### SEARCH/REPLACE Essentials + +**Every .gts file line 1:** +```gts +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +``` + +**Creating new file:** +```gts +http://realm/card.gts (new) +╔═══ SEARCH ════╗ +╠═══════════════╣ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +import { CardDef } from '...'; // ¹ +export class MyCard extends CardDef { } // ² +╚═══ REPLACE ═══╝ +``` +╰ ¹⁻² + +**Modifying existing:** +```gts +https://realm/card.gts +╔═══ SEARCH ════╗ +existing code with tracking markers +╠═══════════════╣ +modified code with new markers // ⁵ +╚═══ REPLACE ═══╝ +``` +⁰ ⁵ + +## File Editing System + +### Tracking Mode + +**MANDATORY for .gts Files:** +1. All `.gts` files require tracking mode indicator on line 1: + ```gts + // ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ + ``` +2. Format: `// ⁿ description` using sequential superscripts: ¹, ², ³... +3. Both SEARCH and REPLACE blocks must contain tracking markers + +### SEARCH/REPLACE Patterns + +#### Creating New File +```gts +http://realm/recipe-card.gts (new) +╔═══ SEARCH ════╗ +╠═══════════════╣ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +import { CardDef } from 'https://cardstack.com/base/card-api'; // ¹ +export class RecipeCard extends CardDef { // ² + static displayName = 'Recipe'; +} +╚═══ REPLACE ═══╝ +``` +⁰ ¹⁻² + +#### Modifying Existing File +```gts +https://example.com/recipe-card.gts +╔═══ SEARCH ════╗ +export class RecipeCard extends CardDef { + static displayName = 'Recipe'; + @field recipeName = contains(StringField); +╠═══════════════╣ +export class RecipeCard extends CardDef { + static displayName = 'Recipe'; + @field recipeName = contains(StringField); + @field servings = contains(NumberField); // ¹⁸ Added servings +╚═══ REPLACE ═══╝ +``` +⁰ ¹⁸ + +### File Type Rules + +- **`.gts` files** → ALWAYS require tracking mode and markers +- **`.json` files** → Never use tracking comments + +### Best Practices + +- Keep search blocks small and precise +- Include tracking comments in SEARCH blocks for uniqueness +- Search text must match EXACTLY +- Use placeholder comments for easy insertion points \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-fitted-formats.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-fitted-formats.md new file mode 100644 index 0000000000..ecd0d95558 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-fitted-formats.md @@ -0,0 +1,34 @@ +## Fitted Format Essentials + +**Four sub-formats strategy:** +- **Badge** (≤150px width, <170px height) - Exportable graphics +- **Strip** (>150px width, <170px height) - Dropdown/chooser panels +- **Tile** (<400px width, ≥170px height) - Grid viewing +- **Card** (≥400px width, ≥170px height) - Full layout + +**Container query skeleton:** +```css +.fitted-container { + container-type: size; + width: 100%; + height: 100%; +} + +/* Hide all by default */ +.badge, .strip, .tile, .card { + display: none; + padding: clamp(0.25rem, 2%, 0.5rem); +} + +/* Activate by size - NO GAPS! */ +@container (max-width: 150px) and (max-height: 169px) { + .badge { display: flex; } +} +``` + +**Content priority:** +1. Title/Name +2. Image +3. Short ID +4. Key info +5. Status badges \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-query-systems.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-query-systems.md new file mode 100644 index 0000000000..50788c7609 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-query-systems.md @@ -0,0 +1,74 @@ +## Query Essentials + +**The 'on' Rule (MEMORIZE THIS!):** +```ts +// ❌ WRONG - Missing 'on' +{ range: { price: { lte: 100 } } } + +// ✅ CORRECT - Include 'on' for filters +{ + on: { module: new URL('./product', import.meta.url).href, name: 'Product' }, + range: { price: { lte: 100 } } +} +``` + +**⚠️ CRITICAL Path Rule:** +- **In .gts files (queries):** Use `./` - you're in the same directory as the module +- **In JSON files (`adoptsFrom`):** Use `../` - instances live in folders, need to navigate up +- `./` means "same directory" when used with `import.meta.url` + +**Filter types needing 'on':** +- `eq`, `contains`, `range` (except after type filter) +- Sort on type-specific fields + +**Filter composition types:** +- `any`: allows an "OR" union of other filters +- `every`: allows an "AND" union of other filters +- `not`: allow negating another filter + +**Basic query pattern:** +```ts +const query = { + filter: { + every: [ + { type: { module: new URL('./product', import.meta.url).href, name: 'Product' } }, + { on: { module: new URL('./product', import.meta.url).href, name: 'Product' }, eq: { status: 'active' } } + ] + } +}; +``` + +**Defining query-backed fields:** +```ts +@field shirts = linksToMany(Shirt, { + query: { + filter: { + // implicit clause merged during execution: on: { module: Shirt.module, name: 'Shirt' } + eq: { size: '$this.profile.shirtSize' }, + }, + realm: '$thisRealm', + sort: [ + { + by: 'updatedAt', + direction: 'desc', + }, + ], + page: { size: 12 }, + }, +}); + +@field profile = linksTo(Profile, { + query: { + filter: { + eq: { primary: true }, + }, + // `linksTo` takes the first matching card (post-sort) or null when no results. + }, +}); +``` + +**When to use what to query cards:** +- Efficient display-only → `PrerenderedCardSearch` +- Need data manipulation → `getCards` +- Treat query result as a field → query-backed fields +``` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-quick-reference.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-quick-reference.md new file mode 100644 index 0000000000..85cbc63240 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-quick-reference.md @@ -0,0 +1,104 @@ +**Core imports:** +```gts +import { CardDef, FieldDef, Component, field, contains, linksTo } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +``` + +**UI Components:** +```gts +import { Button, Pill, BoxelSelect } from '@cardstack/boxel-ui/components'; +``` + +**Helpers:** +```gts +import { eq, gt, and, or, not } from '@cardstack/boxel-ui/helpers'; +import { formatDateTime, formatCurrency } from '@cardstack/boxel-ui/helpers'; +``` + +## Quick Reference + +**File Types:** `.gts` (definitions) | `.json` (instances) +**Core Pattern:** CardDef/FieldDef → contains/linksTo → Templates → Instances +**Essential Formats:** Every CardDef MUST implement `isolated`, `embedded`, AND `fitted` formats + +```gts +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +// ¹ Core imports - ALWAYS needed for definitions +import { CardDef, FieldDef, Component, field, contains, containsMany, linksTo, linksToMany } from 'https://cardstack.com/base/card-api'; + +// ² Base field imports (only what you use) +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import DateField from 'https://cardstack.com/base/date'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import BigIntegerField from 'https://cardstack.com/base/big-integer'; +import CodeRefField from 'https://cardstack.com/base/code-ref'; +import Base64ImageField from 'https://cardstack.com/base/base64-image'; // 🚨 NEVER USE - embeds binary in JSON, crashes AI context; use FileDef types instead + +// ⁸ FileDef imports - for file fields (use linksTo, never contains) +import FileDef from 'https://cardstack.com/base/file-api'; +import ImageDef from 'https://cardstack.com/base/image-file-def'; // any image +import PngDef from 'https://cardstack.com/base/png-image-def'; // .png +import JpgDef from 'https://cardstack.com/base/jpg-image-def'; // .jpg/.jpeg +import SvgDef from 'https://cardstack.com/base/svg-image-def'; // .svg +import GifDef from 'https://cardstack.com/base/gif-image-def'; // .gif +import WebpDef from 'https://cardstack.com/base/webp-image-def'; // .webp +import AvifDef from 'https://cardstack.com/base/avif-image-def'; // .avif +import MarkdownDef from 'https://cardstack.com/base/markdown-file-def'; // .md (NOT same as MarkdownField) +import TextFileDef from 'https://cardstack.com/base/text-file-def'; // .txt +import TsFileDef from 'https://cardstack.com/base/ts-file-def'; // .ts +import GtsFileDef from 'https://cardstack.com/base/gts-file-def'; // .gts +import JsonFileDef from 'https://cardstack.com/base/json-file-def'; // .json +import CsvFileDef from 'https://cardstack.com/base/csv-file-def'; // .csv +import ColorField from 'https://cardstack.com/base/color'; +import EmailField from 'https://cardstack.com/base/email'; +import PercentageField from 'https://cardstack.com/base/percentage'; +import PhoneNumberField from 'https://cardstack.com/base/phone-number'; +import UrlField from 'https://cardstack.com/base/url'; +import AddressField from 'https://cardstack.com/base/address'; + +// ⚠️ EXTENDING BASE FIELDS: To customize a base field, import it and extend: +// import BaseAddressField from 'https://cardstack.com/base/address'; +// export class FancyAddressField extends BaseAddressField { } +// Never import and define the same field name - it causes conflicts! + +// ³ UI Component imports +import { Button, Pill, Avatar, FieldContainer, CardContainer, BoxelSelect, ViewSelector } from '@cardstack/boxel-ui/components'; + +// ⁴ Helper imports +import { eq, gt, gte, lt, lte, and, or, not, cn, add, subtract, multiply, divide } from '@cardstack/boxel-ui/helpers'; +import { currencyFormat, formatDateTime, optional, pick } from '@cardstack/boxel-ui/helpers'; +import { concat, fn } from '@ember/helper'; +import { get } from '@ember/helper'; +import { on } from '@ember/modifier'; +import Modifier from 'ember-modifier'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { task, restartableTask } from 'ember-concurrency'; +// NOTE: 'if' is built into Glimmer templates - DO NOT import it + +// ⁶ TIMING RULE: NEVER use requestAnimationFrame +// - DOM timing: Use Glimmer modifiers with cleanup +// - Async coordination: Use task/restartableTask from ember-concurrency +// - Delays: Use await timeout(ms) from ember-concurrency, not setTimeout + +// ⁵ Icon imports +import EmailIcon from '@cardstack/boxel-icons/mail'; +import PhoneIcon from '@cardstack/boxel-icons/phone'; +import RocketIcon from '@cardstack/boxel-icons/rocket'; +// Available from Lucide, Lucide Labs, and Tabler icon sets +// NOTE: Only use for static card/field type icons, NOT in templates + +// CRITICAL IMPORT RULES: +// ⚠️ If you don't see an import in the approved lists above, DO NOT assume it exists! +// ⚠️ Only use imports explicitly shown in this guide - no exceptions! +// - Verify any import exists in the approved lists before using +// - Do NOT assume similar imports exist (e.g., don't assume IntegerField exists because NumberField does) +// - If needed functionality isn't in approved imports, define it directly with a comment: +// // Defining custom helper - not yet available in Boxel environment +// function customHelper() { ... } +``` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-replicate-ai.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-replicate-ai.md new file mode 100644 index 0000000000..0499d701c3 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-replicate-ai.md @@ -0,0 +1,111 @@ +### Replicate API Essentials + +**Gateway URL Pattern:** +``` +https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/replicate/v1/models/{owner}/{model}/predictions +``` + +**Required Headers:** +```typescript +{ + 'Content-Type': 'application/json', + 'Prefer': 'wait' // CRITICAL: Synchronous response +} +``` + +**Request Structure:** +```typescript +{ + input: { + prompt: string, + // Model-specific parameters (see API docs) + } +} +``` + +### Enum Fields for API Parameters + +**CRITICAL:** Enum `value` must exactly match API spec: + +```gts +import enumField from 'https://cardstack.com/base/enum'; + +const SizeField = enumField(StringField, { + options: [ + { value: '1K', label: '1K (1024px)' }, + { value: '2K', label: '2K (2048px)' }, + { value: 'custom', label: 'Custom' } + ] +}); +``` + +### API Call Pattern + +```gts +import SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy'; +import UploadImageCommand from 'https://realms-staging.stack.cards/catalog/commands/upload-image'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import { CloudflareImage } from 'https://realms-staging.stack.cards/catalog/cloudflare-image'; + +// Build request +const requestBody = { + input: { prompt: input.prompt } +}; + +// Add conditional parameters +if (input.size) requestBody.input.size = input.size; +if (input.aspectRatio && input.size !== 'custom') { + requestBody.input.aspect_ratio = input.aspectRatio; +} + +// Call API +const response = await new SendRequestViaProxyCommand(ctx).execute({ + url: 'https://gateway.ai.cloudflare.com/v1/.../replicate/v1/models/{owner}/{model}/predictions', + method: 'POST', + requestBody: JSON.stringify(requestBody), + headers: { 'Content-Type': 'application/json', 'Prefer': 'wait' } +}); + +// Parse response +const data = await response.response.json(); +let imageUrl = Array.isArray(data.output) ? data.output[0] : data.output; + +// Upload result +const uploaded = await new UploadImageCommand(ctx).execute({ + sourceImageUrl: imageUrl, + targetRealmUrl: input.realm +}); + +return await new GetCardCommand(ctx).execute({ cardId: uploaded.cardId }); +``` + +### Response Parsing + +```typescript +// Handle multiple formats +let imageUrl: string | undefined; + +if (data.output && Array.isArray(data.output)) { + imageUrl = data.output[0]; +} else if (typeof data.output === 'string') { + imageUrl = data.output; +} else if (data.output?.url) { + imageUrl = data.output.url; +} + +if (!imageUrl) throw new Error('No image URL in response'); +``` + +### Common Mistakes + +❌ Missing `Prefer: wait` header → async URL instead of result +❌ Enum value mismatch → API rejects request +❌ Always sending optional params → API validation errors +❌ String booleans in API → Use actual `true`/`false` + +### Finding Model Schemas + +1. Visit `https://replicate.com/{owner}/{model}` +2. Check API tab for exact schema +3. Note required vs optional parameters +4. Match enum values exactly \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-spec-usage.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-spec-usage.md new file mode 100644 index 0000000000..aa93279855 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-spec-usage.md @@ -0,0 +1,185 @@ +**Card specs (linksTo/linksToMany):** +```gts +import { Author } from './author'; +@field author = linksTo(Author); +@field contributors = linksToMany(Author); +``` + +**Field specs (contains/containsMany):** +```gts +import StringField from 'https://cardstack.com/base/string'; +import AddressField from 'https://cardstack.com/base/address-field'; +@field name = contains(StringField); +@field addresses = containsMany(AddressField); +``` + +**Component specs (direct template usage):** +```hbs + + +``` + +**Command specs (programmatic execution):** +```ts +const cmd = new MyCommand(commandContext); +const result = await cmd.execute(input); +``` + +## Spec Usage Examples + +A Spec is a comprehensive documentation and metadata container for code within the Boxel ecosystem. + +This document provides real-world usage examples for each spec type based on actual implementations found in the Boxel repository. + +### Card Specs (`specType: 'card'`) +(Cards are linked to using `linksTo` and `linksToMany` within consuming cards...) + +#### Import +```typescript +import { Author } from './author'; +import { Country } from 'https://cardstack.com/base/country'; +import { Skill } from 'https://cardstack.com/base/skill'; +``` + +#### Usage as a Field +```typescript +export class BlogPost extends CardDef { + // Single card reference + @field author = linksTo(Author); + @field country = linksTo(Country); + + // Multiple card references + @field enabledSkills = linksToMany(Skill); + @field attachedCards = linksToMany(CardDef); +} +``` + +#### Template Usage +```handlebars +{{! Display linked card in different formats }} +<@fields.author @format="embedded" /> +<@fields.author @format="atom" /> + +{{! Display collection of linked cards }} +
+ <@fields.enabledSkills @format="embedded" /> +
+``` + +### Field Specs (`specType: 'field'`) +(Fields are embedded using `contains` and `containsMany` within cards.) + +#### Import +```typescript +import StringField from 'https://cardstack.com/base/string'; +import DateField from 'https://cardstack.com/base/date'; +import { SocialMediaLink } from './social-media-link'; +``` + +#### Usage as a Field +```typescript +export class MinecraftInvite extends CardDef { + // Basic field types + @field celebrantName = contains(StringField); + @field age = contains(StringField); + @field date = contains(DateField); + + // Custom field types + @field socialLinks = containsMany(SocialMediaLink); +} +``` + +#### Template Usage +```handlebars +{{! Display contained fields }} +<@fields.celebrantName /> +<@fields.date @format="atom" /> + +{{! Display collection of contained fields }} + +``` + +### Component Specs (`specType: 'component'`) +(Components are used directly in templates, extending GlimmerComponent...) + +#### Import +```typescript +import { BoxelSelect, Pill } from '@cardstack/boxel-ui/components'; +import { FilterDropdown } from './filter-dropdown'; +import { CardsGrid } from './cards-grid'; +``` + +#### Usage in Templates +```handlebars +{{! Basic component usage }} + + +{{! Custom components }} + + + +{{! Component with content }} + + Active Status + +``` + +### App Specs (`specType: 'app'`) +(Apps extend AppCard and are typically linked to like regular cards...) + +#### Import +```typescript +import { AppCard } from '/experiments/app-card'; +import { GardenAppCard } from './garden-app'; +``` + +#### Usage as a Field +```typescript +export class Dashboard extends CardDef { + @field primaryApp = linksTo(GardenAppCard); + @field availableApps = linksToMany(AppCard); +} +``` + +#### Template Usage +```handlebars +{{! Display app in card context }} +<@fields.primaryApp @format="fitted" /> + +{{! App navigation }} +
+ <@fields.availableApps @format="embedded" /> +
+``` + +### Command Specs (`specType: 'command'`) +(Commands are instantiated and executed programmatically.) + +#### Import +```typescript +import { GenerateReadmeSpecCommand } from './generate-readme-spec'; +import { SwitchSubmodeCommand } from './switch-submode'; +import { UpdatePlaygroundSelectionCommand } from './update-playground-selection'; +``` + +#### Template Usage + +When you need to execute commands in response to user interactions, you can just access the commandContext and invoke it as how you would a simple async function in javascript + +```typescript +let commandContext = this.args.context?.commandContext; +if (!commandContext) { + console.error('Command context not available'); + return; +} + +const someCommandInput = new CommandInput({...args}) +const myCommand = new MyCommand(commandContext); +const result = await myCommand.execute(someCommandInput); +``` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-styling-design.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-styling-design.md new file mode 100644 index 0000000000..61fb4fda4a --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-styling-design.md @@ -0,0 +1,106 @@ +## CSS Safety Essentials + +**Always scoped:** +```gts + +``` + +**CSS comments (NEVER use //):** +```css +/* ✅ CORRECT: Block comments */ +.card { color: blue; } + +// ❌ WRONG: Single-line breaks parsing +``` + +**Never use global selectors:** +```css +/* ❌ WRONG */ +:root { --color: blue; } +body { margin: 0; } + +/* ✅ CORRECT */ +.my-component { + --color: blue; +} +``` + +**Formatters for display:** +```hbs +{{formatCurrency @model.price currency="USD"}} +{{formatDateTime @model.date size="medium"}} +{{formatNumber @model.count size="tiny"}} +``` + +## Design Philosophy and Competitive Styling + +Design and implement your stylesheet to fit the domain you are generating. Research the top 2 products/services in that area and design your card as if you are the 3rd competitor looking to one-up the market in terms of look and feel, functionality, and user-friendliness. + +Approach: Study the leading players' design patterns, then create something that feels more modern, intuitive, and polished. Focus on micro-interactions, thoughtful spacing, superior visual hierarchy, and removing friction from user workflows. + +Key Areas to Compete On: +- Visual polish: better typography, spacing, and color schemes +- Interaction design: smoother animations, better feedback, clearer affordances +- Information architecture: more logical organization, better progressive disclosure +- Accessibility: superior contrast, keyboard navigation, screen reader support +- Performance: faster loading, responsive design + +Typography Guidance (detailed): Choose modern, readable fonts that match your domain. For body text, consider Inter, Roboto, Open Sans, Source Sans Pro, DM Sans, Work Sans, Manrope, or Plus Jakarta Sans. For headings, Poppins, Montserrat, Space Grotesk, Raleway, Archivo Black, Oswald, Anton, Playfair Display, Lora, or Merriweather. Balance readability with character; ensure sufficient contrast and legible sizes across formats. + +## Design Token Foundation + +Dense professional layouts with thoughtful scaling: + +- Typography scale: start at 0.875rem base; headings 1rem–1.375rem; labels 0.75rem +- Spacing scale: 0.25rem increments; inline 0.25–0.5rem; sections 0.75–1rem; major 1.5–2rem +- Colors: define background, foreground, muted, muted-foreground, primary, primary-foreground, secondary, secondary-foreground, accent, accent-foreground, card, card-foreground, sidebar, sidebar-foreground, and border tokens +- Radius: match the aesthetic (sharp for technical, soft for friendly) +- Shadows: subtle elevation for interactive elements; keep z-index conservative (<10) + +Implementation tip: Define CSS variables at component root and use fallbacks. + +```css +.component { + --card-padding: var(--boxel-sp, 1rem); + --card-radius: var(--boxel-border-radius-sm, 0.5rem); + --card-shadow: var(--boxel-box-shadow, 0 2px 4px rgba(0,0,0,0.1)); + padding: var(--card-padding); + border-radius: var(--card-radius); + box-shadow: var(--card-shadow); +} +``` + +## Typography Guidance (Detailed) + +- Base size: 14px (0.875rem) for dense UIs; increase in larger formats +- Hierarchy cascade: each level 80–87% of the previous; adjust weight 100–200 units per level +- Line-height: 1.2–1.5 depending on density; tighter for tiles, looser for isolated +- Clamping: use `clamp()` for responsive sizes across fitted/embedded/isolated +- Accessibility: aim for WCAG AA contrast; avoid ultra-light weights below 16px +- Numbers: tabular-nums for data tables and metrics when available + +Example: +```css +.title { font-size: clamp(1rem, 2.5vw, 1.25rem); font-weight: 700; } +.subtle { font-size: 0.75rem; opacity: 0.8; } +``` + +## Format Dimensions Comparison + +| Format | Width | Height | Parent Sets | Key Behavior | +|----------|------------------|------------------|-------------|-------------| +| Isolated | Max-width, center| Natural + scroll | No | Full detail, scrollable content | +| Embedded | Fills container | Natural | Width only | Truncation/expand controls handled by parent | +| Fitted | Fills exactly | Fills exactly | Both | Must adapt to fixed grid slots | +| Atom | Inline | Inline | No | Minimal inline representation | +| Edit | Fills container | Natural form | Width only | Form layout, grows with fields | + +Notes: +- Fitted requires internal subformats (badge, strip, tile, card) via container queries +- Embedded should be height-flexible; parents may clamp and offer "view more" +- Isolated should ensure comfortable reading with scrollable mat and generous padding \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md new file mode 100644 index 0000000000..3c4f958a00 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md @@ -0,0 +1,87 @@ +### The Cardinal Rule + +**MOST CRITICAL RULE:** +```gts +// ✅ CORRECT +@field author = linksTo(Author); // CardDef +@field address = contains(AddressField); // FieldDef + +// ❌ WRONG - Will break everything +@field author = contains(Author); // NEVER! +@field address = linksTo(AddressField); // NEVER! +``` + +**Must export ALL classes:** +```gts +export class MyCard extends CardDef { } // ✅ +class MyCard extends CardDef { } // ❌ Missing export +``` + +**Computed fields:** +- Keep simple and unidirectional +- No self-reference or cycles +- Wrap cross-card access in try-catch + +## Technical Rules + +### THE CARDINAL RULE: contains vs linksTo + +**THIS IS THE #1 MOST CRITICAL RULE IN BOXEL:** + +| Type | MUST Use | NEVER Use | Why | +|------|----------|-----------|-----| +| **Extends CardDef** | `linksTo` / `linksToMany` | ❌ `contains` / `containsMany` | CardDef = independent entity with own JSON file | +| **Extends FieldDef** | `contains` / `containsMany` | ❌ `linksTo` / `linksToMany` | FieldDef = embedded data, no separate identity | + +```gts +// ✅ CORRECT +@field author = linksTo(Author); // Author extends CardDef +@field address = contains(AddressField); // AddressField extends FieldDef + +// ❌ WRONG +@field author = contains(Author); // NEVER! +@field address = linksTo(AddressField); // NEVER! +``` + +### MANDATORY TECHNICAL REQUIREMENTS + +1. **Always use SEARCH/REPLACE with tracking for .gts files** +2. **Export ALL CardDef and FieldDef classes inline** +3. **Never use reserved words as field names** +4. **Keep computed fields simple and unidirectional** +5. **No JavaScript in templates** +6. **Wrap delegated collections with spacing containers** + +### TECHNICAL VALIDATION CHECKLIST + +Before generating ANY code: +- [ ] SEARCH/REPLACE blocks with tracking markers +- [ ] Every CardDef field uses `linksTo`/`linksToMany` +- [ ] Every FieldDef field uses `contains`/`containsMany` +- [ ] All classes have `export` keyword inline +- [ ] No reserved words as field names +- [ ] No duplicate field definitions +- [ ] Computed fields are simple (no cycles!) +- [ ] Try-catch blocks wrap cross-card data access +- [ ] No JavaScript operations in templates +- [ ] ALL THREE FORMATS: isolated, embedded, fitted + +### Common Mistakes + +#### Using contains with CardDef +```gts +// ❌ WRONG +@field items = containsMany(Item); // Item is CardDef + +// ✅ CORRECT +@field items = linksToMany(Item); +``` + +#### Missing Exports +```gts +// ❌ WRONG +class BlogPost extends CardDef { } + +// ✅ CORRECT +export class BlogPost extends CardDef { } +``` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md new file mode 100644 index 0000000000..d5cae494a2 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md @@ -0,0 +1,325 @@ +### Template Essentials + +**Field access patterns:** +```hbs +{{@model.title}} +<@fields.title /> +<@fields.phone @format="atom" /> +<@fields.items @format="embedded" /> +``` + +For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: Theme-First Design System. + +#### ⚠️ CRITICAL: @model Iteration vs @fields Delegation + +**Once you iterate with @model, you CANNOT delegate to @fields within that iteration.** + +```hbs + +{{#each @model.teamMembers as |member|}} + <@fields.member @format="embedded" /> +{{/each}} + + +<@fields.teamMembers @format="embedded" /> + + +{{#each @model.teamMembers as |member|}} +
{{member.name}}
+{{/each}} + + + +``` + +**Why this breaks:** @fields provides field-level components. Once you're iterating with @model, you're working with raw data, not field components. + +**Decision Rule:** Before iterating, decide: +- Need composability? → Use delegated rendering +- Need filtering? → Use query patterns (PrerenderedCardSearch/getCards) +- Need custom control? → Use @model but handle ALL rendering yourself + +### Accessing @fields by Index: The Bridge Pattern + +**Use Case:** You need to use `@model` data to find specific items in a `containsMany` or `linksToMany` collection, then render those items using their field templates for proper delegated rendering. + +**Key Concept:** The `get` helper allows you to access `@fields` array elements by index, creating a bridge between data-driven iteration and component-based rendering. + +#### When to Use This Pattern + +- **Filtering:** Show only items matching certain criteria +- **Conditional rendering:** Display items based on model data +- **Custom ordering:** Reorder items based on computed logic +- **Highlighted selection:** Emphasize specific items in a collection + +#### Basic Pattern + +```hbs +{{! Access a specific field by index }} +{{#let (get @fields.shoppingList 0) as |firstItem|}} + {{#if firstItem}} + + {{else}} +
No first item
+ {{/if}} +{{/let}} + +{{! Access last item using subtract helper }} +{{#let (get @fields.items (subtract @model.items.length 1)) as |lastItem|}} + {{#if lastItem}} + + {{/if}} +{{/let}} +``` + +#### Displaying Compound Fields + +**CRITICAL:** When displaying compound fields (FieldDef types) like `PhoneNumberField`, `AddressField`, or custom field definitions, you must use their format templates, not raw model access: + +```hbs + +

Phone: {{@model.phone}}

+ + +

Phone: <@fields.phone @format="atom" />

+ + +
+ <@fields.phone @format="embedded" /> +
+``` + +**💡 Line-saving tip:** Keep self-closing tags compact: +```hbs + +<@fields.author @format="embedded" /> +<@fields.phone @format="atom" /> +``` + +#### @fields Delegation Rule + +**CRITICAL:** When delegating to embedded/fitted formats, you must iterate through `@fields`, not `@model`. Always use `@fields` for delegation, even for singular fields. + +```hbs + +<@fields.author @format="embedded" /> +<@fields.items @format="embedded" /> +{{#each @fields.items as |item|}} + +{{/each}} + + +{{#each @model.items as |item|}} + <@fields.??? @format="embedded" /> +{{/each}} +``` + +**containsMany Spacing Pattern:** Due to an additional wrapper div, target `.containsMany-field`: +```css +/* For grids */ +.products-grid > .containsMany-field { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; +} + +/* For lists */ +.items-list > .containsMany-field { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +``` + +### Template Fallback Value Patterns + +**CRITICAL:** Boxel cards boot with no data by default. Templates must gracefully handle null, undefined, and empty string values at ALL levels of data access to prevent runtime errors and provide meaningful visual fallbacks. + +#### Three Primary Patterns for Fallbacks + +**1. Inline if/else (for simple display fallbacks):** +```hbs +{{if @model.eventTime (formatDateTime @model.eventTime "MMM D, h:mm A") "Event time to be announced"}} +

{{if @model.title @model.title "Untitled Document"}}

+

Status: {{if @model.status @model.status "Status pending"}}

+``` + +**2. Block-based if/else (for complex content):** +```hbs +
+ {{#if @model.eventTime}} + {{formatDateTime @model.eventTime "MMM D, h:mm A"}} + {{else}} + Event time to be announced + {{/if}} +
+ +{{#if @model.description}} +
+ <@fields.description /> +
+{{else}} +
+

No description provided yet. Click to add one.

+
+{{/if}} +``` + +**3. Unless for safety/validation checks (composed with other helpers):** +```hbs +{{unless (and @model.isValid @model.hasPermission) "⚠️ Cannot proceed - missing validation or permission"}} +{{unless (or @model.email @model.phone) "Contact information required"}} +{{unless (gt @model.items.length 0) "No items available"}} +{{unless (eq @model.status "active") "Service unavailable"}} +``` + +**Best Practices:** Use descriptive placeholder text rather than generic "N/A", style placeholder text differently (lighter color, italic), use `unless` for safety checks and `if` for display fallbacks. + +**Icon Usage:** Avoid emoji in templates (unless the application specifically calls for it) due to OS/platform variations that cause legibility issues. Use Boxel icons only for static card/field type icons (`static icon` property). In templates, use inline SVG instead since we can't be sure which Boxel icons exist. + +### Template Array Handling Patterns + +**CRITICAL:** Templates must gracefully handle all array states to prevent errors. Arrays can be undefined, null, empty, or populated. + +#### The Three Array States + +Your templates must handle: +1. **Completely undefined arrays** - Field doesn't exist or is null +2. **Empty arrays** - Field exists but has no items (`[]`) +3. **Arrays with actual data** - Field has one or more items + +#### Array Logic Pattern + +**❌ WRONG - Only checks for existence:** +```hbs +{{#if @model.goals}} +
    + {{#each @model.goals as |goal|}} +
  • {{goal}}
  • + {{/each}} +
+{{/if}} +``` + +**✅ CORRECT - Checks for length and provides empty state:** +```hbs +{{#if @model.goals.length}} +
+

+ + + + + + Daily Goals +

+
    + {{#each @model.goals as |goal|}} +
  • {{goal}}
  • + {{/each}} +
+
+{{else}} +
+

+ + + + + + Daily Goals +

+

No goals set yet. What would you like to accomplish?

+
+{{/if}} +``` + +**Remember:** When implementing templates via SEARCH/REPLACE, include tracking markers ⁿ for style blocks + +### Real-World Example: Shopping List with Featured Items + +```gts +import { CardDef, FieldDef, field, contains, containsMany, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import { get } from '@ember/helper'; +import { subtract } from '@cardstack/boxel-ui/helpers'; + +export class FruitItem extends FieldDef { + static displayName = 'Fruit'; + @field title = contains(StringField); + @field quantity = contains(NumberField); + + static embedded = class Embedded extends Component { + + }; +} + +export class ShoppingList extends CardDef { + static displayName = 'Shopping List'; + @field items = containsMany(FruitItem); + + static isolated = class Isolated extends Component { + + }; +} +``` + +#### Important Notes + +**CRITICAL Safety Checks:** +- Always wrap `get` results in `{{#if}}` to handle undefined indices +- Use `subtract` helper for negative indexing (e.g., last item) +- Validate array length before accessing by index + +**When NOT to Use:** +- If you need to iterate all items → use `<@fields.items />` delegation +- If you need custom rendering for each → use `{{#each @model.items}}` pattern +- For simple filtering → use query patterns with PrerenderedCardSearch + +**Performance Consideration:** +The `get` helper is efficient for accessing specific indices. For complex filtering or transformation, consider using query patterns or computed properties instead. \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-theme-design-system.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-theme-design-system.md new file mode 100644 index 0000000000..d397afc338 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-theme-design-system.md @@ -0,0 +1,239 @@ +### Theme-First Principle + +- Always link a Theme before generating code or styling. (See 3.1 Theme Linking Rules) +- All CSS in card templates must use theme variables (no hardcoded colors/spacing/fonts). (See 3.2 Canonical Theme Variables) +- Theme linkage lives at `relationships.cardInfo.theme` on the card instance. + +### 3.1 Theme Linking Rules +- Set this as the Default Theme for all new, non-ThemeCard instances: + +``` +"relationships": { + "cardInfo.theme": { + "links": { + "self": "https://app.boxel.ai/catalog/Theme/cardstack" + } + } +} +``` +- You must also set the remaining cardInfo properties in the card data attributes. Example: +``` +"attributes": { + "cardInfo": { + "notes": null, + "title": "[card title here]", + "description": "[brief card description here]", + "thumbnailURL": "[card thumbnail url here]" + }, +} +``` +- IMPORTANT: Never set `cardInfo.theme` on ThemeCards (cards adopting from `https://cardstack.com/base/theme/default` or its subclasses) to avoid cycles. + +#### ThemeCard Types + +A ThemeCard is an instance of a card definition that inherits from `https://cardstack.com/base/theme/default` or from one of its subclasses. + +- Base: `https://cardstack.com/base/theme/default` +- Subclasses: + - `https://cardstack.com/base/structured-theme/default` + - `https://cardstack.com/base/detailed-style-reference/default` + - `https://cardstack.com/base/style-reference/default` + - `https://cardstack.com/base/brand-guide/default` + +### 3.2 Canonical Theme Variables +Use the variables directly (do not wrap with `hsl(var(...))`). Pair backgrounds with their foregrounds for contrast. + +Our design system is compatible with shadcn css variables. + +- Background Colors: +``` +--background +--card +--popover +--primary +--secondary +--muted +--accent +--destructive +--input +--sidebar +--sidebar-primary +--sidebar-accent +``` + +- Foreground Colors: +``` +--foreground +--card-foreground +--popover-foreground +--primary-foreground +--secondary-foreground +--muted-foreground +--accent-foreground +--destructive-foreground +--sidebar-foreground +--sidebar-primary-foreground +--sidebar-accent-foreground +``` +- Border Colors: +``` +--border +--sidebar-border +``` +- Css Outline Colors: +``` +--ring +--sidebar-ring +``` +- Chart Colors: +``` +--chart-1 +--chart-2 +--chart-3 +--chart-4 +--chart-5 +``` + +- Fonts: (`font-family`) +``` +--font-sans +--font-serif +--font-mono +``` +- Radius: (`border-radius`) +``` +--radius +--boxel-border-radius-xxs +--boxel-border-radius-xs +--boxel-border-radius-sm +--boxel-border-radius +--boxel-border-radius-lg +--boxel-border-radius-xl +--boxel-border-radius-xxl +``` +- Spacing: +``` +--spacing +--boxel-sp-6xs +--boxel-sp-5xs +--boxel-sp-4xs +--boxel-sp-3xs +--boxel-sp-2xs +--boxel-sp-xs +--boxel-sp-sm +--boxel-sp +--boxel-sp-lg +--boxel-sp-xl +--boxel-sp-2xl +--boxel-sp-3xl +--boxel-sp-4xl +--boxel-sp-5xl +--boxel-sp-6xl +``` +- Letter-spacing: +``` +--tracking-normal +--boxel-lsp-xxl +--boxel-lsp-xl +--boxel-lsp-lg +--boxel-lsp +--boxel-lsp-sm +--boxel-lsp-xs +--boxel-lsp-xxs +``` +- Shadows: (`box-shadow`) +``` +--shadow-2xs +--shadow-xs +--shadow-sm +--shadow +--shadow-md +--shadow-lg +--shadow-xl +--shadow-2xl +--boxel-box-shadow +--boxel-box-shadow-hover +--boxel-deep-box-shadow +``` + +- Font Sizes: (`font-size`) +``` +--boxel-font-size-2xl +--boxel-font-size-xl +--boxel-font-size-lg +--boxel-font-size-md +--boxel-font-size +--boxel-font-size-sm +--boxel-font-size-xs +--boxel-heading-font-size +--boxel-section-heading-font-size +--boxel-subheading-font-size +--boxel-body-font-size +--boxel-caption-font-size +``` + +#### CSS Usage Examples: + +✅ Correct: +``` +background-color: var(--card); +color: var(--card-foreground); +border-color: var(--border); +font-family: var(--font-serif); +border-radius: var(--radius); +padding: var(--spacing); +margin-top: calc(var(--spacing) * 2); +box-shadow: var(--shadow-lg); +``` +❌ Incorrect: +``` +background-color: hsl(var(--background)); /* Do not wrap in hsl() */ +``` + +### CSS Safety (All Formats) +- Always use ` + +``` \ No newline at end of file diff --git a/packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md b/packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md new file mode 100644 index 0000000000..b155e83fb9 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md @@ -0,0 +1,305 @@ +--- +name: boxel-file-structure +description: Use when organizing files in a Boxel workspace, choosing filenames or directories for card definitions and instances, or validating JSON `adoptsFrom.module` paths and relationship links. +--- + +# Boxel File Structure Rules + +Rules for organizing files in a Boxel workspace when working locally with boxel-cli. + +## URL Structure + +``` +https://[realm-domain]/[username]/[workspace]/[path].[extension] +Example: https://app.boxel.ai/sarah/pet-rescue/animals/dog.gts +``` + +## File Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Card definitions | `kebab-case.gts` | `blog-post.gts`, `grammy-award.gts` | +| Instance directories | `PascalCase/` | `BlogPost/`, `GrammyAward/` | +| Instance files | `kebab-case.json` | `my-first-post.json` | + +## Directory Structure + +``` +workspace/ +├── .realm.json # Workspace config +├── index.json # Workspace index +├── cards-grid.json # Default cards grid +├── blog-post.gts # Card definition (kebab-case) +├── BlogPost/ # Instance directory (PascalCase) +│ ├── my-first-post.json +│ └── another-post.json +├── author.gts +└── Author/ + └── jane-doe.json +``` + +## Module Paths in JSON (CRITICAL) + +**The `adoptsFrom.module` path is relative to the JSON file location.** + +### ✅ Correct: Instance in subdirectory +``` +grammy-award.gts # Definition at root +GrammyAward/ # Instances in PascalCase directory +└── record-of-the-year.json +``` + +**In `GrammyAward/record-of-the-year.json`:** +```json +{ + "meta": { + "adoptsFrom": { + "module": "../grammy-award", // ← Go UP to parent, then to file + "name": "GrammyAward" + } + } +} +``` + +### ❌ Wrong: Forgetting the relative path +```json +{ + "meta": { + "adoptsFrom": { + "module": "./grammy-award", // ← WRONG! This looks in GrammyAward/ + "name": "GrammyAward" + } + } +} +``` + +## Path Rules Summary + +| JSON Location | Definition Location | Module Path | +|--------------|---------------------|-------------| +| `root/Instance.json` | `root/card.gts` | `"./card"` | +| `root/Card/instance.json` | `root/card.gts` | `"../card"` | +| `root/Card/Sub/instance.json` | `root/card.gts` | `"../../card"` | +| `root/Card/instance.json` | `root/other/card.gts` | `"../other/card"` | + +## Instance JSON Structure (Full) + +```json +{ + "data": { + "type": "card", + "attributes": { + "fieldName": "value", + "numberField": 123, + "boolField": true + }, + "relationships": { + "author": { + "links": { + "self": "../Author/jane-doe" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../card-definition", + "name": "CardClassName" + } + } + } +} +``` + +## linksToMany Relationships (CRITICAL) + +**🔴 For `linksToMany` fields, use numbered keys like `fieldName.0`, `fieldName.1`, etc.** + +```json +{ + "data": { + "relationships": { + "tags.0": { + "links": { + "self": "../Tag/tech" + } + }, + "tags.1": { + "links": { + "self": "../Tag/news" + } + }, + "tags.2": { + "links": { + "self": "../Tag/tutorial" + } + } + } + } +} +``` + +### ❌ Wrong: Array syntax (does NOT work) +```json +{ + "relationships": { + "tags": { + "links": { + "self": ["../Tag/tech", "../Tag/news"] + } + } + } +} +``` +``` + +### JSON Structure Rules + +| Section | Purpose | Required | +|---------|---------|----------| +| `data.type` | Always `"card"` | Yes | +| `data.attributes` | Scalar field values (string, number, bool) | Yes | +| `data.relationships` | Links to other cards (`linksTo`/`linksToMany`) | Only if has links | +| `data.meta.adoptsFrom` | References the card definition | Yes | + +### Attributes vs Relationships + +**Use `attributes` for:** +- StringField, NumberField, BooleanField values +- FieldDef instances (embedded via `contains`) +- Any non-card data + +**Use `relationships` for:** +- CardDef references (`linksTo` → single link) +- CardDef arrays (`linksToMany` → array of links) + +## The Cardinal Rule (linksTo vs contains) + +**🔴 CRITICAL - memorize this:** + +| Field Type | Definition uses | Instance uses | +|------------|-----------------|---------------| +| Extends `CardDef` | `linksTo` / `linksToMany` | `relationships` | +| Extends `FieldDef` | `contains` / `containsMany` | `attributes` | + +```gts +// In .gts definition: +@field author = linksTo(Author); // Author extends CardDef → relationships +@field address = contains(AddressField); // AddressField extends FieldDef → attributes +``` + +```json +// In .json instance: +{ + "attributes": { + "address": { "street": "123 Main", "city": "NYC" } + }, + "relationships": { + "author": { "links": { "self": "../Author/jane" } } + } +} +``` + +## Links Between Cards + +When linking to other cards, use the card's URL without `.json`: + +```json +{ + "data": { + "relationships": { + "author": { + "links": { + "self": "../Author/jane-doe" + } + } + } + } +} +``` + +## Base Realms (Read-Only) + +These realms contain shared definitions you can import from: + +**Production:** +- `https://cardstack.com/base/` - Core types (CardDef, FieldDef, etc.) +- `https://app.boxel.ai/catalog/` - Catalog cards +- `https://app.boxel.ai/skills/` - Skill cards + +**Staging:** +- `https://cardstack.com/base/` - Same core types +- `https://realms-staging.stack.cards/catalog/` +- `https://realms-staging.stack.cards/skills/` + +## Common Import Patterns + +```gts +// Core imports (always from cardstack.com/base) +import { + CardDef, + FieldDef, + field, + contains, + linksTo, + containsMany, + linksToMany, + StringField, + NumberField, + BooleanField, + Component, +} from 'https://cardstack.com/base/card-api'; + +// Import from same workspace +import { Author } from './author'; + +// Import from base realm +import { Skill } from 'https://cardstack.com/base/skill'; +``` + +## Query Structure (for API searches) + +When using the `/_search` API endpoint: + +```json +{ + "filter": { + "type": { + "module": "https://realm-url/card-name", + "name": "CardClassName" + } + } +} +``` + +**With field filters:** +```json +{ + "filter": { + "on": { "module": "https://realm-url/product", "name": "Product" }, + "contains": { "name": "laptop" } + } +} +``` + +**Operations:** `eq`, `contains`, `range`, `not`, `type`, `every` (AND), `any` (OR) + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| `"module": "./card"` from subdirectory | Use `"../card"` | +| `contains(CardDef)` | Use `linksTo(CardDef)` | +| `linksTo(FieldDef)` | Use `contains(FieldDef)` | +| Link in `attributes` | Move to `relationships` | +| FieldDef in `relationships` | Move to `attributes` | +| Missing `data` wrapper in JSON | Wrap everything in `{"data": {...}}` | +| PascalCase for `.gts` files | Use `kebab-case.gts` | +| kebab-case for instance dirs | Use `PascalCase/` | +| `linksToMany` as array | Use numbered keys: `field.0`, `field.1`, etc. | + +## Essential Formats + +Every CardDef should implement these templates: +- `isolated` - Full detail view (scrollable) +- `embedded` - Compact summary for lists +- `fitted` - Fixed dimensions for grids/dashboards (CRITICAL for good UX) diff --git a/packages/software-factory/.agents/skills/boxel-repair/SKILL.md b/packages/software-factory/.agents/skills/boxel-repair/SKILL.md new file mode 100644 index 0000000000..5bb67bafcd --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-repair/SKILL.md @@ -0,0 +1,42 @@ +--- +name: boxel-repair +description: Use when a Boxel workspace has broken realm metadata, missing icons or backgrounds, bad `index.json` or `cards-grid.json` links, or stale Matrix realm metadata that needs `boxel repair-realm` or `boxel repair-realms`. +--- + +# Boxel Repair + +Use this workflow when a workspace has any of these symptoms: +- Missing icon/background in workspace tiles +- Display name is `Unknown Workspace` or mismatched +- Opening a workspace fails due to missing `cards-grid` relationship +- Matrix workspace list (`app.boxel.realms`) is stale/inconsistent + +## Commands + +```bash +# Inspect one realm without mutating +boxel repair-realm --dry-run + +# Repair one realm +boxel repair-realm + +# Repair all realms owned by active profile user +boxel repair-realms +``` + +## Behavior + +`repair-realm` and `repair-realms` perform these repairs: +- `.realm.json`: normalize `name`, `iconURL`, `backgroundURL` +- `index.json`: ensure `relationships.cardsGrid.links.self` = `./cards-grid` +- `cards-grid.json`: restore default cards-grid card if missing/corrupt +- Before replacing `index.json`/`cards-grid.json`, preserve existing content as timestamped backup cards in the same realm +- `index.json`: write `data.meta._touched` timestamp to break cache +- Matrix `app.boxel.realms`: reconcile list to match repaired, accessible realms + +## Important Defaults + +- `personal` realm is excluded unless `--include-personal` is provided. +- Batch repair defaults to active profile owner. +- Use `--no-reconcile-matrix` when you want file/card repair only. +- Use `--no-fix-index`/`--no-touch-index` when debugging minimal metadata-only fixes. diff --git a/packages/software-factory/.agents/skills/boxel-restore/SKILL.md b/packages/software-factory/.agents/skills/boxel-restore/SKILL.md new file mode 100644 index 0000000000..15885c19e7 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-restore/SKILL.md @@ -0,0 +1,76 @@ +--- +name: boxel-restore +description: Use when restoring a Boxel workspace to a previous checkpoint and syncing deletions back to the server safely, including stopping watch first and running `boxel sync . --prefer-local` after restore. +--- + +# Boxel Restore + +Restore workspace to a previous checkpoint and sync deletions to server. + +## Workflow + +1. **Stop watch if running** - Prevents re-pulling deleted files +2. **Show history** - Display recent checkpoints with numbers +3. **Confirm target** - Ask user which checkpoint (or accept from command) +4. **Restore locally** - Run `boxel history . -r ` +5. **Sync to server** - Run `boxel sync . --prefer-local` to push deletions +6. **Restart watch** - Optionally restart watch if it was running + +## Usage + +``` +Use the `boxel-restore` skill interactively +Restore checkpoint `3` +Restore checkpoint `abc123` +``` + +## Commands Used + +```bash +# Stop any running watch first +# (check /tasks and stop if needed) + +# View history +boxel history . + +# Restore to checkpoint (auto-confirm) +echo "y" | boxel history . -r + +# ESSENTIAL: Push deletions to server +boxel sync . --prefer-local + +# Optionally restart watch +boxel watch . -i -d +``` + +## Response Format + +1. Show the checkpoint being restored to (hash, message, date, source) +2. List files that will be deleted (if any new files since checkpoint) +3. Execute restore +4. Execute sync with --prefer-local +5. Confirm completion + +## Critical Notes + +- **Always stop watch before restoring** - Otherwise it re-pulls deleted files +- **Always use --prefer-local after restore** - This syncs deletions to server +- After restore, workspace matches checkpoint exactly (files added later are gone) + +## Example Output + +``` +Restoring to checkpoint #3: abc1234 + Message: Pull: Update knicks-vip-ticket.gts + Source: SERVER (external change) + Date: 5 minutes ago + +Files that will be deleted: + - KnicksVipTicket/knicks-vs-magic.json + - KnicksVipTicket/knicks-vs-thunder.json + +Restoring... ✓ +Syncing deletions to server... ✓ + +Restore complete. Server now matches checkpoint #3. +``` diff --git a/packages/software-factory/.agents/skills/boxel-setup/SKILL.md b/packages/software-factory/.agents/skills/boxel-setup/SKILL.md new file mode 100644 index 0000000000..d8019c9a4e --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-setup/SKILL.md @@ -0,0 +1,103 @@ +--- +name: boxel-setup +description: Use for Boxel CLI onboarding, profile setup, verifying login, listing workspaces, switching profiles, or helping a new user perform their first sync. +--- + +# Boxel Setup + +Guide new users through Boxel CLI setup. + +## Trigger +Run this automatically when: +- User first opens the repo +- No profile is configured (`npx boxel profile` shows nothing) +- User asks about setup or getting started + +## Flow + +### 1. Check Current State +```bash +npx boxel profile +``` + +If no profile exists, proceed with setup. + +### 2. Add a Profile + +**Option A: Interactive (recommended)** +```bash +npx boxel profile add +``` + +This wizard will: +1. Ask for environment (Production or Staging) +2. Ask for username and password +3. Create the profile automatically + +**Option B: Non-interactive (CI/automation)** + +Ask the user for: +- **Environment**: Production (app.boxel.ai) or Staging (realms-staging.stack.cards) +- **Username**: Their Boxel handle (e.g., `aallen90`, `ctse`). Found in Account panel as `@username:stack.cards` or in workspace URLs like `app.boxel.ai/username/workspace-name` +- **Password**: Same as Boxel web login + +Then run (using environment variable for security): + +**Production:** +```bash +BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "Production" +``` + +**Staging:** +```bash +BOXEL_PASSWORD="password" npx boxel profile add -u @username:stack.cards -n "Staging" +``` + +> **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history. + +### 3. Verify +```bash +npx boxel list +``` + +### 4. First Sync +Help them sync a workspace: +```bash +npx boxel sync @username/workspace ./workspace-name +``` + +## Profile Management + +**List profiles:** +```bash +npx boxel profile list +``` + +**Switch profile:** +```bash +npx boxel profile switch +``` + +**Migrate from old .env:** +```bash +npx boxel profile migrate +``` + +## Success Message +``` +Setup complete! You can now: +- `npx boxel list` - See your workspaces +- `npx boxel sync @username/workspace` - Sync a workspace +- `npx boxel watch .` - Monitor for changes +- `npx boxel history .` - View/restore checkpoints + +Profile management: +- `npx boxel profile` - Show active profile +- `npx boxel profile list` - List all profiles +- `npx boxel profile switch ` - Switch profiles + +For AI-assisted development, try: +- `boxel-watch` - Smart watch with auto intervals +- `boxel-sync` - Context-aware sync +- `boxel-restore` - Undo changes +``` diff --git a/packages/software-factory/.agents/skills/boxel-sync/SKILL.md b/packages/software-factory/.agents/skills/boxel-sync/SKILL.md new file mode 100644 index 0000000000..9496478dab --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-sync/SKILL.md @@ -0,0 +1,80 @@ +--- +name: boxel-sync +description: Use when deciding how to sync a Boxel workspace after local edits, server changes, or a restore, including choosing between interactive sync, `--prefer-local`, `--prefer-remote`, or `--prefer-newest`. +--- + +# Boxel Sync + +Smart bidirectional sync with context-aware conflict resolution. + +## Context Detection + +Analyze the situation to choose the right sync strategy: + +### After Local Edits +When Claude has been editing files locally: +- Use `--prefer-local` to push changes +- Creates checkpoint for the push + +### After Server Activity +When watch detected server changes or user mentions UI edits: +- Use `--prefer-remote` or default (interactive) +- Pull changes first + +### After Restore +When a restore was just performed: +- Use `--prefer-local` to sync deletions to server +- Essential for completing the restore workflow + +### Conflict Detected +When both sides have changes: +- Show status first +- Ask user preference or use `--prefer-newest` + +## Commands + +```bash +# Check status first +boxel status . + +# Standard sync (interactive conflicts) +boxel sync . + +# Push local changes +boxel sync . --prefer-local + +# Pull remote changes +boxel sync . --prefer-remote + +# Auto-resolve by timestamp +boxel sync . --prefer-newest + +# Include deletions +boxel sync . --delete + +# Preview only +boxel sync . --dry-run +``` + +## Response Format + +1. Brief status check (what changed where) +2. Chosen strategy and why +3. Execute sync +4. Report results (files pushed/pulled/deleted) + +## Example Output + +``` +Checking status... + Local: 2 files modified + Remote: No changes + +Using --prefer-local since you have local edits. + +Syncing... + Pushed: card-definition.gts, instance.json + Checkpoint: abc1234 [MAJOR] Push: 2 files + +Sync complete! +``` diff --git a/packages/software-factory/.agents/skills/boxel-track/SKILL.md b/packages/software-factory/.agents/skills/boxel-track/SKILL.md new file mode 100644 index 0000000000..965f26377f --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-track/SKILL.md @@ -0,0 +1,117 @@ +--- +name: boxel-track +description: Use when starting or explaining `boxel track` for local file watching, automatic checkpoints, or optional real-time push with `--push` during Boxel development. +--- + +# Boxel Track + +Start `boxel track` to monitor local file changes and create checkpoints automatically. + +## When to Use Track + +Use **track** when you're editing files locally (in IDE, with AI agent, etc.) and want automatic backups: +- Working in VS Code, Cursor, or other IDE +- AI agent is editing files +- You want checkpoint history of your work + +**Track vs Watch:** +| Command | Symbol | Direction | Purpose | +|---------|--------|-----------|---------| +| `track` | ⇆ | Local edits → Checkpoints | Backup your work as you edit | +| `watch` | ⇅ | Server → Local | Pull external changes from Boxel UI | + +## Commands + +```bash +# Start tracking (default: 3s debounce, 10s min interval) +boxel track . + +# Track AND auto-push to server (real-time sync) +boxel track . --push + +# Custom timing (5s debounce, 30s between checkpoints) +boxel track . -d 5 -i 30 + +# Quiet mode (only show checkpoints) +boxel track . -q + +# Verbose mode (debug output) +boxel track . -v + +# Stop all track/watch processes +boxel stop +``` + +## The Track → Sync Workflow + +### Option 1: Manual Sync (Default) +Track creates local checkpoints only. Push to server when ready: + +```bash +# 1. Track creates checkpoints as you edit +boxel track . + +# 2. When ready to push to server, sync with --prefer-local +boxel sync . --prefer-local +``` + +This lets you: +- Work offline with local backups +- Batch multiple edits before pushing +- Review changes before they go live + +### Option 2: Real-Time Sync (--push) +Auto-push changes to server as you edit: + +```bash +# Track AND push changes automatically +boxel track . --push +``` + +Uses batch upload via `/_atomic` endpoint for efficient multi-file uploads. Definitions (.gts) are uploaded before instances (.json) to ensure proper indexing. + +## Context Detection + +When invoked, consider: + +### Standard Development (3s debounce, 10s interval) +- Normal editing workflow +- Balanced between checkpoint frequency and overhead + +### Fast Iteration (2s debounce, 5s interval) +- Rapid prototyping +- User says "track closely" or "capture everything" + +### Background Tracking (5s debounce, 30s interval) +- Long editing sessions +- User says "just backup" or "light tracking" + +## Response Format + +When invoked: +1. Confirm workspace directory +2. Start track with appropriate settings +3. **Remind user about sync options** + +Example (without --push): +``` +Starting track in the current workspace (3s debounce, 10s interval). +Checkpoints will be created automatically as you save files. + +Remember: Track creates LOCAL checkpoints only. +When ready to push changes to Boxel server: + boxel sync . --prefer-local + +Or restart with --push for real-time sync: + boxel track . --push + +Use Ctrl+C to stop tracking, or `boxel stop` from another terminal. +``` + +Example (with --push): +``` +Starting track with auto-push (3s debounce, 10s interval). +Changes will be checkpointed AND pushed to server automatically. + +Use Ctrl+C to stop, or `boxel stop` from another terminal. +``` diff --git a/packages/software-factory/.agents/skills/boxel-watch/SKILL.md b/packages/software-factory/.agents/skills/boxel-watch/SKILL.md new file mode 100644 index 0000000000..a733ef7eb5 --- /dev/null +++ b/packages/software-factory/.agents/skills/boxel-watch/SKILL.md @@ -0,0 +1,66 @@ +--- +name: boxel-watch +description: Use when starting or choosing settings for `boxel watch` to monitor remote Boxel changes, including active-development, quick-feedback, and background-monitoring intervals. +--- + +# Boxel Watch + +Start `boxel watch` with intelligent interval settings based on context. + +## Context Detection + +Analyze the conversation and recent activity to determine the appropriate watch settings: + +### Active Development Mode (5s interval, 3s debounce) +Use when: +- User is actively editing .gts or .json files +- User mentions "editing", "working on", "changing", "updating" +- Recent file writes or edits in the workspace +- User asks to "watch while I work" + +### Monitoring Mode (30s interval, 10s debounce) +Use when: +- User wants to "keep an eye on" changes +- User is doing research, reading, or planning +- No recent edits to workspace files +- User says "background", "monitor", or "check occasionally" + +### Quick Feedback Mode (10s interval, 5s debounce) +Use when: +- User is testing changes in Boxel UI +- User mentions "testing", "trying", "see if it works" +- Balanced between responsiveness and efficiency + +## Execution + +1. Determine the workspace directory (default: current synced workspace) +2. Determine the mode based on context +3. Explain the chosen settings briefly +4. Start watch in background with appropriate flags +5. Inform user how to stop (Ctrl+C or task stop) + +## Commands + +```bash +# Active development +boxel watch . -i 5 -d 3 + +# Monitoring +boxel watch . -i 30 -d 10 + +# Quick feedback +boxel watch . -i 10 -d 5 + +# Quiet mode (any interval) +boxel watch . -i -d -q +``` + +## Response Format + +When invoked, respond with: +1. Detected mode and reasoning (1 sentence) +2. The watch command being run +3. How to stop or adjust + +Example: +"Starting watch in **active development mode** (5s interval) since you're editing card files. Run in background - use `/tasks` to check status or Ctrl+C to stop." diff --git a/packages/software-factory/.agents/skills/software-factory-operations/SKILL.md b/packages/software-factory/.agents/skills/software-factory-operations/SKILL.md new file mode 100644 index 0000000000..7fa9b72234 --- /dev/null +++ b/packages/software-factory/.agents/skills/software-factory-operations/SKILL.md @@ -0,0 +1,62 @@ +--- +name: software-factory-operations +description: Use when building or extending an application through the Boxel software-factory workflow in this repo, especially when the task should be broken into tickets, stored in Boxel, implemented in a target realm, verified with Playwright, and synced/checkpointed incrementally. +--- + +# Software Factory Operations + +Use this skill when the objective is not just to write code, but to run the full Boxel software-factory loop successfully. + +## Read First + +- `AGENTS.md` +- `.boxel-workspaces.json` + +## Realm Map + +- `./realms/guidance-tasks` + Shared tracker schema and demo cards. Import tracker modules from here. +- `./realms/software-factory-demo` + Default implementation realm for the current demo and the place to build new artifacts. + +Use `boxel realms --llm` whenever file placement is unclear. + +## Working Commands + +- Search a realm: + `npm run boxel:search -- --realm --size 20` +- Pick backlog tickets: + `npm run boxel:pick-ticket -- --realm --module http://localhost:4201/factory/guidance-tasks/darkfactory-schema` +- Get browser auth payloads: + `npm run boxel:session -- --realm ` +- Run browser verification: + `npm run test:ticket-flow` +- Run Boxel-hosted project tests: + `npm run test:realm -- --realm-path ./realms/` +- Sync implementation realm: + `boxel sync ./realms/software-factory-demo --prefer-local` +- Create manual checkpoints: + `boxel history ./realms/software-factory-demo -m ""` + +## Required Flow + +1. Search for backlog tickets in the target implementation realm. +2. Move the chosen ticket to `in_progress` before implementation. +3. Build the requested Boxel files in the implementation realm. +4. Keep product-specific Playwright specs and fixture files in the implementation realm when they should persist with the project. +5. Prefer fixture-driven verification through a fresh scratch realm created by `npm run test:realm`. +6. Verify the resulting card URL with Playwright. +7. Update ticket notes, acceptance criteria, and related knowledge. +8. Sync to Boxel and create meaningful checkpoints. +9. Commit repo-side tooling or instruction changes in git. + +## Important Gotchas + +- For tracker searches, use the schema module URL: + `http://localhost:4201/factory/guidance-tasks/darkfactory-schema` +- If a card in one private realm imports definitions from another private realm, seed browser auth for both realms. +- Realm-hosted test fixtures should usually be stored as final realm-relative paths under `tests/fixtures/`. +- Scratch realms should be checked out under the canonical Boxel workspace path layout, not ad hoc folders, so `boxel` commands do not keep reporting legacy workspace locations. +- If a fixture card instance is meant to run in a scratch realm, use an absolute `adoptsFrom.module` URL whenever the backing definition lives in the source realm. +- Boxel host pages keep long-lived network activity. In Playwright, do not wait for `networkidle`; use `domcontentloaded` plus visible assertions. +- `guidance-tasks` is a shared schema realm, not the place to build product-specific implementation files. diff --git a/packages/software-factory/.claude/CLAUDE.md b/packages/software-factory/.claude/CLAUDE.md new file mode 100644 index 0000000000..83247d1ca1 --- /dev/null +++ b/packages/software-factory/.claude/CLAUDE.md @@ -0,0 +1,699 @@ +# Boxel CLI - Claude Code Integration + +## GitHub Repository + +**Official repo:** https://github.com/cardstack/boxel-cli + +--- + +## How to Run Boxel Commands + +After `npm install && npm run build`, use `npx boxel`: + +```bash +npx boxel sync . +npx boxel history ./workspace +npx boxel profile add +``` + +Or use `boxel` directly after `npm link`. + +**For development** (no rebuild needed after code changes): +```bash +npm run dev -- +``` + +All documentation below shows `boxel ` for brevity. + +--- + +## Auto-Activate Boxel Development Skill + +**IMPORTANT:** When the user is doing ANY of the following, automatically read and follow `.claude/skills/boxel-development/SKILL.md`: + +- Creating or editing `.gts` files (card definitions) +- Creating or editing `.json` card instances +- Asking about Boxel patterns, cards, or components +- "Vibe coding" or prototyping Boxel cards +- Working in a synced Boxel workspace (has `.boxel-sync.json`) +- Asking to create, build, or design anything in Boxel + +**How to activate:** Read the skill file at the start of the task: +``` +Read .claude/skills/boxel-development/SKILL.md +``` + +The skill contains comprehensive Boxel development guidance including CardDef/FieldDef patterns, templates, styling, and best practices. + +--- + +**When a user opens this repo, check if they need onboarding first!** + +## Onboarding Flow + +When you detect a new user (no profile configured), guide them through setup: + +### Step 1: Check Profile +```bash +npx boxel profile +``` + +If no profile exists, run the interactive setup: + +### Step 2: Add a Profile +```bash +npx boxel profile add +``` + +This launches an interactive wizard that: +1. Asks for environment (Production or Staging) +2. Asks for username and password +3. Creates the profile in `~/.boxel-cli/profiles.json` + +**Non-interactive option (CI/automation only):** +```bash +# Use environment variable to avoid exposing password in shell history +BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "My Prod Account" +``` + +> **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history and process listings. Use the interactive wizard or `BOXEL_PASSWORD` environment variable. + +### Step 3: Verify & List Workspaces +```bash +npx boxel list +``` + +### Step 4: First Sync +Help them sync their first workspace: +```bash +npx boxel sync @username/workspace ./workspace-name +``` + +### Switching Between Profiles +```bash +npx boxel profile list # See all profiles (★ = active) +npx boxel profile switch username # Switch by partial match +``` + +--- + +## Local Workspace Organization + +When syncing multiple workspaces locally, organize them by **domain/username/realm** to mirror the Matrix ID structure (`@username:domain`): + +``` +boxel-workspaces/ +├── boxel.ai/ # Production domain +│ └── acme-corp/ # Username +│ ├── personal/ # Realm +│ ├── project-atlas/ +│ └── inventory-tracker/ +└── stack.cards/ # Staging domain + └── acme-corp/ + └── sandbox/ +``` + +**Benefits:** +- Clear separation between production and staging environments +- Matches the `@username:domain` profile ID format +- Easy to identify which profile/environment a workspace belongs to +- Supports multiple users on the same machine + +**First-time sync to this structure:** +```bash +# Production workspace +boxel pull https://app.boxel.ai/acme-corp/project-atlas/ ./boxel-workspaces/boxel.ai/acme-corp/project-atlas + +# Staging workspace +boxel pull https://realms-staging.stack.cards/acme-corp/sandbox/ ./boxel-workspaces/stack.cards/acme-corp/sandbox +``` + +--- + +## Available Skills + +Shared repo-local skills live in `.agents/skills/`. +`.claude/skills/` should be a symlink to that directory so Claude and Codex read the same files. + +### `boxel-track` - Track Local Edits +Use this skill when starting `boxel track` for local file watching and checkpoints: +- Creates checkpoints as you save files in IDE +- Use `--push` flag to automatically push changes to server (batch upload) +- Without `--push`: Run `boxel sync . --prefer-local` to push to server + +### `boxel-watch` - Smart Watch +Use this skill when starting `boxel watch` with context-aware timing: +- **Active development** (5s interval, 3s debounce): When editing files +- **Monitoring** (30s interval, 10s debounce): Background observation +- **Quick feedback** (10s interval, 5s debounce): Testing changes + +### `boxel-restore` - Restore Checkpoint +Use this skill for the full restore workflow: +1. Shows history +2. Restores to checkpoint (properly deletes newer files) +3. Syncs deletions to server with `--prefer-local` +4. Optionally restarts watch + +### `boxel-sync` - Smart Sync +Use this skill for context-aware bidirectional sync: +- After local edits or track → `--prefer-local` +- After server changes → `--prefer-remote` +- After restore → `--prefer-local` (essential for syncing deletions) + +### `boxel-repair` - Realm Metadata/Card Repair +Use when workspaces show missing icon/background, wrong display name, or fail to open due to broken `index.json`/`cards-grid.json` links. +- Read `.claude/skills/boxel-repair/SKILL.md` for the step-by-step repair flow. +- `boxel repair-realm ` repairs one realm +- `boxel repair-realms` repairs all owned realms (excluding `personal` by default) +- Also reconciles Matrix account data (`app.boxel.realms`) unless disabled + +### `software-factory-operations` - End-to-End Delivery Loop +Use this skill when the task is to break work into Boxel tickets, implement in an assigned realm, verify with Playwright, and keep knowledge plus progress checkpoints as durable factory memory. + +--- + +## Commands Reference + +### Status & Checking +```bash +boxel status . # Check sync status +boxel status --all # Check all workspaces +boxel status . --pull # Auto-pull remote changes +boxel check ./file.json --sync # Check single file +``` + +### Pull, Push, Sync (Command Relationship) + +| Command | Direction | Purpose | Deletes Local | Deletes Remote | +|---------|-----------|---------|---------------|----------------| +| `pull` | Remote → Local | Fresh download | with `--delete` | never | +| `push` | Local → Remote | Deploy changes | never | with `--delete` | +| `sync` | Both ways | Stay in sync | with `--prefer-remote` | with `--prefer-local` | + +```bash +boxel sync . # Interactive sync +boxel sync . --prefer-local # Keep local + sync deletions +boxel sync . --prefer-remote # Keep remote +boxel sync . --prefer-newest # Keep newest version +boxel sync . --delete # Sync deletions both ways +boxel sync . --dry-run # Preview only + +boxel push ./local # One-way push (local → remote) +boxel push ./local --delete # Push and remove orphaned remote files +boxel pull ./local # One-way pull (remote → local) +``` + +**Failed download cleanup:** When `sync` encounters files that return 500 errors (broken/corrupted on server), it will prompt you to delete them: +``` +⚠️ 3 file(s) failed to download (server error): + - Staff/broken-card.json + - Student/corrupted.json + +These files may be broken on the server. Delete them from remote? [y/N] +``` + +> **Safety tip:** Before any destructive operation, create a checkpoint with a descriptive message: +> ```bash +> boxel history . -m "Before cleanup: removing broken server files" +> ``` + +### Track ⇆ (Local File Watching) +```bash +boxel track . # Track local edits, auto-checkpoint as you save +boxel track . --push # Track AND push changes to server (batch upload) +boxel track . -d 5 -i 30 # 5s debounce, 30s min between checkpoints +boxel track . -q # Quiet mode +boxel track . -v # Verbose mode (debug output) +``` + +**Use track when:** Editing locally in IDE/VS Code. Creates checkpoints as you save files. +**Symbol:** ⇆ (horizontal arrows = local changes) +**With --push:** Real-time sync to server using batch upload via `/_atomic` endpoint. + +### Watch ⇅ (Remote Server Watching) +```bash +boxel watch # Watch all configured realms (from .boxel-workspaces.json) +boxel watch . # Watch single workspace +boxel watch . ./other-realm # Watch multiple realms simultaneously +boxel watch . -i 5 -d 3 # Active: 5s interval, 3s debounce +boxel watch . -q # Quiet mode +``` + +**Use watch when:** Others are editing in Boxel web UI. Pulls their changes and creates checkpoints. +**Symbol:** ⇅ (vertical arrows = remote server changes) + +### Stop +```bash +boxel stop # Stop all running watch (⇅) and track (⇆) processes +``` + +**Multi-realm watching:** Useful when code lives in one realm and data in another. Each realm gets its own checkpoint tracking and debouncing. + +### Realms (Multi-Realm Configuration) +```bash +boxel realms # List configured realms +boxel realms --init # Create .boxel-workspaces.json +boxel realms --add ./path # Add a realm +boxel realms --add ./code --purpose "Card definitions" --patterns "*.gts" --default +boxel realms --add ./data --purpose "Data instances" --card-types "BlogPost,Product" +boxel realms --llm # Output LLM guidance for file placement +boxel realms --remove ./path # Remove a realm +``` + +**File placement guidance:** The `--llm` output tells Claude which realm to use for different file types and card types. + +### History & Restore +```bash +boxel history . # View checkpoints +boxel history . -r # Interactive restore +boxel history . -r 3 # Quick restore to #3 +boxel history . -r abc123 # Restore by hash +boxel history . -m "Message" # Create checkpoint with custom message +``` + +### Skills +```bash +boxel skills --refresh # Fetch skills from Boxel +boxel skills --list # List all available skills +boxel skills --enable "Name" # Enable a skill +boxel skills --disable "Name" # Disable a skill +boxel skills --export ./project # Export as Claude commands +``` + +### Profile (Authentication) +```bash +boxel profile # Show current active profile +boxel profile list # List all saved profiles (★ = active) +boxel profile add # Interactive wizard to add profile (recommended) +# Non-interactive: use BOXEL_PASSWORD env var instead of -p flag for security +boxel profile switch # Switch profile (partial match OK) +boxel profile remove # Remove a profile +boxel profile migrate # Migrate from old .env file +``` + +**Profile IDs:** Use Matrix format `@username:domain` +- Production: `@username:boxel.ai` +- Staging: `@username:stack.cards` + +**Storage:** Profiles stored in `~/.boxel-cli/profiles.json` (permissions: 0600) + +### Other +```bash +boxel list # List workspaces +boxel create endpoint "Name" # Create workspace +boxel consolidate-workspaces . # Move legacy local dirs into domain/owner/realm +boxel repair-realm # Repair one realm metadata/starter cards +boxel repair-realms # Batch repair all owned realms +boxel pull ./local # One-way pull +boxel push ./local # One-way push +``` + +### Share & Gather (GitHub Workflow) +```bash +boxel share . -t /path/to/repo -b branch-name --no-pr # Share to GitHub repo +boxel gather . -s /path/to/repo # Pull from GitHub repo +``` + +**Share** copies workspace state to a GitHub repo branch: +- Preserves repo-level files (package.json, LICENSE, README, etc.) +- Skips realm-specific files (.realm.json, index.json, cards-grid.json) +- Creates branch and commits changes + +**Gather** pulls changes from GitHub back to workspace: +- Symmetric to share +- Preserves workspace's realm-specific files + +**Pushing to GitHub:** Use GitHub Desktop to push branches (no CLI auth configured). +After share creates the branch locally, open GitHub Desktop and push. + +### `/boxel-development` - Default Vibe Coding Skill +The **Boxel Development** skill is auto-enabled for vibe coding. It provides comprehensive guidance for: +- Card definitions (.gts files) +- Card instances (.json files) +- Boxel patterns and best practices + +### `/boxel-file-structure` - File Organization Rules +Reference for local file organization: +- Directory naming: definitions (`kebab-case.gts`), instances (`PascalCase/`) +- Module paths: relative to JSON location (`../card` from subdirectory) +- JSON structure for card instances + +### `boxel skills` - Manage Additional Skills +Fetch and manage AI instruction cards from Boxel: +```bash +boxel skills --refresh # Fetch latest from Boxel +boxel skills --list # See available skills +boxel skills --enable "X" # Enable additional skills +boxel skills --export . # Re-export to .agents/skills/ (shared with .claude/skills/) +``` + +--- + +## Key Workflows + +### Local Development with Track (IDE/Agent Editing) +```bash +boxel track . # Start tracking local edits (auto-checkpoints) +# ... edit files in IDE or with Claude ... +# Track creates LOCAL checkpoints as you save + +# IMPORTANT: When ready to push changes to Boxel server: +boxel sync . --prefer-local # Push your local changes to server +``` + +**Remember:** Track does NOT sync to server automatically - it only creates local checkpoints. Always run `sync --prefer-local` when you want your changes live on the server. + +### Real-Time Sync with Track --push +```bash +boxel track . --push # Track AND auto-push to server +# ... edit files in IDE or with Claude ... +# Changes are checkpointed AND pushed to server automatically +``` + +**With --push:** Uses batch upload via `/_atomic` endpoint for efficient multi-file uploads. Definitions (.gts) are uploaded before instances (.json) to ensure proper indexing. + +### Active Development Session (Watching Server) +```bash +boxel watch . -i 5 -d 3 # Active development settings +# ... edit in Boxel UI or locally ... +boxel sync . # Push/pull changes +``` + +### Undo Server Changes (Restore) +```bash +boxel history . # Find checkpoint +boxel history . -r 3 # Restore to #3 +boxel sync . --prefer-local # ESSENTIAL: sync deletions to server +``` + +### Share Milestone to GitHub +```bash +boxel share . -t /path/to/boxel-home -b boxel/feature-name --no-pr +# Then push via GitHub Desktop +``` + +**URL Portability:** Share automatically converts absolute realm URLs in `index.json` and `cards-grid.json` to relative URLs, making the content portable across different realms. + +### Gather Updates from GitHub +```bash +boxel gather . -s /path/to/boxel-home +boxel sync . --prefer-local # Push gathered changes to Boxel server +``` + +**URL Portability:** Gather includes `index.json` and `cards-grid.json`, transforming any absolute URLs to relative paths for portability. + +Or simply: +``` +consult boxel-restore and restore checkpoint 3 +``` + +### Monitor Server While Working +```bash +boxel watch . -i 30 -d 10 # Monitoring settings +# Checkpoints created automatically +boxel history . # View what changed +``` + +### Multi-Realm Development +When working with multiple realms (e.g., code + data separation): + +```bash +# Configure realms once +boxel realms --add ./code-realm --purpose "Card definitions" --patterns "*.gts" --default +boxel realms --add ./data-realm --purpose "Content instances" --card-types "BlogPost,Product" + +# Watch all configured realms +boxel watch + +# Check where to put a new file +boxel realms --llm +``` + +**File placement heuristics:** +- `.gts` files → realm with `*.gts` pattern (usually code realm) +- Card instances → realm configured for that card type +- Ambiguous → use the default realm + +--- + +## Critical Patterns + +### ⚠️ SAFETY FIRST: Checkpoint Before Destructive Operations +**Always create a checkpoint with a descriptive message before:** +- Deleting files from server (`--prefer-local`, `push --delete`) +- Restoring to an earlier checkpoint +- Bulk cleanup operations +- Removing card definitions or instances + +```bash +boxel history . -m "Before cleanup: removing sample data and unused definitions" +# Now safe to proceed with destructive operation +boxel sync . --prefer-local +``` + +This ensures you can always recover if something goes wrong. The checkpoint message helps identify what state to restore to. + +### 0. ALWAYS Write Source Code, Never Compiled Output +When editing `.gts` files, **always write clean idiomatic source code**: +```gts +// CORRECT - Clean source +export class MyCard extends CardDef { + static fitted = class Fitted extends Component { + + }; +} +``` + +**NEVER** write or edit: +- Compiled JSON blocks (`"block": "[[[10,0]..."`) +- Base64-encoded CSS imports (`./file.gts.CiAg...`) +- Wire format template arrays + +The server compiles source to these formats. If you see them, the file was pulled from server - rewrite it as clean source. + +### 0.5. Edit Lock Before Modifying Files +When editing files locally while watch is running, use edit lock to prevent watch from overwriting your changes: +```bash +boxel edit . grammy-gallery.gts # Lock file before editing +# ... make your edits ... +boxel sync . --prefer-local # Push your changes +boxel touch . Instance/file.json # Force re-index +boxel edit . --done grammy-gallery.gts # Release lock +``` + +**Quick commands:** +```bash +boxel edit . --list # See what's locked +boxel edit . --clear # Clear all locks +boxel edit . --done # Release all locks +``` + +**Why:** Watch mode pulls remote changes which can overwrite local edits. Edit lock tells watch to skip those files. + +### 0.5. Touch Instance After Remote .gts Update +When you update a `.gts` card definition file remotely (via sync/push), touch an instance file to force re-indexing: +```bash +boxel touch . CardName/instance.json # Touch specific instance +boxel touch . # Or touch all files +``` +**Why:** The realm server may not re-index the definition until an instance using it is touched. + +### 1. Stop Watch Before Restore +Watch will re-pull deleted files if running during restore: +```bash +# Stop watch first (Ctrl+C or kill process) +boxel history . -r 3 +boxel sync . --prefer-local +``` + +### 2. Always Use --prefer-local After Restore +This syncs local deletions to the server: +```bash +boxel history . -r 3 # Deletes files locally +boxel sync . --prefer-local # Deletes files on server +``` + +### 3. Debouncing Groups Rapid Changes +Watch waits for changes to settle: +- Change detected → timer starts +- More changes → timer resets +- Timer expires → single checkpoint with all changes + +### 4. Checkpoint Classification +- `[MAJOR]` - New files, deleted files, .gts changes, >3 files +- `[minor]` - Small updates to existing .json files +- `LOCAL` ⇆ - Changes from local edits (track command) +- `SERVER` ⇅ - External changes from web UI (watch command) + +--- + +## File Structure + +``` +workspace/ +├── .boxel-sync.json # Sync manifest (hashes, mtimes) +├── .boxel-history/ # Git-based checkpoint history +├── .realm.json # Workspace config +├── index.json # Workspace index +├── *.gts # Card definitions +└── CardName/ + └── *.json # Card instances +``` + +--- + +## Workspace References + +Commands accept: +- `.` - Current directory (needs `.boxel-sync.json`) +- `./path` - Local path +- `@user/workspace` - e.g., `@username/personal` +- `https://...` - Full URL + +--- + +## Understanding Boxel URLs (Card IDs) + +When a user shares a URL like: +``` +https://app.boxel.ai/tribecaprep/employee-handbook/Document/d8341312-f3a0-442b-a2e5-49c5cdd84695 +``` + +**This is a Card ID, not a fetchable URL!** + +### How to Parse Boxel URLs + +| URL Part | Meaning | +|----------|---------| +| `app.boxel.ai` | Production server | +| `tribecaprep` | User/organization | +| `employee-handbook` | Realm/workspace name | +| `Document/d8341312-...` | Card type and instance path | + +### NEVER Use WebFetch on Boxel URLs + +- Boxel realms are **usually private** and require Matrix authentication +- WebFetch will fail with 401/403 errors +- The user is referencing content **they expect you to have locally** + +### Finding the Local Copy + +If the user references a Boxel URL, the file is likely already synced to the local workspace: + +1. **Parse the path**: `Document/d8341312-f3a0-442b-a2e5-49c5cdd84695` → local path is `Document/d8341312-f3a0-442b-a2e5-49c5cdd84695.json` + +2. **Search the workspace**: +```bash +# Find by card ID +find . -name "d8341312-f3a0-442b-a2e5-49c5cdd84695*" + +# Or search for the card type folder +ls ./Document/ +``` + +3. **Read the local file** using the Read tool + +### Example Workflow + +User says: "Check the handbook at https://app.boxel.ai/tribecaprep/employee-handbook/Document/abc123" + +**Do this:** +``` +# Look for local file +Read ./Document/abc123.json +``` + +**NOT this:** +``` +# This will FAIL - private realm +WebFetch https://app.boxel.ai/tribecaprep/employee-handbook/Document/abc123 +``` + +--- + +## API Reference + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/_mtimes` | GET | File modification times | +| `/` | GET | Download file | +| `/` | POST | Upload file | +| `/` | DELETE | Delete file | +| `/_atomic` | POST | Batch atomic operations | + +Headers: +- `Authorization`: JWT from Matrix auth +- `Accept`: `application/vnd.card+source` or `application/vnd.api+json` + +### Atomic Batch Operations + +The `/_atomic` endpoint supports batch file operations that succeed or fail atomically: + +```json +{ + "atomic:operations": [ + { "op": "add", "href": "./path/to/new.json", "data": { "data": {...} } }, + { "op": "update", "href": "./path/to/existing.gts", "data": { "data": { "type": "module", "attributes": { "content": "..." } } } }, + { "op": "remove", "href": "./path/to/delete.json" } + ] +} +``` + +| Operation | Behavior | +|-----------|----------| +| `add` | Create new file (fails 409 if exists) | +| `update` | Update existing file (fails 404 if missing) | +| `remove` | Delete file | + +**Content-Type:** `application/vnd.api+json` + +--- + +## Conflict Resolution + +| Local | Remote | Action | +|-------|--------|--------| +| Changed | Unchanged | Push | +| Unchanged | Changed | Pull | +| Changed | Changed | Conflict → use strategy | +| Deleted | Changed | `--prefer-local` deletes remote | +| Changed | Deleted | `--prefer-remote` deletes local | + +--- + +## Troubleshooting + +### "Authentication failed" +- Check active profile: `boxel profile` +- Verify credentials: `boxel profile list` +- Verify you can log into Boxel web with same credentials +- For staging: ensure profile uses `@username:stack.cards` + +### "No workspace found" +- Run `boxel list` to see workspaces +- Use full URL for first sync +- Ensure correct profile is active for the environment + +### Files keep reverting after restore +- Stop watch before restoring +- Use `boxel sync . --prefer-local` after + +### Watch not detecting changes +- Check interval setting +- Verify server URL +- Check active profile: `boxel profile` + +### Switching environments (prod/staging) +- Add profiles for each environment +- Switch with: `boxel profile switch ` + +### "500 Internal Server Error" on specific files +- These files are broken/corrupted on the server +- Sync will prompt you to delete them after completion +- Or use `boxel push . --delete` to remove all orphaned remote files +- Check if card definitions have errors in Boxel web UI diff --git a/packages/software-factory/.claude/skills b/packages/software-factory/.claude/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/packages/software-factory/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/packages/software-factory/AGENTS.md b/packages/software-factory/AGENTS.md new file mode 100644 index 0000000000..10b6560671 --- /dev/null +++ b/packages/software-factory/AGENTS.md @@ -0,0 +1,224 @@ +# AGENTS.md - Boxel CLI Codex Guidance + +## High-Priority Safety Rules + +1. Create a checkpoint before destructive operations: + - `boxel history . -m "Before destructive operation"` +2. After restore, always sync with local preference: + - `boxel history . -r ` + - `boxel sync . --prefer-local` +3. Stop watch before restore to avoid re-pulling deleted files. +4. When watch is running and you edit files locally, use edit locks: + - `boxel edit . ` before editing + - `boxel edit . --done ` after sync +5. Write clean source code, never compiled wire-format output for `.gts` files. + +## Mission + +This repo is part of a software factory where humans do not inspect or hand-edit code directly. + +The goal is to: + +- Accept a user request +- Break it down into persistent tasks +- Implement the work incrementally +- Test each step +- Commit or checkpoint progress continuously +- Store the plan, tickets, and knowledge base in Boxel so future runs become more reliable + +Code and project state should live in Boxel realms. Tickets and knowledge cards are the persistent memory for the factory. + +## Current Environment + +- Active Boxel CLI user: `factory` +- Do not put passwords directly into shell commands. If re-auth is needed, use `BOXEL_PASSWORD` or interactive login. +- Realm server: `http://localhost:4201/` +- Matrix server: `http://localhost:8008` +- `boxel list` currently shows access to: + - `http://localhost:4201/factory/guidance-tasks/` +- Check out Boxel workspaces into the local `realms/` subfolder under this repo +- Ignore `dark-factory`; it was an earlier iteration and should not be used as the current target +- Task tracker modules live in the `guidance-tasks` workspace under module `darkfactory` +- There are demo instances in that workspace that should be inspected before inventing new task structures +- Tickets may live in a dedicated task realm or in the realm created for a specific solution, but they should reuse the tracker module instead of duplicating it + +## Factory Execution Model + +When given a product request, the default operating loop is: + +1. Discover the relevant realms, modules, and existing task or knowledge cards +2. Choose or create the target implementation realm +3. Break the request into tickets, milestones, or task cards in Boxel +4. Add or update knowledge-base cards that capture decisions, constraints, and reusable procedures +5. Implement work one task at a time +6. Test after each meaningful change +7. Checkpoint with Boxel history and commit in git when a git repo exists +8. Sync changes back to Boxel and continue iterating until the request is demonstrably working + +Persistent Boxel artifacts are part of the deliverable, not just code. + +## Immediate Demo Priority + +There is one hour to produce an end-to-end demo of this workflow. + +Bias toward: + +- A small but complete project +- Visible task breakdown into tickets +- Clear knowledge-base entries +- Repeated task -> implement -> test -> checkpoint loops +- Fast feedback over architectural perfection + +## Auth and Testing Notes + +- Prefer Boxel CLI capabilities over rebuilding the same behavior from scratch +- Additional tooling is allowed when the CLI does not cover the task cleanly +- You will likely need auth helpers that can obtain JWTs for accessible realms +- The `_server-session` endpoint can provide JWTs for realms the current user can access +- Authenticated card URLs open directly into interact mode, which is the preferred surface for browser testing and Playwright-based verification +- Explore search and query options in the tracker and workspace data model before creating new structures +- Project tests should live in Boxel realms when they are part of the product's persistent memory +- Preferred convention: + - realm-local Playwright specs live under `tests/**/*.spec.mjs` + - files copied into disposable verification realms live under `tests/fixtures/**` + - fixture contents are copied to the scratch realm root preserving paths, so `tests/fixtures/DeliveryBrief/example.json` becomes `DeliveryBrief/example.json` in the scratch realm +- Run realm-hosted tests with: + - `npm run test:realm -- --realm-path ./realms/` +- The default runner flow is: + 1. create a fresh scratch realm + 2. pull it locally under the canonical workspace path `realms////` + 3. copy fixture files from the source realm into the scratch realm + 4. sync the scratch realm + 5. run the source realm's Playwright specs against the scratch realm URL + 6. report failures and keep the scratch realm available for inspection +- When fixture instances depend on card definitions from the source realm, prefer absolute `meta.adoptsFrom.module` URLs so the scratch realm only needs the copied instances + +## Boxel Development Trigger + +When tasks involve Boxel card development, automatically consult: + +- `.agents/skills/boxel-development/SKILL.md` +- `.agents/skills/software-factory-operations/SKILL.md` when the task is about ticket-driven application delivery, realm coordination, or the end-to-end factory loop + +The shared repo-local skills live in `.agents/skills/`. +Claude should read them through `.claude/skills/`, which should point at the same directory to avoid duplicate instructions. + +Trigger examples: + +- Editing `.gts` card definitions +- Editing card instance `.json` +- Asking for Boxel card patterns/components +- Working in a synced workspace (`.boxel-sync.json` present) + +## Core Command Semantics + +- `pull`: remote -> local +- `push`: local -> remote +- `sync`: bidirectional conflict resolution +- `track`: local file watching with auto-checkpoints (use `--push` for real-time server sync) +- `watch`: remote change watching (pulls server changes) +- `repair-realm`: repair one realm metadata + starter cards + optional Matrix reconciliation +- `repair-realms`: batch repair all owned realms and reconcile Matrix realm list + +After local edits tracked with `track`, push to server with: + +- `boxel sync . --prefer-local` +- Or use `boxel track . --push` for automatic real-time sync + +## Onboarding Flow (When Needed) + +If user has no profile configured: + +1. `npx boxel profile` +2. `npx boxel profile add` (interactive preferred) +3. `npx boxel list` +4. First sync/pull into local workspace + +If `boxel list` already works, treat onboarding as complete and move on to workspace discovery. + +Security note: + +- Prefer interactive password entry or `BOXEL_PASSWORD` env var. +- Avoid plain `-p` password usage in shell history. + +## Multi-Realm Guidance + +- Configure realms with `boxel realms --add ...` +- Use `boxel realms --llm` for file-placement guidance. +- This repo already includes `.boxel-workspaces.json` mapping `guidance-tasks` as the shared tracker realm and `software-factory-demo` as the default implementation realm. +- Heuristic: + - `.gts` -> code realm (`*.gts` pattern) + - instances -> realm mapped for card type + - ambiguous -> default realm + +## Boxel URL Handling + +Boxel app URLs usually reference private, authenticated content. + +- Do not fetch them from the public web. +- Parse card path from URL and locate local synced file instead. + Example: +- URL segment `Document/` maps to local `Document/.json` + +## Useful Workflows + +### Local dev loop (manual sync) + +1. `boxel track .` +2. edit files +3. `boxel sync . --prefer-local` + +### Local dev loop (real-time sync) + +1. `boxel track . --push` +2. edit files (changes auto-pushed via batch upload) + +### Monitor server changes + +1. `boxel watch .` +2. inspect checkpoints with `boxel history .` + +### Restore workflow + +1. stop watch +2. `boxel history . -r ` +3. `boxel sync . --prefer-local` + +### Software Factory Loop + +1. `boxel list` +2. sync the relevant realm locally +3. inspect existing task and knowledge cards +4. create or update tickets for the requested outcome +5. implement in the target realm +6. store or update project tests inside the target realm when they are part of the deliverable +7. test using CLI plus authenticated browser flows where useful +8. prefer a disposable scratch realm for fixture-driven browser verification +9. report issues back into tickets or knowledge cards +10. sync to the realm +11. checkpoint and sync +12. update knowledge cards with what was learned + +## Related References + +- `.claude/CLAUDE.md` +- `.agents/skills/boxel-development/SKILL.md` +- `.agents/skills/boxel-file-structure/SKILL.md` +- `.agents/skills/boxel-repair/SKILL.md` +- `.agents/skills/boxel-sync/SKILL.md` +- `.agents/skills/boxel-watch/SKILL.md` +- `.agents/skills/boxel-track/SKILL.md` +- `.agents/skills/boxel-restore/SKILL.md` +- `.agents/skills/boxel-setup/SKILL.md` +- `.agents/skills/software-factory-operations/SKILL.md` + +## Share & Gather (GitHub Workflow) + +Share workspace to GitHub repo, gather changes back: + +```bash +boxel share . -t /path/to/repo -b branch-name --no-pr +boxel gather . -s /path/to/repo +``` + +**URL Portability:** Share/gather automatically convert absolute realm URLs in `index.json` and `cards-grid.json` to relative paths, making content portable across different realms. diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index 568a340bd4..539641b52b 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -20,13 +20,13 @@ with `SOFTWARE_FACTORY_INCLUDE_SKILLS=1`. ## Commands - `pnpm cache:prepare` - - Builds or reuses the cached template database for `demo-realm/` + - Builds or reuses the cached template database for `test-fixtures/darkfactory-adopter/` - `pnpm serve:support` - Starts shared support services and prepares a reusable runtime context in the background - `pnpm serve:realm` - Starts the isolated realm server on `http://localhost:4205/test/` - `pnpm smoke:realm` - - Boots the isolated realm server, fetches `person-1` as card JSON, and exits + - Boots the isolated realm server, fetches `project-demo` as card JSON, and exits - `pnpm test:playwright` - Runs the browser tests against a fresh per-test realm server cloned from the cached template @@ -40,8 +40,8 @@ pnpm smoke:realm ./my-realm Person/example-card ## Layout -- `demo-realm/` - - Example card definitions and instances +- `test-fixtures/darkfactory-adopter/` + - Disposable adopter fixture realm used by the Playwright tests - `src/harness.ts` - Cached template DB creation and isolated realm server startup - `tests/` diff --git a/packages/software-factory/demo-realm/person-1.json b/packages/software-factory/demo-realm/person-1.json deleted file mode 100644 index e380b1c0e1..0000000000 --- a/packages/software-factory/demo-realm/person-1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "firstName": "Mango" - }, - "meta": { - "adoptsFrom": { - "module": "./person.gts", - "name": "Person" - } - } - } -} diff --git a/packages/software-factory/demo-realm/person-2.json b/packages/software-factory/demo-realm/person-2.json deleted file mode 100644 index 6912b5b48e..0000000000 --- a/packages/software-factory/demo-realm/person-2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "firstName": "Papaya" - }, - "meta": { - "adoptsFrom": { - "module": "./person.gts", - "name": "Person" - } - } - } -} diff --git a/packages/software-factory/demo-realm/person.gts b/packages/software-factory/demo-realm/person.gts deleted file mode 100644 index 1ce05804f9..0000000000 --- a/packages/software-factory/demo-realm/person.gts +++ /dev/null @@ -1,25 +0,0 @@ -import { - contains, - field, - Component, - CardDef, -} from 'https://cardstack.com/base/card-api'; -import StringField from 'https://cardstack.com/base/string'; - -export class Person extends CardDef { - static displayName = 'Person'; - - @field firstName = contains(StringField); - - @field cardTitle = contains(StringField, { - computeVia: function (this: Person) { - return this.firstName; - }, - }); - - static isolated = class Isolated extends Component { - - }; -} 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 296eb0f991..df1b2badd9 100644 --- a/packages/software-factory/docs/one-shot-factory-go-plan.md +++ b/packages/software-factory/docs/one-shot-factory-go-plan.md @@ -16,6 +16,24 @@ This document covers: - what is currently missing - the minimum implementation needed in `experiment_1` +## Realm Roles + +The software factory uses three 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 +- fixture realm + - disposable test input used only for verification + - 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`. + +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/`. + ## Current State `experiment_1` already has useful primitives: @@ -89,7 +107,7 @@ Required behavior: Required behavior: - resolve the target realm URL from `.boxel-sync.json` or CLI arguments -- ensure the target realm has the tracker module available +- ensure the target realm can resolve the tracker module from the shared source realm, or explicitly install a local copy if that is the chosen bootstrap strategy - ensure the target realm has a visible entry surface such as `cards-grid.json` Minimum requirement: @@ -105,6 +123,10 @@ Required behavior: - create starter `Ticket` cards - mark exactly one starter ticket as `in_progress` +Artifact location rule: + +- these generated artifacts belong in the target realm selected by the user, not in the source realm that publishes the shared software-factory modules + Rules: - do not duplicate artifacts if they already exist @@ -134,6 +156,12 @@ Default verification policy: - 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 +Implementation note: + +- 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 flow must not stall just because full test infrastructure does not yet exist. ### Phase 6: Stop Conditions @@ -226,7 +254,7 @@ Responsibilities: - parse args - fetch the brief - resolve target realm path and URL -- ensure tracker files exist in target realm +- ensure the target realm can consume the shared tracker module without confusing source content with generated output - bootstrap or reconcile project artifacts - pick the next ticket - invoke the implementation loop @@ -365,23 +393,24 @@ It does not need to complete an entire multi-ticket product in version one. Files to add: -- `packages/software-factory/experiment_1/scripts/factory-go.mjs` -- `packages/software-factory/experiment_1/scripts/lib/factory-bootstrap.mjs` -- `packages/software-factory/experiment_1/scripts/lib/factory-target-realm.mjs` -- `packages/software-factory/experiment_1/scripts/lib/factory-brief.mjs` -- `packages/software-factory/experiment_1/scripts/lib/factory-loop.mjs` +- `packages/software-factory/scripts/factory-go.mjs` +- `packages/software-factory/scripts/lib/factory-bootstrap.mjs` +- `packages/software-factory/scripts/lib/factory-target-realm.mjs` +- `packages/software-factory/scripts/lib/factory-brief.mjs` +- `packages/software-factory/scripts/lib/factory-loop.mjs` Files to update: -- `packages/software-factory/experiment_1/package.json` +- `packages/software-factory/package.json` - add `factory:go` -- `packages/software-factory/experiment_1/AGENTS.md` +- `packages/software-factory/AGENTS.md` - document the new one-shot flow Optional later additions: -- `packages/software-factory/experiment_1/tests/factory-go.spec.mjs` +- `packages/software-factory/tests/factory-go.spec.mjs` - verifies bootstrap behavior +- generated card-test creation that reuses the existing `packages/software-factory` Playwright machinery when browser-level verification is warranted ## Suggested Output Contract diff --git a/packages/software-factory/docs/software-factory-testing-strategy.md b/packages/software-factory/docs/software-factory-testing-strategy.md index d2b7cbe8c2..2e1c8be253 100644 --- a/packages/software-factory/docs/software-factory-testing-strategy.md +++ b/packages/software-factory/docs/software-factory-testing-strategy.md @@ -14,6 +14,22 @@ This document applies to: - the public `DarkFactory` module in `packages/software-factory/realm` - the `factory:go` orchestration work in `packages/software-factory/experiment_1` +## Realm Roles + +The testing strategy assumes three separate realm roles: + +- source realm + - `packages/software-factory/realm` + - publishes shared modules, briefs, templates, and other software-factory inputs +- target realm + - the user-selected realm where the factory writes generated tickets, knowledge articles, tests, and implementation artifacts +- fixture realm + - disposable test data used to verify source-realm publishing and target-realm behavior + +Generated factory output should normally be asserted in target realms or disposable fixture realms, not written back into the source realm. + +If the source realm includes output-like examples, they should be clearly labeled as samples rather than mixed into the canonical published tracker surface. + ## Core Principle Do not treat the agent loop as a single black box. @@ -60,6 +76,15 @@ Coverage should include: These tests should be deterministic and should not involve the agent loop. +Fixture policy for this layer: + +- treat `packages/software-factory/realm` as the published source realm, not as mutable test state +- keep test-only card instances in fixture realms dedicated to testing +- have fixture realms adopt from the public `darkfactory` module URL instead of copying the tracker module +- run browser tests against disposable per-test runtime clones of those fixture realms so mutations can be torn down safely + +If the public realm includes demo instances, they are there for manual exploration, smoke checks, and as-shipped examples of the published module. They should not be the primary place where tests create or mutate state, and any output-like examples should be clearly separated as sample output. + ## Layer 2: Deterministic Orchestration Tests The `factory:go` command should mostly be testable without a real model. @@ -156,6 +181,11 @@ Use: - focused card rendering tests - cross-realm adoption integration tests +Notes: + +- assertions should prove that external fixture realms can resolve cards from the public module URL +- tests should mutate only disposable fixture realms, never the published `packages/software-factory/realm` + ### `factory:go` Entry Point Use: @@ -182,6 +212,11 @@ Use: - temporary-realm integration tests - rerun/idempotency tests +Notes: + +- assert generated artifacts in a temporary or user-style target realm +- do not treat the published source realm as the destination for factory output + ### Verification Policy Use: @@ -205,6 +240,7 @@ Use: These are the highest-value early tests: 1. public `DarkFactory` module resolves from an adopter realm + - use a dedicated fixture realm, not the published realm itself, for any mutable test setup 2. brief normalization handles the sticky-note wiki card 3. target realm bootstrap creates required surfaces in a temp realm 4. artifact bootstrap creates one project and one `in_progress` ticket diff --git a/packages/software-factory/playwright.global-setup.ts b/packages/software-factory/playwright.global-setup.ts index b642681fd0..7ff11d906a 100644 --- a/packages/software-factory/playwright.global-setup.ts +++ b/packages/software-factory/playwright.global-setup.ts @@ -14,10 +14,21 @@ import { } from './src/runtime-metadata.ts'; const packageRoot = resolve(fileURLToPath(new URL('.', import.meta.url))); -const realmDir = resolve( +const configuredRealmDir = resolve( packageRoot, - process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', + process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'test-fixtures/darkfactory-adopter', ); +const fallbackRealmDir = resolve( + packageRoot, + 'test-fixtures/darkfactory-adopter', +); +const testSourceRealmDir = resolve( + packageRoot, + 'test-fixtures/public-software-factory-source', +); +const realmDir = existsSync(configuredRealmDir) + ? configuredRealmDir + : fallbackRealmDir; function appendLog(buffer: string, chunk: string): string { let combined = `${buffer}${chunk}`; @@ -64,6 +75,7 @@ export default async function globalSetup() { ...process.env, SOFTWARE_FACTORY_METADATA_FILE: defaultSupportMetadataFile, SOFTWARE_FACTORY_SUPPORT_METADATA_FILE: defaultSupportMetadataFile, + SOFTWARE_FACTORY_SOURCE_REALM_DIR: testSourceRealmDir, }, }); diff --git a/packages/software-factory/realm/darkfactory-schema.gts b/packages/software-factory/realm/darkfactory-schema.gts index e78157c9a0..feb0b0d35b 100644 --- a/packages/software-factory/realm/darkfactory-schema.gts +++ b/packages/software-factory/realm/darkfactory-schema.gts @@ -138,7 +138,7 @@ export class Project extends CardDef { query: { filter: { on: { - module: new URL('./darkfactory-schema', import.meta.url).href, + module: new URL('./darkfactory', import.meta.url).href, name: 'Ticket', }, eq: { 'project.id': '$this.id' }, diff --git a/packages/software-factory/scripts/boxel-search.mjs b/packages/software-factory/scripts/boxel-search.mjs new file mode 100644 index 0000000000..368042c164 --- /dev/null +++ b/packages/software-factory/scripts/boxel-search.mjs @@ -0,0 +1,71 @@ +import { + fieldPairs, + forceArray, + getAccessibleRealmTokens, + matrixLogin, + parseArgs, + printJson, + searchRealm, +} from './lib/boxel.mjs'; + +let args = parseArgs(process.argv.slice(2)); +if (!args.realm) { + throw new Error('Usage: npm run boxel:search -- --realm [--type-name Ticket --type-module ] [--eq field=value] [--contains field=value]'); +} + +let matrixAuth = await matrixLogin(); +let realmTokens = await getAccessibleRealmTokens(matrixAuth); +let realmUrl = Array.isArray(args.realm) ? args.realm[0] : args.realm; +let jwt = realmTokens[realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`]; + +let query = {}; +let filter = {}; + +if (args['type-name'] && args['type-module']) { + filter.type = { + module: args['type-module'], + name: args['type-name'], + }; +} + +let eq = fieldPairs(args.eq); +if (Object.keys(eq).length > 0) { + filter.eq = eq; +} + +let contains = fieldPairs(args.contains); +if (Object.keys(contains).length > 0) { + filter.contains = contains; +} + +if (Object.keys(filter).length > 0) { + query.filter = filter; +} + +let sortValues = forceArray(args.sort); +if (sortValues.length > 0) { + query.sort = sortValues.map((entry) => { + let [by, direction = 'asc'] = entry.split(':'); + let sort = { by, direction }; + if (args['type-name'] && args['type-module']) { + sort.on = { + module: args['type-module'], + name: args['type-name'], + }; + } + return sort; + }); +} + +if (args.size || args.page) { + query.page = {}; + if (args.size) { + query.page.size = Number(args.size); + } + if (args.page) { + query.page.number = Number(args.page); + } +} + +let results = await searchRealm({ realmUrl, jwt, query }); +printJson(results); diff --git a/packages/software-factory/scripts/boxel-session.mjs b/packages/software-factory/scripts/boxel-session.mjs new file mode 100644 index 0000000000..4022379ca5 --- /dev/null +++ b/packages/software-factory/scripts/boxel-session.mjs @@ -0,0 +1,21 @@ +import { + buildBrowserAuth, + buildBrowserSession, + getAccessibleRealmTokens, + matrixLogin, + parseArgs, + printJson, +} from './lib/boxel.mjs'; + +let args = parseArgs(process.argv.slice(2)); +let matrixAuth = await matrixLogin(); +let realmTokens = await getAccessibleRealmTokens(matrixAuth); +let requestedRealms = args.realm ? (Array.isArray(args.realm) ? args.realm : [args.realm]) : []; +let session = buildBrowserSession(realmTokens, requestedRealms); + +printJson({ + profileId: matrixAuth.credentials.profileId, + username: matrixAuth.credentials.username, + auth: buildBrowserAuth(matrixAuth), + boxelSession: session, +}); diff --git a/packages/software-factory/scripts/lib/boxel.mjs b/packages/software-factory/scripts/lib/boxel.mjs new file mode 100644 index 0000000000..4abf831a5d --- /dev/null +++ b/packages/software-factory/scripts/lib/boxel.mjs @@ -0,0 +1,242 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const PROFILES_FILE = path.join(os.homedir(), '.boxel-cli', 'profiles.json'); + +function ensureTrailingSlash(url) { + return url.endsWith('/') ? url : `${url}/`; +} + +function parseProfilesConfig() { + if (!fs.existsSync(PROFILES_FILE)) { + return { profiles: {}, activeProfile: null }; + } + + return JSON.parse(fs.readFileSync(PROFILES_FILE, 'utf8')); +} + +export function getActiveProfile() { + let config = parseProfilesConfig(); + let activeProfileId = config.activeProfile; + if (activeProfileId && config.profiles[activeProfileId]) { + let profile = config.profiles[activeProfileId]; + return { + profileId: activeProfileId, + username: activeProfileId.replace(/^@/, '').replace(/:.*$/, ''), + matrixUrl: profile.matrixUrl, + realmServerUrl: ensureTrailingSlash(profile.realmServerUrl), + password: profile.password, + }; + } + + let matrixUrl = process.env.MATRIX_URL; + let username = process.env.MATRIX_USERNAME; + let password = process.env.MATRIX_PASSWORD; + let realmServerUrl = process.env.REALM_SERVER_URL; + if (!matrixUrl || !username || !password || !realmServerUrl) { + throw new Error( + 'No active Boxel profile found and MATRIX_URL/MATRIX_USERNAME/MATRIX_PASSWORD/REALM_SERVER_URL are not fully set', + ); + } + + return { + profileId: null, + username, + matrixUrl, + realmServerUrl: ensureTrailingSlash(realmServerUrl), + password, + }; +} + +export async function matrixLogin(credentials = getActiveProfile()) { + let response = await fetch(new URL('_matrix/client/v3/login', credentials.matrixUrl), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: { + type: 'm.id.user', + user: credentials.username, + }, + password: credentials.password, + type: 'm.login.password', + }), + }); + + let json = await response.json(); + if (!response.ok) { + throw new Error(`Matrix login failed: ${response.status} ${JSON.stringify(json)}`); + } + + return { + accessToken: json.access_token, + deviceId: json.device_id, + userId: json.user_id, + homeServer: new URL(credentials.matrixUrl).host, + credentials, + }; +} + +export async function getOpenIdToken(matrixAuth) { + let response = await fetch( + new URL( + `_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/openid/request_token`, + matrixAuth.credentials.matrixUrl, + ), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: '{}', + }, + ); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`OpenID token request failed: ${response.status} ${text}`); + } + + return response.json(); +} + +export async function getRealmServerToken(matrixAuth) { + let openIdToken = await getOpenIdToken(matrixAuth); + let response = await fetch(new URL('_server-session', matrixAuth.credentials.realmServerUrl), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(openIdToken), + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Realm server session request failed: ${response.status} ${text}`); + } + + let token = response.headers.get('Authorization'); + if (!token) { + throw new Error('Realm server session response did not include an Authorization header'); + } + return token; +} + +export async function getAccessibleRealmTokens(matrixAuth) { + let serverToken = await getRealmServerToken(matrixAuth); + let response = await fetch(new URL('_realm-auth', matrixAuth.credentials.realmServerUrl), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: serverToken, + }, + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Realm auth lookup failed: ${response.status} ${text}`); + } + + return response.json(); +} + +export function buildBrowserAuth(matrixAuth) { + return { + access_token: matrixAuth.accessToken, + user_id: matrixAuth.userId, + device_id: matrixAuth.deviceId, + home_server: matrixAuth.homeServer, + }; +} + +export function buildBrowserSession(realmTokens, realmUrls) { + if (!realmUrls || realmUrls.length === 0) { + return realmTokens; + } + + let result = {}; + for (let realmUrl of realmUrls) { + let normalized = ensureTrailingSlash(realmUrl); + if (realmTokens[normalized]) { + result[normalized] = realmTokens[normalized]; + } + } + return result; +} + +export async function searchRealm({ realmUrl, jwt, query }) { + let response = await fetch(new URL('./_search', ensureTrailingSlash(realmUrl)), { + method: 'QUERY', + headers: { + Accept: 'application/vnd.card+json', + 'Content-Type': 'application/json', + ...(jwt ? { Authorization: jwt } : {}), + }, + body: JSON.stringify(query), + }); + + if (!response.ok) { + let text = await response.text(); + throw new Error(`Search failed: ${response.status} ${text}`); + } + + return response.json(); +} + +export function parseArgs(argv) { + let args = { _: [] }; + + for (let i = 0; i < argv.length; i++) { + let token = argv[i]; + if (!token.startsWith('--')) { + args._.push(token); + continue; + } + + let key = token.slice(2); + let next = argv[i + 1]; + if (!next || next.startsWith('--')) { + args[key] = true; + continue; + } + + if (args[key] === undefined) { + args[key] = next; + } else if (Array.isArray(args[key])) { + args[key].push(next); + } else { + args[key] = [args[key], next]; + } + i++; + } + + return args; +} + +export function forceArray(value) { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +export function fieldPairs(values) { + let result = {}; + for (let entry of forceArray(values)) { + let index = entry.indexOf('='); + if (index === -1) { + throw new Error(`Expected field pair in the form field=value, received: ${entry}`); + } + result[entry.slice(0, index)] = entry.slice(index + 1); + } + return result; +} + +export function printJson(value) { + console.log(JSON.stringify(value, null, 2)); +} diff --git a/packages/software-factory/scripts/pick-ticket.mjs b/packages/software-factory/scripts/pick-ticket.mjs new file mode 100644 index 0000000000..708312ffd4 --- /dev/null +++ b/packages/software-factory/scripts/pick-ticket.mjs @@ -0,0 +1,82 @@ +import { + getAccessibleRealmTokens, + matrixLogin, + parseArgs, + printJson, + searchRealm, +} from './lib/boxel.mjs'; + +let args = parseArgs(process.argv.slice(2)); +let realmUrl = args.realm; +if (!realmUrl) { + throw new Error('Usage: npm run boxel:pick-ticket -- --realm [--module ]'); +} + +let statusList = (args.status ? String(args.status) : 'backlog,in_progress,review') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +let moduleUrl = args.module ?? `${realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`}darkfactory-schema`; + +let matrixAuth = await matrixLogin(); +let realmTokens = await getAccessibleRealmTokens(matrixAuth); +let jwt = realmTokens[realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`]; + +let query = { + filter: { + type: { + module: moduleUrl, + name: 'Ticket', + }, + any: statusList.map((status) => ({ + eq: { status }, + })), + }, + sort: [ + { + by: 'priority', + direction: 'asc', + on: { + module: moduleUrl, + name: 'Ticket', + }, + }, + { + by: 'updatedAt', + direction: 'asc', + on: { + module: moduleUrl, + name: 'Ticket', + }, + }, + ], +}; + +if (args.project) { + query.filter.eq = { + ...(query.filter.eq ?? {}), + 'project.id': args.project, + }; +} + +if (args.agent) { + query.filter.eq = { + ...(query.filter.eq ?? {}), + 'assignedAgent.id': args.agent, + }; +} + +let results = await searchRealm({ realmUrl, jwt, query }); +let compact = (results.data ?? []).map((card) => ({ + id: card.id, + ticketId: card.attributes?.ticketId, + summary: card.attributes?.summary, + status: card.attributes?.status, + priority: card.attributes?.priority, + project: card.relationships?.project?.links?.self ?? null, +})); + +printJson({ + count: compact.length, + tickets: compact, +}); diff --git a/packages/software-factory/scripts/run-realm-tests.mjs b/packages/software-factory/scripts/run-realm-tests.mjs new file mode 100644 index 0000000000..407fb1006b --- /dev/null +++ b/packages/software-factory/scripts/run-realm-tests.mjs @@ -0,0 +1,204 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { getActiveProfile, parseArgs } from './lib/boxel.mjs'; + +function ensureTrailingSlash(url) { + return url.endsWith('/') ? url : `${url}/`; +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'z').toLowerCase(); +} + +function runCommand(command, args, options = {}) { + let result = spawnSync(command, args, { + cwd: options.cwd ?? process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...(options.env ?? {}) }, + }); + + if (result.status !== 0) { + let details = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + throw new Error(`Command failed: ${command} ${args.join(' ')}\n${details}`); + } + + return result.stdout; +} + +function readWorkspaceUrl(realmPath) { + let syncFile = path.join(realmPath, '.boxel-sync.json'); + if (!fs.existsSync(syncFile)) { + throw new Error(`Expected synced realm at ${realmPath}; missing .boxel-sync.json`); + } + + let { workspaceUrl } = JSON.parse(fs.readFileSync(syncFile, 'utf8')); + if (!workspaceUrl) { + throw new Error(`No workspaceUrl found in ${syncFile}`); + } + return ensureTrailingSlash(workspaceUrl); +} + +function walkFiles(rootDir) { + let results = []; + + function visit(currentDir) { + for (let entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + let fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + } + + if (fs.existsSync(rootDir)) { + visit(rootDir); + } + + return results; +} + +function findSpecFiles(specRoot) { + return walkFiles(specRoot) + .filter((filePath) => filePath.endsWith('.spec.mjs')) + .sort(); +} + +function copyTreeContents(sourceDir, destinationDir) { + if (!fs.existsSync(sourceDir)) { + return []; + } + + let copied = []; + for (let sourceFile of walkFiles(sourceDir)) { + let relativePath = path.relative(sourceDir, sourceFile); + let destinationFile = path.join(destinationDir, relativePath); + fs.mkdirSync(path.dirname(destinationFile), { recursive: true }); + fs.copyFileSync(sourceFile, destinationFile); + copied.push(relativePath); + } + + return copied.sort(); +} + +function summarizeFailures(report) { + let failures = []; + + function visitSuite(suite, titlePath = []) { + let nextTitlePath = suite.title ? [...titlePath, suite.title] : titlePath; + for (let spec of suite.specs ?? []) { + let specPath = spec.title ? [...nextTitlePath, spec.title] : nextTitlePath; + for (let test of spec.tests ?? []) { + let results = test.results ?? []; + let failedResults = results.filter((result) => result.status !== 'passed' && result.status !== 'skipped'); + if (failedResults.length === 0) { + continue; + } + let errorText = failedResults + .flatMap((result) => result.errors ?? []) + .map((error) => error.message ?? error.value ?? '') + .filter(Boolean) + .join('\n'); + failures.push({ + title: specPath.join(' > '), + outcome: test.outcome ?? failedResults[0]?.status ?? 'failed', + error: errorText || 'No error text captured', + }); + } + } + for (let child of suite.suites ?? []) { + visitSuite(child, nextTitlePath); + } + } + + for (let suite of report.suites ?? []) { + visitSuite(suite); + } + + return failures; +} + +let args = parseArgs(process.argv.slice(2)); +let sourceRealmPath = path.resolve(args['realm-path'] ?? args._[0] ?? 'realms/software-factory-demo'); +let sourceRealmUrl = ensureTrailingSlash(args['realm-url'] ?? readWorkspaceUrl(sourceRealmPath)); +let specRoot = path.resolve(sourceRealmPath, args['spec-dir'] ?? 'tests'); +let fixturesRoot = path.resolve(sourceRealmPath, args['fixtures-dir'] ?? 'tests/fixtures'); +let sourceRealmName = path.basename(sourceRealmPath); +let endpoint = args.endpoint ?? `${sourceRealmName}-test-${timestampSlug()}`; +let credentials = getActiveProfile(); +let scratchRoot = path.resolve( + args['scratch-root'] ?? + path.join('realms', new URL(credentials.realmServerUrl).hostname, credentials.username), +); +let scratchPath = path.join(scratchRoot, endpoint); + +if (fs.existsSync(scratchPath)) { + throw new Error(`Scratch realm path already exists: ${scratchPath}`); +} + +let specFiles = findSpecFiles(specRoot); +if (specFiles.length === 0) { + throw new Error(`No realm-hosted spec files were found under ${specRoot}`); +} + +let scratchRealmUrl = ensureTrailingSlash( + args['scratch-url'] ?? new URL(`${credentials.username}/${endpoint}/`, credentials.realmServerUrl).href, +); +let scratchName = args.name ?? `${sourceRealmName} Test ${new Date().toISOString()}`; + +fs.mkdirSync(scratchRoot, { recursive: true }); + +runCommand('boxel', ['create', endpoint, scratchName]); +runCommand('boxel', ['pull', scratchRealmUrl, scratchPath]); + +let copiedFixtures = copyTreeContents(fixturesRoot, scratchPath); +runCommand('boxel', ['sync', scratchPath, scratchRealmUrl, '--prefer-local']); + +let reportFile = path.join(os.tmpdir(), `${endpoint}-playwright-report.json`); +let playwrightConfig = path.resolve(process.cwd(), 'playwright.realm.config.mjs'); +let playwrightEnv = { + BOXEL_SOURCE_REALM_PATH: sourceRealmPath, + BOXEL_SOURCE_REALM_URL: sourceRealmUrl, + BOXEL_TEST_REALM_PATH: scratchPath, + BOXEL_TEST_REALM_URL: scratchRealmUrl, + PLAYWRIGHT_JSON_OUTPUT_FILE: reportFile, +}; +let relativeSpecFiles = specFiles.map((filePath) => path.relative(sourceRealmPath, filePath)); + +let testRun = spawnSync( + 'npx', + ['playwright', 'test', '--config', playwrightConfig, '--reporter=line,json', ...relativeSpecFiles], + { + cwd: sourceRealmPath, + encoding: 'utf8', + env: { ...process.env, ...playwrightEnv }, + }, +); + +let report = fs.existsSync(reportFile) + ? JSON.parse(fs.readFileSync(reportFile, 'utf8')) + : { stats: {}, suites: [] }; +let failures = summarizeFailures(report); + +let summary = { + sourceRealmPath, + sourceRealmUrl, + scratchPath, + scratchRealmUrl, + specFiles: specFiles.map((filePath) => path.relative(process.cwd(), filePath)), + copiedFixtures, + expected: report.stats?.expected ?? 0, + unexpected: report.stats?.unexpected ?? failures.length, + skipped: report.stats?.skipped ?? 0, + failures, +}; + +console.log(JSON.stringify(summary, null, 2)); + +if (testRun.status !== 0) { + process.exit(testRun.status ?? 1); +} diff --git a/packages/software-factory/src/cli/cache-realm.ts b/packages/software-factory/src/cli/cache-realm.ts index d9b857fa66..4e763a18e6 100644 --- a/packages/software-factory/src/cli/cache-realm.ts +++ b/packages/software-factory/src/cli/cache-realm.ts @@ -4,7 +4,10 @@ import { resolve } from 'node:path'; import { ensureFactoryRealmTemplate } from '../harness.ts'; -let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); +let realmDir = resolve( + process.cwd(), + process.argv[2] ?? 'test-fixtures/darkfactory-adopter', +); try { let template = await ensureFactoryRealmTemplate({ realmDir }); let payload = { diff --git a/packages/software-factory/src/cli/serve-realm.ts b/packages/software-factory/src/cli/serve-realm.ts index 0791b5354f..41046a9c3f 100644 --- a/packages/software-factory/src/cli/serve-realm.ts +++ b/packages/software-factory/src/cli/serve-realm.ts @@ -5,7 +5,10 @@ import { resolve } from 'node:path'; import { startFactoryRealmServer } from '../harness.ts'; -let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); +let realmDir = resolve( + process.cwd(), + process.argv[2] ?? 'test-fixtures/darkfactory-adopter', +); if (!process.env.SOFTWARE_FACTORY_CONTEXT) { let supportContext = readSupportContext(); @@ -24,7 +27,7 @@ try { realmDir, realmURL: runtime.realmURL.href, databaseName: runtime.databaseName, - sampleCardURL: runtime.cardURL('person-1'), + sampleCardURL: runtime.cardURL('project-demo'), ownerBearerToken: runtime.createBearerToken(), }; diff --git a/packages/software-factory/src/cli/serve-support.ts b/packages/software-factory/src/cli/serve-support.ts index 730f952bde..8200a25ff5 100644 --- a/packages/software-factory/src/cli/serve-support.ts +++ b/packages/software-factory/src/cli/serve-support.ts @@ -2,18 +2,21 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { startFactoryGlobalContext } from '../harness.ts'; +import { startFactorySupportServices } from '../harness.ts'; import { defaultSupportMetadataFile, sharedRuntimeDir, } from '../runtime-metadata.ts'; -let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); +let realmDir = resolve( + process.cwd(), + process.argv[2] ?? 'test-fixtures/darkfactory-adopter', +); let metadataFile = process.env.SOFTWARE_FACTORY_METADATA_FILE ?? defaultSupportMetadataFile; try { - let support = await startFactoryGlobalContext({ realmDir }); + let support = await startFactorySupportServices(); let payload = { realmDir, diff --git a/packages/software-factory/src/cli/smoke-realm.ts b/packages/software-factory/src/cli/smoke-realm.ts index 3fb2ab5dfc..fbc0e76953 100644 --- a/packages/software-factory/src/cli/smoke-realm.ts +++ b/packages/software-factory/src/cli/smoke-realm.ts @@ -4,8 +4,11 @@ import { resolve } from 'node:path'; import { fetchRealmCardJson } from '../harness.ts'; import { readSupportContext } from '../runtime-metadata.ts'; -let realmDir = resolve(process.cwd(), process.argv[2] ?? 'demo-realm'); -let cardPath = process.argv[3] ?? 'person-1'; +let realmDir = resolve( + process.cwd(), + process.argv[2] ?? 'test-fixtures/darkfactory-adopter', +); +let cardPath = process.argv[3] ?? 'project-demo'; if (!process.env.SOFTWARE_FACTORY_CONTEXT) { let supportContext = readSupportContext(); diff --git a/packages/software-factory/src/harness.ts b/packages/software-factory/src/harness.ts index e305bfc604..6d329e6a6f 100644 --- a/packages/software-factory/src/harness.ts +++ b/packages/software-factory/src/harness.ts @@ -87,6 +87,10 @@ const workspaceRoot = resolve(packageRoot, '..', '..'); const realmServerDir = resolve(packageRoot, '..', 'realm-server'); const baseRealmDir = resolve(packageRoot, '..', 'base'); const skillsRealmDir = resolve(packageRoot, '..', 'skills-realm', 'contents'); +const sourceRealmDir = resolve( + packageRoot, + process.env.SOFTWARE_FACTORY_SOURCE_REALM_DIR ?? 'realm', +); const boxelIconsDir = resolve(packageRoot, '..', 'boxel-icons'); const prepareTestPgScript = resolve( realmServerDir, @@ -106,9 +110,16 @@ const DEFAULT_REALM_URL = new URL( process.env.SOFTWARE_FACTORY_REALM_URL ?? `http://localhost:${REALM_SERVER_PORT}/test/`, ); +const LOCAL_SOFTWARE_FACTORY_SOURCE_URL = new URL( + `http://localhost:${REALM_SERVER_PORT}/software-factory/`, +); +const PUBLIC_SOFTWARE_FACTORY_SOURCE_URL = new URL( + process.env.SOFTWARE_FACTORY_PUBLIC_SOURCE_URL ?? + 'http://localhost:4201/software-factory/', +); const DEFAULT_REALM_DIR = resolve( packageRoot, - process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', + process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'test-fixtures/darkfactory-adopter', ); const DEFAULT_HOST_URL = process.env.HOST_URL ?? 'http://localhost:4200/'; const DEFAULT_ICONS_URL = process.env.ICONS_URL ?? 'http://localhost:4206/'; @@ -810,6 +821,8 @@ async function startIsolatedRealmStack({ `--toUrl=${realmURL.href}`, '--fromUrl=https://cardstack.com/base/', `--toUrl=http://localhost:${REALM_SERVER_PORT}/base/`, + `--fromUrl=${PUBLIC_SOFTWARE_FACTORY_SOURCE_URL.href}`, + `--toUrl=${LOCAL_SOFTWARE_FACTORY_SOURCE_URL.href}`, ]; if (INCLUDE_SKILLS) { workerArgs.push( @@ -843,6 +856,10 @@ async function startIsolatedRealmStack({ `--path=${baseRealmDir}`, '--fromUrl=https://cardstack.com/base/', `--toUrl=http://localhost:${REALM_SERVER_PORT}/base/`, + '--username=software_factory_realm', + `--path=${sourceRealmDir}`, + `--fromUrl=${LOCAL_SOFTWARE_FACTORY_SOURCE_URL.href}`, + `--toUrl=${LOCAL_SOFTWARE_FACTORY_SOURCE_URL.href}`, ]; if (INCLUDE_SKILLS) { serverArgs.splice( @@ -965,7 +982,7 @@ async function buildTemplateDatabase({ await dropDatabase(builderDatabaseName); } -async function startFactorySupportServices(): Promise<{ +export async function startFactorySupportServices(): Promise<{ context: FactorySupportContext; stop(): Promise; }> { diff --git a/packages/software-factory/demo-realm/.realm.json b/packages/software-factory/test-fixtures/darkfactory-adopter/.realm.json similarity index 51% rename from packages/software-factory/demo-realm/.realm.json rename to packages/software-factory/test-fixtures/darkfactory-adopter/.realm.json index f4a1b5e27a..ad325efeb0 100644 --- a/packages/software-factory/demo-realm/.realm.json +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/.realm.json @@ -1,5 +1,5 @@ { - "name": "Software Factory Demo Realm", + "name": "DarkFactory Adopter Test Realm", "iconURL": null, "backgroundURL": null } diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/AgentProfile/demo-agent.json b/packages/software-factory/test-fixtures/darkfactory-adopter/AgentProfile/demo-agent.json new file mode 100644 index 0000000000..f7782f4340 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/AgentProfile/demo-agent.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "card", + "attributes": { + "agentId": "codex-darkfactory", + "capabilities": ["ticket triage", "ui verification"], + "specialization": "Boxel tracker workflows", + "notes": "Primary test agent for DarkFactory rendering coverage." + }, + "relationships": {}, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "AgentProfile" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/DarkFactory/demo-factory.json b/packages/software-factory/test-fixtures/darkfactory-adopter/DarkFactory/demo-factory.json new file mode 100644 index 0000000000..3081675f4e --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/DarkFactory/demo-factory.json @@ -0,0 +1,22 @@ +{ + "data": { + "type": "card", + "attributes": { + "factoryName": "DarkFactory Test Fixture", + "description": "Read-only fixture realm for validating public tracker-module rendering from an adopter realm." + }, + "relationships": { + "activeProjects.0": { + "links": { + "self": "../Project/demo-project" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "DarkFactory" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/KnowledgeArticle/agent-onboarding.json b/packages/software-factory/test-fixtures/darkfactory-adopter/KnowledgeArticle/agent-onboarding.json new file mode 100644 index 0000000000..b7804c9032 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/KnowledgeArticle/agent-onboarding.json @@ -0,0 +1,25 @@ +{ + "data": { + "type": "card", + "attributes": { + "articleTitle": "Agent Onboarding", + "articleType": "onboarding", + "content": "# Agent Onboarding\n\nUse the project card for scope, the ticket card for execution, and update notes as you go.", + "tags": ["onboarding", "tracker", "tests"], + "updatedAt": "2026-03-16T12:00:00.000Z" + }, + "relationships": { + "lastUpdatedBy": { + "links": { + "self": "../AgentProfile/demo-agent" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "KnowledgeArticle" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/Project/demo-project.json b/packages/software-factory/test-fixtures/darkfactory-adopter/Project/demo-project.json new file mode 100644 index 0000000000..111d98fd55 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/Project/demo-project.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "attributes": { + "projectCode": "DFX", + "projectName": "DarkFactory Adoption Harness", + "projectStatus": "active", + "objective": "Prove the public software-factory tracker module can be adopted by an external realm.", + "scope": "## Scope\n\n- Render a project\n- Render a ticket\n- Render a knowledge article\n- Verify the project ticket query resolves", + "technicalContext": "The fixture realm adopts cards from the public `darkfactory` module URL and runs inside the software-factory Playwright harness.", + "successCriteria": "- [x] Public module resolves\n- [x] Adopter instances render\n- [x] Query-backed tickets section shows the linked ticket", + "risks": "- Public URL remapping could break module resolution\n- Ticket query may fail if canonical card refs drift" + }, + "relationships": { + "knowledgeBase.0": { + "links": { + "self": "../KnowledgeArticle/agent-onboarding" + } + }, + "teamAgents.0": { + "links": { + "self": "../AgentProfile/demo-agent" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "Project" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/Ticket/ticket-001.json b/packages/software-factory/test-fixtures/darkfactory-adopter/Ticket/ticket-001.json new file mode 100644 index 0000000000..55c422d3a8 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/Ticket/ticket-001.json @@ -0,0 +1,42 @@ +{ + "data": { + "type": "card", + "attributes": { + "ticketId": "DF-1", + "summary": "Verify public DarkFactory adoption", + "description": "Render tracker cards from an adopter realm using the public software-factory module URL.", + "ticketType": "task", + "status": "in_progress", + "priority": "high", + "acceptanceCriteria": "- [x] Project resolves from public module\n- [x] Ticket resolves from public module\n- [x] Knowledge article link renders", + "agentNotes": "Testing the shared DarkFactory module from a fixture realm.", + "estimatedHours": 2, + "actualHours": 1, + "createdAt": "2026-03-16T11:00:00.000Z", + "updatedAt": "2026-03-16T12:00:00.000Z" + }, + "relationships": { + "project": { + "links": { + "self": "../Project/demo-project" + } + }, + "assignedAgent": { + "links": { + "self": "../AgentProfile/demo-agent" + } + }, + "relatedKnowledge.0": { + "links": { + "self": "../KnowledgeArticle/agent-onboarding" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "Ticket" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/agent-demo.json b/packages/software-factory/test-fixtures/darkfactory-adopter/agent-demo.json new file mode 100644 index 0000000000..f7782f4340 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/agent-demo.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "card", + "attributes": { + "agentId": "codex-darkfactory", + "capabilities": ["ticket triage", "ui verification"], + "specialization": "Boxel tracker workflows", + "notes": "Primary test agent for DarkFactory rendering coverage." + }, + "relationships": {}, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "AgentProfile" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/factory-demo.json b/packages/software-factory/test-fixtures/darkfactory-adopter/factory-demo.json new file mode 100644 index 0000000000..8ce8907312 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/factory-demo.json @@ -0,0 +1,22 @@ +{ + "data": { + "type": "card", + "attributes": { + "factoryName": "DarkFactory Test Fixture", + "description": "Read-only fixture realm for validating public tracker-module rendering from an adopter realm." + }, + "relationships": { + "activeProjects.0": { + "links": { + "self": "./project-demo" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "DarkFactory" + } + } + } +} diff --git a/packages/software-factory/demo-realm/home.gts b/packages/software-factory/test-fixtures/darkfactory-adopter/home.gts similarity index 78% rename from packages/software-factory/demo-realm/home.gts rename to packages/software-factory/test-fixtures/darkfactory-adopter/home.gts index 9bded29b86..96bddf57f5 100644 --- a/packages/software-factory/demo-realm/home.gts +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/home.gts @@ -3,7 +3,7 @@ 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/demo-realm/index.json b/packages/software-factory/test-fixtures/darkfactory-adopter/index.json similarity index 100% rename from packages/software-factory/demo-realm/index.json rename to packages/software-factory/test-fixtures/darkfactory-adopter/index.json diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/knowledge-article-demo.json b/packages/software-factory/test-fixtures/darkfactory-adopter/knowledge-article-demo.json new file mode 100644 index 0000000000..d484270077 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/knowledge-article-demo.json @@ -0,0 +1,25 @@ +{ + "data": { + "type": "card", + "attributes": { + "articleTitle": "Agent Onboarding", + "articleType": "onboarding", + "content": "# Agent Onboarding\n\nUse the project card for scope, the ticket card for execution, and update notes as you go.", + "tags": ["onboarding", "tracker", "tests"], + "updatedAt": "2026-03-16T12:00:00.000Z" + }, + "relationships": { + "lastUpdatedBy": { + "links": { + "self": "./agent-demo" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "KnowledgeArticle" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/project-demo.json b/packages/software-factory/test-fixtures/darkfactory-adopter/project-demo.json new file mode 100644 index 0000000000..6319b5b464 --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/project-demo.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "attributes": { + "projectCode": "DFX", + "projectName": "DarkFactory Adoption Harness", + "projectStatus": "active", + "objective": "Prove the public software-factory tracker module can be adopted by an external realm.", + "scope": "## Scope\n\n- Render a project\n- Render a ticket\n- Render a knowledge article\n- Verify the project ticket query resolves", + "technicalContext": "The fixture realm adopts cards from the public `darkfactory` module URL and runs inside the software-factory Playwright harness.", + "successCriteria": "- [x] Public module resolves\n- [x] Adopter instances render\n- [x] Query-backed tickets section shows the linked ticket", + "risks": "- Public URL remapping could break module resolution\n- Ticket query may fail if canonical card refs drift" + }, + "relationships": { + "knowledgeBase.0": { + "links": { + "self": "./knowledge-article-demo" + } + }, + "teamAgents.0": { + "links": { + "self": "./agent-demo" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "Project" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/darkfactory-adopter/ticket-demo.json b/packages/software-factory/test-fixtures/darkfactory-adopter/ticket-demo.json new file mode 100644 index 0000000000..e30a61681f --- /dev/null +++ b/packages/software-factory/test-fixtures/darkfactory-adopter/ticket-demo.json @@ -0,0 +1,42 @@ +{ + "data": { + "type": "card", + "attributes": { + "ticketId": "DF-1", + "summary": "Verify public DarkFactory adoption", + "description": "Render tracker cards from an adopter realm using the public software-factory module URL.", + "ticketType": "task", + "status": "in_progress", + "priority": "high", + "acceptanceCriteria": "- [x] Project resolves from public module\n- [x] Ticket resolves from public module\n- [x] Knowledge article link renders", + "agentNotes": "Testing the shared DarkFactory module from a fixture realm.", + "estimatedHours": 2, + "actualHours": 1, + "createdAt": "2026-03-16T11:00:00.000Z", + "updatedAt": "2026-03-16T12:00:00.000Z" + }, + "relationships": { + "project": { + "links": { + "self": "./project-demo" + } + }, + "assignedAgent": { + "links": { + "self": "./agent-demo" + } + }, + "relatedKnowledge.0": { + "links": { + "self": "./knowledge-article-demo" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4201/software-factory/darkfactory", + "name": "Ticket" + } + } + } +} diff --git a/packages/software-factory/test-fixtures/public-software-factory-source/.realm.json b/packages/software-factory/test-fixtures/public-software-factory-source/.realm.json new file mode 100644 index 0000000000..4264b01cd9 --- /dev/null +++ b/packages/software-factory/test-fixtures/public-software-factory-source/.realm.json @@ -0,0 +1,5 @@ +{ + "name": "Software Factory Public Source Test Realm", + "iconURL": null, + "backgroundURL": null +} 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 new file mode 100644 index 0000000000..feb0b0d35b --- /dev/null +++ b/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-schema.gts @@ -0,0 +1,173 @@ +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 new file mode 100644 index 0000000000..3a9cbcdc83 --- /dev/null +++ b/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory-ui.gts @@ -0,0 +1,418 @@ +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 new file mode 100644 index 0000000000..e1b5868473 --- /dev/null +++ b/packages/software-factory/test-fixtures/public-software-factory-source/darkfactory.gts @@ -0,0 +1,8 @@ +// Public barrel for the DarkFactory tracker types. +export { + AgentProfile, + KnowledgeArticle, + Ticket, + Project, + DarkFactory, +} from './darkfactory-ui'; diff --git a/packages/software-factory/test-fixtures/public-software-factory-source/home.gts b/packages/software-factory/test-fixtures/public-software-factory-source/home.gts new file mode 100644 index 0000000000..7716c8d2b8 --- /dev/null +++ b/packages/software-factory/test-fixtures/public-software-factory-source/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/public-software-factory-source/index.json b/packages/software-factory/test-fixtures/public-software-factory-source/index.json new file mode 100644 index 0000000000..f20df53720 --- /dev/null +++ b/packages/software-factory/test-fixtures/public-software-factory-source/index.json @@ -0,0 +1,12 @@ +{ + "data": { + "type": "card", + "attributes": {}, + "meta": { + "adoptsFrom": { + "module": "./home.gts", + "name": "Home" + } + } + } +} diff --git a/packages/software-factory/tests/darkfactory.spec.ts b/packages/software-factory/tests/darkfactory.spec.ts new file mode 100644 index 0000000000..c532ff8220 --- /dev/null +++ b/packages/software-factory/tests/darkfactory.spec.ts @@ -0,0 +1,114 @@ +import { resolve } from 'node:path'; + +import { expect, test } from './fixtures'; + +const adopterRealmDir = resolve( + process.cwd(), + 'test-fixtures', + 'darkfactory-adopter', +); + +test.use({ realmDir: adopterRealmDir }); + +test('renders a project adopted from the public DarkFactory module', async ({ + authedPage, + cardURL, +}) => { + await authedPage.goto(cardURL('project-demo'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'DarkFactory Adoption Harness' }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Objective' }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Success Criteria' }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Knowledge Base' }), + ).toBeVisible(); + await expect(authedPage.getByText('Agent Onboarding')).toBeVisible(); +}); + +test('renders a ticket adopted from the public DarkFactory module', async ({ + authedPage, + cardURL, +}) => { + await authedPage.goto(cardURL('ticket-demo'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { + name: 'Verify public DarkFactory adoption', + }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Project' }), + ).toBeVisible(); + await expect( + authedPage.getByText('DarkFactory Adoption Harness'), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Acceptance Criteria' }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Agent Notes' }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Related Knowledge' }), + ).toBeVisible(); +}); + +test('renders a knowledge article and agent profile adopted from the public DarkFactory module', async ({ + authedPage, + cardURL, +}) => { + await authedPage.goto(cardURL('knowledge-article-demo'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'Agent Onboarding' }).first(), + ).toBeVisible(); + await expect( + authedPage.getByText('onboarding', { exact: true }).first(), + ).toBeVisible(); + await expect( + authedPage.getByText( + 'Use the project card for scope, the ticket card for execution, and update notes as you go.', + ), + ).toBeVisible(); + + await authedPage.goto(cardURL('agent-demo'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'codex-darkfactory' }), + ).toBeVisible(); + await expect(authedPage.getByText('Boxel tracker workflows')).toBeVisible(); + await expect(authedPage.getByText('ticket triage')).toBeVisible(); +}); + +test('renders a DarkFactory card with active projects from the adopter realm', async ({ + authedPage, + cardURL, +}) => { + await authedPage.goto(cardURL('factory-demo'), { + waitUntil: 'domcontentloaded', + }); + + await expect( + authedPage.getByRole('heading', { name: 'DarkFactory Test Fixture' }), + ).toBeVisible(); + await expect( + authedPage.getByRole('heading', { name: 'Active Projects' }), + ).toBeVisible(); + await expect( + authedPage.getByText('DarkFactory Adoption Harness'), + ).toBeVisible(); +}); diff --git a/packages/software-factory/tests/demo-realm.spec.ts b/packages/software-factory/tests/demo-realm.spec.ts deleted file mode 100644 index fc0fc146e6..0000000000 --- a/packages/software-factory/tests/demo-realm.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, test } from './fixtures'; - -test('renders a local card instance through the cached realm server', async ({ - authedPage, - cardURL, -}) => { - await authedPage.goto(cardURL('person-1'), { - waitUntil: 'domcontentloaded', - }); - - await expect( - authedPage.getByRole('heading', { name: 'Mango' }), - ).toBeVisible(); - await expect(authedPage.getByText('Mango').first()).toBeVisible(); -}); - -test('opens another local card instance in the shared browser context', async ({ - authedPage, - cardURL, -}) => { - await authedPage.goto(cardURL('person-2'), { - waitUntil: 'domcontentloaded', - }); - - await expect( - authedPage.getByRole('heading', { name: 'Papaya' }), - ).toBeVisible(); -}); - -test('allows one test to mutate data inside its fresh realm runtime', async ({ - authedPage, - realm, - cardURL, -}) => { - let response = await fetch(cardURL('person-1'), { - method: 'PATCH', - headers: { - Accept: 'application/vnd.card+json', - 'Content-Type': 'application/vnd.card+json', - ...realm.authorizationHeaders(), - }, - body: JSON.stringify({ - data: { - type: 'card', - attributes: { - firstName: 'Dragonfruit', - }, - meta: { - adoptsFrom: { - module: './person.gts', - name: 'Person', - }, - }, - }, - }), - }); - - expect(response.ok).toBeTruthy(); - - await authedPage.goto(cardURL('person-1'), { - waitUntil: 'domcontentloaded', - }); - - await expect( - authedPage.getByRole('heading', { name: 'Dragonfruit' }), - ).toBeVisible(); -}); - -test('restores the cached template data for the next test run', async ({ - authedPage, - realm, - cardURL, -}) => { - let response = await fetch(cardURL('person-1'), { - headers: { - Accept: 'application/vnd.card+json', - ...realm.authorizationHeaders(), - 'Cache-Control': 'no-cache, no-store, max-age=0', - Pragma: 'no-cache', - }, - }); - let json = await response.json(); - - expect(json.data.attributes.firstName).toBe('Mango'); - - await authedPage.goto(cardURL('person-1'), { - waitUntil: 'domcontentloaded', - }); - - await expect( - authedPage.getByRole('heading', { name: 'Mango' }), - ).toBeVisible(); -}); diff --git a/packages/software-factory/tests/fixtures.ts b/packages/software-factory/tests/fixtures.ts index 525e170a04..6471a8b8cf 100644 --- a/packages/software-factory/tests/fixtures.ts +++ b/packages/software-factory/tests/fixtures.ts @@ -25,10 +25,25 @@ export type FactoryRealmFixtures = { authedPage: Page; }; +type FactoryRealmOptions = { + realmDir: string; +}; + const packageRoot = resolve(process.cwd()); const defaultRealmDir = resolve( packageRoot, - process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'demo-realm', + process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'test-fixtures/darkfactory-adopter', +); +const realmPort = Number(process.env.SOFTWARE_FACTORY_REALM_PORT ?? 4205); +const publicSoftwareFactoryPrefix = + process.env.SOFTWARE_FACTORY_PUBLIC_SOURCE_URL ?? + 'http://localhost:4201/software-factory/'; +const localBasePrefix = `http://localhost:${realmPort}/base/`; +const localSoftwareFactoryPrefix = `http://localhost:${realmPort}/software-factory/`; +const localSkillsPrefix = `http://localhost:${realmPort}/skills/`; +const testSourceRealmDir = resolve( + packageRoot, + 'test-fixtures/public-software-factory-source', ); function appendLog(buffer: string, chunk: string): string { @@ -90,6 +105,7 @@ async function startRealmProcess(realmDir = defaultRealmDir) { env: { ...process.env, SOFTWARE_FACTORY_METADATA_FILE: metadataFile, + SOFTWARE_FACTORY_SOURCE_REALM_DIR: testSourceRealmDir, ...(supportMetadata?.context ? { SOFTWARE_FACTORY_CONTEXT: JSON.stringify(supportMetadata.context), @@ -181,25 +197,32 @@ async function setRealmRedirects(page: Page) { await registerRealmRedirect( page, 'http://localhost:4201/base/', - 'http://localhost:4205/base/', + localBasePrefix, + ); + await registerRealmRedirect( + page, + publicSoftwareFactoryPrefix, + localSoftwareFactoryPrefix, ); if (process.env.SOFTWARE_FACTORY_INCLUDE_SKILLS === '1') { await registerRealmRedirect( page, 'http://localhost:4201/skills/', - 'http://localhost:4205/skills/', + localSkillsPrefix, ); } } -export const test = base.extend({ +export const test = base.extend({ + realmDir: [defaultRealmDir, { option: true }], + page: async ({ page }, use) => { await setRealmRedirects(page); await use(page); }, - realm: async ({ browserName: _browserName }, use) => { - let realm = await startRealmProcess(); + realm: async ({ browserName: _browserName, realmDir }, use) => { + let realm = await startRealmProcess(realmDir); try { await use(realm); From c19d18f8a0c09114b7d642f639712157c2d87742 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 16 Mar 2026 10:45:22 -0400 Subject: [PATCH 12/23] fixed tests --- .../playwright.global-setup.ts | 54 +++++++++++++++++++ .../software-factory/src/cli/cache-realm.ts | 11 +++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/software-factory/playwright.global-setup.ts b/packages/software-factory/playwright.global-setup.ts index 7ff11d906a..1522bea5c5 100644 --- a/packages/software-factory/playwright.global-setup.ts +++ b/packages/software-factory/playwright.global-setup.ts @@ -35,6 +35,40 @@ function appendLog(buffer: string, chunk: string): string { return combined.length > 20_000 ? combined.slice(-20_000) : combined; } +async function waitForCommand( + child: ReturnType, + getLogs: () => string, + timeoutMs = 300_000, +): Promise { + let exit = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error(`command exited with code ${code ?? 'null'}\n${getLogs()}`), + ); + } + }); + }); + + await Promise.race([ + exit, + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `timed out waiting for setup command to finish\n${getLogs()}`, + ), + ), + timeoutMs, + ), + ), + ]); +} + async function waitForMetadataFile( metadataFile: string, child: ReturnType, @@ -91,6 +125,26 @@ export default async function globalSetup() { context: Record; }>(defaultSupportMetadataFile, child, () => logs); + let cacheLogs = ''; + let cacheChild = spawn('pnpm', ['cache:prepare', realmDir], { + cwd: packageRoot, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + SOFTWARE_FACTORY_CONTEXT: JSON.stringify(payload.context), + SOFTWARE_FACTORY_SOURCE_REALM_DIR: testSourceRealmDir, + }, + }); + + cacheChild.stdout?.on('data', (chunk) => { + cacheLogs = appendLog(cacheLogs, String(chunk)); + }); + cacheChild.stderr?.on('data', (chunk) => { + cacheLogs = appendLog(cacheLogs, String(chunk)); + }); + + await waitForCommand(cacheChild, () => cacheLogs); + writeFileSync( defaultSupportMetadataFile, JSON.stringify( diff --git a/packages/software-factory/src/cli/cache-realm.ts b/packages/software-factory/src/cli/cache-realm.ts index 4e763a18e6..151a9ae411 100644 --- a/packages/software-factory/src/cli/cache-realm.ts +++ b/packages/software-factory/src/cli/cache-realm.ts @@ -3,13 +3,22 @@ import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { ensureFactoryRealmTemplate } from '../harness.ts'; +import { readSupportContext } from '../runtime-metadata.ts'; let realmDir = resolve( process.cwd(), process.argv[2] ?? 'test-fixtures/darkfactory-adopter', ); + +let supportContext = process.env.SOFTWARE_FACTORY_CONTEXT + ? JSON.parse(process.env.SOFTWARE_FACTORY_CONTEXT) + : readSupportContext(); + try { - let template = await ensureFactoryRealmTemplate({ realmDir }); + let template = await ensureFactoryRealmTemplate({ + realmDir, + context: supportContext, + }); let payload = { realmDir, cacheKey: template.cacheKey, From f9d4d0b78d250ba11fbbb6d89c65d5aae802fc08 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 16 Mar 2026 11:13:00 -0400 Subject: [PATCH 13/23] fix types and lint --- .../references/dev-command-development.md | 45 +++-- .../references/dev-core-patterns.md | 119 +++++++----- .../references/dev-data-management.md | 39 ++-- .../references/dev-defensive-programming.md | 26 ++- .../references/dev-delegated-rendering.md | 82 +++++---- .../references/dev-enumerations.md | 88 +++++---- .../references/dev-external-libraries.md | 23 ++- .../references/dev-file-editing.md | 12 +- .../references/dev-fitted-formats.md | 14 +- .../references/dev-query-systems.md | 31 +++- .../references/dev-quick-reference.md | 84 +++++++-- .../references/dev-replicate-ai.md | 15 +- .../references/dev-spec-usage.md | 59 ++++-- .../references/dev-styling-design.md | 57 ++++-- .../references/dev-technical-rules.md | 16 +- .../references/dev-template-patterns.md | 169 ++++++++++++------ .../references/dev-theme-design-system.md | 85 ++++++--- .../skills/boxel-file-structure/SKILL.md | 61 ++++--- .../.agents/skills/boxel-repair/SKILL.md | 2 + .../.agents/skills/boxel-setup/SKILL.md | 15 ++ .../.agents/skills/boxel-sync/SKILL.md | 8 + .../.agents/skills/boxel-track/SKILL.md | 10 ++ .../.agents/skills/boxel-watch/SKILL.md | 7 + packages/software-factory/.claude/CLAUDE.md | 144 +++++++++++---- packages/software-factory/package.json | 2 +- .../software-factory/realm/darkfactory-ui.gts | 50 +++++- .../software-factory/scripts/boxel-search.mjs | 4 +- .../scripts/boxel-session.mjs | 6 +- .../software-factory/scripts/lib/boxel.mjs | 98 ++++++---- .../software-factory/scripts/pick-ticket.mjs | 12 +- .../scripts/run-realm-tests.mjs | 71 ++++++-- packages/software-factory/src/harness.ts | 23 +-- packages/software-factory/src/index.ts | 2 +- .../tests/darkfactory.spec.ts | 2 +- packages/software-factory/tests/fixtures.ts | 9 +- .../tests/helpers/browser-auth.ts | 6 +- packages/software-factory/tsconfig.json | 18 +- .../software-factory/tsconfig.playwright.json | 7 + 38 files changed, 1049 insertions(+), 472 deletions(-) create mode 100644 packages/software-factory/tsconfig.playwright.json diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md index 3a5acadd0d..f95dc885ac 100644 --- a/packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-command-development.md @@ -15,12 +15,14 @@ class MyInput extends CardDef { export class MyCommand extends Command { static actionVerb = 'Process'; - async getInputType() { return MyInput; } - + async getInputType() { + return MyInput; + } + protected async run(input: MyInput): Promise { // Validation first if (!input.targetRealm) throw new Error('Target realm required'); - + // Execute workflow // Return result or undefined } @@ -40,20 +42,22 @@ import SearchCardsByQueryCommand from '@cardstack/boxel-host/commands/search-car // Save a card await new SaveCardCommand(this.commandContext).execute({ card: myCard, - realm: 'https://realm-url/' + realm: 'https://realm-url/', }); // Get a card const card = await new GetCardCommand(this.commandContext).execute({ - cardId: 'https://realm/Card/id' + cardId: 'https://realm/Card/id', }); // External API call -const response = await new SendRequestViaProxyCommand(this.commandContext).execute({ +const response = await new SendRequestViaProxyCommand( + this.commandContext, +).execute({ url: 'https://api.example.com/endpoint', method: 'POST', requestBody: JSON.stringify(data), - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }); ``` @@ -63,7 +67,7 @@ const response = await new SendRequestViaProxyCommand(this.commandContext).execu const headers = { 'Content-Type': 'application/json', 'HTTP-Referer': 'https://realms-staging.stack.cards', - 'X-Title': 'Your App Name' + 'X-Title': 'Your App Name', }; const response = await new SendRequestViaProxyCommand(ctx).execute({ @@ -71,9 +75,9 @@ const response = await new SendRequestViaProxyCommand(ctx).execute({ method: 'POST', requestBody: JSON.stringify({ model: 'google/gemini-2.5-flash', - messages: [{ role: 'user', content: 'Your prompt' }] + messages: [{ role: 'user', content: 'Your prompt' }], }), - headers + headers, }); if (!response.response.ok) throw new Error('API call failed'); @@ -90,7 +94,7 @@ import UploadImageCommand from 'https://realms-staging.stack.cards/catalog/comma const result = await new UploadImageCommand(this.commandContext).execute({ sourceImageUrl: dataUrl, - targetRealmUrl: input.realm + targetRealmUrl: input.realm, }); ``` @@ -99,14 +103,19 @@ const result = await new UploadImageCommand(this.commandContext).execute({ ```gts import SearchCardsByQueryCommand from '@cardstack/boxel-host/commands/search-cards-by-query'; -const results = await new SearchCardsByQueryCommand(this.commandContext).execute({ +const results = await new SearchCardsByQueryCommand( + this.commandContext, +).execute({ query: { filter: { - on: { module: new URL('./product', import.meta.url).href, name: 'Product' }, - eq: { status: 'active' } - } + on: { + module: new URL('./product', import.meta.url).href, + name: 'Product', + }, + eq: { status: 'active' }, + }, }, - realmURLs: [input.realm] + realmURLs: [input.realm], }); ``` @@ -117,7 +126,7 @@ import { tracked } from '@glimmer/tracking'; export class MyCommand extends Command { @tracked step: 'idle' | 'processing' | 'completed' | 'error' = 'idle'; - + protected async run(input: Input): Promise { this.step = 'processing'; try { @@ -158,4 +167,4 @@ import { getCardMenuItems } from '@cardstack/runtime-common'; - ✅ **Include `on` in queries** - for eq/contains/range filters - ✅ **Delegate to catalog commands** - don't reimplement uploads/services - ✅ **Wrap JSON parsing in try-catch** - handle malformed responses -- ✅ **Track progress states** - use `@tracked` for UI feedback \ No newline at end of file +- ✅ **Track progress states** - use `@tracked` for UI feedback diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md index 204e5e5736..8afd7634a9 100644 --- a/packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-patterns.md @@ -1,26 +1,29 @@ **Card with computed title:** + ```gts export class BlogPost extends CardDef { @field headline = contains(StringField); - + @field title = contains(StringField, { - computeVia: function(this: BlogPost) { + computeVia: function (this: BlogPost) { return this.headline ?? 'Untitled Post'; - } + }, }); } ``` **Field definition:** + ```gts export class AddressField extends FieldDef { @field street = contains(StringField); @field city = contains(StringField); - + static embedded = class Embedded extends Component { }; @@ -30,8 +33,17 @@ export class AddressField extends FieldDef { ## Core Patterns ### 1. Card Definition with Safe Computed Title + ```gts -import { CardDef, field, contains, linksTo, containsMany, linksToMany, Component } from 'https://cardstack.com/base/card-api'; +import { + CardDef, + field, + contains, + linksTo, + containsMany, + linksToMany, + Component, +} from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import DateField from 'https://cardstack.com/base/date'; import FileTextIcon from '@cardstack/boxel-icons/file-text'; @@ -41,15 +53,15 @@ export class BlogPost extends CardDef { static displayName = 'Blog Post'; static icon = FileTextIcon; // ✅ CORRECT: Boxel icons for static card/field type icons static prefersWideFormat = true; - + @field headline = contains(StringField); @field publishDate = contains(DateField); @field author = linksTo(Author); @field tags = containsMany(TagField); @field relatedPosts = linksToMany(() => BlogPost); - + @field title = contains(StringField, { - computeVia: function(this: BlogPost) { + computeVia: function (this: BlogPost) { try { const baseTitle = this.headline ?? 'Untitled Post'; const maxLength = 50; @@ -59,7 +71,7 @@ export class BlogPost extends CardDef { console.error('BlogPost: Error computing title', e); return 'Untitled Post'; } - } + }, }); } ``` @@ -69,7 +81,12 @@ export class BlogPost extends CardDef { **CRITICAL:** Every FieldDef file must import FieldDef and MUST be exported: ```gts -import { FieldDef, field, contains, Component } from 'https://cardstack.com/base/card-api'; +import { + FieldDef, + field, + contains, + Component, +} from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import LocationIcon from '@cardstack/boxel-icons/map-pin'; import { concat } from '@ember/helper'; @@ -77,31 +94,37 @@ import { concat } from '@ember/helper'; export class AddressField extends FieldDef { static displayName = 'Address'; static icon = LocationIcon; // ✅ CORRECT: Boxel icons for static card/field type icons - + @field street = contains(StringField); @field city = contains(StringField); @field postalCode = contains(StringField); @field country = contains(StringField); - + static embedded = class Embedded extends Component { }; @@ -143,7 +166,7 @@ export class AddressField extends FieldDef { ```gts static isolated = class Isolated extends Component { // ³⁰ Isolated format @tracked showComments = false; - + // ³¹ CRITICAL: Do ALL computation in functions, never in templates get safeTitle() { try { @@ -153,7 +176,7 @@ static isolated = class Isolated extends Component { // ³⁰ I return 'Untitled Post'; } } - + get commentButtonText() { try { const count = this.args?.model?.commentCount ?? 0; @@ -163,26 +186,26 @@ static isolated = class Isolated extends Component { // ³⁰ I return this.showComments ? 'Hide Comments' : 'Show Comments'; } } - + // methods referenced from templates must be defined with fat arrow (=>) so that they are properly bound when invoked toggleComments = () => { this.showComments = !this.showComments; } - + ``` **CSS comments (NEVER use //):** + ```css /* ✅ CORRECT: Block comments */ .card { color: blue; } @@ -19,10 +23,15 @@ ``` **Never use global selectors:** + ```css /* ❌ WRONG */ -:root { --color: blue; } -body { margin: 0; } +:root { + --color: blue; +} +body { + margin: 0; +} /* ✅ CORRECT */ .my-component { @@ -31,10 +40,11 @@ body { margin: 0; } ``` **Formatters for display:** + ```hbs -{{formatCurrency @model.price currency="USD"}} -{{formatDateTime @model.date size="medium"}} -{{formatNumber @model.count size="tiny"}} +{{formatCurrency @model.price currency='USD'}} +{{formatDateTime @model.date size='medium'}} +{{formatNumber @model.count size='tiny'}} ``` ## Design Philosophy and Competitive Styling @@ -44,6 +54,7 @@ Design and implement your stylesheet to fit the domain you are generating. Resea Approach: Study the leading players' design patterns, then create something that feels more modern, intuitive, and polished. Focus on micro-interactions, thoughtful spacing, superior visual hierarchy, and removing friction from user workflows. Key Areas to Compete On: + - Visual polish: better typography, spacing, and color schemes - Interaction design: smoother animations, better feedback, clearer affordances - Information architecture: more logical organization, better progressive disclosure @@ -68,7 +79,7 @@ Implementation tip: Define CSS variables at component root and use fallbacks. .component { --card-padding: var(--boxel-sp, 1rem); --card-radius: var(--boxel-border-radius-sm, 0.5rem); - --card-shadow: var(--boxel-box-shadow, 0 2px 4px rgba(0,0,0,0.1)); + --card-shadow: var(--boxel-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1)); padding: var(--card-padding); border-radius: var(--card-radius); box-shadow: var(--card-shadow); @@ -85,22 +96,30 @@ Implementation tip: Define CSS variables at component root and use fallbacks. - Numbers: tabular-nums for data tables and metrics when available Example: + ```css -.title { font-size: clamp(1rem, 2.5vw, 1.25rem); font-weight: 700; } -.subtle { font-size: 0.75rem; opacity: 0.8; } +.title { + font-size: clamp(1rem, 2.5vw, 1.25rem); + font-weight: 700; +} +.subtle { + font-size: 0.75rem; + opacity: 0.8; +} ``` ## Format Dimensions Comparison -| Format | Width | Height | Parent Sets | Key Behavior | -|----------|------------------|------------------|-------------|-------------| -| Isolated | Max-width, center| Natural + scroll | No | Full detail, scrollable content | -| Embedded | Fills container | Natural | Width only | Truncation/expand controls handled by parent | -| Fitted | Fills exactly | Fills exactly | Both | Must adapt to fixed grid slots | -| Atom | Inline | Inline | No | Minimal inline representation | -| Edit | Fills container | Natural form | Width only | Form layout, grows with fields | +| Format | Width | Height | Parent Sets | Key Behavior | +| -------- | ----------------- | ---------------- | ----------- | -------------------------------------------- | +| Isolated | Max-width, center | Natural + scroll | No | Full detail, scrollable content | +| Embedded | Fills container | Natural | Width only | Truncation/expand controls handled by parent | +| Fitted | Fills exactly | Fills exactly | Both | Must adapt to fixed grid slots | +| Atom | Inline | Inline | No | Minimal inline representation | +| Edit | Fills container | Natural form | Width only | Form layout, grows with fields | Notes: + - Fitted requires internal subformats (badge, strip, tile, card) via container queries - Embedded should be height-flexible; parents may clamp and offer "view more" -- Isolated should ensure comfortable reading with scrollable mat and generous padding \ No newline at end of file +- Isolated should ensure comfortable reading with scrollable mat and generous padding diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md index 3c4f958a00..15c9b2838d 100644 --- a/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md @@ -1,6 +1,7 @@ ### The Cardinal Rule **MOST CRITICAL RULE:** + ```gts // ✅ CORRECT @field author = linksTo(Author); // CardDef @@ -12,12 +13,14 @@ ``` **Must export ALL classes:** + ```gts export class MyCard extends CardDef { } // ✅ class MyCard extends CardDef { } // ❌ Missing export ``` **Computed fields:** + - Keep simple and unidirectional - No self-reference or cycles - Wrap cross-card access in try-catch @@ -28,10 +31,10 @@ class MyCard extends CardDef { } // ❌ Missing export **THIS IS THE #1 MOST CRITICAL RULE IN BOXEL:** -| Type | MUST Use | NEVER Use | Why | -|------|----------|-----------|-----| -| **Extends CardDef** | `linksTo` / `linksToMany` | ❌ `contains` / `containsMany` | CardDef = independent entity with own JSON file | -| **Extends FieldDef** | `contains` / `containsMany` | ❌ `linksTo` / `linksToMany` | FieldDef = embedded data, no separate identity | +| Type | MUST Use | NEVER Use | Why | +| -------------------- | --------------------------- | ------------------------------ | ----------------------------------------------- | +| **Extends CardDef** | `linksTo` / `linksToMany` | ❌ `contains` / `containsMany` | CardDef = independent entity with own JSON file | +| **Extends FieldDef** | `contains` / `containsMany` | ❌ `linksTo` / `linksToMany` | FieldDef = embedded data, no separate identity | ```gts // ✅ CORRECT @@ -55,6 +58,7 @@ class MyCard extends CardDef { } // ❌ Missing export ### TECHNICAL VALIDATION CHECKLIST Before generating ANY code: + - [ ] SEARCH/REPLACE blocks with tracking markers - [ ] Every CardDef field uses `linksTo`/`linksToMany` - [ ] Every FieldDef field uses `contains`/`containsMany` @@ -69,6 +73,7 @@ Before generating ANY code: ### Common Mistakes #### Using contains with CardDef + ```gts // ❌ WRONG @field items = containsMany(Item); // Item is CardDef @@ -78,10 +83,11 @@ Before generating ANY code: ``` #### Missing Exports + ```gts // ❌ WRONG class BlogPost extends CardDef { } // ✅ CORRECT export class BlogPost extends CardDef { } -``` \ No newline at end of file +``` diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md index d5cae494a2..8377e76ce8 100644 --- a/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md @@ -1,11 +1,16 @@ ### Template Essentials **Field access patterns:** + ```hbs -{{@model.title}} -<@fields.title /> -<@fields.phone @format="atom" /> -<@fields.items @format="embedded" /> +{{@model.title}} + +<@fields.title /> + +<@fields.phone @format='atom' /> + +<@fields.items @format='embedded' /> + ``` For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: Theme-First Design System. @@ -17,15 +22,16 @@ For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: ```hbs {{#each @model.teamMembers as |member|}} - <@fields.member @format="embedded" /> + <@fields.member @format='embedded' /> + {{/each}} -<@fields.teamMembers @format="embedded" /> +<@fields.teamMembers @format='embedded' /> {{#each @model.teamMembers as |member|}} -
{{member.name}}
+
{{member.name}}
{{/each}} @@ -35,6 +41,7 @@ For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: **Why this breaks:** @fields provides field-level components. Once you're iterating with @model, you're working with raw data, not field components. **Decision Rule:** Before iterating, decide: + - Need composability? → Use delegated rendering - Need filtering? → Use query patterns (PrerenderedCardSearch/getCards) - Need custom control? → Use @model but handle ALL rendering yourself @@ -58,16 +65,16 @@ For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: {{! Access a specific field by index }} {{#let (get @fields.shoppingList 0) as |firstItem|}} {{#if firstItem}} - + {{else}} -
No first item
+
No first item
{{/if}} {{/let}} {{! Access last item using subtract helper }} {{#let (get @fields.items (subtract @model.items.length 1)) as |lastItem|}} {{#if lastItem}} - + {{/if}} {{/let}} ``` @@ -81,19 +88,20 @@ For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3:

Phone: {{@model.phone}}

-

Phone: <@fields.phone @format="atom" />

+

Phone: <@fields.phone @format='atom' />

-
- <@fields.phone @format="embedded" /> +
+ <@fields.phone @format='embedded' />
``` **💡 Line-saving tip:** Keep self-closing tags compact: + ```hbs -<@fields.author @format="embedded" /> -<@fields.phone @format="atom" /> +<@fields.author @format='embedded' /> +<@fields.phone @format='atom' /> ``` #### @fields Delegation Rule @@ -102,19 +110,21 @@ For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: ```hbs -<@fields.author @format="embedded" /> -<@fields.items @format="embedded" /> +<@fields.author @format='embedded' /> +<@fields.items @format='embedded' /> {{#each @fields.items as |item|}} - + {{/each}} {{#each @model.items as |item|}} - <@fields.??? @format="embedded" /> + <@fields.??? @format='embedded' /> + {{/each}} ``` **containsMany Spacing Pattern:** Due to an additional wrapper div, target `.containsMany-field`: + ```css /* For grids */ .products-grid > .containsMany-field { @@ -138,39 +148,49 @@ For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: #### Three Primary Patterns for Fallbacks **1. Inline if/else (for simple display fallbacks):** + ```hbs -{{if @model.eventTime (formatDateTime @model.eventTime "MMM D, h:mm A") "Event time to be announced"}} -

{{if @model.title @model.title "Untitled Document"}}

-

Status: {{if @model.status @model.status "Status pending"}}

+{{if + @model.eventTime + (formatDateTime @model.eventTime 'MMM D, h:mm A') + 'Event time to be announced' + }} +

{{if @model.title @model.title 'Untitled Document'}}

+

Status: {{if @model.status @model.status 'Status pending'}}

``` **2. Block-based if/else (for complex content):** + ```hbs -
+
{{#if @model.eventTime}} - {{formatDateTime @model.eventTime "MMM D, h:mm A"}} + {{formatDateTime @model.eventTime 'MMM D, h:mm A'}} {{else}} - Event time to be announced + Event time to be announced {{/if}}
{{#if @model.description}} -
+
<@fields.description />
{{else}} -
+

No description provided yet. Click to add one.

{{/if}} ``` **3. Unless for safety/validation checks (composed with other helpers):** + ```hbs -{{unless (and @model.isValid @model.hasPermission) "⚠️ Cannot proceed - missing validation or permission"}} -{{unless (or @model.email @model.phone) "Contact information required"}} -{{unless (gt @model.items.length 0) "No items available"}} -{{unless (eq @model.status "active") "Service unavailable"}} +{{unless + (and @model.isValid @model.hasPermission) + '⚠️ Cannot proceed - missing validation or permission' +}} +{{unless (or @model.email @model.phone) 'Contact information required'}} +{{unless (gt @model.items.length 0) 'No items available'}} +{{unless (eq @model.status 'active') 'Service unavailable'}} ``` **Best Practices:** Use descriptive placeholder text rather than generic "N/A", style placeholder text differently (lighter color, italic), use `unless` for safety checks and `if` for display fallbacks. @@ -184,6 +204,7 @@ For theming, CSS variables, spacing scales, and CSS safety rules, see Module 3: #### The Three Array States Your templates must handle: + 1. **Completely undefined arrays** - Field doesn't exist or is null 2. **Empty arrays** - Field exists but has no items (`[]`) 3. **Arrays with actual data** - Field has one or more items @@ -191,9 +212,10 @@ Your templates must handle: #### Array Logic Pattern **❌ WRONG - Only checks for existence:** + ```hbs {{#if @model.goals}} -
    +
      {{#each @model.goals as |goal|}}
    • {{goal}}
    • {{/each}} @@ -202,34 +224,51 @@ Your templates must handle: ``` **✅ CORRECT - Checks for length and provides empty state:** + ```hbs {{#if @model.goals.length}} -
      +

      - - - - + + + + Daily Goals

      -
        +
          {{#each @model.goals as |goal|}}
        • {{goal}}
        • {{/each}}
      {{else}} -
      +

      - - - - + + + + Daily Goals

      -

      No goals set yet. What would you like to accomplish?

      +

      No goals set yet. What would you like to accomplish?

      {{/if}} ``` @@ -239,7 +278,14 @@ Your templates must handle: ### Real-World Example: Shopping List with Featured Items ```gts -import { CardDef, FieldDef, field, contains, containsMany, Component } from 'https://cardstack.com/base/card-api'; +import { + CardDef, + FieldDef, + field, + contains, + containsMany, + Component, +} from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import NumberField from 'https://cardstack.com/base/number'; import { get } from '@ember/helper'; @@ -249,11 +295,11 @@ export class FruitItem extends FieldDef { static displayName = 'Fruit'; @field title = contains(StringField); @field quantity = contains(NumberField); - + static embedded = class Embedded extends Component { @@ -263,42 +309,45 @@ export class FruitItem extends FieldDef { export class ShoppingList extends CardDef { static displayName = 'Shopping List'; @field items = containsMany(FruitItem); - + static isolated = class Isolated extends Component { -``` \ No newline at end of file +``` diff --git a/packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md b/packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md index b155e83fb9..6420101d95 100644 --- a/packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md +++ b/packages/software-factory/.agents/skills/boxel-file-structure/SKILL.md @@ -16,11 +16,11 @@ Example: https://app.boxel.ai/sarah/pet-rescue/animals/dog.gts ## File Naming Conventions -| Type | Convention | Example | -|------|------------|---------| -| Card definitions | `kebab-case.gts` | `blog-post.gts`, `grammy-award.gts` | -| Instance directories | `PascalCase/` | `BlogPost/`, `GrammyAward/` | -| Instance files | `kebab-case.json` | `my-first-post.json` | +| Type | Convention | Example | +| -------------------- | ----------------- | ----------------------------------- | +| Card definitions | `kebab-case.gts` | `blog-post.gts`, `grammy-award.gts` | +| Instance directories | `PascalCase/` | `BlogPost/`, `GrammyAward/` | +| Instance files | `kebab-case.json` | `my-first-post.json` | ## Directory Structure @@ -43,6 +43,7 @@ workspace/ **The `adoptsFrom.module` path is relative to the JSON file location.** ### ✅ Correct: Instance in subdirectory + ``` grammy-award.gts # Definition at root GrammyAward/ # Instances in PascalCase directory @@ -50,11 +51,12 @@ GrammyAward/ # Instances in PascalCase directory ``` **In `GrammyAward/record-of-the-year.json`:** + ```json { "meta": { "adoptsFrom": { - "module": "../grammy-award", // ← Go UP to parent, then to file + "module": "../grammy-award", // ← Go UP to parent, then to file "name": "GrammyAward" } } @@ -62,11 +64,12 @@ GrammyAward/ # Instances in PascalCase directory ``` ### ❌ Wrong: Forgetting the relative path + ```json { "meta": { "adoptsFrom": { - "module": "./grammy-award", // ← WRONG! This looks in GrammyAward/ + "module": "./grammy-award", // ← WRONG! This looks in GrammyAward/ "name": "GrammyAward" } } @@ -75,12 +78,12 @@ GrammyAward/ # Instances in PascalCase directory ## Path Rules Summary -| JSON Location | Definition Location | Module Path | -|--------------|---------------------|-------------| -| `root/Instance.json` | `root/card.gts` | `"./card"` | -| `root/Card/instance.json` | `root/card.gts` | `"../card"` | -| `root/Card/Sub/instance.json` | `root/card.gts` | `"../../card"` | -| `root/Card/instance.json` | `root/other/card.gts` | `"../other/card"` | +| JSON Location | Definition Location | Module Path | +| ----------------------------- | --------------------- | ----------------- | +| `root/Instance.json` | `root/card.gts` | `"./card"` | +| `root/Card/instance.json` | `root/card.gts` | `"../card"` | +| `root/Card/Sub/instance.json` | `root/card.gts` | `"../../card"` | +| `root/Card/instance.json` | `root/other/card.gts` | `"../other/card"` | ## Instance JSON Structure (Full) @@ -139,6 +142,7 @@ GrammyAward/ # Instances in PascalCase directory ``` ### ❌ Wrong: Array syntax (does NOT work) + ```json { "relationships": { @@ -150,7 +154,8 @@ GrammyAward/ # Instances in PascalCase directory } } ``` -``` + +```` ### JSON Structure Rules @@ -185,7 +190,7 @@ GrammyAward/ # Instances in PascalCase directory // In .gts definition: @field author = linksTo(Author); // Author extends CardDef → relationships @field address = contains(AddressField); // AddressField extends FieldDef → attributes -``` +```` ```json // In .json instance: @@ -222,11 +227,13 @@ When linking to other cards, use the card's URL without `.json`: These realms contain shared definitions you can import from: **Production:** + - `https://cardstack.com/base/` - Core types (CardDef, FieldDef, etc.) - `https://app.boxel.ai/catalog/` - Catalog cards - `https://app.boxel.ai/skills/` - Skill cards **Staging:** + - `https://cardstack.com/base/` - Same core types - `https://realms-staging.stack.cards/catalog/` - `https://realms-staging.stack.cards/skills/` @@ -272,6 +279,7 @@ When using the `/_search` API endpoint: ``` **With field filters:** + ```json { "filter": { @@ -285,21 +293,22 @@ When using the `/_search` API endpoint: ## Common Mistakes -| Mistake | Fix | -|---------|-----| -| `"module": "./card"` from subdirectory | Use `"../card"` | -| `contains(CardDef)` | Use `linksTo(CardDef)` | -| `linksTo(FieldDef)` | Use `contains(FieldDef)` | -| Link in `attributes` | Move to `relationships` | -| FieldDef in `relationships` | Move to `attributes` | -| Missing `data` wrapper in JSON | Wrap everything in `{"data": {...}}` | -| PascalCase for `.gts` files | Use `kebab-case.gts` | -| kebab-case for instance dirs | Use `PascalCase/` | -| `linksToMany` as array | Use numbered keys: `field.0`, `field.1`, etc. | +| Mistake | Fix | +| -------------------------------------- | --------------------------------------------- | +| `"module": "./card"` from subdirectory | Use `"../card"` | +| `contains(CardDef)` | Use `linksTo(CardDef)` | +| `linksTo(FieldDef)` | Use `contains(FieldDef)` | +| Link in `attributes` | Move to `relationships` | +| FieldDef in `relationships` | Move to `attributes` | +| Missing `data` wrapper in JSON | Wrap everything in `{"data": {...}}` | +| PascalCase for `.gts` files | Use `kebab-case.gts` | +| kebab-case for instance dirs | Use `PascalCase/` | +| `linksToMany` as array | Use numbered keys: `field.0`, `field.1`, etc. | ## Essential Formats Every CardDef should implement these templates: + - `isolated` - Full detail view (scrollable) - `embedded` - Compact summary for lists - `fitted` - Fixed dimensions for grids/dashboards (CRITICAL for good UX) diff --git a/packages/software-factory/.agents/skills/boxel-repair/SKILL.md b/packages/software-factory/.agents/skills/boxel-repair/SKILL.md index 5bb67bafcd..2add8d647f 100644 --- a/packages/software-factory/.agents/skills/boxel-repair/SKILL.md +++ b/packages/software-factory/.agents/skills/boxel-repair/SKILL.md @@ -6,6 +6,7 @@ description: Use when a Boxel workspace has broken realm metadata, missing icons # Boxel Repair Use this workflow when a workspace has any of these symptoms: + - Missing icon/background in workspace tiles - Display name is `Unknown Workspace` or mismatched - Opening a workspace fails due to missing `cards-grid` relationship @@ -27,6 +28,7 @@ boxel repair-realms ## Behavior `repair-realm` and `repair-realms` perform these repairs: + - `.realm.json`: normalize `name`, `iconURL`, `backgroundURL` - `index.json`: ensure `relationships.cardsGrid.links.self` = `./cards-grid` - `cards-grid.json`: restore default cards-grid card if missing/corrupt diff --git a/packages/software-factory/.agents/skills/boxel-setup/SKILL.md b/packages/software-factory/.agents/skills/boxel-setup/SKILL.md index d8019c9a4e..0515d123a1 100644 --- a/packages/software-factory/.agents/skills/boxel-setup/SKILL.md +++ b/packages/software-factory/.agents/skills/boxel-setup/SKILL.md @@ -8,7 +8,9 @@ description: Use for Boxel CLI onboarding, profile setup, verifying login, listi Guide new users through Boxel CLI setup. ## Trigger + Run this automatically when: + - User first opens the repo - No profile is configured (`npx boxel profile` shows nothing) - User asks about setup or getting started @@ -16,6 +18,7 @@ Run this automatically when: ## Flow ### 1. Check Current State + ```bash npx boxel profile ``` @@ -25,11 +28,13 @@ If no profile exists, proceed with setup. ### 2. Add a Profile **Option A: Interactive (recommended)** + ```bash npx boxel profile add ``` This wizard will: + 1. Ask for environment (Production or Staging) 2. Ask for username and password 3. Create the profile automatically @@ -37,6 +42,7 @@ This wizard will: **Option B: Non-interactive (CI/automation)** Ask the user for: + - **Environment**: Production (app.boxel.ai) or Staging (realms-staging.stack.cards) - **Username**: Their Boxel handle (e.g., `aallen90`, `ctse`). Found in Account panel as `@username:stack.cards` or in workspace URLs like `app.boxel.ai/username/workspace-name` - **Password**: Same as Boxel web login @@ -44,11 +50,13 @@ Ask the user for: Then run (using environment variable for security): **Production:** + ```bash BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "Production" ``` **Staging:** + ```bash BOXEL_PASSWORD="password" npx boxel profile add -u @username:stack.cards -n "Staging" ``` @@ -56,12 +64,15 @@ BOXEL_PASSWORD="password" npx boxel profile add -u @username:stack.cards -n "Sta > **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history. ### 3. Verify + ```bash npx boxel list ``` ### 4. First Sync + Help them sync a workspace: + ```bash npx boxel sync @username/workspace ./workspace-name ``` @@ -69,21 +80,25 @@ npx boxel sync @username/workspace ./workspace-name ## Profile Management **List profiles:** + ```bash npx boxel profile list ``` **Switch profile:** + ```bash npx boxel profile switch ``` **Migrate from old .env:** + ```bash npx boxel profile migrate ``` ## Success Message + ``` Setup complete! You can now: - `npx boxel list` - See your workspaces diff --git a/packages/software-factory/.agents/skills/boxel-sync/SKILL.md b/packages/software-factory/.agents/skills/boxel-sync/SKILL.md index 9496478dab..218e041e29 100644 --- a/packages/software-factory/.agents/skills/boxel-sync/SKILL.md +++ b/packages/software-factory/.agents/skills/boxel-sync/SKILL.md @@ -12,22 +12,30 @@ Smart bidirectional sync with context-aware conflict resolution. Analyze the situation to choose the right sync strategy: ### After Local Edits + When Claude has been editing files locally: + - Use `--prefer-local` to push changes - Creates checkpoint for the push ### After Server Activity + When watch detected server changes or user mentions UI edits: + - Use `--prefer-remote` or default (interactive) - Pull changes first ### After Restore + When a restore was just performed: + - Use `--prefer-local` to sync deletions to server - Essential for completing the restore workflow ### Conflict Detected + When both sides have changes: + - Show status first - Ask user preference or use `--prefer-newest` diff --git a/packages/software-factory/.agents/skills/boxel-track/SKILL.md b/packages/software-factory/.agents/skills/boxel-track/SKILL.md index 965f26377f..7a58b50bd8 100644 --- a/packages/software-factory/.agents/skills/boxel-track/SKILL.md +++ b/packages/software-factory/.agents/skills/boxel-track/SKILL.md @@ -10,6 +10,7 @@ Start `boxel track` to monitor local file changes and create checkpoints automat ## When to Use Track Use **track** when you're editing files locally (in IDE, with AI agent, etc.) and want automatic backups: + - Working in VS Code, Cursor, or other IDE - AI agent is editing files - You want checkpoint history of your work @@ -45,6 +46,7 @@ boxel stop ## The Track → Sync Workflow ### Option 1: Manual Sync (Default) + Track creates local checkpoints only. Push to server when ready: ```bash @@ -56,11 +58,13 @@ boxel sync . --prefer-local ``` This lets you: + - Work offline with local backups - Batch multiple edits before pushing - Review changes before they go live ### Option 2: Real-Time Sync (--push) + Auto-push changes to server as you edit: ```bash @@ -75,25 +79,30 @@ Uses batch upload via `/_atomic` endpoint for efficient multi-file uploads. Defi When invoked, consider: ### Standard Development (3s debounce, 10s interval) + - Normal editing workflow - Balanced between checkpoint frequency and overhead ### Fast Iteration (2s debounce, 5s interval) + - Rapid prototyping - User says "track closely" or "capture everything" ### Background Tracking (5s debounce, 30s interval) + - Long editing sessions - User says "just backup" or "light tracking" ## Response Format When invoked: + 1. Confirm workspace directory 2. Start track with appropriate settings 3. **Remind user about sync options** Example (without --push): + ``` Starting track in the current workspace (3s debounce, 10s interval). Checkpoints will be created automatically as you save files. @@ -109,6 +118,7 @@ Use Ctrl+C to stop tracking, or `boxel stop` from another terminal. ``` Example (with --push): + ``` Starting track with auto-push (3s debounce, 10s interval). Changes will be checkpointed AND pushed to server automatically. diff --git a/packages/software-factory/.agents/skills/boxel-watch/SKILL.md b/packages/software-factory/.agents/skills/boxel-watch/SKILL.md index a733ef7eb5..10fe3624dc 100644 --- a/packages/software-factory/.agents/skills/boxel-watch/SKILL.md +++ b/packages/software-factory/.agents/skills/boxel-watch/SKILL.md @@ -12,21 +12,27 @@ Start `boxel watch` with intelligent interval settings based on context. Analyze the conversation and recent activity to determine the appropriate watch settings: ### Active Development Mode (5s interval, 3s debounce) + Use when: + - User is actively editing .gts or .json files - User mentions "editing", "working on", "changing", "updating" - Recent file writes or edits in the workspace - User asks to "watch while I work" ### Monitoring Mode (30s interval, 10s debounce) + Use when: + - User wants to "keep an eye on" changes - User is doing research, reading, or planning - No recent edits to workspace files - User says "background", "monitor", or "check occasionally" ### Quick Feedback Mode (10s interval, 5s debounce) + Use when: + - User is testing changes in Boxel UI - User mentions "testing", "trying", "see if it works" - Balanced between responsiveness and efficiency @@ -58,6 +64,7 @@ boxel watch . -i -d -q ## Response Format When invoked, respond with: + 1. Detected mode and reasoning (1 sentence) 2. The watch command being run 3. How to stop or adjust diff --git a/packages/software-factory/.claude/CLAUDE.md b/packages/software-factory/.claude/CLAUDE.md index 83247d1ca1..f5aeded35d 100644 --- a/packages/software-factory/.claude/CLAUDE.md +++ b/packages/software-factory/.claude/CLAUDE.md @@ -19,6 +19,7 @@ npx boxel profile add Or use `boxel` directly after `npm link`. **For development** (no rebuild needed after code changes): + ```bash npm run dev -- ``` @@ -39,6 +40,7 @@ All documentation below shows `boxel ` for brevity. - Asking to create, build, or design anything in Boxel **How to activate:** Read the skill file at the start of the task: + ``` Read .claude/skills/boxel-development/SKILL.md ``` @@ -54,6 +56,7 @@ The skill contains comprehensive Boxel development guidance including CardDef/Fi When you detect a new user (no profile configured), guide them through setup: ### Step 1: Check Profile + ```bash npx boxel profile ``` @@ -61,16 +64,19 @@ npx boxel profile If no profile exists, run the interactive setup: ### Step 2: Add a Profile + ```bash npx boxel profile add ``` This launches an interactive wizard that: + 1. Asks for environment (Production or Staging) 2. Asks for username and password 3. Creates the profile in `~/.boxel-cli/profiles.json` **Non-interactive option (CI/automation only):** + ```bash # Use environment variable to avoid exposing password in shell history BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "My Prod Account" @@ -79,17 +85,21 @@ BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "My Pro > **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history and process listings. Use the interactive wizard or `BOXEL_PASSWORD` environment variable. ### Step 3: Verify & List Workspaces + ```bash npx boxel list ``` ### Step 4: First Sync + Help them sync their first workspace: + ```bash npx boxel sync @username/workspace ./workspace-name ``` ### Switching Between Profiles + ```bash npx boxel profile list # See all profiles (★ = active) npx boxel profile switch username # Switch by partial match @@ -114,12 +124,14 @@ boxel-workspaces/ ``` **Benefits:** + - Clear separation between production and staging environments - Matches the `@username:domain` profile ID format - Easy to identify which profile/environment a workspace belongs to - Supports multiple users on the same machine **First-time sync to this structure:** + ```bash # Production workspace boxel pull https://app.boxel.ai/acme-corp/project-atlas/ ./boxel-workspaces/boxel.ai/acme-corp/project-atlas @@ -136,38 +148,49 @@ Shared repo-local skills live in `.agents/skills/`. `.claude/skills/` should be a symlink to that directory so Claude and Codex read the same files. ### `boxel-track` - Track Local Edits + Use this skill when starting `boxel track` for local file watching and checkpoints: + - Creates checkpoints as you save files in IDE - Use `--push` flag to automatically push changes to server (batch upload) - Without `--push`: Run `boxel sync . --prefer-local` to push to server ### `boxel-watch` - Smart Watch + Use this skill when starting `boxel watch` with context-aware timing: + - **Active development** (5s interval, 3s debounce): When editing files - **Monitoring** (30s interval, 10s debounce): Background observation - **Quick feedback** (10s interval, 5s debounce): Testing changes ### `boxel-restore` - Restore Checkpoint + Use this skill for the full restore workflow: + 1. Shows history 2. Restores to checkpoint (properly deletes newer files) 3. Syncs deletions to server with `--prefer-local` 4. Optionally restarts watch ### `boxel-sync` - Smart Sync + Use this skill for context-aware bidirectional sync: + - After local edits or track → `--prefer-local` - After server changes → `--prefer-remote` - After restore → `--prefer-local` (essential for syncing deletions) ### `boxel-repair` - Realm Metadata/Card Repair + Use when workspaces show missing icon/background, wrong display name, or fail to open due to broken `index.json`/`cards-grid.json` links. + - Read `.claude/skills/boxel-repair/SKILL.md` for the step-by-step repair flow. - `boxel repair-realm ` repairs one realm - `boxel repair-realms` repairs all owned realms (excluding `personal` by default) - Also reconciles Matrix account data (`app.boxel.realms`) unless disabled ### `software-factory-operations` - End-to-End Delivery Loop + Use this skill when the task is to break work into Boxel tickets, implement in an assigned realm, verify with Playwright, and keep knowledge plus progress checkpoints as durable factory memory. --- @@ -175,6 +198,7 @@ Use this skill when the task is to break work into Boxel tickets, implement in a ## Commands Reference ### Status & Checking + ```bash boxel status . # Check sync status boxel status --all # Check all workspaces @@ -184,11 +208,11 @@ boxel check ./file.json --sync # Check single file ### Pull, Push, Sync (Command Relationship) -| Command | Direction | Purpose | Deletes Local | Deletes Remote | -|---------|-----------|---------|---------------|----------------| -| `pull` | Remote → Local | Fresh download | with `--delete` | never | -| `push` | Local → Remote | Deploy changes | never | with `--delete` | -| `sync` | Both ways | Stay in sync | with `--prefer-remote` | with `--prefer-local` | +| Command | Direction | Purpose | Deletes Local | Deletes Remote | +| ------- | -------------- | -------------- | ---------------------- | --------------------- | +| `pull` | Remote → Local | Fresh download | with `--delete` | never | +| `push` | Local → Remote | Deploy changes | never | with `--delete` | +| `sync` | Both ways | Stay in sync | with `--prefer-remote` | with `--prefer-local` | ```bash boxel sync . # Interactive sync @@ -204,6 +228,7 @@ boxel pull ./local # One-way pull (remote → local) ``` **Failed download cleanup:** When `sync` encounters files that return 500 errors (broken/corrupted on server), it will prompt you to delete them: + ``` ⚠️ 3 file(s) failed to download (server error): - Staff/broken-card.json @@ -213,11 +238,13 @@ These files may be broken on the server. Delete them from remote? [y/N] ``` > **Safety tip:** Before any destructive operation, create a checkpoint with a descriptive message: +> > ```bash > boxel history . -m "Before cleanup: removing broken server files" > ``` ### Track ⇆ (Local File Watching) + ```bash boxel track . # Track local edits, auto-checkpoint as you save boxel track . --push # Track AND push changes to server (batch upload) @@ -231,6 +258,7 @@ boxel track . -v # Verbose mode (debug output) **With --push:** Real-time sync to server using batch upload via `/_atomic` endpoint. ### Watch ⇅ (Remote Server Watching) + ```bash boxel watch # Watch all configured realms (from .boxel-workspaces.json) boxel watch . # Watch single workspace @@ -243,6 +271,7 @@ boxel watch . -q # Quiet mode **Symbol:** ⇅ (vertical arrows = remote server changes) ### Stop + ```bash boxel stop # Stop all running watch (⇅) and track (⇆) processes ``` @@ -250,6 +279,7 @@ boxel stop # Stop all running watch (⇅) and track (⇆) **Multi-realm watching:** Useful when code lives in one realm and data in another. Each realm gets its own checkpoint tracking and debouncing. ### Realms (Multi-Realm Configuration) + ```bash boxel realms # List configured realms boxel realms --init # Create .boxel-workspaces.json @@ -263,6 +293,7 @@ boxel realms --remove ./path # Remove a realm **File placement guidance:** The `--llm` output tells Claude which realm to use for different file types and card types. ### History & Restore + ```bash boxel history . # View checkpoints boxel history . -r # Interactive restore @@ -272,6 +303,7 @@ boxel history . -m "Message" # Create checkpoint with custom message ``` ### Skills + ```bash boxel skills --refresh # Fetch skills from Boxel boxel skills --list # List all available skills @@ -281,6 +313,7 @@ boxel skills --export ./project # Export as Claude commands ``` ### Profile (Authentication) + ```bash boxel profile # Show current active profile boxel profile list # List all saved profiles (★ = active) @@ -292,12 +325,14 @@ boxel profile migrate # Migrate from old .env file ``` **Profile IDs:** Use Matrix format `@username:domain` + - Production: `@username:boxel.ai` - Staging: `@username:stack.cards` **Storage:** Profiles stored in `~/.boxel-cli/profiles.json` (permissions: 0600) ### Other + ```bash boxel list # List workspaces boxel create endpoint "Name" # Create workspace @@ -309,17 +344,20 @@ boxel push ./local # One-way push ``` ### Share & Gather (GitHub Workflow) + ```bash boxel share . -t /path/to/repo -b branch-name --no-pr # Share to GitHub repo boxel gather . -s /path/to/repo # Pull from GitHub repo ``` **Share** copies workspace state to a GitHub repo branch: + - Preserves repo-level files (package.json, LICENSE, README, etc.) - Skips realm-specific files (.realm.json, index.json, cards-grid.json) - Creates branch and commits changes **Gather** pulls changes from GitHub back to workspace: + - Symmetric to share - Preserves workspace's realm-specific files @@ -327,19 +365,25 @@ boxel gather . -s /path/to/repo # Pull from GitHub repo After share creates the branch locally, open GitHub Desktop and push. ### `/boxel-development` - Default Vibe Coding Skill + The **Boxel Development** skill is auto-enabled for vibe coding. It provides comprehensive guidance for: + - Card definitions (.gts files) - Card instances (.json files) - Boxel patterns and best practices ### `/boxel-file-structure` - File Organization Rules + Reference for local file organization: + - Directory naming: definitions (`kebab-case.gts`), instances (`PascalCase/`) - Module paths: relative to JSON location (`../card` from subdirectory) - JSON structure for card instances ### `boxel skills` - Manage Additional Skills + Fetch and manage AI instruction cards from Boxel: + ```bash boxel skills --refresh # Fetch latest from Boxel boxel skills --list # See available skills @@ -352,6 +396,7 @@ boxel skills --export . # Re-export to .agents/skills/ (shared with .claude ## Key Workflows ### Local Development with Track (IDE/Agent Editing) + ```bash boxel track . # Start tracking local edits (auto-checkpoints) # ... edit files in IDE or with Claude ... @@ -364,6 +409,7 @@ boxel sync . --prefer-local # Push your local changes to server **Remember:** Track does NOT sync to server automatically - it only creates local checkpoints. Always run `sync --prefer-local` when you want your changes live on the server. ### Real-Time Sync with Track --push + ```bash boxel track . --push # Track AND auto-push to server # ... edit files in IDE or with Claude ... @@ -373,6 +419,7 @@ boxel track . --push # Track AND auto-push to server **With --push:** Uses batch upload via `/_atomic` endpoint for efficient multi-file uploads. Definitions (.gts) are uploaded before instances (.json) to ensure proper indexing. ### Active Development Session (Watching Server) + ```bash boxel watch . -i 5 -d 3 # Active development settings # ... edit in Boxel UI or locally ... @@ -380,6 +427,7 @@ boxel sync . # Push/pull changes ``` ### Undo Server Changes (Restore) + ```bash boxel history . # Find checkpoint boxel history . -r 3 # Restore to #3 @@ -387,6 +435,7 @@ boxel sync . --prefer-local # ESSENTIAL: sync deletions to server ``` ### Share Milestone to GitHub + ```bash boxel share . -t /path/to/boxel-home -b boxel/feature-name --no-pr # Then push via GitHub Desktop @@ -395,6 +444,7 @@ boxel share . -t /path/to/boxel-home -b boxel/feature-name --no-pr **URL Portability:** Share automatically converts absolute realm URLs in `index.json` and `cards-grid.json` to relative URLs, making the content portable across different realms. ### Gather Updates from GitHub + ```bash boxel gather . -s /path/to/boxel-home boxel sync . --prefer-local # Push gathered changes to Boxel server @@ -403,11 +453,13 @@ boxel sync . --prefer-local # Push gathered changes to Boxel server **URL Portability:** Gather includes `index.json` and `cards-grid.json`, transforming any absolute URLs to relative paths for portability. Or simply: + ``` consult boxel-restore and restore checkpoint 3 ``` ### Monitor Server While Working + ```bash boxel watch . -i 30 -d 10 # Monitoring settings # Checkpoints created automatically @@ -415,6 +467,7 @@ boxel history . # View what changed ``` ### Multi-Realm Development + When working with multiple realms (e.g., code + data separation): ```bash @@ -430,6 +483,7 @@ boxel realms --llm ``` **File placement heuristics:** + - `.gts` files → realm with `*.gts` pattern (usually code realm) - Card instances → realm configured for that card type - Ambiguous → use the default realm @@ -439,7 +493,9 @@ boxel realms --llm ## Critical Patterns ### ⚠️ SAFETY FIRST: Checkpoint Before Destructive Operations + **Always create a checkpoint with a descriptive message before:** + - Deleting files from server (`--prefer-local`, `push --delete`) - Restoring to an earlier checkpoint - Bulk cleanup operations @@ -454,13 +510,15 @@ boxel sync . --prefer-local This ensures you can always recover if something goes wrong. The checkpoint message helps identify what state to restore to. ### 0. ALWAYS Write Source Code, Never Compiled Output + When editing `.gts` files, **always write clean idiomatic source code**: + ```gts // CORRECT - Clean source export class MyCard extends CardDef { static fitted = class Fitted extends Component { }; -KnowledgeArticle.fitted = class Fitted extends Component { +KnowledgeArticle.fitted = class Fitted extends Component< + typeof KnowledgeArticle +> {