feat: add web surfaces artifacts and developer commands

This commit is contained in:
eust-w 2026-03-26 17:56:23 +08:00
parent 668602de22
commit f81eda80bb
47 changed files with 1532 additions and 14 deletions

View File

@ -16,4 +16,4 @@ else
python3 scripts/check_commit_message.py --rev-range HEAD python3 scripts/check_commit_message.py --rev-range HEAD
fi fi
python3 -m unittest discover -s tests -p 'test_*.py' make test

View File

@ -19,6 +19,19 @@ jobs:
with: with:
python-version: "3.11" python-version: "3.11"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Compute git range - name: Compute git range
id: git_range id: git_range
shell: bash shell: bash
@ -42,4 +55,4 @@ jobs:
- name: Run repository tests - name: Run repository tests
run: | run: |
python3 -m unittest discover -s tests -p 'test_*.py' make test

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.DS_Store

View File

@ -15,7 +15,7 @@ Do not treat design files as background notes. If a code change affects product
5. Install the local git hooks once per clone: 5. Install the local git hooks once per clone:
```bash ```bash
bash scripts/install_hooks.sh make bootstrap
``` ```
6. Use English-only commit messages with a gitmoji prefix, for example: 6. Use English-only commit messages with a gitmoji prefix, for example:
@ -27,7 +27,7 @@ bash scripts/install_hooks.sh
7. Run the local sync check when needed: 7. Run the local sync check when needed:
```bash ```bash
python3 scripts/check_doc_code_sync.py . --strict make guardrails
``` ```
8. If design and code still diverge, document that explicitly in your final summary. 8. If design and code still diverge, document that explicitly in your final summary.
@ -71,13 +71,13 @@ This repository includes:
- a hook installer: - a hook installer:
```bash ```bash
bash scripts/install_hooks.sh make bootstrap
``` ```
- a design/code sync checker: - a design/code sync checker:
```bash ```bash
python3 scripts/check_doc_code_sync.py . --strict make guardrails
``` ```
- a commit message validator: - a commit message validator:
@ -86,6 +86,14 @@ 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
``` ```
- package-level development entry commands:
```bash
make dev-api
make dev-web
make dev-worker
```
The hooks and CI enforce: The hooks and CI enforce:
- English-only commit messages with a gitmoji prefix - English-only commit messages with a gitmoji prefix

26
Makefile Normal file
View File

@ -0,0 +1,26 @@
SHELL := /bin/bash
.PHONY: bootstrap test dev-api dev-web dev-worker guardrails
bootstrap:
pnpm install
bash scripts/install_hooks.sh
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 worker test
dev-api:
pnpm --filter api dev
dev-web:
pnpm --filter web dev
dev-worker:
pnpm --filter worker dev
guardrails:
python3 scripts/check_doc_code_sync.py . --strict
python3 scripts/check_commit_message.py --rev-range HEAD

56
README.md Normal file
View File

@ -0,0 +1,56 @@
# EmboFlow
EmboFlow is a B/S embodied-data workflow platform for raw asset ingestion, delivery normalization, dataset transformation, workflow execution, preview, and export.
## Bootstrap
From the repository root:
```bash
make bootstrap
```
This installs workspace dependencies and runs `scripts/install_hooks.sh` so local commit and push guardrails are active.
## Local Commands
Run the full repository test suite:
```bash
make test
```
Run the strict repository guardrails:
```bash
make guardrails
```
Start package-level development entrypoints:
```bash
make dev-api
make dev-web
make dev-worker
```
## 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/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.
## Developer Workflow
1. Read the relevant design files under `design/` before editing code.
2. Implement code and update impacted docs in the same change set.
3. Use English-only commit messages with a gitmoji prefix.
4. Run `make test` and `make guardrails` before pushing changes.
For direct hook installation or reinstallation:
```bash
bash scripts/install_hooks.sh
```

View File

@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "echo 'api app scaffold pending'", "dev": "tsx watch src/main.ts",
"test": "node --test --experimental-strip-types" "test": "node --test --experimental-strip-types"
} }
} }

35
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,35 @@
import { createArtifactsModule } from "./modules/artifacts/artifacts.module.ts";
import { createAssetsModule } from "./modules/assets/assets.module.ts";
import { createAuthModule } from "./modules/auth/auth.module.ts";
import { createProjectsModule } from "./modules/projects/projects.module.ts";
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";
export function createApiApp() {
const auth = createAuthModule();
const workspaces = createWorkspaceModule();
const projects = createProjectsModule(workspaces.service);
const storage = createStorageModule();
const assets = createAssetsModule(storage.service);
const workflows = createWorkflowsModule();
const runs = createRunsModule(workflows.service);
const artifacts = createArtifactsModule();
return {
status: "ready" as const,
auth,
workspaces,
projects,
storage,
assets,
workflows,
runs,
artifacts,
};
}
if (import.meta.url === `file://${process.argv[1]}`) {
process.stdout.write(JSON.stringify({ status: createApiApp().status }, null, 2));
}

