multica deployment
This commit is contained in:
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
@@ -1,395 +1,418 @@
|
|||||||
$ANSIBLE_VAULT;1.1;AES256
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
37653832633963383966356136353132303335323933666235656436393438656130373538376362
|
39633233363564393465633065323730343130653362373436356334323930393036656638303033
|
||||||
3836346630613130316437396330616635303732356238610a653665376639396364323738633466
|
6333636536643835303936633039633138336434663565320a643961663732373630333238633466
|
||||||
35363233666465393062663232353962383830336165646635663333626263623431633232383037
|
62633236666331326165653637353038313163643162323763313635306230333338613164316337
|
||||||
3230646133313633330a386561653166306166333134343136623364353437643766366233383839
|
6133396638336465350a306163643138653639646133346561323333313332336639393764343364
|
||||||
37343364363066626539363138366235376262376631376362396266643262656365633937633534
|
30666535323264363661653236333839643631376337303563383739656238343732396364366437
|
||||||
62386263643363636638383065666530333066346135626236613430303430316665623364323336
|
33333437643930656564376236333736336465356134653239393036313830333462363633653366
|
||||||
64616436393635316633663962343062373530396330353464316133613261386362313135616239
|
62623863613261323135633262633066383363313731646361326232366661356261316633323164
|
||||||
31333237663130353061373833336131666537643566616265623935383135386561356138366435
|
34653661383366643531353939303333363832323731623830393262373538613631613631616463
|
||||||
37633539653366396630636166343934393432303832623335363464343663333764313465323332
|
66396163613965343833356432643534393539643961373263323533316664386665383330326665
|
||||||
65383037363536653336393063653031396230373663393166343438373930623732313130336563
|
35393537363734333961393931386161386234323264316466303361663139323431303034393035
|
||||||
39633732343031613937386333616531333432666264633266666366613732316164636139366637
|
33353432363162353261646231313136643533363563343537666565306638353564646235623835
|
||||||
39353365623133303633643931316134626531343534613163663939386330303735363666306138
|
33653663393838303364363762613663646139326135313438663838323064326265303733346164
|
||||||
34353464316539663032383164666630313335663139393763366531333938323666313566646332
|
62386130613134653739353930623330663135633632346531306535643332303939356263636232
|
||||||
36336462316661346663393761643463373837653433346239663833316532313937386631633463
|
34313939333263313133373635396138643838396235626662343664316632663930313038643863
|
||||||
61636136653336613033663434343133373330623233373232376630393762626165386362313337
|
65653630373437373631633032333663353866636630306235363035623837656661323735356361
|
||||||
32303031653361663864616435633833376563646335343337346563303964343638376234356163
|
33343961373235613262613363636565396234613865303937323432656433343836373831343162
|
||||||
32343635623537663134616133386136316534363434373862333331656335373530366164656662
|
37356234363662333764656530333139653434336137326631386366306162646433333239316133
|
||||||
35373734316234363037353336653961373738393737393538383362633830623662646530386335
|
35303136633664336433386331316662313330643734383139633938636462346539623835653738
|
||||||
64346130663761353064353462396562646562383637653165356136396237343833386563343130
|
63373439623866363863633963633265373339613130343966656637376632396139646266306666
|
||||||
36656232343832316562323138616430666361613462303465363231353833613465343063353934
|
62316430343338626331326433366266396131666264633234323233646639643236323937396431
|
||||||
35626330306136633665623033666339363832643534653732333930393137346661346631383539
|
62323738376664303237653563323164396166313266613937383431653766616234653762383163
|
||||||
35393463653930306564353537626135346666656565626335336434323536656666656236366139
|
65666138353262663536623036623439363463306136363930343633383436333565363232376162
|
||||||
66393365303339313633313930336564383061353639626134663233303666366230353539636632
|
35343035306230666432666131643634383263663838626466633133653830623330656164313465
|
||||||
63313065626432303561373137663263333064366132303461363764313561346464633166343563
|
32363031313266396532313563343963613133396232353832636464663462636138623637646135
|
||||||
64346131383633373632383738643061396463643434633161623362306266383562653736373663
|
64356364363862653465303837663261333933623266333736343938643065393031343535633762
|
||||||
32643765633937663165666330663562616364346536343935666234623234303832333763616331
|
62363436323931373563323863643066653237393339653363656133316365346262306463336364
|
||||||
33346533343437656237616266376638386361366364643931616361626435343534626165343232
|
66303732366437633461373065363730336235313732623131633366633834376563663734653063
|
||||||
35313363306133343730393962303066363837616662633335353935653864383864303162343737
|
31656638623933376666393232613238323239623836313430356639613131303761346535336338
|
||||||
37333238633138616636643839623861386337636138313161656461626633326164386632666664
|
65613363366331613865343163623863636666303336646636663465373065393731306634663239
|
||||||
34663432333761313462646330363538386534366234353765313365316431316566376662316334
|
32653666343631666235383863336338326438646137656562646431623436323037313438343839
|
||||||
39636637373239613631383936633439666432373432636633613833633238616236623633643930
|
34663062396564306138356465623032396239396562643933306365613033333065323630393665
|
||||||
37333435663132393737663430386263363066643238383435396436346131653639306530353239
|
66346262303965353539663463626261633332373833346430346235376636323633623864353133
|
||||||
64313363653365666231663361666432396566303631663536616138316464383131643331636432
|
34666265373936376339646363383834393166376434653233306632373966336232316339336465
|
||||||
61343430383737323435626439656633353138336331663833626363633664343565383266636564
|
63313866653732386234666531663030313364626534343235333463376334663061316461613331
|
||||||
65306636633764666637333838636365333832373839336131333566653763306137303430376333
|
64303962393930373036643561393762666334666538626233333937653038633734626430623837
|
||||||
61363135356133653230333165303535326533633935373831326533373066626265336336376333
|
33303463386363386562333061353963303538366362313131656433383263666631666566633765
|
||||||
65336631326464643066363266306662623537353963386232643462373762356337303033383961
|
65613238393736646637346333323933326664663862326563363566613361343539646264626561
|
||||||
32383531353966633239333532626664376331383961326132313136363333663362643066323463
|
37323034323136376433343433316266386135333765643036373565326539643066336533343431
|
||||||
37343432616632643530623734323763663466346630393539346565613837396638643339383834
|
34316264393261373130333930383932343864386534376633336239303066356666363438356632
|
||||||
66323437363733333935336233636364623066396434376261313738333134623530376663366338
|
63313637616465386363346162653064613236383166353432636530336437376665643830356134
|
||||||
65353965313763663231633337396364386238303563616264623631363463653635303932323364
|
35366239326637373366383962376165643932336632373330326533366535666434343161373264
|
||||||
63616638613031313435643638383336653266313837663862353166396162386430356161323538
|
30323130626236356666663230613837373666666334646366616339313661613238666138353832
|
||||||
32313134393138313735363934316336343936323562323533373437636235356164356337633033
|
37633066303139313961643935383632646662613766303366663335306263646439383534373639
|
||||||
31373036363934386662343966333433643063316137313066323835613064616635316665303936
|
33643733626531376565623431666637323736393361373566626666633232383832656333333566
|
||||||
36626432343839373032313636383663306661326537363339386230646434393136356530316638
|
65616662383561626161363061646334633033386534633533393032386532643939373736356532
|
||||||
36363766643034316165343538396431323962313830333164306236386138623364353737643536
|
31633430613338343762656630303139316634323635653763386366633366366336383064613333
|
||||||
34336164636466306365366465623037383665366366636164633565613963653963636537626534
|
32373164633066643064316535333364366461666332663165346462386563373864663565613736
|
||||||
34346337393533343539356235616161653138363939313465336264383432346464346362613830
|
64636131346133623166656235306230353733373263313166383761303364366434656666303732
|
||||||
34643639323631383639343933616265333034303932333038373462396138343933333238393438
|
34313234663139616466633562383663316630666261336463653236393832303363316438326635
|
||||||
36643864303131323664313861353635323934303137376332386630633030373264333265626365
|
32343135396333613936383239633938393261393435653239643763373065623531336633313130
|
||||||
34333161343431303931343730613732636433313732663165363534383637353366373963323238
|
64323761646666636464623734656236653439646433303934633536333862653930303133336264
|
||||||
64373064643930363564343036356336383432363362623539373337373231353536346638623738
|
66333361323735386338643933336636303263613739666432626137393061343833636537396539
|
||||||
37653961366631343263353062303466333839356363613630363431626564336164653662643766
|
62626534663636323764376437393237663839356534306438306431663937323364316431323264
|
||||||
31616132373635656463623334383431386262373363343638353965633332313765336230313035
|
38353234303364653833393733383736636139336237386137313937656137636562303036666135
|
||||||
65613438353533643530663436383165366137666662623763326537316665626630336534616433
|
62626530383037613439646261346130353430303131303936393230333662386533356631326533
|
||||||
66326335323661346232366264363162306337343166313262323530343966306437333735343461
|
65643931313036623766316137353130363535383834323363623334376234393265326362653838
|
||||||
62353430343264383863303138323432373432613332326335616464313238333331393531666330
|
34353665666537343232626137383166383761643232313539346461393937353737373935323462
|
||||||
62383439376164303236356332636665363134363365646533633430343332316262653966636264
|
38303631383465623865323230333330383730333536346366616332343539313237333538333431
|
||||||
39353836333036366237363063646138323331393035653766353230653937316562616265353132
|
65636235343938316635633732393430323932626339323236323663663932306463373039343363
|
||||||
37633366643830316164336161323961313464313430646634633766623931393037616430303765
|
66363235366539386430333365613338646166313133346239346537313666396435343064643537
|
||||||
34616464643930333632393536323235386466376431656666666532343639666534313130653138
|
30363236396563346165353032373463386630646134356634646465353136643635373164643263
|
||||||
37366465326433623331646264613766663465646335333861613534383335356139363265333362
|
31396333353232323937346530303334623163316538353937396337636165356163396639373731
|
||||||
32316332663034386362366535613530316462363831346231346232623463633738383430373265
|
64363036346434623366376261316632343238306564646234383433343137396462303466383635
|
||||||
38393535343236376135383566616334303736663933313863613461626564356563373233646233
|
38353161383933336264393332386233643666656132633038323936353630313635313365353663
|
||||||
32363761383266376261306236363433356666316433373931626666336132636433363037666366
|
30353734363435373265616333653737383838376463646466633732333537346565666433656137
|
||||||
35666535393231366562393836633839373632633933623633663133643163316565376635376563
|
62333234653264626532323064386639643136336264346232663537393065386666623463336433
|
||||||
33323632613533656131656666363161633733356337356465663234623461616261396333393034
|
38613966376535303961343361363930313664633030353538306633656335326430653534346161
|
||||||
35316638666639623730323061303866336339366539303064313531313463373538393933353239
|
31613166366231386239366631623839316361303630663063336637303832373463613735356364
|
||||||
66613236346331656631663261336562636530646330313864326665656432623033353932323265
|
39323238656232383632643630386261323764643861353063616130323939383732343863356633
|
||||||
37383865613562373933326332396335613930356635303865633964333534643766343130366633
|
33303965363565313535386463613030373634653763313430643335393032663839636164303630
|
||||||
66633038326437313932366632623337633066393533626431633834323237323439656562623032
|
38653930383661386331613763316365653538386266396430633638633638653134633365343937
|
||||||
31633736333366326234656462326232626536646434643536316563393637343866626564323863
|
30313565663139366139623065623730356363313731663034663362313335346561323130396333
|
||||||
35356637663432363733623237346235366266623730383237373533316230616164616533313038
|
37343165333434613235363964323564306330393738333635303763633466366664616138613862
|
||||||
65663331623137316332383634373939623530333731633836343366613938313761343037663737
|
61623464656630343361353433313836316230653435356661326366366131386637363334326336
|
||||||
32663132636237616164646436373465353365313130306661393938366364663339353238313531
|
34313334386464363261343831356163613834353639323562333930363563343963633833623832
|
||||||
36653132613161636266613661373139376139663364376461383064316134303165346338636436
|
37323964336565383062373739346261306139376637306461336265316336666439353536313063
|
||||||
36393035623634386139626437356531376236373561386266616262346663343166666235383433
|
31653432373235666235396239363261316333373332333234376635323261306234383836383161
|
||||||
62643235363035613937363433616263343833333737316437633166633163383031316161333631
|
33303136363335353539663833306166643737333238643066313633323031613237666264666365
|
||||||
31323831643531643431383666313364663935326632643336313230333134336336303866643066
|
63333766616631366639396235633466383062623462363638363866346462306535623839626231
|
||||||
36666266643266616332626463333931633634343839636337333262663534333664646264336362
|
63666633393137316637616634653139316439626432366236663738316166643335333534626164
|
||||||
38313063353463666661336234623139386631363562636138343562373730363039356163353234
|
62623861356134663637663665313934313833636165643732376438316563383539663037373432
|
||||||
65636137376430396265623934343335336635303363666138633766323738623261623661376238
|
30326233373936346433323934663633363166376463623135386238393334396466316265613965
|
||||||
63623538613931303164353331353530316666653763353333663431643337396131316437323331
|
33623362653436613835386535393038363064373233623266643466633732366632346330343438
|
||||||
38633132343334323061336632663036363866343530653465383834653764626563366363383861
|
65383938363063666263616330356661343732313435383137313435656337353063613230656462
|
||||||
33643462393037666231613238306264323062656135396466616234336632386634393065353364
|
63383465343932303832313335346230313164333362303935343836363764313933623831306130
|
||||||
30613162643231646138613364336339316234306665306433653430323061376133353034636564
|
36333339373230386162323337386661303663616530383863333332396439313833303461643662
|
||||||
61663031383163386365623437343831373836306538396161346662663536373639373331663237
|
37336231343065623635376564393934316333626566623765353265613737646131653934343631
|
||||||
30626166323364633636613738313831393931363337353264306231653934316335653930626136
|
32613463636130333561653066343839303332396239656534303733643639343337363437656662
|
||||||
39613431353434666363333464386233366363323830646165343963376430363130303263646433
|
63303732326235643830303038663131613330303863353231313561376231666135353464303864
|
||||||
64366630353635616330656566343765356638636535326632396136653930396332636233383537
|
66633332613139386136303264366464356434633434656536393664313130646234393634326432
|
||||||
32623062613463373132393031353738656436336161363234613035646635633430326161303337
|
63663938373932386564363834373134356464633662383130386337633564643930613734383631
|
||||||
32643833303434643063336163343835666566393837353762343131643431303834666165633530
|
38643239663733393336643834643438303737636635396630333565656264653866366166623332
|
||||||
61643965366333336439383331383861346464633834386432323562363631383363373466613333
|
35653430323365313834643862393065323531633230613630343738333934383161613264356265
|
||||||
63383730336334343433373831363439646531376133323732303236666162623061663737333731
|
66626561386138396639353934653465373162323838303233636565306534633236343066623638
|
||||||
61633361306631313631306437623133626262373939386232623939346633336638356335636233
|
38653132376663666136383235663337343762646137643935626365343162363439303061393563
|
||||||
38386464393262373262383662323463616563626432313765313862336634356630633738366534
|
32633366623834613739303139333038303762316663663136656636663663386362613264663238
|
||||||
32326337353562313134626535316431643862373635386336396137343366363561316139663465
|
35323038326239643062666336333932383065383461373933626339383434363736323536313533
|
||||||
62326131353431623735623335303066333335386161393663616635313262343461626339386334
|
65363565306239363935666234653438393039303663623636376262666164393334363839393665
|
||||||
62323430626263333233333339303934316363656137616632636661353932623535323135626633
|
62326533613639643938613838396565376334333035333962626135356265353639653633373438
|
||||||
34393963626435396537343639323761633330323931663033326531363332643666346436323966
|
37316463303838633335306333346465386461373766356533313863333534326231353864303531
|
||||||
62633332356265656662366635356638386338393661356138316165656630653535663131363361
|
32663461623731653138646336663266303065306562643961393936373937653664323032626339
|
||||||
38663936306164346266346463633039346538666662353331353532626463376338653335393330
|
39373539363664316432333931633866646333346363336466393963656637366562336162613465
|
||||||
39643635396638373231323238626132663062653038316136623338386439313535313137306438
|
32663230613335393839633633633063636635373363653234663564663264353036393434663963
|
||||||
36666462313464643165396133633436646366656465323065643662613834643131383232393663
|
66653631663062666338326133636236303862356662666533326236613965323462303839623331
|
||||||
39393337343034363039303136323866343934316430396333303466353461326563383864376538
|
35626238323837343738356264313266383135626239363731353639633837323132623635633435
|
||||||
32396261396239633836326531313634653764303964336566316633306336313530363262646130
|
61386663366533326130626332613562633965633837346237633334303564363136633733323361
|
||||||
32313563623265623937656661386233653431626465636166356135616261353739646236313066
|
32353361396166396334386630353738643438643537303034623830303939326436363239343664
|
||||||
62616565343836656436626435323934366531633437326236666561613265653033613537343337
|
35633731396161623934633864313863323336326535313637653935376436363162363565666634
|
||||||
34363831333962663664303064653865366137613037663735383665386166666666326531646665
|
33613435326539343865653862613263383236313130383234623766333637646464613365346532
|
||||||
36613338636237346639326233643665633235353134383033616366333365393738323465396530
|
34653035393862643039623763323138333334623866316437613435666530396134306665316334
|
||||||
30343734656566616235663532333462316462633338356532386565336136363436356337623436
|
31373364653865323765646535303166373638636134326538343563363732636337653931373234
|
||||||
62646365616534623434363632663462373566656134336336366237343661636462666132326631
|
35303433656137333635383334376237616261393334623835353866323333393630373765613835
|
||||||
65613135656439343762633538336338373264303837373830303831613765303765313862373132
|
33363636323664383136353863666635646563393764633531616135313935333539333664343664
|
||||||
62346237636462643364323966353064396666316333656638653564643039383333356663666335
|
62633465356563353433633564303531633236656631326331323638643666643636396162316532
|
||||||
32383861666137663438356366356430666631306134653361316566383866653833646330386466
|
36313839363164663336326639313066316331303365386130666662366638353834306662303336
|
||||||
62393034313536363134616137353065666662356535643334313233326237643866363665376430
|
34393538346338376330333332383231333630353334646635313037313736613139383832663566
|
||||||
38623231643839366164643061616661343936383830326665653236363834613335313833613662
|
30653137386339623136356663613431303564633966383633643830393636383734306238333831
|
||||||
36306562633036393035363365623061356234636364383337333234333032323665613735336537
|
64363933386532346264666638316161393435653661356162306262323131643535636134396439
|
||||||
61336264643561383861343666363033383666643636303335646331643466643366386332383730
|
64656538313938623938323638393734343464653362636532386663303762326234373930363866
|
||||||
36313832363034643762633862303965626132343966376635386535363233633030333538663130
|
32306235363837396339383461396430373964396533616434373937386266396439303964356534
|
||||||
30383765396130323835663664643233626233316639363935323664656533623437373531616361
|
65663262343461343864633934636536366335326162393830633232633832383166366430313934
|
||||||
65663464383239623730313664633734316531363365386235366435626436396464666161366437
|
64643262313163323531326163323431386532386530326332326130353230653263616139303566
|
||||||
37373865306638346138326236653139386266636534343863336265336336353363383036656566
|
64386535326262326463663734303061613435653964303764386537636132646430626530313331
|
||||||
38346232336538653034316431306262323163663265633862343234626163653632366439343166
|
38323563303738353630653631333535376239393730633535353934316131333039353931616439
|
||||||
36666336336139336538343339666338353331313033616530313865343362353034343663343730
|
35343531353332303932353636643763643035376161643536333365666466616234326237633430
|
||||||
30613466343664663664643932643135376635663730623635386539393561363937326338353366
|
62666464613334633537306431396534306237313032306632323266613134363231313331313139
|
||||||
30323433643734386462626532323063626462343032356163343462623330623031343766333039
|
63316661303830653035376261633539386533373235653839333562656135636433333263303030
|
||||||
65663035383138306664643538343237303837326666656662303130363362393730336263323034
|
65333136653939616231363835616134373038633634656366363266386534343233393730386436
|
||||||
36383339363664356538336231646663303761313561636566393363636665316134363266366537
|
61366536333730646562343333313234663037616461643864613334313464326661366133613966
|
||||||
63366135373832316439666364653165643565336236323366383363616430326334356237336637
|
32343831376266376636646439323566373033643939663261356432623865313833316238363363
|
||||||
38613430333030633734636239643434373964323232653030323161313139663364623234323835
|
34353765396233336263373161323433396437363463626464386463346330653861656162346639
|
||||||
32393037353262643534326330386239363231353435663734646638643637323266343766633739
|
32653738353738396630383431346535616165306638666137616664336235623834616239613131
|
||||||
62643733313938326365626631643536653065313534653738616162646532626330383230303739
|
33643866396530643062633537376565306538313437623462363963363364303738376662323237
|
||||||
31313932616361343030313231623062366635643338373965316136393433313964393737656562
|
37313438633138666434646633333262643333633930396439333565663930613263363638366265
|
||||||
39373232333131356566616134393038326363303463663739346166376663323763623365346166
|
61376165353939633165646330656631323765313430323630303335643362393763663134333333
|
||||||
31353562323632376335306338623530643339363534303430353564326365376163346664353930
|
64383338343939616362363930626235303532666538653561656162396566383865363463373233
|
||||||
62663535373338616239616464316266303237376437653138336137613164613636613236376366
|
62363363623938383234343639656631343831616564633136363332393131356531343137396534
|
||||||
35646531643534653162616337343362333261306164313731646431373637626136663135663732
|
65626639613663323139393061353636313036396163646434613431323864303130663531343964
|
||||||
35373330643666353637643036623731383634373361303438343130316430313934613161386662
|
35363339336436336532303763333564363962313637653438626235353339333438376433396565
|
||||||
61646465343866626561373564366439626531333837373232313730626531363464663638616235
|
32646536383036353864356134623564653436326232643330613736386162656161336165363463
|
||||||
34386361306134643833356464633038346537373538623365316335313838396466653361653764
|
37646138323937343839373736656332643430386630383463393637643463396231303730656530
|
||||||
32366138653963326263636137366564363439616431393839376236383334633830363063363237
|
61633563633338663635353235663931303964333839663333323062646266646466356434343432
|
||||||
34303962396331663364666536343261663233626533653665333836343465393533313730316233
|
38303762336132333132373532623637353731306161316564373935303366666530376639613166
|
||||||
62366338306434376137663665376461643239383262663330333831386566343139313066616565
|
66383533663634333162373436313031643533336436373563356364323236333632303663393761
|
||||||
63663866626561306332643930646534623437343237626639323564643839666664643264373239
|
32343631643566626433306532343764373263666537346538303561323465646237396639363065
|
||||||
37326137383963666464366662386363646138656130636465616464613866666530363939613338
|
64646334633033653962303437396430646332306562623763323733303930623763393537646532
|
||||||
61363135343563663361393764633462353730613034363137326333393766346533666638376431
|
38336439636134666264663339346230326435383365616433363535613235626439623037666234
|
||||||
62353034333130323738373264336661363235653039336638373135363633336138343930313162
|
33393434306662333966316364303261643137356136326637346539363538353465313662393534
|
||||||
36653139623863306431663232393663396663636664393636313436343037363265376432633139
|
34306132613636633936616339636632623733616432323564393565336633366464306661306130
|
||||||
30656232363830386261613935316539396664653937623765653663386461346361363663666262
|
32373465656131346138343537373463663937636337303765366337376666346136643139356234
|
||||||
64633737633132653632613637643134353564653966363666626231626639333830643133656232
|
37376436313834366538636136303965383330663437343363633861396634373764373863613734
|
||||||
32626533313331313438346632383539313364616133373937313633646332333563376631316361
|
39363837613363663337323137616139633935643437353838383735383561346365333565316336
|
||||||
63363533366666346130633334376430646364396530653866393833653836326134633033386262
|
37373231343665343838663235656132613331663638323331363763373539323239306232656338
|
||||||
31303934373337346634643963323066626333353864326435363865663262663561346364653430
|
33643632333863333064666332663437613961393530616330643139663736313733306339346535
|
||||||
63326663313038313366386138636663303765376239616335383630313131353137663333356166
|
31386639363338656462663566313836313066623661653139306662636331373038396636386362
|
||||||
30383166386561666430323164323566643736316461656362373332633732373234353630333963
|
63623431336139316233396432396166633762363662616432616634363639616530663931366230
|
||||||
31393333383265653331366439633965653964303661643031303136316634326138303762396164
|
37396434343039303232363633626532383235376538626438356639386661373330663838663134
|
||||||
37633530313638343034616133366134396331313634336536363133366464396365646164336665
|
65666437363139663932383035303036343633303766343836363435313739363839373938643562
|
||||||
36313139343036623466373661316336646137383264316231386365636334363463376261623637
|
33393733303165373364643961326634396265323962313433653634326436336564313338323631
|
||||||
64636162393939643637346462613364313964623230336330336262656362356365306435373133
|
63666636646638626639323631323335653961363964616461393632316363623162653665333532
|
||||||
64613866373762363834353138373463666435383838313639653636663530373635636262623138
|
34346532313436356235343539343330656235376261326262646364393866343562323663363232
|
||||||
66636363313031646466303564623836333164353561316661303762363337653033643234616435
|
32636539343063323734663732303430306261366361383461363963346633316632623531363530
|
||||||
35373035393862626131646339383763336234643438653237656165666339373239623763353363
|
34343633666136323238336530653335643730326439666637663266363563636466306531393431
|
||||||
39363030313338633530313638646133303634366666336634666538616430636464366433333639
|
33306662653537643835656637636466383965356663373530386431383837373438373436383461
|
||||||
64346632666531333639363638653135346637396430643836613565343531313664323866636564
|
66653735376535656637626231613437336663333132386135666432656638636138383939386665
|
||||||
33613130373037333064613139346636623762343031616436373764363236313838663032316535
|
35346636313439613335653635653861303231663239613563333438303738356439343933326332
|
||||||
34383935313332633035323837373835633433336365323562636136353232326331393530616632
|
35653764613061356465666361323134323331326137353863666364333332326133343039353164
|
||||||
65343934613436343663336365373434343637666131666336653966336264643131653637316339
|
35663264326432366330353230643762623933346363623039656137356135613063366265626334
|
||||||
31663633363732313166383661653639613432656165316334653135383036376439303263656638
|
64666539373230376335623365373866613761373637393066343536386134633131393339343439
|
||||||
66373138326239343162663163366364393366656465663133616339373332366434623633386134
|
39353938643233636562353131336262616439623461363531383064616564616462383833363338
|
||||||
62326565373033393864396166653234383538623337666166666138646464363339626132376663
|
31396436303330336532333661333263346233626532653437633766306230643032633035636132
|
||||||
36646561633635346364613163623534336331313230653833623036353338313833323337303635
|
65343938643630303063336664393532363131333532323566306338343837323431373062393265
|
||||||
30626434366563323765373434336134306239323765633836313533383238613939313561343464
|
31653964376339626632313734366665613563383266376533353664363764366639393563313164
|
||||||
39663038346466393762663261336231303237376234346566636632376530313065666434393634
|
63646661373461323039636338313165306435323530633535353331343166636565373630333530
|
||||||
63636634626434613734383337616665313232336436303864363064336234383965393535333965
|
63343262656538616232313633616166623534643834313138326634356334636635363935343030
|
||||||
65313837643235396562306534636366396333316330316265666639316562373033346439666230
|
61373533373863633630616135626335376136336261646465666135383130633032656465623938
|
||||||
35333633666138666465383565373130646537646138343132323266303133623661393031623562
|
32306433303937363133623537313535633164346536626465366663623430383639386662396665
|
||||||
32386230313865643536346430393734333137663461363538386436613036303765623330353630
|
65393038383136306331323035373432666565373934653763336336323131353732356164366262
|
||||||
34633538653033613163653761623731326631393233363837626664353331343639643933653137
|
64396137353838303934363033393363666632323137663762626163303963386461373166323734
|
||||||
64643965383762363065353237333231663534386239663061646430363635636662313835376231
|
30336135396262323736313037653133623237336532663765323034383766363932666562343335
|
||||||
35383337363933396564623334366561326535363063353530353263663036333962393765643961
|
36623731326232653363663937626136386139663630376564333230313632646334353664303137
|
||||||
35353230306630333563383938613962386132366233613233306664666237306236643163646238
|
62363261316161356164313433666562303965646362626331323538663865386530393864373932
|
||||||
33363533636532303766373361613930363765353431626235643036393932663534336332396136
|
63326161656561393665396461313938316430353131643035353933326430653439643634316435
|
||||||
37316137323565373534633433323336383631653833306436653832396334663466346638613264
|
61626437376565363365303061346536386134356335646137626162663739656631343334653866
|
||||||
39626137333966393733333230376261316636653634636634363235663534653931393663373139
|
31323564663165376266656233316266363232333336356137353665613961303065373735353735
|
||||||
31356135303130363263366661346633306638653339363061636463396434393631313231623064
|
30663339343631633931376562316262356462656132666661383064333838323061616361383264
|
||||||
30393137656336613461313731346637386464616433663738353233633132633762373033666661
|
64666664616231363661636337323839613830363366373530313963613132643836353062646561
|
||||||
32323632656566363961333130383434633838306565346130626465356464356365333038373538
|
32396565313631623639666433663037336338313033373938653336366539653166376237326536
|
||||||
30343161336531633535356433373430313633613538326637643337373936343638323336316263
|
35323134313962613962663630376663333334373431383731383038323965333736356462303566
|
||||||
34633731653534623139386537336632643834353038636538646334616366643430376137316636
|
32633164663734366262383264376539313438343530303364623566383564376361343231386564
|
||||||
65636336343639323564336430343262303233323430623034343539333937643239636230623034
|
39316366373964333837656536393961613466353436333839396331326463356636623738613563
|
||||||
34343461363363646562353262373165646133636364353762613464303534353432376434303336
|
66333030376364383535633637383439383237636539623930623966356461366436323933646266
|
||||||
36333339343531386539323266323464656335363066323333623135633466613130353837333561
|
63363264643238333432346432363339323461646533333266666630663161653031626361366364
|
||||||
33623663643135393465636339383164313135393932303235316662653666613566383361353430
|
62613433333235356438326439306132376234326437316437373537346363303537626632373537
|
||||||
65333234343361306534616533396564393562396564343165666436313039393936613566313864
|
33346534306363323133353163663932633632626561313566383865393461316163616532666463
|
||||||
61633065623331396230393135333137333935656664363764306332666537323732373866366163
|
36333966626638336437666338356437643862303332353663303537613933643338396432663336
|
||||||
35336539636633636566663561636435666166393265306463373235333039333062343462376339
|
65613565353536396132373663663730386664323732616533303037363362663335656236383838
|
||||||
66343433336662303861336238303236346262636234616635396137393339396665376539313930
|
32666463323463363736316534393538373938303062396564333435383834646664346261383535
|
||||||
33353034363730383534376262316439373833326462643362663964626262363831373337646163
|
65653931623934363330366361303030306132616637636366313333303664656363623061346638
|
||||||
38393930313236303633393032343237373736316163393766303566316461626539666535336361
|
38626261313765303832333562383763363935656632343735613935356333346237313436326562
|
||||||
33363263616530363730633931376532333135333135333333363439643164633635393631336132
|
65353230353537373737326239636465656566346635303134363739333139666362333964393465
|
||||||
61393334316331326432663237316534323064323435646130346532636130366164643766373665
|
35663837633132663461383036353065656334303730636661363236646462353238343737633635
|
||||||
63323238343839356638336536353162346662333436613566376131346536353364623961613637
|
38663762656234376233623264343762303434353366363262353232386131303231373832336431
|
||||||
61386561313036313964316133613139653662613730343365626664336364376464363563346464
|
35636434346531376461393330363837633564613363373235623837373130336465383933353039
|
||||||
33346338633731653765383963376230316165616564333265316633666633636663326230646138
|
31363732656164333037316438393532336435663035346139373439326565653363343336306638
|
||||||
64613262376538336431613836613338393135383532343066646366386363666166363763306164
|
30383635656336643934623132383433333962323438303733396131333761643730343464633539
|
||||||
63336635356665336633373235353034643039353034346132666433663136616630366636653663
|
32306664393738353334333465363966613261333532393130366437353433643439316135613861
|
||||||
61666534363037616565393735356237353461383235646132323762383932323538373337373336
|
63303863313133333339323665343539343930356637356262316138613733613465343031376538
|
||||||
32313830336664653235333464306438356132636530366636663131313733663234326663663837
|
30633537396262636262663033656430313831616635363239646334356334323731626530303034
|
||||||
65626431653636643662653330376334363638386633396532656432653736663932623034343565
|
61343362383935343835316561333231323261643838616435313032313636613933363837343336
|
||||||
39346165396136303062306533666533373464633966636633313065366563393736653534373235
|
34613131316239653861626434366338343232326531396530373165663931396136373536366339
|
||||||
38346261363163633838336137356163373338373032633436636233643133366636316334316430
|
64306363393061333333663435666130366630633838333533306163313237653462306461303030
|
||||||
64313132316665343332376264393966636234333739623533613833663339303933396134313736
|
61353830326337333563356165663062316165373939333938373637636164646536616639366634
|
||||||
30383866613338653836653663346563393564373163623231386531393865633639303863353832
|
65353035353836356264346234653431383565373637383035323234316637356530346332643635
|
||||||
66623037396337663434656364626463336537303239366532633634313639393366653538343133
|
37326161333838643166313664616331623532356235323564356261663734613464666133313230
|
||||||
37363733393630666464646233336634633836386365663163646331366363643866376238636130
|
35393763353639323666313430666164323836633138373961666162316433396364393563626265
|
||||||
62356164306262333238373863646662393535343033306535613561303036313031363932653237
|
64373565393965313134613365653062323764346432653630643662663930353738353038643661
|
||||||
35393832303637393437656531383363626235376339383433353762313131326532626331303731
|
34376363633338353132363061363964346162663438626638373032326536363733396634303461
|
||||||
37326161633964383531643535633664316162353637353837393565306665303331323436343536
|
39306432646362666336393437396330643864353437376235313966393837393435373437313065
|
||||||
33306364656537623030373334623733626435346432626362636630663064316138373536643230
|
33303439343736626436613663353739656264313639626633323265643966643566623765653739
|
||||||
30636336653565323038313931333966623431643062613930643230303866343837316163376566
|
31336564393138356265623636306266363861323961636131336331653632626439613461383735
|
||||||
37633731616631663562653262346131373464646330363439653333653563656565343038353633
|
61616265646365636133393031333733363537366262326632363962333433656366313965303164
|
||||||
64653333653635356335643934363939646237323062303836636436373431396163663036613030
|
66663335636363343739313838633235323561343833346332353532663065363439303936303266
|
||||||
65306566336339646237623462613365656338636366306361343263366230356261333931323630
|
34356331633961383264393765316236376130323233346163306536386436366636636139363035
|
||||||
39306131623763343032376266643138336462653930653766613632363237646538613366643465
|
34623938366266343164633532646161633264316334313930383565623766396165663664393537
|
||||||
36613966616662333365326231323161636631666536393639626663613930366635616431356162
|
32373266656134333136393965366538316366313735653535626635663462623036323832373964
|
||||||
31643938636339666430393861326530303866386537633834373966316537316262313834383466
|
61343236616264373563386265656364333230396462653833373338356233393962663965346636
|
||||||
65313638393536633865353439616433323536306161636436653966383736623366656463663331
|
39313261353263373364373034653338383632643430323335306632353331633939383137396164
|
||||||
34386565363762346434343535633132363566643730613337306638366264653235656665383032
|
65313166633632366465663466633733663939303039316265313539303564613038313136623861
|
||||||
34373737616663376463346632353361343365346533356132376437616133316238653836666366
|
37383837653262633365333063646331663562356162333937383538623662633134613364653033
|
||||||
36363865303765656239393963666164643138333831326161616266326335373463323531316531
|
32613537633639656361336236323039383533303661366432376665323432386464393534633066
|
||||||
32383766303062303838383637343730633364646632646139346366303036373539326532343331
|
32616666613938373066313866306330393061396263386563306661643831303266633465396364
|
||||||
39313034616465393364373235353536613062396362386462376235663030353033643331323961
|
30613961636333373462386531373264386332303063663264393861363263666638643836396234
|
||||||
65353961623863366431653664633038373737633130393563333932393030306165613564353435
|
66613235643365316233353262663739313831373561633339396165386530313538653538633039
|
||||||
38623138383235303766393566383833303362633433343665313339636336626661353766653262
|
36353364656136306161636135663464343033306161653365653166643762343035336166316164
|
||||||
66343830633132373131336633613336386333326230356132613561373630346433363861303933
|
66346363646238636438353036353631663135356538663762323030353965386635306630643131
|
||||||
30303638313235366263393231366234643262343739303362353265393833393365636235623534
|
36666330663063626334386431663635646561643464616636633735626132383961663265626631
|
||||||
33636638373438636239333138346462643763636239333938336165366666353564663133326461
|
61653365373863313832366538363864333537643336623633343334633333323561373638613961
|
||||||
66346465663730646137303365383436646565333738323661393835653331663135336136303431
|
34396433366665333531373031336362306364633537646565656630323334643038383535383036
|
||||||
61376536333264306230393930346332623266633063326162613532313164343134613864633134
|
36633431633930313065663337366533323463333162326435626264393162333162656236306536
|
||||||
61313334613866363965343233333961303936643866376133666266613236316337656462323865
|
62323032616162313666303265636636323163303234656230313061363862343736373630626435
|
||||||
39643862623563656139373262626566656330383938336665326261343732336361333139616634
|
37616662393737656132326537336534623633613133343833313135336330303536393364336363
|
||||||
33363439313365356438306364323562353231653339666133333330313566323530626432653837
|
34383136376363306264306237653236326166323032383939616563353432323565666263646164
|
||||||
37346231623938626431303332333536363839616264613563316239343531306562626362303265
|
38346663373335636131366236313134623435633766653266383661363532353931323831363065
|
||||||
61303231336164653265326666623335656462396238373939626230303439396561633261393133
|
34663732643435623436323236316365313861346162613636323138303831353062616263626436
|
||||||
32313338666561643233323330376465656637326235313039306461653066633431393936376239
|
37356230643833323062336465306366613637633834303762323838656132333130646462653766
|
||||||
65623931656330333430333963363964313164666337316631616461323834353435336131646236
|
32393532636463363634636665643439366531303162306133626138653839316433343532646530
|
||||||
30613936636263326165626465323766666533366566346261393131396364613735333764363036
|
36383066303539386136643436393764613937313933396335376262323064663363396133373739
|
||||||
36383562303565303233363962383363313039326232643335313362376438626637666132333766
|
66643332333135333437623266626563356337366665336632333035323537343762376132373331
|
||||||
65646531313735363036313337336163303039393734383866646566383432316233666564373534
|
66303630353430313961356234616438363436323938656466636264636364313431396165623530
|
||||||
33633031373730646365616431303138383434343930333031626162353533306131353833333363
|
33306334656338623237313333386135313961633666363031376334653433366330303262363832
|
||||||
35613633633962333261386234633534353236633737643232643436653739616665343962616130
|
32333139643434623031653137393134343961353261366464383537643237623166396431356430
|
||||||
64356663626636616462663464633765386133386430663266623530383931616233623235663332
|
64356435356566336461663766393537303737393139653132363737343238366361316463306136
|
||||||
61363037383934376663343132623134373663646130636537353162633430643434623837633330
|
38313365333863303734336162666130393531356531623865356431653237343432613238633566
|
||||||
36393837656239366436666532353730386335633264333937363763623266613339333937323731
|
35323166623935336361653530373466653063383339396466356334663066393539356432616362
|
||||||
30663930303864393731613430376661346666636161626338643762623961383966333863336234
|
65643536316137326533363930333966666632393334333536326431633962353362313532383033
|
||||||
34623966636364366664633264326433383964666530636531346335623066343536376464366365
|
63376566643166353136353733353862643033363035353534643062303133633064366332623938
|
||||||
31313038653338613436383839303966336231623236656238613836643634663331346636366537
|
33363862306263663737616364633462363736666530613562643865393466656131663663663166
|
||||||
63613932666438633030323839663331666239346432336234626565666139356332393736616234
|
33313839623739386565323133323263613939623838396365313265363466666235316263313535
|
||||||
34336663343661346663353839633831306465356461353035333530623663616439346361616164
|
64393936396332376635343362396564346634336634336663383566376365353836383739333665
|
||||||
36353261373930323830346237653566393337346338326163663130343934363834343862323331
|
30653038346161623536633361363961623931326265666566316565636662633032663639373361
|
||||||
34343062386332666137313765643733366331636139343130666665623337386130656462633262
|
62653665366338656532373734663138353238356535353036336331383635306236656130613633
|
||||||
39303239343665313665326538643062653564363864396437616330396465643262353236316138
|
34323962316636333664616664313163643061623836316432366664623164303365613161653466
|
||||||
33316565613730613965363732336138353137623839336234313836373135326436333462373438
|
39333538343435383066343336313564386136363363326231353838373437643136616336393561
|
||||||
32326539383536636463356331336264316562393263623735393037393637346664643330363664
|
62626430393735326366353839393166313030376435303039643739373337653635316466643236
|
||||||
36366130316333353861376466353035646562333063343862303734663061373831613337343065
|
64646237303064306663396439316431393931626431616630396538626639636432326561653562
|
||||||
36316136623664663631373333386430373734633236643832383566653231333430386137313262
|
61363338623863363731356566306231366336363739623735313134613933373238303765386234
|
||||||
39663736613736323263353934306262363563653939656265373630623837303933353335343931
|
61363034313139626636643533643738663266343235333465663632343533646131393130666632
|
||||||
61616537646634613131373362386230323037383239323866666461653839623063343239346336
|
31366562643166616430613030333832303636616231646239646435616530343032376663653031
|
||||||
34653835396331623363326163396636666433353036346433323266353065376531366462333431
|
65636561353734656562626463643361633362313861373233346633636438653335323761373165
|
||||||
33383565346632643333363139386437656666326637313736313033656130666531313234313533
|
31613130306437346630313466393735643434376133336565656566323132653962636630313431
|
||||||
66356539396666633534323036656366343862623730393333393563303035613334356661343030
|
30386136643462306261383135336362356161666435323132626637346365396436653835363733
|
||||||
34623536353730363933303464363464386333303034333764633762613839643231356534633736
|
33393732633464626437366439303931623962313433626533363136653939316633326432356565
|
||||||
39396635343961363634313737623838623566313336663339636461643633656438623666663863
|
62393332656238303763363732623163653663373536393265343763373365316337323564353064
|
||||||
35623338306661303532633164623363396166646165393832626336376339666532356662323161
|
31616634653837396438303938343562613761363766616561643032643065346238383734376433
|
||||||
31653435633830343339636533383734383531376439316530363637613439643338653166656637
|
34613931366331366138346636376566646539303430353665626263643465313133323939613361
|
||||||
31636165623935323337326465653164376662383461396435383662663163613863393935363464
|
30613063656665666439646362656136656439643934623730383736353031363633653036643161
|
||||||
37393333653930363035303738396164663064383166633037633137343362633134386339633864
|
39366163663035663135343462336564383862336531656138313431316263653064306334633062
|
||||||
64336637306431653363306231363537353064346464323264346365363834353033656261613463
|
39386538616361623265663536636130383362633934313764373538643636353237323133303266
|
||||||
66393133653461656430623938383163636631646432623763633933363638616263316666343435
|
65613765313838363236656334663635636264356331666134643063636334633131353734623632
|
||||||
34633436333861343262303537626664633232393433396633383939316339366433623062346337
|
38316637306163363839366536333365663337383632376230373137386535303666343362316663
|
||||||
65643931623133343161343630656365313165623734646131626563306632343562306339656564
|
62663333313634313361373463313566363063363665323565633935303738316135393264663935
|
||||||
36613137313230623538336539373062386663343431356537363335626161363032343633666132
|
63643662333430633535663130343930373763366430306337656162383537376564653439343430
|
||||||
38343934633562316136363232346361613965323134623962383031626264656238373761653330
|
35656430366365636165386333663566313034646564636261333361373030663633373861323065
|
||||||
32323537613335343633356162383533303434336130363564663933326632333935306530636133
|
33363463303135336436613563633135353531623339616530353538356130656263383265353735
|
||||||
64376535346632636462646337343539393339643666353033306538626363366264376331356433
|
66383964313665623133303130323335393339663331323431363438613637636665623634313862
|
||||||
66356534336235306565616631386661336639626265313638623635373931613435633736353064
|
66336533626266343831363732613331653431306462313830343830336339663636396566343134
|
||||||
31323534393239643636616361343236363730623036646436636462376362623334616163653733
|
34396137396461376433653962613361323334393766303330633237623963653033363532613761
|
||||||
64346435303963353033623433326263666137653232393964356262313835336663616631626466
|
37316139336234366633343064313965393965353461383433366630633935336233383962616662
|
||||||
62656661336231353238323035373632313632373966356461393761396434363766653964633166
|
30356234663661343232633435346532313737376234623336366161303937623964656331633935
|
||||||
36383564613734306264363939323864343261343566323264363432666565613430623962363266
|
62333537303536333062613132646237373031663766623730666330666364313634366362363437
|
||||||
34316137376466653136653761316239363834366265623265376439393861343432393962656639
|
34643634303366633236363665643934323864303732613461326238323637393733393964663562
|
||||||
62363762333966653734656234316533626565343462656361666166346263323436323039313061
|
30386664636139313335333238373632353936646432326436666665326365646565383766316362
|
||||||
36633534656466353439653366653138323362633338663835316663656661333032346162333639
|
62376463626338643937663163336166663465353936626332303739393762313235323535366636
|
||||||
31366532356635343766363761333630326664643834633734353635643661353935366565626633
|
63666438636230383031333838613333633734623134363833383035623564613637633562323263
|
||||||
32346533643232323165333536626431653330643464643439663931383035323935613930623039
|
64356662376532333261316537656234636338393738613730656530613232373662363431393630
|
||||||
64396165306339646334346565313765623930383363336666643737336465643264393230666666
|
32323230346332613038373530643563353162366366636338623566373065373637623638333533
|
||||||
39663265663036353537663362313566666538373534633836646163636663663166663636336338
|
34633333663832393438616266653563623338626431636634303234333636613033333831316264
|
||||||
35663061646161363039663136663338383561333037333430313431393935356361303233303532
|
36333664366262343261373163373763373036653866663539343337323761633431313435323465
|
||||||
37613163613434346235313861343139363963366331636361616138633933353238646666633634
|
35386539636338613666393064303363303263323565613837623866633031303834363837626531
|
||||||
34313761313532636431383437636564373230646361373834376434313261323239353962306437
|
31363838326235643163353731333938386264323730323036333066326463326537613530373832
|
||||||
63303561346162666132326666643832633234343739356562366336353561303630663237626536
|
65303663613361336565643366316238336135396334303432393936336635306434653930643631
|
||||||
39623330323065653630353966616564333639356166646362306661323462613139323938306165
|
65393438643630313330383061643031343663393265646664356636313035313961383634316466
|
||||||
62393765373064353164663335343134343635383236656337383665613734343863383265356139
|
36633139373564623466386264303664373763386534326234323337633732633232346136366133
|
||||||
36656134623438376165646463643562396562323134363131343639356463393262313430663364
|
61363939336531633265396136623031396534363031393164646661383339653232393463636439
|
||||||
32613861326138623464363135326331346236333064376362636562343063616332363134313736
|
63306438613837313136646232333434323430643130313937376230613531383461306262366636
|
||||||
64363063313463333732333562303337656265643530613532366430396330633038386135643831
|
61633137643164653739343663393335373065386339393531343930373161376434356666306132
|
||||||
62633435386666336462623133383533396333303666656563643030306566346131303066663364
|
35383763613966353662333362396265326532303238656133316332363335346437306164653230
|
||||||
33366365306532643439616130366434376363306335316134303932663466643538356438633462
|
38333263323064653533373365336166613431303231373062376663343261663135623937373163
|
||||||
39383066343835613338653433653234333665633165353737616463663239383633616633623635
|
63613239343034303263306138376430613335643862303633343637373430336131623037373537
|
||||||
35303335376336623337373432336665356161306164306430653635643136326436353835323134
|
64323838306233396435656339306132623266363961643166303733373537333135303665336239
|
||||||
64653861643138636330323734663030336233383338656335363466376538363165616132636134
|
62623439623865363736343631613432633264363733353834386335333330313933383433346137
|
||||||
31656632616231616666376463326465393134366335393462643464343330303164336632646464
|
32386432393765356133353736306430376665626665663964313037323136326164653435383332
|
||||||
61303034363432636234376137643437306134306366623963363033306436613637616664646136
|
61346361313063333137356662383533623734633435646638363537303037393638316439316561
|
||||||
66373661313365373665383434353535666532306238363534306265346434353238626135356631
|
62376361363532623266383635323330633033333536636262386239353732633237663139393839
|
||||||
64333034373366316262306366333561306336313961373734386164373164353933613063356431
|
33633537353136376163613334393430663366636533616265633133373563653962393238373862
|
||||||
31363734623265636364633736643238626334336464663934323265646230653032306434396632
|
61376639663631323231376631353964643162616538353835363937663661313632333333373933
|
||||||
62323366633035393938346232346639316331346430633363643762393931656363313263633936
|
62323539643630303062333063386163656530623061613664376436623732386666336162396533
|
||||||
35386130333538616661393339633262363536323766623633383834326534323930623365623836
|
36343563643032356261363234663264303762663439363664353966313561623761336138313165
|
||||||
38653430303430373730396134623666623962323336643731636231363162326436313131623838
|
37633630663131313565346539396234363565646530386266666232306262663961663636326536
|
||||||
39356338623137633530363835323833623763346239623639393063356361613431313338623461
|
64653638643935636365313561313964653764363766623433353365376161623333356164346232
|
||||||
65393030363839386534643039613666303531363830653536303135623036326437643962373365
|
36646630666333383234343931343161646463633134316466616436336634336661356161303764
|
||||||
62363338303436346539326634623230303462303934303765626464373533623037653661373536
|
62646362333139373761333035386666663561643837623665656630393432303839666666303339
|
||||||
32613238663366633331393935343364633738633564393362373463636463306364663932663862
|
31646461613838353033343665396239616538356164333436353133653134313738343530313366
|
||||||
61303232373233363132626331323532343965353563633238363630373830663035306264376439
|
33646530373561633834383865393961353032656339633732646533363535343630613937323461
|
||||||
30663835326436393132303635663837663931633665633637343030383761333533383937616238
|
65363262643937373638633838343266323966623039616333656538363362333962353066323363
|
||||||
32666563373366633137613431646265633839393262616532653362376232616635663165626164
|
36326138323132333837343130363735623861616334343130363064303030306361336161303832
|
||||||
38356165383865363838616131666132323132353437396333323461303631633330373336616561
|
32343534623662393566653131363866343637373930643434326338333362646233383231636235
|
||||||
66613439373666343766666238306237616265323836633531666439383730396366366561633034
|
33636433346464363766376634636533366163333064313534313831636238376639393533616566
|
||||||
32306532623365313434343261656465323163383035353631636135646164383933383838623061
|
66333633613261336361366234643966663536653736323235663030626234386166663033376234
|
||||||
61376135356166623363393934306132323436623139643963396366363733626531326234616661
|
32623435383837336330616235313165636537623462626230346535663662386337366532363733
|
||||||
61616166343736633965333430353962653264376532396137303235333731313466633662626165
|
39306631626262623630393137653763326234366139626461643931613736653932316466396163
|
||||||
64373039316437316239626339643135373161623931626236633065306663383935613933323839
|
66643038343336326533366438356562333561616138383330643235663633336262303266613630
|
||||||
64643234633735316165396437643964326636303164666263356361303733393135386139636539
|
61346237313730303666346639366439313865663430643536336338313130656438613831383633
|
||||||
62656535633236353661393534636530643061653432623931373864333061613666623363313565
|
30613862363961373732333863636134623431626361396562393437613533383738613761363835
|
||||||
33613339663562353330323162343065623564323166623230393631393161346438663333356330
|
30363831363963623133653038383031323364346237326164313232363936656132353732373463
|
||||||
32356131306339383131656539623234306463623065613132323139353532303137396132396564
|
39383737303034346163623737353537646433366539663963313566363032343836383134636131
|
||||||
35313032353564313436383030326266646562376266633562323331646631636564386531653834
|
66363664336435376563343765623737366632383431656166303639366661633565623331613530
|
||||||
61373331363530353966373765386364326466396134353033386464626531333162386234653565
|
39396131613161663436333736666536656364663361626633333637316130646266346165323632
|
||||||
37646466623334666635313563356133316461616266666436646535373164353135653265323965
|
35623062353732616535373166336564383166663861613239373836646637616333613639613862
|
||||||
34623334303161306564356434636666396435363032616134393937383264633634383237636262
|
66396434333039393135623733653938336233613235363134636362666363303337383335303136
|
||||||
61383961333663363964616336383830363265356635656165653330653666656564313039373638
|
66313061323938643432623833363930303236633462363663323362626332363565656135363464
|
||||||
31343939656466323962336535366661323237336530333431636434313964393264636538623633
|
63376266653132333565616363383564353135656431393062393333626336663738366331383737
|
||||||
35313566663238623264623461383038633338313063323865633035393464646435366139616436
|
30626239366666366263353662333166303063386364366530653731353135313761343461646238
|
||||||
33383538646335356563626535633965626330353937363338376133643938353330356638313164
|
39353531656232616530633330393130663862623964363565326233643431343432313039333234
|
||||||
38663366623931373034376635646332356164626332616439666464653532396265626163373030
|
63336232336535373032383933393763623538353061396534373632323165666134333465303066
|
||||||
37663633633533373232623561393130303664356335636430663439346561663266616461386435
|
39333366303231363863333133643062333230316361623635656663323737643965663563376564
|
||||||
30336337623262383832653864316331373765366536383865366164623833633833353531313339
|
34343134306333386663356339616461646639303835333333623363383364666436643436343533
|
||||||
64373836663931376266383333616465646339376435623032383364633337663139616463333630
|
38373364363765343265663138616439316633623865343939353465353466613065653366336230
|
||||||
31363236643535666162653733346536363739343663313266353966666635633430633064356163
|
39636664303335316538326136356361633366373761333635613139633665616433386162623965
|
||||||
38613834616431373762323964396663306161313465306132663061336238323832663132626162
|
32636266303031336162626132363931363163313235363865353334616137643934353637643633
|
||||||
30393466363930363538383238383530323061313137303233383030656365616433316461333831
|
33626137376132353735303737343433323462343830633731396565383037313232313862396330
|
||||||
62353862353538336236336565646366383565356630346635646362363763626636653530306537
|
65383564386564353634636430393562623833373533626632333530336266633038663333663063
|
||||||
34306562366134313239336630376630643838366165323934616562373336313864643465633464
|
32313534666162666134373533313730663730333933613762303238323065363236363166326361
|
||||||
35396435356632386537356131643539326234343237303536356238313236656236383965393139
|
39633938373861366133316261303665356661393830366131343138623138616233623961386233
|
||||||
35643531623832616365643238666131636435323364303733623132613533383766313164616264
|
65356634333833313061313134333364306238623637333733666366373762383732663037643338
|
||||||
63636439393432356662336339356361613863326336626337636432636338393266346666643736
|
33333263356262633366346130386337396263646333636134333935343835386366306663663962
|
||||||
66353338643363653037656339613961333132326161313661356464336234666136663866336432
|
62306232653735656365643533653635366232326638386563393365376464653532656136656266
|
||||||
34613965643635613764363137626165633336313930616637356136663061393936366664636530
|
62393830656339393033343638643334313765383766336338363331386135373864383939643166
|
||||||
31303733383431366339646530333333343762613839623139313864393536343137643162313132
|
66316437666434643266326235663632363161663338336434313134643533383634633936336665
|
||||||
33653766346566363666643663663237316636396261343362646332633362353238383239353239
|
62653638346633336331333364623363376538373562663437373933323932663533333733633036
|
||||||
39343635323961616164363066386332356235613664303138303336653866343431373332636161
|
39386333653636646133366461633237656166396664623136633863653339633463346135393566
|
||||||
64303335626434383062653364663366373262376163383436323865616232373334353566616635
|
39316161613532386432306364333662313261346631636430363061376332633431356132663066
|
||||||
38363935633564653236626234663038613636303563656333656430393061353939653236363065
|
65643137303961383539663733653863346131396232346263393934326239383735353565376533
|
||||||
66656338393131656661656434323831373637626339336566653363366565376138663432666535
|
33356438336463323531326561326264396635623734663835363039613162613762393662363562
|
||||||
32393366313132323036383930303130393561323234613438336665663566653837353131323361
|
32646634643934393263616335633933386564373162303766376661323864306230376533633539
|
||||||
66656234616635663061376236306163616338353733356562346637386539613337653335343835
|
38363032353639633735356266616330396333373665373035643334336566636534383831323862
|
||||||
66313934323634663066616638363637393661386130613462376632633466333863343534646664
|
33623866386539376131393531653562643430663765316539613438663733366566366533393761
|
||||||
61313239313561643561653934623737353936613463306232653139623263343032383464346534
|
31303836636533656535383165323232353065663338646337323530363133393263653930666334
|
||||||
37363964646531353165383135636365666464643530656534316530616636316435343732356539
|
62323836353563366637383138383864323438353339653434306532336164306238633437646235
|
||||||
30633361393162646430383263646362316161623331633831646235386137623264623533616566
|
64366536393039323339616130663939393562363935633435373932396462323533363838356663
|
||||||
38333866356137323764653830643964333964643635306235353938393338333137303434376330
|
34343138353130383734363234366661386638653834643661353031353031343562613461393966
|
||||||
34333136303266653135653136663166633830386662663064393937666539303463303938386235
|
31386463366635353965386462653461653866393864613063636564313638623734663864333334
|
||||||
33616432303730323531383034653062306465386562383630393239333733643839623936336563
|
36386239336431316338643761363566636137333161663833623633376262623830383961613931
|
||||||
65636265316662373833333131376163326437663664633163323433316164356536323633663463
|
33666136653631663131626631643266656430356364343138353663326435656663373035333934
|
||||||
32313733666532383436383431366161633563363230383561646134666339653962633739333631
|
33636339386639666532333763393735386231376666323733333030353239373333383264323130
|
||||||
66393438393566316233663431366562303733383431386630326438366134366162343230646336
|
35303437616433393231653739653230326263616233373166316237346138336364353339653339
|
||||||
63363431666465393638346330626364646465646165626636656137613261346463623261653736
|
33353132636262323030643465313231393763613861343734363031386563646337336562336463
|
||||||
66383037633130653261376463616237376665616536346165366365653363613163373139333435
|
37303232663235313335336435333032626432633531306638313561646236616230306135313639
|
||||||
38653662643861616565346631333034616562666166633634356561366161386630386263333637
|
63633938326266656138383635653339393238303964386663366638643734633866333432373737
|
||||||
61353335626531386564383662626662343366303961383463393236636561393461626464313739
|
34636433613566366132613864643135356136336162643066633035663533666237376437333762
|
||||||
65326563393831313436353263626238623131353338643265303565623564656131343936333439
|
64376262633064333635623438353732376466366338323636643662323234326139316534646533
|
||||||
37356363356266396539633162616431303036343839303736313038303832376361626231653437
|
33666432303636373963666334376263636634323532643837316361376338616430346464656465
|
||||||
30353838653462333462336538613339343436353133313935626363373138353161633062313666
|
33373336653539303163336239613038386230363839353532336335303564306363666665333530
|
||||||
37626632346263656264363437386136623761333238353165653336356130623166643032356230
|
63393863363232653138356234323161323931353932326137653430363836323863393035356230
|
||||||
38376232616234316139363230326330623839306634623961623737653333646331623664346265
|
61633161623761303335646435383934663135633734346433643639383932643662393264626430
|
||||||
64396136363465363765326466306431643839383561336266623765666336643539373433343938
|
38303735343033303765316365353834633461386636653662393538643339346230643335656438
|
||||||
37343564656631646463613562626231616631643764633832306366623633396336373734646461
|
66386164326535646335356661383735366134613939636130383034633734313739333635376233
|
||||||
65303139623334336339653439303331353137333337643835396135626237626163346662363066
|
35623130353239323237386337623962616536643463376632613631646436353630366137343838
|
||||||
31323962393038343562633963623834363963356531656637343130386366646335313031383337
|
30376136616261303235643437613864613031346564363465646533363033303566613061363330
|
||||||
65376266383139633034353836323838393230383637303366346630326636353831643037303538
|
63306530323938383737313438333139363862366331336665623139626135353236316664643661
|
||||||
39383261393466316633353136646539353232663161346666336435303734373066633732653666
|
38343165383537643136623661663730383635376263346131303332646464376263303464373734
|
||||||
38343062393037383131316232373439316437343638363536373930373633323837376331306162
|
62366331316166633631663163306237343862306365326339653363336638633830646233363665
|
||||||
3932356466613238643533633839343335366430383837633864
|
61393461326535653736363834346163343736666665623838643937306539376266393761633263
|
||||||
|
39316163356262633137313261353463373166633735663035663135326437383534616239343661
|
||||||
|
64633762613833653365353234363539643533353239316238636537323064353337316361353630
|
||||||
|
39343463643236656431623861333734313838346430356630656131383763386534373635666331
|
||||||
|
34333137346266353265376431623935336366623461666237666334616634613162623761346161
|
||||||
|
35366334313837333939306563316236313839623236373234623635656138656165313662656632
|
||||||
|
31343961363864346438333932656264363932646132366136663339363930386435666639663135
|
||||||
|
61313037303762626537393330366131616532646666613338393032353837643835353436383539
|
||||||
|
30323064656161636534653664336232613565626162383035636131393539386633643436653633
|
||||||
|
37343339663531396533323432643464393431623338343537646138653236366331366261663631
|
||||||
|
65386666313531323031353762363437653561653534316463666539636439383361373861646331
|
||||||
|
66626434326566653534393964643938393463303930613734336161356431363230363261383139
|
||||||
|
38356663323539636533663237306566623062333036666636663637306332626139333738386365
|
||||||
|
38363635386439646363626631366430393636613763646539303939643764643966353461343466
|
||||||
|
39363331343334653637656561383638626637646230346665393366313366633366653839663839
|
||||||
|
39333263313566626135326633653238626566633536353461376462613462303830353638393161
|
||||||
|
36366433656261313436663466366433663138613561386336383262646663313836633564353266
|
||||||
|
61623139323961346564623939303339366233643231373031626165646364376138383935626166
|
||||||
|
38373833613033633630313965336438626363353031346630376532393932353961363732623631
|
||||||
|
35613038366664663337653230653134373861666430343666623165306130306137383163396235
|
||||||
|
66633035353534386262663336646361303236623365613736383938333261653462386636376531
|
||||||
|
37616539376637386462373063316535636365626634613462623232333435313733326261373230
|
||||||
|
66373061336231663230386133613665396263646563376534313830316439623363653165613666
|
||||||
|
62373063633863656566643333636461353339396635396331633734623266306430
|
||||||
@@ -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
|
||||||
@@ -85,6 +85,13 @@ paperclip_server:
|
|||||||
ansible_user: wed
|
ansible_user: wed
|
||||||
ansible_become: true
|
ansible_become: true
|
||||||
|
|
||||||
|
multica:
|
||||||
|
hosts:
|
||||||
|
muppet-vision:
|
||||||
|
ansible_host: 10.1.71.136
|
||||||
|
ansible_user: wed
|
||||||
|
ansible_become: true
|
||||||
|
|
||||||
jmri_server:
|
jmri_server:
|
||||||
hosts:
|
hosts:
|
||||||
main-street-station:
|
main-street-station:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Deploy Multica + Caddy
|
||||||
|
hosts: multica
|
||||||
|
become: true
|
||||||
|
roles:
|
||||||
|
- role: multica_docker
|
||||||
@@ -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' }}
|
||||||
@@ -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())
|
||||||
Reference in new issue
Block a user