feat: add mongo runtime and react local stack

This commit is contained in:
eust-w 2026-03-26 18:27:56 +08:00
parent f81eda80bb
commit 38ddecbe20
25 changed files with 2840 additions and 11 deletions

View File

@ -3,6 +3,9 @@ NODE_ENV=development
WEB_PORT=3000 WEB_PORT=3000
API_PORT=3001 API_PORT=3001
WORKER_PORT=3002 WORKER_PORT=3002
API_HOST=127.0.0.1
MONGO_URI=mongodb://127.0.0.1:27017
MONGO_PORT=27017 MONGO_PORT=27017
MONGO_DB=emboflow MONGO_DB=emboflow
@ -15,3 +18,6 @@ MINIO_ROOT_USER=emboflow
MINIO_ROOT_PASSWORD=emboflow123 MINIO_ROOT_PASSWORD=emboflow123
STORAGE_PROVIDER=minio 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

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/ node_modules/
.DS_Store .DS_Store
dist/

View File

@ -1,6 +1,6 @@
SHELL := /bin/bash 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: bootstrap:
pnpm install pnpm install
@ -10,6 +10,7 @@ test:
python3 -m unittest discover -s tests -p 'test_*.py' python3 -m unittest discover -s tests -p 'test_*.py'
pnpm --filter api test 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 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 pnpm --filter worker test
dev-api: dev-api:
@ -21,6 +22,24 @@ dev-web:
dev-worker: dev-worker:
pnpm --filter worker dev 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: guardrails:
python3 scripts/check_doc_code_sync.py . --strict python3 scripts/check_doc_code_sync.py . --strict
python3 scripts/check_commit_message.py --rev-range HEAD python3 scripts/check_commit_message.py --rev-range HEAD

View File

@ -34,10 +34,40 @@ make dev-web
make dev-worker 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 ## Repository Structure
- `apps/api` contains the control-plane modules for workspaces, assets, workflows, runs, and artifacts. - `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. - `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. - `design/` contains the architecture and product design documents that must stay aligned with implementation.
- `docs/` contains workflow guidance and the executable implementation plan. - `docs/` contains workflow guidance and the executable implementation plan.

View File

@ -5,6 +5,12 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/main.ts", "dev": "tsx watch src/main.ts",
"start": "tsx src/main.ts",
"test": "node --test --experimental-strip-types" "test": "node --test --experimental-strip-types"
},
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"mongodb": "^7.1.1"
} }
} }

View File

@ -6,6 +6,7 @@ import { createRunsModule } from "./modules/runs/runs.module.ts";
import { createStorageModule } from "./modules/storage/storage.module.ts"; import { createStorageModule } from "./modules/storage/storage.module.ts";
import { createWorkflowsModule } from "./modules/workflows/workflows.module.ts"; import { createWorkflowsModule } from "./modules/workflows/workflows.module.ts";
import { createWorkspaceModule } from "./modules/workspaces/workspaces.module.ts"; import { createWorkspaceModule } from "./modules/workspaces/workspaces.module.ts";
import { startApiServer } from "./runtime/server.ts";
export function createApiApp() { export function createApiApp() {
const auth = createAuthModule(); const auth = createAuthModule();
@ -31,5 +32,6 @@ export function createApiApp() {
} }
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
await startApiServer();
process.stdout.write(JSON.stringify({ status: createApiApp().status }, null, 2)); process.stdout.write(JSON.stringify({ status: createApiApp().status }, null, 2));
} }

View File

