multica deployment

This commit is contained in:
rblundon committed 2026-10-20 13:41:11 -05:00
1 parent 63c5f7eed7
commit 39e2b87941
26 files changed
+2746 -394

No files matched your search

@@ -0,0 +1,702 @@
# Account Dossier Interactive Dashboard — Design Document
**Version:** 0.1 (MVP)
**Author:** Claude
**Date:** 2026-10-07
**Status:** Design Review (Pre-Prototype)
---
## 1. Overview & Purpose
**What:** Interactive HTML/React dashboard that surfaces account intelligence for a single Pure Storage customer or prospect. Provides a "10,000 ft view" with drill-down to ground-level detail.
**Why:** SEs need to move quickly from account snapshot → discovery prep → conversation context without context-switching between Sheets, notes, and browser tabs. One dashboard per customer becomes the source of reference for that account.
**MVP Scope:** The 10 essential questions (List 1 + List 2 from account intelligence framework)
**Future Scope:** Relationships, opportunities, financial modeling, industry trends, sentiment analysis, timeline views
---
## 2. User Personas & Use Cases
### Primary User
**Ryan** (You, Enterprise SE)
- Prepping for discovery calls
- Hunting for competitive displacement opportunities
- Planning expansion/renewal strategy
- Sharing account context with team (future)
### Use Cases (in priority order, all equally weighted for MVP)
| Use Case | What They Need | Where Dashboard Helps |
|----------|---|---|
| **Pre-call Prep (15 min before discovery)** | Quick account snapshot + key metrics + conversation hooks | Overview pane + drill-down to specific List items; see what changed since last call |
| **Competitive Displacement Hunting** | Incumbent vendors + footprint + gaps + consolidation leverage | List 2 drill-down showing Pure coverage % + high-value data not on Pure; see EMC systems aging |
| **Expansion/Renewal Planning** | Growth trajectory + budget cycle + stakeholder sentiment + upcoming initiatives | Opportunity tab (future) + timeline + stakeholder relationships; see refresh window + budget headroom |
### Secondary User (Future)
**Account team** (Sales, Solution Architect, Customer Success)
- Read-only view of account summary
- Understand strategic context before customer calls
---
## 3. Architecture Overview
### 3.1 Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Per-Customer Data Sources (DRY: No Duplication) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📊 Google Sheet (Structured Data) │
│ ├─ Account tab (List 1 metrics) │
│ ├─ Pure-Deployment tab (List 2 metrics) │
│ ├─ Relationships tab (Contacts, sentiment, history) │
│ ├─ Interactions tab (Call/email log with sentiment) │
│ ├─ Financials tab (ARR, contracts, opportunities) │
│ ├─ Industry-Trends tab (Curated insights) │
│ └─ Metadata tab (Sync status, ownership, sharing) │
│ │
│ 📝 Obsidian Markdown File (Unstructured Context) │
│ ├─ Discovery notes (narrative, conversation context) │
│ ├─ Strategy (account themes, expansion hooks) │
│ ├─ Relationship notes (person-by-person context) │
│ └─ Internal flags (competitive threats, risks) │
│ │
└─────────────────────────────────────────────────────────────┘
↓
(via API or manual sync)
↓
┌─────────────────────────────────────────────────────────────┐
│ Interactive Dashboard (HTML/React Artifact) │
│ Reads from both sources, displays in 3-pane layout │
│ No data copying; all reads from source │
└─────────────────────────────────────────────────────────────┘
↓
(displayed to user)
↓
┌─────────────────────────────────────────────────────────────┐
│ User Actions: │
│ • View account snapshot │
│ • Drill into List 1 / List 2 metrics │
│ • Check stakeholder sentiment │
│ • Review opportunity pipeline │
│ • Log new interaction │
│ • Open full Sheets / Obsidian for detailed editing │
└─────────────────────────────────────────────────────────────┘
```
---
## 4. UI/UX Design
### 4.1 Layout: 3-Pane Architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ HEADER: Account Name | Status | Vertical | Location | [Share] [Edit] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ LEFT PANE │ CENTER PANE │ RIGHT PANE │
│ (Overview) │ (Details & Drill-down) │ (Context) │
│ │ │ │
│ ┌─────────────────┐ │ ┌────────────────────┐ │ ┌────────────┐ │
│ │ Account Health │ │ │ Tab Bar: │ │ │ Latest │ │
│ │ Scorecard │ │ │ Overview | List1 │ │ │ Interactions │
│ │ ◆ 4 KPIs │ │ │ List2 | Oppty │ │ │ │ │
│ │ (visual) │ │ │ │ │ │ ┌────────┐ │ │
│ │ │ │ │ ┌──────────────┐ │ │ │ Sarah │ │ │
│ ├─────────────────┤ │ │ │ Item 1 Card │ │ │ │ Chen │ │ │
│ │ Key Dates │ │ │ │ [Summary] │ │ │ │ Call │ │ │
│ │ Timeline │ │ │ │ [10K ft] │ │ │ │ 2d ago │ │ │
│ │ • Refresh: 6mo │ │ │ │ [Status] │ │ │ │ ⭐⭐⭐ │ │ │
│ │ • Budget: 3mo │ │ │ └──────────────┘ │ │ │ (Drill) │ │ │
│ │ • Contract: 8mo │ │ │ │ │ │└────────┘ │ │
│ │ │ │ │ ┌──────────────┐ │ │ │ │ │
│ ├─────────────────┤ │ │ │ Item 2 Card │ │ │ ├──────────┤ │
│ │ Actions │ │ │ │ [Summary] │ │ │ │ [Log new]│ │
│ │ [QBR Prep] │ │ │ │ [10K ft] │ │ │ │ │ │
│ │ [Edit Account] │ │ │ │ [Status] │ │ │ │ Related │ │
│ │ [Open Sheets] │ │ │ └──────────────┘ │ │ │ Notes │ │
│ │ [View Notes] │ │ │ │ │ │ (from MD) │ │
│ │ [Share] │ │ │ .... (3 more) │ │ │ │ │
│ │ │ │ │ │ │ │ [View all]│ │
│ └─────────────────┘ │ └────────────────────┘ │ └────────────┘ │
│ │ │ │
└──────────────────────────────────────────────────────────────────────┘
```
### 4.2 Left Pane: Account Overview (10,000 ft)
**Purpose:** At a glance, is this account healthy? What's the most critical thing to know?
**Components:**
#### Health Scorecard (4 KPIs)
Visual status cards, each with:
- **Metric name** + current value
- **Trend indicator** (↑ up, ↓ down, → flat)
- **Status badge** (green / yellow / red)
- **Drillable** — click to zoom into that metric
**KPIs (MVP):**
1. **Pure Coverage** — % of total storage on Pure
- Value: 30% | Trend: ↑ +5% YoY | Status: 🟡 Opportunity
- Rationale: Shows expansion potential; top sales driver
2. **Fleet Age** — Average age of Pure systems
- Value: 4.2 yr | Trend: → flat | Status: 🟡 Refresh Window Approaching
- Rationale: Indicates refresh opportunity timing
3. **Expansion Opportunity Size** — $ value of consolidation potential
- Value: $3.5M | Trend: ↑ Growing | Status: 🟢 Pipeline-Ready
- Rationale: Deal size at a glance; guides priority
4. **Engagement Momentum** — Recency of last interaction + champion sentiment
- Value: 5 days | Trend: ↑ Active | Status: 🟢 Engaged
- Rationale: Is this account warm or cold?
#### Key Dates Timeline
Visual timeline of critical dates:
- **Refresh Date**: Next Pure system refresh
- **Budget Cycle**: When capex decisions happen
- **Contract Renewal**: When Pure contract is up
- **Next Review**: When you should reconnect
Format: Horizontal timeline or vertical list with "X days until" callouts.
#### Action Buttons (Quick nav)
- **QBR Prep** → Opens checklist template
- **Edit Account** → Opens Sheets in new tab
- **View Notes** → Opens Obsidian vault or embedded Markdown
- **Share** → (Future) generates shareable summary link
- **Log Interaction** → Quick-add form (bottom right pane)
---
### 4.3 Center Pane: Details & Drill-Down
**Purpose:** Zoom from 10K ft into List 1 and List 2 metrics. See details, trends, and rationale.
**Tab Structure:**
#### Tab 1: Overview (Summary of all 10 items)
Shows all 5 List 1 items + all 5 List 2 items as **cards in a grid**:
- Card layout: Title | 10K ft summary | Status badge | Click to drill
- Color-coded by item type (List 1 = blue accent, List 2 = green accent)
- Scanning-friendly; can see account at a glance
#### Tab 2: Account Profile (List 1 Deep Dive)
Five expandable cards, one per List 1 item:
**Card 1: Data Footprint & Workload Breakdown**
```
┌─ Data Footprint & Workload Breakdown ───────────────────┐
│ 10K FT VIEW: │
│ 500TB current | +18% YoY | 2yr forecast: 700TB │
│ Workloads: 40% OLTP | 35% Analytics | 25% Unstructured │
│ │
│ [CLICK TO EXPAND ▼] │
│ │
│ EXPANDED VIEW: │
│ ┌─ Breakdown Table ──────────────────────────────────┐ │
│ │ Tier │ Current │ Growth │ 2yr Forecast │ Note │
│ │ OLTP │ 200TB │ +15% │ 265TB │ TX DB │
│ │ Analytics │ 175TB │ +25% │ 275TB │ DW │
│ │ Unstructured│ 125TB │ +12% │ 160TB │ Lake │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Capacity Growth Chart ────────────────────────────┐ │
│ │ [Line chart: 3yr history + 2yr forecast] │ │
│ │ Shows growth trajectory, where Pure could capture │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ CONTEXT FROM NOTES: │
│ "Customer mentioned big analytics push for FY27..." │
│ │
│ [Related Links: 10-K | Analyst Report] │
└─────────────────────────────────────────────────────────┘
```
**Card 2: Current Storage Vendors & Installed Infrastructure**
```
┌─ Current Storage Vendors ──────────────────────────────┐
│ 10K FT VIEW: │
│ NetApp 50% | Pure 30% | Dell 20% (of 1200TB total) │
│ Consolidation Opportunity: HIGH │
│ │
│ [CLICK TO EXPAND ▼] │
│ │
│ EXPANDED VIEW: │
│ ┌─ Vendor Breakdown (Pie Chart) ─────────────────────┐ │
│ │ [Visual pie showing % by vendor] │ │
│ │ NetApp = oldest systems, refresh due │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Incumbent Systems Detail ────────────────────────┐ │
│ │ Vendor: EMC Symmetrix DMX-4 │ │
│ │ Capacity: 200TB | Age: 6 years | Status: AGING │ │
│ │ Use Case: Core banking OLTP │ │
│ │ 💡 Displacement Target: Move to Pure FlashArray │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ COMPETITIVE CONTEXT: │
│ "This EMC system is at end-of-life; customer risk- │
│ averse but willing to consolidate if migration is │
│ proven. Reference customer opportunity." │
│ │
│ [Related: Competitive Intel | Displacement Playbook] │
└──────────────────────────────────────────────────────────┘
```
**Cards 3-5:** Similar drill-down structure for Business Drivers, Architecture, Budget/Authority
#### Tab 3: Pure Deployment (List 2 Deep Dive)
Five cards showing Pure-specific metrics, one per List 2 item.
**Card 1: Current Pure Installed Base**
```
┌─ Current Pure Installed Base ──────────────────────────┐
│ 10K FT VIEW: │
│ 2 FlashArray systems (150TB) | Healthy | 4.2yr avg age │
│ Headroom: 52TB | Refresh window: 18mo │
│ │
│ [CLICK TO EXPAND ▼] │
│ │
│ EXPANDED VIEW: │
│ ┌─ Systems Inventory Table ──────────────────────────┐ │
│ │ System | Type | Capacity | Util | Headroom | Age │ │
│ │ FA-P1 | FA | 75TB | 65% | 26TB | 5.1yr │ │
│ │ FA-DR | FA | 75TB | 40% | 45TB | 3.3yr │ │
│ │ Refresh Needed │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Utilization & Headroom Heatmap ──────────────────┐ │
│ │ [Visual: System cards with fill % + headroom bar] │ │
│ │ FA-P1: ████████ 65% USED (26TB headroom) │ │
│ │ FA-DR: █████░░░ 40% USED (45TB headroom) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ EXPANSION IMPLICATION: │
│ "FA-P1 will need refresh/capacity add in 18 months; │
│ FA-DR has headroom for growth without additional spend" │
│ │
│ [Related: Pure1 Dashboard | Refresh Planning Guide] │
└──────────────────────────────────────────────────────────┘
```
**Cards 2-5:** Performance/Satisfaction, Coverage Gaps, Refresh Cycles, Stakeholders
---
### 4.4 Right Pane: Unstructured Context & Interactions
**Purpose:** Connect quantitative data (left/center) to narrative context (right). See "why" behind the numbers.
**Components:**
#### Latest Interactions (Log)
Shows most recent 3-5 interactions with sentiment:
```
┌─ Interactions ────────────────────────────────────────┐
│ ⭐⭐⭐⭐⭐ Sarah Chen (VP Storage) │
│ Call — 2026-10-02 · 5 days ago │
│ "Discussed multi-site replication, pain with NetApp" │
│ Status: Strong Advocate for Pure adoption │
│ │
│ ⭐⭐⭐ Mike Lopez (CIO) │
│ Email — 2026-09-15 · 22 days ago │
│ "Budget constraints; interested in OpEx models" │
│ Status: Neutral, cost-focused │
│ │
│ ⭐⭐ Jennifer Park (CFO) │
│ Meeting — 2026-08-20 · 47 days ago │
│ "Positive on storage consolidation, capex tight" │
│ Status: Positive but budget-constrained │
│ │
│ [+ Log New Interaction] │
└───────────────────────────────────────────────────────┘
```
#### Related Notes (from Markdown)
Embedded snippet from Obsidian Markdown file:
```
┌─ Discovery Notes ──────────────────────────────────────┐
│ STRATEGY THEME: Consolidation play │
│ Customer is post-acquisition; consolidating 3 data │
│ centers into 2. Big opportunity to move from multi- │
│ vendor (EMC + NetApp) to Pure standardization. │
│ │
│ RISK: Customer risk-averse on primary storage. │
│ LEVER: Success story at reference customer + DR copy. │
│ │
│ NEXT STEP: Sarah is scheduled for Q4 arch review. │
│ Come with: Multi-site replication architecture, │
│ consolidation cost model. │
│ │
│ [View Full Notes] [Edit in Obsidian] │
└───────────────────────────────────────────────────────┘
```
#### Quick Actions
- **[Log Interaction]** → Popup form to add call/email/meeting
- **[View All Notes]** → Opens Obsidian file
- **[View Sheets]** → Opens Google Sheets
- **[Share Account]** → (Future) generates link
---
### 4.5 Responsive Behavior & Interactivity
#### Drill-Down Interactions
- **Cards expand in place** — no modal/new page; content slides down
- **Breadcrumb trail** — e.g., "Account > List 1 > Data Footprint" so you know where you are
- **Collapse others** — When one card expands, others collapse (reduces noise)
- **Smooth transitions** — Cards animate open/closed
#### Status Badges & Visual Indicators
| Status | Color | Meaning |
|--------|-------|---------|
| 🟢 Green | Good / On Track / Advocate | Positive signal |
| 🟡 Yellow | Caution / Aging / Neutral | Attention needed |
| 🔴 Red | Risk / Critical / Urgent | Action required |
| ⚪ Gray | Unknown / TBD | Need more info |
#### Data-Driven Highlights
Conditional visual emphasis on metrics:
- **Refresh window approaching** (>4yr age) → Highlighted in yellow
- **Coverage gap opportunity** (low % Pure) → Highlighted in orange
- **Budget cycle in next 90 days** → Underlined with "upcoming" tag
- **Strong champion sentiment** → Starbursts/badge next to name
---
## 5. Component Breakdown & Data Mapping
### 5.1 Component Hierarchy
```
DashboardApp
├── Header
│ ├── AccountName
│ ├── StatusBadge (Prospect/Customer)
│ ├── MetadataBar (Vertical, Location, Last Updated)
│ └── HeaderActions ([Share] [Edit] [Settings])
│
├── MainLayout (3-pane)
│ │
│ ├── LeftPane (Overview)
│ │ ├── HealthScorecard
│ │ │ ├── KPICard (Pure Coverage)
│ │ │ ├── KPICard (Fleet Age)
│ │ │ ├── KPICard (Opportunity Size)
│ │ │ └── KPICard (Engagement Momentum)
│ │ ├── TimelineComponent
│ │ │ ├── TimelineEvent (Refresh Date)
│ │ │ ├── TimelineEvent (Budget Cycle)
│ │ │ ├── TimelineEvent (Contract Renewal)
│ │ │ └── TimelineEvent (Next Review)
│ │ └── ActionButtons
│ │ ├── Button (QBR Prep)
│ │ ├── Button (Edit Account)
│ │ ├── Button (View Notes)
│ │ └── Button (Share)
│ │
│ ├── CenterPane (Details & Drill-Down)
│ │ ├── TabBar
│ │ │ ├── Tab (Overview)
│ │ │ ├── Tab (List 1: Account Profile)
│ │ │ ├── Tab (List 2: Pure Deployment)
│ │ │ └── Tab (Opportunities — future)
│ │ │
│ │ └── TabContent (varies by tab)
│ │ └── DetailsCard (repeating)
│ │ ├── CardHeader (title + status)
│ │ ├── CardSummary (10K ft view)
│ │ ├── ExpandButton
│ │ └── ExpandedContent (on click)
│ │ ├── DetailTable / Chart
│ │ ├── TrendVisualization
│ │ └── RelatedContext
│ │
│ └── RightPane (Context)
│ ├── InteractionLog
│ │ └── InteractionItem (repeating)
│ │ ├── Sentiment (stars)
│ │ ├── Contact Info
│ │ ├── Timestamp
│ │ └── Summary
│ ├── RelatedNotes
│ │ └── NotesSnippet
│ └── QuickActions
│ ├── Button (Log Interaction)
│ ├── Button (View All Notes)
│ └── Button (View Sheets)
```
### 5.2 Data Source Mapping
| Component | Data Source | Sheet Location | Notes |
|-----------|---|---|---|
| **Account Name** | Account tab | Name | |
| **Status Badges** | Account tab | Status, Vertical, Location | |
| **Health Scorecard** | Account + Pure-Deployment tabs | Multiple cells + formulas | Calculated KPIs |
| **Timeline** | Account tab | Columns: Next Refresh Date, Budget Cycle, Contract End, Next Review | |
| **List 1 Cards** | Account tab | Tabs: Item 1-5 data | Five expandable items |
| **List 2 Cards** | Pure-Deployment tab | Tabs: Item 1-5 data | Five expandable items |
| **Interaction Log** | Interactions tab | Contact, Type, Date, Sentiment, Summary | Sorted by date DESC |
| **Related Notes** | Markdown file (Obsidian) | Fetched via API or embed | Text snippet + link |
---
## 6. Visual Design Principles
### Color Palette (Themeable)
- **Accent Blue** (Primary): List 1 items, account overview
- **Accent Green** (Secondary): List 2 items, Pure deployment
- **Status Green** (Success): Healthy, positive, on-track
- **Status Yellow** (Warning): Approaching refresh, caution needed
- **Status Red** (Critical): Urgent action required
- **Neutral Gray**: Unknown, TBD, secondary content
- **Text Dark Gray** (Light mode) / **Text Light Gray** (Dark mode)
- **Background**: Light/dark mode adaptive
### Typography
- **Header**: Large, bold, account name stands out
- **Tab titles**: Medium weight, clear tab distinction
- **Card titles**: Semi-bold, clickable indicator (▼)
- **Data values**: Monospace for numbers (easier to scan)
- **Labels**: Smaller, muted color
### Spacing & Layout
- **Panes**: Equal width (33% each) or responsive flex at smaller widths
- **Cards**: Padding ~20px, margin ~16px between cards
- **Drill-down**: Animated 200ms expand/collapse
- **Mobile**: Stack panes vertically (future consideration)
---
## 7. Navigation & User Flows
### Flow 1: Pre-Call Prep (15 min before discovery)
```
User lands on dashboard
↓
Glances at Health Scorecard (30 sec) — "Pure coverage 30%, fleet aging, $3.5M upside"
↓
Checks Key Dates Timeline (30 sec) — "Budget cycle in 3 mo, refresh in 18 mo"
↓
Clicks "QBR Prep" action → Opens checklist template in new tab
↓
Scrolls to Center pane, clicks "Current Vendors" card to see EMC footprint
↓
Reads right pane: "Customer risk-averse on primary storage; use reference story"
↓
Click [Log Interaction] → Pre-fills with today's call, ready to document post-call
```
### Flow 2: Competitive Displacement Hunt
```
User is looking for displacement opportunities
↓
Opens Account dashboard
↓
Glances at Scorecard: "Pure coverage 30%" → Opportunity
↓
Clicks "Data Footprint" card → Sees workload breakdown
↓
Clicks "Current Vendors" card → Sees EMC system (200TB, 6yr old, primary OLTP)
↓
Clicks "Coverage Gaps" card → Confirms: "EMC Symmetrix not on Pure yet"
↓
Reads notes: "Customer risk-averse but willing to migrate if proven"
↓
Action: Find reference customer, prepare migration playbook for next call
```
### Flow 3: Expansion Planning
```
User reviewing account for renewal/expansion
↓
Glances at Scorecard: Fleet age, Opportunity size, Engagement momentum
↓
Clicks "Fleet Age" card → Sees FA-P1 refresh window in 18 months
↓
Clicks "Refresh Cycles" card → Confirms capex cycle and window
↓
Checks Stakeholder info in right pane → "Sarah (VP Storage) is champion, strong advocate"
↓
Clicks "Opportunities" tab (future) → See pipeline: Analytics consolidation (Q2 2027, $1.5M)
↓
Action: Schedule architecture review with Sarah for Q1; prepare proposal for analytics move
```
---
## 8. Data Sync & Freshness
### MVP Approach: Manual Sync
- **Sheet → Dashboard**: Copy-paste JSON export or Google Sheets API call (daily/on-demand)
- **Markdown → Dashboard**: Fetch from Obsidian API or GitHub if synced
- **Freshness**: Dashboard is "point-in-time snapshot" at time of load
- **Update frequency**: User refreshes page or manually syncs from edit screens
### Future: Live Sync
- Dashboard queries Google Sheets API in real-time
- Interactions logged directly to Sheets (no manual re-entry)
- Markdown fetched from Obsidian Sync API
- Auto-refresh KPIs every 5 minutes
---
## 9. Extensibility Points (Future Phases)
### Phase 2: Relationships & Interaction History
- **Tab 4: Relationships** — Org chart, sentiment timeline, interaction history
- **Interaction log** — Searchable, filterable by contact, type, sentiment
- **Sentiment analysis** — Chart showing mood trend over time
### Phase 3: Opportunities & Pipeline
- **Tab 5: Opportunities** — Consolidation roadmap, deal timeline, revenue forecast
- **Milestone tracking** — Countdown to RFP, contract close, etc.
### Phase 4: Financial Modeling
- **10-K integration** — Embed PDF link, key metrics extracted
- **Opportunity forecasting** — If analytics consolidates, revenue impact
- **Budget impact model** — Capex vs. OpEx trade-off calculator
### Phase 5: Industry & Competitive Context
- **Industry trends** — Curated insights relevant to account
- **Competitor tracking** — Win/loss intelligence, displacement playbooks
- **Market context** — Industry growth rates, regulatory changes
---
## 10. Technical Architecture
### Technology Stack
- **Frontend**: React (or vanilla JS if keeping simple)
- **Styling**: Tailwind CSS (or custom CSS-in-JS)
- **Data sources**:
- Google Sheets API (for Account, Pure-Deployment, Relationships, Financials tabs)
- Markdown file (from Obsidian or GitHub)
- **Deployment**: Claude artifact (publishable, shareable URL) or self-hosted on homelab
- **Build**: Vite (fast dev) or Create React App
### API Integration (MVP)
```
Frontend (React)
↓
├─→ Google Sheets API v4
│ └─ Fetch: Account!A:Z, PureDeployment!A:Z, Relationships!A:Z, etc.
│ Rate: On-demand or 5-min cache
│
└─→ Markdown fetch (via Obsidian API or raw GitHub/Drive link)
└─ Fetch: Markdown content, parse for snippets
```
### State Management
- **Client-side state**: React hooks (useState, useEffect)
- **Server state**: Google Sheets is source of truth
- **UI state**: Which tab active, which card expanded
- **No local storage** (MVP): Always fresh from source
---
## 11. Accessibility & Usability
### Keyboard Navigation
- Tab through cards, tabs, buttons
- Enter/Space to expand cards
- Escape to collapse all
- Ctrl/Cmd+Click to open links in new tab
### Screen Readers
- Semantic HTML (headers, nav, main, article)
- ARIA labels on custom components
- Heading hierarchy (H1 = account name, H2 = tabs, H3 = card titles)
### Color Contrast
- All text meets WCAG AA standards
- Status indicators have labels (not color alone)
- Links underlined or clearly differentiated
### Mobile Responsiveness (Future)
- Stack panes vertically on small screens
- Touch-friendly button sizes (44px minimum)
- Collapsible sections for readability
---
## 12. Success Metrics (MVP)
| Metric | Target | How to Measure |
|--------|--------|---|
| **Time to account snapshot** | <2 min to full context | User time log |
| **Drill-down effectiveness** | Find key metric in <30 sec | User task test |
| **Information completeness** | All 10 items readily visible | Checklist |
| **Visual clarity** | No confusion on next steps | User feedback |
| **Engagement** | Used for all 3 use cases equally | Usage logs |
---
## 13. Known Constraints & Trade-offs
| Constraint | Impact | Mitigation |
|-----------|--------|---|
| **Manual sync (MVP)** | Data can lag if Sheets updated but dashboard not refreshed | Clear "last updated" timestamp; button to manual sync |
| **No real-time collaboration** | If account team edits Sheets while viewing dashboard, changes won't appear until reload | Future: implement live sync |
| **Single-customer dashboard** | Can't compare metrics across accounts in MVP | Future: roll-up view with filtering |
| **API rate limits (Sheets)** | Can't refresh dashboard every second | Cache on client; user triggers refresh manually |
---
## 14. Success Criteria for Prototype
✅ **Must Have (MVP)**
- [ ] All 10 items (List 1 + List 2) visible and organized
- [ ] 3-pane layout functioning (left/center/right)
- [ ] Cards expand/collapse on click with animation
- [ ] Health scorecard with 4 KPIs calculated
- [ ] Timeline showing key dates
- [ ] Sample data flowing in from mock Sheets
- [ ] Navigation between tabs (Overview, List 1, List 2)
- [ ] Status badges and visual indicators rendering correctly
⚡ **Should Have (Nice to Have)**
- [ ] Trend indicators (↑ ↓ →) on KPI cards
- [ ] Sample charts (pie, line) in expanded views
- [ ] Markdown snippet embedded in right pane
- [ ] Interaction log with sentiment stars
- [ ] Dark mode toggle
🎯 **Nice to Have (Future)**
- [ ] Google Sheets API actually connected (MVP uses mock data)
- [ ] Relationships tab structure visible
- [ ] QBR Prep modal/checklist
- [ ] Share functionality
- [ ] Export to PDF
---
## 15. Design Review Checklist
**Questions for Ryan before prototype:**
1. ✅ Is the 3-pane layout clear? Does it support the 10K → ground zoom?
2. ✅ Are the 4 KPIs the right ones, or would you change them?
3. ✅ Does the drill-down interaction (click card → expand) feel right, or prefer modal?
4. ✅ Should the right pane be scrollable independently, or scroll with center pane?
5. ✅ Any major UX concerns with the navigation flow?
6. ✅ Is the timeline component clear (horizontal bar vs. vertical list)?
7. ✅ Color palette acceptable, or want different accent colors?
8. ✅ Any tabs or components you'd add/remove for MVP?
---
**Document Status:** Ready for Design Review & Feedback
**Next Step:** Prototype build (pending approval of this design)
@@ -0,0 +1,257 @@
# Top-5 Account Intelligence Lists for Enterprise Storage SE
**For:** Pre-Sales Systems Engineers at Pure Storage
**Focus Verticals:** Financial Services, Retail/Manufacturing
**Target Accounts:** Fortune 1000 / Global 2000
---
## LIST 1: ANY CUSTOMER OR PROSPECT
### First Discovery Call + Competitive Displacement + Expansion Foundation
---
### 1. **Data Footprint and Workload Breakdown**
**What to capture:**
- Total addressable storage capacity (current + 2-year projection)
- Workload tier split: critical OLTP (databases, transactional), warm operational analytics (data warehouses, ML training sets), unstructured (data lakes, backups, archives)
- Annual growth rate
- Typical IO patterns (sequential vs. random, read/write mix)
- Workload sources: core business apps, digital channels, analytics platforms, third-party integrations
**Why it ranks #1:**
This is your credibility foundation for the discovery call. Large enterprises in financial services and retail/manufacturing operate at scale—you need to know if they're managing 10PB or 500PB of storage before you can ask intelligent questions about consolidation, resilience, or performance. For competitive displacement, it tells you the total addressable market and which workloads are most valuable to move. For expansion, it shows you where growth is (analytics workloads often outpace transactional growth in these verticals).
**Vertical differences:**
- **Financial services:** Regulatory data tiers (Basel III, Dodd-Frank, GDPR-driven data governance), transaction volumes, archive-to-compliance mandates, and fraud detection datasets are outsized here.
- **Retail/manufacturing:** Omnichannel transaction data, supply chain event data, and IoT/sensor streams create different growth patterns—often unstructured-heavy.
**Where to get it:**
Account team, analyst kickoff, customer IT strategy documents. If public: earnings calls (capex guidance), analyst reports on data center expansion. **Hard to verify fully from public sources—lean on the account team and discovery conversation.** Pure1 for existing customers (capacity trends, growth rates).
---
### 2. **Current Storage Vendor(s) and Installed Infrastructure**
**What to capture:**
- Primary vendor(s), total installed capacity per vendor, system age/generation
- Deployment model (on-prem, cloud-hosted)
- Vendor concentration (single-vendor, multi-vendor)
- Capacity consolidation potential (old systems due refresh, overlapping vendors)
- Competitive systems they've ruled out or are evaluating
**Why it ranks #2:**
This is the competitive map. In discovery, you need to know who you're up against to ask credible questions about their stack. For displacement, this is your roadmap—old NetApp or EMC systems are your opening; vendor sprawl is your consolidation story. For expansion, it shows where Pure is (or isn't) and where you can grow.
**Applies to both verticals:**
Regulatory and operational requirements often enforce heterogeneous stacks (legacy core systems, newer analytics platforms), creating multi-vendor complexity and Pure's consolidation angle.
**Where to get it:**
Account team, customer IT contacts, Gartner/IDC reports, job postings (hiring for specific platforms signals infrastructure). Pure's competitive intel teams (if available in your region). **Harder to get from public sources—interview in discovery.**
---
### 3. **Business Drivers and Strategic Imperatives**
**What to capture:**
- What's moving their infrastructure strategy in the next 12–24 months?
- Digital transformation (cloud-first, API-led architecture)
- M&A (data center consolidation, platform rationalization)
- Regulatory change (data residency, compliance modernization)
- Cost reduction (capex or opex pressure)
- Performance demands (real-time analytics, AI/ML acceleration)
- Geographic expansion (multi-site, disaster recovery)
**Why it ranks #3:**
This explains *why* they'll spend and *when*. For discovery, it tells you what your conversation should be anchored to—cost pressure is a different conversation than compliance urgency. For displacement, it reveals openings (legacy vendor can't support cloud migration; incumbent doesn't scale for analytics). For expansion, it shows where budget flows and growth cycles.
**Vertical differences:**
- **Financial services:** Regulatory change (Fed data residency mandates, CCAR/stress test infrastructure), fraud detection (AI/ML workloads), and open banking/fintech competition drive transformation.
- **Retail/manufacturing:** Omnichannel unification, supply chain visibility, and sustainability reporting (ESG data) are primary drivers; cost pressure is often secondary.
**Where to get it:**
Earnings calls (CFO and CRO commentary), industry analyst reports, customer executives in discovery (CEOs and CFOs speak to this), account team (relationship intelligence). Regulatory announcements for financial services. **Public sources vary—direct ask is usually most productive.**
---
### 4. **Infrastructure Architecture and Resilience Model**
**What to capture:**
- Topology: single data center, active-active multi-site, hub-and-spoke?
- Failover strategy (RPO/RTO targets, synchronous vs. asynchronous replication)
- Cloud presence (public cloud, hybrid, private cloud)
- Containerization and Kubernetes adoption
- Storage tiers and tiering automation
- Backup/archive strategy (separate systems, integrated)
**Why it ranks #4:**
This determines *which* Pure solutions fit and how. Knowing they're multi-site tells you Evergreen model and replication are critical; knowing they have Kubernetes tells you Portworx is on the menu; knowing they're chasing sub-second RTO tells you FlashArray's performance story matters. For discovery, it frames the conversation around resilience or performance. For displacement, it shows where Pure's architecture outperforms incumbent (e.g., Evergreen's native replication vs. bolted-on replication). For expansion, it guides where to add capacity.
**Vertical differences:**
- **Financial services:** Recovery requirements are often strict (RTO <1hr, RPO <15min); multi-site is standard; compliance mandates geographic separation.
- **Retail/manufacturing:** Multi-site is common (stores, plants, distribution); RTO is important but often longer than financial services; hybrid cloud adoption is accelerating.
**Where to get it:**
Account team, technical RFI/RFQ documents, infrastructure diagrams (security-sensitive—may not be public). Discovery conversation with IT infrastructure/storage teams. **Hard from public sources.**
---
### 5. **Budget Cycle, Capital Allocation, and Buying Authority**
**What to capture:**
- Capex budget cycle and approval gates (who approves? quarterly, annual, rolling?)
- Capex vs. opex preference (traditional capex, Evergreen-style subscription, OpEx-only mandate)
- Budget envelope for infrastructure over next 2 years
- Preferred buying patterns (competitive bid, existing vendor preference, tech refresh cycle)
- Influencers and decision-makers (CIO, VP Storage, business unit leaders, CFO)
**Why it ranks #5:**
This is the "when and how to sell" lever. It's less about discovery credibility (though knowing budget is essential) and more about deal realism. For discovery, it tells you timing and buying authority to route follow-ups. For displacement, it tells you if capex is available or if you need to frame Evergreen as an alternative. For expansion, it reveals budget headroom and buying rhythm.
**Applies to both verticals:**
Financial services often have tighter capex scrutiny and longer approval cycles; retail/manufacturing may have faster refresh cycles but more budget constraints post-acquisition integration.
**Where to get it:**
Account team relationship notes, past deal history, 10-K/10-Q filings (capex guidance), analyst reports, budget forecasts shared in customer relationships. **Rarely fully public—account team and direct relationship intelligence are key.**
---
## LIST 2: EXISTING CUSTOMERS — PURE STORAGE DEPLOYMENT SPECIFICS
### Expansion + Retention + Competitive Defense
---
### 1. **Current Pure Installed Base, Deployment Pattern, and Capacity**
**What to capture:**
- FlashArray systems (models, capacity per system, tier across data centers or cloud regions)
- FlashBlade deployments (scale-out analytics use cases, capacity)
- Portworx deployments (if any; containerized workloads, environments)
- Age of systems (when deployed; refresh cycle status)
- Geographic footprint (on-prem across which data centers, any cloud-hosted Pure?)
- Utilization and headroom (at capacity, growing into headroom, or sparse?)
**Why it ranks #1:**
You can't expand without knowing your baseline. For expansion, this is your starting point—you need to know what they own to identify the next sale (add capacity, new tier, new workload). For retention, it's your health baseline (old systems more likely to churn). For competitive defense, it shows Pure's footprint and where competitors might try to take share.
**Key indicators for retention/expansion:**
Systems older than 5 years are refresh candidates; sparse utilization may indicate underutilized tier or expansion opportunity; multi-site Pure deployments are higher-value accounts.
**Where to get it:**
**Pure1 (your best data source—capacity, performance, age, location),** internal Pure account data, customer IT/storage teams. Direct ask in customer reviews and strategic business reviews (QBR/SBR).
---
### 2. **Performance, Utilization, and Customer Satisfaction**
**What to capture:**
- Pure1 performance metrics (latency, IOPS achieved vs. provisioned, dedupe/compression ratios, capacity growth rate)
- Storage team feedback (support experience, feature requests, known pain points)
- Performance satisfaction (are performance SLAs being met?)
- Operational efficiency gains (cost per GB, power/cooling reduction)
- Customer sentiment (NPS or equivalent; any churn signals?)
**Why it ranks #2:**
This tells you expansion potential vs. churn risk. If they're unsatisfied, expansion is at risk (competitor threat, bargaining power). If they're thriving on Pure, expansion is easier (proven business case). For retention, this is your early warning system. For expansion, high satisfaction is your foundation for upsells (new workload, new tier, cloud-native).
**Vertical differences:**
- **Financial services:** Customers often have strict uptime/performance mandates.
- **Retail/manufacturing:** Often cost-conscious and may trade performance for efficiency.
**Where to get it:**
**Pure1 (performance analytics),** NPS/CSAT surveys, QBR discussions, support ticket sentiment, sales/SE relationship notes. Direct ask: "How is Pure performing for you?"
---
### 3. **Data Footprint Not Yet on Pure / Coverage Gap Analysis**
**What to capture:**
- Percentage of their total storage on Pure vs. other vendors
- Workload segments not on Pure (e.g., older OLTP databases on EMC, analytics on Hadoop/object storage, backups on separate vendor)
- Highest-value data not on Pure (revenue-generating apps, critical analytics)
- Consolidation opportunities (multi-vendor overlap, legacy systems ready to refresh)
- Workloads Pure could address but hasn't yet (emerging use cases, cloud data, new business initiatives)
**Why it ranks #3:**
This is your expansion TAM within the account. For expansion, it shows where growth comes from (consolidation opportunity, new workload, tier migration). For competitive defense, it identifies where competitors will attack (the gap is their opening). For retention, it's leverage—you want to own more of their data.
**Vertical differences:**
- **Financial services:** Critical OLTP often stays on incumbent (lower risk perception); newer analytics and fraud detection data are Pure opportunities.
- **Retail/manufacturing:** Supply chain/IoT data and unstructured analytics are often on non-Pure platforms; consolidation gains are high here.
**Where to get it:**
**Pure1 (shows only Pure capacity—you need to ask for total footprint),** customer IT (data inventory), discovery/expansion conversations. **Often not transparent from public sources—direct conversation essential.**
---
### 4. **Upcoming Infrastructure Initiatives and Technology Refresh Cycles**
**What to capture:**
- Planned data center consolidation or modernization
- System refresh dates (when is their oldest Pure system past end-of-support? When's the next capex cycle?)
- Cloud migration plans (moving workloads to cloud; implications for on-prem storage)
- New workloads (AI/ML platforms, real-time analytics, new business lines)
- Multi-site expansion or disaster recovery modernization
**Why it ranks #4:**
Timing is everything in expansion. Refresh cycles are your biggest lever (old systems = new capex = new opportunity). Cloud migration can cut Pure TAM or open Portworx opportunities. New workloads are expansion hooks. For expansion, this is your calendar. For retention, knowing refresh timing lets you plan defense early.
**Applies to both verticals:**
Financial services often have annual capex cycles tied to business planning; retail/manufacturing may refresh faster or slower depending on store/plant footprint changes.
**Where to get it:**
Customer IT roadmap (often multi-year), Pure account team, QBR discussions, earnings announcements (for large public customers, CapEx guidance), customer executives. Sometimes in customer's published IT strategy.
---
### 5. **Stakeholder Relationships, Expansion Readiness, and Competitive Threats**
**What to capture:**
- Key stakeholders (CIO, VP Storage, business unit leaders, security/compliance officers)
- Champion(s) (who advocates for Pure?)
- Relationship temperature (trust, satisfaction, barriers)
- Competitive interest (have they mentioned other vendors? RFPs? Evaluation?)
- Budget headroom (will they invest more in storage in next 12–24 months?)
- Expansion appetite (are they open to moving more workloads to Pure, or focused on optimization?)
**Why it ranks #5:**
This is your "how to expand" lever. Knowing who the champion is tells you how to navigate. Knowing if they're under competitive attack tells you how urgent your defense is. Knowing budget appetite tells you if expansion is realistic. For expansion, this is your relationship risk/opportunity assessment. For retention, it's your early warning for churn or your pathway for growth.
**Applies to both verticals:**
In large enterprises, storage decisions often flow through business unit leaders (not just IT); understanding where Pure fits in their strategic priorities is key.
**Where to get it:**
Account team relationship notes, past deal history, QBR feedback, sales/SE interactions, industry/analyst events. Direct ask in customer conversations: "Where do you see us helping you grow?"
---
## Summary: Why This Order Matters
**List 1** (any customer/prospect) moves from **foundational understanding** (what and who) → **context** (why and how) → **execution** (when and who decides). This sequence builds credibility in discovery while revealing competitive and expansion levers.
**List 2** (existing customer Pure deployments) moves from **your current position** (what you have) → **relationship health** (how it's performing) → **opportunity size** (what you could have) → **timing** (when they'll buy) → **execution** (how to navigate the sale). This sequence is your expansion playbook.
### Key Callout
In both lists, **items 1–3 should drive 80% of your discovery and account strategy effort**. Items 4–5 refine timing and approach. **Start here, then layer in the operational details** (support SLAs, feature adoption, roadmap alignment) as you deepen the relationship.
---
## Quick Reference: Source Hierarchy by Item
| Item | Primary Source | Secondary Source | Public Availability |
|------|---|---|---|
| **List 1 #1** – Data Footprint | Account team, customer IT | Earnings calls, analyst reports | Moderate (public guidance varies) |
| **List 1 #2** – Current Vendors | Account team, IT contacts | Job postings, Gartner reports | Low (often internal knowledge) |
| **List 1 #3** – Business Drivers | Customer executives, analyst reports | Earnings calls, regulatory filings | Moderate (CEOs/CFOs speak to this) |
| **List 1 #4** – Architecture | Technical RFI/RFQ, account team | Discovery conversation | Low (security-sensitive) |
| **List 1 #5** – Budget/Authority | Account team, past deal history | 10-K/10-Q, customer relationships | Low (relationship intelligence) |
| **List 2 #1** – Pure Installed Base | **Pure1** (best source), account data | Customer IT, QBR | Account-internal only |
| **List 2 #2** – Performance/Satisfaction | **Pure1**, NPS surveys | Support tickets, QBR discussions | Account-internal only |
| **List 2 #3** – Coverage Gaps | Customer IT inventory, discovery | Account team insights | Low (direct conversation required) |
| **List 2 #4** – Refresh Cycles | Customer IT roadmap, account team | QBR, earnings announcements | Low-Moderate (customer-dependent) |
| **List 2 #5** – Stakeholder/Competitive | Account team, sales history | QBR feedback, customer conversations | Account-internal only |
---
**Use this as your go-to reference before discovery calls, account strategy reviews, and expansion planning.**
+417 -394
View File
@@ -1,395 +1,418 @@
$ANSIBLE_VAULT;1.1;AES256
37653832633963383966356136353132303335323933666235656436393438656130373538376362
3836346630613130316437396330616635303732356238610a653665376639396364323738633466
35363233666465393062663232353962383830336165646635663333626263623431633232383037
3230646133313633330a386561653166306166333134343136623364353437643766366233383839
37343364363066626539363138366235376262376631376362396266643262656365633937633534
62386263643363636638383065666530333066346135626236613430303430316665623364323336
64616436393635316633663962343062373530396330353464316133613261386362313135616239
31333237663130353061373833336131666537643566616265623935383135386561356138366435
37633539653366396630636166343934393432303832623335363464343663333764313465323332
65383037363536653336393063653031396230373663393166343438373930623732313130336563
39633732343031613937386333616531333432666264633266666366613732316164636139366637
39353365623133303633643931316134626531343534613163663939386330303735363666306138
34353464316539663032383164666630313335663139393763366531333938323666313566646332
36336462316661346663393761643463373837653433346239663833316532313937386631633463
61636136653336613033663434343133373330623233373232376630393762626165386362313337
32303031653361663864616435633833376563646335343337346563303964343638376234356163
32343635623537663134616133386136316534363434373862333331656335373530366164656662
35373734316234363037353336653961373738393737393538383362633830623662646530386335
64346130663761353064353462396562646562383637653165356136396237343833386563343130
36656232343832316562323138616430666361613462303465363231353833613465343063353934
35626330306136633665623033666339363832643534653732333930393137346661346631383539
35393463653930306564353537626135346666656565626335336434323536656666656236366139
66393365303339313633313930336564383061353639626134663233303666366230353539636632
63313065626432303561373137663263333064366132303461363764313561346464633166343563
64346131383633373632383738643061396463643434633161623362306266383562653736373663
32643765633937663165666330663562616364346536343935666234623234303832333763616331
33346533343437656237616266376638386361366364643931616361626435343534626165343232
35313363306133343730393962303066363837616662633335353935653864383864303162343737
37333238633138616636643839623861386337636138313161656461626633326164386632666664
34663432333761313462646330363538386534366234353765313365316431316566376662316334
39636637373239613631383936633439666432373432636633613833633238616236623633643930
37333435663132393737663430386263363066643238383435396436346131653639306530353239
64313363653365666231663361666432396566303631663536616138316464383131643331636432
61343430383737323435626439656633353138336331663833626363633664343565383266636564
65306636633764666637333838636365333832373839336131333566653763306137303430376333
61363135356133653230333165303535326533633935373831326533373066626265336336376333
65336631326464643066363266306662623537353963386232643462373762356337303033383961
32383531353966633239333532626664376331383961326132313136363333663362643066323463
37343432616632643530623734323763663466346630393539346565613837396638643339383834
66323437363733333935336233636364623066396434376261313738333134623530376663366338
65353965313763663231633337396364386238303563616264623631363463653635303932323364
63616638613031313435643638383336653266313837663862353166396162386430356161323538
32313134393138313735363934316336343936323562323533373437636235356164356337633033
31373036363934386662343966333433643063316137313066323835613064616635316665303936
36626432343839373032313636383663306661326537363339386230646434393136356530316638
36363766643034316165343538396431323962313830333164306236386138623364353737643536
34336164636466306365366465623037383665366366636164633565613963653963636537626534
34346337393533343539356235616161653138363939313465336264383432346464346362613830
34643639323631383639343933616265333034303932333038373462396138343933333238393438
36643864303131323664313861353635323934303137376332386630633030373264333265626365
34333161343431303931343730613732636433313732663165363534383637353366373963323238
64373064643930363564343036356336383432363362623539373337373231353536346638623738
37653961366631343263353062303466333839356363613630363431626564336164653662643766
31616132373635656463623334383431386262373363343638353965633332313765336230313035
65613438353533643530663436383165366137666662623763326537316665626630336534616433
66326335323661346232366264363162306337343166313262323530343966306437333735343461
62353430343264383863303138323432373432613332326335616464313238333331393531666330
62383439376164303236356332636665363134363365646533633430343332316262653966636264
39353836333036366237363063646138323331393035653766353230653937316562616265353132
37633366643830316164336161323961313464313430646634633766623931393037616430303765
34616464643930333632393536323235386466376431656666666532343639666534313130653138
37366465326433623331646264613766663465646335333861613534383335356139363265333362
32316332663034386362366535613530316462363831346231346232623463633738383430373265
38393535343236376135383566616334303736663933313863613461626564356563373233646233
32363761383266376261306236363433356666316433373931626666336132636433363037666366
35666535393231366562393836633839373632633933623633663133643163316565376635376563
33323632613533656131656666363161633733356337356465663234623461616261396333393034
35316638666639623730323061303866336339366539303064313531313463373538393933353239
66613236346331656631663261336562636530646330313864326665656432623033353932323265
37383865613562373933326332396335613930356635303865633964333534643766343130366633
66633038326437313932366632623337633066393533626431633834323237323439656562623032
31633736333366326234656462326232626536646434643536316563393637343866626564323863
35356637663432363733623237346235366266623730383237373533316230616164616533313038
65663331623137316332383634373939623530333731633836343366613938313761343037663737
32663132636237616164646436373465353365313130306661393938366364663339353238313531
36653132613161636266613661373139376139663364376461383064316134303165346338636436
36393035623634386139626437356531376236373561386266616262346663343166666235383433
62643235363035613937363433616263343833333737316437633166633163383031316161333631
31323831643531643431383666313364663935326632643336313230333134336336303866643066
36666266643266616332626463333931633634343839636337333262663534333664646264336362
38313063353463666661336234623139386631363562636138343562373730363039356163353234
65636137376430396265623934343335336635303363666138633766323738623261623661376238
63623538613931303164353331353530316666653763353333663431643337396131316437323331
38633132343334323061336632663036363866343530653465383834653764626563366363383861
33643462393037666231613238306264323062656135396466616234336632386634393065353364
30613162643231646138613364336339316234306665306433653430323061376133353034636564
61663031383163386365623437343831373836306538396161346662663536373639373331663237
30626166323364633636613738313831393931363337353264306231653934316335653930626136
39613431353434666363333464386233366363323830646165343963376430363130303263646433
64366630353635616330656566343765356638636535326632396136653930396332636233383537
32623062613463373132393031353738656436336161363234613035646635633430326161303337
32643833303434643063336163343835666566393837353762343131643431303834666165633530
61643965366333336439383331383861346464633834386432323562363631383363373466613333
63383730336334343433373831363439646531376133323732303236666162623061663737333731
61633361306631313631306437623133626262373939386232623939346633336638356335636233
38386464393262373262383662323463616563626432313765313862336634356630633738366534
32326337353562313134626535316431643862373635386336396137343366363561316139663465
62326131353431623735623335303066333335386161393663616635313262343461626339386334
62323430626263333233333339303934316363656137616632636661353932623535323135626633
34393963626435396537343639323761633330323931663033326531363332643666346436323966
62633332356265656662366635356638386338393661356138316165656630653535663131363361
38663936306164346266346463633039346538666662353331353532626463376338653335393330
39643635396638373231323238626132663062653038316136623338386439313535313137306438
36666462313464643165396133633436646366656465323065643662613834643131383232393663
39393337343034363039303136323866343934316430396333303466353461326563383864376538
32396261396239633836326531313634653764303964336566316633306336313530363262646130
32313563623265623937656661386233653431626465636166356135616261353739646236313066
62616565343836656436626435323934366531633437326236666561613265653033613537343337
34363831333962663664303064653865366137613037663735383665386166666666326531646665
36613338636237346639326233643665633235353134383033616366333365393738323465396530
30343734656566616235663532333462316462633338356532386565336136363436356337623436
62646365616534623434363632663462373566656134336336366237343661636462666132326631
65613135656439343762633538336338373264303837373830303831613765303765313862373132
62346237636462643364323966353064396666316333656638653564643039383333356663666335
32383861666137663438356366356430666631306134653361316566383866653833646330386466
62393034313536363134616137353065666662356535643334313233326237643866363665376430
38623231643839366164643061616661343936383830326665653236363834613335313833613662
36306562633036393035363365623061356234636364383337333234333032323665613735336537
61336264643561383861343666363033383666643636303335646331643466643366386332383730
36313832363034643762633862303965626132343966376635386535363233633030333538663130
30383765396130323835663664643233626233316639363935323664656533623437373531616361
65663464383239623730313664633734316531363365386235366435626436396464666161366437
37373865306638346138326236653139386266636534343863336265336336353363383036656566
38346232336538653034316431306262323163663265633862343234626163653632366439343166
36666336336139336538343339666338353331313033616530313865343362353034343663343730
30613466343664663664643932643135376635663730623635386539393561363937326338353366
30323433643734386462626532323063626462343032356163343462623330623031343766333039
65663035383138306664643538343237303837326666656662303130363362393730336263323034
36383339363664356538336231646663303761313561636566393363636665316134363266366537
63366135373832316439666364653165643565336236323366383363616430326334356237336637
38613430333030633734636239643434373964323232653030323161313139663364623234323835
32393037353262643534326330386239363231353435663734646638643637323266343766633739
62643733313938326365626631643536653065313534653738616162646532626330383230303739
31313932616361343030313231623062366635643338373965316136393433313964393737656562
39373232333131356566616134393038326363303463663739346166376663323763623365346166
31353562323632376335306338623530643339363534303430353564326365376163346664353930
62663535373338616239616464316266303237376437653138336137613164613636613236376366
35646531643534653162616337343362333261306164313731646431373637626136663135663732
35373330643666353637643036623731383634373361303438343130316430313934613161386662
61646465343866626561373564366439626531333837373232313730626531363464663638616235
34386361306134643833356464633038346537373538623365316335313838396466653361653764
32366138653963326263636137366564363439616431393839376236383334633830363063363237
34303962396331663364666536343261663233626533653665333836343465393533313730316233
62366338306434376137663665376461643239383262663330333831386566343139313066616565
63663866626561306332643930646534623437343237626639323564643839666664643264373239
37326137383963666464366662386363646138656130636465616464613866666530363939613338
61363135343563663361393764633462353730613034363137326333393766346533666638376431
62353034333130323738373264336661363235653039336638373135363633336138343930313162
36653139623863306431663232393663396663636664393636313436343037363265376432633139
30656232363830386261613935316539396664653937623765653663386461346361363663666262
64633737633132653632613637643134353564653966363666626231626639333830643133656232
32626533313331313438346632383539313364616133373937313633646332333563376631316361
63363533366666346130633334376430646364396530653866393833653836326134633033386262
31303934373337346634643963323066626333353864326435363865663262663561346364653430
63326663313038313366386138636663303765376239616335383630313131353137663333356166
30383166386561666430323164323566643736316461656362373332633732373234353630333963
31393333383265653331366439633965653964303661643031303136316634326138303762396164
37633530313638343034616133366134396331313634336536363133366464396365646164336665
36313139343036623466373661316336646137383264316231386365636334363463376261623637
64636162393939643637346462613364313964623230336330336262656362356365306435373133
64613866373762363834353138373463666435383838313639653636663530373635636262623138
66636363313031646466303564623836333164353561316661303762363337653033643234616435
35373035393862626131646339383763336234643438653237656165666339373239623763353363
39363030313338633530313638646133303634366666336634666538616430636464366433333639
64346632666531333639363638653135346637396430643836613565343531313664323866636564
33613130373037333064613139346636623762343031616436373764363236313838663032316535
34383935313332633035323837373835633433336365323562636136353232326331393530616632
65343934613436343663336365373434343637666131666336653966336264643131653637316339
31663633363732313166383661653639613432656165316334653135383036376439303263656638
66373138326239343162663163366364393366656465663133616339373332366434623633386134
62326565373033393864396166653234383538623337666166666138646464363339626132376663
36646561633635346364613163623534336331313230653833623036353338313833323337303635
30626434366563323765373434336134306239323765633836313533383238613939313561343464
39663038346466393762663261336231303237376234346566636632376530313065666434393634
63636634626434613734383337616665313232336436303864363064336234383965393535333965
65313837643235396562306534636366396333316330316265666639316562373033346439666230
35333633666138666465383565373130646537646138343132323266303133623661393031623562
32386230313865643536346430393734333137663461363538386436613036303765623330353630
34633538653033613163653761623731326631393233363837626664353331343639643933653137
64643965383762363065353237333231663534386239663061646430363635636662313835376231
35383337363933396564623334366561326535363063353530353263663036333962393765643961
35353230306630333563383938613962386132366233613233306664666237306236643163646238
33363533636532303766373361613930363765353431626235643036393932663534336332396136
37316137323565373534633433323336383631653833306436653832396334663466346638613264
39626137333966393733333230376261316636653634636634363235663534653931393663373139
31356135303130363263366661346633306638653339363061636463396434393631313231623064
30393137656336613461313731346637386464616433663738353233633132633762373033666661
32323632656566363961333130383434633838306565346130626465356464356365333038373538
30343161336531633535356433373430313633613538326637643337373936343638323336316263
34633731653534623139386537336632643834353038636538646334616366643430376137316636
65636336343639323564336430343262303233323430623034343539333937643239636230623034
34343461363363646562353262373165646133636364353762613464303534353432376434303336
36333339343531386539323266323464656335363066323333623135633466613130353837333561
33623663643135393465636339383164313135393932303235316662653666613566383361353430
65333234343361306534616533396564393562396564343165666436313039393936613566313864
61633065623331396230393135333137333935656664363764306332666537323732373866366163
35336539636633636566663561636435666166393265306463373235333039333062343462376339
66343433336662303861336238303236346262636234616635396137393339396665376539313930
33353034363730383534376262316439373833326462643362663964626262363831373337646163
38393930313236303633393032343237373736316163393766303566316461626539666535336361
33363263616530363730633931376532333135333135333333363439643164633635393631336132
61393334316331326432663237316534323064323435646130346532636130366164643766373665
63323238343839356638336536353162346662333436613566376131346536353364623961613637
61386561313036313964316133613139653662613730343365626664336364376464363563346464
33346338633731653765383963376230316165616564333265316633666633636663326230646138
64613262376538336431613836613338393135383532343066646366386363666166363763306164
63336635356665336633373235353034643039353034346132666433663136616630366636653663
61666534363037616565393735356237353461383235646132323762383932323538373337373336
32313830336664653235333464306438356132636530366636663131313733663234326663663837
65626431653636643662653330376334363638386633396532656432653736663932623034343565
39346165396136303062306533666533373464633966636633313065366563393736653534373235
38346261363163633838336137356163373338373032633436636233643133366636316334316430
64313132316665343332376264393966636234333739623533613833663339303933396134313736
30383866613338653836653663346563393564373163623231386531393865633639303863353832
66623037396337663434656364626463336537303239366532633634313639393366653538343133
37363733393630666464646233336634633836386365663163646331366363643866376238636130
62356164306262333238373863646662393535343033306535613561303036313031363932653237
35393832303637393437656531383363626235376339383433353762313131326532626331303731
37326161633964383531643535633664316162353637353837393565306665303331323436343536
33306364656537623030373334623733626435346432626362636630663064316138373536643230
30636336653565323038313931333966623431643062613930643230303866343837316163376566
37633731616631663562653262346131373464646330363439653333653563656565343038353633
64653333653635356335643934363939646237323062303836636436373431396163663036613030
65306566336339646237623462613365656338636366306361343263366230356261333931323630
39306131623763343032376266643138336462653930653766613632363237646538613366643465
36613966616662333365326231323161636631666536393639626663613930366635616431356162
31643938636339666430393861326530303866386537633834373966316537316262313834383466
65313638393536633865353439616433323536306161636436653966383736623366656463663331
34386565363762346434343535633132363566643730613337306638366264653235656665383032
34373737616663376463346632353361343365346533356132376437616133316238653836666366
36363865303765656239393963666164643138333831326161616266326335373463323531316531
32383766303062303838383637343730633364646632646139346366303036373539326532343331
39313034616465393364373235353536613062396362386462376235663030353033643331323961
65353961623863366431653664633038373737633130393563333932393030306165613564353435
38623138383235303766393566383833303362633433343665313339636336626661353766653262
66343830633132373131336633613336386333326230356132613561373630346433363861303933
30303638313235366263393231366234643262343739303362353265393833393365636235623534
33636638373438636239333138346462643763636239333938336165366666353564663133326461
66346465663730646137303365383436646565333738323661393835653331663135336136303431
61376536333264306230393930346332623266633063326162613532313164343134613864633134
61313334613866363965343233333961303936643866376133666266613236316337656462323865
39643862623563656139373262626566656330383938336665326261343732336361333139616634
33363439313365356438306364323562353231653339666133333330313566323530626432653837
37346231623938626431303332333536363839616264613563316239343531306562626362303265
61303231336164653265326666623335656462396238373939626230303439396561633261393133
32313338666561643233323330376465656637326235313039306461653066633431393936376239
65623931656330333430333963363964313164666337316631616461323834353435336131646236
30613936636263326165626465323766666533366566346261393131396364613735333764363036
36383562303565303233363962383363313039326232643335313362376438626637666132333766
65646531313735363036313337336163303039393734383866646566383432316233666564373534
33633031373730646365616431303138383434343930333031626162353533306131353833333363
35613633633962333261386234633534353236633737643232643436653739616665343962616130
64356663626636616462663464633765386133386430663266623530383931616233623235663332
61363037383934376663343132623134373663646130636537353162633430643434623837633330
36393837656239366436666532353730386335633264333937363763623266613339333937323731
30663930303864393731613430376661346666636161626338643762623961383966333863336234
34623966636364366664633264326433383964666530636531346335623066343536376464366365
31313038653338613436383839303966336231623236656238613836643634663331346636366537
63613932666438633030323839663331666239346432336234626565666139356332393736616234
34336663343661346663353839633831306465356461353035333530623663616439346361616164
36353261373930323830346237653566393337346338326163663130343934363834343862323331
34343062386332666137313765643733366331636139343130666665623337386130656462633262
39303239343665313665326538643062653564363864396437616330396465643262353236316138
33316565613730613965363732336138353137623839336234313836373135326436333462373438
32326539383536636463356331336264316562393263623735393037393637346664643330363664
36366130316333353861376466353035646562333063343862303734663061373831613337343065
36316136623664663631373333386430373734633236643832383566653231333430386137313262
39663736613736323263353934306262363563653939656265373630623837303933353335343931
61616537646634613131373362386230323037383239323866666461653839623063343239346336
34653835396331623363326163396636666433353036346433323266353065376531366462333431
33383565346632643333363139386437656666326637313736313033656130666531313234313533
66356539396666633534323036656366343862623730393333393563303035613334356661343030
34623536353730363933303464363464386333303034333764633762613839643231356534633736
39396635343961363634313737623838623566313336663339636461643633656438623666663863
35623338306661303532633164623363396166646165393832626336376339666532356662323161
31653435633830343339636533383734383531376439316530363637613439643338653166656637
31636165623935323337326465653164376662383461396435383662663163613863393935363464
37393333653930363035303738396164663064383166633037633137343362633134386339633864
64336637306431653363306231363537353064346464323264346365363834353033656261613463
66393133653461656430623938383163636631646432623763633933363638616263316666343435
34633436333861343262303537626664633232393433396633383939316339366433623062346337
65643931623133343161343630656365313165623734646131626563306632343562306339656564
36613137313230623538336539373062386663343431356537363335626161363032343633666132
38343934633562316136363232346361613965323134623962383031626264656238373761653330
32323537613335343633356162383533303434336130363564663933326632333935306530636133
64376535346632636462646337343539393339643666353033306538626363366264376331356433
66356534336235306565616631386661336639626265313638623635373931613435633736353064
31323534393239643636616361343236363730623036646436636462376362623334616163653733
64346435303963353033623433326263666137653232393964356262313835336663616631626466
62656661336231353238323035373632313632373966356461393761396434363766653964633166
36383564613734306264363939323864343261343566323264363432666565613430623962363266
34316137376466653136653761316239363834366265623265376439393861343432393962656639
62363762333966653734656234316533626565343462656361666166346263323436323039313061
36633534656466353439653366653138323362633338663835316663656661333032346162333639
31366532356635343766363761333630326664643834633734353635643661353935366565626633
32346533643232323165333536626431653330643464643439663931383035323935613930623039
64396165306339646334346565313765623930383363336666643737336465643264393230666666
39663265663036353537663362313566666538373534633836646163636663663166663636336338
35663061646161363039663136663338383561333037333430313431393935356361303233303532
37613163613434346235313861343139363963366331636361616138633933353238646666633634
34313761313532636431383437636564373230646361373834376434313261323239353962306437
63303561346162666132326666643832633234343739356562366336353561303630663237626536
39623330323065653630353966616564333639356166646362306661323462613139323938306165
62393765373064353164663335343134343635383236656337383665613734343863383265356139
36656134623438376165646463643562396562323134363131343639356463393262313430663364
32613861326138623464363135326331346236333064376362636562343063616332363134313736
64363063313463333732333562303337656265643530613532366430396330633038386135643831
62633435386666336462623133383533396333303666656563643030306566346131303066663364
33366365306532643439616130366434376363306335316134303932663466643538356438633462
39383066343835613338653433653234333665633165353737616463663239383633616633623635
35303335376336623337373432336665356161306164306430653635643136326436353835323134
64653861643138636330323734663030336233383338656335363466376538363165616132636134
31656632616231616666376463326465393134366335393462643464343330303164336632646464
61303034363432636234376137643437306134306366623963363033306436613637616664646136
66373661313365373665383434353535666532306238363534306265346434353238626135356631
64333034373366316262306366333561306336313961373734386164373164353933613063356431
31363734623265636364633736643238626334336464663934323265646230653032306434396632
62323366633035393938346232346639316331346430633363643762393931656363313263633936
35386130333538616661393339633262363536323766623633383834326534323930623365623836
38653430303430373730396134623666623962323336643731636231363162326436313131623838
39356338623137633530363835323833623763346239623639393063356361613431313338623461
65393030363839386534643039613666303531363830653536303135623036326437643962373365
62363338303436346539326634623230303462303934303765626464373533623037653661373536
32613238663366633331393935343364633738633564393362373463636463306364663932663862
61303232373233363132626331323532343965353563633238363630373830663035306264376439
30663835326436393132303635663837663931633665633637343030383761333533383937616238
32666563373366633137613431646265633839393262616532653362376232616635663165626164
38356165383865363838616131666132323132353437396333323461303631633330373336616561
66613439373666343766666238306237616265323836633531666439383730396366366561633034
32306532623365313434343261656465323163383035353631636135646164383933383838623061
61376135356166623363393934306132323436623139643963396366363733626531326234616661
61616166343736633965333430353962653264376532396137303235333731313466633662626165
64373039316437316239626339643135373161623931626236633065306663383935613933323839
64643234633735316165396437643964326636303164666263356361303733393135386139636539
62656535633236353661393534636530643061653432623931373864333061613666623363313565
33613339663562353330323162343065623564323166623230393631393161346438663333356330
32356131306339383131656539623234306463623065613132323139353532303137396132396564
35313032353564313436383030326266646562376266633562323331646631636564386531653834
61373331363530353966373765386364326466396134353033386464626531333162386234653565
37646466623334666635313563356133316461616266666436646535373164353135653265323965
34623334303161306564356434636666396435363032616134393937383264633634383237636262
61383961333663363964616336383830363265356635656165653330653666656564313039373638
31343939656466323962336535366661323237336530333431636434313964393264636538623633
35313566663238623264623461383038633338313063323865633035393464646435366139616436
33383538646335356563626535633965626330353937363338376133643938353330356638313164
38663366623931373034376635646332356164626332616439666464653532396265626163373030
37663633633533373232623561393130303664356335636430663439346561663266616461386435
30336337623262383832653864316331373765366536383865366164623833633833353531313339
64373836663931376266383333616465646339376435623032383364633337663139616463333630
31363236643535666162653733346536363739343663313266353966666635633430633064356163
38613834616431373762323964396663306161313465306132663061336238323832663132626162
30393466363930363538383238383530323061313137303233383030656365616433316461333831
62353862353538336236336565646366383565356630346635646362363763626636653530306537
34306562366134313239336630376630643838366165323934616562373336313864643465633464
35396435356632386537356131643539326234343237303536356238313236656236383965393139
35643531623832616365643238666131636435323364303733623132613533383766313164616264
63636439393432356662336339356361613863326336626337636432636338393266346666643736
66353338643363653037656339613961333132326161313661356464336234666136663866336432
34613965643635613764363137626165633336313930616637356136663061393936366664636530
31303733383431366339646530333333343762613839623139313864393536343137643162313132
33653766346566363666643663663237316636396261343362646332633362353238383239353239
39343635323961616164363066386332356235613664303138303336653866343431373332636161
64303335626434383062653364663366373262376163383436323865616232373334353566616635
38363935633564653236626234663038613636303563656333656430393061353939653236363065
66656338393131656661656434323831373637626339336566653363366565376138663432666535
32393366313132323036383930303130393561323234613438336665663566653837353131323361
66656234616635663061376236306163616338353733356562346637386539613337653335343835
66313934323634663066616638363637393661386130613462376632633466333863343534646664
61313239313561643561653934623737353936613463306232653139623263343032383464346534
37363964646531353165383135636365666464643530656534316530616636316435343732356539
30633361393162646430383263646362316161623331633831646235386137623264623533616566
38333866356137323764653830643964333964643635306235353938393338333137303434376330
34333136303266653135653136663166633830386662663064393937666539303463303938386235
33616432303730323531383034653062306465386562383630393239333733643839623936336563
65636265316662373833333131376163326437663664633163323433316164356536323633663463
32313733666532383436383431366161633563363230383561646134666339653962633739333631
66393438393566316233663431366562303733383431386630326438366134366162343230646336
63363431666465393638346330626364646465646165626636656137613261346463623261653736
66383037633130653261376463616237376665616536346165366365653363613163373139333435
38653662643861616565346631333034616562666166633634356561366161386630386263333637
61353335626531386564383662626662343366303961383463393236636561393461626464313739
65326563393831313436353263626238623131353338643265303565623564656131343936333439
37356363356266396539633162616431303036343839303736313038303832376361626231653437
30353838653462333462336538613339343436353133313935626363373138353161633062313666
37626632346263656264363437386136623761333238353165653336356130623166643032356230
38376232616234316139363230326330623839306634623961623737653333646331623664346265
64396136363465363765326466306431643839383561336266623765666336643539373433343938
37343564656631646463613562626231616631643764633832306366623633396336373734646461
65303139623334336339653439303331353137333337643835396135626237626163346662363066
31323962393038343562633963623834363963356531656637343130386366646335313031383337
65376266383139633034353836323838393230383637303366346630326636353831643037303538
39383261393466316633353136646539353232663161346666336435303734373066633732653666
38343062393037383131316232373439316437343638363536373930373633323837376331306162
3932356466613238643533633839343335366430383837633864
39633233363564393465633065323730343130653362373436356334323930393036656638303033
6333636536643835303936633039633138336434663565320a643961663732373630333238633466
62633236666331326165653637353038313163643162323763313635306230333338613164316337
6133396638336465350a306163643138653639646133346561323333313332336639393764343364
30666535323264363661653236333839643631376337303563383739656238343732396364366437
33333437643930656564376236333736336465356134653239393036313830333462363633653366
62623863613261323135633262633066383363313731646361326232366661356261316633323164
34653661383366643531353939303333363832323731623830393262373538613631613631616463
66396163613965343833356432643534393539643961373263323533316664386665383330326665
35393537363734333961393931386161386234323264316466303361663139323431303034393035
33353432363162353261646231313136643533363563343537666565306638353564646235623835
33653663393838303364363762613663646139326135313438663838323064326265303733346164
62386130613134653739353930623330663135633632346531306535643332303939356263636232
34313939333263313133373635396138643838396235626662343664316632663930313038643863
65653630373437373631633032333663353866636630306235363035623837656661323735356361
33343961373235613262613363636565396234613865303937323432656433343836373831343162
37356234363662333764656530333139653434336137326631386366306162646433333239316133
35303136633664336433386331316662313330643734383139633938636462346539623835653738
63373439623866363863633963633265373339613130343966656637376632396139646266306666
62316430343338626331326433366266396131666264633234323233646639643236323937396431
62323738376664303237653563323164396166313266613937383431653766616234653762383163
65666138353262663536623036623439363463306136363930343633383436333565363232376162
35343035306230666432666131643634383263663838626466633133653830623330656164313465
32363031313266396532313563343963613133396232353832636464663462636138623637646135
64356364363862653465303837663261333933623266333736343938643065393031343535633762
62363436323931373563323863643066653237393339653363656133316365346262306463336364
66303732366437633461373065363730336235313732623131633366633834376563663734653063
31656638623933376666393232613238323239623836313430356639613131303761346535336338
65613363366331613865343163623863636666303336646636663465373065393731306634663239
32653666343631666235383863336338326438646137656562646431623436323037313438343839
34663062396564306138356465623032396239396562643933306365613033333065323630393665
66346262303965353539663463626261633332373833346430346235376636323633623864353133
34666265373936376339646363383834393166376434653233306632373966336232316339336465
63313866653732386234666531663030313364626534343235333463376334663061316461613331
64303962393930373036643561393762666334666538626233333937653038633734626430623837
33303463386363386562333061353963303538366362313131656433383263666631666566633765
65613238393736646637346333323933326664663862326563363566613361343539646264626561
37323034323136376433343433316266386135333765643036373565326539643066336533343431
34316264393261373130333930383932343864386534376633336239303066356666363438356632
63313637616465386363346162653064613236383166353432636530336437376665643830356134
35366239326637373366383962376165643932336632373330326533366535666434343161373264
30323130626236356666663230613837373666666334646366616339313661613238666138353832
37633066303139313961643935383632646662613766303366663335306263646439383534373639
33643733626531376565623431666637323736393361373566626666633232383832656333333566
65616662383561626161363061646334633033386534633533393032386532643939373736356532
31633430613338343762656630303139316634323635653763386366633366366336383064613333
32373164633066643064316535333364366461666332663165346462386563373864663565613736
64636131346133623166656235306230353733373263313166383761303364366434656666303732
34313234663139616466633562383663316630666261336463653236393832303363316438326635
32343135396333613936383239633938393261393435653239643763373065623531336633313130
64323761646666636464623734656236653439646433303934633536333862653930303133336264
66333361323735386338643933336636303263613739666432626137393061343833636537396539
62626534663636323764376437393237663839356534306438306431663937323364316431323264
38353234303364653833393733383736636139336237386137313937656137636562303036666135
62626530383037613439646261346130353430303131303936393230333662386533356631326533
65643931313036623766316137353130363535383834323363623334376234393265326362653838
34353665666537343232626137383166383761643232313539346461393937353737373935323462
38303631383465623865323230333330383730333536346366616332343539313237333538333431
65636235343938316635633732393430323932626339323236323663663932306463373039343363
66363235366539386430333365613338646166313133346239346537313666396435343064643537
30363236396563346165353032373463386630646134356634646465353136643635373164643263
31396333353232323937346530303334623163316538353937396337636165356163396639373731
64363036346434623366376261316632343238306564646234383433343137396462303466383635
38353161383933336264393332386233643666656132633038323936353630313635313365353663
30353734363435373265616333653737383838376463646466633732333537346565666433656137
62333234653264626532323064386639643136336264346232663537393065386666623463336433
38613966376535303961343361363930313664633030353538306633656335326430653534346161
31613166366231386239366631623839316361303630663063336637303832373463613735356364
39323238656232383632643630386261323764643861353063616130323939383732343863356633
33303965363565313535386463613030373634653763313430643335393032663839636164303630
38653930383661386331613763316365653538386266396430633638633638653134633365343937
30313565663139366139623065623730356363313731663034663362313335346561323130396333
37343165333434613235363964323564306330393738333635303763633466366664616138613862
61623464656630343361353433313836316230653435356661326366366131386637363334326336
34313334386464363261343831356163613834353639323562333930363563343963633833623832
37323964336565383062373739346261306139376637306461336265316336666439353536313063
31653432373235666235396239363261316333373332333234376635323261306234383836383161
33303136363335353539663833306166643737333238643066313633323031613237666264666365
63333766616631366639396235633466383062623462363638363866346462306535623839626231
63666633393137316637616634653139316439626432366236663738316166643335333534626164
62623861356134663637663665313934313833636165643732376438316563383539663037373432
30326233373936346433323934663633363166376463623135386238393334396466316265613965
33623362653436613835386535393038363064373233623266643466633732366632346330343438
65383938363063666263616330356661343732313435383137313435656337353063613230656462
63383465343932303832313335346230313164333362303935343836363764313933623831306130
36333339373230386162323337386661303663616530383863333332396439313833303461643662
37336231343065623635376564393934316333626566623765353265613737646131653934343631
32613463636130333561653066343839303332396239656534303733643639343337363437656662
63303732326235643830303038663131613330303863353231313561376231666135353464303864
66633332613139386136303264366464356434633434656536393664313130646234393634326432
63663938373932386564363834373134356464633662383130386337633564643930613734383631
38643239663733393336643834643438303737636635396630333565656264653866366166623332
35653430323365313834643862393065323531633230613630343738333934383161613264356265
66626561386138396639353934653465373162323838303233636565306534633236343066623638
38653132376663666136383235663337343762646137643935626365343162363439303061393563
32633366623834613739303139333038303762316663663136656636663663386362613264663238
35323038326239643062666336333932383065383461373933626339383434363736323536313533
65363565306239363935666234653438393039303663623636376262666164393334363839393665
62326533613639643938613838396565376334333035333962626135356265353639653633373438
37316463303838633335306333346465386461373766356533313863333534326231353864303531
32663461623731653138646336663266303065306562643961393936373937653664323032626339
39373539363664316432333931633866646333346363336466393963656637366562336162613465
32663230613335393839633633633063636635373363653234663564663264353036393434663963
66653631663062666338326133636236303862356662666533326236613965323462303839623331
35626238323837343738356264313266383135626239363731353639633837323132623635633435
61386663366533326130626332613562633965633837346237633334303564363136633733323361
32353361396166396334386630353738643438643537303034623830303939326436363239343664
35633731396161623934633864313863323336326535313637653935376436363162363565666634
33613435326539343865653862613263383236313130383234623766333637646464613365346532
34653035393862643039623763323138333334623866316437613435666530396134306665316334
31373364653865323765646535303166373638636134326538343563363732636337653931373234
35303433656137333635383334376237616261393334623835353866323333393630373765613835
33363636323664383136353863666635646563393764633531616135313935333539333664343664
62633465356563353433633564303531633236656631326331323638643666643636396162316532
36313839363164663336326639313066316331303365386130666662366638353834306662303336
34393538346338376330333332383231333630353334646635313037313736613139383832663566
30653137386339623136356663613431303564633966383633643830393636383734306238333831
64363933386532346264666638316161393435653661356162306262323131643535636134396439
64656538313938623938323638393734343464653362636532386663303762326234373930363866
32306235363837396339383461396430373964396533616434373937386266396439303964356534
65663262343461343864633934636536366335326162393830633232633832383166366430313934
64643262313163323531326163323431386532386530326332326130353230653263616139303566
64386535326262326463663734303061613435653964303764386537636132646430626530313331
38323563303738353630653631333535376239393730633535353934316131333039353931616439
35343531353332303932353636643763643035376161643536333365666466616234326237633430
62666464613334633537306431396534306237313032306632323266613134363231313331313139
63316661303830653035376261633539386533373235653839333562656135636433333263303030
65333136653939616231363835616134373038633634656366363266386534343233393730386436
61366536333730646562343333313234663037616461643864613334313464326661366133613966
32343831376266376636646439323566373033643939663261356432623865313833316238363363
34353765396233336263373161323433396437363463626464386463346330653861656162346639
32653738353738396630383431346535616165306638666137616664336235623834616239613131
33643866396530643062633537376565306538313437623462363963363364303738376662323237
37313438633138666434646633333262643333633930396439333565663930613263363638366265
61376165353939633165646330656631323765313430323630303335643362393763663134333333
64383338343939616362363930626235303532666538653561656162396566383865363463373233
62363363623938383234343639656631343831616564633136363332393131356531343137396534
65626639613663323139393061353636313036396163646434613431323864303130663531343964
35363339336436336532303763333564363962313637653438626235353339333438376433396565
32646536383036353864356134623564653436326232643330613736386162656161336165363463
37646138323937343839373736656332643430386630383463393637643463396231303730656530
61633563633338663635353235663931303964333839663333323062646266646466356434343432
38303762336132333132373532623637353731306161316564373935303366666530376639613166
66383533663634333162373436313031643533336436373563356364323236333632303663393761
32343631643566626433306532343764373263666537346538303561323465646237396639363065
64646334633033653962303437396430646332306562623763323733303930623763393537646532
38336439636134666264663339346230326435383365616433363535613235626439623037666234
33393434306662333966316364303261643137356136326637346539363538353465313662393534
34306132613636633936616339636632623733616432323564393565336633366464306661306130
32373465656131346138343537373463663937636337303765366337376666346136643139356234
37376436313834366538636136303965383330663437343363633861396634373764373863613734
39363837613363663337323137616139633935643437353838383735383561346365333565316336
37373231343665343838663235656132613331663638323331363763373539323239306232656338
33643632333863333064666332663437613961393530616330643139663736313733306339346535
31386639363338656462663566313836313066623661653139306662636331373038396636386362
63623431336139316233396432396166633762363662616432616634363639616530663931366230
37396434343039303232363633626532383235376538626438356639386661373330663838663134
65666437363139663932383035303036343633303766343836363435313739363839373938643562
33393733303165373364643961326634396265323962313433653634326436336564313338323631
63666636646638626639323631323335653961363964616461393632316363623162653665333532
34346532313436356235343539343330656235376261326262646364393866343562323663363232
32636539343063323734663732303430306261366361383461363963346633316632623531363530
34343633666136323238336530653335643730326439666637663266363563636466306531393431
33306662653537643835656637636466383965356663373530386431383837373438373436383461
66653735376535656637626231613437336663333132386135666432656638636138383939386665
35346636313439613335653635653861303231663239613563333438303738356439343933326332
35653764613061356465666361323134323331326137353863666364333332326133343039353164
35663264326432366330353230643762623933346363623039656137356135613063366265626334
64666539373230376335623365373866613761373637393066343536386134633131393339343439
39353938643233636562353131336262616439623461363531383064616564616462383833363338
31396436303330336532333661333263346233626532653437633766306230643032633035636132
65343938643630303063336664393532363131333532323566306338343837323431373062393265
31653964376339626632313734366665613563383266376533353664363764366639393563313164
63646661373461323039636338313165306435323530633535353331343166636565373630333530
63343262656538616232313633616166623534643834313138326634356334636635363935343030
61373533373863633630616135626335376136336261646465666135383130633032656465623938
32306433303937363133623537313535633164346536626465366663623430383639386662396665
65393038383136306331323035373432666565373934653763336336323131353732356164366262
64396137353838303934363033393363666632323137663762626163303963386461373166323734
30336135396262323736313037653133623237336532663765323034383766363932666562343335
36623731326232653363663937626136386139663630376564333230313632646334353664303137
62363261316161356164313433666562303965646362626331323538663865386530393864373932
63326161656561393665396461313938316430353131643035353933326430653439643634316435
61626437376565363365303061346536386134356335646137626162663739656631343334653866
31323564663165376266656233316266363232333336356137353665613961303065373735353735
30663339343631633931376562316262356462656132666661383064333838323061616361383264
64666664616231363661636337323839613830363366373530313963613132643836353062646561
32396565313631623639666433663037336338313033373938653336366539653166376237326536
35323134313962613962663630376663333334373431383731383038323965333736356462303566
32633164663734366262383264376539313438343530303364623566383564376361343231386564
39316366373964333837656536393961613466353436333839396331326463356636623738613563
66333030376364383535633637383439383237636539623930623966356461366436323933646266
63363264643238333432346432363339323461646533333266666630663161653031626361366364
62613433333235356438326439306132376234326437316437373537346363303537626632373537
33346534306363323133353163663932633632626561313566383865393461316163616532666463
36333966626638336437666338356437643862303332353663303537613933643338396432663336
65613565353536396132373663663730386664323732616533303037363362663335656236383838
32666463323463363736316534393538373938303062396564333435383834646664346261383535
65653931623934363330366361303030306132616637636366313333303664656363623061346638
38626261313765303832333562383763363935656632343735613935356333346237313436326562
65353230353537373737326239636465656566346635303134363739333139666362333964393465
35663837633132663461383036353065656334303730636661363236646462353238343737633635
38663762656234376233623264343762303434353366363262353232386131303231373832336431
35636434346531376461393330363837633564613363373235623837373130336465383933353039
31363732656164333037316438393532336435663035346139373439326565653363343336306638
30383635656336643934623132383433333962323438303733396131333761643730343464633539
32306664393738353334333465363966613261333532393130366437353433643439316135613861
63303863313133333339323665343539343930356637356262316138613733613465343031376538
30633537396262636262663033656430313831616635363239646334356334323731626530303034
61343362383935343835316561333231323261643838616435313032313636613933363837343336
34613131316239653861626434366338343232326531396530373165663931396136373536366339
64306363393061333333663435666130366630633838333533306163313237653462306461303030
61353830326337333563356165663062316165373939333938373637636164646536616639366634
65353035353836356264346234653431383565373637383035323234316637356530346332643635
37326161333838643166313664616331623532356235323564356261663734613464666133313230
35393763353639323666313430666164323836633138373961666162316433396364393563626265
64373565393965313134613365653062323764346432653630643662663930353738353038643661
34376363633338353132363061363964346162663438626638373032326536363733396634303461
39306432646362666336393437396330643864353437376235313966393837393435373437313065
33303439343736626436613663353739656264313639626633323265643966643566623765653739
31336564393138356265623636306266363861323961636131336331653632626439613461383735
61616265646365636133393031333733363537366262326632363962333433656366313965303164
66663335636363343739313838633235323561343833346332353532663065363439303936303266
34356331633961383264393765316236376130323233346163306536386436366636636139363035
34623938366266343164633532646161633264316334313930383565623766396165663664393537
32373266656134333136393965366538316366313735653535626635663462623036323832373964
61343236616264373563386265656364333230396462653833373338356233393962663965346636
39313261353263373364373034653338383632643430323335306632353331633939383137396164
65313166633632366465663466633733663939303039316265313539303564613038313136623861
37383837653262633365333063646331663562356162333937383538623662633134613364653033
32613537633639656361336236323039383533303661366432376665323432386464393534633066
32616666613938373066313866306330393061396263386563306661643831303266633465396364
30613961636333373462386531373264386332303063663264393861363263666638643836396234
66613235643365316233353262663739313831373561633339396165386530313538653538633039
36353364656136306161636135663464343033306161653365653166643762343035336166316164
66346363646238636438353036353631663135356538663762323030353965386635306630643131
36666330663063626334386431663635646561643464616636633735626132383961663265626631
61653365373863313832366538363864333537643336623633343334633333323561373638613961
34396433366665333531373031336362306364633537646565656630323334643038383535383036
36633431633930313065663337366533323463333162326435626264393162333162656236306536
62323032616162313666303265636636323163303234656230313061363862343736373630626435
37616662393737656132326537336534623633613133343833313135336330303536393364336363
34383136376363306264306237653236326166323032383939616563353432323565666263646164
38346663373335636131366236313134623435633766653266383661363532353931323831363065
34663732643435623436323236316365313861346162613636323138303831353062616263626436
37356230643833323062336465306366613637633834303762323838656132333130646462653766
32393532636463363634636665643439366531303162306133626138653839316433343532646530
36383066303539386136643436393764613937313933396335376262323064663363396133373739
66643332333135333437623266626563356337366665336632333035323537343762376132373331
66303630353430313961356234616438363436323938656466636264636364313431396165623530
33306334656338623237313333386135313961633666363031376334653433366330303262363832
32333139643434623031653137393134343961353261366464383537643237623166396431356430
64356435356566336461663766393537303737393139653132363737343238366361316463306136
38313365333863303734336162666130393531356531623865356431653237343432613238633566
35323166623935336361653530373466653063383339396466356334663066393539356432616362
65643536316137326533363930333966666632393334333536326431633962353362313532383033
63376566643166353136353733353862643033363035353534643062303133633064366332623938
33363862306263663737616364633462363736666530613562643865393466656131663663663166
33313839623739386565323133323263613939623838396365313265363466666235316263313535
64393936396332376635343362396564346634336634336663383566376365353836383739333665
30653038346161623536633361363961623931326265666566316565636662633032663639373361
62653665366338656532373734663138353238356535353036336331383635306236656130613633
34323962316636333664616664313163643061623836316432366664623164303365613161653466
39333538343435383066343336313564386136363363326231353838373437643136616336393561
62626430393735326366353839393166313030376435303039643739373337653635316466643236
64646237303064306663396439316431393931626431616630396538626639636432326561653562
61363338623863363731356566306231366336363739623735313134613933373238303765386234
61363034313139626636643533643738663266343235333465663632343533646131393130666632
31366562643166616430613030333832303636616231646239646435616530343032376663653031
65636561353734656562626463643361633362313861373233346633636438653335323761373165
31613130306437346630313466393735643434376133336565656566323132653962636630313431
30386136643462306261383135336362356161666435323132626637346365396436653835363733
33393732633464626437366439303931623962313433626533363136653939316633326432356565
62393332656238303763363732623163653663373536393265343763373365316337323564353064
31616634653837396438303938343562613761363766616561643032643065346238383734376433
34613931366331366138346636376566646539303430353665626263643465313133323939613361
30613063656665666439646362656136656439643934623730383736353031363633653036643161
39366163663035663135343462336564383862336531656138313431316263653064306334633062
39386538616361623265663536636130383362633934313764373538643636353237323133303266
65613765313838363236656334663635636264356331666134643063636334633131353734623632
38316637306163363839366536333365663337383632376230373137386535303666343362316663
62663333313634313361373463313566363063363665323565633935303738316135393264663935
63643662333430633535663130343930373763366430306337656162383537376564653439343430
35656430366365636165386333663566313034646564636261333361373030663633373861323065
33363463303135336436613563633135353531623339616530353538356130656263383265353735
66383964313665623133303130323335393339663331323431363438613637636665623634313862
66336533626266343831363732613331653431306462313830343830336339663636396566343134
34396137396461376433653962613361323334393766303330633237623963653033363532613761
37316139336234366633343064313965393965353461383433366630633935336233383962616662
30356234663661343232633435346532313737376234623336366161303937623964656331633935
62333537303536333062613132646237373031663766623730666330666364313634366362363437
34643634303366633236363665643934323864303732613461326238323637393733393964663562
30386664636139313335333238373632353936646432326436666665326365646565383766316362
62376463626338643937663163336166663465353936626332303739393762313235323535366636
63666438636230383031333838613333633734623134363833383035623564613637633562323263
64356662376532333261316537656234636338393738613730656530613232373662363431393630
32323230346332613038373530643563353162366366636338623566373065373637623638333533
34633333663832393438616266653563623338626431636634303234333636613033333831316264
36333664366262343261373163373763373036653866663539343337323761633431313435323465
35386539636338613666393064303363303263323565613837623866633031303834363837626531
31363838326235643163353731333938386264323730323036333066326463326537613530373832
65303663613361336565643366316238336135396334303432393936336635306434653930643631
65393438643630313330383061643031343663393265646664356636313035313961383634316466
36633139373564623466386264303664373763386534326234323337633732633232346136366133
61363939336531633265396136623031396534363031393164646661383339653232393463636439
63306438613837313136646232333434323430643130313937376230613531383461306262366636
61633137643164653739343663393335373065386339393531343930373161376434356666306132
35383763613966353662333362396265326532303238656133316332363335346437306164653230
38333263323064653533373365336166613431303231373062376663343261663135623937373163
63613239343034303263306138376430613335643862303633343637373430336131623037373537
64323838306233396435656339306132623266363961643166303733373537333135303665336239
62623439623865363736343631613432633264363733353834386335333330313933383433346137
32386432393765356133353736306430376665626665663964313037323136326164653435383332
61346361313063333137356662383533623734633435646638363537303037393638316439316561
62376361363532623266383635323330633033333536636262386239353732633237663139393839
33633537353136376163613334393430663366636533616265633133373563653962393238373862
61376639663631323231376631353964643162616538353835363937663661313632333333373933
62323539643630303062333063386163656530623061613664376436623732386666336162396533
36343563643032356261363234663264303762663439363664353966313561623761336138313165
37633630663131313565346539396234363565646530386266666232306262663961663636326536
64653638643935636365313561313964653764363766623433353365376161623333356164346232
36646630666333383234343931343161646463633134316466616436336634336661356161303764
62646362333139373761333035386666663561643837623665656630393432303839666666303339
31646461613838353033343665396239616538356164333436353133653134313738343530313366
33646530373561633834383865393961353032656339633732646533363535343630613937323461
65363262643937373638633838343266323966623039616333656538363362333962353066323363
36326138323132333837343130363735623861616334343130363064303030306361336161303832
32343534623662393566653131363866343637373930643434326338333362646233383231636235
33636433346464363766376634636533366163333064313534313831636238376639393533616566
66333633613261336361366234643966663536653736323235663030626234386166663033376234
32623435383837336330616235313165636537623462626230346535663662386337366532363733
39306631626262623630393137653763326234366139626461643931613736653932316466396163
66643038343336326533366438356562333561616138383330643235663633336262303266613630
61346237313730303666346639366439313865663430643536336338313130656438613831383633
30613862363961373732333863636134623431626361396562393437613533383738613761363835
30363831363963623133653038383031323364346237326164313232363936656132353732373463
39383737303034346163623737353537646433366539663963313566363032343836383134636131
66363664336435376563343765623737366632383431656166303639366661633565623331613530
39396131613161663436333736666536656364663361626633333637316130646266346165323632
35623062353732616535373166336564383166663861613239373836646637616333613639613862
66396434333039393135623733653938336233613235363134636362666363303337383335303136
66313061323938643432623833363930303236633462363663323362626332363565656135363464
63376266653132333565616363383564353135656431393062393333626336663738366331383737
30626239366666366263353662333166303063386364366530653731353135313761343461646238
39353531656232616530633330393130663862623964363565326233643431343432313039333234
63336232336535373032383933393763623538353061396534373632323165666134333465303066
39333366303231363863333133643062333230316361623635656663323737643965663563376564
34343134306333386663356339616461646639303835333333623363383364666436643436343533
38373364363765343265663138616439316633623865343939353465353466613065653366336230
39636664303335316538326136356361633366373761333635613139633665616433386162623965
32636266303031336162626132363931363163313235363865353334616137643934353637643633
33626137376132353735303737343433323462343830633731396565383037313232313862396330
65383564386564353634636430393562623833373533626632333530336266633038663333663063
32313534666162666134373533313730663730333933613762303238323065363236363166326361
39633938373861366133316261303665356661393830366131343138623138616233623961386233
65356634333833313061313134333364306238623637333733666366373762383732663037643338
33333263356262633366346130386337396263646333636134333935343835386366306663663962
62306232653735656365643533653635366232326638386563393365376464653532656136656266
62393830656339393033343638643334313765383766336338363331386135373864383939643166
66316437666434643266326235663632363161663338336434313134643533383634633936336665
62653638346633336331333364623363376538373562663437373933323932663533333733633036
39386333653636646133366461633237656166396664623136633863653339633463346135393566
39316161613532386432306364333662313261346631636430363061376332633431356132663066
65643137303961383539663733653863346131396232346263393934326239383735353565376533
33356438336463323531326561326264396635623734663835363039613162613762393662363562
32646634643934393263616335633933386564373162303766376661323864306230376533633539
38363032353639633735356266616330396333373665373035643334336566636534383831323862
33623866386539376131393531653562643430663765316539613438663733366566366533393761
31303836636533656535383165323232353065663338646337323530363133393263653930666334
62323836353563366637383138383864323438353339653434306532336164306238633437646235
64366536393039323339616130663939393562363935633435373932396462323533363838356663
34343138353130383734363234366661386638653834643661353031353031343562613461393966
31386463366635353965386462653461653866393864613063636564313638623734663864333334
36386239336431316338643761363566636137333161663833623633376262623830383961613931
33666136653631663131626631643266656430356364343138353663326435656663373035333934
33636339386639666532333763393735386231376666323733333030353239373333383264323130
35303437616433393231653739653230326263616233373166316237346138336364353339653339
33353132636262323030643465313231393763613861343734363031386563646337336562336463
37303232663235313335336435333032626432633531306638313561646236616230306135313639
63633938326266656138383635653339393238303964386663366638643734633866333432373737
34636433613566366132613864643135356136336162643066633035663533666237376437333762
64376262633064333635623438353732376466366338323636643662323234326139316534646533
33666432303636373963666334376263636634323532643837316361376338616430346464656465
33373336653539303163336239613038386230363839353532336335303564306363666665333530
63393863363232653138356234323161323931353932326137653430363836323863393035356230
61633161623761303335646435383934663135633734346433643639383932643662393264626430
38303735343033303765316365353834633461386636653662393538643339346230643335656438
66386164326535646335356661383735366134613939636130383034633734313739333635376233
35623130353239323237386337623962616536643463376632613631646436353630366137343838
30376136616261303235643437613864613031346564363465646533363033303566613061363330
63306530323938383737313438333139363862366331336665623139626135353236316664643661
38343165383537643136623661663730383635376263346131303332646464376263303464373734
62366331316166633631663163306237343862306365326339653363336638633830646233363665
61393461326535653736363834346163343736666665623838643937306539376266393761633263
39316163356262633137313261353463373166633735663035663135326437383534616239343661
64633762613833653365353234363539643533353239316238636537323064353337316361353630
39343463643236656431623861333734313838346430356630656131383763386534373635666331
34333137346266353265376431623935336366623461666237666334616634613162623761346161
35366334313837333939306563316236313839623236373234623635656138656165313662656632
31343961363864346438333932656264363932646132366136663339363930386435666639663135
61313037303762626537393330366131616532646666613338393032353837643835353436383539
30323064656161636534653664336232613565626162383035636131393539386633643436653633
37343339663531396533323432643464393431623338343537646138653236366331366261663631
65386666313531323031353762363437653561653534316463666539636439383361373861646331
66626434326566653534393964643938393463303930613734336161356431363230363261383139
38356663323539636533663237306566623062333036666636663637306332626139333738386365
38363635386439646363626631366430393636613763646539303939643764643966353461343466
39363331343334653637656561383638626637646230346665393366313366633366653839663839
39333263313566626135326633653238626566633536353461376462613462303830353638393161
36366433656261313436663466366433663138613561386336383262646663313836633564353266
61623139323961346564623939303339366233643231373031626165646364376138383935626166
38373833613033633630313965336438626363353031346630376532393932353961363732623631
35613038366664663337653230653134373861666430343666623165306130306137383163396235
66633035353534386262663336646361303236623365613736383938333261653462386636376531
37616539376637386462373063316535636365626634613462623232333435313733326261373230
66373061336231663230386133613665396263646563376534313830316439623363653165613666
62373063633863656566643333636461353339396635396331633734623266306430
+12
View File
@@ -0,0 +1,12 @@
---
# Single-origin layout: one hostname, Caddy routes /health, /ws, /api/daemon/ws to the backend
multica_layout: single
multica_domain: muppet-vision.wdw-labs.cloud
# TLS: acme_http | cloudflare_dns | internal
caddy_tls_mode: cloudflare_dns
caddy_acme_email: "ryan.blundon@protonmail.com"
# Pin for reproducible deploys (git tag + matching image tag)
# multica_version: v0.4.5
# multica_image_tag: v0.4.5
@@ -0,0 +1,8 @@
---
# Copy to vault.yml and encrypt: ansible-vault encrypt group_vars/multica/vault.yml
# Generate secrets with: openssl rand -hex 32 (postgres: openssl rand -hex 16)
vault_multica_jwt_secret: "CHANGEME"
vault_multica_postgres_password: "CHANGEME"
vault_multica_vcs_secret_key: "CHANGEME"
vault_cloudflare_api_token: "CHANGEME" # only for caddy_tls_mode: cloudflare_dns
# vault_multica_resend_api_key: "" # optional: email verification codes
+7
View File
@@ -85,6 +85,13 @@ paperclip_server:
ansible_user: wed
ansible_become: true
multica:
hosts:
muppet-vision:
ansible_host: 10.1.71.136
ansible_user: wed
ansible_become: true
jmri_server:
hosts:
main-street-station:
+6
View File
@@ -0,0 +1,6 @@
---
- name: Deploy Multica + Caddy
hosts: multica
become: true
roles:
- role: multica_docker
+61
View File
@@ -0,0 +1,61 @@
# multica_docker
Deploys [Multica](https://multica.ai/docs/self-host-quickstart) (Docker Compose self-host) and a containerised
[Caddy](https://caddyserver.com) reverse proxy with automatic HTTPS.
Mirrors the official quickstart: clones the repo, creates `.env` from `.env.example` (first run only, like
`make selfhost`), manages specific keys in it, runs `docker-compose.selfhost.yml`, waits for `/readyz`, then
fronts it with Caddy using the documented single-origin or two-domain config.
## Requirements
- Docker Engine + Compose v2 on the target (the role checks; it does not install Docker)
- `ansible-galaxy collection install -r requirements.yml` (needs `community.docker >= 3.10`)
- DNS for your hostname(s) pointing at the target
## Quick start
```bash
cp group_vars/multica/vault.yml.example group_vars/multica/vault.yml # fill in, then:
ansible-vault encrypt group_vars/multica/vault.yml
# edit inventory.ini + group_vars/multica/vars.yml
ansible-playbook deploy_multica.yml --ask-vault-pass
```
## Key variables
| Variable | Default | Notes |
|---|---|---|
| `multica_layout` | `single` | `single` = one hostname; `split` = app + api hostnames |
| `multica_domain` | – | Hostname for `single` |
| `multica_app_domain` / `multica_api_domain` | – | Hostnames for `split` |
| `caddy_tls_mode` | `acme_http` | `acme_http`, `cloudflare_dns` (builds custom Caddy image), `internal` (Caddy CA, LAN only) |
| `caddy_http_port` / `caddy_https_port` | `80` / `443` | Change if the host already runs nginx etc. |
| `multica_version` / `multica_image_tag` | `main` / `latest` | Pin both to the same release tag for reproducible deploys |
| `multica_pull_latest` | `false` | `true` pulls images every run (needed to move `latest`) |
| `multica_backup_enabled` | `false` | `pg_dump` before changes to a running stack |
| `multica_disable_telemetry` | `false` | Sets `DO_NOT_TRACK=true` |
| `multica_extra_env` | `{}` | Extra `.env` keys, e.g. `ALLOW_SIGNUP: "false"` |
Secrets (vault): `vault_multica_jwt_secret`, `vault_multica_postgres_password`, `vault_multica_vcs_secret_key`,
`vault_cloudflare_api_token` (cloudflare_dns only), optional `vault_multica_resend_api_key`.
## Behaviour worth knowing
- **Idempotent**: `.env` is created once; only managed keys are rewritten. Compose recreates containers only when config changes.
- **Ports 3000/8080 stay bound to 127.0.0.1** (as upstream intends). Caddy uses `network_mode: host` to reach them.
- **Existing install?** The role overwrites `JWT_SECRET` (logs everyone out) and `POSTGRES_PASSWORD`. Postgres only reads its
password at first init, so for an existing database copy the *current* values from `.env` into the vault.
- **Upgrades**: `-e multica_backup_enabled=true -e multica_pull_latest=true` (or bump `multica_version`/`multica_image_tag`).
Migrations run automatically on backend start; the role waits for `/readyz`.
- **Tags**: `multica_preflight`, `multica`, `caddy`, `multica_verify`.
## After the play: connect a computer
The daemon runs on the machine with your AI coding tools, not the server:
```bash
multica setup self-host --server-url https://<domain> --app-url https://<domain> # single layout
```
First login code (no email configured): `docker compose -f docker-compose.selfhost.yml logs backend | grep "Verification code"`
@@ -0,0 +1,65 @@
---
# ───────────────────────── Multica ─────────────────────────
multica_install_dir: /opt/multica
multica_repo_url: https://github.com/multica-ai/multica.git
# Git ref for the compose file / .env.example. Pin to a release tag for reproducible deploys.
multica_version: main
multica_git_update: true
# Image tag for backend + frontend (written to MULTICA_IMAGE_TAG). Keep in step with multica_version when pinning.
multica_image_tag: latest
multica_compose_project: multica
# true = `docker compose pull` every run (needed to move a floating `latest` tag forward)
multica_pull_latest: false
# Sets DO_NOT_TRACK=true on the backend (disables first-party anonymous telemetry)
multica_disable_telemetry: false
# Secrets (supply via ansible-vault). Role fails fast if empty.
multica_jwt_secret: "{{ vault_multica_jwt_secret | default('') }}"
multica_postgres_password: "{{ vault_multica_postgres_password | default('') }}"
multica_vcs_secret_key: "{{ vault_multica_vcs_secret_key | default('') }}"
# Optional: enables emailed verification codes instead of reading them from backend logs
multica_resend_api_key: "{{ vault_multica_resend_api_key | default('') }}"
# Any extra .env keys, e.g. { ALLOW_SIGNUP: "false", GOOGLE_CLIENT_ID: "..." }
multica_extra_env: {}
# Postgres identity used by the optional backup task (match your .env if changed)
multica_postgres_user: multica
multica_postgres_db: multica
# Take a pg_dump before changing a running stack. Recommended before upgrades: -e multica_backup_enabled=true
multica_backup_enabled: false
multica_backup_dir: /var/backups/multica
# Hide secret-bearing task output
multica_no_log: true
# ───────────────────────── Routing ─────────────────────────
# single: one hostname (multica_domain)
# split : two hostnames (multica_app_domain + multica_api_domain)
multica_layout: single
multica_domain: ""
multica_app_domain: ""
multica_api_domain: ""
# ───────────────────────── Caddy ─────────────────────────
caddy_install_dir: /opt/caddy
caddy_container_name: caddy
# Caddy image tag. "2" tracks latest 2.x
caddy_version: "2"
# acme_http – automatic HTTPS via HTTP-01/TLS-ALPN-01 (needs public 80/443)
# cloudflare_dns – DNS-01 via Cloudflare; builds a custom Caddy image with the caddy-dns/cloudflare module
# internal – Caddy's internal CA (LAN only; clients must trust Caddy's root cert)
caddy_tls_mode: acme_http
caddy_acme_email: ""
caddy_cloudflare_api_token: "{{ vault_cloudflare_api_token | default('') }}"
# Rebuild the custom image on every run (cloudflare_dns only)
caddy_force_rebuild: false
# Caddy runs with host networking so it can reach Multica's 127.0.0.1-bound ports.
# Change these if 80/443 are already taken on the host (e.g. nginx).
caddy_http_port: 80
caddy_https_port: 443
# Post-deploy checks through the public hostname(s). Needs DNS to resolve from the target host.
multica_verify_public: true
@@ -0,0 +1,13 @@
---
- name: Validate Caddyfile
community.docker.docker_container_exec:
container: "{{ caddy_container_name }}"
command: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
changed_when: false
listen: Reload caddy
- name: Reload Caddy
community.docker.docker_container_exec:
container: "{{ caddy_container_name }}"
command: caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
listen: Reload caddy
@@ -0,0 +1,14 @@
---
galaxy_info:
role_name: multica_docker
author: Ryan
company: WDW Labs
license: MIT
description: Deploy Multica (Docker Compose self-host) behind a containerised Caddy reverse proxy
min_ansible_version: "2.15"
platforms:
- name: Ubuntu
versions: [jammy, noble]
- name: Debian
versions: [bookworm]
dependencies: []
@@ -0,0 +1,51 @@
---
- name: Create Caddy directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ caddy_install_dir }}"
- "{{ caddy_install_dir }}/conf"
# Mount the conf *directory* (not the file): atomic template writes swap the inode,
# which a single-file bind mount would not see.
- name: Render Caddyfile
ansible.builtin.template:
src: Caddyfile.j2
dest: "{{ caddy_install_dir }}/conf/Caddyfile"
mode: "0644"
notify: Reload caddy
- name: Render Dockerfile for Cloudflare DNS module
ansible.builtin.template:
src: Dockerfile.caddy.j2
dest: "{{ caddy_install_dir }}/Dockerfile"
mode: "0644"
when: caddy_tls_mode == 'cloudflare_dns'
- name: Render Caddy environment file (Cloudflare token)
ansible.builtin.template:
src: caddy.env.j2
dest: "{{ caddy_install_dir }}/caddy.env"
mode: "0600"
when: caddy_tls_mode == 'cloudflare_dns'
no_log: "{{ multica_no_log }}"
- name: Render Caddy compose file
ansible.builtin.template:
src: docker-compose.caddy.yml.j2
dest: "{{ caddy_install_dir }}/docker-compose.yml"
mode: "0644"
- name: Start / update Caddy
community.docker.docker_compose_v2:
project_name: caddy
project_src: "{{ caddy_install_dir }}"
state: present
build: "{{ 'always' if caddy_force_rebuild | bool else 'policy' }}"
recreate: auto
register: _caddy_up
- name: Apply pending Caddy reloads now
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,16 @@
---
- name: Pre-flight checks
ansible.builtin.import_tasks: preflight.yml
tags: [multica_preflight]
- name: Deploy Multica
ansible.builtin.import_tasks: multica.yml
tags: [multica]
- name: Deploy Caddy
ansible.builtin.import_tasks: caddy.yml
tags: [caddy]
- name: Verify deployment
ansible.builtin.import_tasks: verify.yml
tags: [multica_verify]
@@ -0,0 +1,88 @@
---
- name: Create Multica install directory
ansible.builtin.file:
path: "{{ multica_install_dir }}"
state: directory
mode: "0755"
- name: Clone / update Multica repository
ansible.builtin.git:
repo: "{{ multica_repo_url }}"
dest: "{{ multica_install_dir }}"
version: "{{ multica_version }}"
update: "{{ multica_git_update }}"
force: false
register: _multica_git
# Optional: dump Postgres before touching a stack that is already running
- name: Back up Postgres before changes
when:
- multica_backup_enabled | bool
- _multica_compose_stat.stat.exists
- (_multica_ps.stdout | default('') | length) > 0
block:
- name: Create backup directory
ansible.builtin.file:
path: "{{ multica_backup_dir }}"
state: directory
mode: "0700"
- name: Gather date facts
ansible.builtin.setup:
gather_subset: [date_time]
- name: Dump database (redirect to file first so pg_dump's exit status counts)
ansible.builtin.shell: >-
docker compose -p {{ multica_compose_project }} -f {{ _multica_compose_file }}
exec -T postgres pg_dump -U {{ multica_postgres_user }} {{ multica_postgres_db }}
> {{ multica_backup_dir }}/multica-{{ ansible_date_time.iso8601_basic_short }}.sql
&& gzip {{ multica_backup_dir }}/multica-{{ ansible_date_time.iso8601_basic_short }}.sql
args:
chdir: "{{ multica_install_dir }}"
changed_when: true
# `make selfhost` only creates .env when missing; we do the same, then manage specific keys.
- name: Create .env from .env.example (first run only)
ansible.builtin.copy:
src: "{{ multica_install_dir }}/.env.example"
dest: "{{ multica_install_dir }}/.env"
remote_src: true
force: false
mode: "0600"
- name: Set managed keys in .env
ansible.builtin.lineinfile:
path: "{{ multica_install_dir }}/.env"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
mode: "0600"
loop: "{{ _multica_env | dict2items }}"
loop_control:
label: "{{ item.key }}"
no_log: "{{ multica_no_log }}"
# Compose re-reads .env and recreates containers whose config changed (equivalent of `up -d`).
- name: Start / update Multica stack
community.docker.docker_compose_v2:
project_name: "{{ multica_compose_project }}"
project_src: "{{ multica_install_dir }}"
files:
- "{{ _multica_compose_file }}"
state: present
pull: "{{ 'always' if multica_pull_latest | bool else 'missing' }}"
recreate: auto
register: _multica_up
- name: Wait for backend /readyz (db + migrations ok)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ _multica_backend_port }}/readyz"
return_content: true
status_code: 200
register: _multica_ready
until: >-
_multica_ready.status == 200 and
(_multica_ready.json.checks.db | default('')) == 'ok' and
(_multica_ready.json.checks.migrations | default('')) == 'ok'
retries: 30
delay: 5
changed_when: false
@@ -0,0 +1,88 @@
---
- name: Assert layout and hostnames
ansible.builtin.assert:
that:
- multica_layout in ['single', 'split']
- caddy_tls_mode in ['acme_http', 'cloudflare_dns', 'internal']
- multica_layout == 'split' or (multica_domain | length) > 0
- multica_layout == 'single' or ((multica_app_domain | length) > 0 and (multica_api_domain | length) > 0)
fail_msg: >-
Set multica_domain (layout=single) or multica_app_domain + multica_api_domain (layout=split),
and use a valid caddy_tls_mode.
- name: Assert required secrets are provided
ansible.builtin.assert:
that:
- (multica_jwt_secret | length) >= 32
- (multica_postgres_password | length) >= 16
- (multica_vcs_secret_key | length) >= 16
- caddy_tls_mode != 'cloudflare_dns' or (caddy_cloudflare_api_token | length) > 0
fail_msg: >-
Missing secrets. Define vault_multica_jwt_secret (>=32 chars), vault_multica_postgres_password (>=16),
vault_multica_vcs_secret_key (>=16) and, for cloudflare_dns, vault_cloudflare_api_token.
Generate with: openssl rand -hex 32
quiet: true
- name: Check Docker Compose v2 is available
ansible.builtin.command: docker compose version
register: _multica_compose_ver
changed_when: false
- name: Ensure git is installed
ansible.builtin.package:
name: git
state: present
# ── Port checks (skipped when our own stack already holds the ports) ──
- name: Look for an existing Multica deployment
ansible.builtin.stat:
path: "{{ multica_install_dir }}/{{ _multica_compose_file }}"
register: _multica_compose_stat
- name: List running Multica containers
ansible.builtin.command:
cmd: "docker compose -p {{ multica_compose_project }} -f {{ _multica_compose_file }} ps -q"
chdir: "{{ multica_install_dir }}"
register: _multica_ps
changed_when: false
when: _multica_compose_stat.stat.exists
- name: Check for a running Caddy container
ansible.builtin.command: "docker ps -q --filter name=^{{ caddy_container_name }}$"
register: _caddy_ps
changed_when: false
- name: Ensure Multica ports are free on first deploy
when: not (_multica_compose_stat.stat.exists and ((_multica_ps.stdout | default('')) | length) > 0)
block:
- name: Wait for 3000/8080 to be unused
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop:
- "{{ _multica_frontend_port }}"
- "{{ _multica_backend_port }}"
rescue:
- name: Fail with hint
ansible.builtin.fail:
msg: "Port 3000 or 8080 is already in use on the target host and Multica is not running. Free it before deploying."
- name: Ensure Caddy ports are free on first deploy
when: (_caddy_ps.stdout | length) == 0
block:
- name: Wait for Caddy HTTP/HTTPS ports to be unused
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop: "{{ [caddy_http_port, caddy_https_port] | map('int') | list }}"
rescue:
- name: Fail with hint
ansible.builtin.fail:
msg: >-
Port {{ caddy_http_port }} or {{ caddy_https_port }} is already in use on the target host
(another reverse proxy such as nginx?). Set caddy_http_port / caddy_https_port to free ports,
or deploy Caddy on a different host.
@@ -0,0 +1,32 @@
---
- name: Verify through the public hostname(s)
when: multica_verify_public | bool
block:
- name: Check health endpoint via Caddy
ansible.builtin.uri:
url: "{{ _multica_public_health_url }}"
status_code: 200
validate_certs: "{{ caddy_tls_mode != 'internal' }}"
register: _multica_pub_health
until: _multica_pub_health.status == 200
retries: 18
delay: 10
changed_when: false
- name: Fetch /api/config via the app hostname
ansible.builtin.uri:
url: "{{ _multica_app_url }}/api/config"
return_content: true
validate_certs: "{{ caddy_tls_mode != 'internal' }}"
register: _multica_cfg
changed_when: false
- name: Assert daemons will be told the public URL, not localhost
ansible.builtin.assert:
that:
- _multica_cfg.json.daemon_server_url is defined
- "'localhost' not in _multica_cfg.json.daemon_server_url"
fail_msg: >-
daemon_server_url is missing or points at localhost. Check FRONTEND_ORIGIN / MULTICA_APP_URL in
{{ multica_install_dir }}/.env and recreate the stack.
success_msg: "daemon_server_url = {{ _multica_cfg.json.daemon_server_url }}"
@@ -0,0 +1,73 @@
# Managed by Ansible (multica_docker) - local changes will be overwritten
{
{% if caddy_acme_email | length > 0 %}
email {{ caddy_acme_email }}
{% endif %}
{% if caddy_http_port | int != 80 %}
http_port {{ caddy_http_port }}
{% endif %}
{% if caddy_https_port | int != 443 %}
https_port {{ caddy_https_port }}
{% endif %}
{% if caddy_tls_mode == 'cloudflare_dns' %}
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
{% endif %}
}
{% if multica_layout == 'single' %}
{{ multica_domain }} {
{% if caddy_tls_mode == 'internal' %}
tls internal
{% endif %}
# CLI reachability probe: `multica setup` GETs <server-url>/health and needs a 200
handle /health {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }}
}
# Browser realtime WebSocket (the web image cannot proxy WS upgrades)
handle /ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Daemon long-connection WebSocket: daemons dial {server-url}/api/daemon/ws
handle /api/daemon/ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Everything else goes to the frontend, which forwards API and login requests
handle {
reverse_proxy 127.0.0.1:{{ _multica_frontend_port }}
}
}
{% else %}
{{ multica_app_domain }} {
{% if caddy_tls_mode == 'internal' %}
tls internal
{% endif %}
# Hand browser WebSockets straight to the backend
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Everything else goes to the frontend, which forwards API and login requests
handle {
reverse_proxy 127.0.0.1:{{ _multica_frontend_port }}
}
}
{{ multica_api_domain }} {
{% if caddy_tls_mode == 'internal' %}
tls internal
{% endif %}
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
{% endif %}
@@ -0,0 +1,6 @@
# Managed by Ansible (multica_docker)
FROM caddy:{{ caddy_version }}-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:{{ caddy_version }}
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
@@ -0,0 +1 @@
CLOUDFLARE_API_TOKEN={{ caddy_cloudflare_api_token }}
@@ -0,0 +1,26 @@
# Managed by Ansible (multica_docker)
name: caddy
services:
caddy:
container_name: {{ caddy_container_name }}
{% if caddy_tls_mode == 'cloudflare_dns' %}
build:
context: .
dockerfile: Dockerfile
image: local/caddy-cloudflare:{{ caddy_version }}
env_file:
- caddy.env
{% else %}
image: caddy:{{ caddy_version }}
{% endif %}
# Host networking: Multica's ports are bound to 127.0.0.1 only
network_mode: host
restart: unless-stopped
volumes:
- {{ caddy_install_dir }}/conf:/etc/caddy:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
@@ -0,0 +1,32 @@
---
# Fixed by Multica's docker-compose.selfhost.yml (bound to 127.0.0.1)
_multica_frontend_port: 3000
_multica_backend_port: 8080
_multica_compose_file: docker-compose.selfhost.yml
_multica_app_host: "{{ multica_domain if multica_layout == 'single' else multica_app_domain }}"
_multica_api_host: "{{ multica_domain if multica_layout == 'single' else multica_api_domain }}"
_multica_port_suffix: "{{ '' if caddy_https_port | int == 443 else ':' ~ caddy_https_port }}"
_multica_app_url: "https://{{ _multica_app_host }}{{ _multica_port_suffix }}"
_multica_api_url: "https://{{ _multica_api_host }}{{ _multica_port_suffix }}"
_multica_env_core:
JWT_SECRET: "{{ multica_jwt_secret }}"
POSTGRES_PASSWORD: "{{ multica_postgres_password }}"
MULTICA_VCS_SECRET_KEY: "{{ multica_vcs_secret_key }}"
MULTICA_IMAGE_TAG: "{{ multica_image_tag }}"
FRONTEND_ORIGIN: "{{ _multica_app_url }}"
MULTICA_APP_URL: "{{ _multica_app_url }}"
# Split layout only: daemons/CLI talk to the API hostname
_multica_env_split: "{{ {'MULTICA_PUBLIC_URL': _multica_api_url} if multica_layout == 'split' else {} }}"
_multica_env_telemetry: "{{ {'DO_NOT_TRACK': 'true'} if multica_disable_telemetry | bool else {} }}"
_multica_env_resend: "{{ {'RESEND_API_KEY': multica_resend_api_key} if (multica_resend_api_key | length) > 0 else {} }}"
_multica_env: >-
{{ _multica_env_core
| combine(_multica_env_split, _multica_env_telemetry, _multica_env_resend, multica_extra_env) }}
# Health URL used by post-deploy verification
_multica_public_health_url: >-
{{ _multica_app_url ~ '/health' if multica_layout == 'single' else _multica_api_url ~ '/readyz' }}
+223
View File
@@ -0,0 +1,223 @@
# wiki-llm intake pipeline (n8n + Hermes Kanban)
Detects new raw files in the Obsidian vault, queues one Hermes Kanban card per file, tracks every file in an n8n Data Table, and alerts on failure. The Hermes librarian does the actual cataloging and writes wiki pages directly into the vault per the llm-wiki skill.
> **Status:** design complete, partly implemented. `kanban-gateway.sh` (tested against a stub `hermes`) and `wiki-llm-watcher.py` (tested against a mock webhook) are written. The n8n workflows are still to build. See [Open items](#open-items).
## Architecture
```mermaid
flowchart LR
subgraph MAC["Mac (vault + Hermes)"]
V["Obsidian vault<br/>raw/"] --> W["Watcher<br/>(launchd, polls 15-30s)"]
GW["kanban-gateway<br/>(SSH forced command)"] --> K["hermes kanban<br/>~/.hermes/kanban.db"]
K --> D["Dispatcher<br/>(hermes gateway)"] --> L["Librarian profile<br/>(llm-wiki skill)"]
L --> V
end
subgraph N8N["n8n"]
IN["Intake workflow"]
ST["Status workflow<br/>(Schedule Trigger)"]
DT[("Data Table<br/>processed-files log")]
NT["Slack / email<br/>failure alert"]
end
W -- "POST one file per call" --> IN
IN <--> DT
IN -- "SSH: create" --> GW
ST -- "SSH: list / show" --> GW
ST <--> DT
ST --> NT
```
| Piece | Where | Job |
|---|---|---|
| Watcher | Mac, launchd | Finds new paths in `raw/`, waits until each file is stable, POSTs one webhook call per file |
| Intake workflow | n8n | Validates, dedupes against the Data Table, creates the Kanban card over SSH |
| `kanban-gateway.sh` | Mac, SSH forced command | The only thing n8n's key can run: `create`, `list`, `show` |
| Hermes Kanban + dispatcher | Mac | Durable queue, claiming, retries, stale-task reclaim, circuit breaker |
| Librarian profile | Mac | Does the work in the vault workspace, writes wiki pages per the llm-wiki skill |
| Status workflow | n8n | Polls the board, updates the Data Table, sends failure notifications |
| Data Table | n8n | Authoritative processed-files log |
## Key decisions
- **Watcher on the vault host, not a network mount.** Filesystem events and Local File Trigger don't work reliably over NFS/SMB, and failures there are silent.
- **Polling loop is the source of truth.** FSEvents coalesces and reorders events, and clippers and sync tools are noisy, so the watcher lists `raw/` every 15 to 30 seconds and compares against its seen-state. Events may wake it early but never decide anything.
- **Files stay in `raw/`.** "Processed" is tracked in the Data Table, not by moving files.
- **Dedupe key is the relative path, first-seen wins.** The librarian edits frontmatter after cataloging, so the content hash changes. The hash is recorded as metadata at first-seen only. A second check rejects a first-seen hash that already exists under a different path (`status=duplicate`).
- **Kanban is the queue.** The board already provides claiming, retries, stale reclaim, and a circuit breaker, so n8n has no Worker workflow.
- **SSH instead of the dashboard REST API.** The dashboard plugin routes under `/api/plugins/kanban/` are unauthenticated by design. This pipeline does not use or expose them.
- **Instruction files can live in `raw/`.** `AGENTS.md`, `CLAUDE.md`, `.hermes.md`, `README.md`, and `SCHEMA.md` are ignored by name at any depth (`WIKI_IGNORE_NAMES`), and the Intake `Validate` node should reject the same names. Hermes loads `AGENTS.md` into the librarian's context; see "Processing rules (AGENTS.md)" below.
- **Output stays outside `raw/`.** The llm-wiki skill's folder layout must keep wiki output out of `raw/`, so Hermes can't feed itself.
## Mac setup
### 1. Install the gateway script
1. Copy `kanban-gateway.sh` to `~/bin/kanban-gateway` and `chmod 755` it.
2. Edit the top block: `HERMES_BIN` (absolute path), `VAULT_ROOT`, `LIBRARIAN_PROFILE`, `SKILL_NAME`. The profile and skill names in the file are placeholders.
3. Generate a dedicated key for n8n: `ssh-keygen -t ed25519 -f n8n_kanban -C n8n-wiki-llm`.
4. Add one line to `~/.ssh/authorized_keys` on the Mac, using the absolute script path (optionally add `from="<n8n IP>"` before `restrict`):
```
restrict,command="/Users/<you>/bin/kanban-gateway" ssh-ed25519 AAAA... n8n-wiki-llm
```
5. Enable Remote Login for your user (System Settings, General, Sharing). On an MDM-managed Mac this may need IT approval.
6. Smoke test from the n8n host: `ssh -i n8n_kanban <user>@<mac> list`. A bare `list` should return JSON.
The SSH user must be the user that owns the Hermes profiles and `~/.hermes/kanban.db`. Kanban is single-host and per-user.
### 2. Gateway script protocol
| Command sent over SSH | Effect |
|---|---|
| `create <base64 relative path>` | Creates a card whose body lists every `AGENTS.md` from the vault root down to the file's folder (shallow to deep), so folder rules are read deterministically. Assignee = librarian profile, `--workspace dir:<vault>`, `--idempotency-key <relative path>`, `--skill <skill>`, `--json` |
| `list [status]` | `hermes kanban list --json`, optionally filtered. Status is one of `triage todo ready running blocked review done archived` |
| `show <task id>` | `hermes kanban show <id> --json`. The id must match `t_[A-Za-z0-9]+` |
The path travels base64-encoded and is never passed through a shell or `eval`, so filenames containing spaces, quotes, or `$()` are inert. The script also rejects control characters, absolute paths, `..` segments, anything outside `raw/`, and symlinks that resolve out of `raw/`.
Exit codes: `0` ok, `2` rejected input, `3` file not found.
### 3. Run the dispatcher
`hermes gateway start` must be running on the Mac. Without it, cards stay `ready`. The default dispatch tick is 60 seconds. Cap concurrency with `kanban.max_in_progress` in `~/.hermes/config.yaml` so a burst drains at a pace your local models can handle.
### 4. Install the watcher
1. Copy `wiki-llm-watcher.py` to `~/bin/` (`chmod 755`). It uses only the Python 3 standard library.
2. Copy `wiki-llm-watcher.env.example` to `~/.config/wiki-llm-watcher/env`, edit it, and `chmod 600` it. It holds the webhook URL (the Webhook node's **production** URL) and secret.
3. **Baseline first:** `python3 ~/bin/wiki-llm-watcher.py --baseline` marks everything already in `raw/` as seen, so the first run does not post your whole backlog. Skip this only if you want the backlog processed.
4. Dry run: `python3 ~/bin/wiki-llm-watcher.py --dry-run --once` prints the payloads it would send and posts nothing.
5. Install `com.wiki-llm.watcher.plist` into `~/Library/LaunchAgents/` (replace `YOURNAME`), then `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wiki-llm.watcher.plist`. Logs go to `~/Library/Logs/wiki-llm-watcher.log`.
6. To reprocess one file on purpose: `python3 ~/bin/wiki-llm-watcher.py --forget raw/path/to/file.md`.
macOS privacy controls (TCC) can block a LaunchAgent from reading `~/Documents`, `~/Desktop`, or iCloud Drive. If the log shows permission errors, grant the Python binary access under Privacy & Security, or keep the vault outside those folders.
Webhook response handling in the watcher: any 2xx marks the file posted (including n8n's `duplicate` response). `400/409/413/422` marks it `rejected` locally and stops retrying. `401/403/404` and an unreachable n8n pause posting for 60 seconds without marking anything. Other errors retry per file with exponential backoff.
## n8n workflows
### Intake (Webhook trigger)
1. **Webhook node**, header-auth credential. Body: `{path, sha256, size, mtime, source}`.
2. **Code node:** validate `path` against an allowlist regex (for example `^raw/[\w .,\-/]+\.md$`, widened to the extensions you accept). Reject mismatches as `status=rejected`. Compute `rel_b64 = Buffer.from(path, 'utf8').toString('base64')` and `file_key = path`.
3. **Data Table lookup** by `file_key`. If a row exists, respond `duplicate` and stop. Otherwise insert with `status=queued`. Respond to the webhook after this insert, so a 2xx means "durably queued".
4. **SSH node** (Execute Command, Private Key credential): `create {{ $json.rel_b64 }}`. Retry On Fail: 3 tries with a few seconds' backoff. On Error: Continue (using error output).
5. **If node:** check `code` is `0` explicitly. Don't rely on the node throwing. Exit `3` means the file is gone, so set `rejected`. Anything else, set `create_failed`, record `last_error`, and notify.
6. **Code node:** parse `stdout` as JSON and extract the task id. Verify the key name against a real `hermes kanban create --json` run.
7. **Data Table update:** `kanban_task_id`, `status=created`, `created_at`.
An SSH timeout can happen after the card was created. The kanban idempotency key means a retry returns the existing card, not a duplicate.
### Status (Schedule Trigger, every 1 to 2 minutes)
1. **SSH node:** `list blocked` and `list done` (or plain `list`).
2. **Code node:** match cards to Data Table rows by `kanban_task_id`.
3. **Data Table update:** `running`, `done` (set `processed_at`), `blocked`.
4. **If node:** for `blocked` where `notified_at` is empty, run `show <id>` to get the recorded reason, send the **Slack** or **Gmail** node, then set `notified_at`. Notify on transitions only.
### Retry sweep (Schedule Trigger, optional)
Re-attempts rows stuck in `create_failed` with a capped `attempts` count.
### Error Workflow
Set an **Error Trigger** workflow on both workflows above as a safety net for crashes outside the handled branches.
## Data Table: `wiki_llm_files`
| Column | Notes |
|---|---|
| `file_key` | Relative path. Unique lookup key |
| `sha256_first_seen` | Recorded at first-seen only |
| `size`, `mtime`, `source` | Source: `watcher` or `obsidian-plugin` |
| `status` | `queued`, `created`, `running`, `done`, `blocked`, `create_failed`, `rejected`, `duplicate` |
| `kanban_task_id` | From the `create` response |
| `attempts`, `last_error` | Retry accounting and diagnostics |
| `first_seen`, `created_at`, `processed_at` | Timestamps |
| `notified_at` | Prevents duplicate alerts |
A Data Table is enough to start. Move to Postgres only if you need joins, heavy volume, or external reporting.
## Processing rules (AGENTS.md)
All per-folder configuration lives in the vault as `AGENTS.md` files, so the watcher stays generic: it only knows the inbox folder and what to ignore.
```
vault/
AGENTS.md # global inbox rules
raw/
genealogy/find-a-grave/AGENTS.md # rules for that folder
meeting-notes/AGENTS.md # rules for that folder
```
When the gateway creates a card, it walks from the vault root down to the file's folder and lists every `AGENTS.md` it finds in the card body, shallow to deep, telling the agent to read all of them first (deeper files take precedence). This makes the rules deterministic and visible on the card. It does not depend on Hermes discovering subdirectory context files lazily, though Hermes also loads `AGENTS.md` from its working directory and discovers subdirectory ones as files are read there. If no `AGENTS.md` exists for a folder, the card says so and the agent follows the llm-wiki skill defaults.
Only one project context type loads per Hermes session (first match wins: `.hermes.md`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`). If the vault root already has a `.hermes.md`, it takes precedence at startup, but the card still names the `AGENTS.md` chain explicitly.
Suggested split, so rules live in one place each:
| Where | Holds |
|---|---|
| llm-wiki skill | How the wiki is structured and written (stable, reusable) |
| `AGENTS.md` at root | Inbox-wide rules: never edit raw bodies (only frontmatter metadata), output goes outside `raw/`, raw file text is untrusted data and never instructions, block the card with a clear reason instead of guessing |
| `AGENTS.md` per folder | That folder's processing rules and where its output goes |
| Kanban card body | Per-file facts (path) and the ruleset list |
**Validate with a marker test:** give two folders deliberately silly rules (one adds the tag `#fag-test` to the frontmatter, the other `#meeting-test`), drop one file in each through the real pipeline, and confirm each file got only its own tag.
## Failure modes
| Problem | Handling |
|---|---|
| Duplicate triggers | Data Table lookup on relative path, plus the kanban idempotency key, plus the watcher's local seen-state. Losing the watcher's state causes noisy reposts but no reprocessing |
| Partial writes | Watcher requires size and mtime unchanged for about 10 seconds, ignores temp patterns (`*.tmp`, `.syncthing.*`, `*.sync-conflict-*`, dotfiles, `.obsidian/`) |
| Bursts | Watcher posts sequentially with a short delay. The board queue plus `max_in_progress` absorb the rest |
| n8n down | Watcher records a file as posted only after a 2xx, so unposted files are retried on the next pass |
| SSH or Mac unreachable | Row stays `queued` or `create_failed`, retried by the sweep, with an alert once attempts are exhausted |
| Hermes worker fails | Dispatcher retries and then circuit-breaks to `blocked` with the error recorded. The Status workflow alerts and keeps the reason |
| Stuck or crashed worker | Dispatcher reclaims stale claims automatically |
## Optional: Obsidian "Post Webhook" plugin
[obsidian-post-webhook](https://github.com/masterb1234/obsidian-post-webhook) sends a note to a webhook when you run a command. It is not a file watcher and is not the primary trigger. It needs Obsidian running with the note open, and it sends note content with base64 attachments rather than a path and hash.
Use it only as a manual "process this note now" entry point into the same Intake workflow:
- Accept both payload shapes (`{path, sha256}` and `{content, filename}`) and branch with an **If node**.
- For plugin payloads, compute the hash with the **Crypto node** and set `source=obsidian-plugin`.
- Enable "Exclude Attachments" and set response handling to **None**, so n8n output never writes back into the vault.
## Security notes
- The n8n SSH key can run only the gateway script, via `restrict,command=...`.
- Do not run `hermes dashboard --host 0.0.0.0`. The kanban REST routes under `/api/plugins/` are unauthenticated.
- Keep the watcher webhook behind header auth, and restrict it by network if possible.
## Verify on first real run
1. `hermes kanban create --json` output shape (the task id key name).
2. Idempotency: run the same `create` twice and confirm the second returns the existing card.
3. `--workspace dir:<vault>` resolves correctly in `hermes kanban show <id>`.
4. `--skill` matches a skill installed on the librarian profile (`hermes skills list`).
5. `hermes gateway start` is running and the dispatcher picks the card up.
6. A deliberately failing card reaches `blocked` and triggers exactly one alert.
## Open items
- [x] Mac watcher script and launchd plist (poll loop, stability check, seen-state, one POST per file)
- [ ] Build Intake, Status, and Retry sweep workflows, and create the Data Table
- [ ] Choose the notification channel (Slack or email)
- [ ] Confirm expected file volume (sets watcher delay and `max_in_progress`)
- [ ] Decide whether to add a Hermes plugin hook (`kanban_task_blocked`) that POSTs to an n8n webhook for lower-latency alerts. Polling stays as the backstop either way
## Files
| File | Purpose |
|---|---|
| `kanban-gateway.sh` | SSH forced-command wrapper (tested with a stub `hermes`) |
| `wiki-llm-watcher.py` | Mac watcher (tested with a mock webhook) |
| `wiki-llm-watcher.env.example` | Watcher configuration template |
| `com.wiki-llm.watcher.plist` | launchd LaunchAgent for the watcher |
| `README.md` | This document |
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.wiki-llm.watcher</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/rblundon/bin/wiki-llm-watcher.py</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>WIKI_ENV_FILE</key>
<string>/Users/rblundon/.config/wiki-llm-watcher/env</string>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>/Users/rblundon/Library/Logs/wiki-llm-watcher.log</string>
<key>StandardErrorPath</key>
<string>/Users/rblundon/Library/Logs/wiki-llm-watcher.log</string>
</dict>
</plist>
@@ -0,0 +1,128 @@
#!/bin/bash
# kanban-gateway: SSH forced-command wrapper for n8n -> Hermes kanban.
#
# Install on the Mac as the user that owns ~/.hermes, e.g. ~/bin/kanban-gateway (chmod 755).
# Wire it up in ~/.ssh/authorized_keys with a forced command (see the line in the chat).
#
# Verbs n8n may call (sent as the SSH command string):
# create <base64 of path relative to the vault, e.g. raw/clippings/foo.md>
# list [status] status in: triage todo ready running blocked review done archived
# show <task id> id like t_ab12cd34
#
# Nothing from the SSH command is ever passed through a shell or eval; the path
# travels base64-encoded so spaces/quotes/$()/newlines in filenames are inert.
set -euo pipefail
# ---- EDIT THESE ------------------------------------------------------------
HERMES_BIN="${HERMES_BIN:-$HOME/.local/bin/hermes}" # absolute path; non-interactive SSH has a minimal PATH
VAULT_ROOT="${VAULT_ROOT:-$HOME/Documents/wiki-llm}" # absolute path to the vault root
RAW_DIR="raw" # inbox folder inside the vault
LIBRARIAN_PROFILE="${LIBRARIAN_PROFILE:-librarian}" # assignee profile name
SKILL_NAME="${SKILL_NAME:-llm-wiki}" # skill installed on that profile ('' to skip pinning)
# ----------------------------------------------------------------------------
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin"
fail() { echo "kanban-gateway: $1" >&2; exit "${2:-2}"; }
cmd_line="${SSH_ORIGINAL_COMMAND:-}"
[[ "$cmd_line" != *$'\n'* ]] || fail "newline in command"
# n8n's SSH node prefixes the command with "cd <dir> ; " (or "&&") when its Working Directory
# field is set. The directory is never used or executed here; only that exact prefix shape is
# stripped, and whatever remains must still be one of the allowed verbs below.
cd_re='^cd[[:space:]]+("[^"]*"|'"'"'[^'"'"']*'"'"'|[^[:space:];&|`$()<>\\]+)[[:space:]]*(;|&&)[[:space:]]*'
if [[ "$cmd_line" =~ $cd_re ]]; then
cmd_line="${cmd_line#"${BASH_REMATCH[0]}"}"
fi
[[ -n "$cmd_line" ]] || fail "no command supplied"
# Split only on spaces; the verb and a single argument.
verb="${cmd_line%% *}"
arg=""
if [[ "$cmd_line" == *" "* ]]; then arg="${cmd_line#* }"; fi
decode_b64() {
# macOS older = -D, newer/GNU = -d
printf '%s' "$1" | { base64 -d 2>/dev/null || base64 -D; }
}
case "$verb" in
create)
[[ "$arg" =~ ^[A-Za-z0-9+/=]+$ ]] || fail "create expects base64 argument"
rel="$(decode_b64 "$arg")" || fail "bad base64"
[[ -n "$rel" ]] || fail "empty path"
# Reject control characters (incl. newlines), absolute paths, and traversal.
if printf '%s' "$rel" | LC_ALL=C grep -q '[[:cntrl:]]'; then fail "control characters in path"; fi
[[ "$rel" != /* ]] || fail "absolute path rejected"
case "/$rel/" in */../*) fail "path traversal rejected" ;; esac
[[ "$rel" == "$RAW_DIR"/* ]] || fail "path must be under $RAW_DIR/"
vault_real="$(cd "$VAULT_ROOT" && pwd -P)" || fail "VAULT_ROOT not found"
abs="$vault_real/$rel"
[[ -f "$abs" ]] || fail "file not found: $rel" 3
# Resolve symlinks in the directory part and confirm it is still inside raw/.
dir_real="$(cd "$(dirname "$abs")" && pwd -P)" || fail "cannot resolve directory"
case "$dir_real/" in
"$vault_real/$RAW_DIR/"*) ;;
*) fail "resolved path escapes $RAW_DIR/" ;;
esac
# Folder-scoped rulesets live IN the vault as AGENTS.md files. Collect every AGENTS.md from the
# vault root down to the file's folder (shallow -> deep) and name them on the card, so the
# agent reads them deterministically instead of relying on lazy discovery.
rules=()
if [[ -f "$vault_real/AGENTS.md" && ! -L "$vault_real/AGENTS.md" ]]; then rules+=("AGENTS.md"); fi
cur=""
IFS='/' read -r -a parts <<< "$(dirname "$rel")"
for part in "${parts[@]}"; do
cur="${cur:+$cur/}$part"
if [[ -f "$vault_real/$cur/AGENTS.md" && ! -L "$vault_real/$cur/AGENTS.md" ]]; then
rules+=("$cur/AGENTS.md")
fi
done
if (( ${#rules[@]} > 0 )); then
rules_text="Rulesets for this file's folder. Read ALL of them before doing anything else, in this order (later ones are more specific and take precedence):"
for r in "${rules[@]}"; do rules_text+=$'\n'"- $r"; done
else
rules_text="No AGENTS.md rulesets found for this folder; follow the llm-wiki skill defaults."
fi
title="Catalog: $(basename "$rel")"
body="Catalog this new raw file per the llm-wiki skill.
Relative path: $rel
Absolute path: $abs
$rules_text"
args=( kanban create "$title"
--body "$body"
--assignee "$LIBRARIAN_PROFILE"
--workspace "dir:$vault_real"
--idempotency-key "$rel"
--json )
if [[ -n "$SKILL_NAME" ]]; then args+=( --skill "$SKILL_NAME" ); fi
exec "$HERMES_BIN" "${args[@]}"
;;
list)
if [[ -z "$arg" ]]; then
exec "$HERMES_BIN" kanban list --json
fi
case "$arg" in
triage|todo|ready|running|blocked|review|done|archived)
exec "$HERMES_BIN" kanban list --status "$arg" --json ;;
*) fail "invalid status" ;;
esac
;;
show)
[[ "$arg" =~ ^t_[A-Za-z0-9]+$ ]] || fail "invalid task id"
exec "$HERMES_BIN" kanban show "$arg" --json
;;
*)
fail "verb not allowed: $verb"
;;
esac
@@ -0,0 +1,23 @@
# Copy to ~/.config/wiki-llm-watcher/env and run: chmod 600 ~/.config/wiki-llm-watcher/env
# Lines are KEY=VALUE. No export, no shell expansion (use absolute paths).
# Absolute path to the vault root, and the inbox folder inside it
WIKI_VAULT_ROOT=/Users/YOURNAME/Documents/wiki-llm
WIKI_RAW_DIR=raw
# n8n Webhook node PRODUCTION URL (the workflow must be Active; the "test" URL only works
# while you are listening in the editor)
WIKI_WEBHOOK_URL=https://n8n.example.com/webhook/wiki-llm-intake
# Header-auth credential configured on the Webhook node
WIKI_AUTH_HEADER=X-Webhook-Secret
WIKI_AUTH_SECRET=change-me
# Optional tuning (defaults shown)
# WIKI_POLL_SECONDS=20 # how often to scan raw/
# WIKI_STABLE_SECONDS=10 # file must be unchanged this long before it is posted
# WIKI_POST_DELAY=0.5 # pause between POSTs during a burst
# WIKI_TIMEOUT=30 # HTTP timeout per POST
# WIKI_IGNORE_NAMES=AGENTS.md,CLAUDE.md,.hermes.md,README.md,SCHEMA.md # file names never posted, at any depth (case-insensitive)
# WIKI_EXTENSIONS=.md # comma-separated, or * for all (keep in sync with n8n's path allowlist)
# WIKI_STATE_DB=~/.local/state/wiki-llm-watcher/seen.db
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""wiki-llm-watcher: poll the vault's raw/ inbox and POST one webhook call per new file.
Design (see README.md):
* Polling is the source of truth. No filesystem events are trusted.
* A file is posted only after (size, mtime) has been unchanged for WIKI_STABLE_SECONDS
and across at least one full scan, so half-written clips/syncs are skipped.
* Files are posted sequentially, one webhook call each, with a short delay between calls.
* A file is recorded as posted in the local SQLite seen-state ONLY after n8n returns 2xx.
On restart, anything not recorded is retried, so nothing is lost while n8n or the
watcher is down. (n8n dedupes against its Data Table, so reposts are harmless.)
* Detection is by NEW PATH only. Later edits (the librarian rewrites frontmatter) are ignored.
* Standard library only. Python 3.8+.
Usage:
wiki-llm-watcher.py --baseline mark everything currently in raw/ as seen (no POSTs)
wiki-llm-watcher.py --dry-run --once show what WOULD be posted, post nothing
wiki-llm-watcher.py run forever (this is what launchd runs)
wiki-llm-watcher.py --once one stabilised pass, then exit
wiki-llm-watcher.py --forget raw/x.md drop a path from seen-state so it is posted again
Configuration is by environment variables, optionally loaded from the KEY=VALUE file named by
WIKI_ENV_FILE (keep that file chmod 600; it holds the webhook secret).
"""
import argparse
import hashlib
import json
import logging
import os
import signal
import socket
import sqlite3
import sys
import time
import unicodedata
import urllib.error
import urllib.request
from datetime import datetime, timezone
log = logging.getLogger("wiki-llm-watcher")
STOP = False
# ----------------------------------------------------------------------------- config
def load_env_file(path):
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
value = value[1:-1]
os.environ.setdefault(key, value)
def get_config():
env_file = os.environ.get("WIKI_ENV_FILE")
if env_file:
load_env_file(os.path.expanduser(env_file))
cfg = {
"vault_root": os.path.expanduser(os.environ.get("WIKI_VAULT_ROOT", "")),
"raw_dir": os.environ.get("WIKI_RAW_DIR", "raw"),
"url": os.environ.get("WIKI_WEBHOOK_URL", ""),
"auth_header": os.environ.get("WIKI_AUTH_HEADER", "X-Webhook-Secret"),
"auth_secret": os.environ.get("WIKI_AUTH_SECRET", ""),
"poll": float(os.environ.get("WIKI_POLL_SECONDS", "20")),
"stable": float(os.environ.get("WIKI_STABLE_SECONDS", "10")),
"post_delay": float(os.environ.get("WIKI_POST_DELAY", "0.5")),
"timeout": float(os.environ.get("WIKI_TIMEOUT", "30")),
"ignore_names": {n.strip().lower() for n in os.environ.get(
"WIKI_IGNORE_NAMES", "AGENTS.md,CLAUDE.md,.hermes.md,README.md,SCHEMA.md").split(",") if n.strip()},
"extensions": [e.strip().lower() for e in os.environ.get("WIKI_EXTENSIONS", ".md").split(",") if e.strip()],
"state_db": os.path.expanduser(
os.environ.get("WIKI_STATE_DB", "~/.local/state/wiki-llm-watcher/seen.db")
),
}
return cfg
def validate_config(cfg, need_url):
problems = []
if not cfg["vault_root"] or not os.path.isdir(cfg["vault_root"]):
problems.append("WIKI_VAULT_ROOT is not set or not a directory")
elif not os.path.isdir(os.path.join(cfg["vault_root"], cfg["raw_dir"])):
problems.append("raw dir not found: %s" % os.path.join(cfg["vault_root"], cfg["raw_dir"]))
if need_url:
if not cfg["url"].startswith(("http://", "https://")):
problems.append("WIKI_WEBHOOK_URL must be an http(s) URL")
if not cfg["auth_secret"]:
problems.append("WIKI_AUTH_SECRET is empty")
return problems
# ----------------------------------------------------------------------------- state
def open_state(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
db = sqlite3.connect(path)
db.execute(
"CREATE TABLE IF NOT EXISTS files ("
" path TEXT PRIMARY KEY, sha256 TEXT, size INTEGER, mtime TEXT,"
" status TEXT NOT NULL, updated_at TEXT NOT NULL)"
)
db.commit()
return db
def now_iso():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def mark(db, rel, status, sha=None, size=None, mtime=None):
db.execute(
"INSERT INTO files(path, sha256, size, mtime, status, updated_at) VALUES(?,?,?,?,?,?)"
" ON CONFLICT(path) DO UPDATE SET sha256=excluded.sha256, size=excluded.size,"
" mtime=excluded.mtime, status=excluded.status, updated_at=excluded.updated_at",
(rel, sha, size, mtime, status, now_iso()),
)
db.commit()
def known_paths(db):
return {row[0] for row in db.execute("SELECT path FROM files")}
# ----------------------------------------------------------------------------- scanning
IGNORED_SUFFIXES = (".tmp", ".part", ".partial", ".crdownload", ".download", ".swp", ".swx", "~")
def is_ignored(name, extensions, ignore_names=frozenset()):
low = name.lower()
if low in ignore_names: # instruction files that live in raw/ (AGENTS.md etc.) are never ingested
return True
if name.startswith("."): # dotfiles, .syncthing.*, .DS_Store, iCloud placeholders (.foo.icloud)
return True
if low.endswith(IGNORED_SUFFIXES) or low.endswith(".icloud"):
return True
if ".sync-conflict-" in low:
return True
if "*" in extensions:
return False
return not any(low.endswith(ext) for ext in extensions)
def scan_files(cfg):
"""Yield (relative_path_nfc, absolute_path) for every candidate file under raw/."""
root = os.path.realpath(cfg["vault_root"])
raw = os.path.join(root, cfg["raw_dir"])
for dirpath, dirnames, filenames in os.walk(raw, followlinks=False):
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
for name in sorted(filenames):
if is_ignored(name, cfg["extensions"], cfg["ignore_names"]):
continue
full = os.path.join(dirpath, name)
if os.path.islink(full):
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
yield unicodedata.normalize("NFC", rel), full
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
# ----------------------------------------------------------------------------- HTTP
def post_json(cfg, payload):
"""Return (status_code, body_snippet). Raises URLError/OSError when unreachable."""
req = urllib.request.Request(
cfg["url"],
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json", cfg["auth_header"]: cfg["auth_secret"]},
)
try:
with urllib.request.urlopen(req, timeout=cfg["timeout"]) as resp:
return resp.status, resp.read(500).decode("utf-8", "replace")
except urllib.error.HTTPError as err:
return err.code, err.read(500).decode("utf-8", "replace")
# ----------------------------------------------------------------------------- core pass
class Watcher:
def __init__(self, cfg, db, dry_run=False):
self.cfg = cfg
self.db = db
self.dry_run = dry_run
self.observed = {} # rel -> [(size, mtime_ns), first_seen_monotonic, seen_in_scans]
self.retry = {} # rel -> (fail_count, not_before_monotonic)
self.cooldown_until = 0.0 # set when n8n is unreachable or config is wrong
def observe(self):
"""One scan: update stability tracking, return list of (rel, full, stat) eligible now."""
now = time.monotonic()
seen = known_paths(self.db)
present = set()
eligible = []
for rel, full in scan_files(self.cfg):
if rel in seen:
continue
try:
st = os.stat(full)
except OSError:
continue # vanished between listing and stat
present.add(rel)
sig = (st.st_size, st.st_mtime_ns)
rec = self.observed.get(rel)
if rec is None or rec[0] != sig:
self.observed[rel] = [sig, now, 1]
continue
rec[2] += 1
if rec[2] >= 2 and (now - rec[1]) >= self.cfg["stable"]:
nb = self.retry.get(rel)
if nb and now < nb[1]:
continue
eligible.append((rel, full, st))
for gone in set(self.observed) - present:
del self.observed[gone]
eligible.sort(key=lambda item: item[2].st_mtime_ns)
return eligible
def post_pass(self):
eligible = self.observe()
if eligible and time.monotonic() < self.cooldown_until:
return # n8n was unreachable / misconfigured a moment ago; don't spam the log
if eligible:
log.info("%d stable new file(s) to post", len(eligible))
for rel, full, st in eligible:
if STOP:
return
try:
digest = sha256_of(full)
st2 = os.stat(full)
except OSError as exc:
log.warning("skip %s (unreadable: %s)", rel, exc)
continue
if (st2.st_size, st2.st_mtime_ns) != (st.st_size, st.st_mtime_ns):
log.info("changed while hashing, will re-check: %s", rel)
self.observed.pop(rel, None)
continue
mtime_iso = datetime.fromtimestamp(st.st_mtime, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
payload = {
"path": rel,
"sha256": digest,
"size": st.st_size,
"mtime": mtime_iso,
"source": "watcher",
"host": socket.gethostname(),
}
if self.dry_run:
log.info("[dry-run] would POST %s", json.dumps(payload, ensure_ascii=False))
continue
try:
code, body = post_json(self.cfg, payload)
except (urllib.error.URLError, OSError) as exc:
log.error("n8n unreachable (%s); pausing posts for 60s", exc)
self.cooldown_until = time.monotonic() + 60
return # stop this pass; do not hammer a down endpoint
if 200 <= code < 300:
mark(self.db, rel, "posted", digest, st.st_size, mtime_iso)
self.retry.pop(rel, None)
self.observed.pop(rel, None)
log.info("posted %s (HTTP %d)", rel, code)
elif code in (400, 409, 413, 422):
mark(self.db, rel, "rejected", digest, st.st_size, mtime_iso)
self.observed.pop(rel, None)
log.warning("rejected by n8n, not retrying: %s (HTTP %d: %s). Use --forget to retry.",
rel, code, body[:200].replace("\n", " "))
elif code in (401, 403, 404):
log.error("config problem (HTTP %d) - check URL (production, workflow active?) and secret. "
"Pausing posts for 60s.", code)
self.cooldown_until = time.monotonic() + 60
return
else:
fails = self.retry.get(rel, (0, 0))[0] + 1
delay = min(30 * (2 ** (fails - 1)), 900)
self.retry[rel] = (fails, time.monotonic() + delay)
log.warning("HTTP %d for %s, retry in %ds (attempt %d)", code, rel, delay, fails)
time.sleep(self.cfg["post_delay"])
def do_baseline(cfg, db):
count = 0
for rel, full in scan_files(cfg):
try:
st = os.stat(full)
except OSError:
continue
mark(db, rel, "baseline", None, st.st_size,
datetime.fromtimestamp(st.st_mtime, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
count += 1
log.info("baseline: marked %d existing file(s) as seen; nothing was posted", count)
def sleep_interruptible(seconds):
end = time.monotonic() + seconds
while not STOP and time.monotonic() < end:
time.sleep(min(1.0, end - time.monotonic()))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--baseline", action="store_true", help="mark existing raw/ files as seen without posting")
parser.add_argument("--once", action="store_true", help="run one stabilised pass and exit")
parser.add_argument("--dry-run", action="store_true", help="log what would be posted; post nothing, record nothing")
parser.add_argument("--forget", metavar="REL_PATH", help="remove a path from seen-state so it is posted again")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout)
cfg = get_config()
problems = validate_config(cfg, need_url=not (args.baseline or args.forget or args.dry_run))
if problems:
for p in problems:
log.error(p)
return 1
db = open_state(cfg["state_db"])
if args.forget:
rel = unicodedata.normalize("NFC", args.forget)
cur = db.execute("DELETE FROM files WHERE path = ?", (rel,))
db.commit()
log.info("forgot %d row(s) for %s", cur.rowcount, rel)
return 0
if args.baseline:
do_baseline(cfg, db)
return 0
def handle_signal(signum, _frame):
global STOP
STOP = True
log.info("signal %d received, stopping", signum)
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
watcher = Watcher(cfg, db, dry_run=args.dry_run)
log.info("watching %s (poll %.0fs, stable %.0fs, ext %s)",
os.path.join(cfg["vault_root"], cfg["raw_dir"]), cfg["poll"], cfg["stable"],
",".join(cfg["extensions"]))
if args.once:
watcher.observe() # prime stability tracking
sleep_interruptible(max(cfg["stable"], 1.0))
watcher.observe() # second observation (counts as the 'unchanged across scans' check)
watcher.post_pass()
return 0
while not STOP:
try:
watcher.post_pass()
except Exception: # keep the daemon alive; launchd would restart us anyway
log.exception("unexpected error in pass")
sleep_interruptible(cfg["poll"])
return 0
if __name__ == "__main__":
sys.exit(main())