diff --git a/.env.example b/.env.example index d4b13b5..e20d2c8 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ NODE_ENV=development WEB_PORT=3000 API_PORT=3001 WORKER_PORT=3002 +API_HOST=127.0.0.1 + +MONGO_URI=mongodb://127.0.0.1:27017 MONGO_PORT=27017 MONGO_DB=emboflow @@ -15,3 +18,6 @@ MINIO_ROOT_USER=emboflow MINIO_ROOT_PASSWORD=emboflow123 STORAGE_PROVIDER=minio +CORS_ORIGIN=http://127.0.0.1:3000 +VITE_API_BASE_URL=http://127.0.0.1:3001 +LOCAL_SAMPLE_DATA_PATH=/Users/longtaowu/workspace/emboldata/data diff --git a/.gitignore b/.gitignore index 2752eb9..5a670e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ .DS_Store +dist/ diff --git a/Makefile b/Makefile index 4eb4930..201058b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /bin/bash -.PHONY: bootstrap test dev-api dev-web dev-worker guardrails +.PHONY: bootstrap test dev-api dev-web dev-worker serve-api serve-web infra-up infra-down guardrails bootstrap: pnpm install @@ -10,6 +10,7 @@ test: python3 -m unittest discover -s tests -p 'test_*.py' pnpm --filter api test pnpm --filter web test src/features/assets/assets-page.test.tsx src/features/workflows/workflow-editor-page.test.tsx src/features/explore/explore-page.test.tsx + pnpm --filter web build pnpm --filter worker test dev-api: @@ -21,6 +22,24 @@ dev-web: dev-worker: pnpm --filter worker dev +serve-api: + MONGO_URI="$${MONGO_URI:-mongodb://127.0.0.1:27017}" \ + MONGO_DB="$${MONGO_DB:-emboflow}" \ + API_HOST="$${API_HOST:-127.0.0.1}" \ + API_PORT="$${API_PORT:-3001}" \ + CORS_ORIGIN="$${CORS_ORIGIN:-http://127.0.0.1:3000}" \ + pnpm --filter api start + +serve-web: + VITE_API_BASE_URL="$${VITE_API_BASE_URL:-http://127.0.0.1:3001}" \ + pnpm --filter web start -- --host 127.0.0.1 --port 3000 + +infra-up: + docker compose up -d mongo minio + +infra-down: + docker compose down + guardrails: python3 scripts/check_doc_code_sync.py . --strict python3 scripts/check_commit_message.py --rev-range HEAD diff --git a/README.md b/README.md index 36bea70..09f6aaa 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,40 @@ make dev-web make dev-worker ``` +## Local Deployment + +Start MongoDB and MinIO: + +```bash +make infra-up +``` + +Start the API and web app in separate terminals: + +```bash +make serve-api +make serve-web +``` + +The default local stack uses: + +- API: `http://127.0.0.1:3001` +- Web: `http://127.0.0.1:3000` + +### Local Data Validation + +The local validation path currently used for embodied data testing is: + +```text +/Users/longtaowu/workspace/emboldata/data +``` + +You can register that directory from the Assets page or via `POST /api/assets/register`. + ## Repository Structure - `apps/api` contains the control-plane modules for workspaces, assets, workflows, runs, and artifacts. -- `apps/web` contains the initial shell, asset workspace, workflow editor surface, run detail view, and explore renderers. +- `apps/web` contains the React shell, asset workspace, workflow editor surface, run detail view, and explore renderers. - `apps/worker` contains the local scheduler, task runner, and executor contracts. - `design/` contains the architecture and product design documents that must stay aligned with implementation. - `docs/` contains workflow guidance and the executable implementation plan. diff --git a/apps/api/package.json b/apps/api/package.json index 500e6d5..343279d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,12 @@ "type": "module", "scripts": { "dev": "tsx watch src/main.ts", + "start": "tsx src/main.ts", "test": "node --test --experimental-strip-types" + }, + "dependencies": { + "cors": "^2.8.6", + "express": "^5.2.1", + "mongodb": "^7.1.1" } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 48f68b2..65dfa5d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -6,6 +6,7 @@ import { createRunsModule } from "./modules/runs/runs.module.ts"; import { createStorageModule } from "./modules/storage/storage.module.ts"; import { createWorkflowsModule } from "./modules/workflows/workflows.module.ts"; import { createWorkspaceModule } from "./modules/workspaces/workspaces.module.ts"; +import { startApiServer } from "./runtime/server.ts"; export function createApiApp() { const auth = createAuthModule(); @@ -31,5 +32,6 @@ export function createApiApp() { } if (import.meta.url === `file://${process.argv[1]}`) { + await startApiServer(); process.stdout.write(JSON.stringify({ status: createApiApp().status }, null, 2)); } diff --git a/apps/api/src/runtime/local-source-probe.ts b/apps/api/src/runtime/local-source-probe.ts new file mode 100644 index 0000000..ff7eb6c --- /dev/null +++ b/apps/api/src/runtime/local-source-probe.ts @@ -0,0 +1,224 @@ +import path from "node:path"; +import { readdir, stat } from "node:fs/promises"; + +import type { AssetType } from "../../../../packages/contracts/src/domain.ts"; + +export type LocalAssetShape = { + type: AssetType; + sourceType: "local_path"; + displayName: string; +}; + +export type LocalProbeSummary = { + pathExists: boolean; + kind: "file" | "directory" | "missing"; + detectedFormats: string[]; + warnings: string[]; + recommendedNextNodes: string[]; + topLevelPaths: string[]; + fileCount: number; + structureSummary: Record; +}; + +const ARCHIVE_EXTENSIONS = new Set([".zip", ".tar", ".tgz", ".gz", ".zst"]); +const ROSBAG_EXTENSIONS = new Set([".bag", ".mcap"]); +const HDF5_EXTENSIONS = new Set([".h5", ".hdf5"]); + +export function detectAssetShapeFromLocalPath(sourcePath: string): LocalAssetShape { + const displayName = path.basename(sourcePath); + const extension = path.extname(displayName).toLowerCase(); + + if (ARCHIVE_EXTENSIONS.has(extension)) { + return { type: "archive", sourceType: "local_path", displayName }; + } + if (ROSBAG_EXTENSIONS.has(extension)) { + return { type: "rosbag", sourceType: "local_path", displayName }; + } + if (HDF5_EXTENSIONS.has(extension)) { + return { type: "hdf5_dataset", sourceType: "local_path", displayName }; + } + + return { type: "folder", sourceType: "local_path", displayName }; +} + +export async function probeLocalSourcePath(input: { + sourcePath: string; + assetType: AssetType; + displayName: string; +}): Promise { + try { + const info = await stat(input.sourcePath); + if (info.isDirectory()) { + const entries = await readdir(input.sourcePath, { withFileTypes: true }); + const topLevelPaths = entries.slice(0, 200).map((entry) => entry.name).sort(); + const detectedFormats = detectFormatsFromDirectory({ + displayName: input.displayName, + topLevelPaths, + entries, + assetType: input.assetType, + }); + const warnings = buildWarnings(topLevelPaths, detectedFormats); + return { + pathExists: true, + kind: "directory", + detectedFormats, + warnings, + recommendedNextNodes: recommendNextNodes(detectedFormats), + topLevelPaths, + fileCount: entries.length, + structureSummary: { + entryCount: entries.length, + hasArchives: topLevelPaths.some((item) => + ARCHIVE_EXTENSIONS.has(path.extname(item).toLowerCase()), + ), + hasRosbag: topLevelPaths.some((item) => + ROSBAG_EXTENSIONS.has(path.extname(item).toLowerCase()), + ), + }, + }; + } + + const detectedFormats = detectFormatsFromFile(input.displayName, input.assetType); + return { + pathExists: true, + kind: "file", + detectedFormats, + warnings: [], + recommendedNextNodes: recommendNextNodes(detectedFormats), + topLevelPaths: [path.basename(input.sourcePath)], + fileCount: 1, + structureSummary: { + extension: path.extname(input.displayName).toLowerCase(), + }, + }; + } catch (error) { + return { + pathExists: false, + kind: "missing", + detectedFormats: [input.assetType], + warnings: [ + error instanceof Error ? error.message : "local path is not accessible", + ], + recommendedNextNodes: ["inspect_asset"], + topLevelPaths: [], + fileCount: 0, + structureSummary: {}, + }; + } +} + +function detectFormatsFromFile( + displayName: string, + assetType: AssetType, +): string[] { + const extension = path.extname(displayName).toLowerCase(); + if (ARCHIVE_EXTENSIONS.has(extension)) { + return ["compressed_archive"]; + } + if (ROSBAG_EXTENSIONS.has(extension)) { + return ["rosbag"]; + } + if (HDF5_EXTENSIONS.has(extension)) { + return ["hdf5_dataset"]; + } + return [assetType]; +} + +function detectFormatsFromDirectory(input: { + displayName: string; + topLevelPaths: string[]; + entries: { name: string; isDirectory(): boolean }[]; + assetType: AssetType; +}): string[] { + const detected = new Set([input.assetType]); + const topLevel = new Set(input.topLevelPaths); + + if ( + topLevel.has("meta.json") || + topLevel.has("intrinsics.json") || + topLevel.has("video_meta.json") || + input.displayName.startsWith("BJ_") + ) { + detected.add("delivery_package"); + } + + if ( + topLevel.has("meta") || + topLevel.has("episodes") || + input.topLevelPaths.some((item) => item.endsWith(".parquet")) + ) { + detected.add("lerobot_dataset"); + } + + if ( + topLevel.has("data") || + topLevel.has("task.json") || + input.topLevelPaths.some((item) => item.endsWith(".tfrecord")) + ) { + detected.add("rlds_dataset"); + } + + if ( + input.topLevelPaths.some((item) => + ARCHIVE_EXTENSIONS.has(path.extname(item).toLowerCase()), + ) + ) { + detected.add("archive_collection"); + } + + if ( + input.topLevelPaths.some((item) => + ROSBAG_EXTENSIONS.has(path.extname(item).toLowerCase()), + ) + ) { + detected.add("rosbag"); + } + + if ( + input.topLevelPaths.some((item) => + HDF5_EXTENSIONS.has(path.extname(item).toLowerCase()), + ) + ) { + detected.add("hdf5_dataset"); + } + + if (input.entries.some((entry) => entry.isDirectory() && entry.name.startsWith("DJI_"))) { + detected.add("video_collection"); + } + + return Array.from(detected); +} + +function buildWarnings(topLevelPaths: string[], detectedFormats: string[]): string[] { + const warnings: string[] = []; + if (detectedFormats.includes("delivery_package")) { + for (const required of ["meta.json", "intrinsics.json", "video_meta.json"]) { + if (!topLevelPaths.includes(required)) { + warnings.push(`${required} missing`); + } + } + } + return warnings; +} + +function recommendNextNodes(detectedFormats: string[]): string[] { + if (detectedFormats.includes("compressed_archive")) { + return ["extract_archive", "validate_structure"]; + } + if (detectedFormats.includes("delivery_package")) { + return ["validate_structure", "validate_metadata"]; + } + if (detectedFormats.includes("rosbag")) { + return ["rosbag_reader", "canonical_mapper"]; + } + if (detectedFormats.includes("hdf5_dataset")) { + return ["hdf5_reader", "canonical_mapper"]; + } + if (detectedFormats.includes("lerobot_dataset")) { + return ["lerobot_reader", "canonical_mapper"]; + } + if (detectedFormats.includes("rlds_dataset")) { + return ["rlds_reader", "canonical_mapper"]; + } + return ["inspect_asset"]; +} diff --git a/apps/api/src/runtime/mongo-store.ts b/apps/api/src/runtime/mongo-store.ts new file mode 100644 index 0000000..3ae1cc4 --- /dev/null +++ b/apps/api/src/runtime/mongo-store.ts @@ -0,0 +1,521 @@ +import { randomUUID } from "node:crypto"; + +import type { Db, Document, WithId } from "mongodb"; + +import type { AssetType } from "../../../../packages/contracts/src/domain.ts"; +import { DELIVERY_NODE_DEFINITIONS } from "../modules/plugins/builtin/delivery-nodes.ts"; +import { probeLocalSourcePath } from "./local-source-probe.ts"; + +type Timestamped = { + createdAt: string; + updatedAt?: string; +}; + +type WorkspaceDocument = Timestamped & { + _id: string; + type: "personal" | "team"; + name: string; + slug: string; + ownerId: string; + status: "active"; +}; + +type ProjectDocument = Timestamped & { + _id: string; + workspaceId: string; + name: string; + slug: string; + description: string; + status: "active"; + createdBy: string; +}; + +type AssetDocument = Timestamped & { + _id: string; + workspaceId: string; + projectId: string; + type: AssetType; + sourceType: string; + displayName: string; + sourcePath?: string; + status: "registered" | "probed"; + storageRef: Record; + topLevelPaths: string[]; + detectedFormats: string[]; + fileCount: number; + summary: Record; + createdBy: string; +}; + +type AssetProbeReportDocument = { + _id: string; + assetId: string; + reportVersion: number; + detectedFormatCandidates: string[]; + structureSummary: Record; + warnings: string[]; + recommendedNextNodes: string[]; + rawReport: Record; + createdAt: string; +}; + +type WorkflowDefinitionDocument = Timestamped & { + _id: string; + workspaceId: string; + projectId: string; + name: string; + slug: string; + status: "draft"; + latestVersionNumber: number; + publishedVersionNumber: number | null; + createdBy: string; +}; + +type WorkflowDefinitionVersionDocument = Timestamped & { + _id: string; + workflowDefinitionId: string; + workspaceId: string; + projectId: string; + versionNumber: number; + visualGraph: Record; + logicGraph: { + nodes: Array<{ id: string; type: string }>; + edges: Array<{ from: string; to: string }>; + }; + runtimeGraph: Record; + pluginRefs: string[]; + createdBy: string; +}; + +type WorkflowRunDocument = Timestamped & { + _id: string; + workflowDefinitionId: string; + workflowVersionId: string; + status: "queued"; + triggeredBy: string; +}; + +type RunTaskDocument = Timestamped & { + _id: string; + workflowRunId: string; + workflowVersionId: string; + nodeId: string; + nodeType: string; + status: "queued" | "pending"; + attempt: number; +}; + +type ArtifactDocument = Timestamped & { + _id: string; + type: "json" | "directory" | "video"; + title: string; + producerType: string; + producerId: string; + payload: Record; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function mapDoc(document: WithId): T { + return document as unknown as T; +} + +export class MongoAppStore { + constructor(private readonly db: Db) {} + + async bootstrapDevContext(input: { + userId?: string; + workspaceName?: string; + projectName?: string; + }) { + const userId = input.userId ?? "local-user"; + const workspaceCollection = this.db.collection("workspaces"); + const workspaceUpsert = await workspaceCollection.findOneAndUpdate( + { + ownerId: userId, + type: "personal", + }, + { + $setOnInsert: { + _id: `workspace-${randomUUID()}`, + type: "personal", + name: input.workspaceName ?? "Local Personal Workspace", + slug: `${slugify(userId)}-personal`, + ownerId: userId, + status: "active", + createdAt: nowIso(), + updatedAt: nowIso(), + }, + }, + { + upsert: true, + returnDocument: "after", + }, + ); + const workspace = workspaceUpsert as WorkspaceDocument | null; + if (!workspace) { + throw new Error("failed to bootstrap workspace"); + } + + const projectSlug = slugify(input.projectName ?? "Local Demo Project"); + const projectCollection = this.db.collection("projects"); + const projectUpsert = await projectCollection.findOneAndUpdate( + { + workspaceId: workspace._id, + slug: projectSlug, + }, + { + $setOnInsert: { + _id: `project-${randomUUID()}`, + workspaceId: workspace._id, + name: input.projectName ?? "Local Demo Project", + slug: projectSlug, + description: "Local development project", + status: "active", + createdBy: userId, + createdAt: nowIso(), + updatedAt: nowIso(), + }, + }, + { + upsert: true, + returnDocument: "after", + }, + ); + const project = projectUpsert as ProjectDocument | null; + if (!project) { + throw new Error("failed to bootstrap project"); + } + + return { + userId, + workspace: mapDoc(workspace as WithId), + project: mapDoc(project as WithId), + }; + } + + async listWorkspaces(ownerId: string) { + return this.db + .collection("workspaces") + .find({ ownerId }) + .sort({ createdAt: 1 }) + .toArray(); + } + + async createProject(input: { + workspaceId: string; + name: string; + description?: string; + createdBy: string; + }) { + const project: ProjectDocument = { + _id: `project-${randomUUID()}`, + workspaceId: input.workspaceId, + name: input.name, + slug: slugify(input.name), + description: input.description ?? "", + status: "active", + createdBy: input.createdBy, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await this.db.collection("projects").insertOne(project); + return project; + } + + async listProjects(workspaceId: string) { + return this.db + .collection("projects") + .find({ workspaceId }) + .sort({ createdAt: 1 }) + .toArray(); + } + + async registerAsset(input: { + workspaceId: string; + projectId: string; + type: AssetType; + sourceType: string; + displayName: string; + sourcePath?: string; + createdBy: string; + }) { + const asset: AssetDocument = { + _id: `asset-${randomUUID()}`, + workspaceId: input.workspaceId, + projectId: input.projectId, + type: input.type, + sourceType: input.sourceType, + displayName: input.displayName, + sourcePath: input.sourcePath, + status: "registered", + storageRef: input.sourcePath + ? { provider: "local_path", path: input.sourcePath } + : { provider: "upload_placeholder" }, + topLevelPaths: [], + detectedFormats: [], + fileCount: 0, + summary: {}, + createdBy: input.createdBy, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await this.db.collection("assets").insertOne(asset); + return asset; + } + + async listAssets(projectId: string) { + return this.db + .collection("assets") + .find({ projectId }) + .sort({ createdAt: -1 }) + .toArray(); + } + + async getAsset(assetId: string) { + return this.db.collection("assets").findOne({ _id: assetId }); + } + + async probeAsset(assetId: string) { + const asset = await this.getAsset(assetId); + if (!asset) { + throw new Error(`asset not found: ${assetId}`); + } + + const summary = await probeLocalSourcePath({ + sourcePath: asset.sourcePath ?? asset.displayName, + assetType: asset.type, + displayName: asset.displayName, + }); + + const report: AssetProbeReportDocument = { + _id: `probe-${randomUUID()}`, + assetId: asset._id, + reportVersion: 1, + detectedFormatCandidates: summary.detectedFormats, + structureSummary: summary.structureSummary, + warnings: summary.warnings, + recommendedNextNodes: summary.recommendedNextNodes, + rawReport: { + kind: summary.kind, + pathExists: summary.pathExists, + topLevelPaths: summary.topLevelPaths, + fileCount: summary.fileCount, + }, + createdAt: nowIso(), + }; + + await this.db.collection("asset_probe_reports").insertOne(report); + await this.db.collection("assets").updateOne( + { _id: asset._id }, + { + $set: { + status: "probed", + topLevelPaths: summary.topLevelPaths, + detectedFormats: summary.detectedFormats, + fileCount: summary.fileCount, + summary: summary.structureSummary, + updatedAt: nowIso(), + }, + }, + ); + + return report; + } + + async getLatestProbeReport(assetId: string) { + return this.db + .collection("asset_probe_reports") + .find({ assetId }) + .sort({ createdAt: -1 }) + .limit(1) + .next(); + } + + async createWorkflowDefinition(input: { + workspaceId: string; + projectId: string; + name: string; + createdBy: string; + }) { + const definition: WorkflowDefinitionDocument = { + _id: `workflow-${randomUUID()}`, + workspaceId: input.workspaceId, + projectId: input.projectId, + name: input.name, + slug: slugify(input.name), + status: "draft", + latestVersionNumber: 0, + publishedVersionNumber: null, + createdBy: input.createdBy, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await this.db.collection("workflow_definitions").insertOne(definition); + return definition; + } + + async listWorkflowDefinitions(projectId: string) { + return this.db + .collection("workflow_definitions") + .find({ projectId }) + .sort({ createdAt: -1 }) + .toArray(); + } + + async getWorkflowDefinition(workflowDefinitionId: string) { + return this.db + .collection("workflow_definitions") + .findOne({ _id: workflowDefinitionId }); + } + + async saveWorkflowVersion(input: { + workflowDefinitionId: string; + visualGraph: Record; + logicGraph: WorkflowDefinitionVersionDocument["logicGraph"]; + runtimeGraph: Record; + pluginRefs: string[]; + createdBy: string; + }) { + const definition = await this.getWorkflowDefinition(input.workflowDefinitionId); + if (!definition) { + throw new Error(`workflow definition not found: ${input.workflowDefinitionId}`); + } + + const versionNumber = definition.latestVersionNumber + 1; + const version: WorkflowDefinitionVersionDocument = { + _id: `${definition._id}-v${versionNumber}`, + workflowDefinitionId: definition._id, + workspaceId: definition.workspaceId, + projectId: definition.projectId, + versionNumber, + visualGraph: input.visualGraph, + logicGraph: input.logicGraph, + runtimeGraph: input.runtimeGraph, + pluginRefs: input.pluginRefs, + createdBy: input.createdBy, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await this.db.collection("workflow_definition_versions").insertOne(version); + await this.db.collection("workflow_definitions").updateOne( + { _id: definition._id }, + { $set: { latestVersionNumber: versionNumber, updatedAt: nowIso() } }, + ); + return version; + } + + async listWorkflowVersions(workflowDefinitionId: string) { + return this.db + .collection("workflow_definition_versions") + .find({ workflowDefinitionId }) + .sort({ versionNumber: -1 }) + .toArray(); + } + + async getWorkflowVersion(workflowVersionId: string) { + return this.db + .collection("workflow_definition_versions") + .findOne({ _id: workflowVersionId }); + } + + async createRun(input: { + workflowDefinitionId: string; + workflowVersionId: string; + triggeredBy: string; + }) { + const version = await this.getWorkflowVersion(input.workflowVersionId); + if (!version) { + throw new Error(`workflow version not found: ${input.workflowVersionId}`); + } + + const run: WorkflowRunDocument = { + _id: `run-${randomUUID()}`, + workflowDefinitionId: input.workflowDefinitionId, + workflowVersionId: input.workflowVersionId, + status: "queued", + triggeredBy: input.triggeredBy, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await this.db.collection("workflow_runs").insertOne(run); + + const targetNodes = new Set(version.logicGraph.edges.map((edge) => edge.to)); + const tasks = version.logicGraph.nodes.map((node) => ({ + _id: `task-${randomUUID()}`, + workflowRunId: run._id, + workflowVersionId: version._id, + nodeId: node.id, + nodeType: node.type, + status: targetNodes.has(node.id) ? "pending" : "queued", + attempt: 1, + createdAt: nowIso(), + updatedAt: nowIso(), + })); + + if (tasks.length > 0) { + await this.db.collection("run_tasks").insertMany(tasks); + } + + return run; + } + + async getRun(runId: string) { + return this.db.collection("workflow_runs").findOne({ _id: runId }); + } + + async listRunTasks(runId: string) { + return this.db + .collection("run_tasks") + .find({ workflowRunId: runId }) + .sort({ createdAt: 1 }) + .toArray(); + } + + async createArtifact(input: { + type: "json" | "directory" | "video"; + title: string; + producerType: string; + producerId: string; + payload: Record; + }) { + const artifact: ArtifactDocument = { + _id: `artifact-${randomUUID()}`, + type: input.type, + title: input.title, + producerType: input.producerType, + producerId: input.producerId, + payload: input.payload, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await this.db.collection("artifacts").insertOne(artifact); + return artifact; + } + + async getArtifact(artifactId: string) { + return this.db.collection("artifacts").findOne({ _id: artifactId }); + } + + async listArtifactsByProducer(producerType: string, producerId: string) { + return this.db + .collection("artifacts") + .find({ producerType, producerId }) + .sort({ createdAt: 1 }) + .toArray(); + } + + listNodeDefinitions() { + return DELIVERY_NODE_DEFINITIONS; + } +} diff --git a/apps/api/src/runtime/server.ts b/apps/api/src/runtime/server.ts new file mode 100644 index 0000000..63a79e6 --- /dev/null +++ b/apps/api/src/runtime/server.ts @@ -0,0 +1,343 @@ +import process from "node:process"; + +import cors from "cors"; +import express from "express"; +import { MongoClient } from "mongodb"; + +import { createMongoConnectionUri } from "../common/mongo/mongo.module.ts"; +import { MongoAppStore } from "./mongo-store.ts"; +import { detectAssetShapeFromLocalPath } from "./local-source-probe.ts"; + +export type ApiRuntimeConfig = { + host: string; + port: number; + mongoUri: string; + database: string; + corsOrigin: string; +}; + +export function resolveApiRuntimeConfig( + env: NodeJS.ProcessEnv = process.env, +): ApiRuntimeConfig { + const mongoUri = + env.MONGO_URI ?? + createMongoConnectionUri({ + username: env.MONGO_ROOT_USERNAME ?? "emboflow", + password: env.MONGO_ROOT_PASSWORD ?? "emboflow", + host: env.MONGO_HOST ?? "127.0.0.1", + port: Number(env.MONGO_PORT ?? 27017), + database: env.MONGO_DB ?? "emboflow", + }); + + return { + host: env.API_HOST ?? "127.0.0.1", + port: Number(env.API_PORT ?? 3001), + mongoUri, + database: env.MONGO_DB ?? "emboflow", + corsOrigin: env.CORS_ORIGIN ?? "http://127.0.0.1:3000", + }; +} + +export async function createApiRuntime(config = resolveApiRuntimeConfig()) { + const client = new MongoClient(config.mongoUri); + await client.connect(); + const db = client.db(config.database); + const store = new MongoAppStore(db); + + const app = express(); + app.use(cors({ origin: config.corsOrigin, credentials: true })); + app.use(express.json()); + + app.get("/api/health", async (_request, response) => { + await db.command({ ping: 1 }); + response.json({ status: "ok" }); + }); + + app.post("/api/dev/bootstrap", async (request, response, next) => { + try { + const result = await store.bootstrapDevContext(request.body ?? {}); + response.json(result); + } catch (error) { + next(error); + } + }); + + app.get("/api/workspaces", async (request, response, next) => { + try { + const ownerId = String(request.query.ownerId ?? "local-user"); + response.json(await store.listWorkspaces(ownerId)); + } catch (error) { + next(error); + } + }); + + app.post("/api/projects", async (request, response, next) => { + try { + response.json( + await store.createProject({ + workspaceId: request.body.workspaceId, + name: request.body.name, + description: request.body.description, + createdBy: request.body.createdBy ?? "local-user", + }), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/projects", async (request, response, next) => { + try { + response.json(await store.listProjects(String(request.query.workspaceId))); + } catch (error) { + next(error); + } + }); + + app.post("/api/assets/register", async (request, response, next) => { + try { + const sourcePath = request.body.sourcePath as string | undefined; + const inferred = sourcePath + ? detectAssetShapeFromLocalPath(sourcePath) + : undefined; + response.json( + await store.registerAsset({ + workspaceId: request.body.workspaceId, + projectId: request.body.projectId, + type: request.body.type ?? inferred?.type ?? "folder", + sourceType: request.body.sourceType ?? inferred?.sourceType ?? "upload", + displayName: request.body.displayName ?? inferred?.displayName ?? "asset", + sourcePath, + createdBy: request.body.createdBy ?? "local-user", + }), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/assets", async (request, response, next) => { + try { + response.json(await store.listAssets(String(request.query.projectId))); + } catch (error) { + next(error); + } + }); + + app.get("/api/assets/:assetId", async (request, response, next) => { + try { + const asset = await store.getAsset(request.params.assetId); + if (!asset) { + response.status(404).json({ message: "asset not found" }); + return; + } + response.json(asset); + } catch (error) { + next(error); + } + }); + + app.post("/api/assets/:assetId/probe", async (request, response, next) => { + try { + response.json(await store.probeAsset(request.params.assetId)); + } catch (error) { + next(error); + } + }); + + app.get("/api/assets/:assetId/probe-report", async (request, response, next) => { + try { + const report = await store.getLatestProbeReport(request.params.assetId); + if (!report) { + response.status(404).json({ message: "probe report not found" }); + return; + } + response.json(report); + } catch (error) { + next(error); + } + }); + + app.get("/api/node-definitions", (_request, response) => { + response.json(store.listNodeDefinitions()); + }); + + app.post("/api/workflows", async (request, response, next) => { + try { + response.json( + await store.createWorkflowDefinition({ + workspaceId: request.body.workspaceId, + projectId: request.body.projectId, + name: request.body.name, + createdBy: request.body.createdBy ?? "local-user", + }), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/workflows", async (request, response, next) => { + try { + response.json(await store.listWorkflowDefinitions(String(request.query.projectId))); + } catch (error) { + next(error); + } + }); + + app.get("/api/workflows/:workflowDefinitionId", async (request, response, next) => { + try { + const definition = await store.getWorkflowDefinition(request.params.workflowDefinitionId); + if (!definition) { + response.status(404).json({ message: "workflow definition not found" }); + return; + } + response.json(definition); + } catch (error) { + next(error); + } + }); + + app.post("/api/workflows/:workflowDefinitionId/versions", async (request, response, next) => { + try { + response.json( + await store.saveWorkflowVersion({ + workflowDefinitionId: request.params.workflowDefinitionId, + visualGraph: request.body.visualGraph ?? {}, + logicGraph: request.body.logicGraph, + runtimeGraph: request.body.runtimeGraph ?? {}, + pluginRefs: request.body.pluginRefs ?? [], + createdBy: request.body.createdBy ?? "local-user", + }), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/workflows/:workflowDefinitionId/versions", async (request, response, next) => { + try { + response.json(await store.listWorkflowVersions(request.params.workflowDefinitionId)); + } catch (error) { + next(error); + } + }); + + app.post("/api/runs", async (request, response, next) => { + try { + response.json( + await store.createRun({ + workflowDefinitionId: request.body.workflowDefinitionId, + workflowVersionId: request.body.workflowVersionId, + triggeredBy: request.body.triggeredBy ?? "local-user", + }), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/runs/:runId", async (request, response, next) => { + try { + const run = await store.getRun(request.params.runId); + if (!run) { + response.status(404).json({ message: "run not found" }); + return; + } + response.json(run); + } catch (error) { + next(error); + } + }); + + app.get("/api/runs/:runId/tasks", async (request, response, next) => { + try { + response.json(await store.listRunTasks(request.params.runId)); + } catch (error) { + next(error); + } + }); + + app.post("/api/artifacts", async (request, response, next) => { + try { + response.json( + await store.createArtifact({ + type: request.body.type, + title: request.body.title, + producerType: request.body.producerType, + producerId: request.body.producerId, + payload: request.body.payload ?? {}, + }), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/artifacts", async (request, response, next) => { + try { + response.json( + await store.listArtifactsByProducer( + String(request.query.producerType), + String(request.query.producerId), + ), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/artifacts/:artifactId", async (request, response, next) => { + try { + const artifact = await store.getArtifact(request.params.artifactId); + if (!artifact) { + response.status(404).json({ message: "artifact not found" }); + return; + } + response.json(artifact); + } catch (error) { + next(error); + } + }); + + app.use( + ( + error: unknown, + _request: express.Request, + response: express.Response, + _next: express.NextFunction, + ) => { + const message = error instanceof Error ? error.message : "unknown error"; + response.status(400).json({ message }); + }, + ); + + return { app, client, db, store, config }; +} + +export async function startApiServer(config = resolveApiRuntimeConfig()) { + const runtime = await createApiRuntime(config); + + return new Promise<{ + close: () => Promise; + port: number; + }>((resolve) => { + const server = runtime.app.listen(config.port, config.host, () => { + resolve({ + port: config.port, + close: async () => { + await new Promise((done, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + done(); + }); + }); + await runtime.client.close(); + }, + }); + }); + }); +} diff --git a/apps/api/test/local-source-probe.spec.ts b/apps/api/test/local-source-probe.spec.ts new file mode 100644 index 0000000..11ed225 --- /dev/null +++ b/apps/api/test/local-source-probe.spec.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; + +import { + detectAssetShapeFromLocalPath, + probeLocalSourcePath, +} from "../src/runtime/local-source-probe.ts"; + +test("detect archive asset shape from local path", () => { + const result = detectAssetShapeFromLocalPath("/tmp/sample-data.zip"); + + assert.equal(result.type, "archive"); + assert.equal(result.sourceType, "local_path"); + assert.equal(result.displayName, "sample-data.zip"); +}); + +test("probe local source path summarizes top-level entries and delivery recommendations", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "emboflow-probe-")); + const deliveryRoot = path.join(tempRoot, "BJ_001_0001_OsmoNano_2026-03-19_14-51-43"); + + await mkdir(path.join(deliveryRoot, "DJI_001"), { recursive: true }); + await writeFile(path.join(deliveryRoot, "meta.json"), "{}"); + await writeFile(path.join(deliveryRoot, "video_meta.json"), "{}"); + + const summary = await probeLocalSourcePath({ + sourcePath: deliveryRoot, + assetType: "folder", + displayName: path.basename(deliveryRoot), + }); + + assert.equal(summary.pathExists, true); + assert.match(summary.detectedFormats.join(","), /delivery_package/); + assert.ok(summary.topLevelPaths.includes("DJI_001")); + assert.ok(summary.recommendedNextNodes.includes("validate_structure")); +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..45add92 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + EmboFlow + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json index 26b6995..2b2b566 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,18 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "tsx watch src/main.tsx", + "dev": "vite --host 0.0.0.0 --port 3000", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 3000", + "start": "vite --host 0.0.0.0 --port 3000", "test": "tsx --test" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.3" } } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4c75c67..6992c82 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,12 +1,33 @@ -import { createRouter } from "./app/router.tsx"; +import React from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./runtime/app.tsx"; +import "./styles.css"; export function createWebApp() { return { status: "ready" as const, - routes: createRouter(), }; } -if (import.meta.url === `file://${process.argv[1]}`) { - process.stdout.write(JSON.stringify({ status: createWebApp().status }, null, 2)); +export function mountWebApp() { + const target = document.getElementById("root"); + if (!target) { + throw new Error("root element not found"); + } + const apiBaseUrl = + typeof import.meta !== "undefined" && import.meta.env?.VITE_API_BASE_URL + ? String(import.meta.env.VITE_API_BASE_URL) + : "http://127.0.0.1:3001"; + createRoot(target).render( + + + , + ); +} + +if (typeof document !== "undefined") { + mountWebApp(); +} else if (import.meta.url === `file://${process.argv[1]}`) { + process.stdout.write(JSON.stringify(createWebApp(), null, 2)); } diff --git a/apps/web/src/runtime/api-client.ts b/apps/web/src/runtime/api-client.ts new file mode 100644 index 0000000..4cf5311 --- /dev/null +++ b/apps/web/src/runtime/api-client.ts @@ -0,0 +1,144 @@ +export type BootstrapContext = { + userId: string; + workspace: { _id: string; name: string }; + project: { _id: string; name: string }; +}; + +async function readJson(response: Response): Promise { + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? `Request failed: ${response.status}`); + } + return (await response.json()) as T; +} + +export class ApiClient { + constructor(private readonly baseUrl: string) {} + + async bootstrapDev(projectName?: string): Promise { + const response = await fetch(`${this.baseUrl}/api/dev/bootstrap`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectName }), + }); + return readJson(response); + } + + async listAssets(projectId: string) { + return readJson( + await fetch(`${this.baseUrl}/api/assets?projectId=${encodeURIComponent(projectId)}`), + ); + } + + async registerLocalAsset(input: { + workspaceId: string; + projectId: string; + sourcePath: string; + displayName?: string; + }) { + return readJson( + await fetch(`${this.baseUrl}/api/assets/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + ); + } + + async getAsset(assetId: string) { + return readJson(await fetch(`${this.baseUrl}/api/assets/${assetId}`)); + } + + async probeAsset(assetId: string) { + return readJson( + await fetch(`${this.baseUrl}/api/assets/${assetId}/probe`, { method: "POST" }), + ); + } + + async getProbeReport(assetId: string) { + return readJson( + await fetch(`${this.baseUrl}/api/assets/${assetId}/probe-report`), + ); + } + + async listWorkflows(projectId: string) { + return readJson( + await fetch(`${this.baseUrl}/api/workflows?projectId=${encodeURIComponent(projectId)}`), + ); + } + + async createWorkflow(input: { + workspaceId: string; + projectId: string; + name: string; + }) { + return readJson( + await fetch(`${this.baseUrl}/api/workflows`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + ); + } + + async listWorkflowVersions(workflowDefinitionId: string) { + return readJson( + await fetch(`${this.baseUrl}/api/workflows/${workflowDefinitionId}/versions`), + ); + } + + async saveWorkflowVersion(workflowDefinitionId: string, payload: Record) { + return readJson( + await fetch(`${this.baseUrl}/api/workflows/${workflowDefinitionId}/versions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + } + + async listNodeDefinitions() { + return readJson(await fetch(`${this.baseUrl}/api/node-definitions`)); + } + + async createRun(input: { + workflowDefinitionId: string; + workflowVersionId: string; + }) { + return readJson( + await fetch(`${this.baseUrl}/api/runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + ); + } + + async getRun(runId: string) { + return readJson(await fetch(`${this.baseUrl}/api/runs/${runId}`)); + } + + async listRunTasks(runId: string) { + return readJson(await fetch(`${this.baseUrl}/api/runs/${runId}/tasks`)); + } + + async createArtifact(input: { + type: "json" | "directory" | "video"; + title: string; + producerType: string; + producerId: string; + payload: Record; + }) { + return readJson( + await fetch(`${this.baseUrl}/api/artifacts`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + ); + } + + async getArtifact(artifactId: string) { + return readJson(await fetch(`${this.baseUrl}/api/artifacts/${artifactId}`)); + } +} diff --git a/apps/web/src/runtime/app.tsx b/apps/web/src/runtime/app.tsx new file mode 100644 index 0000000..2504db6 --- /dev/null +++ b/apps/web/src/runtime/app.tsx @@ -0,0 +1,659 @@ +import React, { useEffect, useMemo, useState } from "react"; + +import { ApiClient } from "./api-client.ts"; + +type NavItem = "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin"; + +type BootstrapContext = { + userId: string; + workspace: { _id: string; name: string }; + project: { _id: string; name: string }; +}; + +type AppProps = { + apiBaseUrl: string; +}; + +function usePathname() { + const [pathname, setPathname] = useState( + typeof window === "undefined" ? "/assets" : window.location.pathname || "/assets", + ); + + useEffect(() => { + const handle = () => setPathname(window.location.pathname || "/assets"); + window.addEventListener("popstate", handle); + return () => window.removeEventListener("popstate", handle); + }, []); + + return pathname === "/" ? "/assets" : pathname; +} + +function AppShell(props: { + workspaceName: string; + projectName: string; + active: NavItem; + children: React.ReactNode; +}) { + const navItems: Array<{ label: NavItem; href: string }> = [ + { label: "Assets", href: "/assets" }, + { label: "Workflows", href: "/workflows" }, + { label: "Runs", href: "/runs" }, + { label: "Explore", href: "/explore" }, + { label: "Labels", href: "/labels" }, + { label: "Admin", href: "/admin" }, + ]; + + return ( +
+
+
+
+ Workspace + {props.workspaceName} +
+
+ Project + {props.projectName} +
+
+
+ Runs + Local Dev +
+
+ +
{props.children}
+
+ ); +} + +function AssetsPage(props: { + api: ApiClient; + bootstrap: BootstrapContext; +}) { + const [sourcePath, setSourcePath] = useState(""); + const [assets, setAssets] = useState([]); + const [error, setError] = useState(null); + + const loadAssets = async () => { + try { + setAssets(await props.api.listAssets(props.bootstrap.project._id)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load assets"); + } + }; + + useEffect(() => { + void loadAssets(); + }, [props.bootstrap.project._id]); + + return ( +
+
+

Assets

+

Register local folders, archives, or dataset files, then probe them into managed asset metadata.

+
+ +
+
+ +
+ {error ?

{error}

: null} +
+ +
+
+ {assets.length === 0 ? ( +

No assets have been registered yet.

+ ) : ( + assets.map((asset) => ( +
+
+ + {asset.displayName} + + + {asset.status} + +
+

Type: {asset.type}

+

Source: {asset.sourceType}

+

Detected: {(asset.detectedFormats ?? []).join(", ") || "pending"}

+

Top-level entries: {(asset.topLevelPaths ?? []).slice(0, 6).join(", ") || "n/a"}

+
+ )) + )} +
+
+
+ ); +} + +function AssetDetailPage(props: { + api: ApiClient; + assetId: string; +}) { + const [asset, setAsset] = useState(null); + const [probeReport, setProbeReport] = useState(null); + const [artifactId, setArtifactId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + void (async () => { + try { + const [assetData, reportData] = await Promise.all([ + props.api.getAsset(props.assetId), + props.api.getProbeReport(props.assetId).catch(() => null), + ]); + setAsset(assetData); + setProbeReport(reportData); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load asset detail"); + } + })(); + }, [props.assetId]); + + if (error) { + return
{error}
; + } + if (!asset) { + return
Loading asset detail...
; + } + + return ( +
+
+
+

{asset.displayName}

+

Asset ID: {asset._id}

+

Type: {asset.type}

+

Status: {asset.status}

+

Source path: {asset.sourcePath ?? "n/a"}

+
+ + + {artifactId ? Open Explore View : null} +
+
+ + +
+
+ ); +} + +const SAMPLE_WORKFLOW_VERSION = { + visualGraph: { + viewport: { x: 0, y: 0, zoom: 1 }, + }, + logicGraph: { + nodes: [ + { id: "source-asset", type: "source" }, + { id: "rename-folder", type: "transform" }, + { id: "validate-structure", type: "inspect" }, + { id: "export-delivery-package", type: "export" }, + ], + edges: [ + { from: "source-asset", to: "rename-folder" }, + { from: "rename-folder", to: "validate-structure" }, + { from: "validate-structure", to: "export-delivery-package" }, + ], + }, + runtimeGraph: { + selectedPreset: "delivery-normalization", + }, + pluginRefs: ["builtin:delivery-nodes"], +}; + +function WorkflowsPage(props: { + api: ApiClient; + bootstrap: BootstrapContext; +}) { + const [workflows, setWorkflows] = useState([]); + const [error, setError] = useState(null); + + const load = async () => { + try { + setWorkflows(await props.api.listWorkflows(props.bootstrap.project._id)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load workflows"); + } + }; + + useEffect(() => { + void load(); + }, [props.bootstrap.project._id]); + + return ( +
+
+
+

Workflows

+ +
+ {error ?

{error}

: null} +
+ +
+
+ {workflows.length === 0 ? ( +

No workflows yet.

+ ) : ( + workflows.map((workflow) => ( + + )) + )} +
+
+
+ ); +} + +function WorkflowEditorPage(props: { + api: ApiClient; + workflowId: string; +}) { + const [nodes, setNodes] = useState([]); + const [versions, setVersions] = useState([]); + const [selectedNodeId, setSelectedNodeId] = useState("rename-folder"); + const [lastRunId, setLastRunId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + void (async () => { + try { + const [nodeDefs, workflowVersions] = await Promise.all([ + props.api.listNodeDefinitions(), + props.api.listWorkflowVersions(props.workflowId), + ]); + setNodes(nodeDefs); + setVersions(workflowVersions); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load workflow"); + } + })(); + }, [props.workflowId]); + + const selectedNode = useMemo( + () => nodes.find((node) => node.id === selectedNodeId) ?? null, + [nodes, selectedNodeId], + ); + + return ( +
+
+
+

Workflow Editor

+ + + {lastRunId ? Open Latest Run : null} +
+ {error ?

{error}

: null} +
+ +
+ + +
+

Canvas

+
+ {SAMPLE_WORKFLOW_VERSION.logicGraph.nodes.map((node) => ( +
+ {node.id} +

Type: {node.type}

+
+ ))} +
+

+ Latest saved versions: {versions.length > 0 ? versions.map((item) => item.versionNumber).join(", ") : "none"} +

+
+ + +
+
+ ); +} + +function RunsIndexPage() { + return ( +
+

Runs

+

Open a specific run from the workflow editor.

+
+ ); +} + +function RunDetailPage(props: { + api: ApiClient; + runId: string; +}) { + const [run, setRun] = useState(null); + const [tasks, setTasks] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + void (async () => { + try { + const [runData, taskData] = await Promise.all([ + props.api.getRun(props.runId), + props.api.listRunTasks(props.runId), + ]); + setRun(runData); + setTasks(taskData); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load run detail"); + } + })(); + }, [props.runId]); + + if (error) { + return
{error}
; + } + if (!run) { + return
Loading run...
; + } + + return ( +
+
+

Run Detail

+

Run ID: {run._id}

+

Status: {run.status}

+
+
+
+

Run Graph

+
+ {tasks.map((task) => ( +
+
+ {task.nodeId} + + {task.status} + +
+

Node type: {task.nodeType}

+
+ ))} +
+
+ +
+
+ ); +} + +function ExplorePage(props: { + api: ApiClient; + artifactId: string | null; +}) { + const [artifact, setArtifact] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!props.artifactId) { + return; + } + void (async () => { + try { + setArtifact(await props.api.getArtifact(props.artifactId!)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load artifact"); + } + })(); + }, [props.artifactId]); + + if (!props.artifactId) { + return ( +
+

Explore

+

Create an artifact from asset detail to inspect it here.

+
+ ); + } + + if (error) { + return
{error}
; + } + if (!artifact) { + return
Loading artifact...
; + } + + return ( +
+
+

{artifact.title}

+

Artifact ID: {artifact._id}

+

Type: {artifact.type}

+
{JSON.stringify(artifact.payload, null, 2)}
+
+
+ ); +} + +export function App(props: AppProps) { + const api = useMemo(() => new ApiClient(props.apiBaseUrl), [props.apiBaseUrl]); + const pathname = usePathname(); + const [bootstrap, setBootstrap] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + void (async () => { + try { + setBootstrap(await api.bootstrapDev()); + } catch (bootstrapError) { + setError( + bootstrapError instanceof Error ? bootstrapError.message : "Failed to bootstrap local context", + ); + } + })(); + }, [api]); + + if (error) { + return
{error}
; + } + if (!bootstrap) { + return
Bootstrapping local workspace...
; + } + + const assetMatch = pathname.match(/^\/assets\/([^/]+)$/); + const workflowMatch = pathname.match(/^\/workflows\/([^/]+)$/); + const runMatch = pathname.match(/^\/runs\/([^/]+)$/); + const exploreMatch = pathname.match(/^\/explore\/([^/]+)$/); + + let active: NavItem = "Assets"; + let content: React.ReactNode = ; + + if (pathname === "/workflows") { + active = "Workflows"; + content = ; + } else if (workflowMatch) { + active = "Workflows"; + content = ; + } else if (pathname === "/runs") { + active = "Runs"; + content = ; + } else if (runMatch) { + active = "Runs"; + content = ; + } else if (exploreMatch) { + active = "Explore"; + content = ; + } else if (pathname === "/explore") { + active = "Explore"; + content = ; + } else if (assetMatch) { + active = "Assets"; + content = ; + } + + return ( + + {content} + + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..b36729d --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,231 @@ +:root { + color: #111827; + background: #f5f6f7; + font-family: + "SF Pro Text", + "Helvetica Neue", + sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea { + font: inherit; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 240px 1fr; + grid-template-rows: 72px 1fr; +} + +.app-header { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 20px; + border-bottom: 1px solid #d1d5db; + background: #ffffff; +} + +.app-header__group { + display: flex; + align-items: center; + gap: 16px; +} + +.app-header__pill { + display: inline-flex; + flex-direction: column; + gap: 2px; + min-width: 140px; +} + +.app-header__label { + font-size: 12px; + color: #6b7280; +} + +.app-sidebar { + border-right: 1px solid #d1d5db; + background: linear-gradient(180deg, #fafaf9 0%, #f0fdf4 100%); + padding: 20px 16px; +} + +.app-sidebar ul { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 8px; +} + +.app-sidebar a { + display: block; + padding: 10px 12px; + border-radius: 12px; +} + +.app-sidebar a[data-active="true"] { + background: #111827; + color: #ffffff; +} + +.app-main { + padding: 24px; +} + +.panel { + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 16px; + padding: 16px; +} + +.page-stack { + display: grid; + gap: 16px; +} + +.two-column { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr); + gap: 16px; +} + +.editor-layout { + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 320px; + gap: 16px; +} + +.list-grid { + display: grid; + gap: 12px; +} + +.asset-card, +.task-card, +.node-card { + border: 1px solid #d1d5db; + border-radius: 12px; + padding: 12px; + background: #ffffff; +} + +.status-pill { + display: inline-flex; + padding: 4px 10px; + border-radius: 999px; + background: #e5e7eb; + font-size: 12px; +} + +.status-pill[data-status="success"] { + background: #dcfce7; +} + +.status-pill[data-status="running"] { + background: #dbeafe; +} + +.status-pill[data-status="queued"], +.status-pill[data-status="pending"] { + background: #fef3c7; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.field-grid { + display: grid; + gap: 10px; +} + +.field-grid label { + display: grid; + gap: 6px; +} + +.field-grid input, +.field-grid textarea { + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 10px 12px; + background: #f8fafc; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.button-primary, +.button-secondary { + border-radius: 999px; + padding: 10px 16px; + border: none; + cursor: pointer; +} + +.button-primary { + background: #111827; + color: #ffffff; +} + +.button-secondary { + background: #e5e7eb; + color: #111827; +} + +.mono-block { + border-radius: 12px; + background: #0f172a; + color: #e2e8f0; + padding: 12px; + overflow: auto; + font-family: "SF Mono", Menlo, monospace; + font-size: 12px; +} + +.empty-state { + color: #6b7280; +} + +@media (max-width: 1080px) { + .app-shell { + grid-template-columns: 1fr; + grid-template-rows: 72px auto 1fr; + } + + .app-sidebar { + border-right: none; + border-bottom: 1px solid #d1d5db; + } + + .editor-layout, + .two-column { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..34e0bfc --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "0.0.0.0", + port: 3000, + }, +}); diff --git a/design/02-architecture/system-architecture.md b/design/02-architecture/system-architecture.md index 03ae558..aa9b14e 100644 --- a/design/02-architecture/system-architecture.md +++ b/design/02-architecture/system-architecture.md @@ -198,3 +198,13 @@ V1 runtime policy: ## Deployment Direction V1 deployment target is a single public server using containerized application services. The architecture must still preserve future migration to multi-node environments. + +## Current Runtime Implementation Notes + +The current repository runtime now includes: + +- a real HTTP API process backed by MongoDB +- a React and Vite web application that reads those APIs +- a local-path asset registration flow for development and dataset inspection + +The repository still keeps some in-memory module tests for contract stability, but the executable local stack now runs through Mongo-backed runtime services. diff --git a/design/03-workflows/workflow-execution-model.md b/design/03-workflows/workflow-execution-model.md index 04b72ab..430c1e0 100644 --- a/design/03-workflows/workflow-execution-model.md +++ b/design/03-workflows/workflow-execution-model.md @@ -261,7 +261,9 @@ Each task must emit: ## Current V1 Implementation Notes -The current foundation implementation keeps the control plane in memory while stabilizing contracts for: +The current codebase keeps the low-level contract tests in memory while the executable local runtime persists workflow state to MongoDB. + +The persisted local runtime now covers: - workspace and project bootstrap - asset registration and probe reporting diff --git a/design/04-ui-ux/information-architecture-and-key-screens.md b/design/04-ui-ux/information-architecture-and-key-screens.md index 410e5ee..747c387 100644 --- a/design/04-ui-ux/information-architecture-and-key-screens.md +++ b/design/04-ui-ux/information-architecture-and-key-screens.md @@ -258,6 +258,17 @@ Use consistent object names in the UI: - Run - Task - Artifact + +## Current Runtime Implementation Notes + +The current local runtime now exposes these surfaces as a real React application: + +- Assets list and asset detail +- Workflows list and workflow editor +- Run detail +- Explore artifact detail + +The current implementation uses direct API-driven page loads and lightweight route handling instead of a deeper client-side state framework. - Plugin Do not rename the same concept differently across pages. diff --git a/design/05-data/mongodb-data-model.md b/design/05-data/mongodb-data-model.md index f858b93..adce11f 100644 --- a/design/05-data/mongodb-data-model.md +++ b/design/05-data/mongodb-data-model.md @@ -24,14 +24,15 @@ The database must support: ## Current V1 Implementation Notes -The first code pass stabilizes these collection boundaries with in-memory services before full MongoDB persistence is wired through every module. +The first code pass stabilized these collection boundaries with in-memory services. The executable local runtime now persists the core objects below into MongoDB. -This means the implementation currently validates: +This means the implementation now validates: - document shapes - controller and service boundaries - workflow/run/task separation - artifact lookup by producer +- asset persistence and probe reports through Mongo-backed collections while still targeting the collection model below as the persistent shape. diff --git a/docs/development-workflow.md b/docs/development-workflow.md index 5a8709a..512b897 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -73,6 +73,10 @@ make test make dev-api make dev-web make dev-worker +make serve-api +make serve-web +make infra-up +make infra-down make guardrails ``` diff --git a/docs/plans/2026-03-26-emboflow-v1-foundation-and-mvp.md b/docs/plans/2026-03-26-emboflow-v1-foundation-and-mvp.md index 47ea661..0e29ea7 100644 --- a/docs/plans/2026-03-26-emboflow-v1-foundation-and-mvp.md +++ b/docs/plans/2026-03-26-emboflow-v1-foundation-and-mvp.md @@ -16,6 +16,7 @@ - `2026-03-26`: Tasks 3 through 6 are implemented against in-memory V1 control-plane services so the API and worker contracts can stabilize before persistence and framework wiring are deepened. - `2026-03-26`: Package-level verification continues to use the Node 22 built-in test runner with direct file targets such as `pnpm --filter api test test/projects.e2e-spec.ts` and `pnpm --filter worker test test/task-runner.spec.ts`. - `2026-03-26`: Tasks 7 through 10 add the first web shell, workflow editor surfaces, artifact explore renderers, developer entry commands, and CI/pre-push test execution through `make test`. +- `2026-03-26`: The next runtime pass adds a Mongo-backed HTTP API, a real React and Vite web runtime, and local data validation against `/Users/longtaowu/workspace/emboldata/data`. --- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e015884..4fc1aa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,21 @@ importers: apps/api: {} - apps/web: {} + apps/web: + dependencies: + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.3(esbuild@0.27.4)(tsx@4.21.0)) + vite: + specifier: ^8.0.3 + version: 8.0.3(esbuild@0.27.4)(tsx@4.21.0) apps/worker: {} @@ -22,6 +36,15 @@ importers: packages: + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} @@ -178,11 +201,141 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -191,16 +344,189 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} hasBin: true + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + snapshots: + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true @@ -279,6 +605,78 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.122.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@vitejs/plugin-react@6.0.1(vite@8.0.3(esbuild@0.27.4)(tsx@4.21.0))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.3(esbuild@0.27.4)(tsx@4.21.0) + + detect-libc@2.1.2: {} + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -308,6 +706,10 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fsevents@2.3.3: optional: true @@ -315,11 +717,124 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react@19.2.4: {} + resolve-pkg-maps@1.0.0: {} + rolldown@1.0.0-rc.12: + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + tsx@4.21.0: dependencies: esbuild: 0.27.4 get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 + + vite@8.0.3(esbuild@0.27.4)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12 + tinyglobby: 0.2.15 + optionalDependencies: + esbuild: 0.27.4 + fsevents: 2.3.3 + tsx: 4.21.0 diff --git a/tests/test_dev_commands.py b/tests/test_dev_commands.py index 8d93edd..ebcd9a1 100644 --- a/tests/test_dev_commands.py +++ b/tests/test_dev_commands.py @@ -15,6 +15,10 @@ class DevCommandDocsTest(unittest.TestCase): "dev-api:", "dev-web:", "dev-worker:", + "serve-api:", + "serve-web:", + "infra-up:", + "infra-down:", "guardrails:", ): with self.subTest(target=target): @@ -30,6 +34,9 @@ class DevCommandDocsTest(unittest.TestCase): "make dev-api", "make dev-web", "make dev-worker", + "make infra-up", + "make serve-api", + "make serve-web", ): with self.subTest(phrase=phrase): self.assertIn(phrase, readme)