0.6 rust重构01

This commit is contained in:
lix-2026
2026-04-14 13:22:29 +08:00
parent 71fb1aee7e
commit 84a8454fa9
401 changed files with 5841 additions and 1512 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"hooks": {}
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { execSync } from "node:child_process";
function rootDir() {
try {
return execSync("git rev-parse --show-toplevel", {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return process.cwd();
}
}
function readStdin() {
try {
return JSON.parse(readFileSync(0, "utf8") || "{}");
} catch {
return {};
}
}
const payload = readStdin();
const root = rootDir();
mkdirSync(join(root, ".omx", "logs"), { recursive: true });
const exitCode =
typeof payload?.tool_output?.exit_code === "number"
? payload.tool_output.exit_code
: typeof payload?.exit_code === "number"
? payload.exit_code
: null;
const status = exitCode ?? "unknown";
const isExplicitBashFailure = payload?.tool_name === "Bash" && exitCode !== null && exitCode !== 0;
appendFileSync(
join(root, ".omx", "logs", "hooks.log"),
`${new Date().toISOString()} PostToolUse status=${status} ${JSON.stringify(payload)}\n`,
"utf8",
);
process.stdout.write(JSON.stringify({
continue: true,
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: isExplicitBashFailure ? "OMX noticed a failing Bash command. Check the team inbox or diagnostics." : "",
},
}));
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
function readStdin() {
try {
return JSON.parse(readFileSync(0, "utf8") || "{}");
} catch {
return {};
}
}
const payload = readStdin();
const command = JSON.stringify(payload).toLowerCase();
const blocked = [
"rm -rf /",
"mkfs",
"dd if=",
"shutdown",
"reboot",
"poweroff",
"git reset --hard",
];
if (blocked.some((token) => command.includes(token))) {
process.stdout.write(JSON.stringify({
decision: "block",
reason: "OMX safety hook blocked an obviously destructive command.",
}));
process.exit(0);
}
process.stdout.write(JSON.stringify({
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse",
},
}));
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { execSync } from "node:child_process";
function rootDir() {
try {
return execSync("git rev-parse --show-toplevel", {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return process.cwd();
}
}
function readStdin() {
try {
return JSON.parse(readFileSync(0, "utf8") || "{}");
} catch {
return {};
}
}
function readJson(path, fallback) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return fallback;
}
}
function writeJson(path, value) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
}
const root = rootDir();
const payload = readStdin();
const omx = join(root, ".omx");
mkdirSync(join(omx, "logs"), { recursive: true });
appendFileSync(join(omx, "logs", "hooks.log"), `${new Date().toISOString()} SessionStart ${JSON.stringify(payload)}\n`, "utf8");
const runtimePath = join(omx, "state", "hook-runtime.json");
const runtime = readJson(runtimePath, {});
writeJson(runtimePath, {
...runtime,
lastSessionStartAt: new Date().toISOString(),
});
process.stdout.write(JSON.stringify({
continue: true,
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: "",
},
}));
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { execSync } from "node:child_process";
function rootDir() {
try {
return execSync("git rev-parse --show-toplevel", {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return process.cwd();
}
}
function readStdin() {
try {
return JSON.parse(readFileSync(0, "utf8") || "{}");
} catch {
return {};
}
}
function readJson(path, fallback) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return fallback;
}
}
function writeJson(path, value) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
}
const root = rootDir();
const payload = readStdin();
const omx = join(root, ".omx");
mkdirSync(join(omx, "logs"), { recursive: true });
appendFileSync(join(omx, "logs", "hooks.log"), `${new Date().toISOString()} Stop ${JSON.stringify(payload)}\n`, "utf8");
const runtimePath = join(omx, "state", "hook-runtime.json");
const runtime = readJson(runtimePath, {});
writeJson(runtimePath, {
...runtime,
lastStopAt: new Date().toISOString(),
});
process.stdout.write(JSON.stringify({
continue: true,
}));
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { execSync } from "node:child_process";
function rootDir() {
try {
return execSync("git rev-parse --show-toplevel", {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return process.cwd();
}
}
function readStdin() {
try {
return JSON.parse(readFileSync(0, "utf8") || "{}");
} catch {
return {};
}
}
function readJson(path, fallback) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return fallback;
}
}
function writeJson(path, value) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
}
const root = rootDir();
const payload = readStdin();
const omx = join(root, ".omx");
mkdirSync(join(omx, "logs"), { recursive: true });
appendFileSync(join(omx, "logs", "hooks.log"), `${new Date().toISOString()} UserPromptSubmit ${JSON.stringify(payload)}\n`, "utf8");
const runtimePath = join(omx, "state", "hook-runtime.json");
const runtime = readJson(runtimePath, {});
writeJson(runtimePath, {
...runtime,
lastPromptAt: new Date().toISOString(),
});
process.stdout.write(JSON.stringify({
continue: true,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "",
},
}));
+1
View File
@@ -49,3 +49,4 @@ wolai-frontend/public/documents/**
artifacts/ artifacts/
artifacts/** artifacts/**
tmp tmp
design
+1
View File
@@ -0,0 +1 @@
+309
View File
@@ -0,0 +1,309 @@
# Design System Inspired by Notion
## 1. Visual Theme & Atmosphere
Notion's website embodies the philosophy of the tool itself: a blank canvas that gets out of your way. The design system is built on warm neutrals rather than cold grays, creating a distinctly approachable minimalism that feels like quality paper rather than sterile glass. The page canvas is pure white (`#ffffff`) but the text isn't pure black -- it's a warm near-black (`rgba(0,0,0,0.95)`) that softens the reading experience imperceptibly. The warm gray scale (`#f6f5f4`, `#31302e`, `#615d59`, `#a39e98`) carries subtle yellow-brown undertones, giving the interface a tactile, almost analog warmth.
The custom NotionInter font (a modified Inter) is the backbone of the system. At display sizes (64px), it uses aggressive negative letter-spacing (-2.125px), creating headlines that feel compressed and precise. The weight range is broader than typical systems: 400 for body, 500 for UI elements, 600 for semi-bold labels, and 700 for display headings. OpenType features `"lnum"` (lining numerals) and `"locl"` (localized forms) are enabled on larger text, adding typographic sophistication that rewards close reading.
What makes Notion's visual language distinctive is its border philosophy. Rather than heavy borders or shadows, Notion uses ultra-thin `1px solid rgba(0,0,0,0.1)` borders -- borders that exist as whispers, barely perceptible division lines that create structure without weight. The shadow system is equally restrained: multi-layer stacks with cumulative opacity never exceeding 0.05, creating depth that's felt rather than seen.
**Key Characteristics:**
- NotionInter (modified Inter) with negative letter-spacing at display sizes (-2.125px at 64px)
- Warm neutral palette: grays carry yellow-brown undertones (`#f6f5f4` warm white, `#31302e` warm dark)
- Near-black text via `rgba(0,0,0,0.95)` -- not pure black, creating micro-warmth
- Ultra-thin borders: `1px solid rgba(0,0,0,0.1)` throughout -- whisper-weight division
- Multi-layer shadow stacks with sub-0.05 opacity for barely-there depth
- Notion Blue (`#0075de`) as the singular accent color for CTAs and interactive elements
- Pill badges (9999px radius) with tinted blue backgrounds for status indicators
- 8px base spacing unit with an organic, non-rigid scale
## 2. Color Palette & Roles
### Primary
- **Notion Black** (`rgba(0,0,0,0.95)` / `#000000f2`): Primary text, headings, body copy. The 95% opacity softens pure black without sacrificing readability.
- **Pure White** (`#ffffff`): Page background, card surfaces, button text on blue.
- **Notion Blue** (`#0075de`): Primary CTA, link color, interactive accent -- the only saturated color in the core UI chrome.
### Brand Secondary
- **Deep Navy** (`#213183`): Secondary brand color, used sparingly for emphasis and dark feature sections.
- **Active Blue** (`#005bab`): Button active/pressed state -- darker variant of Notion Blue.
### Warm Neutral Scale
- **Warm White** (`#f6f5f4`): Background surface tint, section alternation, subtle card fill. The yellow undertone is key.
- **Warm Dark** (`#31302e`): Dark surface background, dark section text. Warmer than standard grays.
- **Warm Gray 500** (`#615d59`): Secondary text, descriptions, muted labels.
- **Warm Gray 300** (`#a39e98`): Placeholder text, disabled states, caption text.
### Semantic Accent Colors
- **Teal** (`#2a9d99`): Success states, positive indicators.
- **Green** (`#1aae39`): Confirmation, completion badges.
- **Orange** (`#dd5b00`): Warning states, attention indicators.
- **Pink** (`#ff64c8`): Decorative accent, feature highlights.
- **Purple** (`#391c57`): Premium features, deep accents.
- **Brown** (`#523410`): Earthy accent, warm feature sections.
### Interactive
- **Link Blue** (`#0075de`): Primary link color with underline-on-hover.
- **Link Light Blue** (`#62aef0`): Lighter link variant for dark backgrounds.
- **Focus Blue** (`#097fe8`): Focus ring on interactive elements.
- **Badge Blue Bg** (`#f2f9ff`): Pill badge background, tinted blue surface.
- **Badge Blue Text** (`#097fe8`): Pill badge text, darker blue for readability.
### Shadows & Depth
- **Card Shadow** (`rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.84688px, rgba(0,0,0,0.02) 0px 0.8px 2.925px, rgba(0,0,0,0.01) 0px 0.175px 1.04062px`): Multi-layer card elevation.
- **Deep Shadow** (`rgba(0,0,0,0.01) 0px 1px 3px, rgba(0,0,0,0.02) 0px 3px 7px, rgba(0,0,0,0.02) 0px 7px 15px, rgba(0,0,0,0.04) 0px 14px 28px, rgba(0,0,0,0.05) 0px 23px 52px`): Five-layer deep elevation for modals and featured content.
- **Whisper Border** (`1px solid rgba(0,0,0,0.1)`): Standard division border -- cards, dividers, sections.
## 3. Typography Rules
### Font Family
- **Primary**: `NotionInter`, with fallbacks: `Inter, -apple-system, system-ui, Segoe UI, Helvetica, Apple Color Emoji, Arial, Segoe UI Emoji, Segoe UI Symbol`
- **OpenType Features**: `"lnum"` (lining numerals) and `"locl"` (localized forms) enabled on display and heading text.
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display Hero | NotionInter | 64px (4.00rem) | 700 | 1.00 (tight) | -2.125px | Maximum compression, billboard headlines |
| Display Secondary | NotionInter | 54px (3.38rem) | 700 | 1.04 (tight) | -1.875px | Secondary hero, feature headlines |
| Section Heading | NotionInter | 48px (3.00rem) | 700 | 1.00 (tight) | -1.5px | Feature section titles, with `"lnum"` |
| Sub-heading Large | NotionInter | 40px (2.50rem) | 700 | 1.50 | normal | Card headings, feature sub-sections |
| Sub-heading | NotionInter | 26px (1.63rem) | 700 | 1.23 (tight) | -0.625px | Section sub-titles, content headers |
| Card Title | NotionInter | 22px (1.38rem) | 700 | 1.27 (tight) | -0.25px | Feature cards, list titles |
| Body Large | NotionInter | 20px (1.25rem) | 600 | 1.40 | -0.125px | Introductions, feature descriptions |
| Body | NotionInter | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text |
| Body Medium | NotionInter | 16px (1.00rem) | 500 | 1.50 | normal | Navigation, emphasized UI text |
| Body Semibold | NotionInter | 16px (1.00rem) | 600 | 1.50 | normal | Strong labels, active states |
| Body Bold | NotionInter | 16px (1.00rem) | 700 | 1.50 | normal | Headlines at body size |
| Nav / Button | NotionInter | 15px (0.94rem) | 600 | 1.33 | normal | Navigation links, button text |
| Caption | NotionInter | 14px (0.88rem) | 500 | 1.43 | normal | Metadata, secondary labels |
| Caption Light | NotionInter | 14px (0.88rem) | 400 | 1.43 | normal | Body captions, descriptions |
| Badge | NotionInter | 12px (0.75rem) | 600 | 1.33 | 0.125px | Pill badges, tags, status labels |
| Micro Label | NotionInter | 12px (0.75rem) | 400 | 1.33 | 0.125px | Small metadata, timestamps |
### Principles
- **Compression at scale**: NotionInter at display sizes uses -2.125px letter-spacing at 64px, progressively relaxing to -0.625px at 26px and normal at 16px. The compression creates density at headlines while maintaining readability at body sizes.
- **Four-weight system**: 400 (body/reading), 500 (UI/interactive), 600 (emphasis/navigation), 700 (headings/display). The broader weight range compared to most systems allows nuanced hierarchy.
- **Warm scaling**: Line height tightens as size increases -- 1.50 at body (16px), 1.23-1.27 at sub-headings, 1.00-1.04 at display. This creates denser, more impactful headlines.
- **Badge micro-tracking**: The 12px badge text uses positive letter-spacing (0.125px) -- the only positive tracking in the system, creating wider, more legible small text.
## 4. Component Stylings
### Buttons
**Primary Blue**
- Background: `#0075de` (Notion Blue)
- Text: `#ffffff`
- Padding: 8px 16px
- Radius: 4px (subtle)
- Border: `1px solid transparent`
- Hover: background darkens to `#005bab`
- Active: scale(0.9) transform
- Focus: `2px solid` focus outline, `var(--shadow-level-200)` shadow
- Use: Primary CTA ("Get Notion free", "Try it")
**Secondary / Tertiary**
- Background: `rgba(0,0,0,0.05)` (translucent warm gray)
- Text: `#000000` (near-black)
- Padding: 8px 16px
- Radius: 4px
- Hover: text color shifts, scale(1.05)
- Active: scale(0.9) transform
- Use: Secondary actions, form submissions
**Ghost / Link Button**
- Background: transparent
- Text: `rgba(0,0,0,0.95)`
- Decoration: underline on hover
- Use: Tertiary actions, inline links
**Pill Badge Button**
- Background: `#f2f9ff` (tinted blue)
- Text: `#097fe8`
- Padding: 4px 8px
- Radius: 9999px (full pill)
- Font: 12px weight 600
- Use: Status badges, feature labels, "New" tags
### Cards & Containers
- Background: `#ffffff`
- Border: `1px solid rgba(0,0,0,0.1)` (whisper border)
- Radius: 12px (standard cards), 16px (featured/hero cards)
- Shadow: `rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.84688px, rgba(0,0,0,0.02) 0px 0.8px 2.925px, rgba(0,0,0,0.01) 0px 0.175px 1.04062px`
- Hover: subtle shadow intensification
- Image cards: 12px top radius, image fills top half
### Inputs & Forms
- Background: `#ffffff`
- Text: `rgba(0,0,0,0.9)`
- Border: `1px solid #dddddd`
- Padding: 6px
- Radius: 4px
- Focus: blue outline ring
- Placeholder: warm gray `#a39e98`
### Navigation
- Clean horizontal nav on white, not sticky
- Brand logo left-aligned (33x34px icon + wordmark)
- Links: NotionInter 15px weight 500-600, near-black text
- Hover: color shift to `var(--color-link-primary-text-hover)`
- CTA: blue pill button ("Get Notion free") right-aligned
- Mobile: hamburger menu collapse
- Product dropdowns with multi-level categorized menus
### Image Treatment
- Product screenshots with `1px solid rgba(0,0,0,0.1)` border
- Top-rounded images: `12px 12px 0px 0px` radius
- Dashboard/workspace preview screenshots dominate feature sections
- Warm gradient backgrounds behind hero illustrations (decorative character illustrations)
### Distinctive Components
**Feature Cards with Illustrations**
- Large illustrative headers (The Great Wave, product UI screenshots)
- 12px radius card with whisper border
- Title at 22px weight 700, description at 16px weight 400
- Warm white (`#f6f5f4`) background variant for alternating sections
**Trust Bar / Logo Grid**
- Company logos (trusted teams section) in their brand colors
- Horizontal scroll or grid layout with team counts
- Metric display: large number + description pattern
**Metric Cards**
- Large number display (e.g., "$4,200 ROI")
- NotionInter 40px+ weight 700 for the metric
- Description below in warm gray body text
- Whisper-bordered card container
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 2px, 3px, 4px, 5px, 6px, 7px, 8px, 11px, 12px, 14px, 16px, 24px, 32px
- Non-rigid organic scale with fractional values (5.6px, 6.4px) for micro-adjustments
### Grid & Container
- Max content width: approximately 1200px
- Hero: centered single-column with generous top padding (80-120px)
- Feature sections: 2-3 column grids for cards
- Full-width warm white (`#f6f5f4`) section backgrounds for alternation
- Code/dashboard screenshots as contained with whisper border
### Whitespace Philosophy
- **Generous vertical rhythm**: 64-120px between major sections. Notion lets content breathe with vast vertical padding.
- **Warm alternation**: White sections alternate with warm white (`#f6f5f4`) sections, creating gentle visual rhythm without harsh color breaks.
- **Content-first density**: Body text blocks are compact (line-height 1.50) but surrounded by ample margin, creating islands of readable content in a sea of white space.
### Border Radius Scale
- Micro (4px): Buttons, inputs, functional interactive elements
- Subtle (5px): Links, list items, menu items
- Standard (8px): Small cards, containers, inline elements
- Comfortable (12px): Standard cards, feature containers, image tops
- Large (16px): Hero cards, featured content, promotional blocks
- Full Pill (9999px): Badges, pills, status indicators
- Circle (100%): Tab indicators, avatars
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow, no border | Page background, text blocks |
| Whisper (Level 1) | `1px solid rgba(0,0,0,0.1)` | Standard borders, card outlines, dividers |
| Soft Card (Level 2) | 4-layer shadow stack (max opacity 0.04) | Content cards, feature blocks |
| Deep Card (Level 3) | 5-layer shadow stack (max opacity 0.05, 52px blur) | Modals, featured panels, hero elements |
| Focus (Accessibility) | `2px solid var(--focus-color)` outline | Keyboard focus on all interactive elements |
**Shadow Philosophy**: Notion's shadow system uses multiple layers with extremely low individual opacity (0.01 to 0.05) that accumulate into soft, natural-looking elevation. The 4-layer card shadow spans from 1.04px to 18px blur, creating a gradient of depth rather than a single hard shadow. The 5-layer deep shadow extends to 52px blur at 0.05 opacity, producing ambient occlusion that feels like natural light rather than computer-generated depth. This layered approach makes elements feel embedded in the page rather than floating above it.
### Decorative Depth
- Hero section: decorative character illustrations (playful, hand-drawn style)
- Section alternation: white to warm white (`#f6f5f4`) background shifts
- No hard section borders -- separation comes from background color changes and spacing
## 7. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <400px | Tight single column, minimal padding |
| Mobile | 400-600px | Standard mobile, stacked layout |
| Tablet Small | 600-768px | 2-column grids begin |
| Tablet | 768-1080px | Full card grids, expanded padding |
| Desktop Small | 1080-1200px | Standard desktop layout |
| Desktop | 1200-1440px | Full layout, maximum content width |
| Large Desktop | >1440px | Centered, generous margins |
### Touch Targets
- Buttons use comfortable padding (8px-16px vertical)
- Navigation links at 15px with adequate spacing
- Pill badges have 8px horizontal padding for tap targets
- Mobile menu toggle uses standard hamburger button
### Collapsing Strategy
- Hero: 64px display -> scales to 40px -> 26px on mobile, maintains proportional letter-spacing
- Navigation: horizontal links + blue CTA -> hamburger menu
- Feature cards: 3-column -> 2-column -> single column stacked
- Product screenshots: maintain aspect ratio with responsive images
- Trust bar logos: grid -> horizontal scroll on mobile
- Footer: multi-column -> stacked single column
- Section spacing: 80px+ -> 48px on mobile
### Image Behavior
- Workspace screenshots maintain whisper border at all sizes
- Hero illustrations scale proportionally
- Product screenshots use responsive images with consistent border radius
- Full-width warm white sections maintain edge-to-edge treatment
## 8. Accessibility & States
### Focus System
- All interactive elements receive visible focus indicators
- Focus outline: `2px solid` with focus color + shadow level 200
- Tab navigation supported throughout all interactive components
- High contrast text: near-black on white exceeds WCAG AAA (>14:1 ratio)
### Interactive States
- **Default**: Standard appearance with whisper borders
- **Hover**: Color shift on text, scale(1.05) on buttons, underline on links
- **Active/Pressed**: scale(0.9) transform, darker background variant
- **Focus**: Blue outline ring with shadow reinforcement
- **Disabled**: Warm gray (`#a39e98`) text, reduced opacity
### Color Contrast
- Primary text (rgba(0,0,0,0.95)) on white: ~18:1 ratio
- Secondary text (#615d59) on white: ~5.5:1 ratio (WCAG AA)
- Blue CTA (#0075de) on white: ~4.6:1 ratio (WCAG AA for large text)
- Badge text (#097fe8) on badge bg (#f2f9ff): ~4.5:1 ratio (WCAG AA for large text)
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: Notion Blue (`#0075de`)
- Background: Pure White (`#ffffff`)
- Alt Background: Warm White (`#f6f5f4`)
- Heading text: Near-Black (`rgba(0,0,0,0.95)`)
- Body text: Near-Black (`rgba(0,0,0,0.95)`)
- Secondary text: Warm Gray 500 (`#615d59`)
- Muted text: Warm Gray 300 (`#a39e98`)
- Border: `1px solid rgba(0,0,0,0.1)`
- Link: Notion Blue (`#0075de`)
- Focus ring: Focus Blue (`#097fe8`)
### Example Component Prompts
- "Create a hero section on white background. Headline at 64px NotionInter weight 700, line-height 1.00, letter-spacing -2.125px, color rgba(0,0,0,0.95). Subtitle at 20px weight 600, line-height 1.40, color #615d59. Blue CTA button (#0075de, 4px radius, 8px 16px padding, white text) and ghost button (transparent bg, near-black text, underline on hover)."
- "Design a card: white background, 1px solid rgba(0,0,0,0.1) border, 12px radius. Use shadow stack: rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.85px, rgba(0,0,0,0.02) 0px 0.8px 2.93px, rgba(0,0,0,0.01) 0px 0.175px 1.04px. Title at 22px NotionInter weight 700, letter-spacing -0.25px. Body at 16px weight 400, color #615d59."
- "Build a pill badge: #f2f9ff background, #097fe8 text, 9999px radius, 4px 8px padding, 12px NotionInter weight 600, letter-spacing 0.125px."
- "Create navigation: white header. NotionInter 15px weight 600 for links, near-black text. Blue pill CTA 'Get Notion free' right-aligned (#0075de bg, white text, 4px radius)."
- "Design an alternating section layout: white sections alternate with warm white (#f6f5f4) sections. Each section has 64-80px vertical padding, max-width 1200px centered. Section heading at 48px weight 700, line-height 1.00, letter-spacing -1.5px."
### Iteration Guide
1. Always use warm neutrals -- Notion's grays have yellow-brown undertones (#f6f5f4, #31302e, #615d59, #a39e98), never blue-gray
2. Letter-spacing scales with font size: -2.125px at 64px, -1.875px at 54px, -0.625px at 26px, normal at 16px
3. Four weights: 400 (read), 500 (interact), 600 (emphasize), 700 (announce)
4. Borders are whispers: 1px solid rgba(0,0,0,0.1) -- never heavier
5. Shadows use 4-5 layers with individual opacity never exceeding 0.05
6. The warm white (#f6f5f4) section background is essential for visual rhythm
7. Pill badges (9999px) for status/tags, 4px radius for buttons and inputs
8. Notion Blue (#0075de) is the only saturated color in core UI -- use it sparingly for CTAs and links
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# Harness 会在每次会话启动时执行此脚本,这里只做轻量且可重复执行的环境检查。
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
command -v git >/dev/null || {
echo "ERROR: 缺少 git"
exit 1
}
command -v python3 >/dev/null || {
echo "ERROR: 缺少 python3"
exit 1
}
if [ -d "$ROOT_DIR/wolai-frontend" ]; then
if ! command -v node >/dev/null; then
echo "WARN: 缺少 node,前端相关任务可能无法执行"
fi
if ! command -v pnpm >/dev/null; then
echo "WARN: 缺少 pnpm,前端相关任务可能无法执行"
fi
fi
echo "Harness 环境检查完成"
+1
View File
@@ -0,0 +1 @@
[2026-04-14T05:21:15Z] [SESSION-0] INIT Harness initialized for project /mnt/Data1T/mnote
+12
View File
@@ -0,0 +1,12 @@
{
"version": 2,
"created": "2026-04-14T05:21:15Z",
"session_config": {
"concurrency_mode": "exclusive",
"max_tasks_per_session": 20,
"max_sessions": 50
},
"tasks": [],
"session_count": 0,
"last_session": null
}
+38
View File
@@ -0,0 +1,38 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "core-domain"
version = "0.1.0"
[[package]]
name = "core-protocol"
version = "0.1.0"
dependencies = [
"core-domain",
]
[[package]]
name = "event-log"
version = "0.1.0"
dependencies = [
"core-domain",
]
[[package]]
name = "index-fts"
version = "0.1.0"
dependencies = [
"core-domain",
"event-log",
]
[[package]]
name = "storage-convex-bridge"
version = "0.1.0"
dependencies = [
"core-domain",
"core-protocol",
"event-log",
]
+18
View File
@@ -0,0 +1,18 @@
[workspace]
members = [
"crates/core-domain",
"crates/core-protocol",
"crates/event-log",
"crates/storage-convex-bridge",
"crates/index-fts",
]
resolver = "2"
[workspace.package]
edition = "2021"
license = "MIT"
version = "0.1.0"
authors = ["mnote"]
[workspace.metadata]
description = "mnote 单仓收口阶段的 Rust 内核 workspace"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "core-domain"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
+6
View File
@@ -0,0 +1,6 @@
# core-domain
阶段 1 骨架 crate。
职责说明请先看:
- ../../design/phase1-b-crate-responsibilities-v0.md
+43
View File
@@ -0,0 +1,43 @@
use crate::ids::{AssetId, AssetVersionId, BlockId, PageId, TaskId};
use crate::time::Timestamp;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActorRef {
pub actor_type: String,
pub actor_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceKind {
Web,
Cli,
Agent,
Job,
Import,
System,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeReason {
pub code: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefLink {
Page(PageId),
Block(BlockId),
Asset(AssetId),
AssetVersion(AssetVersionId),
Task(TaskId),
External(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuditInfo {
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub created_by: Option<ActorRef>,
pub updated_by: Option<ActorRef>,
}
+289
View File
@@ -0,0 +1,289 @@
use crate::audit::{ActorRef, AuditInfo, ChangeReason, RefLink, SourceKind};
use crate::ids::{
AgentSessionId, AssetId, AssetVersionId, BlockId, CommandLogId, EventId, PageId, ReferenceId,
TaskId, WorkspaceId,
};
use crate::time::Timestamp;
use crate::types::{Locale, Revision};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Workspace {
pub workspace_id: WorkspaceId,
pub slug: String,
pub title: String,
pub owner_actor_id: String,
pub default_locale: Locale,
pub storage_policy: String,
pub sync_policy: String,
pub feature_flags: Vec<String>,
pub archived_at: Option<Timestamp>,
pub audit: AuditInfo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PageType {
Note,
Doc,
DatabaseRecord,
Inbox,
Template,
System,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PageStatus {
Active,
Archived,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page {
pub page_id: PageId,
pub workspace_id: WorkspaceId,
pub parent_page_id: Option<PageId>,
pub title: String,
pub slug: String,
pub icon: Option<String>,
pub cover_asset_id: Option<AssetId>,
pub body_root_block_id: Option<BlockId>,
pub page_type: PageType,
pub status: PageStatus,
pub last_edited_at: Option<Timestamp>,
pub last_edited_by: Option<ActorRef>,
pub current_revision: Revision,
pub audit: AuditInfo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockType {
Paragraph,
Heading,
BulletedListItem,
NumberedListItem,
Todo,
Quote,
CodeBlock,
Divider,
Callout,
PageReference,
BlockReference,
EmbedAsset,
EmbedView,
EmbedOnlyoffice,
EmbedMindmap,
EmbedTable,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub block_id: BlockId,
pub workspace_id: WorkspaceId,
pub page_id: PageId,
pub parent_block_id: Option<BlockId>,
pub prev_block_id: Option<BlockId>,
pub next_block_id: Option<BlockId>,
pub sort_key: String,
pub block_type: BlockType,
pub content: String,
pub props: Vec<(String, String)>,
pub annotations: Vec<String>,
pub status: String,
pub revision: Revision,
pub deleted_at: Option<Timestamp>,
pub audit: AuditInfo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssetKind {
Image,
Office,
Pdf,
Audio,
Video,
Export,
OcrDerived,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssetStatus {
Active,
Archived,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Asset {
pub asset_id: AssetId,
pub workspace_id: WorkspaceId,
pub storage_key: String,
pub original_name: String,
pub mime_type: String,
pub size_bytes: u64,
pub checksum: Option<String>,
pub origin: String,
pub asset_kind: AssetKind,
pub status: AssetStatus,
pub latest_version_id: Option<AssetVersionId>,
pub audit: AuditInfo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssetVersion {
pub asset_version_id: AssetVersionId,
pub asset_id: AssetId,
pub version_no: u32,
pub storage_key: String,
pub checksum: Option<String>,
pub derived_from_version_id: Option<AssetVersionId>,
pub change_reason: Option<ChangeReason>,
pub created_at: Timestamp,
pub created_by: Option<ActorRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReferenceKind {
PageToPage,
BlockToBlock,
BlockToPage,
BlockToAssetFragment,
TaskToObject,
External,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reference {
pub reference_id: ReferenceId,
pub workspace_id: WorkspaceId,
pub source_object_type: String,
pub source_object_id: String,
pub target_object_type: String,
pub target_object_id: String,
pub anchor: String,
pub label: Option<String>,
pub snippet: Option<String>,
pub confidence: Option<String>,
pub ref_kind: ReferenceKind,
pub created_at: Timestamp,
pub created_by: Option<ActorRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskType {
Import,
Ocr,
Summary,
Reindex,
Sync,
Transform,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskStatus {
Pending,
Running,
Succeeded,
Failed,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskPriority {
Low,
Normal,
High,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
pub task_id: TaskId,
pub workspace_id: WorkspaceId,
pub title: String,
pub description: Option<String>,
pub task_type: TaskType,
pub status: TaskStatus,
pub priority: TaskPriority,
pub assignee_actor_id: Option<String>,
pub source_page_id: Option<PageId>,
pub source_block_id: Option<BlockId>,
pub input_payload: Option<String>,
pub output_payload: Option<String>,
pub due_at: Option<Timestamp>,
pub completed_at: Option<Timestamp>,
pub audit: AuditInfo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSession {
pub agent_session_id: AgentSessionId,
pub workspace_id: WorkspaceId,
pub provider: String,
pub model: String,
pub initiator_actor_id: Option<String>,
pub status: String,
pub started_at: Timestamp,
pub updated_at: Timestamp,
pub ended_at: Option<Timestamp>,
pub tool_policy: Option<String>,
pub confirmation_policy: Option<String>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandStatus {
Pending,
Succeeded,
Failed,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandLog {
pub command_log_id: CommandLogId,
pub workspace_id: WorkspaceId,
pub command_name: String,
pub actor: ActorRef,
pub source: SourceKind,
pub target_objects: Vec<RefLink>,
pub payload_summary: String,
pub refs: Vec<RefLink>,
pub idempotency_key: Option<String>,
pub dry_run: bool,
pub status: CommandStatus,
pub created_at: Timestamp,
pub finished_at: Option<Timestamp>,
pub error_code: Option<String>,
pub error_message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventType {
PageCreated,
PageUpdated,
BlockInserted,
BlockMoved,
BlockUpdated,
BlockDeleted,
AssetVersionAdded,
ReferenceCreated,
TaskUpdated,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DomainEvent {
pub event_id: EventId,
pub workspace_id: WorkspaceId,
pub command_log_id: CommandLogId,
pub aggregate_type: String,
pub aggregate_id: String,
pub event_type: EventType,
pub before_revision: Option<Revision>,
pub after_revision: Option<Revision>,
pub payload_json: String,
pub created_at: Timestamp,
}
+27
View File
@@ -0,0 +1,27 @@
macro_rules! define_id {
($name:ident) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $name(pub String);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
};
}
define_id!(WorkspaceId);
define_id!(PageId);
define_id!(BlockId);
define_id!(AssetId);
define_id!(AssetVersionId);
define_id!(ReferenceId);
define_id!(TaskId);
define_id!(AgentSessionId);
define_id!(CommandLogId);
define_id!(EventId);
+34
View File
@@ -0,0 +1,34 @@
pub mod audit;
pub mod entities;
pub mod ids;
pub mod time;
pub mod types;
pub use audit::{ActorRef, AuditInfo, ChangeReason, RefLink, SourceKind};
pub use entities::{
AgentSession, Asset, AssetKind, AssetStatus, AssetVersion, Block, BlockType, CommandLog,
CommandStatus, DomainEvent, EventType, Page, PageStatus, PageType, Reference, ReferenceKind,
Task, TaskPriority, TaskStatus, TaskType, Workspace,
};
pub use ids::{
AgentSessionId, AssetId, AssetVersionId, BlockId, CommandLogId, EventId, PageId, ReferenceId,
TaskId, WorkspaceId,
};
pub use time::Timestamp;
pub use types::{Locale, Revision};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn revision_initial_is_one() {
assert_eq!(Revision::initial().0, 1);
}
#[test]
fn workspace_id_keeps_original_value() {
let workspace_id = WorkspaceId::new("ws_demo");
assert_eq!(workspace_id.as_str(), "ws_demo");
}
}
+12
View File
@@ -0,0 +1,12 @@
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Timestamp(pub String);
impl Timestamp {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
+17
View File
@@ -0,0 +1,17 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Revision(pub u64);
impl Revision {
pub fn initial() -> Self {
Self(1)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Locale(pub String);
impl Locale {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "core-protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
core-domain = { path = "../core-domain" }
+8
View File
@@ -0,0 +1,8 @@
# core-protocol
阶段 1 协议模型 crate。
职责说明请先看:
- ../../design/phase1-d-command-api-v0.md
- ../../design/phase1-d-query-api-v0.md
- ../../design/phase1-d-tool-api-constraints-v0.md
+101
View File
@@ -0,0 +1,101 @@
use crate::common::{ActorPayload, AffectedObject, SourcePayload, TargetRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandEnvelope<T> {
pub name: String,
pub command_id: String,
pub idempotency_key: Option<String>,
pub actor: ActorPayload,
pub source: SourcePayload,
pub target: Option<TargetRef>,
pub payload: T,
pub reason: Option<String>,
pub refs: Vec<String>,
pub dry_run: bool,
pub validate_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandResult {
pub ok: bool,
pub command_id: String,
pub event_ids: Vec<String>,
pub affected_objects: Vec<AffectedObject>,
pub revision: Option<u64>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateWorkspace {
pub name: String,
pub slug: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatePage {
pub title: String,
pub parent_page_id: Option<String>,
pub position: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatePageTitle {
pub page_id: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatePageStats {
pub page_id: String,
pub word_count: i64,
pub character_count: i64,
pub block_count: i64,
pub todo_total: i64,
pub todo_done: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatePageOptions {
pub page_id: String,
pub wide_layout: Option<bool>,
pub small_text: Option<bool>,
pub show_heading_numbers: Option<bool>,
pub show_toc: Option<bool>,
pub show_structure: Option<bool>,
pub protect_editing: Option<bool>,
pub show_word_count: Option<bool>,
pub collapse_backlinks: Option<bool>,
pub page_font: Option<String>,
pub layout_density: Option<String>,
pub hide_child_pages: Option<bool>,
pub show_block_ref_count: Option<bool>,
pub embed_default_block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InsertBlock {
pub page_id: String,
pub block_type: String,
pub content: String,
pub parent_block_id: Option<String>,
pub prev_block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateBlock {
pub block_id: String,
pub patch_content: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MoveBlock {
pub block_id: String,
pub new_parent_block_id: Option<String>,
pub new_page_id: Option<String>,
pub prev_block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteBlock {
pub block_id: String,
}
+59
View File
@@ -0,0 +1,59 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActorPayload {
pub actor_type: String,
pub actor_id: String,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourcePayload {
pub channel: String,
pub client: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetRef {
pub workspace_id: Option<String>,
pub page_id: Option<String>,
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestMeta {
pub idempotency_key: Option<String>,
pub validate_only: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AffectedObject {
pub object_type: String,
pub object_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseMeta {
pub request_id: String,
pub trace_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OkPayload<T> {
pub data: T,
pub meta: ResponseMeta,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorDetail {
pub field: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorPayload {
pub code: String,
pub message: String,
pub details: Vec<ErrorDetail>,
pub retryable: bool,
pub meta: ResponseMeta,
}
+116
View File
@@ -0,0 +1,116 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessContext {
pub tenant_id: Option<String>,
pub workspace_id: String,
pub actor_id: String,
pub actor_type: String,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessDecision {
Allow,
Deny(AccessDenyReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessDenyReason {
MissingTenant,
MissingWorkspace,
PermissionDenied,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JobStatus {
Pending,
Running,
Succeeded,
Failed,
RetryScheduled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobTicket {
pub job_id: String,
pub job_type: String,
pub request_id: String,
pub trace_id: String,
pub workspace_id: String,
pub status: JobStatus,
}
impl JobTicket {
pub fn new(
job_id: impl Into<String>,
job_type: impl Into<String>,
request_id: impl Into<String>,
trace_id: impl Into<String>,
workspace_id: impl Into<String>,
status: JobStatus,
) -> Self {
Self {
job_id: job_id.into(),
job_type: job_type.into(),
request_id: request_id.into(),
trace_id: trace_id.into(),
workspace_id: workspace_id.into(),
status,
}
}
}
pub fn decide_access(context: &AccessContext) -> AccessDecision {
if context.tenant_id.is_none() {
return AccessDecision::Deny(AccessDenyReason::MissingTenant);
}
if context.workspace_id.trim().is_empty() {
return AccessDecision::Deny(AccessDenyReason::MissingWorkspace);
}
AccessDecision::Allow
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_requires_tenant() {
let context = AccessContext {
tenant_id: None,
workspace_id: "ws-1".to_string(),
actor_id: "user-1".to_string(),
actor_type: "human".to_string(),
source: "react-next".to_string(),
};
assert_eq!(
decide_access(&context),
AccessDecision::Deny(AccessDenyReason::MissingTenant)
);
}
#[test]
fn access_allows_valid_context() {
let context = AccessContext {
tenant_id: Some("tenant-1".to_string()),
workspace_id: "ws-1".to_string(),
actor_id: "user-1".to_string(),
actor_type: "human".to_string(),
source: "react-next".to_string(),
};
assert_eq!(decide_access(&context), AccessDecision::Allow);
}
#[test]
fn job_ticket_keeps_trace_fields() {
let ticket = JobTicket::new(
"job-1",
"rebuild-index",
"req-1",
"trace-1",
"ws-1",
JobStatus::Pending,
);
assert_eq!(ticket.trace_id, "trace-1");
assert_eq!(ticket.status, JobStatus::Pending);
}
}
+50
View File
@@ -0,0 +1,50 @@
pub mod command;
pub mod common;
pub mod governance;
pub mod query;
pub mod tool;
pub use command::{
CommandEnvelope, CreatePage, CreateWorkspace, DeleteBlock, InsertBlock, MoveBlock,
UpdateBlock, UpdatePageStats, UpdatePageTitle,
};
pub use common::{
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
SourcePayload, TargetRef,
};
pub use query::{GetPage, ListPageBlocks, QueryEnvelope, SearchBlocks, SearchPages};
pub use tool::{InvocationKind, ToolInvocation};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_envelope_keeps_validate_only_flag() {
let envelope = CommandEnvelope::<CreateWorkspace> {
name: "create_workspace".into(),
command_id: "cmd_1".into(),
idempotency_key: Some("idem_1".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "cli".into(),
client: "mnote-cli".into(),
},
target: None,
payload: CreateWorkspace {
name: "demo".into(),
slug: Some("demo".into()),
},
reason: Some("初始化工作区".into()),
refs: vec![],
dry_run: false,
validate_only: true,
};
assert!(envelope.validate_only);
}
}
+36
View File
@@ -0,0 +1,36 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryEnvelope<T> {
pub name: String,
pub payload: T,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pagination {
pub limit: u32,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetPage {
pub page_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListPageBlocks {
pub page_id: String,
pub pagination: Pagination,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchPages {
pub query: String,
pub workspace_id: Option<String>,
pub pagination: Pagination,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchBlocks {
pub query: String,
pub page_id: Option<String>,
pub pagination: Pagination,
}
+13
View File
@@ -0,0 +1,13 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InvocationKind {
Command,
Query,
Job,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolInvocation {
pub tool: String,
pub kind: InvocationKind,
pub args_json: String,
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "event-log"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
core-domain = { path = "../core-domain" }
+8
View File
@@ -0,0 +1,8 @@
# event-log
阶段 1 事件日志 crate。
职责说明请先看:
- ../../design/phase1-e-command-log-v0.md
- ../../design/phase1-e-domain-event-v0.md
- ../../design/phase1-e-event-generation-rules-v0.md
+29
View File
@@ -0,0 +1,29 @@
use core_domain::Timestamp;
use core_domain::{RefLink, WorkspaceId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandLogStatus {
Pending,
Succeeded,
Failed,
RolledBack,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandLogRecord {
pub command_log_id: String,
pub command_name: String,
pub actor_type: String,
pub actor_id: String,
pub source: String,
pub workspace_id: WorkspaceId,
pub target_objects: Vec<RefLink>,
pub payload_summary: String,
pub refs_json: String,
pub idempotency_key: Option<String>,
pub status: CommandLogStatus,
pub created_at: Timestamp,
pub finished_at: Option<Timestamp>,
pub trace_id: String,
pub request_id: String,
}
+36
View File
@@ -0,0 +1,36 @@
use core_domain::Timestamp;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventStatus {
Pending,
Committed,
Rejected,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DomainEventRecord {
pub event_id: String,
pub workspace_id: String,
pub aggregate_type: String,
pub aggregate_id: String,
pub event_type: String,
pub event_version: u32,
pub payload_json: String,
pub command_log_id: String,
pub actor_type: String,
pub created_at: Timestamp,
pub status: EventStatus,
pub trace_id: String,
pub request_id: String,
pub command_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventGenerationOutcome {
pub command_log_id: String,
pub emitted_event_ids: Vec<String>,
pub generated_for_replay: bool,
pub generated_for_audit: bool,
pub generated_for_index_catchup: bool,
}
+23
View File
@@ -0,0 +1,23 @@
pub mod command_log;
pub mod domain_event;
pub mod rules;
pub use command_log::{CommandLogRecord, CommandLogStatus};
pub use domain_event::{DomainEventRecord, EventGenerationOutcome, EventStatus};
pub use rules::{should_emit_domain_event, EventGenerationRule};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_command_does_not_emit_success_event() {
let should_emit = should_emit_domain_event(&EventGenerationRule {
command_succeeded: false,
changed_domain_state: false,
rolled_back: false,
});
assert!(!should_emit);
}
}
+10
View File
@@ -0,0 +1,10 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventGenerationRule {
pub command_succeeded: bool,
pub changed_domain_state: bool,
pub rolled_back: bool,
}
pub fn should_emit_domain_event(rule: &EventGenerationRule) -> bool {
rule.command_succeeded && rule.changed_domain_state && !rule.rolled_back
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "index-fts"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
core-domain = { path = "../core-domain" }
event-log = { path = "../event-log" }
+6
View File
@@ -0,0 +1,6 @@
# index-fts
阶段 1 骨架 crate。
职责说明请先看:
- ../../design/phase1-b-crate-responsibilities-v0.md
+355
View File
@@ -0,0 +1,355 @@
use event_log::DomainEventRecord;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexedEntityKind {
PageTitle,
PageSummary,
BlockContent,
BlockPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedDocument {
pub workspace_id: String,
pub entity_kind: IndexedEntityKind,
pub entity_id: String,
pub parent_id: Option<String>,
pub title: Option<String>,
pub content: String,
pub updated_at: String,
pub source_event_id: Option<String>,
pub revision: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexCursor {
pub workspace_id: String,
pub last_processed_event_id: String,
pub last_processed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
pub workspace_id: String,
pub entity_kind: IndexedEntityKind,
pub entity_id: String,
pub snippet: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResultSet {
pub query: String,
pub workspace_id: Option<String>,
pub page_id: Option<String>,
pub hits: Vec<SearchHit>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectedDocumentBatch {
pub workspace_id: String,
pub source_event_id: String,
pub documents: Vec<IndexedDocument>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionResult {
pub cursor: IndexCursor,
pub batches: Vec<ProjectedDocumentBatch>,
}
pub trait DomainEventProjector {
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
}
pub fn supported_index_objects() -> Vec<IndexedEntityKind> {
vec![
IndexedEntityKind::PageTitle,
IndexedEntityKind::PageSummary,
IndexedEntityKind::BlockContent,
IndexedEntityKind::BlockPath,
]
}
pub fn can_rebuild_from_events(cursor: &IndexCursor) -> bool {
!cursor.workspace_id.trim().is_empty() && !cursor.last_processed_event_id.trim().is_empty()
}
pub fn advance_workspace_cursor(
cursor: &IndexCursor,
event_id: impl Into<String>,
processed_at: impl Into<String>,
) -> IndexCursor {
IndexCursor {
workspace_id: cursor.workspace_id.clone(),
last_processed_event_id: event_id.into(),
last_processed_at: processed_at.into(),
}
}
pub fn project_domain_event<P: DomainEventProjector>(
projector: &P,
event: &DomainEventRecord,
cursor: &IndexCursor,
) -> ProjectionResult {
let documents = projector.project(event);
ProjectionResult {
cursor: advance_workspace_cursor(
cursor,
event.event_id.clone(),
event.created_at.as_str().to_string(),
),
batches: if documents.is_empty() {
Vec::new()
} else {
vec![ProjectedDocumentBatch {
workspace_id: event.workspace_id.clone(),
source_event_id: event.event_id.clone(),
documents,
}]
},
}
}
pub fn rebuild_from_events<P: DomainEventProjector>(
projector: &P,
events: &[DomainEventRecord],
cursor: &IndexCursor,
) -> ProjectionResult {
let mut next_cursor = cursor.clone();
let mut batches = Vec::new();
for event in events {
if event.workspace_id != cursor.workspace_id {
continue;
}
let projected = projector.project(event);
next_cursor = advance_workspace_cursor(
&next_cursor,
event.event_id.clone(),
event.created_at.as_str().to_string(),
);
if !projected.is_empty() {
batches.push(ProjectedDocumentBatch {
workspace_id: event.workspace_id.clone(),
source_event_id: event.event_id.clone(),
documents: projected,
});
}
}
ProjectionResult {
cursor: next_cursor,
batches,
}
}
pub fn search_pages(
query: &str,
workspace_id: Option<&str>,
documents: &[IndexedDocument],
limit: usize,
) -> SearchResultSet {
let query = query.trim().to_lowercase();
let hits = documents
.iter()
.filter(|document| matches!(document.entity_kind, IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary))
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
.filter(|document| document.title.as_deref().unwrap_or("").to_lowercase().contains(&query)
|| document.content.to_lowercase().contains(&query))
.take(limit)
.map(|document| SearchHit {
workspace_id: document.workspace_id.clone(),
entity_kind: document.entity_kind.clone(),
entity_id: document.entity_id.clone(),
snippet: Some(document.content.chars().take(120).collect()),
})
.collect();
SearchResultSet {
query: query.into(),
workspace_id: workspace_id.map(|value| value.into()),
page_id: None,
hits,
}
}
pub fn search_blocks(
query: &str,
page_id: Option<&str>,
documents: &[IndexedDocument],
limit: usize,
) -> SearchResultSet {
let query = query.trim().to_lowercase();
let hits = documents
.iter()
.filter(|document| matches!(document.entity_kind, IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath))
.filter(|document| page_id.map_or(true, |page| document.parent_id.as_deref() == Some(page) || document.entity_id == page))
.filter(|document| document.content.to_lowercase().contains(&query))
.take(limit)
.map(|document| SearchHit {
workspace_id: document.workspace_id.clone(),
entity_kind: document.entity_kind.clone(),
entity_id: document.entity_id.clone(),
snippet: Some(document.content.chars().take(120).collect()),
})
.collect();
SearchResultSet {
query: query.into(),
workspace_id: None,
page_id: page_id.map(|value| value.into()),
hits,
}
}
pub struct MinimalWorkspaceProjector;
impl DomainEventProjector for MinimalWorkspaceProjector {
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument> {
let content = event.payload_json.trim();
let (entity_kind, title, parent_id) = match event.aggregate_type.as_str() {
"page" => (
IndexedEntityKind::PageTitle,
Some(event.event_type.clone()),
None,
),
"block" => (
IndexedEntityKind::BlockContent,
None,
Some(event.aggregate_id.clone()),
),
_ => return Vec::new(),
};
vec![IndexedDocument {
workspace_id: event.workspace_id.clone(),
entity_kind,
entity_id: event.aggregate_id.clone(),
parent_id,
title,
content: content.to_string(),
updated_at: event.created_at.as_str().to_string(),
source_event_id: Some(event.event_id.clone()),
revision: Some(event.event_version as u64),
}]
}
}
#[cfg(test)]
mod tests {
use super::*;
use core_domain::Timestamp;
use event_log::EventStatus;
fn cursor() -> IndexCursor {
IndexCursor {
workspace_id: "ws_1".into(),
last_processed_event_id: "evt_0".into(),
last_processed_at: "2026-04-11T00:00:00Z".into(),
}
}
fn event(
workspace_id: &str,
aggregate_type: &str,
aggregate_id: &str,
event_id: &str,
event_type: &str,
) -> DomainEventRecord {
DomainEventRecord {
event_id: event_id.into(),
workspace_id: workspace_id.into(),
aggregate_type: aggregate_type.into(),
aggregate_id: aggregate_id.into(),
event_type: event_type.into(),
event_version: 1,
payload_json: "{\"text\":\"hello\"}".into(),
command_log_id: "cmd_1".into(),
actor_type: "human".into(),
created_at: Timestamp::new("2026-04-11T00:00:01Z"),
status: EventStatus::Committed,
trace_id: "trace_1".into(),
request_id: "req_1".into(),
command_id: "cmd_1".into(),
}
}
#[test]
fn supports_page_and_block_projection_objects() {
assert_eq!(
supported_index_objects(),
vec![
IndexedEntityKind::PageTitle,
IndexedEntityKind::PageSummary,
IndexedEntityKind::BlockContent,
IndexedEntityKind::BlockPath
]
);
}
#[test]
fn cursor_advances_incrementally() {
let next = advance_workspace_cursor(&cursor(), "evt_2", "2026-04-11T00:00:02Z");
assert_eq!(next.last_processed_event_id, "evt_2");
assert_eq!(next.workspace_id, "ws_1");
}
#[test]
fn projector_builds_real_documents_for_page_and_block() {
let projector = MinimalWorkspaceProjector;
let page_docs =
projector.project(&event("ws_1", "page", "page_1", "evt_1", "page.created"));
let block_docs =
projector.project(&event("ws_1", "block", "block_1", "evt_2", "block.created"));
assert_eq!(page_docs.len(), 1);
assert_eq!(block_docs.len(), 1);
assert_eq!(page_docs[0].entity_kind, IndexedEntityKind::PageTitle);
assert_eq!(block_docs[0].entity_kind, IndexedEntityKind::BlockContent);
}
#[test]
fn rebuild_skips_other_workspaces_and_keeps_cursor() {
let projector = MinimalWorkspaceProjector;
let events = vec![
event("ws_2", "page", "page_x", "evt_x", "page.created"),
event("ws_1", "block", "block_1", "evt_1", "block.created"),
];
let result = rebuild_from_events(&projector, &events, &cursor());
assert_eq!(result.batches.len(), 1);
assert_eq!(result.cursor.last_processed_event_id, "evt_1");
}
#[test]
fn search_helpers_return_hits() {
let docs = vec![
IndexedDocument {
workspace_id: "ws_1".into(),
entity_kind: IndexedEntityKind::PageTitle,
entity_id: "page_1".into(),
parent_id: None,
title: Some("Rust Notes".into()),
content: "Rust notes for phase 2".into(),
updated_at: "2026-04-11T00:00:01Z".into(),
source_event_id: Some("evt_1".into()),
revision: Some(1),
},
IndexedDocument {
workspace_id: "ws_1".into(),
entity_kind: IndexedEntityKind::BlockContent,
entity_id: "block_1".into(),
parent_id: Some("page_1".into()),
title: None,
content: "A block about rust search indexing".into(),
updated_at: "2026-04-11T00:00:02Z".into(),
source_event_id: Some("evt_2".into()),
revision: Some(1),
},
];
let pages = search_pages("rust", Some("ws_1"), &docs, 10);
assert_eq!(pages.hits.len(), 1);
assert_eq!(pages.hits[0].entity_id, "page_1");
let blocks = search_blocks("search", Some("page_1"), &docs, 10);
assert_eq!(blocks.hits.len(), 1);
assert_eq!(blocks.hits[0].entity_id, "block_1");
}
}
@@ -0,0 +1,11 @@
[package]
name = "storage-convex-bridge"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
core-domain = { path = "../core-domain" }
core-protocol = { path = "../core-protocol" }
event-log = { path = "../event-log" }
@@ -0,0 +1,18 @@
# storage-convex-bridge
阶段 1 的 Convex 桥接 crate。
当前职责:
- 把 Rust 协议对象映射为 Convex 读写请求
- 透传 request / actor / workspace / auth 上下文
- 生成最小 CommandLog / DomainEvent 骨架
当前明确不做:
- 不复制主事实层
- 不维护第二数据库
- 不隐藏 tenant / auth / request context
- 不承载复杂业务裁决
相关设计文档:
- `../../design/phase1-f-bridge-responsibilities-v0.md`
- `../../design/phase1-f-write-read-flow-v0.md`
@@ -0,0 +1,18 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BridgeContext {
pub deployment_id: Option<String>,
pub project_id: Option<String>,
pub workspace_id: Option<String>,
pub request_id: String,
pub trace_id: String,
pub actor_type: String,
pub actor_id: String,
pub session_id: Option<String>,
pub tenant_id: Option<String>,
pub auth_token: Option<String>,
pub source_channel: String,
pub source_client: String,
pub idempotency_key: Option<String>,
pub validate_only: bool,
pub dry_run: bool,
}
@@ -0,0 +1,266 @@
pub mod context;
pub mod mapping;
pub mod read_path;
pub mod types;
pub mod validation;
pub mod write_path;
pub use context::BridgeContext;
pub use mapping::{
map_command_name_to_convex, map_query_name_to_convex, ConvexMutationRequest, ConvexQueryRequest,
};
pub use read_path::{build_query_request, QueryReadResult};
pub use types::{BridgeError, BridgeErrorKind, BridgeResult};
pub use validation::{validate_command_envelope, validate_query_envelope};
pub use write_path::{
build_command_log_record, build_domain_event_record, build_write_pipeline, build_write_request,
WritePipelineResult,
};
#[cfg(test)]
mod tests {
use super::*;
use core_protocol::{
command::{CreatePage, CreateWorkspace, UpdatePageOptions, UpdatePageStats, UpdatePageTitle},
query::GetPage,
ActorPayload, CommandEnvelope, QueryEnvelope, SourcePayload, TargetRef,
};
fn demo_context() -> BridgeContext {
BridgeContext {
deployment_id: Some("dep_1".into()),
project_id: Some("proj_1".into()),
request_id: "req_1".into(),
trace_id: "trace_1".into(),
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
workspace_id: Some("ws_1".into()),
tenant_id: Some("tenant_1".into()),
auth_token: Some("token_1".into()),
source_channel: "cli".into(),
source_client: "mnote-cli".into(),
idempotency_key: Some("idem_ctx".into()),
validate_only: false,
dry_run: false,
}
}
#[test]
fn build_write_request_keeps_workspace_scope() {
let command = CommandEnvelope {
name: "create_page".into(),
command_id: "cmd_1".into(),
idempotency_key: Some("idem_cmd".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "cli".into(),
client: "mnote-cli".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: None,
block_id: None,
}),
payload: CreatePage {
title: "第一页".into(),
parent_page_id: None,
position: None,
},
reason: Some("初始化页面".into()),
refs: vec!["spec:phase1-f".into()],
dry_run: false,
validate_only: false,
};
let request = build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "pages:create");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert_eq!(request.deployment_id.as_deref(), Some("dep_1"));
assert_eq!(request.project_id.as_deref(), Some("proj_1"));
assert_eq!(request.idempotency_key.as_deref(), Some("idem_cmd"));
assert!(request.payload_json.contains("\"kind\":\"command\""));
assert!(request.payload_json.contains("\"name\":\"create_page\""));
}
#[test]
fn build_query_request_includes_trace_and_workspace() {
let query = QueryEnvelope {
name: "get_page".into(),
payload: GetPage {
page_id: "page_1".into(),
},
};
let request = build_query_request(&demo_context(), &query).expect("query request should build");
assert_eq!(request.function_name, "pages:get");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert_eq!(request.request_id, "req_1");
assert_eq!(request.trace_id, "trace_1");
assert!(request.payload_json.contains("\"kind\":\"query\""));
assert!(request.payload_json.contains("\"name\":\"get_page\""));
}
#[test]
fn build_write_pipeline_sets_trace_fields() {
let command = CommandEnvelope {
name: "create_workspace".into(),
command_id: "cmd_2".into(),
idempotency_key: None,
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "cli".into(),
client: "mnote-cli".into(),
},
target: None,
payload: CreateWorkspace {
name: "demo".into(),
slug: Some("demo".into()),
},
reason: Some("初始化工作区".into()),
refs: vec![],
dry_run: false,
validate_only: false,
};
let pipeline = build_write_pipeline(&demo_context(), &command).expect("pipeline should build");
assert_eq!(pipeline.command_log.command_name, "create_workspace");
assert!(pipeline.command_log.payload_summary.contains("request_id=req_1"));
assert_eq!(pipeline.command_log.trace_id, "trace_1");
assert_eq!(pipeline.command_log.request_id, "req_1");
assert!(pipeline.domain_event.payload_json.contains("\"trace_id\":\"trace_1\""));
assert_eq!(pipeline.result.event_ids, vec!["evt_cmd_2".to_string()]);
}
#[test]
fn document_title_command_maps_to_documents_update_title() {
let command = CommandEnvelope {
name: "documents.title.update".into(),
command_id: "cmd_title_1".into(),
idempotency_key: Some("idem_title".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: Some("page_1".into()),
block_id: None,
}),
payload: UpdatePageTitle {
page_id: "page_1".into(),
title: "新标题".into(),
},
reason: None,
refs: vec![],
dry_run: false,
validate_only: false,
};
let request = build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateTitle");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request.payload_json.contains("\"name\":\"documents.title.update\""));
}
#[test]
fn document_stats_command_maps_to_documents_update_stats() {
let command = CommandEnvelope {
name: "documents.stats.update".into(),
command_id: "cmd_stats_1".into(),
idempotency_key: Some("idem_stats".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: Some("page_1".into()),
block_id: None,
}),
payload: UpdatePageStats {
page_id: "page_1".into(),
word_count: 10,
character_count: 20,
block_count: 3,
todo_total: 4,
todo_done: 1,
},
reason: None,
refs: vec![],
dry_run: false,
validate_only: false,
};
let request = build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateStats");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request.payload_json.contains("\"name\":\"documents.stats.update\""));
}
#[test]
fn document_options_command_maps_to_documents_update_options() {
let command = CommandEnvelope {
name: "documents.options.update".into(),
command_id: "cmd_options_1".into(),
idempotency_key: Some("idem_options".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: Some("page_1".into()),
block_id: None,
}),
payload: UpdatePageOptions {
page_id: "page_1".into(),
wide_layout: None,
small_text: None,
show_heading_numbers: None,
show_toc: Some(true),
show_structure: None,
protect_editing: None,
show_word_count: None,
collapse_backlinks: None,
page_font: None,
layout_density: Some("compact".into()),
hide_child_pages: None,
show_block_ref_count: None,
embed_default_block_id: None,
},
reason: None,
refs: vec![],
dry_run: false,
validate_only: false,
};
let request = build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateOptions");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request.payload_json.contains("\"name\":\"documents.options.update\""));
}
}
@@ -0,0 +1,93 @@
use crate::context::BridgeContext;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConvexMutationRequest {
pub function_name: String,
pub deployment_id: Option<String>,
pub project_id: Option<String>,
pub workspace_id: Option<String>,
pub request_id: String,
pub trace_id: String,
pub idempotency_key: Option<String>,
pub actor_id: String,
pub payload_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConvexQueryRequest {
pub function_name: String,
pub deployment_id: Option<String>,
pub project_id: Option<String>,
pub workspace_id: Option<String>,
pub request_id: String,
pub trace_id: String,
pub actor_id: String,
pub payload_json: String,
}
pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
match command_name {
"create_workspace" => "workspaces:create",
"create_page" => "pages:create",
"documents.title.update" => "documents:updateTitle",
"documents.stats.update" => "documents:updateStats",
"documents.options.update" => "documents:updateOptions",
"insert_block" => "blocks:insert",
"update_block" => "blocks:update",
"move_block" => "blocks:move",
"delete_block" => "blocks:delete",
_ => "commands:unknown",
}
}
pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
match query_name {
"get_page" => "pages:get",
"list_page_blocks" => "blocks:list_by_page",
"search_pages" => "search:pages",
"search_blocks" => "search:blocks",
_ => "queries:unknown",
}
}
fn json_opt(value: &Option<String>) -> String {
match value {
Some(v) => format!("\"{}\"", v),
None => "null".into(),
}
}
pub fn payload_json_for_command(context: &BridgeContext, command_name: &str) -> String {
format!(
"{{\"kind\":\"command\",\"name\":\"{}\",\"request_id\":\"{}\",\"trace_id\":\"{}\",\"deployment_id\":{},\"project_id\":{},\"workspace_id\":{},\"tenant_id\":{},\"idempotency_key\":{},\"actor\":{{\"type\":\"{}\",\"id\":\"{}\",\"session_id\":{}}},\"source\":{{\"channel\":\"{}\",\"client\":\"{}\"}}}}",
command_name,
context.request_id,
context.trace_id,
json_opt(&context.deployment_id),
json_opt(&context.project_id),
json_opt(&context.workspace_id),
json_opt(&context.tenant_id),
json_opt(&context.idempotency_key),
context.actor_type,
context.actor_id,
json_opt(&context.session_id),
context.source_channel,
context.source_client,
)
}
pub fn payload_json_for_query(context: &BridgeContext, query_name: &str) -> String {
format!(
"{{\"kind\":\"query\",\"name\":\"{}\",\"request_id\":\"{}\",\"trace_id\":\"{}\",\"deployment_id\":{},\"project_id\":{},\"workspace_id\":{},\"tenant_id\":{},\"actor_id\":\"{}\",\"source\":{{\"channel\":\"{}\",\"client\":\"{}\"}}}}",
query_name,
context.request_id,
context.trace_id,
json_opt(&context.deployment_id),
json_opt(&context.project_id),
json_opt(&context.workspace_id),
json_opt(&context.tenant_id),
context.actor_id,
context.source_channel,
context.source_client,
)
}
@@ -0,0 +1,29 @@
use crate::context::BridgeContext;
use crate::mapping::{map_query_name_to_convex, payload_json_for_query, ConvexQueryRequest};
use crate::types::BridgeResult;
use crate::validation::validate_query_envelope;
use core_protocol::QueryEnvelope;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryReadResult {
pub workspace_id: Option<String>,
pub request_id: String,
pub trace_id: String,
}
pub fn build_query_request<T>(
context: &BridgeContext,
query: &QueryEnvelope<T>,
) -> BridgeResult<ConvexQueryRequest> {
validate_query_envelope(context, query)?;
Ok(ConvexQueryRequest {
function_name: map_query_name_to_convex(&query.name).to_string(),
deployment_id: context.deployment_id.clone(),
project_id: context.project_id.clone(),
workspace_id: context.workspace_id.clone(),
request_id: context.request_id.clone(),
trace_id: context.trace_id.clone(),
actor_id: context.actor_id.clone(),
payload_json: payload_json_for_query(context, &query.name),
})
}
@@ -0,0 +1,26 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BridgeErrorKind {
Validation,
Unauthorized,
Conflict,
NotFound,
Transport,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BridgeError {
pub kind: BridgeErrorKind,
pub message: String,
}
impl BridgeError {
pub fn validation(message: impl Into<String>) -> Self {
Self {
kind: BridgeErrorKind::Validation,
message: message.into(),
}
}
}
pub type BridgeResult<T> = Result<T, BridgeError>;
@@ -0,0 +1,50 @@
use crate::context::BridgeContext;
use crate::types::{BridgeError, BridgeResult};
use core_protocol::{CommandEnvelope, QueryEnvelope};
pub fn validate_command_envelope<T>(
context: &BridgeContext,
command: &CommandEnvelope<T>,
) -> BridgeResult<()> {
if command.name.trim().is_empty() {
return Err(BridgeError::validation("command name 不能为空"));
}
if command.command_id.trim().is_empty() {
return Err(BridgeError::validation("command_id 不能为空"));
}
if context.request_id.trim().is_empty() {
return Err(BridgeError::validation("request_id 不能为空"));
}
if context.trace_id.trim().is_empty() {
return Err(BridgeError::validation("trace_id 不能为空"));
}
if context.actor_id.trim().is_empty() {
return Err(BridgeError::validation("actor_id 不能为空"));
}
if context
.workspace_id
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(BridgeError::validation("workspace_id 不能为空"));
}
Ok(())
}
pub fn validate_query_envelope<T>(
context: &BridgeContext,
query: &QueryEnvelope<T>,
) -> BridgeResult<()> {
if query.name.trim().is_empty() {
return Err(BridgeError::validation("query name 不能为空"));
}
if context.request_id.trim().is_empty() {
return Err(BridgeError::validation("request_id 不能为空"));
}
if context.trace_id.trim().is_empty() {
return Err(BridgeError::validation("trace_id 不能为空"));
}
Ok(())
}
@@ -0,0 +1,131 @@
use crate::context::BridgeContext;
use crate::mapping::{map_command_name_to_convex, payload_json_for_command, ConvexMutationRequest};
use crate::types::BridgeResult;
use crate::validation::validate_command_envelope;
use core_domain::{RefLink, Timestamp, WorkspaceId};
use core_protocol::{command::CommandResult, AffectedObject, CommandEnvelope};
use event_log::{CommandLogRecord, CommandLogStatus, DomainEventRecord, EventStatus};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WritePipelineResult {
pub mutation: ConvexMutationRequest,
pub command_log: CommandLogRecord,
pub domain_event: DomainEventRecord,
pub result: CommandResult,
}
pub fn build_write_request<T>(
context: &BridgeContext,
command: &CommandEnvelope<T>,
) -> BridgeResult<ConvexMutationRequest> {
validate_command_envelope(context, command)?;
let workspace_id = command
.target
.as_ref()
.and_then(|target| target.workspace_id.clone())
.or_else(|| context.workspace_id.clone());
Ok(ConvexMutationRequest {
function_name: map_command_name_to_convex(&command.name).to_string(),
deployment_id: context.deployment_id.clone(),
project_id: context.project_id.clone(),
workspace_id,
request_id: context.request_id.clone(),
trace_id: context.trace_id.clone(),
idempotency_key: command
.idempotency_key
.clone()
.or_else(|| context.idempotency_key.clone()),
actor_id: context.actor_id.clone(),
payload_json: payload_json_for_command(context, &command.name),
})
}
pub fn build_command_log_record(
command_id: &str,
command_name: &str,
context: &BridgeContext,
workspace_id: &str,
) -> CommandLogRecord {
CommandLogRecord {
command_log_id: format!("clog_{command_id}"),
command_name: command_name.into(),
actor_type: context.actor_type.clone(),
actor_id: context.actor_id.clone(),
source: context.source_channel.clone(),
workspace_id: WorkspaceId::new(workspace_id),
target_objects: vec![RefLink::External(format!("workspace:{workspace_id}"))],
payload_summary: format!(
"command={command_name};request_id={};trace_id={}",
context.request_id, context.trace_id
),
refs_json: format!(
"[\"request:{}\",\"trace:{}\"]",
context.request_id, context.trace_id
),
idempotency_key: context.idempotency_key.clone(),
status: CommandLogStatus::Pending,
created_at: Timestamp::new("2026-04-11T00:00:00Z"),
finished_at: None,
trace_id: context.trace_id.clone(),
request_id: context.request_id.clone(),
}
}
pub fn build_domain_event_record(
command_id: &str,
command_name: &str,
context: &BridgeContext,
workspace_id: &str,
) -> DomainEventRecord {
DomainEventRecord {
event_id: format!("evt_{command_id}"),
workspace_id: workspace_id.into(),
aggregate_type: "workspace".into(),
aggregate_id: workspace_id.into(),
event_type: format!("{command_name}_requested"),
event_version: 1,
payload_json: format!(
"{{\"request_id\":\"{}\",\"trace_id\":\"{}\",\"command_id\":\"{}\",\"command_name\":\"{}\"}}",
context.request_id, context.trace_id, command_id, command_name
),
command_log_id: format!("clog_{command_id}"),
actor_type: context.actor_type.clone(),
created_at: Timestamp::new("2026-04-11T00:00:00Z"),
status: EventStatus::Pending,
trace_id: context.trace_id.clone(),
request_id: context.request_id.clone(),
command_id: command_id.into(),
}
}
pub fn build_write_pipeline<T>(
context: &BridgeContext,
command: &CommandEnvelope<T>,
) -> BridgeResult<WritePipelineResult> {
let mutation = build_write_request(context, command)?;
let workspace_id = mutation
.workspace_id
.clone()
.unwrap_or_else(|| "workspace_unknown".into());
let command_log =
build_command_log_record(&command.command_id, &command.name, context, &workspace_id);
let domain_event =
build_domain_event_record(&command.command_id, &command.name, context, &workspace_id);
let result = CommandResult {
ok: true,
command_id: command.command_id.clone(),
event_ids: vec![domain_event.event_id.clone()],
affected_objects: vec![AffectedObject {
object_type: "workspace".into(),
object_id: workspace_id.clone(),
}],
revision: Some(1),
warnings: vec![],
};
Ok(WritePipelineResult {
mutation,
command_log,
domain_event,
result,
})
}
+1
View File
@@ -0,0 +1 @@
{"rustc_fingerprint":4704921907237318175,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.88.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: x86_64-unknown-linux-gnu\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""}},"successes":{}}
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
View File
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
e053498f67f46e4d
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":6167971972065703354,"profile":17672942494452627365,"path":7278239953193670039,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/core-domain-40e6f74f7ef1a9aa/dep-lib-core_domain","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
31ec3f0d1a2e5472
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":6167971972065703354,"profile":1722584277633009122,"path":7278239953193670039,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/core-domain-bd799d0a25dcc494/dep-test-lib-core_domain","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
4e803ee3d8e7c709
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":6167971972065703354,"profile":8731458305071235362,"path":7278239953193670039,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/core-domain-cdff95eec220868e/dep-lib-core_domain","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
44b9888b46dbed5c
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":12929318708107190822,"profile":1722584277633009122,"path":17093070036165809465,"deps":[[12060949818064432287,"core_domain",false,704786785418248270]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/core-protocol-0fdc11ecf89ad5aa/dep-test-lib-core_protocol","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
69002a3ff9918d3d
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":12929318708107190822,"profile":17672942494452627365,"path":17093070036165809465,"deps":[[12060949818064432287,"core_domain",false,5579665713981379552]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/core-protocol-4592bf3d65d89282/dep-lib-core_protocol","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
49c72387560f483c
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":12929318708107190822,"profile":8731458305071235362,"path":17093070036165809465,"deps":[[12060949818064432287,"core_domain",false,704786785418248270]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/core-protocol-f5d7351438cd4f82/dep-lib-core_protocol","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
a1e984aa5f972227
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":2075902485319387119,"profile":17672942494452627365,"path":3721623360069338606,"deps":[[12060949818064432287,"core_domain",false,5579665713981379552]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/event-log-563618231cacaf7e/dep-lib-event_log","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
8f57032f1a7da02a
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":2075902485319387119,"profile":8731458305071235362,"path":3721623360069338606,"deps":[[12060949818064432287,"core_domain",false,704786785418248270]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/event-log-d9b5c77d292421e8/dep-lib-event_log","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
bf54d8ba9752e161
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":1159665536100219803,"profile":17672942494452627365,"path":12914430766979976756,"deps":[[12060949818064432287,"core_domain",false,5579665713981379552],[16479709356797637466,"event_log",false,2819982753825876385]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/index-fts-e444a40571811be1/dep-lib-index_fts","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":16973354705979238807,"profile":8731458305071235362,"path":2510431785475190115,"deps":[[7016416594668144918,"core_protocol",false,4343738704907716425],[12060949818064432287,"core_domain",false,704786785418248270],[16479709356797637466,"event_log",false,3071592497278048143]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/storage-convex-bridge-45166d07753c36d7/dep-lib-storage_convex_bridge","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":16973354705979238807,"profile":1722584277633009122,"path":2510431785475190115,"deps":[[7016416594668144918,"core_protocol",false,4343738704907716425],[12060949818064432287,"core_domain",false,704786785418248270],[16479709356797637466,"event_log",false,3071592497278048143]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/storage-convex-bridge-5bf1d06211b95150/dep-test-lib-storage_convex_bridge","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":11410426090777951712,"features":"[]","declared_features":"[]","target":16973354705979238807,"profile":17672942494452627365,"path":2510431785475190115,"deps":[[7016416594668144918,"core_protocol",false,4435361707722408041],[12060949818064432287,"core_domain",false,5579665713981379552],[16479709356797637466,"event_log",false,2819982753825876385]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/storage-convex-bridge-cd4527dde6c0c1c6/dep-lib-storage_convex_bridge","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}
@@ -0,0 +1,10 @@
/mnt/Data1T/mnote/rust/target/debug/deps/core_domain-40e6f74f7ef1a9aa.d: crates/core-domain/src/lib.rs crates/core-domain/src/audit.rs crates/core-domain/src/entities.rs crates/core-domain/src/ids.rs crates/core-domain/src/time.rs crates/core-domain/src/types.rs
/mnt/Data1T/mnote/rust/target/debug/deps/libcore_domain-40e6f74f7ef1a9aa.rmeta: crates/core-domain/src/lib.rs crates/core-domain/src/audit.rs crates/core-domain/src/entities.rs crates/core-domain/src/ids.rs crates/core-domain/src/time.rs crates/core-domain/src/types.rs
crates/core-domain/src/lib.rs:
crates/core-domain/src/audit.rs:
crates/core-domain/src/entities.rs:
crates/core-domain/src/ids.rs:
crates/core-domain/src/time.rs:
crates/core-domain/src/types.rs:

Some files were not shown because too many files have changed in this diff Show More