View File

@ -0,0 +1,20 @@
import {
ArtifactsService,
type ArtifactRecord,
} from "./artifacts.service.ts";
export class ArtifactsController {
private readonly service: ArtifactsService;
constructor(service: ArtifactsService) {
this.service = service;
}
createArtifact(input: ArtifactRecord) {
return this.service.createArtifact(input);
}
listArtifactsByProducer(producerType: string, producerId: string) {
return this.service.listArtifactsByProducer(producerType, producerId);
}
}

View File

@ -0,0 +1,12 @@
import { ArtifactsController } from "./artifacts.controller.ts";
import { ArtifactsService } from "./artifacts.service.ts";
export function createArtifactsModule() {
const service = new ArtifactsService();
const controller = new ArtifactsController(service);
return {
service,
controller,
};
}

View File

@ -0,0 +1,28 @@
export type ArtifactRecord = {
id: string;
type: "json" | "directory" | "video";
title: string;
producerType: string;
producerId: string;
payload: Record<string, unknown>;
};
export class ArtifactsService {
private readonly artifacts: ArtifactRecord[] = [];
createArtifact(input: ArtifactRecord): ArtifactRecord {
this.artifacts.push(input);
return input;
}
listArtifactsByProducer(
producerType: string,
producerId: string,
): ArtifactRecord[] {
return this.artifacts.filter(
(artifact) =>
artifact.producerType === producerType &&
artifact.producerId === producerId,
);
}
}

View File

@ -0,0 +1,38 @@
export const DELIVERY_NODE_DEFINITIONS = [
{
id: "source-asset",
name: "Source Asset",
category: "Source",
description: "Load the uploaded asset or registered path into the workflow.",
},
{
id: "extract-archive",
name: "Extract Archive",
category: "Transform",
description: "Unpack tar, zip, or zst archives for downstream processing.",
},
{
id: "rename-folder",
name: "Rename Delivery Folder",
category: "Transform",
description: "Rename the top-level folder to the delivery naming convention.",
},
{
id: "validate-structure",
name: "Validate Structure",
category: "Inspect",
description: "Check required directories and metadata files.",
},
{
id: "validate-metadata",
name: "Validate Metadata",
category: "Inspect",
description: "Validate meta.json, intrinsics.json, and video_meta.json.",
},
{
id: "export-delivery-package",
name: "Export Delivery Package",
category: "Export",
description: "Publish the normalized package for downstream upload or handoff.",
},
] as const;

View File

@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createArtifactsModule } from "../src/modules/artifacts/artifacts.module.ts";
test("retrieve artifacts by producer", () => {
const module = createArtifactsModule();
module.controller.createArtifact({
id: "artifact-1",
type: "json",
title: "Probe Report",
producerType: "run_task",
producerId: "task-1",
payload: {
detectedFormats: ["delivery_package"],
},
});
module.controller.createArtifact({
id: "artifact-2",
type: "directory",
title: "Normalized Folder",
producerType: "run_task",
producerId: "task-1",
payload: {
entries: ["DJI_001", "meta.json"],
},
});
const artifacts = module.controller.listArtifactsByProducer("run_task", "task-1");
assert.equal(artifacts.length, 2);
assert.equal(artifacts[0]?.title, "Probe Report");
assert.equal(artifacts[1]?.type, "directory");
});

View File

@ -1,8 +1,10 @@
{ {
"name": "@emboflow/web", "name": "web",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module",
"scripts": { "scripts": {
"dev": "echo 'web app scaffold pending'" "dev": "tsx watch src/main.tsx",
"test": "tsx --test"
} }
} }

View File