@ -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<string, unknown>;
};
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<LocalProbeSummary> {
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<string>([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"];
}

View File

@ -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<string, unknown>;
topLevelPaths: string[];
detectedFormats: string[];
fileCount: number;
summary: Record<string, unknown>;
createdBy: string;
};
type AssetProbeReportDocument = {
_id: string;
assetId: string;
reportVersion: number;
detectedFormatCandidates: string[];
structureSummary: Record<string, unknown>;
warnings: string[];
recommendedNextNodes: string[];
rawReport: Record<string, unknown>;
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<string, unknown>;
logicGraph: {
nodes: Array<{ id: string; type: string }>;
edges: Array<{ from: string; to: string }>;
};
runtimeGraph: Record<string, unknown>;
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<string, unknown>;
};
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<T extends Document>(document: WithId<T>): 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<WorkspaceDocument>("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<ProjectDocument>("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<WorkspaceDocument>),
project: mapDoc(project as WithId<ProjectDocument>),
};
}
async listWorkspaces(ownerId: string) {
return this.db
.collection<WorkspaceDocument>("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<ProjectDocument>("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<AssetDocument>("assets")
.find({ projectId })
.sort({ createdAt: -1 })
.toArray();
}
async getAsset(assetId: string) {
return this.db.collection<AssetDocument>("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<AssetProbeReportDocument>("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<WorkflowDefinitionDocument>("workflow_definitions")
.find({ projectId })
.sort({ createdAt: -1 })
.toArray();
}
async getWorkflowDefinition(workflowDefinitionId: string) {
return this.db
.collection<WorkflowDefinitionDocument>("workflow_definitions")
.findOne({ _id: workflowDefinitionId });
}
async saveWorkflowVersion(input: {
workflowDefinitionId: string;
visualGraph: Record<string, unknown>;
logicGraph: WorkflowDefinitionVersionDocument["logicGraph"];
runtimeGraph: Record<string, unknown>;
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<WorkflowDefinitionVersionDocument>("workflow_definition_versions")
.find({ workflowDefinitionId })
.sort({ versionNumber: -1 })
.toArray();
}
async getWorkflowVersion(workflowVersionId: string) {
return this.db
.collection<WorkflowDefinitionVersionDocument>("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<RunTaskDocument>((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<WorkflowRunDocument>("workflow_runs").findOne({ _id: runId });
}
async listRunTasks(runId: string) {
return this.db
.collection<RunTaskDocument>("run_tasks")
.find({ workflowRunId: runId })
.sort({ createdAt: 1 })
.toArray();
}
async createArtifact(input: {
type: "json" | "directory" | "video";
title: string;
producerType: string;
producerId: string;
payload: Record<string, unknown>;
}) {
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<ArtifactDocument>("artifacts").findOne({ _id: artifactId });
}
async listArtifactsByProducer(producerType: string, producerId: string) {
return this.db
.collection<ArtifactDocument>("artifacts")
.find({ producerType, producerId })
.sort({ createdAt: 1 })
.toArray();
}
listNodeDefinitions() {
return DELIVERY_NODE_DEFINITIONS;
}
}

View File

@ -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<void>;
port: number;
}>((resolve) => {
const server = runtime.app.listen(config.port, config.host, () => {
resolve({
port: config.port,
close: async () => {
await new Promise<void>((done, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
done();
});
});
await runtime.client.close();
},
});
});
});
}

View File

@ -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"));
});

12
apps/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EmboFlow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -4,7 +4,18 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "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" "test": "tsx --test"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.3"
} }
} }

View File

@ -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() { export function createWebApp() {
return { return {
status: "ready" as const, status: "ready" as const,
routes: createRouter(),
}; };
} }
if (import.meta.url === `file://${process.argv[1]}`) { export function mountWebApp() {
process.stdout.write(JSON.stringify({ status: createWebApp().status }, null, 2)); 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(
<React.StrictMode>
<App apiBaseUrl={apiBaseUrl} />
</React.StrictMode>,
);
}
if (typeof document !== "undefined") {
mountWebApp();
} else if (import.meta.url === `file://${process.argv[1]}`) {
process.stdout.write(JSON.stringify(createWebApp(), null, 2));
} }

View File

