diff --git a/apps/desktop/E2E_CANARY.md b/apps/desktop/E2E_CANARY.md new file mode 100644 index 00000000..8fa86b99 --- /dev/null +++ b/apps/desktop/E2E_CANARY.md @@ -0,0 +1,167 @@ +# Luna Live E2E Canary + +## Purpose + +The Live E2E Canary proves that Luna can take one real product from Organization Intake through team allocation, PM planning, independent Agent work, marketing/documentation governance, PR integration, retrospective, and Team Evolution. + +This is intentionally different from CI. A green TypeScript build or Rust `cargo check` proves that Luna compiles. The Canary proves that the local orchestration can actually use the Product Owner's ChatGPT-authenticated Codex session and local GitHub authentication to create and integrate a real BloomBouquet project. + +## Canary product + +The fixed product is **PulseNote**, a deliberately small full-stack notes application: + +- React web UI +- backend HTTP API +- SQLite persistence +- create/edit/delete/list notes +- required title, optional body, tags, timestamps +- loading, empty, validation, success, and recoverable error states +- health endpoint and backend input validation +- frontend/backend automated tests +- reproducible local setup/run path +- basic keyboard/accessibility semantics +- no auth, payments, realtime, third-party API, or external production secret + +The scope is narrow on purpose. The test is Luna's project organization, not the novelty of the sample app. + +Every run uses an isolated repository. Given Luna Project ID `PROJECT-ABC-123`, the PM must choose exactly: + +```text +pulsenote-canary-project-abc-123 +``` + +The Project ID makes the repository deterministic and unique per run. Reusing a prior `pulsenote` or prior Canary repository can leave stale branches, commits, PRs, documentation, or test artifacts and therefore does not qualify as a valid fresh E2E run. Luna validates the planned repository name before Agent execution continues. + +## Actual flow + +```text +Live E2E Canary + ↓ +Runtime preflight + ↓ +Organization Project Intake + ↓ +equal-team allocation + ↓ +team PM Codex + ↓ +fresh BloomBouquet repository bootstrap + ↓ +Frontend + Backend Agent work + ↓ +independent branches / worktrees / commits / PRs + ↓ +Data & Marketing + ↓ +Documentation + ↓ +Code Review + ↓ +Reviewer + ↓ +QA + ↓ +develop PR integration + ↓ +participating Agent retrospectives + ↓ +Team Evolution + ↓ +project completed / team idle +``` + +The existing Project Teams runtime performs the work. The Canary does not use a mock Agent runtime or a fake GitHub repository. + +## Starting the run + +1. Open Luna → Tools → **Live E2E Canary**. +2. Ensure Project Teams Runtime has a valid Workspace root. +3. Press **실제 E2E Canary 시작**. +4. Luna checks Git, GitHub CLI/authentication, ChatGPT Codex authentication, and BloomBouquet access. Any missing prerequisite blocks the run before Intake. +5. Organization Intake runs and a real idle team is allocated. +6. Luna moves to Project Teams with the Canary project selected. +7. Press **PM Runtime 실행** for the newly queued project. +8. PM must return the exact isolated Canary repository name and include real Frontend + Backend implementation work. Luna's normal marketing policy appends the Data & Marketing → Documentation → Code Review → Reviewer → QA governance chain. +9. The existing PM → Agent queue → integration → retrospective flow continues automatically until completion or an explicit blocker. +10. Return to Tools → Live E2E Canary and press **E2E 상태 새로고침** to inspect the final report. + +The second explicit PM click exists because a project arriving from another Luna tool is intentionally queued before repository mutation. It preserves the same boundary used by Market Discovery handoff rather than silently creating a repository from a navigation action. + +## Pass criteria + +The Canary has 12 evidence stages. **Every stage must be PASS**: + +1. Organization Intake +2. team allocation +3. PM / repository bootstrap +4. Frontend + Backend development +5. Data & Marketing +6. Documentation +7. Code Review +8. Reviewer +9. QA +10. develop integration +11. Agent retrospective +12. Team Evolution + +A Canary is PASS only when the stored project is `completed`, all required stages are evidence-backed, the repository name matches the Project-ID-derived isolated name, and no required-plan blocker remains. + +The report also records: + +- actual BloomBouquet repository name +- actual Agent PR numbers +- actual commit SHAs +- failure-route count +- PM replan count +- current blocker details + +## What does not count as a pass + +The following do **not** independently prove E2E success: + +- `pnpm build` +- policy tests +- `cargo check` +- Harness workflow success +- a PM plan without Agent execution +- Agent text claiming that a test passed +- local files without pushed branches/PR evidence +- reuse of a previous Canary repository +- PRs that never pass the review/QA integration gate +- completed application code without Data & Marketing / Documentation governance +- merged PRs without retrospective and Team Evolution completion + +## Environment prerequisites + +The live run requires the same runtime prerequisites as normal Luna project work: + +- Codex CLI installed +- `codex login status` reports ChatGPT authentication +- `gh` installed and authenticated for the target BloomBouquet organization +- access to create/use the intended project repository +- a writable Luna Workspace root +- Git available +- network access required by GitHub/package tooling + +Missing prerequisites are blockers. They must be surfaced; they are not replaced with fake success. + +## Failure policy + +Canary failures use the normal production failure path: + +```text +Agent failure + ↓ +Debug / Problem Router + ├─ retry-owner + ├─ escalate-pm + └─ needs-human +``` + +A recovered Canary may still pass. The final report preserves failure-route and replan counts so recovery is visible rather than erased. + +## Why the Canary is versioned + +The request includes `[LUNA-E2E-CANARY:v1]`. The marker lets Luna distinguish this controlled product from normal user projects and evolve the test deliberately when the orchestration contract changes. + +Changing the Canary product or required stages should increment the marker version and update the policy test so historical runs are not silently reinterpreted under different pass criteria. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f59da3a8..b4c091b4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "test:allocation": "node -e \"require('fs').rmSync('../../.tmp/luna-policy-tests',{recursive:true,force:true})\" && tsc -p tsconfig.policy-tests.json && node ../../.tmp/luna-policy-tests/teamAllocation.policy-test.js && node ../../.tmp/luna-policy-tests/intakeClarification.policy-test.js && node ../../.tmp/luna-policy-tests/dataMarketing.policy-test.js && node ../../.tmp/luna-policy-tests/seniorAgent.policy-test.js", + "test:allocation": "node -e \"require('fs').rmSync('../../.tmp/luna-policy-tests',{recursive:true,force:true})\" && tsc -p tsconfig.policy-tests.json && node ../../.tmp/luna-policy-tests/teamAllocation.policy-test.js && node ../../.tmp/luna-policy-tests/intakeClarification.policy-test.js && node ../../.tmp/luna-policy-tests/dataMarketing.policy-test.js && node ../../.tmp/luna-policy-tests/seniorAgent.policy-test.js && node ../../.tmp/luna-policy-tests/e2eCanary.policy-test.js", "preview": "vite preview", "tauri": "tauri" }, diff --git a/apps/desktop/src/E2ECanaryPanel.css b/apps/desktop/src/E2ECanaryPanel.css new file mode 100644 index 00000000..12f0f11e --- /dev/null +++ b/apps/desktop/src/E2ECanaryPanel.css @@ -0,0 +1,121 @@ +.e2e-canary-panel { + display: grid; + gap: 12px; +} + +.e2e-canary-panel > p { + margin: 0; + color: var(--text-muted, #6d6d6d); + font-size: 12px; + line-height: 1.55; +} + +.e2e-canary-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.e2e-canary-heading h3 { + margin: 3px 0 0; +} + +.e2e-canary-heading > span { + border: 1px solid var(--border-soft, #d7d7d7); + border-radius: 999px; + padding: 3px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.e2e-canary-heading > span.passed { + border-color: #73a47a; +} + +.e2e-canary-heading > span.blocked { + border-color: #b77a72; +} + +.e2e-canary-meta, +.e2e-canary-evidence, +.e2e-canary-actions { + display: flex; + gap: 7px; + flex-wrap: wrap; +} + +.e2e-canary-meta span, +.e2e-canary-evidence span { + border: 1px solid var(--border-soft, #dedede); + border-radius: 7px; + padding: 4px 7px; + font-size: 10px; + color: var(--text-muted, #666); +} + +.e2e-canary-stages { + display: grid; + gap: 5px; +} + +.e2e-canary-stage { + display: grid; + grid-template-columns: 42px 1fr; + gap: 8px; + align-items: start; + border-top: 1px solid var(--border-soft, #ececec); + padding-top: 7px; +} + +.e2e-canary-stage > span { + font-size: 9px; + font-weight: 800; + letter-spacing: 0.05em; + color: var(--text-muted, #777); +} + +.e2e-canary-stage[data-status="passed"] > span { + color: #557f5b; +} + +.e2e-canary-stage[data-status="blocked"] > span { + color: #9a5f58; +} + +.e2e-canary-stage[data-status="running"] > span { + color: var(--text-strong, #252525); +} + +.e2e-canary-stage div { + display: grid; + gap: 2px; +} + +.e2e-canary-stage strong { + font-size: 11px; +} + +.e2e-canary-stage small { + font-size: 10px; + line-height: 1.45; + color: var(--text-muted, #777); +} + +.e2e-canary-blockers { + display: grid; + gap: 4px; + border-top: 1px solid var(--border-soft, #e5e5e5); + padding-top: 9px; +} + +.e2e-canary-blockers strong { + font-size: 11px; +} + +.e2e-canary-blockers span { + font-size: 10px; + line-height: 1.45; + color: var(--text-muted, #6c6c6c); +} diff --git a/apps/desktop/src/components/E2ECanaryPanel.tsx b/apps/desktop/src/components/E2ECanaryPanel.tsx new file mode 100644 index 00000000..76dcc2db --- /dev/null +++ b/apps/desktop/src/components/E2ECanaryPanel.tsx @@ -0,0 +1,117 @@ +import "../E2ECanaryPanel.css"; + +import { + E2E_CANARY_REQUEST, + evaluateE2ECanaryProject, + findLatestE2ECanaryProject, +} from "../projectTeams/e2eCanary"; +import type { ProjectTeamsState } from "../projectTeams/types"; + +type E2ECanaryPanelProps = { + state: ProjectTeamsState; + busy: boolean; + onStart: (request: string) => Promise; + onSelectProject: (projectId: string) => void; +}; + +function statusLabel(status: ReturnType["stages"][number]["status"]) { + switch (status) { + case "passed": + return "PASS"; + case "running": + return "RUN"; + case "blocked": + return "BLOCK"; + case "pending": + default: + return "WAIT"; + } +} + +export function E2ECanaryPanel({ state, busy, onStart, onSelectProject }: E2ECanaryPanelProps) { + const project = findLatestE2ECanaryProject(state); + const report = project ? evaluateE2ECanaryProject(project) : null; + + return ( +
+
+
+ LIVE E2E +