@ -0,0 +1,90 @@
import { renderAssetsPage } from "../features/assets/assets-page.tsx";
import { renderAssetDetailPage } from "../features/assets/asset-detail-page.tsx";
import { renderExplorePage } from "../features/explore/explore-page.tsx";
import { renderRunDetailPage } from "../features/runs/run-detail-page.tsx";
import { renderWorkflowEditorPage } from "../features/workflows/workflow-editor-page.tsx";
import { renderWorkflowsPage } from "../features/workflows/workflows-page.tsx";
export type WebRouteMap = {
"/assets": string;
"/assets/:assetId": string;
"/workflows": string;
"/workflows/:workflowId": string;
"/runs/:runId": string;
"/explore/:artifactId": string;
};
export function createRouter(): WebRouteMap {
return {
"/assets": renderAssetsPage({
workspaceName: "Personal Workspace",
projectName: "Demo Project",
assets: [],
}),
"/assets/:assetId": renderAssetDetailPage({
workspaceName: "Personal Workspace",
projectName: "Demo Project",
asset: {
id: "asset-demo",
displayName: "Demo Asset",
type: "folder",
status: "pending",
sourceType: "upload",
},
probeReport: {
detectedFormats: ["folder"],
warnings: [],
recommendedNextNodes: ["inspect_asset"],
},
}),
"/workflows": renderWorkflowsPage({
workspaceName: "Personal Workspace",
projectName: "Demo Project",
workflowNames: ["Delivery Normalize"],
}),
"/workflows/:workflowId": renderWorkflowEditorPage({
workspaceName: "Personal Workspace",
projectName: "Demo Project",
workflowName: "Delivery Normalize",
selectedNodeId: "rename-folder",
}),
"/runs/:runId": renderRunDetailPage({
workspaceName: "Personal Workspace",
projectName: "Demo Project",
run: {
id: "run-demo",
workflowName: "Delivery Normalize",
status: "queued",
},
tasks: [
{
id: "task-source",
nodeId: "source-asset",
nodeName: "Source Asset",
status: "success",
logLines: ["Source asset available"],
},
{
id: "task-validate",
nodeId: "validate-structure",
nodeName: "Validate Structure",
status: "queued",
logLines: ["Waiting for upstream task"],
},
],
selectedTaskId: "task-validate",
}),
"/explore/:artifactId": renderExplorePage({
workspaceName: "Personal Workspace",
projectName: "Demo Project",
artifact: {
id: "artifact-demo",
type: "json",
title: "Probe Report",
payload: {
detectedFormats: ["delivery_package"],
},
},
}),
};
}

View File

@ -0,0 +1,42 @@
import { renderAppShell } from "../layout/app-shell.tsx";
import {
renderAssetSummaryPanel,
type ProbeSummary,
} from "./components/asset-summary-panel.tsx";
export type AssetDetailInput = {
workspaceName: string;
projectName: string;
asset: {
id: string;
displayName: string;
type: string;
status: string;
sourceType: string;
};
probeReport: ProbeSummary;
};
export function renderAssetDetailPage(input: AssetDetailInput): string {
const content = `
<section data-view="asset-detail-page">
<div data-slot="file-tree">File Tree</div>
<div data-slot="preview-surface">Preview Surface</div>
<section data-slot="asset-metadata">
<h1>${input.asset.displayName}</h1>
<p>Asset ID: ${input.asset.id}</p>
<p>Type: ${input.asset.type}</p>
<p>Status: ${input.asset.status}</p>
<p>Source: ${input.asset.sourceType}</p>
</section>
${renderAssetSummaryPanel(input.probeReport)}
</section>
`;
return renderAppShell({
workspaceName: input.workspaceName,
projectName: input.projectName,
activeItem: "Assets",
content,
});
}

View File

@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import { renderAppShell } from "../../features/layout/app-shell.tsx";
import { renderAssetsPage } from "./assets-page.tsx";
import { renderAssetDetailPage } from "./asset-detail-page.tsx";
test("app shell renders primary navigation", () => {
const html = renderAppShell({
workspaceName: "Personal Workspace",
projectName: "Delivery Demo",
content: "<section>Assets</section>",
});
assert.match(html, /Assets/);
assert.match(html, /Workflows/);
assert.match(html, /Runs/);
assert.match(html, /Explore/);
});
test("assets page renders asset rows from API data", () => {
const html = renderAssetsPage({
workspaceName: "Team Workspace",
projectName: "Xspark Delivery",
assets: [
{
id: "asset-1",
displayName: "BJ_001_0001_OsmoNano_2026-03-19_14-51-43",
status: "pending",
sourceType: "upload",
probeSummary: "Detected delivery package structure",
},
],
});
assert.match(html, /BJ_001_0001_OsmoNano_2026-03-19_14-51-43/);
assert.match(html, /Detected delivery package structure/);
assert.match(html, /Create Workflow/);
});
test("asset detail page renders probe summary", () => {
const html = renderAssetDetailPage({
workspaceName: "Team Workspace",
projectName: "Xspark Delivery",
asset: {
id: "asset-1",
displayName: "BJ_001_0001_OsmoNano_2026-03-19_14-51-43",
type: "folder",
status: "pending",
sourceType: "upload",
},
probeReport: {
detectedFormats: ["delivery_package"],
warnings: ["intrinsics.json missing"],
recommendedNextNodes: ["validate_structure", "validate_metadata"],
},
});
assert.match(html, /delivery_package/);
assert.match(html, /intrinsics.json missing/);
assert.match(html, /validate_structure/);
});