@ -0,0 +1,144 @@
export type BootstrapContext = {
userId: string;
workspace: { _id: string; name: string };
project: { _id: string; name: string };
};
async function readJson<T>(response: Response): Promise<T> {
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<BootstrapContext> {
const response = await fetch(`${this.baseUrl}/api/dev/bootstrap`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ projectName }),
});
return readJson<BootstrapContext>(response);
}
async listAssets(projectId: string) {
return readJson<any[]>(
await fetch(`${this.baseUrl}/api/assets?projectId=${encodeURIComponent(projectId)}`),
);
}
async registerLocalAsset(input: {
workspaceId: string;
projectId: string;
sourcePath: string;
displayName?: string;
}) {
return readJson<any>(
await fetch(`${this.baseUrl}/api/assets/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
}),
);
}
async getAsset(assetId: string) {
return readJson<any>(await fetch(`${this.baseUrl}/api/assets/${assetId}`));
}
async probeAsset(assetId: string) {
return readJson<any>(
await fetch(`${this.baseUrl}/api/assets/${assetId}/probe`, { method: "POST" }),
);
}
async getProbeReport(assetId: string) {
return readJson<any>(
await fetch(`${this.baseUrl}/api/assets/${assetId}/probe-report`),
);
}
async listWorkflows(projectId: string) {
return readJson<any[]>(
await fetch(`${this.baseUrl}/api/workflows?projectId=${encodeURIComponent(projectId)}`),
);
}
async createWorkflow(input: {
workspaceId: string;
projectId: string;
name: string;
}) {
return readJson<any>(
await fetch(`${this.baseUrl}/api/workflows`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
}),
);
}
async listWorkflowVersions(workflowDefinitionId: string) {
return readJson<any[]>(
await fetch(`${this.baseUrl}/api/workflows/${workflowDefinitionId}/versions`),
);
}
async saveWorkflowVersion(workflowDefinitionId: string, payload: Record<string, unknown>) {
return readJson<any>(
await fetch(`${this.baseUrl}/api/workflows/${workflowDefinitionId}/versions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
async listNodeDefinitions() {
return readJson<any[]>(await fetch(`${this.baseUrl}/api/node-definitions`));
}
async createRun(input: {
workflowDefinitionId: string;
workflowVersionId: string;
}) {
return readJson<any>(
await fetch(`${this.baseUrl}/api/runs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
}),
);
}
async getRun(runId: string) {
return readJson<any>(await fetch(`${this.baseUrl}/api/runs/${runId}`));
}
async listRunTasks(runId: string) {
return readJson<any[]>(await fetch(`${this.baseUrl}/api/runs/${runId}/tasks`));
}
async createArtifact(input: {
type: "json" | "directory" | "video";
title: string;
producerType: string;
producerId: string;
payload: Record<string, unknown>;
}) {
return readJson<any>(
await fetch(`${this.baseUrl}/api/artifacts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
}),
);
}
async getArtifact(artifactId: string) {
return readJson<any>(await fetch(`${this.baseUrl}/api/artifacts/${artifactId}`));
}
}

View File

@ -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 (
<div className="app-shell">
<header className="app-header">
<div className="app-header__group">
<div className="app-header__pill">
<span className="app-header__label">Workspace</span>
<strong>{props.workspaceName}</strong>
</div>
<div className="app-header__pill">
<span className="app-header__label">Project</span>
<strong>{props.projectName}</strong>
</div>
</div>
<div className="app-header__group">
<span className="app-header__label">Runs</span>
<span className="app-header__label">Local Dev</span>
</div>
</header>
<aside className="app-sidebar">
<ul>
{navItems.map((item) => (
<li key={item.label}>
<a href={item.href} data-active={String(item.label === props.active)}>
{item.label}
</a>
</li>
))}
</ul>
</aside>
<main className="app-main">{props.children}</main>
</div>
);
}
function AssetsPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
}) {
const [sourcePath, setSourcePath] = useState("");
const [assets, setAssets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(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 (
<div className="page-stack">
<section className="panel">
<h1>Assets</h1>
<p>Register local folders, archives, or dataset files, then probe them into managed asset metadata.</p>
<div className="field-grid">
<label>
Local Path
<input
value={sourcePath}
onChange={(event) => setSourcePath(event.target.value)}
placeholder="/Users/longtaowu/workspace/emboldata/data"
/>
</label>
</div>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
onClick={async () => {
setError(null);
try {
const asset = await props.api.registerLocalAsset({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
sourcePath,
});
await props.api.probeAsset(asset._id);
await loadAssets();
} catch (registerError) {
setError(
registerError instanceof Error
? registerError.message
: "Failed to register local asset",
);
}
}}
>
Register Local Path
</button>
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="panel">
<div className="list-grid">
{assets.length === 0 ? (
<p className="empty-state">No assets have been registered yet.</p>
) : (
assets.map((asset) => (
<article key={asset._id} className="asset-card">
<div className="toolbar">
<a href={`/assets/${asset._id}`}>
<strong>{asset.displayName}</strong>
</a>
<span className="status-pill" data-status={asset.status}>
{asset.status}
</span>
</div>
<p>Type: {asset.type}</p>
<p>Source: {asset.sourceType}</p>
<p>Detected: {(asset.detectedFormats ?? []).join(", ") || "pending"}</p>
<p>Top-level entries: {(asset.topLevelPaths ?? []).slice(0, 6).join(", ") || "n/a"}</p>
</article>
))
)}
</div>
</section>
</div>
);
}
function AssetDetailPage(props: {
api: ApiClient;
assetId: string;
}) {
const [asset, setAsset] = useState<any | null>(null);
const [probeReport, setProbeReport] = useState<any | null>(null);
const [artifactId, setArtifactId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 <section className="panel">{error}</section>;
}
if (!asset) {
return <section className="panel">Loading asset detail...</section>;
}
return (
<div className="page-stack">
<section className="two-column">
<div className="panel">
<h1>{asset.displayName}</h1>
<p>Asset ID: {asset._id}</p>
<p>Type: {asset.type}</p>
<p>Status: {asset.status}</p>
<p>Source path: {asset.sourcePath ?? "n/a"}</p>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
onClick={async () => {
const report = await props.api.probeAsset(asset._id);
setProbeReport(report);
const reloaded = await props.api.getAsset(asset._id);
setAsset(reloaded);
}}
>
Probe Again
</button>
<button
className="button-secondary"
onClick={async () => {
if (!probeReport) {
return;
}
const artifact = await props.api.createArtifact({
type: "json",
title: `Probe Report: ${asset.displayName}`,
producerType: "asset",
producerId: asset._id,
payload: probeReport.rawReport ?? {},
});
setArtifactId(artifact._id);
}}
>
Create Explore Artifact
</button>
{artifactId ? <a href={`/explore/${artifactId}`}>Open Explore View</a> : null}
</div>
</div>
<aside className="panel">
<h2>Probe Summary</h2>
{probeReport ? (
<>
<p>Detected: {(probeReport.detectedFormatCandidates ?? []).join(", ")}</p>
<p>Warnings: {(probeReport.warnings ?? []).join(", ") || "none"}</p>
<p>
Recommended nodes: {(probeReport.recommendedNextNodes ?? []).join(", ") || "inspect_asset"}
</p>
<pre className="mono-block">
{JSON.stringify(probeReport.structureSummary ?? {}, null, 2)}
</pre>
</>
) : (
<p className="empty-state">No probe report yet.</p>
)}
</aside>
</section>
</div>
);
}
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<any[]>([]);
const [error, setError] = useState<string | null>(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 (
<div className="page-stack">
<section className="panel">
<div className="toolbar">
<h1 style={{ margin: 0 }}>Workflows</h1>
<button
className="button-primary"
onClick={async () => {
await props.api.createWorkflow({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
name: `Delivery Normalize ${workflows.length + 1}`,
});
await load();
}}
>
Create Workflow
</button>
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="panel">
<div className="list-grid">
{workflows.length === 0 ? (
<p className="empty-state">No workflows yet.</p>
) : (
workflows.map((workflow) => (
<article key={workflow._id} className="asset-card">
<a href={`/workflows/${workflow._id}`}>
<strong>{workflow.name}</strong>
</a>
<p>Status: {workflow.status}</p>
<p>Latest version: {workflow.latestVersionNumber}</p>
</article>
))
)}
</div>
</section>
</div>
);
}
function WorkflowEditorPage(props: {
api: ApiClient;
workflowId: string;
}) {
const [nodes, setNodes] = useState<any[]>([]);
const [versions, setVersions] = useState<any[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState("rename-folder");
const [lastRunId, setLastRunId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="page-stack">
<section className="panel">
<div className="toolbar">
<h1 style={{ margin: 0 }}>Workflow Editor</h1>
<button
className="button-primary"
onClick={async () => {
const version = await props.api.saveWorkflowVersion(
props.workflowId,
SAMPLE_WORKFLOW_VERSION,
);
setVersions([version, ...versions]);
}}
>
Save Workflow Version
</button>
<button
className="button-secondary"
onClick={async () => {
const latestVersion = versions[0] ??
(await props.api.saveWorkflowVersion(props.workflowId, SAMPLE_WORKFLOW_VERSION));
const run = await props.api.createRun({
workflowDefinitionId: props.workflowId,
workflowVersionId: latestVersion._id,
});
setLastRunId(run._id);
}}
>
Trigger Workflow Run
</button>
{lastRunId ? <a href={`/runs/${lastRunId}`}>Open Latest Run</a> : null}
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="editor-layout">
<aside className="panel">
<h2>Node Library</h2>
<div className="list-grid">
{nodes.map((node) => (
<button
key={node.id}
className="button-secondary"
onClick={() => setSelectedNodeId(node.id)}
>
{node.name}
</button>
))}
</div>
</aside>
<section className="panel">
<h2>Canvas</h2>
<div className="list-grid">
{SAMPLE_WORKFLOW_VERSION.logicGraph.nodes.map((node) => (
<div key={node.id} className="node-card">
<strong>{node.id}</strong>
<p>Type: {node.type}</p>
</div>
))}
</div>
<p style={{ marginTop: 12 }}>
Latest saved versions: {versions.length > 0 ? versions.map((item) => item.versionNumber).join(", ") : "none"}
</p>
</section>
<aside className="panel">
<h2>Node Configuration</h2>
{selectedNode ? (
<>
<p><strong>{selectedNode.name}</strong></p>
<p>{selectedNode.description}</p>
<p>Category: {selectedNode.category}</p>
</>
) : (
<p className="empty-state">Select a node.</p>
)}
</aside>
</section>
</div>
);
}
function RunsIndexPage() {
return (
<section className="panel">
<h1>Runs</h1>
<p className="empty-state">Open a specific run from the workflow editor.</p>
</section>
);
}
function RunDetailPage(props: {
api: ApiClient;
runId: string;
}) {
const [run, setRun] = useState<any | null>(null);
const [tasks, setTasks] = useState<any[]>([]);
const [error, setError] = useState<string | null>(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 <section className="panel">{error}</section>;
}
if (!run) {
return <section className="panel">Loading run...</section>;
}
return (
<div className="page-stack">
<section className="panel">
<h1>Run Detail</h1>
<p>Run ID: {run._id}</p>
<p>Status: {run.status}</p>
</section>
<section className="two-column">
<div className="panel">
<h2>Run Graph</h2>
<div className="list-grid">
{tasks.map((task) => (
<article key={task._id} className="task-card">
<div className="toolbar">
<strong>{task.nodeId}</strong>
<span className="status-pill" data-status={task.status}>
{task.status}
</span>
</div>
<p>Node type: {task.nodeType}</p>
</article>
))}
</div>
</div>
<aside className="panel">
<h2>Selected Task</h2>
{tasks[0] ? (
<>
<p>Node: {tasks[0].nodeId}</p>
<p>Status: {tasks[0].status}</p>
<pre className="mono-block">{JSON.stringify(tasks[0], null, 2)}</pre>
</>
) : (
<p className="empty-state">No tasks created.</p>
)}
</aside>
</section>
</div>
);
}
function ExplorePage(props: {
api: ApiClient;
artifactId: string | null;
}) {
const [artifact, setArtifact] = useState<any | null>(null);
const [error, setError] = useState<string | null>(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 (
<section className="panel">
<h1>Explore</h1>
<p className="empty-state">Create an artifact from asset detail to inspect it here.</p>
</section>
);
}
if (error) {
return <section className="panel">{error}</section>;
}
if (!artifact) {
return <section className="panel">Loading artifact...</section>;
}
return (
<div className="page-stack">
<section className="panel">
<h1>{artifact.title}</h1>
<p>Artifact ID: {artifact._id}</p>
<p>Type: {artifact.type}</p>
<pre className="mono-block">{JSON.stringify(artifact.payload, null, 2)}</pre>
</section>
</div>
);
}
export function App(props: AppProps) {
const api = useMemo(() => new ApiClient(props.apiBaseUrl), [props.apiBaseUrl]);
const pathname = usePathname();
const [bootstrap, setBootstrap] = useState<BootstrapContext | null>(null);
const [error, setError] = useState<string | null>(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 <section className="panel">{error}</section>;
}
if (!bootstrap) {
return <section className="panel">Bootstrapping local workspace...</section>;
}
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 = <AssetsPage api={api} bootstrap={bootstrap} />;
if (pathname === "/workflows") {
active = "Workflows";
content = <WorkflowsPage api={api} bootstrap={bootstrap} />;
} else if (workflowMatch) {
active = "Workflows";
content = <WorkflowEditorPage api={api} workflowId={workflowMatch[1]} />;
} else if (pathname === "/runs") {
active = "Runs";
content = <RunsIndexPage />;
} else if (runMatch) {
active = "Runs";
content = <RunDetailPage api={api} runId={runMatch[1]} />;
} else if (exploreMatch) {
active = "Explore";
content = <ExplorePage api={api} artifactId={exploreMatch[1]} />;
} else if (pathname === "/explore") {
active = "Explore";
content = <ExplorePage api={api} artifactId={null} />;
} else if (assetMatch) {
active = "Assets";
content = <AssetDetailPage api={api} assetId={assetMatch[1]} />;
}
return (
<AppShell
workspaceName={bootstrap.workspace.name}
projectName={bootstrap.project.name}
active={active}
>
{content}
</AppShell>
);
}

231
apps/web/src/styles.css Normal file
View File

@ -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;
}
}

10
apps/web/vite.config.ts Normal file
View File

@ -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,
},
});