PulseNote Canary

+
+ + {report?.passed ? "PASS" : report?.blockers.length ? "BLOCKED" : report ? "ACTIVE" : "READY"} + +
+ +

+ 실제 `/start` 경로로 작은 full-stack 제품을 생성해 Intake부터 Team Evolution까지 끊김 없이 통과하는지 증명합니다. +

+ + {!report ? ( + + ) : ( + <> +
+ {report.projectName} + {report.repositoryFullName ?? "repository 생성 전"} + {report.teamId} · {report.status} +
+ +
+ {report.stages.map((stage) => ( +
+ {statusLabel(stage.status)} +
+ {stage.label} + {stage.evidence} +
+
+ ))} +
+ +
+ PR {report.pullRequests.length} + Commit {report.commitShas.length} + Failure route {report.failureRouteCount} + Replan {report.replanCount} +
+ + {report.blockers.length > 0 && ( +
+ 현재 blocker + {report.blockers.map((blocker) => {blocker})} +
+ )} + +
+ + {report.passed && ( + + )} +
+ + )} +
+ ); +} diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 10b5ca9d..8ff442c3 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ export type LunaPage = | "tools" | "project-teams" | "market-discovery" + | "e2e-canary" | "inventory" | "shop" | "settings"; diff --git a/apps/desktop/src/pages/E2ECanaryPage.tsx b/apps/desktop/src/pages/E2ECanaryPage.tsx new file mode 100644 index 00000000..62a20da2 --- /dev/null +++ b/apps/desktop/src/pages/E2ECanaryPage.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; + +import { E2ECanaryPanel } from "../components/E2ECanaryPanel"; +import type { LunaPage } from "../components/Sidebar"; +import { analyzeProjectIntake } from "../projectTeams/intakeRuntime"; +import { loadOrganizationRuntimeSettings } from "../projectTeams/organization"; +import { startProjectWithIntake } from "../projectTeams/projectIntakeState"; +import { checkProjectRuntime } from "../projectTeams/runtime"; +import { getTeamName, loadProjectTeamsState } from "../projectTeams/store"; +import type { ProjectTeamsState } from "../projectTeams/types"; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function preflightBlockers(preflight: Awaited>) { + const blockers: string[] = []; + if (!preflight.gitAvailable) blockers.push("git 없음"); + if (!preflight.ghAvailable) blockers.push("gh 없음"); + if (!preflight.ghAuthenticated) blockers.push("gh 로그인 필요"); + if (!preflight.codexAvailable) blockers.push("Codex CLI 없음"); + if (!preflight.codexChatgptAuth) blockers.push("Codex ChatGPT 로그인 필요"); + if (!preflight.organizationAccessible) blockers.push(`${preflight.organization} 접근 불가`); + return blockers; +} + +export function E2ECanaryPage({ onChangePage }: { onChangePage: (page: LunaPage) => void }) { + const [state, setState] = useState(() => loadProjectTeamsState()); + const [running, setRunning] = useState(false); + const [message, setMessage] = useState( + "PulseNote Canary는 실제 Organization Intake와 팀 배정을 만든 뒤 Project Teams의 기존 PM/Agent Runtime으로 이어집니다.", + ); + + const startCanary = async (request: string) => { + if (running) return; + const runtimeSettings = loadOrganizationRuntimeSettings(); + if (!runtimeSettings.workspaceRoot.trim()) { + setMessage("Project Teams Runtime에서 Workspace root를 먼저 설정해 주세요."); + return; + } + + setRunning(true); + setMessage("E2E Canary preflight 실행 중 · git / gh / Codex / BloomBouquet 접근을 실제 확인합니다."); + + try { + const preflight = await checkProjectRuntime(runtimeSettings.organization); + const blockers = preflightBlockers(preflight); + if (blockers.length > 0) { + setMessage(`E2E Canary 시작 차단 · ${blockers.join(" · ")} · ${preflight.message}`); + return; + } + + setMessage("E2E Canary preflight PASS · Organization Intake 실행 중 · 실제 요구사항 분석 후 대기 팀을 배정합니다."); + const intake = await analyzeProjectIntake({ + organization: runtimeSettings.organization, + workspaceRoot: runtimeSettings.workspaceRoot, + request, + }); + const currentState = loadProjectTeamsState(); + const result = startProjectWithIntake(currentState, request, intake); + setState(result.state); + if (!result.ok) { + setMessage(result.message); + return; + } + + const teamName = getTeamName(result.state, result.project.teamId); + setMessage( + `E2E Canary 생성 완료 · ${teamName}팀 배정 · Project Teams에서 PM Runtime 실행을 누르면 PM → Agent → PR → 회고/Evolution 전체 체인이 이어집니다.`, + ); + onChangePage("project-teams"); + } catch (error) { + setMessage(`E2E Canary 시작 실패: ${errorMessage(error)}`); + } finally { + setRunning(false); + } + }; + + const refresh = () => { + setState(loadProjectTeamsState()); + setMessage("최신 Project Teams 상태에서 E2E evidence를 다시 계산했습니다."); + }; + + return ( +
+
+
+ LIVE E2E CANARY +

Luna 전체 흐름 증명

+

작은 실제 full-stack 제품 하나로 Intake부터 Team Evolution까지 같은 production Runtime을 끝까지 통과시키고 단계별 evidence를 확인합니다.

+
+
+ +
+ {running ? "Canary Runtime 확인 중" : "실제 Runtime 사용"} + mock orchestration이 아니라 기존 ChatGPT Codex + gh + BloomBouquet 경로를 사용 +
+
+
+ +
{message}
+ +
+
+ onChangePage("project-teams")} + /> +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/pages/ToolsPage.tsx b/apps/desktop/src/pages/ToolsPage.tsx index bab09404..92ff9372 100644 --- a/apps/desktop/src/pages/ToolsPage.tsx +++ b/apps/desktop/src/pages/ToolsPage.tsx @@ -47,6 +47,17 @@ export function ToolsPage({ onChangePage }: ToolsPageProps) { + +