View File

@ -0,0 +1,35 @@
import { renderAppShell } from "../layout/app-shell.tsx";
import {
renderAssetList,
type AssetListItem,
} from "./components/asset-list.tsx";
export type AssetsPageInput = {
workspaceName: string;
projectName: string;
assets: AssetListItem[];
};
export function renderAssetsPage(input: AssetsPageInput): string {
const content = `
<section data-view="assets-page">
<header>
<h1>Assets</h1>
<div data-slot="import-actions">
<button>Upload File</button>
<button>Upload Archive</button>
<button>Import Object Storage Prefix</button>
<button>Register Storage Path</button>
</div>
</header>
${renderAssetList(input.assets)}
</section>
`;
return renderAppShell({
workspaceName: input.workspaceName,
projectName: input.projectName,
activeItem: "Assets",
content,
});
}

View File

@ -0,0 +1,26 @@
export type AssetListItem = {
id: string;
displayName: string;
status: string;
sourceType: string;
probeSummary?: string;
};
export function renderAssetList(items: AssetListItem[]): string {
const rows = items
.map(
(item) => `
<article data-asset-id="${item.id}">
<h3>${item.displayName}</h3>
<p>Status: ${item.status}</p>
<p>Source: ${item.sourceType}</p>
<p>${item.probeSummary ?? "Probe pending"}</p>
<button>Create Workflow</button>
<button>Open Preview</button>
</article>
`,
)
.join("");
return `<section data-view="asset-list">${rows}</section>`;
}

View File

@ -0,0 +1,33 @@
export type ProbeSummary = {
detectedFormats: string[];
warnings: string[];
recommendedNextNodes: string[];
};
export function renderAssetSummaryPanel(summary: ProbeSummary): string {
const detectedFormats = summary.detectedFormats
.map((item) => `<li>${item}</li>`)
.join("");
const warnings = summary.warnings.map((item) => `<li>${item}</li>`).join("");
const nextNodes = summary.recommendedNextNodes
.map((item) => `<li>${item}</li>`)
.join("");
return `
<aside data-view="asset-summary-panel">
<h2>Probe Summary</h2>
<section>
<h3>Detected Formats</h3>
<ul>${detectedFormats}</ul>
</section>
<section>
<h3>Warnings</h3>
<ul>${warnings || "<li>none</li>"}</ul>
</section>
<section>
<h3>Recommended Next Nodes</h3>
<ul>${nextNodes || "<li>inspect_asset</li>"}</ul>
</section>
</aside>
`;
}

View File

@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import { renderExplorePage } from "./explore-page.tsx";
test("open and render json artifacts", () => {
const html = renderExplorePage({
workspaceName: "Team Workspace",
projectName: "Pipeline Project",
artifact: {
id: "artifact-json",
type: "json",
title: "Probe Report",
payload: {
detectedFormats: ["delivery_package"],
},
},
});
assert.match(html, /Probe Report/);
assert.match(html, /Json Renderer/);
assert.match(html, /delivery_package/);
});
test("open and render directory artifacts", () => {
const html = renderExplorePage({
workspaceName: "Team Workspace",
projectName: "Pipeline Project",
artifact: {
id: "artifact-directory",
type: "directory",
title: "Normalized Folder",
payload: {
entries: ["DJI_001", "meta.json"],
},
},
});
assert.match(html, /Directory Renderer/);
assert.match(html, /DJI_001/);
assert.match(html, /meta.json/);
});
test("open and render video artifacts", () => {
const html = renderExplorePage({
workspaceName: "Team Workspace",
projectName: "Pipeline Project",
artifact: {
id: "artifact-video",
type: "video",
title: "Preview Clip",
payload: {
path: "DJI_001/DJI_20260318112834_0001_D.mp4",
},
},
});
assert.match(html, /Video Renderer/);
assert.match(html, /Preview Clip/);
assert.match(html, /DJI_20260318112834_0001_D.mp4/);
});

View File

@ -0,0 +1,51 @@
import { renderAppShell } from "../layout/app-shell.tsx";
import { renderDirectoryRenderer } from "./renderers/directory-renderer.tsx";
import { renderJsonRenderer } from "./renderers/json-renderer.tsx";
import { renderVideoRenderer } from "./renderers/video-renderer.tsx";
export type ExploreArtifact = {
id: string;
type: "json" | "directory" | "video";
title: string;
payload: Record<string, unknown>;
};
export type ExplorePageInput = {
workspaceName: string;
projectName: string;
artifact: ExploreArtifact;
};
function renderArtifact(input: ExploreArtifact): string {
if (input.type === "json") {
return renderJsonRenderer(input.payload);
}
if (input.type === "directory") {
return renderDirectoryRenderer({
entries: Array.isArray(input.payload.entries)
? input.payload.entries.map((item) => String(item))
: [],
});
}
return renderVideoRenderer({
path: typeof input.payload.path === "string" ? input.payload.path : undefined,
});
}
export function renderExplorePage(input: ExplorePageInput): string {
return renderAppShell({
workspaceName: input.workspaceName,
projectName: input.projectName,
activeItem: "Explore",
content: `
<section data-view="explore-page">
<header>
<h1>${input.artifact.title}</h1>
<p>Artifact ${input.artifact.id}</p>
<p>Type: ${input.artifact.type}</p>
</header>
${renderArtifact(input.artifact)}
</section>
`,
});
}