View File

@ -198,3 +198,13 @@ V1 runtime policy:
## Deployment Direction ## 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. 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.

View File

@ -261,7 +261,9 @@ Each task must emit:
## Current V1 Implementation Notes ## 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 - workspace and project bootstrap
- asset registration and probe reporting - asset registration and probe reporting

View File

@ -258,6 +258,17 @@ Use consistent object names in the UI:
- Run - Run
- Task - Task
- Artifact - 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 - Plugin
Do not rename the same concept differently across pages. Do not rename the same concept differently across pages.

View File

@ -24,14 +24,15 @@ The database must support:
## Current V1 Implementation Notes ## 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 - document shapes
- controller and service boundaries - controller and service boundaries
- workflow/run/task separation - workflow/run/task separation
- artifact lookup by producer - artifact lookup by producer
- asset persistence and probe reports through Mongo-backed collections
while still targeting the collection model below as the persistent shape. while still targeting the collection model below as the persistent shape.

View File

@ -73,6 +73,10 @@ make test
make dev-api make dev-api
make dev-web make dev-web
make dev-worker make dev-worker
make serve-api
make serve-web
make infra-up
make infra-down
make guardrails make guardrails
``` ```

View File

@ -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`: 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`: 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`: 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`.
--- ---

517
pnpm-lock.yaml generated
View File