View File

@ -0,0 +1,11 @@
export function renderDirectoryRenderer(payload: {
entries?: string[];
}): string {
const items = (payload.entries ?? []).map((entry) => `<li>${entry}</li>`).join("");
return `
<section data-view="directory-renderer">
<h2>Directory Renderer</h2>
<ul>${items || "<li>No entries</li>"}</ul>
</section>
`;
}

View File

@ -0,0 +1,8 @@
export function renderJsonRenderer(payload: Record<string, unknown>): string {
return `
<section data-view="json-renderer">
<h2>Json Renderer</h2>
<pre>${JSON.stringify(payload, null, 2)}</pre>
</section>
`;
}

View File

@ -0,0 +1,10 @@
export function renderVideoRenderer(payload: {
path?: string;
}): string {
return `
<section data-view="video-renderer">
<h2>Video Renderer</h2>
<p>${payload.path ?? "No video path"}</p>
</section>
`;
}

View File

@ -0,0 +1,43 @@
import { renderProjectSelector } from "../projects/project-selector.tsx";
import { renderWorkspaceSwitcher } from "../workspaces/workspace-switcher.tsx";
export const PRIMARY_NAV_ITEMS = [
"Assets",
"Workflows",
"Runs",
"Explore",
"Labels",
"Admin",
] as const;
export type AppShellInput = {
workspaceName: string;
projectName: string;
content: string;
activeItem?: (typeof PRIMARY_NAV_ITEMS)[number];
};
export function renderAppShell(input: AppShellInput): string {
const navigation = PRIMARY_NAV_ITEMS.map((item) => {
const active = item === input.activeItem ? "data-active=\"true\"" : "";
return `<li ${active}>${item}</li>`;
}).join("");
return `
<div data-view="app-shell">
<header>
${renderWorkspaceSwitcher({ workspaceName: input.workspaceName })}
${renderProjectSelector({ projectName: input.projectName })}
<div data-slot="global-search">Search</div>
<div data-slot="run-notifications">Runs</div>
<div data-slot="user-menu">User</div>
</header>
<aside>
<nav>
<ul>${navigation}</ul>
</nav>
</aside>
<main>${input.content}</main>
</div>
`;
}

View File

@ -0,0 +1,7 @@
export type ProjectSelectorInput = {
projectName: string;
};
export function renderProjectSelector(input: ProjectSelectorInput): string {
return `<div data-slot="project-selector"><label>Project</label><strong>${input.projectName}</strong></div>`;
}

View File

@ -0,0 +1,22 @@
export type RunTaskView = {
id: string;
nodeId: string;
nodeName: string;
status: string;
logLines: string[];
};
export function renderRunGraphView(tasks: RunTaskView[]): string {
const nodes = tasks
.map(
(task) => `
<article data-task-id="${task.id}">
<strong>${task.nodeName}</strong>
<span data-status="${task.status}">${task.status}</span>
</article>
`,
)
.join("");
return `<section data-view="run-graph-view">${nodes}</section>`;
}

View File

@ -0,0 +1,20 @@
import type { RunTaskView } from "./run-graph-view.tsx";
export function renderTaskLogPanel(
tasks: RunTaskView[],
selectedTaskId?: string,
): string {
const task = tasks.find((item) => item.id === selectedTaskId) ?? tasks[0];
if (!task) {
return `<aside data-view="task-log-panel"><p>No task selected.</p></aside>`;
}
const lines = task.logLines.map((line) => `<li>${line}</li>`).join("");
return `
<aside data-view="task-log-panel">
<h2>${task.nodeName}</h2>
<p>Status: ${task.status}</p>
<ul>${lines || "<li>No logs</li>"}</ul>
</aside>
`;
}

View File

@ -0,0 +1,37 @@
import { renderAppShell } from "../layout/app-shell.tsx";
import {
renderRunGraphView,
type RunTaskView,
} from "./components/run-graph-view.tsx";
import { renderTaskLogPanel } from "./components/task-log-panel.tsx";
export type RunDetailPageInput = {
workspaceName: string;
projectName: string;
run: {
id: string;
workflowName: string;
status: string;
};
tasks: RunTaskView[];
selectedTaskId?: string;
};
export function renderRunDetailPage(input: RunDetailPageInput): string {
return renderAppShell({
workspaceName: input.workspaceName,
projectName: input.projectName,
activeItem: "Runs",
content: `
<section data-view="run-detail-page">
<header>
<h1>${input.run.workflowName}</h1>
<p>Run ${input.run.id}</p>
<p>Status: ${input.run.status}</p>
</header>
${renderRunGraphView(input.tasks)}
${renderTaskLogPanel(input.tasks, input.selectedTaskId)}
</section>
`,
});
}

View File

@ -0,0 +1,34 @@
import { WORKFLOW_NODE_DEFINITIONS } from "./node-library.tsx";
export function renderNodeConfigPanel(selectedNodeId?: string): string {
if (!selectedNodeId) {
return `
<aside data-view="node-config-panel">
<h2>Node Configuration</h2>
<p>Select a node to inspect config, schemas, executor, and runtime policy.</p>
</aside>
`;
}
const node = WORKFLOW_NODE_DEFINITIONS.find((item) => item.id === selectedNodeId);
if (!node) {
return `
<aside data-view="node-config-panel">
<h2>Node Configuration</h2>
<p>Unknown node: ${selectedNodeId}</p>
</aside>
`;
}
return `
<aside data-view="node-config-panel">
<h2>Node Configuration</h2>
<h3>${node.name}</h3>
<p>${node.description}</p>
<p>Executor: ${node.executorType}</p>
<p>Input Schema: ${node.inputSchemaSummary}</p>
<p>Output Schema: ${node.outputSchemaSummary}</p>
<p>Code Hook: ${node.supportsCodeHook ? "enabled" : "not available"}</p>
</aside>
`;
}

View File

@ -0,0 +1,76 @@
export type WorkflowNodeDefinition = {
id: string;
name: string;
category: "Source" | "Transform" | "Inspect" | "Annotate" | "Export" | "Utility";
description: string;
executorType: "python" | "docker" | "http";
inputSchemaSummary: string;
outputSchemaSummary: string;
supportsCodeHook?: boolean;
};
export const WORKFLOW_NODE_DEFINITIONS: WorkflowNodeDefinition[] = [
{
id: "source-asset",
name: "Source Asset",
category: "Source",
description: "Load an uploaded asset or registered storage path.",
executorType: "python",
inputSchemaSummary: "assetRef",
outputSchemaSummary: "assetRef",
},
{
id: "extract-archive",
name: "Extract Archive",
category: "Transform",
description: "Expand a compressed archive into a managed folder artifact.",
executorType: "docker",
inputSchemaSummary: "assetRef",
outputSchemaSummary: "artifactRef",
supportsCodeHook: true,
},
{
id: "rename-folder",
name: "Rename Delivery Folder",
category: "Transform",
description: "Rename the top-level delivery folder to the business naming convention.",
executorType: "python",
inputSchemaSummary: "artifactRef",
outputSchemaSummary: "artifactRef",
supportsCodeHook: true,
},
{
id: "validate-structure",
name: "Validate Structure",
category: "Inspect",
description: "Validate required directories and metadata files.",
executorType: "python",
inputSchemaSummary: "artifactRef",
outputSchemaSummary: "artifactRef + report",
supportsCodeHook: true,
},
{
id: "export-delivery-package",
name: "Export Delivery Package",
category: "Export",
description: "Produce the final delivery package artifact for upload.",
executorType: "http",
inputSchemaSummary: "artifactRef",
outputSchemaSummary: "artifactRef",
},
];
export function renderNodeLibrary(): string {
const sections = ["Source", "Transform", "Inspect", "Annotate", "Export", "Utility"]
.map((category) => {
const nodes = WORKFLOW_NODE_DEFINITIONS.filter(
(item) => item.category === category,
)
.map((item) => `<li data-node-id="${item.id}">${item.name}</li>`)
.join("");
return `<section><h3>${category}</h3><ul>${nodes || "<li>none</li>"}</ul></section>`;
})
.join("");
return `<aside data-view="node-library"><h2>Node Library</h2>${sections}</aside>`;
}

View File