@ -14,7 +14,21 @@ importers:
apps/api: {} 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: {} apps/worker: {}
@ -22,6 +36,15 @@ importers:
packages: 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': '@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -178,11 +201,141 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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: esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true 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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -191,16 +344,189 @@ packages:
get-tsconfig@4.13.7: get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} 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: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 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: tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true 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: 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': '@esbuild/aix-ppc64@0.27.4':
optional: true optional: true
@ -279,6 +605,78 @@ snapshots:
'@esbuild/win32-x64@0.27.4': '@esbuild/win32-x64@0.27.4':
optional: true 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: esbuild@0.27.4:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4 '@esbuild/aix-ppc64': 0.27.4
@ -308,6 +706,10 @@ snapshots:
'@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 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: fsevents@2.3.3:
optional: true optional: true
@ -315,11 +717,124 @@ snapshots:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 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: {} 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: tsx@4.21.0:
dependencies: dependencies:
esbuild: 0.27.4 esbuild: 0.27.4
get-tsconfig: 4.13.7 get-tsconfig: 4.13.7
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 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

View File

@ -15,6 +15,10 @@ class DevCommandDocsTest(unittest.TestCase):
"dev-api:", "dev-api:",
"dev-web:", "dev-web:",
"dev-worker:", "dev-worker:",
"serve-api:",
"serve-web:",
"infra-up:",
"infra-down:",
"guardrails:", "guardrails:",
): ):
with self.subTest(target=target): with self.subTest(target=target):
@ -30,6 +34,9 @@ class DevCommandDocsTest(unittest.TestCase):
"make dev-api", "make dev-api",
"make dev-web", "make dev-web",
"make dev-worker", "make dev-worker",
"make infra-up",
"make serve-api",
"make serve-web",
): ):
with self.subTest(phrase=phrase): with self.subTest(phrase=phrase):
self.assertIn(phrase, readme) self.assertIn(phrase, readme)