@ -0,0 +1,30 @@
import { WORKFLOW_NODE_DEFINITIONS } from "./node-library.tsx";
export type WorkflowCanvasInput = {
selectedNodeId?: string;
};
export function renderWorkflowCanvas(input: WorkflowCanvasInput): string {
const defaultNodes = ["source-asset", "rename-folder", "validate-structure"];
const nodes = defaultNodes
.map((nodeId) => {
const node = WORKFLOW_NODE_DEFINITIONS.find((item) => item.id === nodeId);
if (!node) {
return "";
}
const selected = node.id === input.selectedNodeId ? "data-selected=\"true\"" : "";
return `<div data-canvas-node="${node.id}" ${selected}>${node.name}</div>`;
})
.join("");
return `
<section data-view="workflow-canvas">
<header>
<button>Save Workflow Version</button>
<button>Trigger Workflow Run</button>
</header>
<div data-slot="canvas-surface">${nodes}</div>
<div data-slot="canvas-controls">Zoom | Pan | Mini-map</div>
</section>
`;
}

View File

@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import { renderNodeLibrary } from "./components/node-library.tsx";
import { renderWorkflowEditorPage } from "./workflow-editor-page.tsx";
import { renderRunDetailPage } from "../runs/run-detail-page.tsx";
test("node library renders categories", () => {
const html = renderNodeLibrary();
assert.match(html, /Source/);
assert.match(html, /Transform/);
assert.match(html, /Inspect/);
assert.match(html, /Export/);
});
test("node config panel opens when a node is selected", () => {
const html = renderWorkflowEditorPage({
workspaceName: "Team Workspace",
projectName: "Pipeline Project",
workflowName: "Delivery Normalize",
selectedNodeId: "rename-folder",
});
assert.match(html, /Node Configuration/);
assert.match(html, /Rename Delivery Folder/);
assert.match(html, /Executor/);
});
test("run detail view shows node status badges from run data", () => {
const html = renderRunDetailPage({
workspaceName: "Team Workspace",
projectName: "Pipeline Project",
run: {
id: "run-1",
workflowName: "Delivery Normalize",
status: "running",
},
tasks: [
{
id: "task-1",
nodeId: "source-asset",
nodeName: "Source Asset",
status: "success",
logLines: ["Asset loaded"],
},
{
id: "task-2",
nodeId: "validate-structure",
nodeName: "Validate Structure",
status: "running",
logLines: ["Checking metadata"],
},
],
selectedTaskId: "task-2",
});
assert.match(html, /Source Asset/);
assert.match(html, /success/);
assert.match(html, /Validate Structure/);
assert.match(html, /running/);
assert.match(html, /Checking metadata/);
});

View File

@ -0,0 +1,31 @@
import { renderAppShell } from "../layout/app-shell.tsx";
import { renderNodeConfigPanel } from "./components/node-config-panel.tsx";
import { renderNodeLibrary } from "./components/node-library.tsx";
import { renderWorkflowCanvas } from "./components/workflow-canvas.tsx";
export type WorkflowEditorPageInput = {
workspaceName: string;
projectName: string;
workflowName: string;
selectedNodeId?: string;
};
export function renderWorkflowEditorPage(
input: WorkflowEditorPageInput,
): string {
return renderAppShell({
workspaceName: input.workspaceName,
projectName: input.projectName,
activeItem: "Workflows",
content: `
<section data-view="workflow-editor-page">
<header><h1>${input.workflowName}</h1></header>
<div data-layout="editor">
${renderNodeLibrary()}
${renderWorkflowCanvas({ selectedNodeId: input.selectedNodeId })}
${renderNodeConfigPanel(input.selectedNodeId)}
</div>
</section>
`,
});
}

View File

@ -0,0 +1,25 @@
import { renderAppShell } from "../layout/app-shell.tsx";
export type WorkflowsPageInput = {
workspaceName: string;
projectName: string;
workflowNames: string[];
};
export function renderWorkflowsPage(input: WorkflowsPageInput): string {
const items = input.workflowNames.map((name) => `<li>${name}</li>`).join("");
return renderAppShell({
workspaceName: input.workspaceName,
projectName: input.projectName,
activeItem: "Workflows",
content: `
<section data-view="workflows-page">
<header>
<h1>Workflows</h1>
<button>Create Workflow</button>
</header>
<ul>${items || "<li>No workflows yet</li>"}</ul>
</section>
`,
});
}

View File

@ -0,0 +1,9 @@
export type WorkspaceSwitcherInput = {
workspaceName: string;
};
export function renderWorkspaceSwitcher(
input: WorkspaceSwitcherInput,
): string {
return `<div data-slot="workspace-switcher"><label>Workspace</label><strong>${input.workspaceName}</strong></div>`;
}

12
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import { createRouter } from "./app/router.tsx";
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));
}

View File

@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "echo 'worker app scaffold pending'", "dev": "tsx watch src/main.ts",
"test": "node --test --experimental-strip-types" "test": "node --test --experimental-strip-types"
} }
} }

View File

@ -3,3 +3,7 @@ export function bootstrapWorker() {
status: "ready" as const, status: "ready" as const,
}; };
} }
if (import.meta.url === `file://${process.argv[1]}`) {
process.stdout.write(JSON.stringify(bootstrapWorker(), null, 2));
}

View File

@ -259,6 +259,28 @@ Each task must emit:
- structured task summary - structured task summary
- artifact refs - artifact refs
## Current V1 Implementation Notes
The current foundation implementation keeps the control plane in memory while stabilizing contracts for:
- workspace and project bootstrap
- asset registration and probe reporting
- workflow definition and immutable version snapshots
- workflow runs and task creation
- artifact registration and producer lookup
The first web authoring surface already follows the three-pane layout contract with:
- left node library
- center workflow canvas
- right node configuration panel
The first explore surface currently includes built-in renderers for:
- JSON artifacts
- directory artifacts
- video artifacts
The UI must allow: The UI must allow:
- graph-level run status - graph-level run status

View File

@ -22,6 +22,19 @@ The database must support:
- Large, fast-growing arrays should be split into separate collections - Large, fast-growing arrays should be split into separate collections
- Platform contracts should use references, not embedded file blobs - Platform contracts should use references, not embedded file blobs
## 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.
This means the implementation currently validates:
- document shapes
- controller and service boundaries
- workflow/run/task separation
- artifact lookup by producer
while still targeting the collection model below as the persistent shape.
## Primary Collections ## Primary Collections
- `users` - `users`

View File

@ -44,7 +44,7 @@ Do not defer the design update. Treat design edits as part of the implementation
From the repo root: From the repo root:
```bash ```bash
python3 scripts/check_doc_code_sync.py . --strict make guardrails
``` ```
Interpret warnings manually. The script is a guardrail, not a replacement for judgment. Interpret warnings manually. The script is a guardrail, not a replacement for judgment.
@ -54,7 +54,7 @@ Interpret warnings manually. The script is a guardrail, not a replacement for ju
Install local hooks once per clone: Install local hooks once per clone:
```bash ```bash
bash scripts/install_hooks.sh make bootstrap
``` ```
This enables: This enables:
@ -63,6 +63,19 @@ This enables:
- `pre-commit`: block staged code/config drift without doc updates - `pre-commit`: block staged code/config drift without doc updates
- `pre-push`: run commit-message validation, doc/code sync checks, and repository tests - `pre-push`: run commit-message validation, doc/code sync checks, and repository tests
### 5.1 Preferred Local Commands
Use the repo entry commands instead of ad hoc command strings whenever possible:
```bash
make bootstrap
make test
make dev-api
make dev-web
make dev-worker
make guardrails
```
### 6. Close With Explicit Status ### 6. Close With Explicit Status
Every implementation summary should state one of: Every implementation summary should state one of:

View File

@ -15,6 +15,7 @@
- `2026-03-26`: Tasks 1 and 2 are complete and committed. - `2026-03-26`: Tasks 1 and 2 are complete and committed.
- `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`.
--- ---

View File

@ -4,6 +4,11 @@
"version": "0.1.0", "version": "0.1.0",
"packageManager": "pnpm@9.12.3", "packageManager": "pnpm@9.12.3",
"scripts": { "scripts": {
"test": "python3 -m unittest discover -s tests -p 'test_*.py'" "bootstrap": "make bootstrap",
"guardrails": "make guardrails",
"test": "make test"
},
"devDependencies": {
"tsx": "^4.21.0"
} }
} }

310
pnpm-lock.yaml generated
View File

@ -6,7 +6,11 @@ settings:
importers: importers:
.: {} .:
devDependencies:
tsx:
specifier: ^4.21.0
version: 4.21.0
apps/api: {} apps/api: {}
@ -15,3 +19,307 @@ importers:
apps/worker: {} apps/worker: {}
packages/contracts: {} packages/contracts: {}
packages:
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
snapshots:
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.7:
dependencies:
resolve-pkg-maps: 1.0.0
resolve-pkg-maps@1.0.0: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.4
get-tsconfig: 4.13.7
optionalDependencies:
fsevents: 2.3.3

View File

@ -0,0 +1,39 @@
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
class DevCommandDocsTest(unittest.TestCase):
def test_makefile_exposes_expected_local_commands(self):
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
for target in (
"bootstrap:",
"test:",
"dev-api:",
"dev-web:",
"dev-worker:",
"guardrails:",
):
with self.subTest(target=target):
self.assertIn(target, makefile)
def test_readme_documents_bootstrap_hooks_test_and_local_run(self):
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
for phrase in (
"make bootstrap",
"scripts/install_hooks.sh",
"make test",
"make dev-api",
"make dev-web",
"make dev-worker",
):
with self.subTest(phrase=phrase):
self.assertIn(phrase, readme)
if __name__ == "__main__":
unittest.main()