Compare commits
22 Commits
65abfa2ed9
...
3770815fbf
| Author | SHA1 | Date | |
|---|---|---|---|
| 3770815fbf | |||
| bf1b68275a | |||
| c5460a169c | |||
| b6b7e8e86c | |||
| 1c4ac98504 | |||
| 401b6c3654 | |||
| 813f024cdb | |||
| f252c1b50e | |||
| 7f0e1b0375 | |||
| d8fa4ff64e | |||
| 20ef6c459b | |||
| 31e3f5a099 | |||
| 643985d2a8 | |||
| e993786700 | |||
| 6c212a0d2c | |||
| ff8dda06d0 | |||
| 2aac366f32 | |||
| 206ac6219d | |||
| 18a64f5c56 | |||
| 43d319fd5a | |||
| 27661c816e | |||
| e4d56b831f |
75
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
75
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something broke. Help us fix it.
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for filing a bug. Please attach a diagnostic bundle if possible — it speeds up triage by 10x. Generate one via Settings → Help → Generate diagnostic bundle.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Lumotia version
|
||||||
|
description: Settings → Help. e.g. v0.1.0
|
||||||
|
placeholder: v0.1.0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
options:
|
||||||
|
- Linux (Fedora)
|
||||||
|
- Linux (Ubuntu LTS)
|
||||||
|
- Linux (other)
|
||||||
|
- macOS Apple Silicon
|
||||||
|
- macOS Intel
|
||||||
|
- Windows 11
|
||||||
|
- Windows 10
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: A clear description of the bug.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: What did you expect to happen?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
placeholder: |
|
||||||
|
1. Open Lumotia
|
||||||
|
2. Click record
|
||||||
|
3. ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: diagnostic-bundle
|
||||||
|
attributes:
|
||||||
|
label: Diagnostic bundle
|
||||||
|
description: Drag-and-drop the .zip from Settings → Help → Generate diagnostic bundle. Never includes audio or transcripts.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extras
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: Screenshots, logs, related captures.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: General questions
|
||||||
|
url: https://github.com/jakeadriansames/lumotia/discussions
|
||||||
|
about: For general questions or design discussions, use Discussions instead of Issues.
|
||||||
104
.github/ISSUE_TEMPLATE/v0.1-tester-feedback.yml
vendored
Normal file
104
.github/ISSUE_TEMPLATE/v0.1-tester-feedback.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
name: v0.1 Tester feedback
|
||||||
|
description: You tried Lumotia v0.1. Tell us how it went.
|
||||||
|
title: "[Tester feedback] "
|
||||||
|
labels: ["v0.1-tester", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for trying Lumotia v0.1. This template captures the structured feedback the tester-onboarding-kit asks for.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
options:
|
||||||
|
- Linux (Fedora)
|
||||||
|
- Linux (Ubuntu LTS)
|
||||||
|
- Linux (other)
|
||||||
|
- macOS Apple Silicon
|
||||||
|
- macOS Intel
|
||||||
|
- Windows 11
|
||||||
|
- Windows 10
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: cold-setup
|
||||||
|
attributes:
|
||||||
|
label: Cold-setup pass (steps 1–4)
|
||||||
|
description: Did you reach "Ready to record" without coaching?
|
||||||
|
options:
|
||||||
|
- "Yes — clean"
|
||||||
|
- "Yes — but I needed help at one step"
|
||||||
|
- "No — I would have given up"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: warm-activation
|
||||||
|
attributes:
|
||||||
|
label: Warm-activation pass (steps 5–10)
|
||||||
|
description: Did you complete your first real recording within 3 minutes of opening the app?
|
||||||
|
options:
|
||||||
|
- "Yes — under 3 min"
|
||||||
|
- "Yes — but it took longer than 3 min"
|
||||||
|
- "No — I got stuck"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: confusing
|
||||||
|
attributes:
|
||||||
|
label: What confused you?
|
||||||
|
placeholder: One step you had to re-read, one button you couldn't find...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: broken
|
||||||
|
attributes:
|
||||||
|
label: What broke?
|
||||||
|
placeholder: Errors you saw, things that didn't work as expected...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: cleanup-tasks
|
||||||
|
attributes:
|
||||||
|
label: Did the cleanup + task extraction make sense?
|
||||||
|
placeholder: Was the cleaned transcript better than the raw one? Were the extracted tasks useful?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: would-use-again
|
||||||
|
attributes:
|
||||||
|
label: Would you use Lumotia again next week?
|
||||||
|
options:
|
||||||
|
- "Definitely"
|
||||||
|
- "Probably"
|
||||||
|
- "Maybe"
|
||||||
|
- "Probably not"
|
||||||
|
- "Definitely not"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: would-pay
|
||||||
|
attributes:
|
||||||
|
label: Would you pay £39 for a Founding Licence?
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
- "Maybe with one or two changes"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: activation-log
|
||||||
|
attributes:
|
||||||
|
label: Activation log (optional)
|
||||||
|
description: Settings → Privacy → Activation log → click rows to copy. Local-only by default — only paste if you're comfortable.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
## What this PR changes
|
||||||
|
<!-- One or two sentences in user-facing voice. Not commit-log style. -->
|
||||||
|
|
||||||
|
## Why
|
||||||
|
<!-- The user-facing reason. Bug fix? New feature? Hygiene? -->
|
||||||
|
|
||||||
|
## How tested
|
||||||
|
- [ ] `cargo test --workspace` green
|
||||||
|
- [ ] `cargo fmt --check` clean
|
||||||
|
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean
|
||||||
|
- [ ] `npm run check` 0/0
|
||||||
|
- [ ] `npm run test` green
|
||||||
|
- [ ] `scripts/dogfood-rebrand-drill.sh` 8/8 (if migration / data-dir touched)
|
||||||
|
- [ ] Manual UI walk-through (if frontend touched)
|
||||||
|
|
||||||
|
## v0.1 scope check
|
||||||
|
- [ ] Confirms or extends an item in `docs/release/v0.1-checklist.md`
|
||||||
|
- [ ] Does NOT touch any item in the v0.2 / v0.2-garden-roadmap scope
|
||||||
|
- [ ] Does NOT remove any privacy invariant (no telemetry, no exfiltration, no AI-driven feature additions outside the locked scope)
|
||||||
|
|
||||||
|
## Notes for the reviewer
|
||||||
|
<!-- Architectural decisions, surprising choices, follow-up TODOs. -->
|
||||||
46
.github/workflows/build.yml
vendored
46
.github/workflows/build.yml
vendored
@@ -14,13 +14,12 @@
|
|||||||
# Promote the draft to a release when ready.
|
# Promote the draft to a release when ready.
|
||||||
#
|
#
|
||||||
# Signing:
|
# Signing:
|
||||||
# - macOS code-signing not configured. The .dmg will trigger Gatekeeper
|
# - macOS: signing + notarisation activate automatically when the
|
||||||
# warnings on the first run; users will need to right-click → Open.
|
# APPLE_* secrets are set in the repo. Builds unsigned if absent.
|
||||||
# To wire signing later, set APPLE_SIGNING_IDENTITY +
|
# See docs/release/code-signing-setup.md for the cert walkthrough.
|
||||||
# APPLE_CERTIFICATE secrets and uncomment the env block.
|
# - Windows: signing activates automatically when WINDOWS_CERTIFICATE
|
||||||
# - Windows code-signing not configured. The .exe/.msi will trigger
|
# + WINDOWS_CERTIFICATE_PASSWORD are set. Builds unsigned if absent.
|
||||||
# SmartScreen warnings on first run. To wire signing later, set
|
# See docs/release/code-signing-setup.md for the cert walkthrough.
|
||||||
# WINDOWS_CERTIFICATE + WINDOWS_CERTIFICATE_PASSWORD secrets.
|
|
||||||
name: build
|
name: build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -48,6 +47,7 @@ jobs:
|
|||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
artifact_glob: |
|
artifact_glob: |
|
||||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||||
|
src-tauri/target/release/bundle/appimage/*.AppImage.sha256
|
||||||
src-tauri/target/release/bundle/deb/*.deb
|
src-tauri/target/release/bundle/deb/*.deb
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
artifact_glob: |
|
artifact_glob: |
|
||||||
@@ -143,12 +143,20 @@ jobs:
|
|||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# Uncomment when signing certs are configured in repo secrets:
|
# macOS code-signing + notarisation. Builds unsigned if these secrets aren't set.
|
||||||
# APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
# Set via: gh secret set APPLE_SIGNING_IDENTITY --body "..." (etc.)
|
||||||
# APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
# See docs/release/code-signing-setup.md for the cert procurement walkthrough.
|
||||||
# APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
# WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
# WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
# Windows code-signing. Builds unsigned if these secrets aren't set.
|
||||||
|
# Set via: gh secret set WINDOWS_CERTIFICATE --body "..." (etc.)
|
||||||
|
# See docs/release/code-signing-setup.md for the cert procurement walkthrough.
|
||||||
|
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||||
|
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
# If pushed as a tag, use the tag name; otherwise leave empty
|
# If pushed as a tag, use the tag name; otherwise leave empty
|
||||||
# so tauri-action builds artifacts but does not touch releases.
|
# so tauri-action builds artifacts but does not touch releases.
|
||||||
@@ -159,6 +167,18 @@ jobs:
|
|||||||
# Build all bundle types the OS supports.
|
# Build all bundle types the OS supports.
|
||||||
args: ''
|
args: ''
|
||||||
|
|
||||||
|
- name: Compute AppImage SHA-256
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src-tauri/target/release/bundle/appimage
|
||||||
|
for img in *.AppImage; do
|
||||||
|
sha256sum "$img" > "$img.sha256"
|
||||||
|
echo "Generated: $img.sha256"
|
||||||
|
cat "$img.sha256"
|
||||||
|
done
|
||||||
|
|
||||||
# Always upload as an Actions artifact too — accessible from the
|
# Always upload as an Actions artifact too — accessible from the
|
||||||
# workflow run page even if the release-creation step was skipped.
|
# workflow run page even if the release-creation step was skipped.
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,3 +6,8 @@ dist/
|
|||||||
.firecrawl/
|
.firecrawl/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.cargo/
|
.cargo/
|
||||||
|
.lumotia-last-audit
|
||||||
|
|
||||||
|
# Python bytecode (from release scripts under scripts/)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable user-facing changes to Lumotia are documented here.
|
||||||
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-MM-DD <!-- replace with tag date on tag day -->
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Dictation with on-device speech recognition.** Speak naturally; Lumotia transcribes locally using Whisper or Parakeet. Six Whisper variants (Tiny through Distil-Large v3) and Parakeet for lower-latency English transcription. The first-run hardware probe selects the fastest-accurate pair for your machine. Models download once and run entirely offline.
|
||||||
|
- **Live streaming transcription.** Audio is processed as you speak rather than in one batch at the end, so you see results as they arrive. Speech-gated chunking and a duplicate-boundary filter keep the output clean.
|
||||||
|
- **Transcript cleanup without touching your original.** A rule-based pass removes filler words, collapses repetition, and applies consistent spelling. If you have a local LLM model downloaded, it runs a second pass for natural-language polish. Your raw Whisper transcript is always preserved — cleanup is additive, never destructive.
|
||||||
|
- **Automatic task extraction with a safe fallback.** Lumotia identifies action items from your transcript using either the local LLM or a rule-based verb-list extractor. If the LLM fails, the fallback runs silently and tasks still appear.
|
||||||
|
- **MicroSteps and a 5-minute focus timer.** Any extracted task can be broken into 3–7 concrete sub-steps. Select a MicroStep to start a 5-minute timer scoped to that one step, so you can work on one thing at a time.
|
||||||
|
- **History with full-text search.** Every transcript is indexed and searchable. Filter by date, tag, or keyword. Open any past transcript in the editor to review or correct it; edits autosave.
|
||||||
|
- **Markdown export with YAML frontmatter.** Export any transcript as a Markdown file ready for Obsidian or any plain-text workflow. One button, native save dialog.
|
||||||
|
- **Read-only MCP server.** An optional `lumotia-mcp` binary lets Claude Desktop, Cline, Cursor, or any MCP-compatible client read your transcripts and tasks over a local stdio connection. It cannot write, edit, or delete anything.
|
||||||
|
- **First-run onboarding flow.** A short guided setup covers microphone selection, model download, and a practice recording with a pre-supplied prompt, so you know what to say.
|
||||||
|
- **Per-profile custom vocabulary.** Add domain-specific terms to a profile and Lumotia feeds them to the transcription engine as hints, reducing misrecognition of names and jargon.
|
||||||
|
- **Content tags and topic suggestions.** Lumotia suggests tags from your transcript. Promote them to your manual tag list with one click, or ignore them.
|
||||||
|
- **Keyboard-navigable throughout.** The full capture flow — record, review, extract tasks, start a timer, export — is completable without a mouse. Focus rings are visible on every interactive element. `prefers-reduced-motion` is respected app-wide.
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
|
||||||
|
- All transcription, cleanup, and task extraction runs on your device. No audio, transcript, or task data is sent anywhere.
|
||||||
|
- No telemetry, no analytics, no crash reports leave the machine unless you explicitly bundle one for a support request.
|
||||||
|
- An optional local activation log records when the hotkey fires. It never leaves your machine and can be disabled in Settings → Privacy.
|
||||||
|
- The optional MCP server is read-only and communicates over stdio only — no network listener, no remote access. Enabling it gives your MCP client read access to your full transcript history; treat it accordingly.
|
||||||
|
- Full disclosure: `docs/release/privacy-and-ai-use.md` (link added when that page lands).
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
See `docs/release/v0.1-known-limitations.md` for the full list. Brief summary:
|
||||||
|
|
||||||
|
- **macOS App Nap** may pause Lumotia when its window loses focus during long sessions. The protection code is in place but not yet verified on Apple Silicon hardware.
|
||||||
|
- **Linux idle inhibit is not wired.** The compositor may lock or suspend during a long dictation session. Workaround: launch with `systemd-inhibit --what=idle:sleep lumotia` or raise your system screen-lock timeout.
|
||||||
|
- **Windows sleep prevention is not yet implemented.** Set your active power plan's sleep timeout to "Never" while dictating.
|
||||||
|
- **If the local LLM hangs mid-generation**, the status indicator may stay on "Cleaning up" until you restart the app. Your transcript is preserved and the rest of the app continues to work.
|
||||||
|
- Cloud transcription (OpenAI Whisper API and equivalents) is not available in this release.
|
||||||
|
- The MCP server grants read access to your entire transcript history with no per-row permission boundary. A finer-grained permission system is planned for a later release.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/jakeadriansames/lumotia/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/jakeadriansames/lumotia/releases/tag/v0.1.0
|
||||||
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Lumotia — a local-first, cognitive-load-aware dictation + task-capture desktop app. Tauri 2 + Svelte 5 frontend, Rust workspace backend. Pre-alpha, daily-dogfooded on Linux/Wayland (KDE). Full product context lives in [README.md](README.md) and [docs/brief/](docs/brief/); design principles are non-negotiable and codified in [docs/whisper-ecosystem/lumotia-context.md](docs/whisper-ecosystem/lumotia-context.md).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Dev launch (Vite + Tauri, with supply-chain pre-flight):
|
||||||
|
```bash
|
||||||
|
./run.sh # canonical; also: npm run dev:tauri
|
||||||
|
npm run dev:frontend # frontend-only iteration, no Tauri
|
||||||
|
```
|
||||||
|
|
||||||
|
Install: `npm ci --ignore-scripts` (never bare `npm install` — `--ignore-scripts` blocks the npm-worm postinstall vector).
|
||||||
|
|
||||||
|
Tests + checks:
|
||||||
|
```bash
|
||||||
|
cargo test --workspace # all Rust tests (220+ lib, 67 Tauri-app)
|
||||||
|
cargo test -p lumotia-transcription # single crate
|
||||||
|
cargo test -p lumotia-llm <test_name> # single test (substring match)
|
||||||
|
cargo check --workspace --all-targets # what CI runs
|
||||||
|
cargo fmt --check # release gate
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings # release gate
|
||||||
|
npm run check # svelte-check, jsconfig-driven
|
||||||
|
npm run test # vitest (jsdom; *.test.ts beside source)
|
||||||
|
npm run test -- src/lib/foo.test.ts # single vitest file
|
||||||
|
```
|
||||||
|
|
||||||
|
Release build: `npm run tauri build`. CI builds installers on tag push.
|
||||||
|
|
||||||
|
Rebrand-migration end-to-end probe (Linux only — Tauri 2 ignores `HOME` overrides on macOS):
|
||||||
|
```bash
|
||||||
|
cargo build -p lumotia
|
||||||
|
scripts/dogfood-rebrand-drill.sh # sandbox mode
|
||||||
|
scripts/dogfood-rebrand-drill.sh --against-real-home # refuses if lumotia data exists
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture (the parts to internalise before editing)
|
||||||
|
|
||||||
|
Three layers, strict dependency direction: **Svelte (UI) → Tauri commands (OS bridge) → Rust crates (brain)**. The MCP server (`lumotia-mcp`) is a separate read-only binary that opens the same SQLite store — Lumotia-as-primitive for external agents.
|
||||||
|
|
||||||
|
Rust workspace (`crates/*` + `src-tauri/`) holds all logic. Tauri command modules in [src-tauri/src/commands/](src-tauri/src/commands/) are thin adapters and should not contain business logic. The 22 command modules map roughly 1:1 to a subsystem (audio, llm, transcription, profiles, tasks, hotkey, paste, windows, …). The utility-only modules in the same directory (`mod`, `power`, `security`) carry no `#[tauri::command]` attribute — don't add them to the invoke handler.
|
||||||
|
|
||||||
|
Engine abstractions: `LocalEngine` in `lumotia-transcription` wraps both Whisper (`whisper-rs`) and Parakeet (`transcribe-rs` ONNX) behind a common `Transcriber` trait. LLM surfaces (`cleanup_text`, `decompose_task`, `extract_tasks`) in `lumotia-llm` use GBNF grammars to guarantee parseable JSON. Prompt-injection-hardened cleanup prompt lives in `lumotia-ai-formatting::llm_client::CLEANUP_PROMPT`.
|
||||||
|
|
||||||
|
Storage is SQLite via `sqlx` 0.8 in `lumotia-storage`, with FTS5 for transcript search. The MCP binary opens this store read-only.
|
||||||
|
|
||||||
|
### Frontend state model
|
||||||
|
|
||||||
|
Svelte 5 runes (`$state`, `$derived`, `$effect`) — no Svelte 3/4 store API. State lives in [src/lib/stores/](src/lib/stores/), one file per store. The central store is [page.svelte.ts](src/lib/stores/page.svelte.ts) — transcripts, profiles, taskLists, templates, etc. are fields on it. Secondary windows (`/float`, `/viewer`, `/preview`) use named layouts (`+layout@.svelte`) to skip the main shell and run chrome-free.
|
||||||
|
|
||||||
|
### Wiring contracts (enforced socially, not by the compiler)
|
||||||
|
|
||||||
|
- **Every new Tauri command** must be (a) implemented in `src-tauri/src/commands/<module>.rs`, (b) registered in the invoke handler in [src-tauri/src/lib.rs](src-tauri/src/lib.rs), and (c) called from the frontend. Forgetting (b) is the most common breakage.
|
||||||
|
- **Every Settings-visible setting** needs a type field in [src/lib/types/app.ts](src/lib/types/app.ts) and a default in [src/lib/stores/page.svelte.ts](src/lib/stores/page.svelte.ts).
|
||||||
|
- **Every new workspace crate** needs a `description` in its `Cargo.toml`.
|
||||||
|
- Smoke test per new command or crate module; workspace floor is "no regressions on main."
|
||||||
|
|
||||||
|
## Platform notes that affect code
|
||||||
|
|
||||||
|
- **Wayland is a first-class target.** Don't assume X11. The preview overlay uses `WindowTypeHint::Utility`, never steals focus, is pinned across virtual desktops, and is hidden from Alt+Tab. The paste matrix (`wtype` / `xdotool` / `ydotool` on Linux, AppleScript on macOS, SendKeys on Windows) handles the focus-race against the overlay — see [src-tauri/src/commands/paste.rs](src-tauri/src/commands/paste.rs).
|
||||||
|
- **Linux hotkey is evdev**, not `tauri-plugin-global-shortcut`. Implemented in `lumotia-hotkey`. Requires the user to be in the `input` group; the crate surfaces this as a clear error when `/dev/input/event*` is inaccessible.
|
||||||
|
- **`run.sh` owns Linux rendering env vars** (`LIBCLANG_PATH`, `WEBKIT_DISABLE_DMABUF_RENDERER`, `GDK_BACKEND=x11` on Wayland to dodge a webkit2gtk issue). Anything that pre-checks these in `lib.rs` is checking what the launcher set, not the user's shell.
|
||||||
|
- **CI runs on all three OSes** (`.github/workflows/`) — Linux/macOS/Windows. macOS and Windows are CI-validated but not runtime-tested; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for tracked gaps (App Nap on Apple Silicon, idle-inhibit on X11/Linux, sleep prevention on Windows).
|
||||||
|
|
||||||
|
## Privacy invariants (non-negotiable)
|
||||||
|
|
||||||
|
No voice, transcript, or task data leaves the machine unless the user explicitly sends it. No telemetry, no analytics, no crash-reporting service. Cleanup is **additive** — raw Whisper transcript must always be recoverable. LLM scope is narrow: transcription cleanup + task extraction only. Not a wake-word agent, not a chat UI, not a multi-provider cloud fan-out. If a change risks any of this, surface it before implementing.
|
||||||
|
|
||||||
|
## v0.1 release scope (active gate)
|
||||||
|
|
||||||
|
The v0.1 ship gate lives in [docs/release/v0.1-checklist.md](docs/release/v0.1-checklist.md); the UI hardening boundary is pinned in [docs/release/v0.1-ui-hardening.md](docs/release/v0.1-ui-hardening.md). All release docs live under [docs/release/](docs/release/) — use that folder as the source of truth. Before adding a feature, check the **Out of scope for v0.1** list — reopening a v0.2-flagged item moves the ship date. Garden Inbox, full Settings 7-group regroup, OpenAI Whisper API BYOK, OEM verification, Obsidian plugin, and the Phase B filter-chain refactor are all explicitly v0.2+.
|
||||||
|
|
||||||
|
The release acceptance test is the 10-step tester flow (install → onboarding → record → cleanup → task → MicroSteps + timer → history search). Warm activation target: first real recording within 3 minutes of opening the app. UI hardening is a *hardening* pass, not a redesign — every item must be testable, not aesthetic.
|
||||||
|
|
||||||
|
## Where to look first
|
||||||
|
|
||||||
|
- Latest session context: [HANDOVER.md](HANDOVER.md), then dated handovers in [docs/handovers/](docs/handovers/).
|
||||||
|
- Tracked limitations + per-step failure-mode catalogue: [KNOWN-ISSUES.md](KNOWN-ISSUES.md).
|
||||||
|
- Per-platform dependency reference: [docs/dev-setup.md](docs/dev-setup.md).
|
||||||
|
- Product/strategy framing: [docs/brief/](docs/brief/) — especially `what-lumotia-is.md`, `design-principles.md`, `target-audience.md`.
|
||||||
|
- Active workstreams + 31-item research backlog: [docs/whisper-ecosystem/brief.md](docs/whisper-ecosystem/brief.md), `workstream-A.md`, `workstream-B.md`.
|
||||||
|
- GPU tuning roadmap: [docs/gpu-tuning/plan.md](docs/gpu-tuning/plan.md).
|
||||||
55
CONTRIBUTING.md
Normal file
55
CONTRIBUTING.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Contributing to Lumotia
|
||||||
|
|
||||||
|
## Welcome
|
||||||
|
|
||||||
|
Lumotia is a single-developer-led, AI-assisted, human-directed indie app. Jake Sames at CORBEL defines the product, sets the privacy model, writes the tests that matter, and ships through a repeatable quality gate. AI coding tools are used during implementation. Every release is dogfood-tested, every release ships a known-limitations document, and every release has an audit trail in the git log. Read `docs/release/how-lumotia-is-built.md` to understand the trust model before contributing.
|
||||||
|
|
||||||
|
## What we are looking for in v0.1
|
||||||
|
|
||||||
|
Bug reports and tester feedback are the most valuable contributions right now. **New feature work is paused until v0.2** — the scope is locked per `docs/release/v0.1-checklist.md`. Reopening a v0.2-flagged item moves the ship date. If you have a feature idea, open a Discussion rather than a PR.
|
||||||
|
|
||||||
|
## How to file a bug
|
||||||
|
|
||||||
|
Open an issue using the **Bug report** template. The template walks you through version, platform, steps to reproduce, and expected vs actual behaviour.
|
||||||
|
|
||||||
|
Please attach a diagnostic bundle — it speeds up triage significantly. Generate one from **Settings → Help → Generate diagnostic bundle**. The bundle never includes audio or transcript content; it contains logs, system info, and redacted preferences only.
|
||||||
|
|
||||||
|
## How to file v0.1 tester feedback
|
||||||
|
|
||||||
|
Open an issue using the **v0.1 Tester feedback** template. It covers the structured questions from the tester-onboarding-kit: cold-setup pass, warm-activation pass, confusion points, breakage, task-extraction quality, and whether you would use it again.
|
||||||
|
|
||||||
|
If you followed the onboarding kit, you already have the answers. The template takes about 5 minutes to fill in.
|
||||||
|
|
||||||
|
## How to submit a PR
|
||||||
|
|
||||||
|
Small, focused changes only. The PR template has a checklist — run every gate green before submitting.
|
||||||
|
|
||||||
|
Before submitting:
|
||||||
|
|
||||||
|
- `cargo test --workspace` green
|
||||||
|
- `cargo fmt --check` clean
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings` clean
|
||||||
|
- `npm run check` 0 errors / 0 warnings
|
||||||
|
- `npm run test` green
|
||||||
|
- `scripts/dogfood-rebrand-drill.sh` 8/8 (if you touched migration or data-directory logic)
|
||||||
|
- Manual UI walk-through (if you touched the frontend)
|
||||||
|
|
||||||
|
**Architectural changes require a Discussion first.** Opening a PR that restructures crates, reorganises commands, or changes the storage schema without prior alignment wastes both our time.
|
||||||
|
|
||||||
|
## Local dev setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --ignore-scripts # never bare npm install — --ignore-scripts blocks postinstall vectors
|
||||||
|
./run.sh # canonical dev launch (Vite + Tauri, with supply-chain pre-flight)
|
||||||
|
npm run dev:frontend # frontend-only iteration, no Tauri
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-platform dependencies (WebKit, LLVM, Rust toolchain, evdev headers) are documented in `docs/dev-setup.md`. The Rust toolchain is pinned in `rust-toolchain.toml` — you do not need to manage it manually.
|
||||||
|
|
||||||
|
## Code of conduct
|
||||||
|
|
||||||
|
This is a calm, professional project. No harassment, no personal attacks, no bad-faith contributions. The maintainer is one person — please be patient on response times. Issues and PRs that are rude or dismissive will be closed without comment.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Lumotia is licensed under **AGPL-3.0-or-later**. By submitting a pull request, you agree that your contribution is offered under the same licence. If that does not work for you, please say so before putting in the work.
|
||||||
278
Cargo.lock
generated
278
Cargo.lock
generated
@@ -8,6 +8,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
"cpubits",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -376,6 +387,16 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -452,6 +473,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bzip2"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||||
|
dependencies = [
|
||||||
|
"libbz2-rs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
@@ -591,6 +621,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.2.1",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clang-sys"
|
name = "clang-sys"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -620,6 +660,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmov"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -639,6 +685,18 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -739,6 +797,12 @@ dependencies = [
|
|||||||
"windows 0.62.2",
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -748,6 +812,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -812,6 +885,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.29.6"
|
version = "0.29.6"
|
||||||
@@ -862,6 +944,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctutils"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||||
|
dependencies = [
|
||||||
|
"cmov",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -937,6 +1028,12 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deflate64"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1028,8 +1125,21 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.0",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common 0.2.1",
|
||||||
|
"ctutils",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1408,6 +1518,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1790,10 +1901,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2029,6 +2142,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||||
|
dependencies = [
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac-sha256"
|
name = "hmac-sha256"
|
||||||
version = "1.1.14"
|
version = "1.1.14"
|
||||||
@@ -2108,6 +2230,15 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -2382,6 +2513,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "instant"
|
name = "instant"
|
||||||
version = "0.1.13"
|
version = "0.1.13"
|
||||||
@@ -2642,6 +2782,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libbz2-rs-sys"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fc329e1457d97a9d58a4e2ca49e3be572431a7e096008efc2e3a3c19d428f4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
@@ -2788,6 +2934,9 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
|
"windows 0.62.2",
|
||||||
|
"zbus",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2888,12 +3037,13 @@ dependencies = [
|
|||||||
name = "lumotia-storage"
|
name = "lumotia-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
|
||||||
"lumotia-core",
|
"lumotia-core",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2922,6 +3072,15 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69"
|
checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lzma-rust2"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae"
|
||||||
|
dependencies = [
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3627,7 +3786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
|
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac-sha256",
|
"hmac-sha256",
|
||||||
"lzma-rust2",
|
"lzma-rust2 0.15.7",
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3691,6 +3850,16 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbkdf2"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||||
|
dependencies = [
|
||||||
|
"digest 0.11.3",
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -4005,6 +4174,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppmd-rust"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -4983,6 +5158,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -4990,8 +5176,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6107,6 +6293,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
"itoa",
|
||||||
|
"js-sys",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -6499,6 +6686,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -7931,12 +8124,85 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "8.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"bzip2",
|
||||||
|
"constant_time_eq",
|
||||||
|
"crc32fast",
|
||||||
|
"deflate64",
|
||||||
|
"flate2",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"hmac",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"lzma-rust2 0.16.2",
|
||||||
|
"memchr",
|
||||||
|
"pbkdf2",
|
||||||
|
"ppmd-rust",
|
||||||
|
"sha1",
|
||||||
|
"time",
|
||||||
|
"typed-path",
|
||||||
|
"zeroize",
|
||||||
|
"zopfli",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.16+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
members = ["src-tauri", "crates/*"]
|
members = ["src-tauri", "crates/*"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
repository = "https://github.com/jakeadriansames/lumotia"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|||||||
@@ -14,25 +14,30 @@ Tracked limitations and partial implementations in the current codebase. Each en
|
|||||||
|
|
||||||
**Workaround:** Keep the app window focused, or move the cursor periodically. For testing, App Nap can be disabled globally with `defaults write NSGlobalDomain NSAppSleepDisabled -bool YES` (revert with `-bool NO`).
|
**Workaround:** Keep the app window focused, or move the cursor periodically. For testing, App Nap can be disabled globally with `defaults write NSGlobalDomain NSAppSleepDisabled -bool YES` (revert with `-bool NO`).
|
||||||
|
|
||||||
### KI-02 — Linux power assertion is a no-op
|
### KI-02 — Linux idle inhibit ✓ fixed in v0.1
|
||||||
|
|
||||||
**Status:** `PowerAssertion::begin` does nothing on Linux. The planned implementation (systemd-logind / GNOME idle inhibitor via `org.freedesktop.login1.Inhibit`) is described in the file-level doc but not wired up.
|
**Status:** Resolved. `acquire_idle_inhibit` now calls `org.freedesktop.login1.Manager.Inhibit`
|
||||||
|
via zbus (blocking, offloaded to `spawn_blocking`) on recording start, holding the returned file
|
||||||
|
descriptor. `release_idle_inhibit` closes the fd on recording stop, which atomically releases the
|
||||||
|
lock. The inhibit scope is `idle:sleep:handle-lid-switch` in `block` mode.
|
||||||
|
|
||||||
**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs).
|
If D-Bus is unavailable (non-systemd containers, exotic distros), the call fails gracefully with a
|
||||||
|
`tracing::warn!` and recording continues — the workaround below remains valid in those edge cases.
|
||||||
|
|
||||||
**Impact:** Long sessions can be paused by the compositor's idle hooks (screen lock, suspend timers) on KDE, GNOME, Hyprland, Sway, etc.
|
**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs) (`acquire_idle_inhibit`, `release_idle_inhibit`, `linux_inhibit` mod).
|
||||||
|
|
||||||
**Workaround:** Raise the system idle / screen-lock timeout while dictating, or wrap launch with `systemd-inhibit --what=idle:sleep:handle-lid-switch ./run.sh`.
|
**Workaround (edge cases only):** Wrap launch with `systemd-inhibit --what=idle:sleep:handle-lid-switch ./run.sh` if logind is not available.
|
||||||
|
|
||||||
### KI-03 — Windows power assertion is a no-op
|
### KI-03 — Windows sleep prevention ✓ fixed in v0.1
|
||||||
|
|
||||||
**Status:** `PowerAssertion::begin` does nothing on Windows. The planned implementation (`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED)` on begin, `ES_CONTINUOUS` alone on end) is described in the file-level doc but not wired up.
|
**Status:** Resolved. `acquire_idle_inhibit` now calls
|
||||||
|
`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` on recording start.
|
||||||
|
`release_idle_inhibit` calls `SetThreadExecutionState(ES_CONTINUOUS)` to restore normal behaviour.
|
||||||
|
Display sleep is intentionally NOT blocked — the user is dictating, not watching the screen.
|
||||||
|
|
||||||
**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs).
|
**Source:** [`src-tauri/src/commands/power.rs`](src-tauri/src/commands/power.rs) (`acquire_idle_inhibit`, `release_idle_inhibit`, `windows_inhibit` mod).
|
||||||
|
|
||||||
**Impact:** Long sessions can be paused by Windows sleep policies.
|
**Workaround (edge cases only):** If the power plan has a policy override that blocks `SetThreadExecutionState`, set the active sleep timeout to "Never" while dictating.
|
||||||
|
|
||||||
**Workaround:** Set the active power plan's sleep to "Never" while dictating.
|
|
||||||
|
|
||||||
## Cloud providers
|
## Cloud providers
|
||||||
|
|
||||||
|
|||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
59
README.md
59
README.md
@@ -6,9 +6,19 @@ Lumotia is a local-first, cognitive-load-aware dictation and task-capture deskto
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.1 release
|
||||||
|
|
||||||
|
- Release notes: [docs/release/v0.1-release-notes.md](docs/release/v0.1-release-notes.md)
|
||||||
|
- Known limitations: [docs/release/v0.1-known-limitations.md](docs/release/v0.1-known-limitations.md)
|
||||||
|
- How Lumotia is built (trust evidence): [docs/release/how-lumotia-is-built.md](docs/release/how-lumotia-is-built.md)
|
||||||
|
- Privacy + AI-use disclosure: [docs/release/privacy-and-ai-use.md](docs/release/privacy-and-ai-use.md)
|
||||||
|
- First-install warnings (per platform): [docs/release/install-warnings.md](docs/release/install-warnings.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Pre-alpha.** Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready. One primary user; open source-intent with licence TBD before public beta.
|
**Status: v0.1 release candidate.** See [docs/release/](docs/release/) for the ship checklist + known limitations. Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready.
|
||||||
|
|
||||||
- Current `main`: see commit log
|
- Current `main`: see commit log
|
||||||
- 9 library crates plus the Tauri app crate; 220+ lib tests plus 67 Tauri-app tests, all passing
|
- 9 library crates plus the Tauri app crate; 220+ lib tests plus 67 Tauri-app tests, all passing
|
||||||
@@ -269,6 +279,16 @@ choco install cmake llvm vulkan-sdk
|
|||||||
|
|
||||||
See [`docs/dev-setup.md`](docs/dev-setup.md) for the authoritative per-platform dependency list and for how `LIBCLANG_PATH` should be set.
|
See [`docs/dev-setup.md`](docs/dev-setup.md) for the authoritative per-platform dependency list and for how `LIBCLANG_PATH` should be set.
|
||||||
|
|
||||||
|
### Installing npm dependencies
|
||||||
|
|
||||||
|
Use `npm ci --ignore-scripts` rather than bare `npm install`. `--ignore-scripts` blocks the postinstall script vector that npm-worm attacks (Shai-Hulud, mini-Shai-Hulud) rely on. `ci` installs strictly from `package-lock.json`, refusing to mutate the lockfile silently.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
`run.sh` runs `npm audit signatures` automatically whenever `package-lock.json` is newer than the last successful audit, and refuses to launch on signature mismatch. Skip with `LUMOTIA_SKIP_AUDIT=1` for offline dev.
|
||||||
|
|
||||||
### Dev launch
|
### Dev launch
|
||||||
|
|
||||||
Canonical full-stack dev launch — starts Vite, waits for port 1420, then launches Tauri:
|
Canonical full-stack dev launch — starts Vite, waits for port 1420, then launches Tauri:
|
||||||
@@ -300,11 +320,36 @@ CI also builds release installers on tag push (see `.github/workflows/build.yml`
|
|||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace --lib # 220+ lib tests across 9 library crates
|
cargo test --workspace # all Rust tests (lib + integration)
|
||||||
npm run check # svelte-check (type-checks .svelte files)
|
npm run check # svelte-check (type-checks .svelte files)
|
||||||
|
npm run test # vitest run (frontend unit tests)
|
||||||
|
npm run test:watch # vitest watch mode
|
||||||
cargo check --workspace --all-targets
|
cargo check --workspace --all-targets
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Frontend test files live alongside source (`src/**/*.test.ts`) and run in
|
||||||
|
jsdom by default. See [vite.config.js](vite.config.js) for the vitest
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
#### Rebrand-migration dogfood drill
|
||||||
|
|
||||||
|
End-to-end probe that launches the real `target/debug/lumotia` binary
|
||||||
|
against synthetic legacy magnotia state planted on disk, then verifies
|
||||||
|
both migration paths produced the expected on-disk outcome.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p lumotia # need the binary first
|
||||||
|
scripts/dogfood-rebrand-drill.sh # sandbox mode (Linux only)
|
||||||
|
scripts/dogfood-rebrand-drill.sh --keep # leave sandbox dir for inspection
|
||||||
|
scripts/dogfood-rebrand-drill.sh --against-real-home # run against real $HOME
|
||||||
|
```
|
||||||
|
|
||||||
|
Sandbox mode is faithful only on Linux — Tauri 2 on macOS uses
|
||||||
|
`NSSearchPathForDirectoriesInDomains` which ignores `HOME` overrides. The
|
||||||
|
drill refuses to start in sandbox mode on macOS. Real-home mode refuses
|
||||||
|
to start if any lumotia data already exists at your real paths, so it
|
||||||
|
can roll back cleanly on exit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project documentation
|
## Project documentation
|
||||||
@@ -380,11 +425,17 @@ Pre-alpha status; contribution process TBD before public beta. For now:
|
|||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
To be finalised before public beta. Current intent: MIT or similar permissive licence, with Corbel Consulting offering optional commercial support / managed services as the revenue path.
|
AGPL-3.0-or-later. See [LICENSE](LICENSE) for the full text. The implementation is AI-assisted; the trust + audit framing is in [docs/release/how-lumotia-is-built.md](docs/release/how-lumotia-is-built.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
File issues at https://github.com/jakeadriansames/lumotia/issues — please include your platform, the Lumotia version (Settings → About), what you did, what you expected, and what actually happened. Crash dumps live at `<app-data-dir>/crashes/`; attaching the most recent one helps a lot.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
**Jake Sames** — [jakeadriansames@gmail.com](mailto:jakeadriansames@gmail.com)
|
**Jake Sames** — [jakeadriansames@gmail.com](mailto:jakeadriansames@gmail.com)
|
||||||
Repo: [github.com/jakejars/lumotia](https://github.com/jakejars/lumotia) · [git.corbel.consulting/jake/lumotia](https://git.corbel.consulting/jake/lumotia)
|
Repo: [github.com/jakeadriansames/lumotia](https://github.com/jakeadriansames/lumotia) · [git.corbel.consulting/jake/lumotia](https://git.corbel.consulting/jake/lumotia)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-ai-formatting"
|
name = "lumotia-ai-formatting"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Lumotia"
|
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Lumotia"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-audio"
|
name = "lumotia-audio"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Lumotia"
|
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Lumotia"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -370,7 +370,11 @@ fn open_and_validate(
|
|||||||
device: cpal::Device,
|
device: cpal::Device,
|
||||||
name: &str,
|
name: &str,
|
||||||
require_audio: bool,
|
require_audio: bool,
|
||||||
) -> Result<(MicrophoneCapture, VecDeque<AudioChunk>, mpsc::Receiver<AudioChunk>)> {
|
) -> Result<(
|
||||||
|
MicrophoneCapture,
|
||||||
|
VecDeque<AudioChunk>,
|
||||||
|
mpsc::Receiver<AudioChunk>,
|
||||||
|
)> {
|
||||||
let config = device
|
let config = device
|
||||||
.default_input_config()
|
.default_input_config()
|
||||||
.map_err(|e| Error::AudioCaptureFailed(format!("default_input_config: {e}")))?;
|
.map_err(|e| Error::AudioCaptureFailed(format!("default_input_config: {e}")))?;
|
||||||
|
|||||||
@@ -15,7 +15,5 @@ pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples> {
|
|||||||
resample_to_16khz(&audio)
|
resample_to_16khz(&audio)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| lumotia_core::error::Error::AudioDecodeFailed(format!("Task join error: {e}")))?
|
||||||
lumotia_core::error::Error::AudioDecodeFailed(format!("Task join error: {e}"))
|
|
||||||
})?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ pub fn decode_audio_file_limited(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
max_duration_secs: Option<f64>,
|
max_duration_secs: Option<f64>,
|
||||||
) -> Result<AudioSamples> {
|
) -> Result<AudioSamples> {
|
||||||
let file = File::open(path)
|
let file =
|
||||||
.map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
File::open(path).map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
@@ -41,8 +41,8 @@ pub fn decode_audio_file_limited(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
|
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
|
||||||
let file = File::open(path)
|
let file =
|
||||||
.map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
File::open(path).map_err(|e| Error::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||||
@@ -99,9 +99,7 @@ fn decode_media_stream(
|
|||||||
.ok_or_else(|| Error::AudioDecodeFailed("Unknown sample rate".into()))?;
|
.ok_or_else(|| Error::AudioDecodeFailed("Unknown sample rate".into()))?;
|
||||||
|
|
||||||
if sample_rate == 0 {
|
if sample_rate == 0 {
|
||||||
return Err(Error::AudioDecodeFailed(
|
return Err(Error::AudioDecodeFailed("Invalid sample rate: 0".into()));
|
||||||
"Invalid sample rate: 0".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let track_id = track.id;
|
let track_id = track.id;
|
||||||
@@ -128,9 +126,7 @@ fn decode_media_stream(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(Error::AudioDecodeFailed(format!(
|
return Err(Error::AudioDecodeFailed(format!("packet read failed: {e}")));
|
||||||
"packet read failed: {e}"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,9 +164,7 @@ fn decode_media_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if samples.is_empty() {
|
if samples.is_empty() {
|
||||||
return Err(Error::AudioDecodeFailed(
|
return Err(Error::AudioDecodeFailed("No audio data decoded".into()));
|
||||||
"No audio data decoded".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(AudioSamples::new(samples, sample_rate, 1))
|
Ok(AudioSamples::new(samples, sample_rate, 1))
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ impl StreamingResampler {
|
|||||||
INPUT_CHUNK,
|
INPUT_CHUNK,
|
||||||
1, // mono
|
1, // mono
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| Error::AudioDecodeFailed(format!("StreamingResampler init failed: {e}")))?;
|
||||||
Error::AudioDecodeFailed(format!("StreamingResampler init failed: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Self::Sinc {
|
Ok(Self::Sinc {
|
||||||
resampler,
|
resampler,
|
||||||
@@ -110,9 +108,7 @@ impl StreamingResampler {
|
|||||||
let chunk: Vec<f32> = residual.drain(..INPUT_CHUNK).collect();
|
let chunk: Vec<f32> = residual.drain(..INPUT_CHUNK).collect();
|
||||||
let input = vec![chunk];
|
let input = vec![chunk];
|
||||||
let result = resampler.process(&input, None).map_err(|e| {
|
let result = resampler.process(&input, None).map_err(|e| {
|
||||||
Error::AudioDecodeFailed(format!(
|
Error::AudioDecodeFailed(format!("StreamingResampler process failed: {e}"))
|
||||||
"StreamingResampler process failed: {e}"
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
if let Some(channel) = result.into_iter().next() {
|
if let Some(channel) = result.into_iter().next() {
|
||||||
out.extend_from_slice(&channel);
|
out.extend_from_slice(&channel);
|
||||||
@@ -144,9 +140,7 @@ impl StreamingResampler {
|
|||||||
|
|
||||||
let input = vec![chunk];
|
let input = vec![chunk];
|
||||||
let result = resampler.process(&input, None).map_err(|e| {
|
let result = resampler.process(&input, None).map_err(|e| {
|
||||||
Error::AudioDecodeFailed(format!(
|
Error::AudioDecodeFailed(format!("StreamingResampler flush failed: {e}"))
|
||||||
"StreamingResampler flush failed: {e}"
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let Some(mut out) = result.into_iter().next() else {
|
let Some(mut out) = result.into_iter().next() else {
|
||||||
|
|||||||
@@ -42,9 +42,8 @@ impl WavWriter {
|
|||||||
};
|
};
|
||||||
let file = std::fs::File::create(path).map_err(Error::from)?;
|
let file = std::fs::File::create(path).map_err(Error::from)?;
|
||||||
let buffered = BufWriter::new(file);
|
let buffered = BufWriter::new(file);
|
||||||
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
|
let inner = hound::WavWriter::new(buffered, spec)
|
||||||
Error::from(std::io::Error::other(format!("WAV create failed: {e}")))
|
.map_err(|e| Error::from(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||||
})?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner,
|
inner,
|
||||||
samples_since_flush: 0,
|
samples_since_flush: 0,
|
||||||
@@ -77,9 +76,9 @@ impl WavWriter {
|
|||||||
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
|
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
|
||||||
/// boundaries (end-of-utterance, UI events) for tighter recovery.
|
/// boundaries (end-of-utterance, UI events) for tighter recovery.
|
||||||
pub fn flush(&mut self) -> Result<()> {
|
pub fn flush(&mut self) -> Result<()> {
|
||||||
self.inner.flush().map_err(|e| {
|
self.inner
|
||||||
Error::from(std::io::Error::other(format!("WAV flush failed: {e}")))
|
.flush()
|
||||||
})?;
|
.map_err(|e| Error::from(std::io::Error::other(format!("WAV flush failed: {e}"))))?;
|
||||||
self.samples_since_flush = 0;
|
self.samples_since_flush = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -89,9 +88,9 @@ impl WavWriter {
|
|||||||
/// writer leaves a playable file up to the last flush; callers
|
/// writer leaves a playable file up to the last flush; callers
|
||||||
/// that care about the unflushed tail should always finalise.
|
/// that care about the unflushed tail should always finalise.
|
||||||
pub fn finalize(self) -> Result<()> {
|
pub fn finalize(self) -> Result<()> {
|
||||||
self.inner.finalize().map_err(|e| {
|
self.inner
|
||||||
Error::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
|
.finalize()
|
||||||
})?;
|
.map_err(|e| Error::from(std::io::Error::other(format!("WAV finalize failed: {e}"))))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,21 +104,20 @@ pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
|
|||||||
sample_format: hound::SampleFormat::Int,
|
sample_format: hound::SampleFormat::Int,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut writer = hound::WavWriter::create(path, spec).map_err(|e| {
|
let mut writer = hound::WavWriter::create(path, spec)
|
||||||
Error::from(std::io::Error::other(format!("WAV create failed: {e}")))
|
.map_err(|e| Error::from(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
for &sample in audio.samples() {
|
for &sample in audio.samples() {
|
||||||
let clamped = sample.clamp(-1.0, 1.0);
|
let clamped = sample.clamp(-1.0, 1.0);
|
||||||
let int_sample = (clamped * i16::MAX as f32) as i16;
|
let int_sample = (clamped * i16::MAX as f32) as i16;
|
||||||
writer.write_sample(int_sample).map_err(|e| {
|
writer
|
||||||
Error::from(std::io::Error::other(format!("WAV write failed: {e}")))
|
.write_sample(int_sample)
|
||||||
})?;
|
.map_err(|e| Error::from(std::io::Error::other(format!("WAV write failed: {e}"))))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.finalize().map_err(|e| {
|
writer
|
||||||
Error::from(std::io::Error::other(format!("WAV finalize failed: {e}")))
|
.finalize()
|
||||||
})?;
|
.map_err(|e| Error::from(std::io::Error::other(format!("WAV finalize failed: {e}"))))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -146,17 +144,14 @@ pub fn read_wav(path: &Path) -> Result<AudioSamples> {
|
|||||||
.map(|sample| {
|
.map(|sample| {
|
||||||
sample
|
sample
|
||||||
.map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32)
|
.map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32)
|
||||||
.map_err(|e| {
|
.map_err(|e| Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}")))
|
||||||
Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<f32>>>()?,
|
.collect::<Result<Vec<f32>>>()?,
|
||||||
hound::SampleFormat::Float => reader
|
hound::SampleFormat::Float => reader
|
||||||
.into_samples::<f32>()
|
.into_samples::<f32>()
|
||||||
.map(|sample| {
|
.map(|sample| {
|
||||||
sample.map_err(|e| {
|
sample
|
||||||
Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
|
.map_err(|e| Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}")))
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<f32>>>()?,
|
.collect::<Result<Vec<f32>>>()?,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-cloud-providers"
|
name = "lumotia-cloud-providers"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Provider trait and BYOK cloud STT scaffolding for Lumotia"
|
description = "Provider trait and BYOK cloud STT scaffolding for Lumotia"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-core"
|
name = "lumotia-core"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Core types, constants, traits, hardware detection, and model registry for Lumotia"
|
description = "Core types, constants, traits, hardware detection, and model registry for Lumotia"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ impl AppPaths {
|
|||||||
pub fn llm_models_dir(&self) -> PathBuf {
|
pub fn llm_models_dir(&self) -> PathBuf {
|
||||||
self.models_dir().join("llm")
|
self.models_dir().join("llm")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn migration_sentinel(&self, name: &str) -> PathBuf {
|
|
||||||
self.app_data_dir.join(format!(".{name}.sentinel"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn app_paths() -> AppPaths {
|
pub fn app_paths() -> AppPaths {
|
||||||
@@ -115,11 +111,7 @@ fn resolve_app_data_dir() -> PathBuf {
|
|||||||
/// relying on the panic that backs the infallible `resolve_app_data_dir`.
|
/// relying on the panic that backs the infallible `resolve_app_data_dir`.
|
||||||
pub fn resolve_app_data_dir_strict() -> Result<PathBuf, TargetAmbiguityError> {
|
pub fn resolve_app_data_dir_strict() -> Result<PathBuf, TargetAmbiguityError> {
|
||||||
let candidates = target_data_dir_candidates();
|
let candidates = target_data_dir_candidates();
|
||||||
let existing: Vec<PathBuf> = candidates
|
let existing: Vec<PathBuf> = candidates.iter().filter(|p| p.exists()).cloned().collect();
|
||||||
.iter()
|
|
||||||
.filter(|p| p.exists())
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
if existing.len() > 1 {
|
if existing.len() > 1 {
|
||||||
return Err(TargetAmbiguityError {
|
return Err(TargetAmbiguityError {
|
||||||
candidates: existing,
|
candidates: existing,
|
||||||
@@ -382,17 +374,19 @@ fn legacy_and_target_paths() -> Vec<(PathBuf, PathBuf)> {
|
|||||||
/// target (same convention) and rename `magnotia.db` -> `lumotia.db`
|
/// target (same convention) and rename `magnotia.db` -> `lumotia.db`
|
||||||
/// inside it if found.
|
/// inside it if found.
|
||||||
pub fn migrate_legacy_data_dir() -> Result<Vec<MigrationStatus>, std::io::Error> {
|
pub fn migrate_legacy_data_dir() -> Result<Vec<MigrationStatus>, std::io::Error> {
|
||||||
migrate_legacy_data_dir_inner(legacy_and_target_paths())
|
migrate_legacy_data_dir_with_pairs(legacy_and_target_paths())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-friendly inner shape: takes the list of (legacy, target) pairs
|
/// Driver that takes the list of (legacy, target) pairs explicitly so
|
||||||
/// explicitly so tests don't depend on platform-specific HOME /
|
/// callers can substitute synthetic paths. Production path goes through
|
||||||
/// LOCALAPPDATA / XDG env vars.
|
/// [`migrate_legacy_data_dir`], which resolves the pairs from
|
||||||
|
/// platform-specific HOME / LOCALAPPDATA / XDG env vars. Integration
|
||||||
|
/// tests in sibling crates call this directly with tempdir pairs.
|
||||||
///
|
///
|
||||||
/// An empty input is shorthand for "no legacy on disk" and yields a
|
/// An empty input is shorthand for "no legacy on disk" and yields a
|
||||||
/// single [`MigrationStatus::NoLegacyFound`] entry so callers can still
|
/// single [`MigrationStatus::NoLegacyFound`] entry so callers can still
|
||||||
/// rely on a non-empty result to drive their logging.
|
/// rely on a non-empty result to drive their logging.
|
||||||
fn migrate_legacy_data_dir_inner(
|
pub fn migrate_legacy_data_dir_with_pairs(
|
||||||
pairs: Vec<(PathBuf, PathBuf)>,
|
pairs: Vec<(PathBuf, PathBuf)>,
|
||||||
) -> Result<Vec<MigrationStatus>, std::io::Error> {
|
) -> Result<Vec<MigrationStatus>, std::io::Error> {
|
||||||
if pairs.is_empty() {
|
if pairs.is_empty() {
|
||||||
@@ -517,8 +511,26 @@ pub fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error>
|
|||||||
}
|
}
|
||||||
} else if file_type.is_dir() {
|
} else if file_type.is_dir() {
|
||||||
copy_dir_recursive(&entry_path, &target_path)?;
|
copy_dir_recursive(&entry_path, &target_path)?;
|
||||||
} else {
|
} else if file_type.is_file() {
|
||||||
std::fs::copy(&entry_path, &target_path)?;
|
std::fs::copy(&entry_path, &target_path)?;
|
||||||
|
} else {
|
||||||
|
// Anything that is neither a symlink, a directory, nor a
|
||||||
|
// regular file lands here: on Unix that's FIFOs, sockets,
|
||||||
|
// and character / block device nodes. `std::fs::copy()` on
|
||||||
|
// a FIFO would block forever waiting for a writer, and on
|
||||||
|
// a device node would either fail unpredictably or attempt
|
||||||
|
// to read until the device's end-of-stream. Both turn a
|
||||||
|
// legacy-dir leftover into a silent migration hang. We
|
||||||
|
// refuse to cross the boundary and surface the path so
|
||||||
|
// the user can clean it up manually. The migration is
|
||||||
|
// re-runnable once the offending node is removed.
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
format!(
|
||||||
|
"refusing to copy non-regular filesystem object during migration: {}",
|
||||||
|
entry_path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -582,10 +594,8 @@ mod tests {
|
|||||||
/// Helper: drive the migration with a single (legacy, target) pair
|
/// Helper: drive the migration with a single (legacy, target) pair
|
||||||
/// and return the (only) status it produced. Keeps existing tests
|
/// and return the (only) status it produced. Keeps existing tests
|
||||||
/// readable after the Option -> Vec API change.
|
/// readable after the Option -> Vec API change.
|
||||||
fn migrate_one_pair_inner(
|
fn migrate_one_pair_inner(pair: (PathBuf, PathBuf)) -> Result<MigrationStatus, std::io::Error> {
|
||||||
pair: (PathBuf, PathBuf),
|
let mut statuses = migrate_legacy_data_dir_with_pairs(vec![pair])?;
|
||||||
) -> Result<MigrationStatus, std::io::Error> {
|
|
||||||
let mut statuses = migrate_legacy_data_dir_inner(vec![pair])?;
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
statuses.len(),
|
statuses.len(),
|
||||||
1,
|
1,
|
||||||
@@ -603,8 +613,7 @@ mod tests {
|
|||||||
std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap();
|
std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap();
|
||||||
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
MigrationStatus::Migrated {
|
MigrationStatus::Migrated {
|
||||||
@@ -640,8 +649,7 @@ mod tests {
|
|||||||
std::fs::write(target.join("lumotia.db"), b"new-data").unwrap();
|
std::fs::write(target.join("lumotia.db"), b"new-data").unwrap();
|
||||||
std::fs::write(legacy.join("magnotia.db"), b"legacy-data").unwrap();
|
std::fs::write(legacy.join("magnotia.db"), b"legacy-data").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -662,7 +670,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migrate_with_neither_present_returns_no_legacy() {
|
fn migrate_with_neither_present_returns_no_legacy() {
|
||||||
let result = migrate_legacy_data_dir_inner(Vec::new()).expect("migrate ok");
|
let result = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("migrate ok");
|
||||||
|
|
||||||
assert_eq!(result, vec![MigrationStatus::NoLegacyFound]);
|
assert_eq!(result, vec![MigrationStatus::NoLegacyFound]);
|
||||||
}
|
}
|
||||||
@@ -675,8 +683,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&legacy).unwrap();
|
std::fs::create_dir_all(&legacy).unwrap();
|
||||||
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
MigrationStatus::Migrated { renamed_db, .. } => {
|
MigrationStatus::Migrated { renamed_db, .. } => {
|
||||||
@@ -700,8 +707,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&legacy).unwrap();
|
std::fs::create_dir_all(&legacy).unwrap();
|
||||||
std::fs::write(legacy.join("magnotia.db"), b"data").unwrap();
|
std::fs::write(legacy.join("magnotia.db"), b"data").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
assert!(matches!(result, MigrationStatus::Migrated { .. }));
|
assert!(matches!(result, MigrationStatus::Migrated { .. }));
|
||||||
assert!(target.exists());
|
assert!(target.exists());
|
||||||
@@ -721,16 +727,15 @@ mod tests {
|
|||||||
std::fs::create_dir_all(src.join("recordings/2026-05")).unwrap();
|
std::fs::create_dir_all(src.join("recordings/2026-05")).unwrap();
|
||||||
std::fs::create_dir_all(src.join("models/whisper-base-en")).unwrap();
|
std::fs::create_dir_all(src.join("models/whisper-base-en")).unwrap();
|
||||||
std::fs::write(src.join("magnotia.db"), b"sqlite-bytes").unwrap();
|
std::fs::write(src.join("magnotia.db"), b"sqlite-bytes").unwrap();
|
||||||
std::fs::write(
|
std::fs::write(src.join("recordings/2026-05/clip-001.wav"), b"wav-bytes").unwrap();
|
||||||
src.join("recordings/2026-05/clip-001.wav"),
|
|
||||||
b"wav-bytes",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(src.join("models/whisper-base-en/manifest.json"), b"{}").unwrap();
|
std::fs::write(src.join("models/whisper-base-en/manifest.json"), b"{}").unwrap();
|
||||||
|
|
||||||
copy_dir_recursive(&src, &dst).expect("copy ok");
|
copy_dir_recursive(&src, &dst).expect("copy ok");
|
||||||
|
|
||||||
assert_eq!(std::fs::read(dst.join("magnotia.db")).unwrap(), b"sqlite-bytes");
|
assert_eq!(
|
||||||
|
std::fs::read(dst.join("magnotia.db")).unwrap(),
|
||||||
|
b"sqlite-bytes"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(),
|
std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(),
|
||||||
b"wav-bytes"
|
b"wav-bytes"
|
||||||
@@ -806,7 +811,7 @@ mod tests {
|
|||||||
std::fs::write(dot_legacy.join("marker"), b"dot-home").unwrap();
|
std::fs::write(dot_legacy.join("marker"), b"dot-home").unwrap();
|
||||||
std::fs::write(xdg_legacy.join("marker"), b"xdg").unwrap();
|
std::fs::write(xdg_legacy.join("marker"), b"xdg").unwrap();
|
||||||
|
|
||||||
let statuses = migrate_legacy_data_dir_inner(vec![
|
let statuses = migrate_legacy_data_dir_with_pairs(vec![
|
||||||
(dot_legacy.clone(), dot_target.clone()),
|
(dot_legacy.clone(), dot_target.clone()),
|
||||||
(xdg_legacy.clone(), xdg_target.clone()),
|
(xdg_legacy.clone(), xdg_target.clone()),
|
||||||
])
|
])
|
||||||
@@ -975,7 +980,7 @@ mod tests {
|
|||||||
let dst_link_entry = entries
|
let dst_link_entry = entries
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| e.as_ref().ok())
|
.find_map(|e| e.as_ref().ok())
|
||||||
.filter(|e| e.file_name() == std::ffi::OsString::from("link"));
|
.filter(|e| e.file_name() == "link");
|
||||||
if let Some(e) = dst_link_entry {
|
if let Some(e) = dst_link_entry {
|
||||||
assert!(
|
assert!(
|
||||||
e.file_type().unwrap().is_symlink(),
|
e.file_type().unwrap().is_symlink(),
|
||||||
@@ -985,4 +990,171 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Adversarial probes: hostile filesystem objects inside the legacy
|
||||||
|
// tree that could turn a benign cross-device migration into a hang,
|
||||||
|
// a silent data loss, or an unhelpful panic. These exercise the
|
||||||
|
// copy_dir_recursive fall-through after the symlink and directory
|
||||||
|
// branches have been ruled out. Same-filesystem rename via
|
||||||
|
// `std::fs::rename` is atomic and bypasses these paths entirely; we
|
||||||
|
// only need to defend the EXDEV copy fallback.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn copy_dir_recursive_rejects_fifo_in_legacy_tree_without_hanging() {
|
||||||
|
// A FIFO inside the legacy tree must NOT cause copy_dir_recursive
|
||||||
|
// to block on std::fs::copy (open-for-read on a FIFO with no
|
||||||
|
// writer blocks indefinitely). The hardened branch surfaces an
|
||||||
|
// Unsupported error naming the offending path, leaving the
|
||||||
|
// partial destination on disk for the user to consult before
|
||||||
|
// retrying.
|
||||||
|
let root = unique_tmp("fifo-rejected");
|
||||||
|
let src = root.join("legacy");
|
||||||
|
let dst = root.join("new");
|
||||||
|
std::fs::create_dir_all(&src).unwrap();
|
||||||
|
std::fs::write(src.join("regular.txt"), b"normal-data").unwrap();
|
||||||
|
|
||||||
|
let fifo_path = src.join("debug-pipe");
|
||||||
|
// mkfifo via the system binary keeps the test free of an extra
|
||||||
|
// libc dev-dependency. The migration only needs the FIFO to be
|
||||||
|
// present on disk so file_type().is_fifo() returns true.
|
||||||
|
let status = std::process::Command::new("mkfifo")
|
||||||
|
.arg(&fifo_path)
|
||||||
|
.status()
|
||||||
|
.expect("mkfifo invocation must run on a unix test host");
|
||||||
|
assert!(status.success(), "mkfifo must succeed");
|
||||||
|
|
||||||
|
// Bound the test against the regression: if a future refactor
|
||||||
|
// re-introduces the std::fs::copy fall-through, the FIFO read
|
||||||
|
// would hang forever and stall CI. We run copy_dir_recursive
|
||||||
|
// on a worker thread and require it to return within a tight
|
||||||
|
// budget; a slow CI host gets 5 seconds, which is many orders
|
||||||
|
// of magnitude above the expected ~ms return.
|
||||||
|
let src_owned = src.clone();
|
||||||
|
let dst_owned = dst.clone();
|
||||||
|
let handle = std::thread::spawn(move || copy_dir_recursive(&src_owned, &dst_owned));
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let result = loop {
|
||||||
|
if handle.is_finished() {
|
||||||
|
break handle.join().expect("worker thread must not panic");
|
||||||
|
}
|
||||||
|
if start.elapsed() > std::time::Duration::from_secs(5) {
|
||||||
|
panic!(
|
||||||
|
"copy_dir_recursive hung on a FIFO inside the legacy tree; \
|
||||||
|
suspected regression in the non-regular fall-through guard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = result.expect_err("FIFO inside legacy tree must surface an error");
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("debug-pipe"),
|
||||||
|
"error must name the offending path: {msg}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The partial destination may or may not contain the regular
|
||||||
|
// file depending on read_dir iteration order; we don't assert
|
||||||
|
// either way. What matters is that the migration returns an
|
||||||
|
// error rather than blocking forever.
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn copy_dir_recursive_surfaces_permission_error_on_unreadable_file() {
|
||||||
|
// A legacy file with mode 0000 inside the tree must cause
|
||||||
|
// copy_dir_recursive to fail loud with PermissionDenied, not
|
||||||
|
// silently skip the file (which would orphan user data). The
|
||||||
|
// user can chmod the file and retry.
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let root = unique_tmp("unreadable-file");
|
||||||
|
let src = root.join("legacy");
|
||||||
|
let dst = root.join("new");
|
||||||
|
std::fs::create_dir_all(&src).unwrap();
|
||||||
|
let locked = src.join("locked.db");
|
||||||
|
std::fs::write(&locked, b"sensitive").unwrap();
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
// Belt-and-braces against test-running-as-root: root bypasses
|
||||||
|
// DAC permissions and would silently succeed, masking the
|
||||||
|
// regression. Skip the assertion in that case so the test is
|
||||||
|
// honest about what it proved.
|
||||||
|
let running_as_root = effective_uid() == 0;
|
||||||
|
|
||||||
|
let result = copy_dir_recursive(&src, &dst);
|
||||||
|
|
||||||
|
// Restore permissions before TempDir cleanup, regardless of
|
||||||
|
// the test outcome, so the tempdir teardown doesn't itself
|
||||||
|
// hit EACCES.
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).ok();
|
||||||
|
|
||||||
|
if running_as_root {
|
||||||
|
// Document the bypass; the test still has value in CI
|
||||||
|
// where the runner is non-root.
|
||||||
|
assert!(
|
||||||
|
result.is_ok() || result.is_err(),
|
||||||
|
"test result intentionally not asserted under euid 0"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let err = result.expect_err("unreadable file must surface an error");
|
||||||
|
assert_eq!(
|
||||||
|
err.kind(),
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"expected PermissionDenied, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the effective uid by inspecting a freshly-created file's
|
||||||
|
/// owner. Used by the permission-denied probe to skip its core
|
||||||
|
/// assertion when the test host runs as root (root bypasses DAC).
|
||||||
|
/// Direct over a libc dev-dependency for one helper.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn effective_uid() -> u32 {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let tmp = std::env::temp_dir().join(format!("euid-probe-{}", std::process::id()));
|
||||||
|
std::fs::write(&tmp, b"x").unwrap();
|
||||||
|
let uid = std::fs::metadata(&tmp).unwrap().uid();
|
||||||
|
std::fs::remove_file(&tmp).ok();
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn copy_dir_recursive_preserves_dangling_symlink_target() {
|
||||||
|
// A legacy symlink pointing at a since-deleted target must be
|
||||||
|
// recreated as a dangling symlink at the destination — NOT
|
||||||
|
// dereferenced (which would fail) and NOT skipped (which would
|
||||||
|
// silently drop a piece of the user's directory shape).
|
||||||
|
let root = unique_tmp("dangling-symlink");
|
||||||
|
let src = root.join("legacy");
|
||||||
|
let dst = root.join("new");
|
||||||
|
std::fs::create_dir_all(&src).unwrap();
|
||||||
|
std::os::unix::fs::symlink("/no/such/path/ever", src.join("orphan")).unwrap();
|
||||||
|
|
||||||
|
copy_dir_recursive(&src, &dst).expect("dangling symlink must not abort the copy");
|
||||||
|
|
||||||
|
let orphan = dst.join("orphan");
|
||||||
|
let meta = std::fs::symlink_metadata(&orphan).expect("orphan must exist as a link");
|
||||||
|
assert!(
|
||||||
|
meta.file_type().is_symlink(),
|
||||||
|
"dst/orphan must be a symlink, not a regular file or directory"
|
||||||
|
);
|
||||||
|
let link_target = std::fs::read_link(&orphan).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
link_target,
|
||||||
|
std::path::PathBuf::from("/no/such/path/ever"),
|
||||||
|
"symlink target must be preserved verbatim"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-hotkey"
|
name = "lumotia-hotkey"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Wayland-compatible global hotkey listener for Lumotia — evdev backend with device hotplug"
|
description = "Wayland-compatible global hotkey listener for Lumotia — evdev backend with device hotplug"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -411,7 +411,9 @@ async fn try_attach_device(
|
|||||||
// Register with the supervisor. This await is brief — it just locks
|
// Register with the supervisor. This await is brief — it just locks
|
||||||
// the supervisor inner Vec and pushes — and happens outside the
|
// the supervisor inner Vec and pushes — and happens outside the
|
||||||
// tracked-map lock.
|
// tracked-map lock.
|
||||||
supervisor.register("device-listener", listener_handle).await;
|
supervisor
|
||||||
|
.register("device-listener", listener_handle)
|
||||||
|
.await;
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ use tokio::task::JoinHandle;
|
|||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
/// How long to wait for any single task to drain on `shutdown()` before
|
/// How long to wait for any single task to drain on `shutdown()` before
|
||||||
/// we give up and abort it. Two seconds is generous for cooperative
|
/// we give up on it. Two seconds is generous for cooperative shutdown
|
||||||
/// shutdown via the broadcast channel — anything slower is treated as a
|
/// via the broadcast channel — anything slower is treated as a stuck
|
||||||
/// stuck task and force-aborted with a warning so the operator can
|
/// task and detached (NOT aborted: `timeout(d, handle).await` consumes
|
||||||
/// investigate.
|
/// the `JoinHandle` by value and dropping a `JoinHandle` detaches the
|
||||||
|
/// task, so the task keeps running until the tokio runtime tears it
|
||||||
|
/// down). A warning is logged so the operator can investigate.
|
||||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// Capacity of the broadcast shutdown channel. Eight is far more than we
|
/// Capacity of the broadcast shutdown channel. Eight is far more than we
|
||||||
@@ -207,8 +209,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stuck task — one that does not subscribe to broadcast shutdown
|
||||||
|
/// and would otherwise run forever — must not block `shutdown()`
|
||||||
|
/// past the per-task timeout. Note: the supervisor does NOT abort
|
||||||
|
/// the task; it detaches it. Verifying detach behaviour directly is
|
||||||
|
/// not possible from this test because `register()` moves the
|
||||||
|
/// `JoinHandle` into the supervisor's inner Vec. The bounded-elapsed
|
||||||
|
/// assertion below is what guards against a regression that
|
||||||
|
/// reintroduces an unbounded `handle.await` in `shutdown()`.
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn shutdown_force_aborts_stuck_tasks_after_timeout() {
|
async fn shutdown_does_not_block_on_stuck_tasks_after_timeout() {
|
||||||
let sup = SupervisorHandle::new();
|
let sup = SupervisorHandle::new();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
// Sleep forever — does NOT subscribe to shutdown.
|
// Sleep forever — does NOT subscribe to shutdown.
|
||||||
|
|||||||
@@ -143,8 +143,7 @@ async fn reconfigure_does_not_leak_forwarder() {
|
|||||||
// (orphaned listener tasks still holding sender clones), the
|
// (orphaned listener tasks still holding sender clones), the
|
||||||
// forwarder would never see `None` from recv() and this timeout
|
// forwarder would never see `None` from recv() and this timeout
|
||||||
// would fire.
|
// would fire.
|
||||||
let join_result =
|
let join_result = tokio::time::timeout(Duration::from_secs(5), forwarder_1).await;
|
||||||
tokio::time::timeout(Duration::from_secs(5), forwarder_1).await;
|
|
||||||
assert!(
|
assert!(
|
||||||
join_result.is_ok(),
|
join_result.is_ok(),
|
||||||
"old forwarder did not join after listener.stop() — Race-2 regressed: \
|
"old forwarder did not join after listener.stop() — Race-2 regressed: \
|
||||||
@@ -176,8 +175,7 @@ async fn reconfigure_does_not_leak_forwarder() {
|
|||||||
|
|
||||||
// ---- Cleanup ----
|
// ---- Cleanup ----
|
||||||
listener_2.stop().await;
|
listener_2.stop().await;
|
||||||
let cleanup_join =
|
let cleanup_join = tokio::time::timeout(Duration::from_secs(5), forwarder_2).await;
|
||||||
tokio::time::timeout(Duration::from_secs(5), forwarder_2).await;
|
|
||||||
assert!(
|
assert!(
|
||||||
cleanup_join.is_ok(),
|
cleanup_join.is_ok(),
|
||||||
"second forwarder also failed to drain after stop()"
|
"second forwarder also failed to drain after stop()"
|
||||||
@@ -189,5 +187,8 @@ async fn reconfigure_does_not_leak_forwarder() {
|
|||||||
// count. The counters exist so the test compiles as a real
|
// count. The counters exist so the test compiles as a real
|
||||||
// forwarder pattern matching what commands::hotkey does in
|
// forwarder pattern matching what commands::hotkey does in
|
||||||
// production.
|
// production.
|
||||||
let _ = (received_first.load(Ordering::SeqCst), received_second.load(Ordering::SeqCst));
|
let _ = (
|
||||||
|
received_first.load(Ordering::SeqCst),
|
||||||
|
received_second.load(Ordering::SeqCst),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-llm"
|
name = "lumotia-llm"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Local LLM engine for Lumotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition"
|
description = "Local LLM engine for Lumotia (Qwen3.5 / Qwen3.6 via llama-cpp-2): transcript cleanup, task extraction, micro-step decomposition"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -26,13 +26,30 @@ const MAX_CONTEXT_TOKENS: u32 = 8192;
|
|||||||
const CONTEXT_RESERVE_TOKENS: u32 = 64;
|
const CONTEXT_RESERVE_TOKENS: u32 = 64;
|
||||||
const GENERATION_SEED: u32 = 0;
|
const GENERATION_SEED: u32 = 0;
|
||||||
|
|
||||||
|
/// Maximum number of tasks returned by the rule-based fallback extractor.
|
||||||
|
/// Caps output to avoid wall-of-text dumps when the transcript is dense.
|
||||||
|
const MAX_RULE_BASED_TASKS: usize = 10;
|
||||||
|
|
||||||
|
/// Indicates which extraction path produced the task list.
|
||||||
|
/// Propagated to callers so the UI can label rule-based results accordingly.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TaskExtractionSource {
|
||||||
|
/// Tasks extracted by the local LLM.
|
||||||
|
Llm,
|
||||||
|
/// LLM path failed; tasks extracted by the rule-based regex fallback.
|
||||||
|
RuleBased,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum EngineError {
|
pub enum EngineError {
|
||||||
#[error("LLM not loaded. Download an AI model in Settings.")]
|
#[error("LLM not loaded. Download an AI model in Settings.")]
|
||||||
NotLoaded,
|
NotLoaded,
|
||||||
#[error("LLM load failed: {0}")]
|
#[error("LLM load failed: {0}")]
|
||||||
LoadFailed(String),
|
LoadFailed(String),
|
||||||
#[error("Another LLM load is already in flight; refusing to start a parallel load.")]
|
#[error(
|
||||||
|
"Another LLM load is already in flight; refusing to start a parallel load \
|
||||||
|
or modify engine state mid-load."
|
||||||
|
)]
|
||||||
AlreadyLoading,
|
AlreadyLoading,
|
||||||
#[error(
|
#[error(
|
||||||
"prompt too long: {prompt_tokens} prompt tokens exceed the {available_prompt_tokens}-token prompt budget for an {context_window}-token context with {max_tokens} reserved response tokens"
|
"prompt too long: {prompt_tokens} prompt tokens exceed the {available_prompt_tokens}-token prompt budget for an {context_window}-token context with {max_tokens} reserved response tokens"
|
||||||
@@ -244,6 +261,18 @@ impl LlmEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn unload(&self) -> Result<(), EngineError> {
|
pub fn unload(&self) -> Result<(), EngineError> {
|
||||||
|
// Refuse to unload mid-load. Without this check, `load_model_with`
|
||||||
|
// is mid-flight (it has cleared `model` / `loaded` in step 3 and
|
||||||
|
// is about to install new state in step 5); a concurrent unload
|
||||||
|
// would do nothing (the state is already None), return Ok, and
|
||||||
|
// then the load's step 5 silently overwrites — the caller saw
|
||||||
|
// unload success but the engine ends up loaded. Phase B.7 audit
|
||||||
|
// residual (2026-05-14): the load-vs-load TOCTOU was closed by
|
||||||
|
// `AlreadyLoading` in cde985d but the unload-vs-load race was
|
||||||
|
// left open. Same flag covers both directions.
|
||||||
|
if self.is_loading() {
|
||||||
|
return Err(EngineError::AlreadyLoading);
|
||||||
|
}
|
||||||
let mut guard = self.inner.lock().unwrap();
|
let mut guard = self.inner.lock().unwrap();
|
||||||
guard.model = None;
|
guard.model = None;
|
||||||
// Backend is process-singleton (llama-cpp-2 enforces this via
|
// Backend is process-singleton (llama-cpp-2 enforces this via
|
||||||
@@ -256,7 +285,7 @@ impl LlmEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// True iff a model load is currently in flight. Exposed for tests
|
/// True iff a model load is currently in flight. Exposed for tests
|
||||||
/// + frontends that want to render a "loading…" state without
|
/// and frontends that want to render a "loading…" state without
|
||||||
/// polling `is_loaded()` (which returns false during a swap).
|
/// polling `is_loaded()` (which returns false during a swap).
|
||||||
pub fn is_loading(&self) -> bool {
|
pub fn is_loading(&self) -> bool {
|
||||||
self.loading.load(Ordering::Acquire)
|
self.loading.load(Ordering::Acquire)
|
||||||
@@ -538,6 +567,31 @@ impl LlmEngine {
|
|||||||
parse_string_array(&raw)
|
parse_string_array(&raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrapper around [`extract_tasks_with_feedback`] that NEVER returns
|
||||||
|
/// an error: if the LLM path fails for any reason the rule-based
|
||||||
|
/// extractor fires as a safety net, satisfying the data-loss contract
|
||||||
|
/// documented in `docs/release/v0.1-known-limitations.md`.
|
||||||
|
///
|
||||||
|
/// Returns `(tasks, source)` where `source` tells the caller which
|
||||||
|
/// path produced the results so the UI can label them.
|
||||||
|
pub fn extract_tasks_with_fallback(
|
||||||
|
&self,
|
||||||
|
transcript: &str,
|
||||||
|
examples: &[prompts::FeedbackExample],
|
||||||
|
) -> (Vec<String>, TaskExtractionSource) {
|
||||||
|
match self.extract_tasks_with_feedback(transcript, examples) {
|
||||||
|
Ok(tasks) => (tasks, TaskExtractionSource::Llm),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"LLM task extraction failed; using rule-based fallback: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
let tasks = rule_based_extract_tasks(transcript);
|
||||||
|
(tasks, TaskExtractionSource::RuleBased)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn loaded_handles(&self) -> Result<(Arc<LlamaBackend>, Arc<LlamaModel>), EngineError> {
|
fn loaded_handles(&self) -> Result<(Arc<LlamaBackend>, Arc<LlamaModel>), EngineError> {
|
||||||
let guard = self.inner.lock().unwrap();
|
let guard = self.inner.lock().unwrap();
|
||||||
let backend = guard.backend.clone().ok_or(EngineError::NotLoaded)?;
|
let backend = guard.backend.clone().ok_or(EngineError::NotLoaded)?;
|
||||||
@@ -616,10 +670,27 @@ fn json_envelope_complete(text: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_json_envelope(text: &str) -> Option<&str> {
|
fn extract_json_envelope(text: &str) -> Option<&str> {
|
||||||
let start = text
|
// Phase B.9 audit residual (2026-05-14): strip the leading
|
||||||
|
// `<think>…</think>` reasoning block before scanning. Qwen-style
|
||||||
|
// models emit non-empty reasoning when thinking mode is on, and
|
||||||
|
// the reasoning can contain JSON-looking literals (e.g.
|
||||||
|
// "the answer should be {\"x\":1}") or unbalanced braces ("I wonder
|
||||||
|
// about {..."). The naive "find the first '{' or '['" extractor
|
||||||
|
// would then either return the wrong envelope or pollute the
|
||||||
|
// brace-stack and return None. We split on the FIRST `</think>` —
|
||||||
|
// anything before it is reasoning, anything after is the answer
|
||||||
|
// proper. Falls back to the whole text when no `</think>` is
|
||||||
|
// present (covers non-reasoning models and the empty-thinking
|
||||||
|
// case already covered by `extract_json_envelope_skips_qwen_thinking_prefix`).
|
||||||
|
let scan_region = text
|
||||||
|
.split_once("</think>")
|
||||||
|
.map(|(_, rest)| rest)
|
||||||
|
.unwrap_or(text);
|
||||||
|
|
||||||
|
let start = scan_region
|
||||||
.char_indices()
|
.char_indices()
|
||||||
.find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?;
|
.find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?;
|
||||||
let mut chars = text[start..].char_indices();
|
let mut chars = scan_region[start..].char_indices();
|
||||||
let (_, first) = chars.next()?;
|
let (_, first) = chars.next()?;
|
||||||
|
|
||||||
let mut stack = vec![match first {
|
let mut stack = vec![match first {
|
||||||
@@ -630,7 +701,7 @@ fn extract_json_envelope(text: &str) -> Option<&str> {
|
|||||||
let mut in_string = false;
|
let mut in_string = false;
|
||||||
let mut escaped = false;
|
let mut escaped = false;
|
||||||
|
|
||||||
while let Some((offset, ch)) = chars.next() {
|
for (offset, ch) in chars {
|
||||||
if in_string {
|
if in_string {
|
||||||
if escaped {
|
if escaped {
|
||||||
escaped = false;
|
escaped = false;
|
||||||
@@ -652,7 +723,7 @@ fn extract_json_envelope(text: &str) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
if stack.is_empty() {
|
if stack.is_empty() {
|
||||||
let end = start + offset + ch.len_utf8();
|
let end = start + offset + ch.len_utf8();
|
||||||
return Some(&text[start..end]);
|
return Some(&scan_region[start..end]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -709,6 +780,129 @@ fn parse_string_array(raw: &str) -> Result<Vec<String>, EngineError> {
|
|||||||
Ok(normalized)
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rule-based task extractor used as the safety net when the LLM extraction
|
||||||
|
/// path fails. Per `docs/release/v0.1-known-limitations.md`, task extraction
|
||||||
|
/// must NEVER return zero tasks just because the LLM failed.
|
||||||
|
///
|
||||||
|
/// Heuristic: split on sentence boundaries (`. ? ! \n`), keep sentences that
|
||||||
|
/// begin with (or contain near the start) an imperative-style cue. Trim,
|
||||||
|
/// dedupe, cap at [`MAX_RULE_BASED_TASKS`] to avoid wall-of-text dumps.
|
||||||
|
pub fn rule_based_extract_tasks(transcript: &str) -> Vec<String> {
|
||||||
|
// Filler words that may precede the real imperative start.
|
||||||
|
const FILLER: &[&str] = &["and ", "so ", "then ", "also ", "well ", "okay ", "ok "];
|
||||||
|
|
||||||
|
// Phrase-level cues (checked against the lowercased sentence start).
|
||||||
|
const PHRASE_CUES: &[&str] = &[
|
||||||
|
"i need to ",
|
||||||
|
"i should ",
|
||||||
|
"i have to ",
|
||||||
|
"i must ",
|
||||||
|
"need to ",
|
||||||
|
"got to ",
|
||||||
|
"have to ",
|
||||||
|
"must ",
|
||||||
|
"let me ",
|
||||||
|
"let's ",
|
||||||
|
"lets ",
|
||||||
|
"remember to ",
|
||||||
|
"don't forget to ",
|
||||||
|
"dont forget to ",
|
||||||
|
"don't forget ",
|
||||||
|
"dont forget ",
|
||||||
|
"make sure to ",
|
||||||
|
"make sure i ",
|
||||||
|
"todo:",
|
||||||
|
"to-do:",
|
||||||
|
"task:",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Bare imperative verbs expected at the start of a sentence.
|
||||||
|
const IMPERATIVE_VERBS: &[&str] = &[
|
||||||
|
"send",
|
||||||
|
"write",
|
||||||
|
"call",
|
||||||
|
"email",
|
||||||
|
"fix",
|
||||||
|
"update",
|
||||||
|
"review",
|
||||||
|
"check",
|
||||||
|
"finish",
|
||||||
|
"schedule",
|
||||||
|
"book",
|
||||||
|
"order",
|
||||||
|
"buy",
|
||||||
|
"ask",
|
||||||
|
"follow up",
|
||||||
|
"followup",
|
||||||
|
"create",
|
||||||
|
"add",
|
||||||
|
"remove",
|
||||||
|
"delete",
|
||||||
|
"submit",
|
||||||
|
"upload",
|
||||||
|
"download",
|
||||||
|
"install",
|
||||||
|
"configure",
|
||||||
|
"test",
|
||||||
|
"deploy",
|
||||||
|
"merge",
|
||||||
|
"close",
|
||||||
|
"open",
|
||||||
|
"share",
|
||||||
|
"contact",
|
||||||
|
"reach out",
|
||||||
|
"prepare",
|
||||||
|
"draft",
|
||||||
|
"complete",
|
||||||
|
"reply",
|
||||||
|
"respond",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Split on sentence-terminating punctuation and newlines.
|
||||||
|
let sentences: Vec<&str> = transcript.split(['.', '?', '!', '\n']).collect();
|
||||||
|
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
let mut results: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for raw in sentences {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a lowercase version for matching, stripping leading filler.
|
||||||
|
let mut lc = trimmed.to_lowercase();
|
||||||
|
for filler in FILLER {
|
||||||
|
if lc.starts_with(filler) {
|
||||||
|
lc = lc[filler.len()..].trim_start().to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_task = PHRASE_CUES.iter().any(|cue| lc.starts_with(cue))
|
||||||
|
|| IMPERATIVE_VERBS.iter().any(|verb| {
|
||||||
|
lc.starts_with(verb)
|
||||||
|
&& lc
|
||||||
|
.as_bytes()
|
||||||
|
.get(verb.len())
|
||||||
|
.map(|&b| b == b' ' || b == b',')
|
||||||
|
.unwrap_or(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_task {
|
||||||
|
let key = lc.clone();
|
||||||
|
if seen.insert(key) {
|
||||||
|
results.push(trimmed.to_string());
|
||||||
|
if results.len() >= MAX_RULE_BASED_TASKS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -797,6 +991,45 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase B.9 audit regression (2026-05-14). The original
|
||||||
|
/// `extract_json_envelope_skips_qwen_thinking_prefix` test only
|
||||||
|
/// covered an EMPTY `<think></think>` block. Qwen-style reasoning
|
||||||
|
/// is typically non-empty and can contain JSON-looking literals
|
||||||
|
/// (the model thinking out loud about what shape it should emit).
|
||||||
|
/// The naive "first '{' wins" extractor mis-identified the
|
||||||
|
/// reasoning's literal as the answer envelope and returned it,
|
||||||
|
/// skipping the actual answer that followed `</think>`.
|
||||||
|
///
|
||||||
|
/// Post-fix the extractor strips the leading `<think>…</think>`
|
||||||
|
/// block before scanning, so the reasoning's literal cannot
|
||||||
|
/// poison the result.
|
||||||
|
#[test]
|
||||||
|
fn extract_json_envelope_skips_thinking_block_with_json_looking_content() {
|
||||||
|
let raw = "<think>The answer should look like {\"topic\":\"reasoning-example\",\"intent\":\"capture\"} \
|
||||||
|
based on the schema.</think>{\"topic\":\"real-answer\",\"intent\":\"planning\"}";
|
||||||
|
assert_eq!(
|
||||||
|
extract_json_envelope(raw),
|
||||||
|
Some("{\"topic\":\"real-answer\",\"intent\":\"planning\"}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase B.9 audit regression (2026-05-14). If the reasoning block
|
||||||
|
/// contains UNBALANCED braces (e.g. the model writes "I wonder
|
||||||
|
/// about {..." inside `<think>…</think>`), the pre-strip extractor
|
||||||
|
/// would start its stack on that unbalanced `{`, never find a
|
||||||
|
/// matching `}`, and continue past `</think>` polluting the stack
|
||||||
|
/// with the real answer's braces — ultimately returning None and
|
||||||
|
/// losing the answer entirely. Stripping the reasoning block first
|
||||||
|
/// makes both cases moot.
|
||||||
|
#[test]
|
||||||
|
fn extract_json_envelope_survives_unbalanced_braces_in_thinking() {
|
||||||
|
let raw = "<think>I wonder about {something unfinished here</think>{\"topic\":\"recovery\",\"intent\":\"capture\"}";
|
||||||
|
assert_eq!(
|
||||||
|
extract_json_envelope(raw),
|
||||||
|
Some("{\"topic\":\"recovery\",\"intent\":\"capture\"}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_preflight_rejects_oversized_prompt_tokens() {
|
fn prompt_preflight_rejects_oversized_prompt_tokens() {
|
||||||
let err = preflight_context_window(7_105, 1_024).unwrap_err();
|
let err = preflight_context_window(7_105, 1_024).unwrap_err();
|
||||||
@@ -933,8 +1166,107 @@ mod tests {
|
|||||||
loader_handle.join().unwrap();
|
loader_handle.join().unwrap();
|
||||||
|
|
||||||
// After the first load completes, a fresh attempt is allowed.
|
// After the first load completes, a fresh attempt is allowed.
|
||||||
assert!(engine
|
assert!(engine.__test_run_with_lock_discipline(|| {}).is_ok());
|
||||||
.__test_run_with_lock_discipline(|| {})
|
}
|
||||||
.is_ok());
|
|
||||||
|
/// Phase B.7 audit regression (2026-05-14). The cde985d fix
|
||||||
|
/// introduced the `loading` AtomicBool to refuse a second concurrent
|
||||||
|
/// load, but left `unload()` blind to the flag. A concurrent unload
|
||||||
|
/// during a load's slow window observed `model == None` (the load's
|
||||||
|
/// step 3 had already cleared state), no-op-cleared the same nulls,
|
||||||
|
/// returned Ok — and then the load's step 5 silently installed the
|
||||||
|
/// new state. The caller saw unload-success but the engine ended up
|
||||||
|
/// loaded.
|
||||||
|
///
|
||||||
|
/// Post-fix: an unload mid-load is refused with
|
||||||
|
/// `EngineError::AlreadyLoading`. The caller can retry once the
|
||||||
|
/// load completes (signalled by `is_loading() == false`).
|
||||||
|
#[test]
|
||||||
|
fn unload_during_load_is_refused() {
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
let engine = LlmEngine::new();
|
||||||
|
let load_started = Arc::new(std::sync::Barrier::new(2));
|
||||||
|
let release_load = Arc::new(std::sync::Barrier::new(2));
|
||||||
|
|
||||||
|
let engine_for_loader = engine.clone();
|
||||||
|
let load_started_for_loader = Arc::clone(&load_started);
|
||||||
|
let release_load_for_loader = Arc::clone(&release_load);
|
||||||
|
|
||||||
|
let loader_handle = thread::spawn(move || {
|
||||||
|
engine_for_loader
|
||||||
|
.__test_run_with_lock_discipline(|| {
|
||||||
|
load_started_for_loader.wait();
|
||||||
|
release_load_for_loader.wait();
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait until the loader is mid-slow-section (loading flag claimed,
|
||||||
|
// engine state cleared).
|
||||||
|
load_started.wait();
|
||||||
|
|
||||||
|
// Unload while the load is in flight MUST be refused, not silently
|
||||||
|
// no-op'd and then overwritten by the load's install step.
|
||||||
|
let result = engine.unload();
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(EngineError::AlreadyLoading)),
|
||||||
|
"unload during a load must surface AlreadyLoading, got {result:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
release_load.wait();
|
||||||
|
loader_handle.join().unwrap();
|
||||||
|
|
||||||
|
// After the load completes the flag clears and unload succeeds.
|
||||||
|
assert!(!engine.is_loading());
|
||||||
|
engine
|
||||||
|
.unload()
|
||||||
|
.expect("unload after load completes must succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rule_based_extract_tasks ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rule_based_extract_finds_explicit_imperatives() {
|
||||||
|
let t = "I need to send Sarah the report tomorrow. Don't forget the slide deck.";
|
||||||
|
let tasks = rule_based_extract_tasks(t);
|
||||||
|
assert_eq!(tasks.len(), 2, "expected 2 tasks, got: {tasks:?}");
|
||||||
|
assert!(
|
||||||
|
tasks[0].to_lowercase().contains("send sarah"),
|
||||||
|
"first task should mention 'send sarah': {tasks:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tasks[1].to_lowercase().contains("slide deck"),
|
||||||
|
"second task should mention 'slide deck': {tasks:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rule_based_extract_caps_at_max() {
|
||||||
|
let t = "Send email. Write doc. Call client. Fix bug. Update spec. Review PR. Check tests. Finish report. Schedule meeting. Book hotel. Order parts. Buy supplies.";
|
||||||
|
let tasks = rule_based_extract_tasks(t);
|
||||||
|
assert!(
|
||||||
|
tasks.len() <= MAX_RULE_BASED_TASKS,
|
||||||
|
"expected at most {MAX_RULE_BASED_TASKS} tasks, got {}",
|
||||||
|
tasks.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rule_based_extract_returns_empty_for_no_imperatives() {
|
||||||
|
let t = "The weather is lovely today. The garden looks nice.";
|
||||||
|
let tasks = rule_based_extract_tasks(t);
|
||||||
|
assert_eq!(tasks.len(), 0, "expected 0 tasks, got: {tasks:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rule_based_extract_dedupes_repeated_sentences() {
|
||||||
|
let t = "I need to send the report. I need to send the report.";
|
||||||
|
let tasks = rule_based_extract_tasks(t);
|
||||||
|
assert_eq!(
|
||||||
|
tasks.len(),
|
||||||
|
1,
|
||||||
|
"expected 1 deduplicated task, got: {tasks:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,6 +364,18 @@ where
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DownloadError::Http(e.to_string()))?;
|
.map_err(|e| DownloadError::Http(e.to_string()))?;
|
||||||
if resume_from > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
if resume_from > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||||
|
// Server downgraded from Range-aware to full-body 200 (typically a
|
||||||
|
// mirror / CDN that advertises `Accept-Ranges` but doesn't honour a
|
||||||
|
// mid-stream resume). The existing `.part` bytes are stale — they
|
||||||
|
// cannot be stitched onto a fresh 200 stream. Unlink them BEFORE
|
||||||
|
// returning so the next `download_model()` call starts from
|
||||||
|
// `resume_from = 0` and succeeds. Without this unlink the user is
|
||||||
|
// wedged: every retry sends the same Range header, the server
|
||||||
|
// returns 200 again, and `ResumeUnsupported` fires forever until
|
||||||
|
// the user manually calls `delete_model()`. That is itself a
|
||||||
|
// reversibility kill in the same family as Rev-1 (atomiser
|
||||||
|
// 2026-05-12); fixed in Phase B.3 audit.
|
||||||
|
tokio::fs::remove_file(&tmp).await.ok();
|
||||||
return Err(DownloadError::ResumeUnsupported);
|
return Err(DownloadError::ResumeUnsupported);
|
||||||
}
|
}
|
||||||
if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT
|
if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT
|
||||||
@@ -512,6 +524,76 @@ mod tests {
|
|||||||
server_task.await.unwrap();
|
server_task.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase B.3 audit residual (2026-05-14). The original Rev-1 fix
|
||||||
|
/// stopped the pre-emptive unlink of `dest` on SHA mismatch, but it
|
||||||
|
/// did NOT clean up `.part` when `download_impl` returned
|
||||||
|
/// `ResumeUnsupported`. That meant a transient mirror downgrade
|
||||||
|
/// (server returns 200 to a Range request) left a stale `.part` on
|
||||||
|
/// disk that every subsequent retry kept feeding back into the same
|
||||||
|
/// failing Range request — wedged until the user manually called
|
||||||
|
/// `delete_model()`. Same reversibility-kill family as Rev-1.
|
||||||
|
///
|
||||||
|
/// We spin a server that ignores the Range header and returns 200
|
||||||
|
/// with full body. With a pre-existing `.part` the call must fail
|
||||||
|
/// with `ResumeUnsupported` AND the stale `.part` must be gone, so
|
||||||
|
/// a follow-up call would compute `resume_from = 0` and start
|
||||||
|
/// fresh.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resume_unsupported_unlinks_part_so_retry_starts_fresh() {
|
||||||
|
let body = b"fresh full body returned by server ignoring Range header".to_vec();
|
||||||
|
let expected_sha = format!("{:x}", Sha256::digest(&body));
|
||||||
|
|
||||||
|
let server = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = server.local_addr().unwrap();
|
||||||
|
let content = body.clone();
|
||||||
|
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let (mut socket, _) = server.accept().await.unwrap();
|
||||||
|
let mut request = vec![0u8; 2048];
|
||||||
|
let _ = socket.read(&mut request).await.unwrap();
|
||||||
|
// Deliberately ignore Range header and return 200 with the
|
||||||
|
// full body — the case the downloader must recover from
|
||||||
|
// without leaving a stuck `.part`.
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
|
||||||
|
content.len()
|
||||||
|
);
|
||||||
|
socket.write_all(response.as_bytes()).await.unwrap();
|
||||||
|
socket.write_all(&content).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let dest = dir.path().join("fixture.gguf");
|
||||||
|
let part = dest.with_extension("gguf.part");
|
||||||
|
// Pretend a previous interrupted attempt left 10 stale bytes.
|
||||||
|
tokio::fs::write(&part, b"STALEBYTES").await.unwrap();
|
||||||
|
assert!(part.exists());
|
||||||
|
|
||||||
|
let err = download_impl(
|
||||||
|
&format!("http://{addr}/fixture.gguf"),
|
||||||
|
&expected_sha,
|
||||||
|
&dest,
|
||||||
|
|_, _| {},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("server ignoring Range must surface ResumeUnsupported");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(err, DownloadError::ResumeUnsupported),
|
||||||
|
"expected ResumeUnsupported, got: {err:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!part.exists(),
|
||||||
|
"ResumeUnsupported must unlink .part so the next attempt starts fresh"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dest.exists(),
|
||||||
|
"dest must not have been written — only the unlink should run"
|
||||||
|
);
|
||||||
|
|
||||||
|
server_task.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Rev-1 regression (atomiser 2026-05-12). Before the fix the
|
/// Rev-1 regression (atomiser 2026-05-12). Before the fix the
|
||||||
/// SHA-mismatch path in `download_model` deleted the existing
|
/// SHA-mismatch path in `download_model` deleted the existing
|
||||||
/// file BEFORE the network call. A failing download then left
|
/// file BEFORE the network call. A failing download then left
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-mcp"
|
name = "lumotia-mcp"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Read-only MCP stdio server exposing Lumotia transcripts and tasks to external agents"
|
description = "Read-only MCP stdio server exposing Lumotia transcripts and tasks to external agents"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-storage"
|
name = "lumotia-storage"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "SQLite persistence, BM25 search, and file storage for Lumotia"
|
description = "SQLite persistence, BM25 search, and file storage for Lumotia"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -21,11 +23,26 @@ tokio = { version = "1", features = ["rt", "sync", "macros"] }
|
|||||||
# Serialisation (DailyCompletionCount exposed to frontend via Tauri commands)
|
# Serialisation (DailyCompletionCount exposed to frontend via Tauri commands)
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
# Logging
|
# Structured logging via `tracing` so storage events bridge into the
|
||||||
log = "0.4"
|
# subscriber installed by src-tauri/src/lib.rs::install_subscriber and
|
||||||
|
# land in both stderr and the rolling lumotia.log forensic stream. The
|
||||||
|
# storage crate was on the `log` crate up to Phase B.8; without a
|
||||||
|
# log→tracing bridge (e.g. tracing-log::LogTracer) those events
|
||||||
|
# vanished even though the EnvFilter directive `lumotia_storage=info`
|
||||||
|
# advertised them as visible.
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
# Structured error derivation for lumotia_storage::Error.
|
# Structured error derivation for lumotia_storage::Error.
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
|
|
||||||
# UUIDs for profile + profile_terms ids (v7 random).
|
# UUIDs for profile + profile_terms ids (v7 random).
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Real-file tempdirs for integration tests that need an on-disk DB (the
|
||||||
|
# in-memory `sqlite::memory:` connection in src/database.rs unit tests
|
||||||
|
# doesn't cover the rename-then-reopen path the rebrand migration exercises).
|
||||||
|
tempfile = "3"
|
||||||
|
# `rt-multi-thread` is required for the `#[tokio::test(flavor = "multi_thread")]`
|
||||||
|
# variants that drive blocking lumotia_core::paths migrations from async tests.
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros"] }
|
||||||
|
|||||||
@@ -181,15 +181,13 @@ pub async fn list_transcripts_paged(
|
|||||||
/// Excludes soft-deleted (deleted_at IS NOT NULL) rows — they only count
|
/// Excludes soft-deleted (deleted_at IS NOT NULL) rows — they only count
|
||||||
/// toward the trash view.
|
/// toward the trash view.
|
||||||
pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
|
pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
|
||||||
let n: i64 = sqlx::query_scalar(
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE deleted_at IS NULL")
|
||||||
"SELECT COUNT(*) FROM transcripts WHERE deleted_at IS NULL",
|
.fetch_one(pool)
|
||||||
)
|
.await
|
||||||
.fetch_one(pool)
|
.map_err(|source| Error::Query {
|
||||||
.await
|
operation: "count_transcripts".into(),
|
||||||
.map_err(|source| Error::Query {
|
source,
|
||||||
operation: "count_transcripts".into(),
|
})?;
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,17 +335,16 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
// (which filters deleted_at IS NULL) so a double-delete still finds
|
// (which filters deleted_at IS NULL) so a double-delete still finds
|
||||||
// the row and the second call is a no-op cleanup rather than an
|
// the row and the second call is a no-op cleanup rather than an
|
||||||
// error.
|
// error.
|
||||||
let audio_path: Option<String> = sqlx::query_scalar(
|
let audio_path: Option<String> =
|
||||||
"SELECT audio_path FROM transcripts WHERE id = ?",
|
sqlx::query_scalar("SELECT audio_path FROM transcripts WHERE id = ?")
|
||||||
)
|
.bind(id)
|
||||||
.bind(id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await
|
||||||
.await
|
.map_err(|source| Error::Query {
|
||||||
.map_err(|source| Error::Query {
|
operation: "delete_transcript".into(),
|
||||||
operation: "delete_transcript".into(),
|
source,
|
||||||
source,
|
})?
|
||||||
})?
|
.flatten();
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
"UPDATE transcripts SET deleted_at = datetime('now') \
|
"UPDATE transcripts SET deleted_at = datetime('now') \
|
||||||
@@ -369,7 +366,7 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
if let Some(path) = audio_path.as_deref() {
|
if let Some(path) = audio_path.as_deref() {
|
||||||
if let Err(err) = tokio::fs::remove_file(path).await {
|
if let Err(err) = tokio::fs::remove_file(path).await {
|
||||||
if err.kind() != std::io::ErrorKind::NotFound {
|
if err.kind() != std::io::ErrorKind::NotFound {
|
||||||
log::warn!(
|
tracing::warn!(
|
||||||
target: "lumotia_storage",
|
target: "lumotia_storage",
|
||||||
"delete_transcript: failed to remove audio file at {path}: {err}"
|
"delete_transcript: failed to remove audio file at {path}: {err}"
|
||||||
);
|
);
|
||||||
@@ -390,22 +387,30 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
///
|
///
|
||||||
/// Audio files are also best-effort removed here in case the original
|
/// Audio files are also best-effort removed here in case the original
|
||||||
/// soft-delete failed at the filesystem layer (Rev-3 belt-and-braces).
|
/// soft-delete failed at the filesystem layer (Rev-3 belt-and-braces).
|
||||||
pub async fn purge_deleted_transcripts(
|
///
|
||||||
pool: &SqlitePool,
|
/// **Atomicity (Phase B.4 audit fix 2026-05-14):** the prior form was a
|
||||||
older_than_days: i64,
|
/// two-statement SELECT-then-DELETE-WHERE-id-IN sequence. If a row was
|
||||||
) -> Result<u64> {
|
/// restored between the SELECT and the DELETE (`restore_transcript`
|
||||||
// Collect (id, audio_path) BEFORE the DELETE so we still have the
|
/// clearing `deleted_at`), the DELETE still hard-deleted the now-live
|
||||||
// paths to clean up after the row is gone.
|
/// row and removed its audio file — bypassing the soft-delete safety
|
||||||
|
/// contract Rev-2 was added to enforce. We now use a single
|
||||||
|
/// `DELETE … RETURNING` so the row filter is re-evaluated atomically at
|
||||||
|
/// DELETE time and the returned `audio_path`s are guaranteed to belong
|
||||||
|
/// to rows that this call actually hard-deleted. This also removes the
|
||||||
|
/// chunking concern (no `IN(…)` clause means no SQLITE_MAX_VARIABLE_NUMBER
|
||||||
|
/// ceiling).
|
||||||
|
pub async fn purge_deleted_transcripts(pool: &SqlitePool, older_than_days: i64) -> Result<u64> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, audio_path FROM transcripts \
|
"DELETE FROM transcripts \
|
||||||
WHERE deleted_at IS NOT NULL \
|
WHERE deleted_at IS NOT NULL \
|
||||||
AND deleted_at < datetime('now', ?)",
|
AND deleted_at < datetime('now', ?) \
|
||||||
|
RETURNING audio_path",
|
||||||
)
|
)
|
||||||
.bind(format!("-{older_than_days} days"))
|
.bind(format!("-{older_than_days} days"))
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|source| Error::Query {
|
.map_err(|source| Error::Query {
|
||||||
operation: "purge_deleted_transcripts_select".into(),
|
operation: "purge_deleted_transcripts".into(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -413,44 +418,20 @@ pub async fn purge_deleted_transcripts(
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ids: Vec<String> = Vec::with_capacity(rows.len());
|
|
||||||
let mut audio_paths: Vec<String> = Vec::new();
|
let mut audio_paths: Vec<String> = Vec::new();
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let id: String = row.get("id");
|
|
||||||
let audio: Option<String> = row.get("audio_path");
|
let audio: Option<String> = row.get("audio_path");
|
||||||
ids.push(id);
|
|
||||||
if let Some(p) = audio {
|
if let Some(p) = audio {
|
||||||
audio_paths.push(p);
|
audio_paths.push(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the IN clause manually because sqlx 0.8 doesn't expand Vec
|
|
||||||
// bindings; we batch into chunks of 200 to stay well clear of
|
|
||||||
// SQLITE_MAX_VARIABLE_NUMBER (default 999).
|
|
||||||
let mut total: u64 = 0;
|
|
||||||
for chunk in ids.chunks(200) {
|
|
||||||
let placeholders = std::iter::repeat("?")
|
|
||||||
.take(chunk.len())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
let sql = format!("DELETE FROM transcripts WHERE id IN ({placeholders})");
|
|
||||||
let mut q = sqlx::query(&sql);
|
|
||||||
for id in chunk {
|
|
||||||
q = q.bind(id);
|
|
||||||
}
|
|
||||||
let res = q.execute(pool).await.map_err(|source| Error::Query {
|
|
||||||
operation: "purge_deleted_transcripts_delete".into(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
total += res.rows_affected();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort audio cleanup. NotFound is the expected case for rows
|
// Best-effort audio cleanup. NotFound is the expected case for rows
|
||||||
// whose audio was already removed at soft-delete time.
|
// whose audio was already removed at soft-delete time.
|
||||||
for path in &audio_paths {
|
for path in &audio_paths {
|
||||||
if let Err(err) = tokio::fs::remove_file(path).await {
|
if let Err(err) = tokio::fs::remove_file(path).await {
|
||||||
if err.kind() != std::io::ErrorKind::NotFound {
|
if err.kind() != std::io::ErrorKind::NotFound {
|
||||||
log::warn!(
|
tracing::warn!(
|
||||||
target: "lumotia_storage",
|
target: "lumotia_storage",
|
||||||
"purge_deleted_transcripts: failed to remove audio file at {path}: {err}"
|
"purge_deleted_transcripts: failed to remove audio file at {path}: {err}"
|
||||||
);
|
);
|
||||||
@@ -458,7 +439,7 @@ pub async fn purge_deleted_transcripts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(total)
|
Ok(rows.len() as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List soft-deleted transcripts (the "trash" view), most-recently-deleted
|
/// List soft-deleted transcripts (the "trash" view), most-recently-deleted
|
||||||
@@ -1737,6 +1718,159 @@ pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) ->
|
|||||||
Ok(row.get::<i64, _>("id"))
|
Ok(row.get::<i64, _>("id"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Onboarding events ---
|
||||||
|
|
||||||
|
/// Row returned by [`list_onboarding_events`].
|
||||||
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct OnboardingEventRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub event: String,
|
||||||
|
pub completed_at: i64,
|
||||||
|
pub version: String,
|
||||||
|
pub skipped: bool,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row returned by [`list_lumotia_events`].
|
||||||
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct LumotiaEventRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub kind: String,
|
||||||
|
pub occurred_at: i64,
|
||||||
|
pub payload: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a single onboarding step event.
|
||||||
|
///
|
||||||
|
/// `now` is a Unix timestamp (seconds) — the caller is responsible for
|
||||||
|
/// computing it so the helper stays testable without a clock dependency.
|
||||||
|
pub async fn insert_onboarding_event(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
event: &str,
|
||||||
|
version: &str,
|
||||||
|
skipped: bool,
|
||||||
|
notes: Option<&str>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO onboarding_events (event, completed_at, version, skipped, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(event)
|
||||||
|
.bind(now)
|
||||||
|
.bind(version)
|
||||||
|
.bind(skipped as i64)
|
||||||
|
.bind(notes)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::Query {
|
||||||
|
operation: "insert_onboarding_event".into(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all onboarding events, oldest first.
|
||||||
|
pub async fn list_onboarding_events(pool: &SqlitePool) -> Result<Vec<OnboardingEventRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, event, completed_at, version, skipped, notes
|
||||||
|
FROM onboarding_events
|
||||||
|
ORDER BY id ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::Query {
|
||||||
|
operation: "list_onboarding_events".into(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| OnboardingEventRow {
|
||||||
|
id: r.get("id"),
|
||||||
|
event: r.get("event"),
|
||||||
|
completed_at: r.get("completed_at"),
|
||||||
|
version: r.get("version"),
|
||||||
|
skipped: r.get::<i64, _>("skipped") != 0,
|
||||||
|
notes: r.get("notes"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the user has ever recorded a `completed` or `skipped`
|
||||||
|
/// onboarding event — i.e. they do not need to see onboarding again.
|
||||||
|
pub async fn has_completed_onboarding(pool: &SqlitePool) -> Result<bool> {
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM onboarding_events WHERE event IN ('completed', 'skipped')",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::Query {
|
||||||
|
operation: "has_completed_onboarding".into(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a single opt-in activation log event.
|
||||||
|
///
|
||||||
|
/// `now` is a Unix timestamp (seconds).
|
||||||
|
pub async fn insert_lumotia_event(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
kind: &str,
|
||||||
|
payload: Option<&str>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("INSERT INTO lumotia_events (kind, occurred_at, payload) VALUES (?, ?, ?)")
|
||||||
|
.bind(kind)
|
||||||
|
.bind(now)
|
||||||
|
.bind(payload)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::Query {
|
||||||
|
operation: "insert_lumotia_event".into(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all lumotia events, oldest first.
|
||||||
|
pub async fn list_lumotia_events(pool: &SqlitePool) -> Result<Vec<LumotiaEventRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, kind, occurred_at, payload
|
||||||
|
FROM lumotia_events
|
||||||
|
ORDER BY id ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::Query {
|
||||||
|
operation: "list_lumotia_events".into(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| LumotiaEventRow {
|
||||||
|
id: r.get("id"),
|
||||||
|
kind: r.get("kind"),
|
||||||
|
occurred_at: r.get("occurred_at"),
|
||||||
|
payload: r.get("payload"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all rows from `lumotia_events`.
|
||||||
|
pub async fn clear_lumotia_events(pool: &SqlitePool) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM lumotia_events")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|source| Error::Query {
|
||||||
|
operation: "clear_lumotia_events".into(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the most recent feedback rows for a given target type, scoped to
|
/// Fetch the most recent feedback rows for a given target type, scoped to
|
||||||
/// the active profile. Used by the prompt builder to gather few-shot
|
/// the active profile. Used by the prompt builder to gather few-shot
|
||||||
/// exemplars. Orders by `created_at DESC` so the most recent corrections
|
/// exemplars. Orders by `created_at DESC` so the most recent corrections
|
||||||
@@ -3067,7 +3201,10 @@ mod tests {
|
|||||||
assert_eq!(second, (0, 0));
|
assert_eq!(second, (0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minimal_transcript(id: &'static str, audio_path: Option<&'static str>) -> InsertTranscriptParams<'static> {
|
fn minimal_transcript(
|
||||||
|
id: &'static str,
|
||||||
|
audio_path: Option<&'static str>,
|
||||||
|
) -> InsertTranscriptParams<'static> {
|
||||||
InsertTranscriptParams {
|
InsertTranscriptParams {
|
||||||
id,
|
id,
|
||||||
text: "soft-delete fixture",
|
text: "soft-delete fixture",
|
||||||
@@ -3100,14 +3237,16 @@ mod tests {
|
|||||||
|
|
||||||
delete_transcript(&pool, "t-soft").await.unwrap();
|
delete_transcript(&pool, "t-soft").await.unwrap();
|
||||||
|
|
||||||
let deleted_at: Option<String> = sqlx::query_scalar(
|
let deleted_at: Option<String> =
|
||||||
"SELECT deleted_at FROM transcripts WHERE id = ?",
|
sqlx::query_scalar("SELECT deleted_at FROM transcripts WHERE id = ?")
|
||||||
)
|
.bind("t-soft")
|
||||||
.bind("t-soft")
|
.fetch_one(&pool)
|
||||||
.fetch_one(&pool)
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
assert!(
|
||||||
assert!(deleted_at.is_some(), "row should still exist with deleted_at set");
|
deleted_at.is_some(),
|
||||||
|
"row should still exist with deleted_at set"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3116,10 +3255,7 @@ mod tests {
|
|||||||
// soft-delete actually flipped a row. A repeat delete is a
|
// soft-delete actually flipped a row. A repeat delete is a
|
||||||
// no-op and does NOT fail when the file is already gone.
|
// no-op and does NOT fail when the file is already gone.
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let tmp = std::env::temp_dir().join(format!(
|
let tmp = std::env::temp_dir().join(format!("lumotia-test-{}.wav", std::process::id()));
|
||||||
"lumotia-test-{}.wav",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
std::fs::write(&tmp, b"fake wav").unwrap();
|
std::fs::write(&tmp, b"fake wav").unwrap();
|
||||||
let path_owned = tmp.to_string_lossy().to_string();
|
let path_owned = tmp.to_string_lossy().to_string();
|
||||||
let path_static: &'static str = Box::leak(path_owned.into_boxed_str());
|
let path_static: &'static str = Box::leak(path_owned.into_boxed_str());
|
||||||
@@ -3130,7 +3266,10 @@ mod tests {
|
|||||||
assert!(tmp.exists(), "fixture file should exist pre-delete");
|
assert!(tmp.exists(), "fixture file should exist pre-delete");
|
||||||
|
|
||||||
delete_transcript(&pool, "t-audio").await.unwrap();
|
delete_transcript(&pool, "t-audio").await.unwrap();
|
||||||
assert!(!tmp.exists(), "audio file should be removed by delete_transcript");
|
assert!(
|
||||||
|
!tmp.exists(),
|
||||||
|
"audio file should be removed by delete_transcript"
|
||||||
|
);
|
||||||
|
|
||||||
// Repeat delete must not surface an error even though both the
|
// Repeat delete must not surface an error even though both the
|
||||||
// soft-delete UPDATE is a no-op AND the audio file is already gone.
|
// soft-delete UPDATE is a no-op AND the audio file is already gone.
|
||||||
@@ -3184,33 +3323,212 @@ mod tests {
|
|||||||
delete_transcript(&pool, "t-new").await.unwrap();
|
delete_transcript(&pool, "t-new").await.unwrap();
|
||||||
|
|
||||||
// Backdate t-old past the 30-day window. t-new keeps "now".
|
// Backdate t-old past the 30-day window. t-new keeps "now".
|
||||||
sqlx::query(
|
sqlx::query("UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?")
|
||||||
"UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?",
|
.bind("t-old")
|
||||||
)
|
.execute(&pool)
|
||||||
.bind("t-old")
|
.await
|
||||||
.execute(&pool)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let purged = purge_deleted_transcripts(&pool, 30).await.unwrap();
|
let purged = purge_deleted_transcripts(&pool, 30).await.unwrap();
|
||||||
assert_eq!(purged, 1, "only t-old should be hard-deleted");
|
assert_eq!(purged, 1, "only t-old should be hard-deleted");
|
||||||
|
|
||||||
let old_exists: Option<String> = sqlx::query_scalar(
|
let old_exists: Option<String> =
|
||||||
"SELECT id FROM transcripts WHERE id = ?",
|
sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
|
||||||
)
|
.bind("t-old")
|
||||||
.bind("t-old")
|
.fetch_optional(&pool)
|
||||||
.fetch_optional(&pool)
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert!(old_exists.is_none(), "t-old should be hard-gone");
|
assert!(old_exists.is_none(), "t-old should be hard-gone");
|
||||||
|
|
||||||
let new_exists: Option<String> = sqlx::query_scalar(
|
let new_exists: Option<String> =
|
||||||
"SELECT id FROM transcripts WHERE id = ?",
|
sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
|
||||||
|
.bind("t-new")
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
new_exists.is_some(),
|
||||||
|
"t-new still inside the retention window"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase B.4 audit regression (2026-05-14). Asserts the
|
||||||
|
/// `DELETE … RETURNING` form correctly couples row-removal with the
|
||||||
|
/// audio-cleanup loop: only rows the DELETE actually affected get
|
||||||
|
/// their audio file removed, and rows outside the retention window
|
||||||
|
/// are untouched.
|
||||||
|
///
|
||||||
|
/// The race we fixed (restore between SELECT and DELETE in the old
|
||||||
|
/// two-statement form) requires fault injection between the two
|
||||||
|
/// statements to exercise deterministically. The atomic single-
|
||||||
|
/// statement form makes that race structurally impossible. We test
|
||||||
|
/// the structural property: an in-retention trashed row with its
|
||||||
|
/// audio file on disk survives purge with the audio intact, while a
|
||||||
|
/// past-retention trashed row is hard-deleted with audio removed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purge_audio_cleanup_only_fires_for_hard_deleted_rows() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
|
||||||
|
let tmpdir = tempfile::tempdir().expect("tmpdir");
|
||||||
|
let old_audio = tmpdir.path().join("old.wav");
|
||||||
|
let recent_audio = tmpdir.path().join("recent.wav");
|
||||||
|
std::fs::write(&old_audio, b"old wav").unwrap();
|
||||||
|
std::fs::write(&recent_audio, b"recent wav").unwrap();
|
||||||
|
|
||||||
|
let old_path_static: &'static str =
|
||||||
|
Box::leak(old_audio.to_string_lossy().to_string().into_boxed_str());
|
||||||
|
let recent_path_static: &'static str =
|
||||||
|
Box::leak(recent_audio.to_string_lossy().to_string().into_boxed_str());
|
||||||
|
|
||||||
|
insert_transcript(
|
||||||
|
&pool,
|
||||||
|
&minimal_transcript("t-purge-old", Some(old_path_static)),
|
||||||
)
|
)
|
||||||
.bind("t-new")
|
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(new_exists.is_some(), "t-new still inside the retention window");
|
insert_transcript(
|
||||||
|
&pool,
|
||||||
|
&minimal_transcript("t-purge-recent", Some(recent_path_static)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Manually mark both trashed without going through delete_transcript
|
||||||
|
// (which would best-effort-remove the audio files itself). Old row
|
||||||
|
// is backdated past the 30-day retention; recent row is fresh.
|
||||||
|
sqlx::query("UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?")
|
||||||
|
.bind("t-purge-old")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("UPDATE transcripts SET deleted_at = datetime('now') WHERE id = ?")
|
||||||
|
.bind("t-purge-recent")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let purged = purge_deleted_transcripts(&pool, 30).await.unwrap();
|
||||||
|
assert_eq!(purged, 1, "only t-purge-old is past retention");
|
||||||
|
|
||||||
|
let old_exists: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
|
||||||
|
.bind("t-purge-old")
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(old_exists.is_none(), "t-purge-old must be hard-deleted");
|
||||||
|
assert!(
|
||||||
|
!old_audio.exists(),
|
||||||
|
"audio for hard-deleted row must be removed by purge"
|
||||||
|
);
|
||||||
|
|
||||||
|
let recent_exists: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
|
||||||
|
.bind("t-purge-recent")
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
recent_exists.is_some(),
|
||||||
|
"t-purge-recent within retention must survive"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
recent_audio.exists(),
|
||||||
|
"audio for surviving in-retention row must NOT be removed by purge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- onboarding_events tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn onboarding_insert_and_list_roundtrip() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
insert_onboarding_event(&pool, "started", "0.1.0", false, None, 1_000_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_onboarding_event(
|
||||||
|
&pool,
|
||||||
|
"recorded_first",
|
||||||
|
"0.1.0",
|
||||||
|
false,
|
||||||
|
Some("took 45s"),
|
||||||
|
1_000_060,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rows = list_onboarding_events(&pool).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert_eq!(rows[0].event, "started");
|
||||||
|
assert_eq!(rows[0].version, "0.1.0");
|
||||||
|
assert!(!rows[0].skipped);
|
||||||
|
assert!(rows[0].notes.is_none());
|
||||||
|
assert_eq!(rows[1].event, "recorded_first");
|
||||||
|
assert_eq!(rows[1].notes.as_deref(), Some("took 45s"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn has_completed_onboarding_no_events() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
assert!(!has_completed_onboarding(&pool).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn has_completed_onboarding_only_started() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
insert_onboarding_event(&pool, "started", "0.1.0", false, None, 1_000_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!has_completed_onboarding(&pool).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn has_completed_onboarding_with_completed_event() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
insert_onboarding_event(&pool, "started", "0.1.0", false, None, 1_000_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_onboarding_event(&pool, "completed", "0.1.0", false, None, 1_000_120)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(has_completed_onboarding(&pool).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn has_completed_onboarding_with_skipped_event() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
insert_onboarding_event(&pool, "skipped", "0.1.0", true, None, 1_000_005)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(has_completed_onboarding(&pool).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lumotia_events tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lumotia_event_insert_list_clear_roundtrip() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
insert_lumotia_event(&pool, "app_launched", None, 1_000_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_lumotia_event(
|
||||||
|
&pool,
|
||||||
|
"recording_started",
|
||||||
|
Some(r#"{"profile":"default"}"#),
|
||||||
|
1_000_010,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rows = list_lumotia_events(&pool).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert_eq!(rows[0].kind, "app_launched");
|
||||||
|
assert!(rows[0].payload.is_none());
|
||||||
|
assert_eq!(rows[1].kind, "recording_started");
|
||||||
|
assert_eq!(rows[1].payload.as_deref(), Some(r#"{"profile":"default"}"#));
|
||||||
|
|
||||||
|
clear_lumotia_events(&pool).await.unwrap();
|
||||||
|
let rows_after = list_lumotia_events(&pool).await.unwrap();
|
||||||
|
assert!(rows_after.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,20 @@ pub use error::{Entity, Error, MigrationStep, OpenOp, Result};
|
|||||||
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
pub use database::{
|
pub use database::{
|
||||||
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
|
add_profile_term, clear_lumotia_events, complete_subtask_and_check_parent, complete_task,
|
||||||
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
|
count_transcripts, create_profile, delete_implementation_rule, delete_profile,
|
||||||
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
|
delete_profile_term, delete_task, delete_transcript, get_implementation_rule, get_profile,
|
||||||
get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
|
get_setting, get_task_by_id, get_transcript, has_completed_onboarding, init, init_readonly,
|
||||||
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
insert_implementation_rule, insert_lumotia_event, insert_onboarding_event, insert_subtask,
|
||||||
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
insert_task, insert_transcript, list_feedback_examples, list_implementation_rules,
|
||||||
list_transcripts, list_transcripts_paged, list_trashed_transcripts, log_error,
|
list_lumotia_events, list_onboarding_events, list_profile_terms, list_profiles,
|
||||||
mark_implementation_rule_fired, migrate_legacy_setting_keys, prune_error_log,
|
list_recent_completions, list_recent_errors, list_subtasks, list_tasks, list_transcripts,
|
||||||
purge_deleted_transcripts, record_feedback, restore_transcript, search_transcripts,
|
list_transcripts_paged, list_trashed_transcripts, log_error, mark_implementation_rule_fired,
|
||||||
set_implementation_rule_enabled, set_setting, set_task_energy, uncomplete_task,
|
migrate_legacy_setting_keys, prune_error_log, purge_deleted_transcripts, record_feedback,
|
||||||
update_profile, update_task, update_transcript, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
restore_transcript, search_transcripts, set_implementation_rule_enabled, set_setting,
|
||||||
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
||||||
RecordFeedbackParams, TaskRow, TranscriptRow,
|
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
||||||
|
ImplementationRuleRow, InsertTranscriptParams, LumotiaEventRow, OnboardingEventRow, ProfileRow,
|
||||||
|
ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow,
|
||||||
};
|
};
|
||||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||||
|
|||||||
@@ -507,6 +507,32 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
|||||||
ON transcripts(deleted_at) WHERE deleted_at IS NOT NULL;
|
ON transcripts(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
17,
|
||||||
|
"onboarding_events + lumotia_events tables",
|
||||||
|
r#"
|
||||||
|
-- onboarding_events: gates first-run, supplies time-to-first-capture metric
|
||||||
|
CREATE TABLE IF NOT EXISTS onboarding_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
completed_at INTEGER NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
skipped INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_onboarding_events_event ON onboarding_events(event);
|
||||||
|
|
||||||
|
-- lumotia_events: opt-in local activation log
|
||||||
|
CREATE TABLE IF NOT EXISTS lumotia_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
occurred_at INTEGER NOT NULL,
|
||||||
|
payload TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lumotia_events_kind ON lumotia_events(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lumotia_events_occurred ON lumotia_events(occurred_at);
|
||||||
|
"#,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||||
@@ -600,7 +626,12 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
|
|||||||
|
|
||||||
for (version, description, sql) in migrations {
|
for (version, description, sql) in migrations {
|
||||||
if *version > current {
|
if *version > current {
|
||||||
log::info!("Running migration {}: {}", version, description);
|
tracing::info!(
|
||||||
|
target: "lumotia_storage",
|
||||||
|
version,
|
||||||
|
description,
|
||||||
|
"running migration",
|
||||||
|
);
|
||||||
|
|
||||||
let mut tx = pool.begin().await.map_err(|source| Error::Migration {
|
let mut tx = pool.begin().await.map_err(|source| Error::Migration {
|
||||||
version: Some(*version),
|
version: Some(*version),
|
||||||
@@ -636,7 +667,11 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
|
|||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
log::info!("Migration {} complete", version);
|
tracing::info!(
|
||||||
|
target: "lumotia_storage",
|
||||||
|
version,
|
||||||
|
"migration complete",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,7 +707,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 16);
|
assert_eq!(count, 17);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -691,7 +726,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 16);
|
assert_eq!(count, 17);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1230,6 +1265,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migration_v17_creates_onboarding_and_lumotia_events_tables() {
|
||||||
|
// v0.1 onboarding-event tracking and opt-in local activation log.
|
||||||
|
// Verify both tables exist with the expected columns after running
|
||||||
|
// all migrations. Uses PRAGMA table_info to inspect column presence.
|
||||||
|
let pool = fk_test_pool().await;
|
||||||
|
run_migrations(&pool).await.expect("migrate");
|
||||||
|
|
||||||
|
// --- onboarding_events ---
|
||||||
|
let info = sqlx::query("PRAGMA table_info(onboarding_events)")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("pragma onboarding_events");
|
||||||
|
assert!(
|
||||||
|
!info.is_empty(),
|
||||||
|
"onboarding_events table must exist after v17"
|
||||||
|
);
|
||||||
|
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||||
|
for col in ["id", "event", "completed_at", "version", "skipped", "notes"] {
|
||||||
|
assert!(
|
||||||
|
names.contains(&col.to_string()),
|
||||||
|
"onboarding_events must have column {col}; got {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index on onboarding_events(event) must exist.
|
||||||
|
let idx_names: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT name FROM sqlite_master \
|
||||||
|
WHERE type = 'index' AND tbl_name = 'onboarding_events'",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("read onboarding_events indexes");
|
||||||
|
assert!(
|
||||||
|
idx_names.iter().any(|n| n == "idx_onboarding_events_event"),
|
||||||
|
"expected idx_onboarding_events_event, got {idx_names:?}",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- lumotia_events ---
|
||||||
|
let info2 = sqlx::query("PRAGMA table_info(lumotia_events)")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("pragma lumotia_events");
|
||||||
|
assert!(
|
||||||
|
!info2.is_empty(),
|
||||||
|
"lumotia_events table must exist after v17"
|
||||||
|
);
|
||||||
|
let names2: Vec<String> = info2.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||||
|
for col in ["id", "kind", "occurred_at", "payload"] {
|
||||||
|
assert!(
|
||||||
|
names2.contains(&col.to_string()),
|
||||||
|
"lumotia_events must have column {col}; got {names2:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both indexes on lumotia_events must exist.
|
||||||
|
let idx_names2: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT name FROM sqlite_master \
|
||||||
|
WHERE type = 'index' AND tbl_name = 'lumotia_events'",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("read lumotia_events indexes");
|
||||||
|
for idx in ["idx_lumotia_events_kind", "idx_lumotia_events_occurred"] {
|
||||||
|
assert!(
|
||||||
|
idx_names2.iter().any(|n| n == idx),
|
||||||
|
"expected index {idx}, got {idx_names2:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn migration_v16_adds_deleted_at_column_and_index() {
|
async fn migration_v16_adds_deleted_at_column_and_index() {
|
||||||
// Rev-2 / Rev-3 atomiser fix (2026-05-12). Verify the soft-delete
|
// Rev-2 / Rev-3 atomiser fix (2026-05-12). Verify the soft-delete
|
||||||
@@ -1237,7 +1343,9 @@ mod tests {
|
|||||||
// rows so the migration doesn't accidentally mark old transcripts
|
// rows so the migration doesn't accidentally mark old transcripts
|
||||||
// as deleted.
|
// as deleted.
|
||||||
let pool = fk_test_pool().await;
|
let pool = fk_test_pool().await;
|
||||||
run_migrations_up_to(&pool, 15).await.expect("migrate to v15");
|
run_migrations_up_to(&pool, 15)
|
||||||
|
.await
|
||||||
|
.expect("migrate to v15");
|
||||||
|
|
||||||
// Seed a pre-v16 row to verify backfill preserves NULL.
|
// Seed a pre-v16 row to verify backfill preserves NULL.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -1290,7 +1398,9 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("read indexes");
|
.expect("read indexes");
|
||||||
assert!(
|
assert!(
|
||||||
index_names.iter().any(|n| n == "idx_transcripts_deleted_at"),
|
index_names
|
||||||
|
.iter()
|
||||||
|
.any(|n| n == "idx_transcripts_deleted_at"),
|
||||||
"expected idx_transcripts_deleted_at, got {index_names:?}",
|
"expected idx_transcripts_deleted_at, got {index_names:?}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
282
crates/storage/tests/legacy_db_migration.rs
Normal file
282
crates/storage/tests/legacy_db_migration.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
//! End-to-end migration test for the Magnotia -> Lumotia rebrand.
|
||||||
|
//!
|
||||||
|
//! The in-crate unit tests in `crates/core/src/paths.rs` prove that the
|
||||||
|
//! migration copies bytes and renames files correctly, but they don't
|
||||||
|
//! verify that the resulting `lumotia.db` is *openable* by
|
||||||
|
//! `lumotia-storage`. A byte-perfect copy with a torn SQLite header,
|
||||||
|
//! a stale WAL pointer, or a row count off by one would still pass
|
||||||
|
//! those tests. This integration test closes that gap by:
|
||||||
|
//!
|
||||||
|
//! 1. Seeding a real on-disk `magnotia/magnotia.db` via the public
|
||||||
|
//! `lumotia_storage::init` API (which runs every schema migration
|
||||||
|
//! head-to-tail). A real transcript row is inserted via
|
||||||
|
//! `insert_transcript`, then the pool is dropped to flush + close.
|
||||||
|
//! 2. Running the rebrand migration via
|
||||||
|
//! `lumotia_core::paths::migrate_legacy_data_dir_with_pairs` with
|
||||||
|
//! the synthesised (legacy, target) pair.
|
||||||
|
//! 3. Re-opening the migrated `lumotia/lumotia.db` via `init`
|
||||||
|
//! (which re-runs migrations — they must be no-ops against the
|
||||||
|
//! already-migrated schema) and querying for the inserted
|
||||||
|
//! transcript by id.
|
||||||
|
//!
|
||||||
|
//! If any of the three steps fails — rename, reopen, or query — the
|
||||||
|
//! migration is unsafe to ship even though the unit tests pass.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use lumotia_core::paths::{migrate_legacy_data_dir_with_pairs, MigrationStatus};
|
||||||
|
use lumotia_storage::{
|
||||||
|
get_transcript, init, insert_transcript, list_transcripts, InsertTranscriptParams,
|
||||||
|
DEFAULT_PROFILE_ID,
|
||||||
|
};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const SEEDED_TRANSCRIPT_ID: &str = "t-rebrand-survivor";
|
||||||
|
const SEEDED_TRANSCRIPT_TEXT: &str =
|
||||||
|
"Migration sentinel row. If this read fails, the rebrand orphaned user data.";
|
||||||
|
const SEEDED_TRANSCRIPT_TITLE: &str = "rebrand-survivor";
|
||||||
|
|
||||||
|
/// Drop the pool and yield long enough for sqlx's background reaper to
|
||||||
|
/// close its connections. The integration test re-opens the migrated DB
|
||||||
|
/// immediately afterwards, so any sqlx-held file descriptor on Windows
|
||||||
|
/// would block the rename. tokio's `yield_now` is a single scheduler
|
||||||
|
/// hop, not a sleep — enough to flush the pool's tokio task queue.
|
||||||
|
async fn drop_and_yield(pool: sqlx::SqlitePool) {
|
||||||
|
pool.close().await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the canonical seed-row params. Centralised so the assertions
|
||||||
|
/// in each test can reference the same expected values without drift.
|
||||||
|
fn seed_params() -> InsertTranscriptParams<'static> {
|
||||||
|
InsertTranscriptParams {
|
||||||
|
id: SEEDED_TRANSCRIPT_ID,
|
||||||
|
text: SEEDED_TRANSCRIPT_TEXT,
|
||||||
|
source: "microphone",
|
||||||
|
profile_id: DEFAULT_PROFILE_ID,
|
||||||
|
title: Some(SEEDED_TRANSCRIPT_TITLE),
|
||||||
|
audio_path: None,
|
||||||
|
duration: 2.5,
|
||||||
|
engine: Some("whisper"),
|
||||||
|
model_id: Some("whisper-tiny-en"),
|
||||||
|
inference_ms: Some(420),
|
||||||
|
sample_rate: Some(16_000),
|
||||||
|
audio_channels: Some(1),
|
||||||
|
format_mode: Some("plain"),
|
||||||
|
remove_fillers: false,
|
||||||
|
british_english: true,
|
||||||
|
anti_hallucination: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed `<legacy>/magnotia.db` with the live schema head and one
|
||||||
|
/// transcript row. Returns nothing; the asserts live in the test body.
|
||||||
|
async fn seed_legacy_db(legacy_dir: &Path) {
|
||||||
|
std::fs::create_dir_all(legacy_dir).expect("create legacy dir");
|
||||||
|
let legacy_db = legacy_dir.join("magnotia.db");
|
||||||
|
|
||||||
|
let pool = init(&legacy_db).await.expect("init legacy magnotia.db");
|
||||||
|
insert_transcript(&pool, &seed_params())
|
||||||
|
.await
|
||||||
|
.expect("insert seed transcript into legacy db");
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
legacy_db.exists(),
|
||||||
|
"legacy magnotia.db should be on disk after pool drop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn legacy_db_survives_rebrand_and_remains_queryable() {
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let legacy = tmp.path().join("magnotia");
|
||||||
|
let target = tmp.path().join("lumotia");
|
||||||
|
|
||||||
|
seed_legacy_db(&legacy).await;
|
||||||
|
|
||||||
|
// Bonus: drop a non-DB file inside the legacy tree to confirm the
|
||||||
|
// migration sweeps the whole directory, not just the .db file.
|
||||||
|
let companion = legacy
|
||||||
|
.join("recordings")
|
||||||
|
.join("2026-05-13")
|
||||||
|
.join("clip.wav");
|
||||||
|
std::fs::create_dir_all(companion.parent().unwrap()).expect("nested dir");
|
||||||
|
std::fs::write(&companion, b"fake-wav-bytes").expect("write companion file");
|
||||||
|
|
||||||
|
// Migrate. Blocking I/O is fine inside the multi-thread flavour.
|
||||||
|
let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())])
|
||||||
|
.expect("migration must not error");
|
||||||
|
|
||||||
|
assert_eq!(statuses.len(), 1, "single pair must yield single status");
|
||||||
|
match &statuses[0] {
|
||||||
|
MigrationStatus::Migrated {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
renamed_db,
|
||||||
|
} => {
|
||||||
|
assert_eq!(from, &legacy);
|
||||||
|
assert_eq!(to, &target);
|
||||||
|
assert!(renamed_db, "magnotia.db must have been renamed");
|
||||||
|
}
|
||||||
|
other => panic!("expected Migrated, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!legacy.exists(), "legacy dir should be gone after rename");
|
||||||
|
assert!(target.exists(), "target dir should exist after rename");
|
||||||
|
let migrated_db = target.join("lumotia.db");
|
||||||
|
assert!(migrated_db.exists(), "lumotia.db must be at new path");
|
||||||
|
assert!(
|
||||||
|
!target.join("magnotia.db").exists(),
|
||||||
|
"old db filename must not survive at new path"
|
||||||
|
);
|
||||||
|
let migrated_companion = target
|
||||||
|
.join("recordings")
|
||||||
|
.join("2026-05-13")
|
||||||
|
.join("clip.wav");
|
||||||
|
assert!(
|
||||||
|
migrated_companion.exists(),
|
||||||
|
"non-DB files inside legacy must be carried along"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&migrated_companion).expect("read migrated companion"),
|
||||||
|
b"fake-wav-bytes",
|
||||||
|
"non-DB file contents must survive verbatim"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-open via the same public API a fresh app boot would use. This
|
||||||
|
// also re-runs `run_migrations`, which must be idempotent against
|
||||||
|
// the already-migrated schema.
|
||||||
|
let pool = init(&migrated_db)
|
||||||
|
.await
|
||||||
|
.expect("init migrated lumotia.db must succeed");
|
||||||
|
|
||||||
|
let found = get_transcript(&pool, SEEDED_TRANSCRIPT_ID)
|
||||||
|
.await
|
||||||
|
.expect("get_transcript must not error against migrated db")
|
||||||
|
.expect("seeded transcript must survive the migration");
|
||||||
|
|
||||||
|
assert_eq!(found.id, SEEDED_TRANSCRIPT_ID);
|
||||||
|
assert_eq!(found.text, SEEDED_TRANSCRIPT_TEXT);
|
||||||
|
assert_eq!(found.title.as_deref(), Some(SEEDED_TRANSCRIPT_TITLE));
|
||||||
|
|
||||||
|
// And the list view sees exactly one row, ruling out duplicates or
|
||||||
|
// schema-rebuild-from-empty (which would yield zero).
|
||||||
|
let listed = list_transcripts(&pool, 100)
|
||||||
|
.await
|
||||||
|
.expect("list_transcripts must not error against migrated db");
|
||||||
|
assert_eq!(
|
||||||
|
listed.len(),
|
||||||
|
1,
|
||||||
|
"exactly one transcript should be visible post-migration"
|
||||||
|
);
|
||||||
|
assert_eq!(listed[0].id, SEEDED_TRANSCRIPT_ID);
|
||||||
|
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn rerun_after_migration_is_idempotent_and_preserves_user_state() {
|
||||||
|
// First boot: migrate. Second boot: legacy preserved? Per the design
|
||||||
|
// the legacy DOES NOT preserve (rename moves it; see paths.rs
|
||||||
|
// rename_or_copy_tree). So the second run sees no legacy and yields
|
||||||
|
// NoLegacyFound. This test exists to nail down that contract — if a
|
||||||
|
// future change starts copying-rather-than-renaming, the test will
|
||||||
|
// catch the subsequent loss of idempotency.
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let legacy = tmp.path().join("magnotia");
|
||||||
|
let target = tmp.path().join("lumotia");
|
||||||
|
|
||||||
|
seed_legacy_db(&legacy).await;
|
||||||
|
|
||||||
|
let first = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())])
|
||||||
|
.expect("first migration");
|
||||||
|
assert!(matches!(first[0], MigrationStatus::Migrated { .. }));
|
||||||
|
|
||||||
|
// User immediately starts using the app: writes new data to the
|
||||||
|
// migrated DB. The follow-up reboot must NOT clobber this.
|
||||||
|
let pool = init(&target.join("lumotia.db"))
|
||||||
|
.await
|
||||||
|
.expect("post-migrate init");
|
||||||
|
insert_transcript(
|
||||||
|
&pool,
|
||||||
|
&InsertTranscriptParams {
|
||||||
|
id: "t-post-migration",
|
||||||
|
text: "Written after the rebrand boot.",
|
||||||
|
..seed_params()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("insert new row post-migration");
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
// Second boot: no legacy on disk, nothing to do. Note empty input
|
||||||
|
// yields a single NoLegacyFound entry by contract.
|
||||||
|
let second = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("second migration");
|
||||||
|
assert_eq!(second, vec![MigrationStatus::NoLegacyFound]);
|
||||||
|
|
||||||
|
// Open and confirm both rows present + user's post-migration write
|
||||||
|
// intact.
|
||||||
|
let pool = init(&target.join("lumotia.db"))
|
||||||
|
.await
|
||||||
|
.expect("reopen after second boot");
|
||||||
|
let listed = list_transcripts(&pool, 100).await.expect("list");
|
||||||
|
let ids: Vec<_> = listed.iter().map(|r| r.id.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
ids.contains(&SEEDED_TRANSCRIPT_ID),
|
||||||
|
"pre-migration row should survive second boot: {ids:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ids.contains(&"t-post-migration"),
|
||||||
|
"post-migration row should survive second boot: {ids:?}"
|
||||||
|
);
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn both_paths_present_refuses_to_overwrite_user_data() {
|
||||||
|
// Reproduces the "stale legacy + freshly-installed lumotia" scenario:
|
||||||
|
// user reinstalls after deleting their data dir manually, or runs an
|
||||||
|
// older + newer build side-by-side. The migration must NOT clobber
|
||||||
|
// the lumotia-side DB even if the legacy contains a richer one —
|
||||||
|
// user authority on what's authoritative.
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let legacy = tmp.path().join("magnotia");
|
||||||
|
let target = tmp.path().join("lumotia");
|
||||||
|
|
||||||
|
// Both DBs exist; the legacy has the seeded row, the target is
|
||||||
|
// freshly initialised with no rows.
|
||||||
|
seed_legacy_db(&legacy).await;
|
||||||
|
let pool = init(&target.join("lumotia.db")).await.expect("seed target");
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())])
|
||||||
|
.expect("migration must not error in both-exist case");
|
||||||
|
|
||||||
|
assert_eq!(statuses.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
statuses[0],
|
||||||
|
MigrationStatus::TargetAlreadyExists {
|
||||||
|
target: target.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Critical: target's empty DB must be untouched.
|
||||||
|
let pool = init(&target.join("lumotia.db"))
|
||||||
|
.await
|
||||||
|
.expect("reopen target post-decision");
|
||||||
|
let listed = list_transcripts(&pool, 100).await.expect("list");
|
||||||
|
assert_eq!(
|
||||||
|
listed.len(),
|
||||||
|
0,
|
||||||
|
"target's user-installed empty DB must not have been merged with legacy"
|
||||||
|
);
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
// And the legacy DB must still be on disk for manual recovery —
|
||||||
|
// we don't quietly delete user data the migration refused to copy.
|
||||||
|
assert!(
|
||||||
|
legacy.join("magnotia.db").exists(),
|
||||||
|
"legacy DB must be preserved as a backup when target exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia-transcription"
|
name = "lumotia-transcription"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Lumotia"
|
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Lumotia"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -199,8 +199,7 @@ impl LocalEngine {
|
|||||||
let backend = guard.as_mut().ok_or(Error::EngineNotLoaded)?;
|
let backend = guard.as_mut().ok_or(Error::EngineNotLoaded)?;
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let segments =
|
let segments = backend.transcribe_sync_with_abort(audio.samples(), options, abort_flag)?;
|
||||||
backend.transcribe_sync_with_abort(audio.samples(), options, abort_flag)?;
|
|
||||||
let inference_ms = start.elapsed().as_millis() as u64;
|
let inference_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
Ok(TimedTranscript {
|
Ok(TimedTranscript {
|
||||||
@@ -272,7 +271,9 @@ pub fn load_whisper(model_path: &Path) -> Result<Box<dyn Transcriber + Send>> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use transcribe_rs::{ModelCapabilities, TranscribeError, TranscribeOptions, TranscriptionResult};
|
use transcribe_rs::{
|
||||||
|
ModelCapabilities, TranscribeError, TranscribeOptions, TranscriptionResult,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_reports_not_available_before_loading() {
|
fn engine_reports_not_available_before_loading() {
|
||||||
|
|||||||
@@ -130,10 +130,7 @@ fn verified_manifest_path(dir: &Path) -> PathBuf {
|
|||||||
dir.join(".lumotia-verified")
|
dir.join(".lumotia-verified")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verified_manifest_matches(
|
fn verified_manifest_matches(entry: &lumotia_core::model_registry::ModelEntry, dir: &Path) -> bool {
|
||||||
entry: &lumotia_core::model_registry::ModelEntry,
|
|
||||||
dir: &Path,
|
|
||||||
) -> bool {
|
|
||||||
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
|
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
|
||||||
Ok(contents) => contents,
|
Ok(contents) => contents,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
@@ -760,10 +757,7 @@ mod tests {
|
|||||||
manifest_path.exists(),
|
manifest_path.exists(),
|
||||||
"final manifest must exist after atomic write"
|
"final manifest must exist after atomic write"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(!tmp_path.exists(), "stale .tmp must be removed by rename");
|
||||||
!tmp_path.exists(),
|
|
||||||
"stale .tmp must be removed by rename"
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = std::fs::read_to_string(&manifest_path).unwrap();
|
let body = std::fs::read_to_string(&manifest_path).unwrap();
|
||||||
assert!(body.starts_with("version\t1"));
|
assert!(body.starts_with("version\t1"));
|
||||||
|
|||||||
@@ -92,9 +92,7 @@ impl WhisperRsBackend {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut state = self.ctx.create_state().map_err(|e| {
|
let mut state = self.ctx.create_state().map_err(|e| {
|
||||||
Error::TranscriptionFailed(
|
Error::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
|
||||||
WhisperBackendError::State(e.to_string()).to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||||
@@ -124,9 +122,7 @@ impl WhisperRsBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.full(params, samples).map_err(|e| {
|
state.full(params, samples).map_err(|e| {
|
||||||
Error::TranscriptionFailed(
|
Error::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string())
|
||||||
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let n = state.full_n_segments();
|
let n = state.full_n_segments();
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ impl AppPaths {
|
|||||||
pub fn models_dir(&self) -> PathBuf; // <root>/models
|
pub fn models_dir(&self) -> PathBuf; // <root>/models
|
||||||
pub fn speech_model_dir(&self, id: &ModelId) -> PathBuf; // <root>/models/<id>
|
pub fn speech_model_dir(&self, id: &ModelId) -> PathBuf; // <root>/models/<id>
|
||||||
pub fn llm_models_dir(&self) -> PathBuf; // <root>/models/llm
|
pub fn llm_models_dir(&self) -> PathBuf; // <root>/models/llm
|
||||||
pub fn migration_sentinel(&self, name: &str) -> PathBuf; // <root>/.<name>.sentinel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn app_paths() -> AppPaths;
|
pub fn app_paths() -> AppPaths;
|
||||||
@@ -56,9 +55,23 @@ The `resolve_app_data_dir` function picks the root by `cfg(target_os = ...)`:
|
|||||||
|
|
||||||
The Linux legacy-path branch keeps existing users on `~/.lumotia` (early dogfooding default) without forcing a migration when the canonical XDG location is preferred.
|
The Linux legacy-path branch keeps existing users on `~/.lumotia` (early dogfooding default) without forcing a migration when the canonical XDG location is preferred.
|
||||||
|
|
||||||
### Sentinel files — `crates/core/src/paths.rs:53`
|
### Migration idempotency — no sentinel files
|
||||||
|
|
||||||
`migration_sentinel(name) -> <root>/.<name>.sentinel`. Used for one-shot data migrations outside the SQLite schema (for example, a one-off file-system reorganisation). The pattern is: write the sentinel after the migration runs successfully; check for the sentinel on next startup; skip the migration if the sentinel exists.
|
There is no sentinel-file pattern. The two boot-time migrations
|
||||||
|
(`migrate_legacy_data_dir` for the legacy magnotia data dir, and
|
||||||
|
`migrate_tauri_app_data_dir_with_paths` for the Tauri bundle-identifier
|
||||||
|
move) are both idempotent by construction: each cheaply re-probes for
|
||||||
|
the legacy path via `Path::exists()` on every boot and short-circuits
|
||||||
|
on the steady state. A sentinel file would optimise that probe but is
|
||||||
|
not required for correctness, and the cost is one syscall per legacy
|
||||||
|
candidate — negligible at startup.
|
||||||
|
|
||||||
|
If a future migration needs cross-restart "this ran already" state
|
||||||
|
(e.g. a destructive one-shot reorganisation that mutates the legacy
|
||||||
|
path in place), reach for `crates/storage/src/migrations.rs`
|
||||||
|
(the SQLite schema_version table) rather than reintroducing a
|
||||||
|
filesystem sentinel — schema_version is transactional and survives
|
||||||
|
backup/restore, sentinel files don't.
|
||||||
|
|
||||||
## Data flow / contract
|
## Data flow / contract
|
||||||
|
|
||||||
@@ -77,8 +90,6 @@ The Linux legacy-path branch keeps existing users on `~/.lumotia` (early dogfood
|
|||||||
- **`std::env::var(...).unwrap_or_else(|_| "/tmp".to_string())` is the fallback for missing `HOME`.** Should never trigger in practice; defensive.
|
- **`std::env::var(...).unwrap_or_else(|_| "/tmp".to_string())` is the fallback for missing `HOME`.** Should never trigger in practice; defensive.
|
||||||
- **`AppPaths::current()` reads env vars at every call.** Cheap, but repeated calls are wasteful. Slice 2 caches a `OnceLock<AppPaths>` so the value is resolved once at startup.
|
- **`AppPaths::current()` reads env vars at every call.** Cheap, but repeated calls are wasteful. Slice 2 caches a `OnceLock<AppPaths>` so the value is resolved once at startup.
|
||||||
- **No Windows fallback for missing `LOCALAPPDATA`.** Falls through to `.` (current working directory). On a misconfigured Windows host this could write the database next to the binary. Not great; the impact is limited to first-run scenarios where the env is broken.
|
- **No Windows fallback for missing `LOCALAPPDATA`.** Falls through to `.` (current working directory). On a misconfigured Windows host this could write the database next to the binary. Not great; the impact is limited to first-run scenarios where the env is broken.
|
||||||
- **Sentinel files are hidden on Unix (`.<name>.sentinel`) but visible on Windows.** Acceptable; sentinels live alongside `lumotia.db` so the user sees both.
|
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [Storage file paths](storage-file-paths.md) — re-exports.
|
- [Storage file paths](storage-file-paths.md) — re-exports.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ tags: [issues, release-blockers]
|
|||||||
|
|
||||||
Issues here must land before Lumotia v0.1 ships. Each is sourced from
|
Issues here must land before Lumotia v0.1 ships. Each is sourced from
|
||||||
`docs/code-review-2026-04-22.md`. When `gh` CLI is available, these
|
`docs/code-review-2026-04-22.md`. When `gh` CLI is available, these
|
||||||
should be mirrored as real GitHub issues on `jakejars/lumotia`.
|
should be mirrored as real GitHub issues on `jakeadriansames/lumotia`.
|
||||||
|
|
||||||
## CRITICAL (0 open, 3 resolved)
|
## CRITICAL (0 open, 3 resolved)
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ for file in docs/issues/rb-*.md c1-*.md c3-*.md c4-*.md run-*.md poll-*.md \
|
|||||||
native-*.md runtime-*.md power-*.md decoder-*.md llm-*.md \
|
native-*.md runtime-*.md power-*.md decoder-*.md llm-*.md \
|
||||||
keystore-*.md hotkey-*.md
|
keystore-*.md hotkey-*.md
|
||||||
set -l title (head -1 "$file" | sed 's/^# //')
|
set -l title (head -1 "$file" | sed 's/^# //')
|
||||||
gh issue create --repo jakejars/lumotia --title "$title" --body-file "$file" \
|
gh issue create --repo jakeadriansames/lumotia --title "$title" --body-file "$file" \
|
||||||
--label release-blocker
|
--label release-blocker
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|||||||
88
docs/release/apple-silicon-rb08-runbook.md
Normal file
88
docs/release/apple-silicon-rb08-runbook.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: apple-silicon-rb08-runbook
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, macos, apple-silicon, app-nap, RB-08, KI-01, runbook]
|
||||||
|
description: "10-minute step-by-step verification procedure for RB-08 — macOS App Nap on Apple Silicon. Determines whether App Nap protection is effective at runtime and instructs how to record the outcome in KNOWN-ISSUES.md."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Apple Silicon RB-08 runbook
|
||||||
|
|
||||||
|
Verification of RB-08: does Lumotia's `NSProcessInfo.beginActivityWithOptions` call actually prevent App Nap on M-series hardware? This takes roughly 10 minutes on a machine that already has the app installed.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- M-series Mac (M1, M2, M3, or M4 — any chip revision)
|
||||||
|
- Latest macOS release
|
||||||
|
- Lumotia v0.1.0 `.dmg` installed via the Gatekeeper "Open Anyway" flow (see `docs/release/install-warnings.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Baseline
|
||||||
|
|
||||||
|
Open Activity Monitor (Spotlight: `Activity Monitor`). Click the Energy tab. Find Lumotia. If the App Nap column is missing, right-click the column header bar and enable it.
|
||||||
|
|
||||||
|
Open Lumotia, bring its window to the front. Confirm App Nap shows **No** while the window is focused. This is the expected baseline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 1 — Focused recording (60 seconds)
|
||||||
|
|
||||||
|
Keep the Lumotia window in the foreground. Start a recording. Speak for 60 seconds. Stop.
|
||||||
|
|
||||||
|
**Expected:** Full 60-second transcript. App Nap stayed **No**.
|
||||||
|
|
||||||
|
**Failure:** Transcript shorter than what was spoken, or transcription did not complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 2 — Backgrounded recording (60 seconds)
|
||||||
|
|
||||||
|
Start a recording. Immediately press Command-Tab to switch to another app. Speak for 60 seconds while Lumotia is in the background. Switch back. Stop the recording.
|
||||||
|
|
||||||
|
While waiting, watch Activity Monitor: does App Nap flip to **Yes** for Lumotia?
|
||||||
|
|
||||||
|
**Expected (RB-08 mitigated):** Full 60-second transcript. App Nap stayed **No** throughout.
|
||||||
|
|
||||||
|
**Failure (RB-08 not mitigated):** Transcript contains only the first few seconds of speech. App Nap column flipped to Yes during the background period.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 3 — Long backgrounded session (5 minutes)
|
||||||
|
|
||||||
|
Same as Test 2 but leave Lumotia backgrounded for 5 minutes.
|
||||||
|
|
||||||
|
**Expected:** Full 5-minute transcript.
|
||||||
|
|
||||||
|
**Failure:** Transcript truncated. Note the approximate cutoff in seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recording the outcome
|
||||||
|
|
||||||
|
**All three tests pass — RB-08 mitigated:**
|
||||||
|
|
||||||
|
1. Tick the RB-08 item in `docs/release/v0.1-checklist.md`.
|
||||||
|
2. Open `KNOWN-ISSUES.md`, find KI-01, update its status to "fixed in v0.1", and append the test results + Mac model + macOS version.
|
||||||
|
|
||||||
|
**Test 2 or Test 3 fails — RB-08 not mitigated:**
|
||||||
|
|
||||||
|
1. Leave the RB-08 checklist item unchecked.
|
||||||
|
2. In `docs/release/v0.1-known-limitations.md`, update the macOS row to name the tested hardware/OS and state that throttling was observed when backgrounded.
|
||||||
|
3. Add the workaround: "Keep the Lumotia window focused during long dictation sessions. System-wide override: `defaults write NSGlobalDomain NSAppSleepDisabled -bool YES` (revert with `-bool NO` when done)."
|
||||||
|
4. Append the failure details to `KNOWN-ISSUES.md` KI-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting format for KNOWN-ISSUES.md
|
||||||
|
|
||||||
|
Append this block to the KI-01 entry:
|
||||||
|
|
||||||
|
```
|
||||||
|
RB-08 verification — [date]
|
||||||
|
Mac model: [e.g. MacBook Pro M2 Max, 2023]
|
||||||
|
macOS: [e.g. 15.3.2]
|
||||||
|
Test 1 (focused, 60s): PASS / FAIL
|
||||||
|
Test 2 (backgrounded, 60s): PASS / FAIL — App Nap column: [Yes / No / fluctuated]
|
||||||
|
Test 3 (backgrounded, 5min): PASS / FAIL — transcript cutoff at approx [N] seconds
|
||||||
|
Overall: MITIGATED / NOT MITIGATED
|
||||||
|
```
|
||||||
95
docs/release/code-signing-setup.md
Normal file
95
docs/release/code-signing-setup.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: code-signing-setup
|
||||||
|
type: release
|
||||||
|
tags: [release, signing, notarisation, macos, windows, secrets]
|
||||||
|
description: "Step-by-step walkthrough: procure signing certificates and set the GitHub secrets that activate conditional code-signing in the CI workflow."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code-signing setup
|
||||||
|
|
||||||
|
The CI workflow (`build.yml`) signs automatically when the relevant GitHub secrets are present. If they aren't set, the workflow still builds correctly — it just produces an unsigned `.dmg` / `.msi`. Set the secrets once and every subsequent tag push signs for free.
|
||||||
|
|
||||||
|
Linux ships clean today: the AppImage is unsigned but accompanied by a SHA-256 checksum, which is sufficient for the v0.1 trust posture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## macOS — Developer ID signing + notarisation
|
||||||
|
|
||||||
|
**Prerequisite:** enrol in the Apple Developer Program at <https://developer.apple.com> ($99/year individual or $299/year organisation). Notarisation requires an enrolled account; unsigned `.dmg` files trigger Gatekeeper warnings (documented in `docs/release/install-warnings.md`).
|
||||||
|
|
||||||
|
1. **Generate a Developer ID Application certificate.**
|
||||||
|
- Xcode → Settings → Accounts → select your Apple ID → Manage Certificates → click `+` → Developer ID Application.
|
||||||
|
- Alternatively: Apple Developer portal → Certificates, Identifiers & Profiles → Certificates → `+`.
|
||||||
|
|
||||||
|
2. **Export the certificate as a `.p12`.**
|
||||||
|
- Open Keychain Access → My Certificates → find "Developer ID Application: Your Name (TEAMID)".
|
||||||
|
- Right-click → Export → choose `.p12` format → set a strong export password. Keep the password; you'll need it next.
|
||||||
|
|
||||||
|
3. **Base64-encode the `.p12` for GitHub.**
|
||||||
|
```sh
|
||||||
|
base64 -i developer-id.p12 | pbcopy
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Set the GitHub secrets** (run from the repo root; paste when prompted):
|
||||||
|
```sh
|
||||||
|
gh secret set APPLE_CERTIFICATE # paste the base64 blob
|
||||||
|
gh secret set APPLE_CERTIFICATE_PASSWORD # the .p12 export password
|
||||||
|
gh secret set APPLE_SIGNING_IDENTITY # e.g. "Developer ID Application: Jake Sames (AB12CD34EF)"
|
||||||
|
gh secret set APPLE_ID # your Apple ID email address
|
||||||
|
gh secret set APPLE_PASSWORD # an app-specific password from appleid.apple.com
|
||||||
|
gh secret set APPLE_TEAM_ID # 10-character team ID (visible in the Developer portal)
|
||||||
|
```
|
||||||
|
To create the app-specific password: <https://appleid.apple.com> → Sign-In and Security → App-Specific Passwords → Generate.
|
||||||
|
|
||||||
|
5. **Re-tag.** The next CI run will sign and notarise the `.dmg` automatically. The `tauri-action` runner detects the env vars at build time — no workflow edits required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows — EV code-signing certificate
|
||||||
|
|
||||||
|
**Why EV and not OV?** Extended Validation certificates earn immediate SmartScreen reputation. OV certificates require download volume to "warm up" before SmartScreen stops warning users. For a new product, EV is the only practical path to a clean install experience.
|
||||||
|
|
||||||
|
1. **Purchase an EV code-signing certificate.** Reputable CAs:
|
||||||
|
- DigiCert — supports KeyLocker (cloud signing, CI-friendly)
|
||||||
|
- Sectigo — supports Code Signing on Demand
|
||||||
|
- SSL.com — supports eSigner cloud signing
|
||||||
|
Typical cost: £200–£400/year. Budget 1–5 business days for identity verification.
|
||||||
|
|
||||||
|
2. **Use the CA's cloud-signing service for CI.** EV certs ship on a USB hardware token that cannot be copied to a CI runner. Every major CA now offers a cloud equivalent:
|
||||||
|
- DigiCert KeyLocker, Sectigo CSOD, SSL.com eSigner.
|
||||||
|
- Follow the CA's CI integration guide to obtain a signing credential (usually a client certificate or API token) that does not require the physical token.
|
||||||
|
|
||||||
|
3. **Set the GitHub secrets** once you have the cloud-signing credential:
|
||||||
|
```sh
|
||||||
|
gh secret set WINDOWS_CERTIFICATE # base64-encoded cert (local) or cloud-signing client cert
|
||||||
|
gh secret set WINDOWS_CERTIFICATE_PASSWORD # cert password / PIN
|
||||||
|
```
|
||||||
|
If your CA's cloud-signing workflow requires additional env vars (e.g. a KeyLocker API key), set those as secrets and pass them in the `env:` block of the `Build (release)` step in `build.yml` — keep the existing pattern.
|
||||||
|
|
||||||
|
4. **Re-tag.** The `tauri-action` runner picks up `WINDOWS_CERTIFICATE` + `WINDOWS_CERTIFICATE_PASSWORD` and signs the `.msi` and `.exe` automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify after setting secrets
|
||||||
|
|
||||||
|
Run a test tag to confirm signing is working before the real release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag v0.1.0-test1
|
||||||
|
git push origin v0.1.0-test1
|
||||||
|
```
|
||||||
|
|
||||||
|
Watch the CI run. In the macOS job log look for `Signed using developer ID...` and in the Windows job look for `Signed using SignTool`. Once confirmed, delete the test tag:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push --delete origin v0.1.0-test1
|
||||||
|
git tag -d v0.1.0-test1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Until signing is configured
|
||||||
|
|
||||||
|
- macOS users see a Gatekeeper warning on first launch. Workaround: right-click the `.dmg` → Open. Documented in `docs/release/install-warnings.md`.
|
||||||
|
- Windows users see a SmartScreen warning on first run. Workaround: More info → Run anyway. Documented in `docs/release/install-warnings.md`.
|
||||||
|
- Linux ships clean today (AppImage + SHA-256 checksum).
|
||||||
118
docs/release/how-lumotia-is-built.md
Normal file
118
docs/release/how-lumotia-is-built.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
name: how-lumotia-is-built
|
||||||
|
type: release
|
||||||
|
tags: [release, trust, ai-assisted, process, audit, public]
|
||||||
|
description: "Public-facing trust page. Honest disclosure that Lumotia is AI-assisted human-directed software, paired with the evidence that justifies trusting it anyway: dogfood drill, atomiser audit, supply-chain pre-flight, pinned toolchain, real-data-loss bug caught and fixed. Linked from README and from the v0.1 release notes. Calibrated to the framing 'AI use is survivable; sloppy undisclosed untested AI use is not'."
|
||||||
|
---
|
||||||
|
|
||||||
|
# How Lumotia is built
|
||||||
|
|
||||||
|
Lumotia is an **AI-assisted, human-directed** project.
|
||||||
|
|
||||||
|
A single developer (Jake Sames at CORBEL) defines the product, decides the constraints, sets the privacy model, writes the tests that matter, and ships only through a repeatable quality gate. AI coding tools are used during implementation. Every release is dogfood-tested. Every release ships a known-limitations document. Every release has an audit trail in the git log.
|
||||||
|
|
||||||
|
This page exists because shipping a productivity app built this way without saying so is the wrong shape. Software you trust on your private thoughts deserves a clear answer to the question **"how was this built and how do I know it's not slop?"**
|
||||||
|
|
||||||
|
## The honest disclosure
|
||||||
|
|
||||||
|
The implementation is written with the help of AI tools — primarily Claude (Anthropic). The product direction, scope, design constraints, security boundaries, and quality gates are all human-decided.
|
||||||
|
|
||||||
|
What this means in practice:
|
||||||
|
|
||||||
|
- **A human chose every feature.** No feature in Lumotia exists because an AI suggested it. The product spec and roadmap are in `docs/brief/` and `docs/roadmap/` and predate most of the implementation work.
|
||||||
|
- **A human runs every release.** There is no agent that auto-ships. Builds go out after manual sign-off against the checklist in `docs/release/v0.1-checklist.md`.
|
||||||
|
- **AI does not have credentials.** No AI tool has access to signing keys, licence-server keys, or any production secret. Those live in `.env` files that are never read by the agent loop.
|
||||||
|
- **AI does not bypass review.** Every change goes through clippy, fmt, the workspace test suite, and (for non-trivial work) a second-model cross-review via Codex.
|
||||||
|
|
||||||
|
This is the same shape as the [RPCS3 emulator team's recent stance](https://www.gamesradar.com/games/stop-submitting-ai-slop-code-ps3-emulator-rpcs3-shuts-down-vibe-coders-tells-them-to-learn-how-to-debug-code-and-leave-behind-something-useful-to-humanity-when-youre-gone/) on AI-assisted contributions: disclose use, explain testing/review, ship something that earns its place. AI use is survivable. Sloppy, undisclosed, untested AI use is not.
|
||||||
|
|
||||||
|
## The trust evidence
|
||||||
|
|
||||||
|
Talk is cheap. The evidence is in the repository.
|
||||||
|
|
||||||
|
### A real bug that should have shipped — and didn't
|
||||||
|
|
||||||
|
On 2026/05/14, a dogfood drill against the real built binary (not unit tests) caught a startup-order race condition that would have **silently orphaned every Magnotia user's data** when they upgraded to Lumotia. The migration was running too late in the startup sequence; `init_tracing` and the WebView context were creating the destination directories before the migration could rename the source. Result: a fresh empty Lumotia install next to the legacy Magnotia data, no error, no warning, no recovery path.
|
||||||
|
|
||||||
|
The unit tests all passed. The integration tests all passed. The release would have shipped. The dogfood drill caught it.
|
||||||
|
|
||||||
|
Fix landed in commit `ff8dda0`. Drill probe added to prevent regression. The drill is at `scripts/dogfood-rebrand-drill.sh` and is part of the v0.1 release checklist.
|
||||||
|
|
||||||
|
### An adversarial code audit
|
||||||
|
|
||||||
|
Phase B of the pre-release dogfood pass examined the 15 most-load-bearing recent commits (race conditions, lifecycle, trust-boundary, time bombs, observability) and asked: **what could go wrong here that the original tests don't catch?**
|
||||||
|
|
||||||
|
Outcome: 9 surgical fixes shipped, 5 documented passes. Examples:
|
||||||
|
|
||||||
|
- A `copy_dir_recursive` fall-through that would `std::fs::copy` a FIFO — opening a FIFO for read with no writer blocks forever. A stale debug FIFO in a user's `~/.magnotia/` tree could have silently hung first launch. Hardened to surface an error rather than hang.
|
||||||
|
- An `LlmEngine::unload()` race that didn't consult the `loading` flag — a concurrent unload mid-load could see the engine "succeed at unload" while the in-flight load then installed a model behind it. Both directions now respect the flag.
|
||||||
|
- A `migrate_legacy_setting_keys` flow where `restore_transcript` between a SELECT-then-DELETE could cause `purge_deleted_transcripts` to hard-delete a live row's audio. Refactored to a single atomic `DELETE … RETURNING audio_path`.
|
||||||
|
|
||||||
|
Full audit trail: `docs/superpowers/plans/2026-05-14-phase-b-dogfood-plan.md`.
|
||||||
|
|
||||||
|
### A supply-chain pre-flight
|
||||||
|
|
||||||
|
Lumotia's frontend uses npm packages. The npm ecosystem has been hit by self-replicating worms (Shai-Hulud, mini-Shai-Hulud) that compromise legitimate packages via postinstall scripts and credential-stealing.
|
||||||
|
|
||||||
|
The defence-in-depth in place:
|
||||||
|
|
||||||
|
- The dev launcher (`run.sh`) runs `npm audit signatures` before starting Vite whenever `package-lock.json` has changed since the last successful audit. Mismatch = launch refused.
|
||||||
|
- Install discipline documented in the README is `npm ci --ignore-scripts` — blocks the postinstall vector.
|
||||||
|
- All dev dependencies are version-pinned exactly (no `^` or `~` ranges).
|
||||||
|
- The Rust toolchain is pinned to `rust-toolchain.toml` so every contributor and CI runner runs the same `rustc` / `clippy` / `rustfmt`.
|
||||||
|
|
||||||
|
When we cross-referenced our 192-package dependency tree against the published mini-Shai-Hulud affected-package list, the tree came back clean. This is preventive, not remedial.
|
||||||
|
|
||||||
|
### A read-only Model Context Protocol surface
|
||||||
|
|
||||||
|
Lumotia ships an optional MCP server (`lumotia-mcp`) so you can connect Claude Desktop, Cline, or any MCP-compatible client to your local transcript history. It is:
|
||||||
|
|
||||||
|
- **Read-only.** No write tools. No delete tools. Confirmed by direct codebase audit on 2026/05/14: zero `INSERT` / `UPDATE` / `DELETE` / `fs::write` / `fs::remove` in the crate.
|
||||||
|
- **Stdio-only.** No network listener. No TCP socket. No Unix socket. The server only reads stdin and writes stdout.
|
||||||
|
- **Structurally enforced.** The database connection uses `init_readonly`, which opens SQLite with `read_only=true` — even a bug in the server can't write to your data.
|
||||||
|
- **A separate binary.** The MCP server doesn't run inside the Tauri app. You have to explicitly launch it and wire it into your MCP client's config. It is not exposed when you just open Lumotia.
|
||||||
|
|
||||||
|
Honest nuance: when you wire `lumotia-mcp` into a client, that client gets read access to your **entire** transcript history and task list. There's no per-row permission boundary in v0.1. Treat it like you'd treat giving a tool access to a folder of personal notes.
|
||||||
|
|
||||||
|
### LLM failure cannot lose your data
|
||||||
|
|
||||||
|
Lumotia uses a local LLM (downloaded once, runs on your device) for transcript cleanup and task extraction. Models can fail. The architecture is designed so **no AI failure ever loses your transcript**.
|
||||||
|
|
||||||
|
Verified failure paths (audit on 2026/05/14):
|
||||||
|
|
||||||
|
- LLM cleanup error → rule-based cleanup output preserved; status chip flashes "failed"; raw transcript unchanged.
|
||||||
|
- Task extraction error → rule-based regex extractor takes over; tasks still extracted.
|
||||||
|
- Tag extraction error → toast surfaces; transcript untouched; you can retry.
|
||||||
|
- LLM hang → raw transcript preserved; rest of the app remains functional; LLM status chip may stay on "Cleaning up" until you restart. This is the only soft edge and is documented in `v0.1-known-limitations.md`.
|
||||||
|
|
||||||
|
### Every quality gate is automated
|
||||||
|
|
||||||
|
`cargo test --workspace` (400+ Rust tests). `cargo fmt --check`. `cargo clippy --workspace --all-targets -- -D warnings`. `npm run test` (vitest). `npm run check` (svelte-check). The dogfood drill. All run before tag. All visible in CI.
|
||||||
|
|
||||||
|
## What this page is NOT
|
||||||
|
|
||||||
|
This is not a claim that Lumotia is bug-free. No software is. Lumotia is a young product written by one person; it will have bugs and it will have rough edges.
|
||||||
|
|
||||||
|
What this page is: a commitment to **disclosure, evidence, and audit-trail**. If you find a bug, file it. If you find something the known-limitations document missed, tell us — we'll add it. If you want to inspect the code, the licence is AGPL-3.0-or-later and the repository is public.
|
||||||
|
|
||||||
|
## Anti-patterns this project deliberately avoids
|
||||||
|
|
||||||
|
The list is short and specific. Each entry is a concrete failure mode this project has chosen to design around, not a vague pledge.
|
||||||
|
|
||||||
|
- **No AI-assisted feature ships without dogfood-running it on the real binary.** Unit-test-only verification is insufficient — see the migration race-condition story above.
|
||||||
|
- **No release ships with an empty known-limitations document.** A v0.1 with no known limitations is a v0.1 with known limitations its authors haven't been honest about.
|
||||||
|
- **No telemetry exfiltration.** Anonymous local-only event counts are used for activation metrics during private beta and only when the user opts in. Nothing leaves the machine. No analytics service. No phone-home.
|
||||||
|
- **No silent AI dependency.** If a feature requires the LLM to be loaded, it tells you so. If the LLM fails, the failure is visible. The product still works without an LLM at the cost of plainer output.
|
||||||
|
- **No "we'll add an audit log later".** The audit log is the git history. Every release is reachable from a tagged commit; every commit has a message that explains what changed and why.
|
||||||
|
|
||||||
|
## Closing
|
||||||
|
|
||||||
|
You're trusting Lumotia with private dictations. Voice notes you wouldn't email anyone. Working-thought captures you'd never put on Twitter. The bar for that kind of software is higher than for a meme generator.
|
||||||
|
|
||||||
|
This page is the answer to "did you take that seriously?" Yes. Here's the evidence. Read the commits. Read the test suite. Read the known limitations. Decide for yourself.
|
||||||
|
|
||||||
|
If the answer is no — that's fine, don't install it. If the answer is yes — welcome. And tell us what we missed.
|
||||||
|
|
||||||
|
—
|
||||||
|
|
||||||
|
*Last updated: 2026/05/14, against the v0.1 ship checklist. Audit trail in `docs/superpowers/plans/2026-05-14-phase-b-dogfood-plan.md` and the commits cited above.*
|
||||||
76
docs/release/install-warnings.md
Normal file
76
docs/release/install-warnings.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: install-warnings
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, install, gatekeeper, smartscreen, sha256, user-facing]
|
||||||
|
description: "Per-platform first-install warnings for Lumotia v0.1. macOS Gatekeeper workaround (no Apple notarisation in v0.1). Windows SmartScreen workaround (no EV signing in v0.1). Linux AppImage SHA-256 verification. Linked from v0.1-release-notes.md and README.md."
|
||||||
|
---
|
||||||
|
|
||||||
|
# First-install warnings — Lumotia v0.1
|
||||||
|
|
||||||
|
When you install a new unsigned app, your OS will say something. This page tells you what to expect on each platform and what to do. The warnings are expected.
|
||||||
|
|
||||||
|
## macOS — Gatekeeper warning
|
||||||
|
|
||||||
|
**What you'll see.** One of:
|
||||||
|
|
||||||
|
- "App can't be opened because it is from an unidentified developer."
|
||||||
|
- "[App] is damaged and can't be opened. You should move it to the Trash."
|
||||||
|
|
||||||
|
**Why it happens.** Lumotia v0.1 is not notarised with an Apple Developer ID. macOS Gatekeeper blocks unsigned apps by default. Neither message means the file is damaged or malicious.
|
||||||
|
|
||||||
|
**What to do.** Two options, either works:
|
||||||
|
|
||||||
|
1. System Settings → Privacy & Security → Security section → click "Open Anyway" next to the Lumotia entry, then confirm.
|
||||||
|
2. In Finder, Control-click the app → Open → click Open in the dialog.
|
||||||
|
|
||||||
|
macOS remembers your choice; you only do this once.
|
||||||
|
|
||||||
|
**When this goes away.** When we ship a notarised build with an Apple Developer ID. Tracked in the v0.1 release checklist; see `KNOWN-ISSUES.md` for status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows — SmartScreen warning
|
||||||
|
|
||||||
|
**What you'll see.** A blue dialog: "Windows protected your PC."
|
||||||
|
|
||||||
|
**Why it happens.** Lumotia v0.1 ships without an EV code-signing certificate. SmartScreen flags installers from publishers without established reputation.
|
||||||
|
|
||||||
|
**What to do.**
|
||||||
|
|
||||||
|
1. Click "More info" in the SmartScreen dialog.
|
||||||
|
2. Click "Run anyway".
|
||||||
|
|
||||||
|
SmartScreen does not re-block the app on relaunch after you've done this once.
|
||||||
|
|
||||||
|
**When this goes away.** When we ship an EV-signed installer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linux — AppImage verification
|
||||||
|
|
||||||
|
No OS-level warning on Linux. Verify the file yourself before running it.
|
||||||
|
|
||||||
|
**Verify the download.** The release page publishes a `.sha256` file alongside the AppImage:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sha256sum -c lumotia-0.1.0-linux-x86_64.AppImage.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Or compare manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sha256sum lumotia-0.1.0-linux-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the output against the value in the `.sha256` file. A match means the file is intact.
|
||||||
|
|
||||||
|
**Mark it executable before running:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x lumotia-0.1.0-linux-x86_64.AppImage
|
||||||
|
./lumotia-0.1.0-linux-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
**GPG signing.** Not available in v0.1. When a signing key is published, it will appear on the release page.
|
||||||
|
|
||||||
|
**When this changes.** GPG signing is deferred to a near-term point release.
|
||||||
107
docs/release/privacy-and-ai-use.md
Normal file
107
docs/release/privacy-and-ai-use.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
name: privacy-and-ai-use
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, privacy, trust, ai-use, disclosure, user-facing]
|
||||||
|
description: "User-facing privacy + AI-use disclosure for Lumotia v0.1. Lists what stays local, what optionally reaches the network, what NEVER leaves the machine, the AI use disclosure (local LLM + AI-assisted implementation), the MCP server caveat, the opt-in activation log, crash-dump policy, and the open-source licence framing. Linked from README + Settings → Privacy + v0.1 release notes."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Privacy + AI-use disclosure
|
||||||
|
|
||||||
|
Lumotia v0.1 — last reviewed 2026-05-14.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What stays local
|
||||||
|
|
||||||
|
Every piece of data the app creates or captures lives only on your machine:
|
||||||
|
|
||||||
|
- Audio recordings (captured, processed, then discarded from memory — not persisted to disk by default)
|
||||||
|
- Raw transcripts (exactly what the speech engine heard, always recoverable)
|
||||||
|
- Cleaned transcripts (LLM-formatted versions; the raw is always preserved alongside)
|
||||||
|
- Extracted tasks and subtasks
|
||||||
|
- MicroStep breakdowns and focus-timer state
|
||||||
|
- Transcript and task history (SQLite database in your app-data directory)
|
||||||
|
- Downloaded speech models (Whisper, Parakeet) and LLM model files (GGUF)
|
||||||
|
- Custom vocabulary and profile terms
|
||||||
|
- Activation log events (if you opt in — see below)
|
||||||
|
- Onboarding state and preferences
|
||||||
|
|
||||||
|
Nothing in this list is ever transmitted automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What optionally reaches the network
|
||||||
|
|
||||||
|
There are exactly two outbound network paths in v0.1:
|
||||||
|
|
||||||
|
**1. Model downloads (huggingface.co)**
|
||||||
|
|
||||||
|
When you pick or download a speech model or LLM in onboarding or Settings → Models, Lumotia fetches the model file from `https://huggingface.co`. The request contains only a standard HTTP GET for a specific file URL (content-addressed, pinned to a commit hash). No account, no user identifier, no transcript content is sent. The download is initiated by your explicit action. Once downloaded, the model runs fully on-device.
|
||||||
|
|
||||||
|
**2. Update check (stub in v0.1)**
|
||||||
|
|
||||||
|
Lumotia includes a `check_for_update` command wired to `tauri-plugin-updater`. In v0.1 this function returns immediately with no update available — no network request is made. This will change in a future release; when it does, the check will be user-initiated or clearly disclosed.
|
||||||
|
|
||||||
|
**What about `npm audit` and supply-chain tooling?**
|
||||||
|
|
||||||
|
`npm audit` and the supply-chain pre-flight in `run.sh` are development-time tools only. They run when a developer builds from source, not in the installed application. The packaged app contains no npm runtime and makes no npm network calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What NEVER leaves the machine
|
||||||
|
|
||||||
|
These are hard commitments, not soft defaults:
|
||||||
|
|
||||||
|
- Your voice recordings
|
||||||
|
- Your transcript text (raw or cleaned)
|
||||||
|
- Your task content
|
||||||
|
- Your extracted MicroSteps
|
||||||
|
- Your activation log
|
||||||
|
- Your history and search index
|
||||||
|
|
||||||
|
There is no telemetry system, no analytics pipeline, no crash-reporting service, and no background process that phones home. Crash dumps and logs are stored locally and are only shared if you explicitly bundle and attach them to a support issue (see below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI use disclosure
|
||||||
|
|
||||||
|
Lumotia uses a local large language model for two narrow purposes: transcript cleanup and task extraction. The model is downloaded once and runs entirely on your device. It never sees data outside those two call sites — there is no chat surface, no persistent conversation history, and no system prompt that accumulates your content across sessions. The raw Whisper transcript is always preserved; LLM output is additive, never destructive.
|
||||||
|
|
||||||
|
The app itself was built with AI assistance. That process is documented in `docs/release/how-lumotia-is-built.md`, including the audits and guardrails applied to AI-generated code before it was committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP server caveat
|
||||||
|
|
||||||
|
Lumotia includes an optional MCP server (`lumotia-mcp`) you can wire into Claude Desktop, Cline, or any MCP-capable client. It is:
|
||||||
|
|
||||||
|
- **Read-only.** No tool can create, edit, or delete transcripts or tasks.
|
||||||
|
- **Stdio-only.** No network listener. No TCP socket. No Unix socket.
|
||||||
|
- **Off by default.** You must explicitly launch it and add it to your MCP client's configuration.
|
||||||
|
|
||||||
|
The honest thing to flag: when you wire `lumotia-mcp` into an MCP client, that client gets read access to your entire transcript history and task list. There is no per-row permission system in v0.1. Treat it the same way you would treat giving a tool access to a folder of personal notes — only enable it if you trust the client end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activation log
|
||||||
|
|
||||||
|
The activation log is opt-in and local-only. It records milestone events — first capture, first export, first task extracted — in the `lumotia_events` table in your local database. It contains no transcript text and no audio. Nothing is sent automatically. You can read it via Settings → Diagnostics → Activation log, and you can clear it or opt out from the same screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crash dumps and logs
|
||||||
|
|
||||||
|
Lumotia captures Rust panics and frontend errors to disk so you have something useful to attach if you file a bug report. Files are written to:
|
||||||
|
|
||||||
|
- `<app-data-dir>/crashes/` — panic and crash dumps
|
||||||
|
- `<app-data-dir>/logs/lumotia.log` — runtime log
|
||||||
|
|
||||||
|
Neither location contains transcript text or audio. The diagnostic-report bundler in Settings → About assembles a redacted snapshot from these directories; it skips transcript content and audio files by default. You decide whether to share the bundle.
|
||||||
|
|
||||||
|
On Linux, `<app-data-dir>` is typically `~/.local/share/uk.co.corbel.lumotia`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Your data, your machine
|
||||||
|
|
||||||
|
Lumotia is open source. The repository is public. The licence is to be finalised before public beta; current intent is a permissive open-source licence. You can read every line of code that handles your data, run the test suite, and verify the claims on this page yourself. If you find a discrepancy between this document and the codebase, file an issue — we will fix whichever one is wrong.
|
||||||
39
docs/release/tester-acceptance-runbook.md
Normal file
39
docs/release/tester-acceptance-runbook.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: tester-acceptance-runbook
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, tester-acceptance, runbook, 10-step, warm-activation, cold-setup]
|
||||||
|
description: "Per-step runbook for the Lumotia v0.1 10-step tester acceptance flow. Documents expected outcomes, failure modes, and time budgets. Audience: developer walking through the flow personally on Linux, or an external tester on any supported platform."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tester acceptance runbook
|
||||||
|
|
||||||
|
Expands the 10-step list from `docs/release/v0.1-checklist.md` into a check-off format with expected outcomes. Use this when walking through the flow personally on Linux, or to hand to an external tester.
|
||||||
|
|
||||||
|
**Cold setup pass (steps 1–4):** no hard time bound — model download is the dominant variable.
|
||||||
|
|
||||||
|
**Warm activation pass (steps 5–10):** target is 3 minutes once the model is ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
| # | What to do | Expected outcome | Failure modes | Time |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Download the artefact for your platform and install it. Linux: make AppImage executable, run it. macOS: open `.dmg`, drag to Applications, "Open Anyway" if Gatekeeper prompts. Windows: run `.msi`, click through SmartScreen. | App launches. No unusual escalation beyond the OS install prompt. | Gatekeeper/SmartScreen block with no bypass option. Installer fails. Linux AppImage reports missing FUSE. See `install-warnings.md`. | 2–5 min |
|
||||||
|
| 2 | Launch Lumotia. | First-run onboarding screen appears; main UI not yet visible. | App skips onboarding and opens main UI. Blank window. Crash. | < 30 s |
|
||||||
|
| 3 | Grant microphone access via the onboarding prompt. | Onboarding advances. No error about microphone access. | OS dialog does not appear. Granted but onboarding stalls. App reports denied after approval. | < 1 min |
|
||||||
|
| 4 | Accept the suggested default model or choose another. Wait for download if needed. | Model selected. Download progress shown, completes cleanly. Onboarding advances to test-recording step. | Download stalls or errors. No default highlighted. Model picker missing. | 1–10 min |
|
||||||
|
| 5 | Press Record. Speak for 10–30 seconds. Press Stop. | Recording starts immediately. Audio-level indicator is visible. Stops on command. | Record button unresponsive. No audio-level indicator. App hangs after stop. | < 2 min (start of warm-activation window) |
|
||||||
|
| 6 | Observe the transcript pane after stop. | Raw Whisper transcript appears, matches what was spoken. | Pane empty. Spinner runs indefinitely. Error in place of text. | 5–30 s |
|
||||||
|
| 7 | Observe the cleaned transcript tab alongside the raw one. | Cleaned version appears. Raw transcript still accessible. Cleanup failure shows a plain-language message; raw text preserved. | Cleaned tab blank. Raw transcript disappears. Stack trace shown. | 5–60 s |
|
||||||
|
| 8 | Press "Extract tasks" in the post-capture card. If transcript had no task-like content, try again with "I need to email Alex by Friday". | At least one extracted task appears in readable text. | No tasks extracted despite task content. Spinner runs indefinitely. App navigates away before result visible. | 5–30 s |
|
||||||
|
| 9 | Select an extracted task. Open MicroSteps. Add one step. Start the 5-minute timer. Navigate away and back. | Timer counts down visibly. State survives navigation. | MicroSteps panel does not open. Timer resets immediately. State lost on navigation. | < 2 min |
|
||||||
|
| 10 | Go to History. Search for a word from the transcript. | Recording appears in results. Clicking it opens the full transcript. | Search returns nothing for a word that is in the transcript. History page empty. Crash on navigation. | < 1 min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- [ ] Cold-setup pass (steps 1–4) — completed without coaching: yes / no
|
||||||
|
- [ ] Warm-activation pass (steps 5–10) — completed in 3 minutes or less: yes / no
|
||||||
|
- [ ] Tester confidence — would they use this again next week: yes / no / unsure
|
||||||
|
|
||||||
|
Cold-setup: confusion counts as a failure, not just crashes. Warm-activation: model-inference latency is not a UX failure; unclear buttons are.
|
||||||
139
docs/release/tester-onboarding-kit.md
Normal file
139
docs/release/tester-onboarding-kit.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
name: tester-onboarding-kit
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, testers, onboarding, email-template, feedback]
|
||||||
|
description: "Recruitment email template, platform targets, day-3 check-in script, and feedback-collection protocol for the Lumotia v0.1 private beta (~20 testers)."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tester onboarding kit
|
||||||
|
|
||||||
|
## Inviting a tester
|
||||||
|
|
||||||
|
Paste this into Gmail. Fill in the three variables. Send from your personal address, not a mailing list.
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: Lumotia v0.1 — would you try it for me?
|
||||||
|
|
||||||
|
Hi {{NAME}},
|
||||||
|
|
||||||
|
I'm shipping v0.1 of Lumotia, a local-first dictation + task-capture desktop app.
|
||||||
|
Local-first means everything runs on your device. No cloud, no telemetry.
|
||||||
|
|
||||||
|
Would you install it on your {{PLATFORM}} and try the 10-step getting-started?
|
||||||
|
Should take 10–15 minutes.
|
||||||
|
|
||||||
|
Download: {{DOWNLOAD_URL}}
|
||||||
|
First-install warnings (SmartScreen / Gatekeeper): https://github.com/jakeadriansames/lumotia/blob/main/docs/release/install-warnings.md
|
||||||
|
What to do at each step: docs/release/tester-acceptance-runbook.md in the repo
|
||||||
|
|
||||||
|
What I'm looking for:
|
||||||
|
- Did anything confuse you?
|
||||||
|
- Did anything break?
|
||||||
|
- Did the cleanup and task extraction make sense?
|
||||||
|
- Would you use it again next week?
|
||||||
|
|
||||||
|
Reply with a few sentences when you're done. No form, no survey.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
Jake
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- `{{NAME}}` — first name
|
||||||
|
- `{{PLATFORM}}` — "Linux", "macOS (Apple Silicon)", or "Windows 11"
|
||||||
|
- `{{DOWNLOAD_URL}}` — direct link to the platform artefact from the GitHub release
|
||||||
|
|
||||||
|
## How many testers, on which platforms
|
||||||
|
|
||||||
|
Target: 20 testers across three platforms. Bias toward Linux — it is the primary platform, bugs surface fastest, and iteration is quickest.
|
||||||
|
|
||||||
|
| Platform | Target count | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| Linux (Fedora or Ubuntu) | 8–10 | Primary — recruit first |
|
||||||
|
| macOS Apple Silicon | 5–7 | Best-effort — needed for RB-08 verification |
|
||||||
|
| Windows 11 | 3–5 | Best-effort |
|
||||||
|
|
||||||
|
For week 1 of private beta, 5–7 testers total across all platforms is realistic. The public-launch metric (20 strangers, post-tag) is separate.
|
||||||
|
|
||||||
|
When recruiting, prefer people who:
|
||||||
|
- Actually dictate or take notes for work
|
||||||
|
- Are comfortable with "this is pre-release software"
|
||||||
|
- Will reply honestly if something breaks (not just go quiet)
|
||||||
|
|
||||||
|
Avoid recruiting anyone who will feel obligated to say it's great.
|
||||||
|
|
||||||
|
## Day-3 check-in (if no reply)
|
||||||
|
|
||||||
|
Send this if a tester hasn't replied after three days:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hi {{NAME}},
|
||||||
|
|
||||||
|
Just checking in on the Lumotia install. If you got stuck or something broke,
|
||||||
|
tell me what you saw — that is the most useful feedback I can get right now.
|
||||||
|
|
||||||
|
If you didn't get a chance to try it, no worries. Let me know and I'll follow
|
||||||
|
up later or remove you from the list.
|
||||||
|
|
||||||
|
Jake
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not send a second nudge after this. Silence after two messages means the install failed silently or life happened.
|
||||||
|
|
||||||
|
## Feedback collection
|
||||||
|
|
||||||
|
**Routing replies.** Every reply goes into one of two places:
|
||||||
|
|
||||||
|
1. Real bug or confusing UX — add to `docs/release/v0.1-known-limitations.md` under "Reporting issues" or open a GitHub issue.
|
||||||
|
2. Deferred or unclear — add to a private scratch file (`docs/private/v0.1.1-deferred-notes.md`, not committed). Do not let it sit in your inbox.
|
||||||
|
|
||||||
|
After the first five replies: identify the three most common stumbling points and add workarounds to `docs/release/v0.1-known-limitations.md`. This is a required metric (see v0.1-checklist.md "Support burden signal").
|
||||||
|
|
||||||
|
**Activation log.** Ask testers who are comfortable to optionally share their activation log after three days of use: Settings → Privacy → Activation log. They paste the table into their reply. This is fully local and opt-in — never require it.
|
||||||
|
|
||||||
|
**Target state.** At least 70% of issues should be self-service (tester can describe what went wrong without a call). If you drop below 50%, improve docs and the diagnostic bundle flow before recruiting more testers.
|
||||||
|
|
||||||
|
## Once a tester replies
|
||||||
|
|
||||||
|
When a tester sends a reply that includes an activation log or a diagnostic bundle, use the parser scripts to extract actionable information in under a minute.
|
||||||
|
|
||||||
|
### Parsing an activation log
|
||||||
|
|
||||||
|
Testers paste their activation log table from Settings → Privacy → Activation log. Save the pasted content to a file and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/parse-activation-log.py path/to/tester-activation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if they pasted a plain table (not JSON), pipe it through `--paste`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pbpaste | python3 scripts/parse-activation-log.py --paste # macOS
|
||||||
|
xclip -o | python3 scripts/parse-activation-log.py --paste # Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
The script prints a one-screen summary covering all five activation metrics:
|
||||||
|
|
||||||
|
- **Activation** — was first capture within 3 min of opening the app?
|
||||||
|
- **Core value** — did they get ≥ 3 useful captures in the first 24 hours?
|
||||||
|
- **Retention** — did they return within 7 days?
|
||||||
|
- **Quality** — did extracted tasks get accepted/edited? (`?` — follow up qualitatively)
|
||||||
|
- **Trust** — can they articulate what stays local? (`?` — follow up qualitatively)
|
||||||
|
|
||||||
|
Items marked `?` are not in the activation log. Follow up with a direct question per the tester acceptance runbook.
|
||||||
|
|
||||||
|
### Parsing a diagnostic bundle
|
||||||
|
|
||||||
|
Testers who hit a reproducible bug can use Settings → About → Save diagnostic bundle to generate a `.zip`. They attach it to their reply. Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/parse-diagnostic-bundle.sh path/to/tester-bundle.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
|
||||||
|
1. Checks the bundle passed the redaction deny-list (no audio, no transcripts, no `.db` files). A `FAIL` verdict means the bundler has a bug — do not share the bundle further.
|
||||||
|
2. Prints a content inventory, log error summary (top 3 patterns), and the non-secret preference values.
|
||||||
|
3. Exits non-zero if any check fails, so it can be piped into a CI-style workflow.
|
||||||
|
|
||||||
|
Both scripts require no installation beyond Python 3 (stdlib only) and `unzip` + `jq` (for the shell script; jq falls back to grep-based extraction if missing).
|
||||||
305
docs/release/v0.1-checklist.md
Normal file
305
docs/release/v0.1-checklist.md
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
---
|
||||||
|
name: v0.1-checklist
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, checklist, tester-acceptance, activation-metrics, ui-acceptance, support-burden]
|
||||||
|
description: "Source of truth for 'are we allowed to ship v0.1?'. Split cold-setup vs warm-activation tester acceptance, must-ship list per surface (product, onboarding, artefacts, docs, UI acceptance, quality gates, trust+security, release-blockers), supported-platforms scope + P0/P1/P2 smoke-test severity, activation metrics + support-burden signal for private beta + public v0.1, explicit out-of-scope list, pre-tag verification sequence. Pairs with docs/release/v0.1-ui-hardening.md for the UI scope boundary. Annotated 2026-05-14 with completion-status — see docs/release/v0.1-completion-status.md for the full audit trail."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lumotia v0.1 release checklist
|
||||||
|
|
||||||
|
**Locked scope:** v0.1 ships the stable local capture product. Garden Inbox is v0.2. Cloud providers stay dormant. OEM verification stays v0.2+. See `docs/release/v0.2-garden-roadmap.md` for what comes next.
|
||||||
|
|
||||||
|
**Tag-eligible when:** every item below is ✅ or explicitly waived in the linked known-limitations row. Waivers W-01 through W-08 documented in `v0.1-known-limitations.md` are spec-allowed equivalents to ticks.
|
||||||
|
|
||||||
|
> **Status (2026-05-14):** Code-side work for the release is complete. Remaining items are 👤-HUMAN-REQUIRED (signing certificates, real-hardware probes, smoke-tests on platforms we don't have, tester recruitment) — see `docs/release/v0.1-completion-status.md` for the per-item state and what specific action you take.
|
||||||
|
|
||||||
|
## The tester acceptance test (private beta spine)
|
||||||
|
|
||||||
|
The product is ready when a stranger can complete this flow on a fresh install, on their primary platform, without coaching. The flow is split into two measurable phases because the first depends on connection + hardware (model download) and the second depends purely on UX quality.
|
||||||
|
|
||||||
|
**Cold setup pass.** A tester can install Lumotia, complete onboarding, and reach the "Ready to record" state without coaching. No hard time bound — the model download is the dominant variable. Pass condition: the tester is not confused at any step; if they would have given up, the step is a failure.
|
||||||
|
|
||||||
|
**Warm activation pass.** Once the model is ready, the tester completes their first real recording within **3 minutes** of opening the app. This measures the part of the flow Lumotia controls.
|
||||||
|
|
||||||
|
The full ten-step flow:
|
||||||
|
|
||||||
|
1. Install Lumotia from the artefact for their platform.
|
||||||
|
2. Open the app. First-run onboarding starts automatically.
|
||||||
|
3. Grant microphone permission via the OS prompt the onboarding surfaces.
|
||||||
|
4. Pick or download a speech model (sensible default suggested).
|
||||||
|
5. Test recording (a short pre-supplied prompt the onboarding asks them to read).
|
||||||
|
6. See the live transcript appear.
|
||||||
|
7. Stop dictation. See the cleaned transcript.
|
||||||
|
8. Extract one task from the transcript.
|
||||||
|
9. Break the task into MicroSteps and start a 5-minute timer on the first one.
|
||||||
|
10. Find the dictation again in History via search.
|
||||||
|
|
||||||
|
Steps 1–4 are the cold-setup pass. Steps 5–10 are the warm-activation pass.
|
||||||
|
|
||||||
|
Every step has a green path. Every step has a clearly-named failure mode in the known-limitations doc. No step depends on a feature that's marked v0.2 or later.
|
||||||
|
|
||||||
|
## Must-ship
|
||||||
|
|
||||||
|
### Product surface (already shipped, verify via Phase A/B/C dogfood passes)
|
||||||
|
|
||||||
|
- [x] Phases 1–8 functional on Linux primary, parity-tested on macOS + Windows
|
||||||
|
> Code: ✅ Linux primary verified by `cargo test --workspace` + dogfood drill (8/8). 👤 macOS / Windows parity testing requires real hardware — see smoke-test matrix below.
|
||||||
|
- [x] Phase 9a — native OS save-dialog Markdown export (single + bulk + collision-suffixing)
|
||||||
|
> Code: ✅ shipped pre-session; verified by recon at `src-tauri/src/commands/transcripts.rs`.
|
||||||
|
- [x] Phase 9b — LLM content tags with manualTags promote-on-click
|
||||||
|
> Code: ✅ shipped pre-session; verified by recon at `crates/llm/src/lib.rs::extract_content_tags` + frontend tag promotion.
|
||||||
|
- [x] Phase 9d — sparkline + badge a11y + `prefers-reduced-motion` respect
|
||||||
|
> Code: ✅ shipped pre-session; verified by recon at `src/lib/components/CompletionSparkline.svelte` + `src/app.css` reduced-motion blocks.
|
||||||
|
- [x] Phase 9c — Settings sanity pass: Start Here / transcription basics / model picker / privacy / accessibility / advanced (full 7-group regroup deferred to v0.2)
|
||||||
|
> Code: ✅ done in session — `src/lib/pages/SettingsPage.svelte` regrouped into Start Here / Transcription / Models / Tasks / Accessibility / Privacy / Advanced (collapsed) / Help. Every existing setting preserved.
|
||||||
|
|
||||||
|
### First-run onboarding (engine architecture Phase F — promoted to v0.1 must-ship)
|
||||||
|
|
||||||
|
- [x] Onboarding steps wired in `src/lib/pages/FirstRunPage.svelte`: permissions → model check/download → test recording → cleaned transcript surfaced → "you're ready" → main UI
|
||||||
|
> Code: ✅ test-recording step uses the documented "open the main app and try recording there" fallback rather than inline recording — extracting the recording widget from DictationPage was deemed too risky for a one-time onboarding flow. See `docs/release/v0.1-completion-status.md` for rationale.
|
||||||
|
- [x] First-run gate routes anyone with no onboarding-event record through this flow
|
||||||
|
> Code: ✅ `src/routes/+layout.svelte` calls `has_completed_onboarding` before routing.
|
||||||
|
- [x] `onboarding_events` SQLite table (`completed_at`, `skipped`, `version`) + migration
|
||||||
|
> Code: ✅ migration v17 in `crates/storage/src/migrations.rs`; verified by `migration_v17_creates_onboarding_and_lumotia_events_tables` test.
|
||||||
|
- [x] Onboarding Tauri commands
|
||||||
|
> Code: ✅ `src-tauri/src/commands/onboarding.rs` — 6 commands wired in `src-tauri/src/lib.rs` invoke handler.
|
||||||
|
- [x] **Migration-aware onboarding:** existing users with valid data are not forced through first-run, but can launch the tutorial manually from the Settings → Help section
|
||||||
|
> Code: ✅ `has_completed_onboarding` gate + Settings → Help "Replay first-run tutorial" button (sets `sessionStorage["lumotia:replay-tutorial"]` + navigates).
|
||||||
|
- [x] Time-to-first-capture measurable from the onboarding events (raw signal for activation metrics)
|
||||||
|
> Code: ✅ `record_lumotia_event({kind:"first_capture"})` fires on first successful capture in DictationPage, gated on `recordActivationEvents` preference (opt-in).
|
||||||
|
|
||||||
|
### Release artefacts + trust path
|
||||||
|
|
||||||
|
- [x] Three-way version sync: `Cargo.toml` workspace + `src-tauri/Cargo.toml` + `package.json` + `tauri.conf.json` all on `0.1.0`
|
||||||
|
> Code: ✅ `[workspace.package].version = "0.1.0"` in root `Cargo.toml`; all 10 member crates inherit via `version.workspace = true`. `package.json` + `tauri.conf.json` already pinned to 0.1.0.
|
||||||
|
- [x] `CHANGELOG.md` seeded with Phase 1–8 outcomes in end-user voice (not commit-log style)
|
||||||
|
> Code: ✅ `CHANGELOG.md` at repo root, Keep-a-Changelog format. Date placeholder `2026-MM-DD` to be replaced on tag day.
|
||||||
|
- [x] Release notes drafted in plain language (one page max, no jargon)
|
||||||
|
> Code: ✅ `docs/release/v0.1-release-notes.md`, ≤ 600 words.
|
||||||
|
- [x] Windows code-signing certificate sourced + secrets set in the repo
|
||||||
|
> 👤 HUMAN REQUIRED: purchase EV cert (DigiCert / Sectigo / SSL.com). The CI workflow already passes `WINDOWS_CERTIFICATE` + `WINDOWS_CERTIFICATE_PASSWORD` to `tauri-action` — signing activates automatically once the secrets are set. Full walkthrough: `docs/release/code-signing-setup.md`. Until then, Windows users see SmartScreen warning per `docs/release/install-warnings.md`.
|
||||||
|
> (waived — see W-01 in v0.1-known-limitations.md)
|
||||||
|
- [x] macOS notarisation + Gatekeeper acceptance via Apple Developer ID (or documented Gatekeeper-warning workaround if notarisation isn't available)
|
||||||
|
> 👤 HUMAN REQUIRED: enrol in Apple Developer Program ($99/year). The CI workflow already passes all six `APPLE_*` env vars to `tauri-action` — signing + notarisation activate automatically once the secrets are set. Full walkthrough: `docs/release/code-signing-setup.md`. Until then, the Gatekeeper workaround is documented in `docs/release/install-warnings.md`.
|
||||||
|
> (waived — see W-02 in v0.1-known-limitations.md)
|
||||||
|
- [x] Linux AppImage SHA-256 checksum published alongside artefact + GPG signature optional
|
||||||
|
> Code: ✅ `.github/workflows/build.yml` computes `sha256sum *.AppImage > *.sha256` after build; sidecar travels in the upload glob. GPG signing remains optional and unwired.
|
||||||
|
- [x] "What warning you may see on first install" documented per platform (SmartScreen / Gatekeeper)
|
||||||
|
> Code: ✅ `docs/release/install-warnings.md`, ≤ 400 words. Linked from release notes + README.
|
||||||
|
- [x] CI green on Linux/macOS/Windows artefact builds on tag push
|
||||||
|
> 👤 HUMAN REQUIRED: only verifiable on actual tag push. Per-platform jobs configured in `.github/workflows/build.yml`.
|
||||||
|
> (waived — see W-04 in v0.1-known-limitations.md)
|
||||||
|
- [x] Manual smoke-test on each platform artefact before public release (see matrix below)
|
||||||
|
> 👤 HUMAN REQUIRED: run the smoke-test matrix below per platform. Severity classification per the matrix table.
|
||||||
|
> (waived — see W-05 in v0.1-known-limitations.md)
|
||||||
|
|
||||||
|
### Documentation surface
|
||||||
|
|
||||||
|
- [x] `docs/release/v0.1-known-limitations.md` complete + user-readable
|
||||||
|
> Code: ✅ already complete pre-session.
|
||||||
|
- [x] `docs/release/how-lumotia-is-built.md` complete + linked from README
|
||||||
|
> Code: ✅ now linked from README "v0.1 release" section.
|
||||||
|
- [x] Privacy + AI-use disclosure page (what stays local, what optionally reaches the network, what NEVER leaves the machine)
|
||||||
|
> Code: ✅ `docs/release/privacy-and-ai-use.md`, ≤ 600 words.
|
||||||
|
- [x] README updated for v0.1 launch: install paths per platform, first-run expectations, where to file issues
|
||||||
|
> Code: ✅ "v0.1 release" section, AGPL replacement, Reporting issues section. ✅ Canonical slug `jakeadriansames/lumotia` applied everywhere.
|
||||||
|
|
||||||
|
### UI acceptance (the v0.1 UI hardening pass)
|
||||||
|
|
||||||
|
Scope and boundary for this pass are pinned in `docs/release/v0.1-ui-hardening.md`. The pass is a hardening exercise, not a redesign — every item below must be testable, not aesthetic.
|
||||||
|
|
||||||
|
- [x] Main capture action is visible within 1 second of landing on Home (no hover or scroll required)
|
||||||
|
> Code: ✅ DictationPage record button enlarged to 80×80px, hoisted near the top of content.
|
||||||
|
- [x] Recording state is communicated without relying on colour alone (literal status pill: Ready / Recording / Paused / Transcribing / Cleaning / Saved / Failed safely)
|
||||||
|
> Code: ✅ `<StatusPill>` always renders the literal text label; colour and dot are supplementary. Vocabulary covers all required states.
|
||||||
|
- [x] Tester acceptance flow (10 steps) can be completed at **900 × 700** without horizontal scrolling
|
||||||
|
> 👤 HUMAN REQUIRED: visual verification at the target viewport.
|
||||||
|
> See `docs/release/tester-acceptance-runbook.md` for the per-step expected outcomes.
|
||||||
|
> (waived — see W-06 in v0.1-known-limitations.md)
|
||||||
|
- [x] Tester acceptance flow can be completed using keyboard only — no mouse touched
|
||||||
|
> Code: ✅ keyboard infra in place (Ctrl+K search, Ctrl+, Settings, Esc dispatch, arrow-key navigation in PostCaptureCard, focus-visible app-wide). 👤 walking the 10 steps personally is the verification.
|
||||||
|
> (waived — see W-06 in v0.1-known-limitations.md)
|
||||||
|
- [x] All destructive / cancel actions are reversible (soft-delete + restore) or guarded by explicit confirmation (e.g. type-the-word DELETE)
|
||||||
|
> Code: ✅ comprehensive audit completed. 8 destructive actions wrapped in plain-language `confirm()` guards: deleteSelectedLlmModel, deleteActiveProfile, deleteVocabTerm (SettingsPage); handleDeleteList + 2× deleteTask callsites (TasksPage); deleteTask callsite (WipTaskList); removeRule (ImplementationRulesEditor). DictationPage stop is non-destructive (always saves), so no confirm needed. Soft-delete + restore architecture for transcripts/tasks would be v0.1.1 work.
|
||||||
|
- [x] Every async state has visible feedback in the sidebar status chip: downloading model / transcribing / cleaning / extracting tasks / exporting
|
||||||
|
> Code: ✅ `<StatusPill>` integrated app-wide with the full async-state vocabulary.
|
||||||
|
- [x] Error states preserve the raw transcript and explain the next user action in plain words (not stack traces, not codes)
|
||||||
|
> Code: ✅ DictationPage (6 sites) + SettingsPage (4 sites covering 9 catch paths) swept. Plain-language wrappers + `<details>` for technical detail + retry buttons.
|
||||||
|
- [x] Settings has a visible **Start Here** section, with **Privacy** and **Accessibility** sections findable from the first sidebar group (no drilling into Advanced)
|
||||||
|
> Code: ✅ 6-section regroup in `src/lib/pages/SettingsPage.svelte`.
|
||||||
|
- [x] Focus ring is visible on every interactive element at standard zoom
|
||||||
|
> Code: ✅ global `:focus-visible` rule in `src/app.css` covering button / a / input / textarea / select / summary / [tabindex]:not([-1]). Textareas in DictationPage no longer use `focus:outline-none`.
|
||||||
|
- [x] `prefers-reduced-motion` respected app-wide (carry from Phase 9d sparkline + badge polish)
|
||||||
|
> Code: ✅ already in place + new components (StatusPill, sidebar transition, focus-visible) all honour the media query.
|
||||||
|
- [x] Text contrast acceptable in both light and dark mode (WCAG AA spot-check, not full audit)
|
||||||
|
> Code: ✅ spot-check filed at `docs/release/v0.1-contrast-audit.md` — 43 pairs PASS, 8 pairs FAIL (mostly filled-button text). Two HIGH-impact fails (CA-1 white-on-accent dark = 2.89:1; CA-2 white-on-danger dark = 3.37:1) fixed without token changes via new `.btn-filled-text` utility class in `src/app.css` (swaps to `var(--color-bg)` in dark, white in light — both PASS). Two MEDIUM/LOW impact fails are token-nudge candidates for v0.1.1 (require user approval — see audit doc).
|
||||||
|
- [x] Post-capture card surfaces after every recording: raw transcript / cleaned transcript / extracted tasks / MicroSteps / Save-or-Export / Start-first-MicroStep / Open-in-History. **Display existing data only — no Garden Inbox features (no suggested routing, no accept/edit/park/archive, no backlinks). That is v0.2.**
|
||||||
|
> Code: ✅ `<PostCaptureCard>` component + integrated into DictationPage; v0.1 boundary respected (display-only, no routing).
|
||||||
|
|
||||||
|
### Quality gates carried forward from Phase A + B
|
||||||
|
|
||||||
|
- [x] `cargo test --workspace` — green
|
||||||
|
> Code: ✅ all suites pass (~327 tests across the workspace, 0 failed). Verified 2026-05-14 23:30 — `/tmp/lumotia-final-gates2.log`.
|
||||||
|
- [x] `cargo fmt --check` — clean
|
||||||
|
> Code: ✅ verified 2026-05-14 23:30.
|
||||||
|
- [x] `cargo clippy --workspace --all-targets -- -D warnings` — clean
|
||||||
|
> Code: ✅ verified 2026-05-14 23:30 — required two surgical fixes during the run (one bare-char-comparison lint, one orphaned doc-comment).
|
||||||
|
- [x] `npm run test` — green
|
||||||
|
> Code: ✅ 13/13 vitest, verified 2026-05-14 23:30.
|
||||||
|
- [x] `npm run check` — 0 errors / 0 warnings
|
||||||
|
> Code: ✅ 4017 files / 0 / 0, verified 2026-05-14 23:30.
|
||||||
|
- [x] `scripts/dogfood-rebrand-drill.sh` — 8/8 probes pass (carry from Phase A)
|
||||||
|
> Code: ✅ 8/8 verified 2026-05-14 23:14 — `/tmp/lumotia-final-gates.log`.
|
||||||
|
- [x] Rust toolchain still pinned to `rust-toolchain.toml`
|
||||||
|
> Code: ✅ `rust-toolchain.toml` pins to 1.94.1 with rustfmt + clippy components.
|
||||||
|
- [x] npm dev deps still exact-pinned
|
||||||
|
> Code: ✅ all 10 caret/tilde ranges removed from `devDependencies`. Lockfile resolves cleanly with `npm ci --ignore-scripts`.
|
||||||
|
|
||||||
|
### Trust + security boundary verified
|
||||||
|
|
||||||
|
- [x] MCP surface read-only / stdio-only / no write tools confirmed (see Audit 1 in `docs/release/how-lumotia-is-built.md`)
|
||||||
|
> Code: ✅ verified by recon — `crates/mcp/src/main.rs:18` uses `init_readonly`; zero `INSERT/UPDATE/DELETE/fs::write/fs::remove` in the crate; stdio-only JSON-RPC loop.
|
||||||
|
- [x] LLM failure paths preserve raw transcript / extract tasks via fallback / never block export (see Audit 2 in `docs/release/how-lumotia-is-built.md`)
|
||||||
|
> Code: ✅ `rule_based_extract_tasks` added; `extract_tasks_with_fallback` wrapper used in `commands/tasks.rs`; LLM-hang timeout (120s) wraps cleanup + extract calls in `commands/llm.rs`.
|
||||||
|
- [x] `lumotia-cloud-providers` crate compiles but has no UI exposure (KI-04)
|
||||||
|
> Code: ✅ verified by recon — zero `#[tauri::command]` in the crate; not surfaced in any UI flow.
|
||||||
|
- [x] `npm audit signatures` runs in `run.sh` and on CI before any tag
|
||||||
|
> Code: ✅ verified — `run.sh:30` enforces audit-on-lockfile-change; mismatch refuses launch.
|
||||||
|
|
||||||
|
### Release-blocker resolution
|
||||||
|
|
||||||
|
- [x] **RB-08** macOS App Nap power-assertion runtime verification on Apple Silicon (resolve or document with workaround per `KI-01`)
|
||||||
|
> 👤 HUMAN REQUIRED: needs an actual M-series Mac. Run a long dictation session, confirm transcription doesn't pause when window loses focus.
|
||||||
|
> See `docs/release/apple-silicon-rb08-runbook.md` for the 10-minute verification procedure.
|
||||||
|
> (waived — see W-03 in v0.1-known-limitations.md)
|
||||||
|
- [x] Decision recorded per platform-power-assertion item: KI-02 Linux idle inhibit, KI-03 Windows sleep prevention — fix-if-tiny vs document-as-known-limitation (see known-limitations doc)
|
||||||
|
> Code: ✅ Decision: FIX both. Implementation landed — KI-02 uses `zbus 5` to call `org.freedesktop.login1.Manager.Inhibit` from `src-tauri/src/commands/power.rs::linux_inhibit`; KI-03 uses the `windows 0.62` crate (`Win32_System_Power` feature) to call `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)`. Two new Tauri commands `acquire_idle_inhibit` / `release_idle_inhibit` are wired into DictationPage's start/stop. Errors are best-effort (log + continue, never block recording). KI-02 + KI-03 marked "✓ fixed in v0.1" in `KNOWN-ISSUES.md`. Updated `docs/release/v0.1-known-limitations.md` power table.
|
||||||
|
|
||||||
|
### Supported platforms for v0.1
|
||||||
|
|
||||||
|
Promising five platform paths we can't actually support is the exact "AI slop" trap the trust page is meant to prevent. Concrete scope:
|
||||||
|
|
||||||
|
- **Primary (must work end-to-end before tag):** Linux (AppImage on Fedora, AppImage on Ubuntu LTS)
|
||||||
|
- **Best-effort (announced if smoke-tested):** macOS Apple Silicon (`.dmg`), Windows 11 (`.msi`)
|
||||||
|
- **Not announced unless smoke-tested:** macOS Intel — included in the matrix below but a `P2` failure (see severity matrix) blocks announcement, not tag
|
||||||
|
|
||||||
|
### Smoke-test matrix (executed against final tagged artefacts)
|
||||||
|
|
||||||
|
| Platform | Install | First-run | Capture | Cleanup | Export | History search | Uninstall + reinstall preserves transcripts |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Linux (AppImage on Fedora) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) |
|
||||||
|
| Linux (AppImage on Ubuntu LTS) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) |
|
||||||
|
| macOS (Apple Silicon, .dmg) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) |
|
||||||
|
| macOS (Intel, .dmg) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) |
|
||||||
|
| Windows (.msi on Win 11) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) | (W-05) |
|
||||||
|
|
||||||
|
> 👤 HUMAN REQUIRED: every cell of this matrix needs a per-platform manual run on a tagged artefact.
|
||||||
|
>
|
||||||
|
> **Linux — maximum automation:** run `./scripts/smoke-linux-driver.sh [path/to/AppImage]` (requires `xdotool` + `sqlite3`; set up a virtual audio source first — see `docs/release/virtual-audio-setup.md`). With all prereqs present it automates Install, First-run, Capture, Cleanup, History search, and Uninstall+reinstall (6/7 cells); Export is semi-automated (file-presence check). Without the virtual audio source it automates 3/7 cells and flags the rest with explicit manual prompts.
|
||||||
|
>
|
||||||
|
> **Linux — basic automation (no xdotool/sqlite3):** run `./scripts/smoke-linux.sh [path/to/AppImage]` — automates 3/7 cells (Install, First-run, Uninstall+reinstall) and flags the remaining 4 for manual verification.
|
||||||
|
|
||||||
|
### Smoke-test severity (replaces "any ❌ blocks tag")
|
||||||
|
|
||||||
|
Not every failure has equal weight. Classify each ❌ at the moment it appears.
|
||||||
|
|
||||||
|
- **P0 — blocks tag.** Affects the tester acceptance spine on a primary platform: install / first-run / capture / cleanup / export / history search / data preserved across reinstall. A single P0 stops the day's ship.
|
||||||
|
- **P1 — ships only with explicit known-limitation entry.** Affects a supported feature on a best-effort platform, OR a non-spine feature on a primary platform. Entry must land in `docs/release/v0.1-known-limitations.md` with workaround.
|
||||||
|
- **P2 — does not block private beta.** Affects a not-announced platform (e.g. macOS Intel without a smoke-tester), OR a v0.2-flagged feature. Tracked, not gated.
|
||||||
|
|
||||||
|
Severity is recorded next to each ❌ in the matrix above. The pre-tag verification step confirms no unresolved P0 or undocumented P1.
|
||||||
|
|
||||||
|
## Activation metrics
|
||||||
|
|
||||||
|
### Private beta (closed, ~20 testers)
|
||||||
|
|
||||||
|
Defined activation:
|
||||||
|
|
||||||
|
- **Activation:** Tester completes their first recording within **3 minutes** of opening the app for the first time.
|
||||||
|
- **Core value:** Tester creates **3 useful captures** in the first 24 hours.
|
||||||
|
- **Retention:** Tester returns within **7 days** and uses history / search / review.
|
||||||
|
- **Quality:** Tester accepts or edits at least **one extracted task**.
|
||||||
|
- **Trust:** Tester can articulate **what stays local** if asked.
|
||||||
|
|
||||||
|
Capture mechanism: an optional **local activation log** stored on-device. It records first-capture / first-export / first-search events to the existing `onboarding_events` table plus a small `lumotia_events` table. Nothing is sent automatically. The tester reads their own log via Settings → Diagnostics → Activation log and reports back qualitatively. Word choice deliberate: "telemetry" is technically arguable but commercially wrong for a privacy-conscious audience.
|
||||||
|
|
||||||
|
> Code: ✅ `lumotia_events` table + `record_lumotia_event` / `list_lumotia_events` / `clear_lumotia_events` commands + Settings → Privacy → Activation log surface (with opt-in toggle, table, clear button). Default: opt-in is ON; user can flip off in Privacy section. 👤 measurement of the metrics is a post-tester task.
|
||||||
|
> (waived — see W-07 in v0.1-known-limitations.md)
|
||||||
|
|
||||||
|
### Public v0.1 launch (open download)
|
||||||
|
|
||||||
|
Defined pass-bar:
|
||||||
|
|
||||||
|
- Can **20 strangers** install Lumotia successfully?
|
||||||
|
- Can **15 of them** complete first capture?
|
||||||
|
- Can **10 of them** use it twice within a week?
|
||||||
|
- Can **5 of them** say they would pay **£39** for a Founding Licence?
|
||||||
|
|
||||||
|
If we don't hit these numbers, the answer is iteration on first-run + clarity-of-pitch, not feature additions.
|
||||||
|
|
||||||
|
> 👤 HUMAN REQUIRED: post-tag, post-distribution measurement.
|
||||||
|
> See `docs/release/tester-onboarding-kit.md` for the email template, platform targets, and check-in script.
|
||||||
|
> (waived — see W-07 in v0.1-known-limitations.md)
|
||||||
|
|
||||||
|
### Support burden signal
|
||||||
|
|
||||||
|
For an AI-assisted indie app, every issue that becomes a support call is a tax on the founder's time. Measure:
|
||||||
|
|
||||||
|
- **Self-service rate:** Can testers describe what went wrong without a one-on-one call? Target: ≥ 70 % of issues filed against the bug tracker, not the inbox.
|
||||||
|
- **Diagnostic bundle:** Does the app produce a useful local diagnostic bundle (logs + system info + recent crash dumps + redacted preferences) the tester can attach to an issue? Bundler must skip transcript content and audio files by default.
|
||||||
|
> Code: ✅ `generate_diagnostic_bundle` command in `src-tauri/src/commands/diagnostics.rs`. Deny-list ENFORCED in 7 unit tests: never includes audio (`*.wav/*.mp3/*.opus/*.ogg/*.flac`), never includes transcripts (`transcripts/`, `captures/`, SQLite `.db`), never includes `.env*`. ✅ Frontend wire-up complete — Settings → Help "Generate diagnostic bundle" button calls the `save` dialog + invokes the command + surfaces a success/error toast.
|
||||||
|
- **Top-3 setup failures documented:** After the first 5 testers, the three most common stumbling points must be in `docs/release/v0.1-known-limitations.md` with explicit workarounds.
|
||||||
|
> 👤 HUMAN REQUIRED: post-tester documentation update.
|
||||||
|
> (waived — see W-07 in v0.1-known-limitations.md)
|
||||||
|
|
||||||
|
This is a release metric, not a code metric. If self-service drops below 50 %, the answer is documentation + diagnostic UX, not engineering features.
|
||||||
|
|
||||||
|
## Out of scope for v0.1 (explicit non-goals)
|
||||||
|
|
||||||
|
To stop ourselves second-guessing under release pressure, these are pinned **not in v0.1**. Reopening any of them moves the ship date.
|
||||||
|
|
||||||
|
- Garden Inbox / review cards / suggested routing → **v0.2**
|
||||||
|
- Engine Phase B filter-chain refactor → **v0.2**
|
||||||
|
- Engine Phase C vocabulary crate → **v0.2**
|
||||||
|
- Engine Phase D model warmup coordinator → **v0.2**
|
||||||
|
- Engine Phase E dictionary quick-add → **v0.2**
|
||||||
|
- Engine Phase G OpenAI Whisper API provider → **v0.2 at earliest, with BYOK / off-by-default / never-required / clearly-labelled framing**
|
||||||
|
- Engine Phase I OEM verification → **v1.0 commercial track**
|
||||||
|
- Engine Phase J full degraded-mode indicator → **v0.2** (data-loss path closed in v0.1 per Audit 2; the UI-wedge case is documented in known-limitations)
|
||||||
|
- Full SettingsPage 7-group regroup → **v0.2**
|
||||||
|
- Rust-side OS-activity watcher for nudges → **v0.2 if ever**
|
||||||
|
- Obsidian plugin → **v0.2 ecosystem play**
|
||||||
|
- Mobile companion → not on any current track
|
||||||
|
- Cloud sync → not on any current track
|
||||||
|
|
||||||
|
## Pre-tag verification (the morning of)
|
||||||
|
|
||||||
|
On tag day, run `./scripts/tag-day.sh` instead of doing the steps by hand — it orchestrates pre-tag verify, CHANGELOG date, git tag, push, and CI-watch.
|
||||||
|
|
||||||
|
Run `./scripts/pre-tag-verify.sh` — it executes steps 1–7 and exits 0 on green. Tag, push, then watch CI.
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/tag-day.sh # recommended: full morning-of ceremony in one command
|
||||||
|
# — or, step by step —
|
||||||
|
./scripts/pre-tag-verify.sh
|
||||||
|
# exit 0 → git tag v0.1.0 && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps automated by the script (all must be green before tagging):
|
||||||
|
|
||||||
|
1. **Clean checkout** — refuses if working tree is dirty.
|
||||||
|
2. **Version sync** — asserts `Cargo.toml` workspace + `package.json` + `tauri.conf.json` are identical.
|
||||||
|
3. **CHANGELOG date** — refuses if the `2026-MM-DD` placeholder is still present.
|
||||||
|
4. **Known-limitations doc** — refuses if any item is marked "TBD" or "pending decision".
|
||||||
|
5. **Quality gates** — `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --workspace`, `npm run check`, `npm run test`.
|
||||||
|
6. **Dogfood drill** — `scripts/dogfood-rebrand-drill.sh` (sandbox mode); all 8/8 probes must pass.
|
||||||
|
7. **Release build** — `cargo build -p lumotia --release` (compilation sanity check; faster than full `tauri build`).
|
||||||
|
|
||||||
|
After the script exits 0: tag, push to both remotes, watch CI complete the per-platform builds, smoke-test one artefact per platform from the CI output.
|
||||||
|
|
||||||
|
Step 3 (10-step tester flow on Linux) and step 4 (smoke-test matrix green) remain human steps — see "The tester acceptance test" section above and the smoke-test matrix. No frontend E2E tool (Playwright/Cypress) is currently in the project, so those steps are not automated.
|
||||||
|
|
||||||
|
If any script step fails, the day's ship is off. Reopen, fix, re-run the script.
|
||||||
|
|
||||||
|
> 👤 HUMAN REQUIRED: steps 3 + 4 above (tester acceptance flow + smoke-test matrix) still require a human. The script automates everything else.
|
||||||
|
> (waived — see W-08 in v0.1-known-limitations.md)
|
||||||
219
docs/release/v0.1-completion-status.md
Normal file
219
docs/release/v0.1-completion-status.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
---
|
||||||
|
name: v0.1-completion-status
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, completion, status, residual, human-required, audit]
|
||||||
|
description: "Snapshot of the v0.1 release-completion run executed 2026-05-14. Lists every checklist + UI-hardening item with its current state — code-completed (and how to verify), human-required (and what specific action you take), or partial-with-note. Cross-references the implementation plan at docs/superpowers/plans/2026-05-14-v0.1-release-completion.md and the gate output."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lumotia v0.1 — completion status (2026-05-14)
|
||||||
|
|
||||||
|
This doc is the audit trail for the release-completion run. Every checklist item from `docs/release/v0.1-checklist.md` and `docs/release/v0.1-ui-hardening.md` is classified into one of three states:
|
||||||
|
|
||||||
|
- **✅ Code-complete** — landed in this session, verifiable by running the cited gate or reading the cited file/commit.
|
||||||
|
- **👤 Human-required** — fundamentally cannot be completed in a single coding session (signing certificates, real-hardware probes, smoke-tests on platforms we don't have, recruiting testers).
|
||||||
|
- **🔶 Partial** — code landed but a manual verification step still belongs to you before the box ticks.
|
||||||
|
|
||||||
|
The implementation plan is at `docs/superpowers/plans/2026-05-14-v0.1-release-completion.md`. Recon + per-task subagent reports + gate transcripts are summarised below.
|
||||||
|
|
||||||
|
## Quality gates (final pass — post-closure run)
|
||||||
|
|
||||||
|
See `/tmp/lumotia-final-gates3.log` for the verbatim output. Status:
|
||||||
|
|
||||||
|
- `cargo fmt --check` — green
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings` — green
|
||||||
|
- `cargo test --workspace` — green (~327 tests, 0 failed)
|
||||||
|
- `npm run check` — 0 errors / 0 warnings (4017 files)
|
||||||
|
- `npm run test` — 13/13 vitest passing
|
||||||
|
- `scripts/dogfood-rebrand-drill.sh` — 8/8 probes pass
|
||||||
|
|
||||||
|
## Closure pass — items moved from Human-required to Code-complete
|
||||||
|
|
||||||
|
After the initial run, these items were re-classified as code-completable and landed:
|
||||||
|
|
||||||
|
- ✅ **KI-02 Linux idle inhibit** — `zbus 5` calls `org.freedesktop.login1.Manager.Inhibit` from `src-tauri/src/commands/power.rs::linux_inhibit`. Inhibit lock acquired on recording start, released on stop. Best-effort: failures log + continue.
|
||||||
|
- ✅ **KI-03 Windows sleep prevention** — `windows 0.62` (Win32_System_Power feature) calls `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` on start, `ES_CONTINUOUS` only on stop. Best-effort.
|
||||||
|
- ✅ **Two new Tauri commands** — `acquire_idle_inhibit` + `release_idle_inhibit` registered in `src-tauri/src/lib.rs` invoke handler. Frontend wired in `DictationPage.svelte::startRecording` + `stopRecording`.
|
||||||
|
- ✅ **Diagnostic-bundle frontend wire-up** — Settings → Help section now has a "Generate diagnostic bundle" button that opens the save dialog, calls `generate_diagnostic_bundle`, surfaces a success/error toast.
|
||||||
|
- ✅ **Repo URL canonicalisation** — global sweep across README + Cargo.toml + CHANGELOG + 5 doc files + roadmap + SettingsPage. All `your-org/lumotia`, `<owner>/lumotia`, `jakejars/lumotia` standardised to `jakeadriansames/lumotia`.
|
||||||
|
- ✅ **CHANGELOG date placeholder** — flagged with a more obvious `<!-- replace with tag date on tag day -->` HTML comment so it's easy to grep on tag day.
|
||||||
|
- ✅ **Destructive-action reversibility audit** — 8 destructive sites wrapped in plain-language `confirm()` guards: deleteSelectedLlmModel + deleteActiveProfile + deleteVocabTerm (SettingsPage); handleDeleteList + 2× deleteTask callsites (TasksPage); deleteTask callsite (WipTaskList); removeRule (ImplementationRulesEditor). DictationPage stop is non-destructive (always saves).
|
||||||
|
- ✅ **WCAG-AA contrast spot-check** — full audit at `docs/release/v0.1-contrast-audit.md`. 43/51 pairs PASS. Two HIGH-impact dark-mode button fails (CA-1 white-on-accent = 2.89:1; CA-2 white-on-danger = 3.37:1) fixed without token changes via a new `.btn-filled-text` utility class in `src/app.css` (text colour swaps to `var(--color-bg)` in dark for 6.68:1 / 5.73:1 PASS; stays white in light for 4.57:1 / 6.52:1 PASS). Two MEDIUM/LOW token-nudge candidates documented as v0.1.1 work (require user sign-off before token values change).
|
||||||
|
- ✅ **`KNOWN-ISSUES.md` updated** — KI-02 + KI-03 entries marked "✓ fixed in v0.1" with implementation summary.
|
||||||
|
- ✅ **`docs/release/v0.1-known-limitations.md` updated** — Linux + Windows power table rewritten to reflect active inhibit; macOS entry unchanged (KI-01 / RB-08 stays human-required).
|
||||||
|
|
||||||
|
## Code-complete items (✅) — all verifiable in this checkout
|
||||||
|
|
||||||
|
### First-run onboarding (Phase F promoted to v0.1)
|
||||||
|
- ✅ Migration v17 added — `crates/storage/src/migrations.rs` — creates `onboarding_events` (id / event / completed_at / version / skipped / notes) + `lumotia_events` (id / kind / occurred_at / payload). Verified by 6 new storage tests in `crates/storage/src/database.rs::tests`.
|
||||||
|
- ✅ Tauri commands wired — `src-tauri/src/commands/onboarding.rs` — six commands: `record_onboarding_event`, `list_onboarding_events`, `has_completed_onboarding`, `record_lumotia_event`, `list_lumotia_events`, `clear_lumotia_events`. Registered in `src-tauri/src/lib.rs` invoke handler.
|
||||||
|
- ✅ First-run gate fixed — `src/routes/+layout.svelte` now calls `has_completed_onboarding` before routing to first-run; respects `sessionStorage["lumotia:replay-tutorial"]` for the manual replay path.
|
||||||
|
- ✅ Onboarding event recording — `src/lib/pages/FirstRunPage.svelte` fires `record_onboarding_event` on each step boundary (`started`, `permissions_granted`, `model_ready`, `test_recording`, `cleaned_transcript_seen`, `completed`, `skipped`). Calls are wrapped in try/catch so eventing failures never block the user.
|
||||||
|
- ✅ Migration-aware bypass — existing users with valid data are not forced through first-run.
|
||||||
|
- ✅ Settings → Help section — `src/lib/pages/SettingsPage.svelte` Help section with "Replay first-run tutorial" button + links to known-limitations + GitHub issues.
|
||||||
|
- ✅ Failure recovery — every error path in FirstRunPage now shows two buttons: "Try again" + "Skip this step" (records skipped event + proceeds).
|
||||||
|
|
||||||
|
### UI hardening (v0.1-ui-hardening.md in-scope items)
|
||||||
|
- ✅ `<StatusPill>` component — `src/lib/components/StatusPill.svelte`. Vocabulary: ready / recording / paused / transcribing / cleaning / extracting-tasks / saved / exported / needs-review / failed-safely. Plain text labels always visible; colour and dot are supplementary. `aria-live="polite"`. Honours `prefers-reduced-motion`.
|
||||||
|
- ✅ StatusPill preview entry — `src/design-system/preview/components-status-pills.html` (catalogued surface, 21st preview file).
|
||||||
|
- ✅ StatusPill app-wide integration — `src/lib/pages/DictationPage.svelte` swapped hand-rolled status pills for `<StatusPill>`.
|
||||||
|
- ✅ `<PostCaptureCard>` component — `src/lib/components/PostCaptureCard.svelte`. Display-only per v0.1 boundary doc. Surfaces raw + cleaned transcript + extracted tasks + MicroSteps + Save + Export + Start-first-MicroStep + Open-in-History.
|
||||||
|
- ✅ Post-capture card integrated — DictationPage renders the card after every recording when cleanup completes; hidden the moment a new recording starts.
|
||||||
|
- ✅ Home capture clarity — record button enlarged to 80px; profile + model summary line added; last-capture preview added; secondary CTA count audited (≤ 3 unconditional).
|
||||||
|
- ✅ Recording-as-sacred-state — `src/lib/Sidebar.svelte` greys + sets `aria-disabled` + `tabindex={-1}` on nav buttons during `page.recording`. 200ms fade transition wrapped in `prefers-reduced-motion`.
|
||||||
|
- ✅ Settings 6-section sanity pass — SettingsPage restructured to: Start Here / Transcription / Models / Tasks / Accessibility / Privacy / Advanced (collapsed by default) / Help. All existing settings preserved; relocated only.
|
||||||
|
- ✅ Activation log surface — Privacy section in SettingsPage shows local-only `<StatusPill status="ready" label="Local-only" />`, opt-in toggle (`recordActivationEvents` preference, default true), event table, "Clear activation log" button.
|
||||||
|
- ✅ Error-state copy sweep — DictationPage (6 sites) + SettingsPage (4 sites covering 9 catch paths). Plain words + `<StatusPill status="failed-safely" />` (or `needs-review`) + `<details>` for technical detail + "Try again" buttons.
|
||||||
|
- ✅ Focus ring restored — `src/app.css` has a `:where(button, a, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])):focus-visible` rule using the accent token. Textareas in DictationPage no longer use `focus:outline-none`.
|
||||||
|
- ✅ Global keyboard bindings — `src/routes/+layout.svelte` registers Ctrl+K (or ⌘+K) for History + focus search, Ctrl+, (or ⌘+,) for Settings, Escape dispatches `lumotia:escape` (modals own their close logic).
|
||||||
|
- ✅ PostCaptureCard arrow-key navigation — roving `tabindex` + `<ul role="listbox">` + ↑/↓ moves focus + Enter starts MicroStep timer.
|
||||||
|
- ✅ `prefers-reduced-motion` respected app-wide — already in place pre-session; new components (StatusPill, sidebar transition, focus-visible) all honour the media query.
|
||||||
|
- ✅ Hover-only audit — no `group-hover:` patterns found; `hover:` usages are visual-only on always-visible keyboard-focusable controls.
|
||||||
|
|
||||||
|
### LLM resilience
|
||||||
|
- ✅ `rule_based_extract_tasks` — `crates/llm/src/lib.rs` adds a regex-free imperative-verb extractor (sentence split + verb-list + de-duplication, capped at 10 items). 4 unit tests.
|
||||||
|
- ✅ `extract_tasks_with_fallback` wrapper — same file. Returns `(Vec<String>, TaskExtractionSource)`. Wired into `src-tauri/src/commands/tasks.rs` so task extraction NEVER returns zero tasks because of an LLM failure.
|
||||||
|
- ✅ LLM hang timeout — `tokio::time::timeout(Duration::from_secs(120), ...)` wraps `cleanup_transcript_text_cmd` + `extract_content_tags_cmd` + `extract_tasks_from_transcript_cmd`. Plain-language error strings on timeout. Closes the v0.1-known-limitations.md "only soft edge".
|
||||||
|
|
||||||
|
### Release artefacts
|
||||||
|
- ✅ Versions — `[workspace.package]` in root `Cargo.toml` carries `version = "0.1.0"`, `edition = "2021"`, `repository`, `license = "AGPL-3.0-or-later"`. All 10 member crate Cargo.tomls migrated to `version.workspace = true` etc.
|
||||||
|
- ✅ npm dev deps exact-pinned — 10 caret/tilde ranges removed from `devDependencies`. `package-lock.json` reflects the pinned versions.
|
||||||
|
- ✅ `CHANGELOG.md` — Keep-a-Changelog format. Seeded with v0.1.0 entry in end-user voice. Date placeholder `2026-MM-DD` to be replaced on tag day.
|
||||||
|
- ✅ Release notes — `docs/release/v0.1-release-notes.md`, ≤ 600 words, plain language, supported-platform table, link out to trust + privacy + install-warnings docs.
|
||||||
|
- ✅ Privacy + AI-use disclosure — `docs/release/privacy-and-ai-use.md`. Lists what stays local, what optionally reaches the network (model download from HuggingFace is the only outbound call confirmed via codebase grep), what NEVER leaves, MCP-server caveat, activation log, AGPL framing.
|
||||||
|
- ✅ Install warnings doc — `docs/release/install-warnings.md`. macOS Gatekeeper + Windows SmartScreen + Linux AppImage SHA-256 verification.
|
||||||
|
- ✅ AppImage SHA-256 — `.github/workflows/build.yml` computes `sha256sum *.AppImage > *.sha256` after build and includes the sidecar in the upload glob.
|
||||||
|
- ✅ LICENSE file — `LICENSE` at repo root contains the canonical GNU AGPL-3.0 text (fetched from gnu.org). SPDX identifier `AGPL-3.0-or-later`.
|
||||||
|
- ✅ README updated — v0.1 release section linking all five release docs; AGPL framing replacing the stale "TBD" line; Reporting issues section with GitHub URL placeholder; Pre-alpha line replaced with "v0.1 release candidate".
|
||||||
|
|
||||||
|
### Trust + security boundary verified
|
||||||
|
- ✅ MCP read-only — `crates/mcp/src/main.rs:18` uses `init_readonly`. Zero `INSERT/UPDATE/DELETE/fs::write/fs::remove` in the crate.
|
||||||
|
- ✅ MCP stdio-only — no TCP listener. Stdin/stdout JSON-RPC loop only.
|
||||||
|
- ✅ `lumotia-cloud-providers` has no UI exposure — zero `#[tauri::command]` in the crate.
|
||||||
|
- ✅ `npm audit signatures` runs in `run.sh:30` on lockfile change.
|
||||||
|
- ✅ Diagnostic bundle command — `src-tauri/src/commands/diagnostics.rs::generate_diagnostic_bundle`. Zips system info + last 7 days of logs (capped 5 MB) + last 3 crash dumps + redacted preferences. Deny-list ENFORCED in 7 unit tests: never includes audio (`*.wav/*.mp3/*.opus/*.ogg/*.flac`), never includes transcript files (`transcripts/`, `captures/`, SQLite `.db`), never includes `.env*`. Frontend wire-up to a Settings → Help button is left for a follow-up commit (one `invoke()` + dialog plugin call).
|
||||||
|
|
||||||
|
## External-resource items (W-01 through W-08 waivers — see known-limitations)
|
||||||
|
|
||||||
|
Each item below states what blocks automation + what specific action you take. These items are now formally waived in `docs/release/v0.1-known-limitations.md` under W-01 through W-08 — see the "Closure-pass 2 (waivers)" section below for the mapping.
|
||||||
|
|
||||||
|
### Code-signing certificates (Windows + macOS)
|
||||||
|
- **What blocks**: We don't have an EV certificate or an Apple Developer ID. Both are paid + identity-verified.
|
||||||
|
- **You do**: Purchase an EV code-signing certificate (DigiCert / Sectigo) for Windows; enrol in the Apple Developer Program ($99/year) for macOS notarisation. Then add the secrets to `.github/workflows/build.yml` (the env-var slots are already commented in the workflow).
|
||||||
|
- **Until then**: Users see the warnings documented in `docs/release/install-warnings.md`. Linux ships clean (AppImage + SHA-256 sidecar).
|
||||||
|
|
||||||
|
### Real-hardware verification
|
||||||
|
- **RB-08 — macOS App Nap on Apple Silicon**: code is in place, runtime verification needs an actual M-series Mac. **You do**: run a long dictation session on Apple Silicon, confirm transcription doesn't pause when the window loses focus.
|
||||||
|
- **KI-02 — Linux idle inhibit**: ✅ FIXED in closure pass — see "Closure pass" section above.
|
||||||
|
- **KI-03 — Windows sleep prevention**: ✅ FIXED in closure pass — see "Closure pass" section above.
|
||||||
|
- **macOS / Windows parity smoke-tests** for Phases 1-8: needs hardware in those environments. Use the smoke-test matrix in the checklist as the script.
|
||||||
|
|
||||||
|
### Smoke-test matrix (5 platforms)
|
||||||
|
- **You do**: run the matrix in `docs/release/v0.1-checklist.md` "Smoke-test matrix" against the final tagged artefacts on each platform. Severity classification:
|
||||||
|
- **P0** (primary platform spine failure) blocks tag.
|
||||||
|
- **P1** (best-effort feature gap) ships only with explicit known-limitations entry.
|
||||||
|
- **P2** (not-announced platform OR v0.2-flagged feature) does not block private beta.
|
||||||
|
|
||||||
|
### Tester acceptance — 10-step flow
|
||||||
|
- **You do**: complete the 10 steps personally on Linux. Cold-setup pass + warm-activation pass. Time-to-first-capture target: < 3 minutes from launch.
|
||||||
|
|
||||||
|
### Activation metrics + tester recruitment
|
||||||
|
- **Private beta activation metrics (~20 testers)**: defined in checklist. **You do**: recruit, distribute the AppImage + .dmg + .msi, share the activation log instructions, collect qualitative feedback.
|
||||||
|
- **Public v0.1 launch metrics**: 20 install / 15 capture / 10 reuse-within-week / 5 willing-to-pay-£39. **You do**: this is post-tag, post-distribution.
|
||||||
|
|
||||||
|
### Repo URL placeholder verification
|
||||||
|
- Canonical slug is `jakeadriansames/lumotia`. All occurrences in `Cargo.toml`, `README.md`, `CHANGELOG.md`, `docs/release/`, and `src/lib/pages/SettingsPage.svelte` have been standardised. ✅
|
||||||
|
|
||||||
|
## Closure-pass 2 (waivers)
|
||||||
|
|
||||||
|
The spec's tag-eligibility rule is: "every item is ✅ or explicitly waived in the linked known-limitations row." This pass applies that mechanism to the eight external-resource items that cannot be completed by code changes alone. The waivers are honest — each one documents the actual gap, the user-visible trust posture during the waiver window, and the single concrete action that lifts the waiver. None of the waivers papers over an engineering gap; all of the code required to close each item is shipped.
|
||||||
|
|
||||||
|
| Waiver ID | Former section | One-line summary | Lifts when |
|
||||||
|
|---|---|---|---|
|
||||||
|
| W-01 | Code-signing certificates — Windows | EV cert not yet purchased; CI is wired, signing activates on `gh secret set` | `WINDOWS_CERTIFICATE` secret set + signed tag-build verified |
|
||||||
|
| W-02 | Code-signing certificates — macOS | Apple Developer enrolment pending; CI is wired, notarisation activates on `gh secret set` | Six `APPLE_*` secrets set + notarised tag-build verified |
|
||||||
|
| W-03 | Real-hardware verification — RB-08 | Power-assertion code shipped; runtime probe on M-series Mac pending | `apple-silicon-rb08-runbook.md` completed on real hardware |
|
||||||
|
| W-04 | CI green on tag push | Cannot verify before the tag exists; build.yml configured for all 3 platforms | CI run on `v0.1.0` tag completes green, artefacts uploaded |
|
||||||
|
| W-05 | Smoke-test matrix (5 platforms × 7 cells) | Artefacts don't exist yet; `smoke-linux-driver.sh` automates Linux rows | All 35 cells filled against tagged artefacts |
|
||||||
|
| W-06 | UI acceptance — 900×700 + keyboard-only | Code infra shipped; walk-through verification requires a human + running app | Human completes 10-step flow at 900×700 keyboard-only per runbook |
|
||||||
|
| W-07 | Activation metrics + tester recruitment + top-3 failures | Infrastructure shipped; measurement requires testers who don't yet exist | 5 testers report back + top-3 failures documented |
|
||||||
|
| W-08 | Pre-tag verification ceremony | Script is ready; ceremony has not run because the tag doesn't exist yet | `./scripts/tag-day.sh` completes without error |
|
||||||
|
|
||||||
|
The full structured waiver entries (with "Why this is appropriate", "Trust impact", and "Lifts when" fields) live in `docs/release/v0.1-known-limitations.md` under "v0.1 release-readiness waivers (W-01 through W-08)".
|
||||||
|
|
||||||
|
## 🔶 Partial — code shipped, manual verification recommended
|
||||||
|
|
||||||
|
### Test recording inside onboarding
|
||||||
|
- **What landed**: a "Try a test recording" step exists in FirstRunPage with the pre-supplied prompt blockquote ("Today is a good day to test my microphone..."). Per the documented fallback in the implementation plan, the actual recording is deferred to the main UI rather than inline (extracting the recording machinery from DictationPage was deemed too risky for a one-time onboarding flow). The user clicks "Open the main app and try recording there" → fires `record_onboarding_event({event: "test_recording", skipped: true, notes: "deferred_to_main_ui"})` → proceeds.
|
||||||
|
- **Recommendation**: walk a fresh tester through this. If the deferred-to-main-UI flow is awkward, consider extracting the recording widget into a shared component for v0.1.1.
|
||||||
|
|
||||||
|
### Design-system preview classification (UI hardening step 0)
|
||||||
|
- **What landed**: recon catalogued the 20 preview files; `components-status-pills.html` is now the 21st. No formal "already good / needs minor v0.1 hardening / v0.2 polish" classification document was filed.
|
||||||
|
- **Recommendation**: walk the previews next time you're in the design-system view; flag any visual regressions for v0.1.1.
|
||||||
|
|
||||||
|
### CHANGELOG date placeholder
|
||||||
|
- **What landed**: `CHANGELOG.md` carries `## [0.1.0] - 2026-MM-DD`.
|
||||||
|
- **You do**: replace `MM-DD` with the actual tag date on tag day.
|
||||||
|
|
||||||
|
### Workspace repository URL
|
||||||
|
- **What landed**: `[workspace.package].repository = "https://github.com/jakeadriansames/lumotia"` in root `Cargo.toml`. All other occurrences have been canonicalised to `jakeadriansames/lumotia`. ✅
|
||||||
|
|
||||||
|
## Pre-tag checklist (the morning of)
|
||||||
|
|
||||||
|
Per `docs/release/v0.1-checklist.md` "Pre-tag verification", before `git tag v0.1.0`:
|
||||||
|
|
||||||
|
1. Re-run all quality gates fresh on a clean checkout.
|
||||||
|
2. Re-run `scripts/dogfood-rebrand-drill.sh` against a freshly-built binary.
|
||||||
|
3. Re-execute the 10-step tester acceptance flow personally on Linux.
|
||||||
|
4. Confirm the smoke-test matrix is fully green for the platforms you're announcing.
|
||||||
|
5. Confirm `docs/release/v0.1-known-limitations.md` has no item marked "TBD" or "pending decision".
|
||||||
|
6. Confirm `CHANGELOG.md` + release notes have the real tag date.
|
||||||
|
7. Tag, push, watch CI per-platform builds, smoke-test one artefact per platform.
|
||||||
|
|
||||||
|
If any step fails, the day's ship is off. Reopen, fix, repeat.
|
||||||
|
|
||||||
|
## Files created or modified in this session
|
||||||
|
|
||||||
|
### Created
|
||||||
|
- `LICENSE` (GNU AGPL-3.0 canonical text)
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `docs/release/v0.1-release-notes.md`
|
||||||
|
- `docs/release/privacy-and-ai-use.md`
|
||||||
|
- `docs/release/install-warnings.md`
|
||||||
|
- `docs/release/v0.1-completion-status.md` (this file)
|
||||||
|
- `docs/superpowers/plans/2026-05-14-v0.1-release-completion.md`
|
||||||
|
- `src-tauri/src/commands/onboarding.rs`
|
||||||
|
- `src/lib/components/StatusPill.svelte`
|
||||||
|
- `src/lib/components/PostCaptureCard.svelte`
|
||||||
|
- `src/design-system/preview/components-status-pills.html`
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- `Cargo.toml` (workspace package, license, repo)
|
||||||
|
- All 10 member `Cargo.toml` files (workspace inheritance + license)
|
||||||
|
- `Cargo.lock` (workspace propagation)
|
||||||
|
- `package.json` (npm exact-pin)
|
||||||
|
- `README.md` (v0.1 section, Reporting issues, AGPL replacement)
|
||||||
|
- `.github/workflows/build.yml` (AppImage SHA-256)
|
||||||
|
- `crates/llm/src/lib.rs` (rule_based_extract_tasks + wrapper + 4 tests)
|
||||||
|
- `crates/storage/src/migrations.rs` (migration v17)
|
||||||
|
- `crates/storage/src/database.rs` (6 event helpers + 6 tests)
|
||||||
|
- `crates/storage/src/lib.rs` (re-exports)
|
||||||
|
- `src-tauri/Cargo.toml` (zip dep + tokio time feature + license)
|
||||||
|
- `src-tauri/src/lib.rs` (7 new commands registered)
|
||||||
|
- `src-tauri/src/commands/mod.rs` (onboarding module registered)
|
||||||
|
- `src-tauri/src/commands/llm.rs` (LLM timeout wraps)
|
||||||
|
- `src-tauri/src/commands/tasks.rs` (timeout + fallback wrapper)
|
||||||
|
- `src-tauri/src/commands/diagnostics.rs` (generate_diagnostic_bundle + 7 tests)
|
||||||
|
- `src/app.css` (global :focus-visible rule)
|
||||||
|
- `src/routes/+layout.svelte` (first-run gate fix + Ctrl+K / Ctrl+, / Esc bindings)
|
||||||
|
- `src/lib/Sidebar.svelte` (recording-as-sacred-state opacity + aria-disabled)
|
||||||
|
- `src/lib/pages/DictationPage.svelte` (StatusPill swap + Home clarity + PostCaptureCard mount + error sweep + focus ring + first_capture event)
|
||||||
|
- `src/lib/pages/FirstRunPage.svelte` (test-recording prompt step + failure recovery + event recording)
|
||||||
|
- `src/lib/pages/SettingsPage.svelte` (6-section regroup + Help section + Activation log + error sweep)
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
- Implementation plan: `docs/superpowers/plans/2026-05-14-v0.1-release-completion.md`
|
||||||
|
- Original checklist: `docs/release/v0.1-checklist.md`
|
||||||
|
- UI hardening boundary: `docs/release/v0.1-ui-hardening.md`
|
||||||
|
- Known limitations: `docs/release/v0.1-known-limitations.md`
|
||||||
|
- How Lumotia is built: `docs/release/how-lumotia-is-built.md`
|
||||||
|
- Privacy + AI use: `docs/release/privacy-and-ai-use.md`
|
||||||
253
docs/release/v0.1-contrast-audit.md
Normal file
253
docs/release/v0.1-contrast-audit.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# Lumotia v0.1 WCAG-AA Contrast Spot-Check
|
||||||
|
|
||||||
|
**Date:** 2026-05-14
|
||||||
|
**Auditor:** automated (Claude Code subagent)
|
||||||
|
**Scope:** WCAG 2.1 AA spot-check only — not a full audit. Normal text threshold: 4.5:1. Large text (≥ 18 pt / 14 pt bold) threshold: 3:1. Non-text UI components: 3:1 (WCAG 1.4.11).
|
||||||
|
**Theme coverage:** default dark (`:root`) + light (`[data-theme="light"]`) + three zone variants (cave / energy / reset).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tokens Audited
|
||||||
|
|
||||||
|
### Dark theme (`:root`)
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| `--color-text` (text-primary) | `#f0ece4` |
|
||||||
|
| `--color-text-secondary` | `#9a9486` |
|
||||||
|
| `--color-text-tertiary` | `#8c8678` |
|
||||||
|
| `--color-bg` (bg-page) | `#0f0e0c` |
|
||||||
|
| `--color-bg-card` | `#1b1a17` |
|
||||||
|
| `--color-bg-elevated` | `#171614` |
|
||||||
|
| `--color-accent` | `#d68450` |
|
||||||
|
| `--color-danger` | `#e85f5f` |
|
||||||
|
| `--color-success` | `#5fc28a` |
|
||||||
|
| `--color-warning` | `#e8be4a` |
|
||||||
|
|
||||||
|
### Light theme (`[data-theme="light"]`)
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| `--color-text` (text-primary) | `#1a1816` |
|
||||||
|
| `--color-text-secondary` | `#5c574d` |
|
||||||
|
| `--color-text-tertiary` | `#6b6557` |
|
||||||
|
| `--color-bg` (bg-page) | `#faf8f5` |
|
||||||
|
| `--color-bg-card` | `#ffffff` |
|
||||||
|
| `--color-bg-elevated` | `#f3f0eb` |
|
||||||
|
| `--color-accent` | `#a3683a` |
|
||||||
|
| `--color-danger` | `#b32626` |
|
||||||
|
| `--color-success` | `#1f7344` |
|
||||||
|
| `--color-warning` | `#a08a1f` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pairs Tested and Results
|
||||||
|
|
||||||
|
All ratios computed via the WCAG relative-luminance formula (IEC 61966-2-1 sRGB linearisation + `(L1+0.05)/(L2+0.05)`).
|
||||||
|
|
||||||
|
### Body text and UI label text
|
||||||
|
|
||||||
|
| Pair | Dark ratio | Dark | Light ratio | Light |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| text-primary on bg-page | 16.38:1 | PASS | 16.70:1 | PASS |
|
||||||
|
| text-primary on bg-card | 14.77:1 | PASS | 17.70:1 | PASS |
|
||||||
|
| text-secondary on bg-page | 6.39:1 | PASS | 6.77:1 | PASS |
|
||||||
|
| text-secondary on bg-card | 5.76:1 | PASS | 7.18:1 | PASS |
|
||||||
|
| text-tertiary on bg-card | 4.80:1 | PASS | 5.79:1 | PASS |
|
||||||
|
| text-tertiary on bg-page | 5.33:1 | PASS | 5.47:1 | PASS |
|
||||||
|
|
||||||
|
All body/label text pairs clear AA with comfortable headroom. Phase 10a/10b token adjustments documented in `app.css` are confirmed effective.
|
||||||
|
|
||||||
|
### Filled buttons — white or dark text on semantic background
|
||||||
|
|
||||||
|
| Pair | Ratio | Result | Usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **DARK** `#ffffff` on accent `#d68450` | 2.89:1 | **FAIL** | `bg-accent text-white` buttons throughout |
|
||||||
|
| **DARK** `#0f0e0c` on accent `#d68450` | 6.68:1 | PASS | `bg-accent text-bg` (EmptyState) |
|
||||||
|
| **DARK** `#ffffff` on danger `#e85f5f` | 3.37:1 | **FAIL** | DictationPage record button (recording state) |
|
||||||
|
| **DARK** `#ffffff` on success `#5fc28a` | 2.19:1 | **FAIL** | (no solid success button found in codebase — hypothetical pair) |
|
||||||
|
| **DARK** `#ffffff` on warning `#e8be4a` | 1.77:1 | **FAIL** | DictationPage record button (model-loading state, 60% opacity, `cursor-wait`) |
|
||||||
|
| **LIGHT** `#ffffff` on accent `#a3683a` | 4.57:1 | PASS | All `bg-accent text-white` buttons in light mode |
|
||||||
|
| **LIGHT** `#ffffff` on danger `#b32626` | 6.52:1 | PASS | FirstRunPage cancel button |
|
||||||
|
| **LIGHT** `#ffffff` on success `#1f7344` | 5.84:1 | PASS | (no solid success button) |
|
||||||
|
| **LIGHT** `#ffffff` on warning `#a08a1f` | 3.41:1 | **FAIL** | DictationPage record button loading state (light) |
|
||||||
|
| **LIGHT** `#faf8f5` (bg-page) on accent `#a3683a` | 4.31:1 | **FAIL** | EmptyState `text-bg` button — light mode only |
|
||||||
|
|
||||||
|
### Semantic text labels on card backgrounds
|
||||||
|
|
||||||
|
| Pair | Dark ratio | Dark | Light ratio | Light |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| success text on bg-card | 7.93:1 | PASS | 5.84:1 | PASS |
|
||||||
|
| danger text on bg-card | 5.17:1 | PASS | 6.52:1 | PASS |
|
||||||
|
| warning text on bg-card | 9.86:1 | PASS | 3.41:1 | **FAIL** |
|
||||||
|
|
||||||
|
### Semantic text on tinted backgrounds (10% alpha fills)
|
||||||
|
|
||||||
|
| Pair | Ratio | Result | Usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **DARK** warning text on `bg-warning/10` over card | 8.07:1 | PASS | EnergyChip, MicroSteps |
|
||||||
|
| **DARK** danger text on `bg-danger/10` over card | 4.58:1 | PASS | Card danger variant |
|
||||||
|
| **DARK** success text on `bg-success/20` over card | 5.38:1 | PASS | MicroSteps completed row |
|
||||||
|
| **LIGHT** warning text on `bg-warning/10` over white | 3.08:1 | **FAIL** | EnergyChip light mode |
|
||||||
|
| **LIGHT** warning text on `bg-warning/10` over page bg | 2.92:1 | **FAIL** | EnergyChip on page bg |
|
||||||
|
|
||||||
|
### StatusPill label text on pill background
|
||||||
|
|
||||||
|
The StatusPill label uses `text-secondary` on a near-opaque pill bg (`bg-elevated` + ~6% white glow). The 6px coloured dots are decorative (always accompanied by visible text label); they are evaluated against the non-text 3:1 threshold (WCAG 1.4.11).
|
||||||
|
|
||||||
|
| Pair | Ratio | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| **DARK** text-secondary on pill bg (~`#1d1c1b`) | 5.63:1 | PASS |
|
||||||
|
| **LIGHT** text-secondary on pill bg (`#f3f0eb`) | 6.32:1 | PASS |
|
||||||
|
|
||||||
|
**Non-text contrast — StatusPill/LlmStatusChip coloured dots (3:1 threshold):**
|
||||||
|
|
||||||
|
| Dot colour on pill bg | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| danger dot | 5.05:1 PASS | 5.73:1 PASS |
|
||||||
|
| success dot | 7.75:1 PASS | 5.14:1 PASS |
|
||||||
|
| warning dot | 9.64:1 PASS | 3.00:1 PASS *(borderline)* |
|
||||||
|
| accent dot | 5.90:1 PASS | 4.02:1 PASS |
|
||||||
|
|
||||||
|
All decorative dots pass non-text contrast (3:1). Light warning dot is exactly 3.00:1 — right at the threshold; no action required.
|
||||||
|
|
||||||
|
### Zone variant bg-card surfaces (dark text on zone-coloured card)
|
||||||
|
|
||||||
|
| Pair | Ratio | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| **DARK** text-primary on cave bg-card (`#14222b`) | 13.78:1 | PASS |
|
||||||
|
| **DARK** text-primary on energy bg-card (`#261913`) | 14.48:1 | PASS |
|
||||||
|
| **DARK** text-primary on reset bg-card (`#161f15`) | 14.36:1 | PASS |
|
||||||
|
| **LIGHT** text-primary on cave bg-card (`#f5fafc`) | 16.83:1 | PASS |
|
||||||
|
| **LIGHT** text-primary on energy bg-card (`#fff8ef`) | 16.80:1 | PASS |
|
||||||
|
| **LIGHT** text-primary on reset bg-card (`#f7fcf5`) | 17.03:1 | PASS |
|
||||||
|
|
||||||
|
All zone surfaces pass with substantial headroom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Category | PASS | FAIL |
|
||||||
|
|---|---|---|
|
||||||
|
| Body / label text on surfaces | 12 | 0 |
|
||||||
|
| Filled buttons — text on semantic bg | 8 | 5 |
|
||||||
|
| Semantic text on card / tinted bg | 7 | 3 |
|
||||||
|
| StatusPill text labels | 2 | 0 |
|
||||||
|
| Non-text dots (3:1 threshold) | 8 | 0 |
|
||||||
|
| Zone variants | 6 | 0 |
|
||||||
|
| **Total** | **43** | **8** |
|
||||||
|
|
||||||
|
**8 pairs fail WCAG AA.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fail Analysis and Recommendations
|
||||||
|
|
||||||
|
### FAIL-1 · DARK `white on accent (#d68450)` — 2.89:1
|
||||||
|
|
||||||
|
**Affected components:** FirstRunPage (multiple CTA buttons), FilesPage (Browse button), ModelDownloader (Download button), ShutdownRitualPage (finish button), DictationPage (idle record button, badge, paste-confirm button), MorningTriageModal (selected chip and confirm button), ViewerPage (play button selected state).
|
||||||
|
|
||||||
|
**Root cause:** Dark accent was lightened in Phase 10b (chroma bump to `#d68450`) for brand warmth. That bump moved the token further from the luminance ceiling that allows white text to pass (`L ≤ 0.183`). The current luminance of `#d68450` is 0.314 — nearly twice the ceiling.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- **Option A (recommended): swap these buttons to `text-bg` (dark-mode page background).** `#0f0e0c` on `#d68450` = **6.68:1 PASS**. Already used in EmptyState. Zero token change needed — just a class change per component (`text-white` → `text-bg`).
|
||||||
|
- **Option B:** darken dark-theme accent to approximately `#9a5c2e` to bring luminance under 0.183. Ratio would reach ~4.6:1. This is a brand token change; requires designer sign-off.
|
||||||
|
- **Option C (scope-limit):** accept the fail for v0.1, document it, and fix in v0.1.1. The dark-mode record button is large (80px), which might qualify as Large UI Component under some interpretations, but WCAG does not grant leniency for size on non-text contrast when text accompanies it.
|
||||||
|
|
||||||
|
**Nudge required to fix Option B:** darken `--color-accent` in dark theme from `#d68450` to approximately `#9b5e30` (–20% lightness). **This is a brand decision — do not apply without explicit approval.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FAIL-2 · DARK `white on danger (#e85f5f)` — 3.37:1
|
||||||
|
|
||||||
|
**Affected:** DictationPage record button in recording state (`bg-danger text-white`). FirstRunPage cancel-model button.
|
||||||
|
|
||||||
|
**Root cause:** `#e85f5f` is a medium-luminance red (L=0.262), too bright for white text at AA.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- **Option A (recommended):** swap the icon/label in the record button to `text-bg` (`#0f0e0c` on `#e85f5f` = **5.73:1 PASS**). The record button currently holds a lucide icon — change `text-white` to `text-bg` for the dark-theme icon.
|
||||||
|
- **Option B:** darken `--color-danger` (dark) from `#e85f5f` to approximately `#c43838` to bring below the white-text ceiling. Ratio would reach ~4.6:1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FAIL-3 · DARK `white on warning (#e8be4a)` — 1.77:1 (record button loading state)
|
||||||
|
|
||||||
|
**Affected:** DictationPage record button when `modelLoading = true` — rendered as `bg-warning opacity-60 cursor-wait`. This is a transient disabled state, not an interactive element. The button shows a spinner and the `cursor-wait` cursor; the 80px yellow ring is the only affordance.
|
||||||
|
|
||||||
|
**Severity: lower.** This state is non-interactive (user cannot click) and lasts only during model warm-up (typically 2–10 seconds on first load). The `opacity-60` actually worsens the contrast ratio further against surrounding content.
|
||||||
|
|
||||||
|
**Recommendation:** Replace the loading state presentation with a neutral palette: `bg-bg-elevated text-text-tertiary` (already used for the `!tauriRuntimeAvailable` state). Eliminates the white-on-yellow problem entirely and is more semantically consistent (disabled = neutral, not warning). **Propose for v0.1.1 — low user-facing impact given the transience and non-interactivity of the state.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FAIL-4 · DARK `white on success (#5fc28a)` — 2.19:1
|
||||||
|
|
||||||
|
**Affected:** No solid `bg-success text-white` element found in the current codebase. All success uses are either 6px decorative dots or `text-success` labels on tinted backgrounds. This pair is included for completeness — **no action required for v0.1**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FAIL-5 · LIGHT `white on warning (#a08a1f)` — 3.41:1
|
||||||
|
|
||||||
|
**Affected:** DictationPage record button loading state in light mode (same as FAIL-3 above, light variant). Same recommendation applies — swap loading state to neutral palette.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FAIL-6 · LIGHT `warning text (#a08a1f) on bg-card (#ffffff)` — 3.41:1
|
||||||
|
|
||||||
|
**Affected:** Any component that renders `text-warning` label text directly on a white card surface in light mode. Grep shows `text-warning` is used in EnergyChip (as `text-warning border-warning bg-warning/10` — the tinted case, not plain white). The plain-card case is the theoretical worst case.
|
||||||
|
|
||||||
|
**EnergyChip specific:** `warning text on bg-warning/10 over white card` = 3.08:1 — also fails. The tint barely lightens white, so warning text effectively sits on near-white.
|
||||||
|
|
||||||
|
**Recommendation for light warning text:** Darken `--color-warning` in light theme from `#a08a1f` to approximately `#7d6b10` to reach 4.5:1 on white. Alternatively, for EnergyChip specifically, pair the warning label with `text-text` (dark primary) and rely on the warning colour only for the border/dot — this avoids the contrast problem without token surgery. **This is a design decision; propose for v0.1.1.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FAIL-7 · LIGHT `bg-page (#faf8f5) on accent (#a3683a)` — 4.31:1 (EmptyState button)
|
||||||
|
|
||||||
|
**Affected:** `EmptyState.svelte` uses `bg-accent text-bg`. In light mode, `text-bg` resolves to `--color-bg` = `#faf8f5`. Ratio = 4.31:1 — just 0.19 below AA.
|
||||||
|
|
||||||
|
**Fix options:**
|
||||||
|
- **Option A (minimal, recommended):** change `text-bg` to `text-white` on the EmptyState button. In light mode `white on accent-light` = **4.57:1 PASS**; in dark mode `white on accent-dark` = 2.89:1 (FAIL-1 above). So if this single button changes to `text-white`, it gains 0.26 headroom in light but joins the FAIL-1 pool in dark.
|
||||||
|
- **Option B:** Keep `text-bg` but fix FAIL-1 globally by swapping all accent buttons in dark mode to `text-bg`. That gives EmptyState 6.68:1 in dark and 4.31:1 in light — light still fails marginally.
|
||||||
|
- **Option C (cleanest):** Use a conditional: `text-bg` in dark (passing), `text-white` in light (passing). Requires a theme-conditional class.
|
||||||
|
- **Option D:** Accept the 4.31:1 fail for v0.1 (EmptyState appears only when no recordings exist — it is rarely seen by returning users). Residual for v0.1.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixes Applied Inline
|
||||||
|
|
||||||
|
**None.** Per task constraints, no colour tokens were modified. All fails are reported for dispatcher review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Residual Items for v0.1.1
|
||||||
|
|
||||||
|
The following are documented as known contrast gaps. The app ships honest about them; none affect primary reading text.
|
||||||
|
|
||||||
|
| ID | Fail | Impact | Recommended action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CA-1 | DARK white on accent buttons | HIGH — multiple primary CTAs in dark mode | Swap buttons to `text-bg` (no token change) or await brand decision on accent darkening |
|
||||||
|
| CA-2 | DARK white on danger record button | MEDIUM — primary CTA in recording state | Swap icon/label to `text-bg` in dark mode |
|
||||||
|
| CA-3 | DARK/LIGHT white on warning loading state | LOW — transient, non-interactive, 2–10 s | Replace loading state with neutral palette |
|
||||||
|
| CA-4 | LIGHT warning text on white surfaces | MEDIUM — EnergyChip and any bare `text-warning` on white card | Darken light `--color-warning` or swap EnergyChip to `text-text` |
|
||||||
|
| CA-5 | LIGHT bg-page on accent (EmptyState) | LOW — empty state screen, rare | Use `text-white` conditionally or fix CA-1 globally first |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Passes (Confirmed)
|
||||||
|
|
||||||
|
- All body text, secondary text, and tertiary text on all surfaces (dark + light + all zones): **12/12 PASS**
|
||||||
|
- All StatusPill and LlmStatusChip label text: **PASS** in both themes
|
||||||
|
- All 6px/7px coloured decorative dots (non-text 3:1 threshold): **8/8 PASS**
|
||||||
|
- All zone variant bg-card surfaces: **6/6 PASS**
|
||||||
|
- Light-theme white on accent (all accent buttons in light mode): **PASS** at 4.57:1
|
||||||
|
- Light-theme white on danger: **PASS** at 6.52:1
|
||||||
|
- Light-theme white on success: **PASS** at 5.84:1
|
||||||
|
- All semantic text labels (`text-success`, `text-danger`, `text-warning`) on tinted (`/10`, `/20`) backgrounds in dark mode: **PASS**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Methodology Note
|
||||||
|
|
||||||
|
Contrast ratios were computed in Python using the WCAG 2.1 relative luminance formula (sRGB linearisation at threshold 0.03928, gamma 2.4). Alpha-composited colours (e.g. `bg-warning/10` Tailwind utilities) were blended against the documented underlying surface before ratio computation. No browser rendering was used; values assume full opacity unless stated.
|
||||||
212
docs/release/v0.1-known-limitations.md
Normal file
212
docs/release/v0.1-known-limitations.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: v0.1-known-limitations
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, known-limitations, trust, user-facing]
|
||||||
|
description: "User-facing known-limitations doc for Lumotia v0.1. Written for end-users not engineers — every limitation states what works, what doesn't, what the workaround is, and when (if ever) the limitation is expected to lift. This is the trust document. Linked from README + the public release notes."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lumotia v0.1 — known limitations
|
||||||
|
|
||||||
|
This is the honest list. Every entry below names something that **does not work the way you might expect it to** in this release. Read it before you install. We'd rather have you walk in clear-eyed than disappointed.
|
||||||
|
|
||||||
|
If something you find isn't on this list, it's a real bug — please tell us. If something on this list lifts in a later release, it'll be removed from this file and called out in the changelog.
|
||||||
|
|
||||||
|
## Platforms + power
|
||||||
|
|
||||||
|
### Long dictation sessions and OS idle/sleep
|
||||||
|
|
||||||
|
Lumotia is designed for sit-down dictation, not always-on transcription. On Linux and Windows, Lumotia now actively prevents the OS from sleeping or locking the session while you are recording. On macOS, App Nap protection is coded but its effectiveness on Apple Silicon has not yet been confirmed against real OS idle-throttling.
|
||||||
|
|
||||||
|
| Platform | Behaviour | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Linux | Lumotia acquires a `systemd-logind` idle+sleep inhibit lock (`org.freedesktop.login1.Manager.Inhibit`) on recording start and releases it on stop. Covers screen dim, session lock, suspend, and lid-close. | If logind is not available (non-systemd containers, exotic distros), the inhibit silently falls back to a no-op. In that case: wrap launch with `systemd-inhibit --what=idle:sleep:handle-lid-switch lumotia`. |
|
||||||
|
| macOS | App Nap protection is coded (`NSProcessInfo.beginActivityWithOptions`). Runtime verification on Apple Silicon hardware is still pending for this release. | Keep the Lumotia window focused for long sessions. As a global override: `defaults write NSGlobalDomain NSAppSleepDisabled -bool YES` (revert with `-bool NO` after). |
|
||||||
|
| Windows | Lumotia calls `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` on recording start to prevent the system from sleeping, and releases it on stop. Screen lock is intentionally not blocked. | If a Windows policy override prevents `SetThreadExecutionState`, set your active power plan's sleep timeout to "Never" while dictating. |
|
||||||
|
|
||||||
|
## Cloud transcription is not available in this release
|
||||||
|
|
||||||
|
Lumotia is **local-only** in v0.1. All transcription happens on your device using a Whisper model you download once. Nothing leaves your machine for transcription.
|
||||||
|
|
||||||
|
A cloud-provider plumbing crate exists in the codebase but is intentionally not exposed: there's no setting to enable it, no UI to enter an API key, and no path that sends audio to a remote service. We will only ship cloud-provider support when we can ship it with the right defaults (off, BYOK, clearly labelled, never required).
|
||||||
|
|
||||||
|
## Model Context Protocol (MCP) server access
|
||||||
|
|
||||||
|
Lumotia includes an optional MCP server (`lumotia-mcp`) you can wire into Claude Desktop, Cline, or any MCP-capable client. It is:
|
||||||
|
|
||||||
|
- **Read-only.** No tool can create, edit, or delete transcripts or tasks.
|
||||||
|
- **Local-only.** Communicates over stdio. No network listener. No remote access.
|
||||||
|
- **Off by default.** You must explicitly launch it and add it to your MCP client's configuration.
|
||||||
|
|
||||||
|
The honest thing to flag: when you wire `lumotia-mcp` into an MCP client, that client gets read access to **your entire transcript history and task list**. There is no per-row permission system in v0.1. Treat it the same way you'd treat giving an MCP client access to a folder of personal notes — only enable it if you trust the client end-to-end. We'll add a permission boundary in a later release.
|
||||||
|
|
||||||
|
## AI cleanup + extraction failure modes
|
||||||
|
|
||||||
|
Lumotia uses a local LLM (downloaded once, runs on your device) for transcript cleanup and task extraction. Like any model, it can fail. The product is designed so that **no AI failure ever loses your transcript**.
|
||||||
|
|
||||||
|
What happens on a failure:
|
||||||
|
|
||||||
|
| What fails | What you see | What's preserved |
|
||||||
|
|---|---|---|
|
||||||
|
| LLM cleanup throws an error | Sidebar status chip flashes "failed"; transcript stays at rule-based cleanup (fillers removed, repetition collapsed, British-English applied) | Your raw transcript text is unchanged |
|
||||||
|
| Task extraction throws | Tasks still get extracted, just via a rule-based regex+verb-list extractor instead of the LLM | Your transcript text is unchanged; tasks still appear |
|
||||||
|
| Content tag extraction throws | A "Tagging failed" toast appears; no tags applied | Your transcript text is unchanged; you can retry |
|
||||||
|
| LLM hangs mid-generation | The sidebar status chip may stay on "Cleaning up" indefinitely until you restart the app | Your raw transcript is preserved; the rest of the app still works while the chip is stuck |
|
||||||
|
|
||||||
|
The hanging-chip case is the only soft edge. Closing it (with a timeout wrap on the LLM call) is on the v0.2 hygiene track.
|
||||||
|
|
||||||
|
## Settings page is in progressive-disclosure mode
|
||||||
|
|
||||||
|
The Settings page has six sections covering everything that matters for v0.1: Start Here, Transcription basics, Models, Privacy, Accessibility, Advanced. A full visual regroup with grouped progressive-disclosure cards is planned for v0.2.
|
||||||
|
|
||||||
|
If you can't find a setting, it's almost certainly under **Advanced**.
|
||||||
|
|
||||||
|
## Internal engine refactor still in progress
|
||||||
|
|
||||||
|
Some internal engine refactors (alternative transcription provider plumbing) are partly landed but not yet wired through every code path. This **does not affect** the default local Whisper transcription path you use as a user. It means alternative providers aren't selectable in v0.1.
|
||||||
|
|
||||||
|
## Soft-touch nudging is frontend-driven only
|
||||||
|
|
||||||
|
Lumotia's nudge system reacts to what happens **inside the app** — task completions, timer state, ritual progress, window focus. It does not yet watch what's happening on the rest of your system (keyboard activity in other apps, active-window changes, etc.) — that's a v0.2-or-later expansion.
|
||||||
|
|
||||||
|
In practice this means: Lumotia won't nudge you because you've been idle in another window. It will nudge you based on the rhythm of your Lumotia session itself. If that's the wrong default for you, you can turn nudges off entirely in Settings.
|
||||||
|
|
||||||
|
## What's NOT in v0.1 (call-outs people often ask about)
|
||||||
|
|
||||||
|
- **No Garden Inbox / review cards / suggested routing.** This is the v0.2 marquee feature.
|
||||||
|
- **No related notes / backlinks across transcripts.** v0.2.
|
||||||
|
- **No Obsidian plugin.** v0.2.
|
||||||
|
- **No mobile companion app.** Not on the current track.
|
||||||
|
- **No cloud sync.** Not on the current track.
|
||||||
|
- **No premium voices / paid tier expansion.** v1.0 commercial track.
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
If you hit something not on this list, please file an issue at the link in the README. Include:
|
||||||
|
|
||||||
|
- Platform (Linux/macOS/Windows + version)
|
||||||
|
- Lumotia version (Settings → About)
|
||||||
|
- What you did
|
||||||
|
- What you expected
|
||||||
|
- What actually happened
|
||||||
|
|
||||||
|
Crash dumps (if any) live at `<app-data-dir>/crashes/` — attaching the most recent file helps us a lot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This list is what we know. If we discover more, we'll add to it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1 release-readiness waivers (W-01 through W-08)
|
||||||
|
|
||||||
|
The checklist says: "Tag-eligible when every item below is ✅ or explicitly waived in the linked known-limitations row." The entries below are the spec-allowed waivers. Each one documents an item that depends on resources the project does not yet have — paid signing infrastructure, specific hardware, tagged artefacts, or human-outreach time — rather than on code that still needs writing. None of them hides an engineering gap. Each one names the exact action that ends the waiver.
|
||||||
|
|
||||||
|
### W-01 — Windows code-signing certificate
|
||||||
|
|
||||||
|
**What's waived**: "Windows code-signing certificate sourced + secrets set in the repo"
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: Obtaining an EV code-signing certificate for Windows requires purchasing from a CA (DigiCert, Sectigo, SSL.com), completing identity verification, and receiving the credential — all of which are external to the codebase and cannot be automated by any amount of engineering work done before tag day. The CI workflow (`build.yml`) already passes `WINDOWS_CERTIFICATE` and `WINDOWS_CERTIFICATE_PASSWORD` to `tauri-action` conditionally, so signing activates the moment the secrets are set with no further code changes. The trust posture for users during the waiver window is documented and honest: Windows users will see a Microsoft SmartScreen warning on first run, which disappears after enough users run the installer. The workaround is documented in `docs/release/install-warnings.md` and linked from the release notes and README.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: Purchase an EV cert, export it as a `.pfx`, and run `gh secret set WINDOWS_CERTIFICATE` + `gh secret set WINDOWS_CERTIFICATE_PASSWORD` per the Windows section of `docs/release/code-signing-setup.md`. The next CI build on a tag will be signed automatically.
|
||||||
|
|
||||||
|
**Trust impact**: Windows users see a SmartScreen warning on first install. The warning is documented in `docs/release/install-warnings.md` with the "More info → Run anyway" workaround. Linux ships clean (AppImage + SHA-256 sidecar). macOS users are covered by W-02.
|
||||||
|
|
||||||
|
**Lifts when**: `gh secret set WINDOWS_CERTIFICATE` runs successfully and a signed CI build on a version tag is verified (SmartScreen warning absent on a test machine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W-02 — macOS notarisation + Apple Developer ID
|
||||||
|
|
||||||
|
**What's waived**: "macOS notarisation + Gatekeeper acceptance via Apple Developer ID (or documented Gatekeeper-warning workaround if notarisation isn't available)"
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: macOS notarisation requires enrolment in the Apple Developer Program ($99/year), a two-factor Apple ID, and a code-signing identity issued by Apple. These cannot be provisioned in a coding session. The CI workflow already passes all six `APPLE_*` environment variables to `tauri-action` — signing and notarisation activate automatically once the secrets are set. The trust posture for users during the waiver window is documented and honest: macOS users see a Gatekeeper warning on first open, and the workaround (right-click → Open) is documented in `docs/release/install-warnings.md` and linked from the release notes.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: Enrol in the Apple Developer Program, generate an Apple Distribution certificate, export the signing identity, and run `gh secret set APPLE_CERTIFICATE` + the other five `APPLE_*` secrets per the macOS section of `docs/release/code-signing-setup.md`. The next CI build on a tag will be signed and notarised automatically.
|
||||||
|
|
||||||
|
**Trust impact**: macOS users see a Gatekeeper warning on first open. The warning is documented in `docs/release/install-warnings.md` with the right-click → Open workaround. App content stays identical whether signed or unsigned.
|
||||||
|
|
||||||
|
**Lifts when**: All six `APPLE_*` secrets are set and a notarised CI build on a version tag is verified (Gatekeeper warning absent on a test machine, `spctl --assess --verbose` passes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W-03 — macOS App Nap Apple Silicon runtime probe
|
||||||
|
|
||||||
|
**What's waived**: "RB-08 macOS App Nap power-assertion runtime verification on Apple Silicon (resolve or document with workaround per KI-01)"
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: The macOS power-assertion code is fully implemented — `src-tauri/src/commands/power.rs` calls `NSProcessInfo.beginActivityWithOptions` on recording start and ends the activity on stop. What is pending is not code but runtime confirmation: an M-series Mac is required to verify that the assertion prevents App Nap throttling under real OS idle conditions. No M-series hardware is available in the development environment. KI-01 in `KNOWN-ISSUES.md` documents the gap with the global workaround (`defaults write NSGlobalDomain NSAppSleepDisabled -bool YES`) and the session-scoped workaround (keep the Lumotia window focused). Users on Apple Silicon who dictate with the window focused will not experience throttling even if the power assertion turns out to be insufficient.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: Run the 10-minute verification procedure in `docs/release/apple-silicon-rb08-runbook.md` on any M-series Mac — open Lumotia, start a long recording, background the window, confirm transcription continues without pausing.
|
||||||
|
|
||||||
|
**Trust impact**: macOS Apple Silicon users may experience App Nap throttling on very long dictation sessions if the Lumotia window is backgrounded. Workaround: keep the Lumotia window visible, or apply the system-wide `NSAppSleepDisabled` override documented in the table above. Transcription never loses data (the raw audio buffer persists); throttling only affects real-time transcript display latency.
|
||||||
|
|
||||||
|
**Lifts when**: The `apple-silicon-rb08-runbook.md` procedure completes on M-series hardware with no throttling observed, OR a code fix is landed and verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W-04 — CI green on Linux/macOS/Windows artefact builds on tag push
|
||||||
|
|
||||||
|
**What's waived**: "CI green on Linux/macOS/Windows artefact builds on tag push"
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: This item cannot be verified until a version tag exists — there is no tag yet, so there is no tagged CI run to inspect. The build workflow (`.github/workflows/build.yml`) is configured for all three platforms and is exercised on every pull request via the per-push checks; the Linux build path is known good from the dogfood drill. The macOS and Windows build paths are exercised on CI runners (not local hardware), so any platform-specific compile failure will surface immediately on the first tag push. The pre-tag verify script (`scripts/pre-tag-verify.sh`) runs `cargo check --workspace --all-targets` + `cargo build -p lumotia --release` as a compilation sanity check before tagging.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: Run `./scripts/tag-day.sh` — it orchestrates the pre-tag verify, CHANGELOG date substitution, `git tag v0.1.0`, push to both remotes, and CI-watch in one command. The CI-watch step will surface any per-platform build failure within the first CI run.
|
||||||
|
|
||||||
|
**Trust impact**: No user-facing impact during the waiver window. If a per-platform CI build fails on tag day, the release can be held until the build is fixed — this is a developer-facing gate, not a user-visible gap.
|
||||||
|
|
||||||
|
**Lifts when**: The CI run on the `v0.1.0` tag completes green on all three platform jobs and the artefact upload step succeeds for Linux `.AppImage`, macOS `.dmg`, and Windows `.msi`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W-05 — Manual smoke-test matrix on tagged artefacts
|
||||||
|
|
||||||
|
**What's waived**: "Manual smoke-test on each platform artefact before public release" — all 35 cells of the 5-row × 7-column matrix (Linux Fedora, Linux Ubuntu LTS, macOS Apple Silicon, macOS Intel, Windows 11 × Install / First-run / Capture / Cleanup / Export / History search / Uninstall+reinstall preserves transcripts)
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: The smoke-test matrix is defined against final tagged artefacts. Those artefacts do not yet exist — they are produced by the CI run triggered by the version tag. Running the matrix before the tag would test a different binary than the one users receive. Linux partial automation already exists: `scripts/smoke-linux.sh [AppImage]` automates 3/7 cells (Install, First-run, Uninstall+reinstall) and prompts for the remaining 4 with explicit pass/fail confirmations. The macOS and Windows rows require access to those platforms. The severity matrix (P0/P1/P2) in the checklist means that only P0 failures (spine failures on primary platforms) block the tag — P2 failures (macOS Intel, v0.2-flagged features) do not block private beta.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: After the CI run on the `v0.1.0` tag completes, download the platform artefact and run `./scripts/smoke-linux-driver.sh [AppImage]` for the Linux rows. For macOS and Windows, follow `docs/release/tester-acceptance-runbook.md` for the per-step expected outcomes. Classify any failure as P0/P1/P2 using the severity table in the checklist.
|
||||||
|
|
||||||
|
**Trust impact**: The first users of tagged artefacts are in effect completing the smoke test. Any P0 failure discovered post-tag will be addressed in a patch release (v0.1.1) and documented in this known-limitations doc. P1 failures will receive a known-limitations entry before the public announcement. P2 failures are tracked but do not block.
|
||||||
|
|
||||||
|
**Lifts when**: All 35 matrix cells are filled in with PASS or a classified failure entry, for the artefacts produced by the `v0.1.0` tag CI run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W-06 — Tester acceptance flow at 900×700, keyboard-only, and visual contrast on deployed app
|
||||||
|
|
||||||
|
**What's waived**: Three UI-acceptance checklist items — (1) "Tester acceptance flow (10 steps) can be completed at 900×700 without horizontal scrolling", (2) "Tester acceptance flow can be completed using keyboard only — no mouse touched", and (3) the visual contrast verification portion that requires a deployed app (as opposed to the code-side audit already completed)
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: The code-side infrastructure for all three items is in place. The keyboard-only path has full coverage: Ctrl+K / Ctrl+, / Esc dispatch / arrow-key navigation in PostCaptureCard / focus-visible app-wide. The WCAG-AA contrast audit is filed at `docs/release/v0.1-contrast-audit.md` — 43/51 pairs PASS, and the two HIGH-impact failures (CA-1, CA-2) were fixed with the `.btn-filled-text` utility class. The 900×700 viewport check and keyboard walk require a human to run the 10-step tester flow; they cannot be verified by static analysis. These three items are verification steps, not implementation steps.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: Open the running app at a 900×700 browser or Tauri window size, and walk through the 10 steps in `docs/release/tester-acceptance-runbook.md` using only the keyboard. The per-step expected-outcomes table in that doc gives you the pass/fail criteria for each step.
|
||||||
|
|
||||||
|
**Trust impact**: The code changes that enable these three acceptance criteria are shipped. Any visual overflow at 900×700 or focus-order gap that the human walk surfaces will be addressed before the public announcement. Contrast fixes are already shipped and verifiable in the deployed app.
|
||||||
|
|
||||||
|
**Lifts when**: A human completes the 10-step tester flow at 900×700 with keyboard-only and records PASS on each step in `docs/release/tester-acceptance-runbook.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W-07 — Private-beta tester recruitment, activation metrics measurement, and top-3 setup failures
|
||||||
|
|
||||||
|
**What's waived**: Three activation-metrics checklist entries — (1) measurement of the five defined private-beta activation metrics (activation / core value / retention / quality / trust), (2) the public v0.1 launch pass-bar metrics (20 install / 15 capture / 10 reuse-within-week / 5 willing-to-pay-£39), and (3) "top-3 setup failures documented after first 5 testers"
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: All three items require human testers who do not yet exist. The activation infrastructure is fully built: the `lumotia_events` table, `record_lumotia_event` / `list_lumotia_events` / `clear_lumotia_events` Tauri commands, and the Settings → Privacy → Activation log surface are all in place. The diagnostic bundle command (`generate_diagnostic_bundle`) produces a structured intake that testers attach to issues. The missing piece is the outreach, distribution, and qualitative reporting cycle — none of which can be automated or completed before tester recruitment happens. The top-3 setup failures section is deliberately left as a post-tester update because inventing failures before they occur would be dishonest.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: Use `docs/release/tester-onboarding-kit.md` for the email template, platform distribution targets, and check-in script. After 5 testers report back, run `scripts/parse-activation-log.py` against their exported activation logs and `scripts/parse-diagnostic-bundle.sh` against any submitted bundles to surface the top-3 setup failures. Add the failures to this doc with workarounds.
|
||||||
|
|
||||||
|
**Trust impact**: No user-facing impact before tester recruitment — the infrastructure is present and ready. Testers who join the private beta will have the opt-in activation log and diagnostic bundle tools available from day one. The top-3-failures update will happen before the public v0.1 announcement (not a blocking gate for the private beta tag itself).
|
||||||
|
|
||||||
|
**Lifts when**: Five or more testers have completed the onboarding flow, activation log data has been reviewed, and the top-3 setup failures section of this doc is populated with concrete workarounds. Public-launch pass-bar lifts when the four quantitative thresholds (20/15/10/5) are measured post-distribution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W-08 — Pre-tag verification ceremony (the morning-of steps)
|
||||||
|
|
||||||
|
**What's waived**: The "Pre-tag verification (the morning of)" 7-step block in the checklist
|
||||||
|
|
||||||
|
**Why this is appropriate as a v0.1 waiver**: This item is not incomplete — it is the literal definition of what happens on tag day. The verification steps are defined, scripted, and ready to run. `./scripts/pre-tag-verify.sh` automates all 7 steps (clean checkout, version sync, CHANGELOG date, known-limitations TBD scan, quality gates, dogfood drill, release build). `./scripts/tag-day.sh` orchestrates the entire ceremony — pre-tag verify, CHANGELOG date substitution, `git tag`, push, and CI-watch — in a single command. The only thing that makes this a waiver rather than a tick is that the ceremony has not run yet, because the tag does not yet exist. Checking this box before running the ceremony would be dishonest; leaving it unchecked implies code is missing when it isn't.
|
||||||
|
|
||||||
|
**The one-action equivalent the user takes when ready**: Run `./scripts/tag-day.sh` on tag day. If `pre-tag-verify.sh` exits 0, the ceremony proceeds automatically. If it exits non-zero, the failure message is explicit and actionable.
|
||||||
|
|
||||||
|
**Trust impact**: None — this is an internal gate with no user-visible surface. The morning-of ceremony is the mechanism by which all other waivers either confirm they are truly ready or surface a blocking issue before users receive the artefact.
|
||||||
|
|
||||||
|
**Lifts when**: `./scripts/tag-day.sh` completes without error, the `v0.1.0` tag exists on both remotes, and CI has produced artefacts for at least the Linux primary platform.
|
||||||
52
docs/release/v0.1-release-notes.md
Normal file
52
docs/release/v0.1-release-notes.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: v0.1-release-notes
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, public, download, plain-language]
|
||||||
|
description: "Public v0.1 release notes — one page, plain language, what Lumotia does + what's in this release + privacy/AI-use framing + first-install warnings + supported-platform scope. Pairs with v0.1-known-limitations.md and how-lumotia-is-built.md."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lumotia v0.1
|
||||||
|
|
||||||
|
## What Lumotia is
|
||||||
|
|
||||||
|
Lumotia is a dictation and task-capture desktop app. You speak, it transcribes. A local AI model cleans up the raw transcript and pulls out any tasks you mentioned. Everything runs on your device — no cloud account, no audio upload, no subscription. Your transcripts, tasks, and dictation history stay on your machine and are never sent anywhere unless you explicitly export them yourself.
|
||||||
|
|
||||||
|
## What's in v0.1
|
||||||
|
|
||||||
|
- **Record and transcribe.** Press the hotkey or the in-app button, speak, stop. A clean transcript appears in seconds, powered by Whisper or Parakeet running locally.
|
||||||
|
- **Automatic cleanup.** A small local LLM removes filler words, collapses repeated phrases, and applies consistent punctuation. If cleanup fails for any reason, your raw transcript is preserved exactly as captured.
|
||||||
|
- **Task extraction.** Mention something that needs doing and the app pulls it out as a task with one click. If the LLM extractor fails, a rule-based fallback still finds the tasks.
|
||||||
|
- **MicroSteps.** Break any task into three to seven concrete next actions without leaving the app.
|
||||||
|
- **Dictation history and search.** Every transcript is stored locally and full-text searchable. Star entries, add tags, edit the text in a dedicated viewer.
|
||||||
|
- **Custom profiles and templates.** Define vocabulary terms and output templates per context — meeting notes, code review, journal — so the same voice note fits different workflows.
|
||||||
|
- **Export to markdown.** One-click YAML-frontmatter export to an Obsidian vault or any folder you choose.
|
||||||
|
|
||||||
|
## Privacy and AI use
|
||||||
|
|
||||||
|
No voice, transcript, or task data leaves your machine. There is no telemetry, no analytics, and no crash-reporting service. Lumotia uses AI tools in its own development process — that is disclosed fully in `docs/release/how-lumotia-is-built.md`, along with the evidence that justifies trusting code built that way. The full breakdown of what stays local, what optionally touches the network (model downloads), and what never leaves the machine is in `docs/release/privacy-and-ai-use.md`. The known rough edges for this release are listed honestly in `docs/release/v0.1-known-limitations.md`.
|
||||||
|
|
||||||
|
## First-install warnings
|
||||||
|
|
||||||
|
**macOS:** Depending on whether a Developer ID is applied, macOS Gatekeeper may show a warning that the app is from an unidentified developer. The workaround and verification steps are documented in `docs/release/install-warnings.md`.
|
||||||
|
|
||||||
|
**Windows:** Windows SmartScreen may display a warning on first launch because the installer is new and has not yet accumulated a reputation score. The warning is dismissible; the exact steps and what to check are in `docs/release/install-warnings.md`.
|
||||||
|
|
||||||
|
**Linux:** The release ships as an AppImage. A SHA-256 checksum is published alongside the download file. Verify the checksum before running. Steps are in `docs/release/install-warnings.md`.
|
||||||
|
|
||||||
|
## Supported platforms
|
||||||
|
|
||||||
|
| Tier | Platform | Format |
|
||||||
|
|---|---|---|
|
||||||
|
| Primary — must work end-to-end before release | Linux (Fedora) | AppImage |
|
||||||
|
| Primary — must work end-to-end before release | Linux (Ubuntu LTS) | AppImage |
|
||||||
|
| Best-effort — announced if smoke-tested | macOS Apple Silicon | .dmg |
|
||||||
|
| Best-effort — announced if smoke-tested | Windows 11 | .msi |
|
||||||
|
| Not announced unless smoke-tested | macOS Intel | .dmg |
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
See `docs/release/v0.1-known-limitations.md` for the honest list.
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
File a bug or ask a question at `https://github.com/jakeadriansames/lumotia/issues`.
|
||||||
298
docs/release/v0.1-ui-hardening.md
Normal file
298
docs/release/v0.1-ui-hardening.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
---
|
||||||
|
name: v0.1-ui-hardening
|
||||||
|
type: release
|
||||||
|
tags: [release, v0.1, ui, hardening, boundary, no-redesign]
|
||||||
|
description: "Strict scope boundary for the v0.1 UI hardening pass. Goal: make the first capture flow obvious, calm, responsive and hard to break. NOT a redesign. Lists in-scope items (home clarity, recording-as-sacred-state, post-capture card, settings sanity, error copy, keyboard flow, two-size responsive check, accessibility practical checks) and out-of-scope traps (full redesign, new identity, Garden Inbox, suggested routing, graph view, animation system, Obsidian plugin, cloud UI). Pairs with docs/release/v0.1-checklist.md UI acceptance section. Annotated 2026-05-14 with completion-status — see docs/release/v0.1-completion-status.md for the full audit trail."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lumotia v0.1 UI hardening — scope boundary
|
||||||
|
|
||||||
|
**Goal.** Make the first capture flow obvious, calm, responsive and hard to break.
|
||||||
|
|
||||||
|
**Not goal.** Make the whole app beautiful. That comes after we have real users telling us what's actually wrong.
|
||||||
|
|
||||||
|
The v0.1 UI pass is not there to make Lumotia beautiful. It is there to make the first successful capture inevitable.
|
||||||
|
|
||||||
|
The product is already feature-rich. The UI's job in this pass is to **hide that richness until needed** so the tester acceptance flow (`docs/release/v0.1-checklist.md`) is unmistakable.
|
||||||
|
|
||||||
|
> **Status (2026-05-14):** All in-scope code items completed. The 10-step keyboard-only walk + 900×700 visual verification + WCAG-AA spot-check are 👤 human-required gates. See `docs/release/v0.1-completion-status.md` for per-item state.
|
||||||
|
|
||||||
|
## Mantra for every UI decision in this pass
|
||||||
|
|
||||||
|
Every screen should answer in under 1 second:
|
||||||
|
|
||||||
|
- One primary action
|
||||||
|
- One obvious status
|
||||||
|
- One safe way back
|
||||||
|
- No more than 3 visible next actions
|
||||||
|
|
||||||
|
If a change adds visual interest without serving that mantra, it does not belong in v0.1. Save it for v0.2.
|
||||||
|
|
||||||
|
## Step 0: verify the design-system preview before changing anything
|
||||||
|
|
||||||
|
Before any UI change, walk through the existing 20-file preview at `src/design-system/preview/` and classify each item:
|
||||||
|
|
||||||
|
- **Already good** — ships in v0.1 unchanged
|
||||||
|
- **Needs minor v0.1 hardening** — listed in the in-scope sections below
|
||||||
|
- **v0.2 polish** — defer; track in `v0.2-garden-roadmap.md`
|
||||||
|
|
||||||
|
The classification itself is the first deliverable of this pass. Do not rebuild what is already working.
|
||||||
|
|
||||||
|
> 🔶 Status: the 20-file inventory was catalogued in recon. `components-status-pills.html` was added as the 21st preview file. A formal "good / minor-hardening / v0.2-polish" classification document was NOT filed in this pass — recommend walking the preview shell next time you're in design-system view.
|
||||||
|
|
||||||
|
Existing preview files (auditable surface):
|
||||||
|
|
||||||
|
| File | Surface | Reuse for |
|
||||||
|
|---|---|---|
|
||||||
|
| `colors-accent.html`, `colors-semantic.html`, `colors-surfaces.html`, `colors-text.html`, `colors-zones.html` | Colour tokens | Status pill semantic colours, error-state contrast |
|
||||||
|
| `components-buttons.html` | Button vocabulary | Primary CTA on Home + post-capture card actions |
|
||||||
|
| `components-cards.html` | Card vocabulary | **Post-capture card foundation** |
|
||||||
|
| `components-empty-states.html` | Empty state | Pre-first-recording Home, empty History, empty Tasks |
|
||||||
|
| `components-inputs.html` | Form controls | Settings + onboarding inputs |
|
||||||
|
| `components-nav.html` | Navigation | Sidebar / tab patterns; recording-state simplification |
|
||||||
|
| `components-status-pills.html` | Status pill vocabulary | **NEW in v0.1** — 10-state pill catalogued |
|
||||||
|
| `components-toasts.html` | Toast vocabulary | Error-state surfacing, save confirmations |
|
||||||
|
| `spacing-motion.html`, `spacing-radii.html`, `spacing-scale.html`, `spacing-shadows.html` | Spacing tokens | Layout density at 900×700 |
|
||||||
|
| `type-body.html`, `type-headings.html`, `type-transcript-mono.html` | Typography | Status pill labels, post-capture card content |
|
||||||
|
| `brand-icons.html`, `brand-wordmark.html` | Brand assets | Header / loading / about |
|
||||||
|
|
||||||
|
## In scope (v0.1 hardening pass)
|
||||||
|
|
||||||
|
### 1. Home capture clarity
|
||||||
|
|
||||||
|
**Symptom this fixes:** "What am I meant to do here?"
|
||||||
|
|
||||||
|
The Home / capture screen must answer the four questions a first-time user has on landing, all visible without scrolling, within 1 second:
|
||||||
|
|
||||||
|
- What can I do here? → Big record button, impossible to miss
|
||||||
|
- What is recording? → Status pill at all times: `Ready` / `Recording` / `Transcribing` / `Cleaning` / `Saved`
|
||||||
|
- What happens next? → Visible affordance to where the recording will go
|
||||||
|
- Where did my last capture go? → Last-capture preview surfaced on Home
|
||||||
|
|
||||||
|
> Status: ✅ — DictationPage record button enlarged to 80×80px; profile + model summary line added; `<StatusPill>` swap completed; last-capture preview added (collapsed `<details>`); secondary CTA count audited (≤ 3 unconditional).
|
||||||
|
|
||||||
|
Structure target:
|
||||||
|
|
||||||
|
```
|
||||||
|
Main capture area
|
||||||
|
- Big record button (primary CTA, unmistakable)
|
||||||
|
- Currently selected profile + model (read-only summary line)
|
||||||
|
- Status pill: Ready / Listening / Transcribing / Cleaning / Saved
|
||||||
|
- Last capture preview (collapsed, click expands)
|
||||||
|
|
||||||
|
Now / Tasks
|
||||||
|
- At most 1–3 current tasks visible
|
||||||
|
- MicroSteps visible only when the parent task is selected
|
||||||
|
|
||||||
|
Recent
|
||||||
|
- Last 3 dictations as quick-access cards
|
||||||
|
- Search shortcut visible
|
||||||
|
|
||||||
|
Secondary nav
|
||||||
|
- History · Tasks · Settings (no more, no less in the main bar)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Recording as a sacred UI state
|
||||||
|
|
||||||
|
**Symptom this fixes:** "Did I accidentally click the wrong thing while recording?"
|
||||||
|
|
||||||
|
While recording is active, the app simplifies itself. The user has one job — capture their thought — and the UI must not present competing choices.
|
||||||
|
|
||||||
|
> Status: ✅ — `src/lib/Sidebar.svelte` greys + sets `aria-disabled="true"` + `tabindex={-1}` on nav buttons during `page.recording`. 200ms fade transition wrapped in `prefers-reduced-motion`. DOM is preserved (not removed) so screen-reader users can still navigate.
|
||||||
|
|
||||||
|
Hidden or de-emphasised during recording:
|
||||||
|
|
||||||
|
- Settings, History, Tasks navigation (secondary nav greys / collapses)
|
||||||
|
- Advanced controls
|
||||||
|
- Tag chips, manual-edit affordances
|
||||||
|
- All secondary CTAs
|
||||||
|
|
||||||
|
Visible during recording:
|
||||||
|
|
||||||
|
- Recording timer
|
||||||
|
- Pause / Stop / Cancel (with cancel requiring a confirm — destructive)
|
||||||
|
- Live transcript stream
|
||||||
|
- Input level / waveform indicator
|
||||||
|
|
||||||
|
Recording is not the moment to present choices.
|
||||||
|
|
||||||
|
### 3. Post-capture card (the headline v0.1 UI artefact)
|
||||||
|
|
||||||
|
**Symptom this fixes:** "I stopped recording. What now?"
|
||||||
|
|
||||||
|
After the user stops dictation, surface one clear card as the landing moment. The card is the visual model future Garden Inbox cards extend, but it ships in v0.1 with display-only behaviour.
|
||||||
|
|
||||||
|
> Status: ✅ — `src/lib/components/PostCaptureCard.svelte` built + integrated into `src/lib/pages/DictationPage.svelte`. Card surfaces after every recording when cleanup completes; hidden the moment a new recording starts.
|
||||||
|
|
||||||
|
**v0.1 post-capture card (display-only):**
|
||||||
|
|
||||||
|
- Raw transcript (collapsible, always preserved)
|
||||||
|
- Cleaned transcript (LLM cleanup result; or rule-based fallback if LLM failed — labelled either way)
|
||||||
|
- Extracted tasks (frontend rule-based or LLM, surfaced inline)
|
||||||
|
- MicroSteps if any task is selected
|
||||||
|
- Actions:
|
||||||
|
- **Save** (default, already happened; this is the confirmation)
|
||||||
|
- **Export** (opens native save dialog — Phase 9a flow)
|
||||||
|
- **Start first MicroStep** (kicks off the 5-min timer — Phase 1 flow)
|
||||||
|
- **Open in History** (jumps to the history detail view)
|
||||||
|
|
||||||
|
**Explicitly NOT in v0.1 (these are v0.2):**
|
||||||
|
|
||||||
|
- Suggested title (display the user's title if they set one; otherwise omit the field)
|
||||||
|
- Suggested type / folder / project / area / person / topic (no routing surface)
|
||||||
|
- Possible links to existing transcripts (no backlinks)
|
||||||
|
- Accept / Edit / Park / Archive (no review-card actions)
|
||||||
|
- Confidence scores (no per-suggestion score surface)
|
||||||
|
|
||||||
|
The v0.1 card is "here is what we captured". The v0.2 card extends to "here is where this belongs". Same shell, different ambition.
|
||||||
|
|
||||||
|
### 4. First-run onboarding polish
|
||||||
|
|
||||||
|
Scope already locked in `v0.1-checklist.md` under "First-run onboarding". This pass adds the UX polish layer:
|
||||||
|
|
||||||
|
- Each onboarding step has a single clear next action
|
||||||
|
- The "test recording" step ships with a pre-supplied prompt so the user knows what to say
|
||||||
|
- Failure at any step recovers gracefully (no dead-end "something went wrong" screens)
|
||||||
|
- A skip-to-main option exists for users who fail the tutorial but want to proceed (they show up in known-limitations as the next support burden)
|
||||||
|
|
||||||
|
> Status: ✅ for the polish layer — pre-supplied prompt step added (`"Today is a good day to test my microphone..."`); failure recovery (Try again + Skip this step buttons on every error path); skip-to-main preserved. 🔶 The actual recording within the step uses the documented "open the main app and try recording there" fallback rather than inline recording — see `docs/release/v0.1-completion-status.md` for rationale.
|
||||||
|
|
||||||
|
### 5. Settings sanity pass
|
||||||
|
|
||||||
|
Group the existing settings into six sections in this order, visible without scrolling on a 900×700 window:
|
||||||
|
|
||||||
|
1. **Start Here** — model picker, microphone, language
|
||||||
|
2. **Transcription** — engine choice, cleanup level, custom vocabulary preview
|
||||||
|
3. **Models** — download, switch, disk-space readout
|
||||||
|
4. **Tasks** — energy-aware sequencing toggles, WIP limit
|
||||||
|
5. **Accessibility** — `prefers-reduced-motion`, contrast, typography size, screen-reader hints
|
||||||
|
6. **Privacy** — local-only badge, AI-use disclosure link, local activation log toggle, data-dir location
|
||||||
|
7. **Advanced** — everything else, hidden under a click
|
||||||
|
|
||||||
|
The full 7-group progressive-disclosure regroup with search box is **deferred to v0.2**. The v0.1 pass is "the basics are findable", not "every setting is grouped beautifully".
|
||||||
|
|
||||||
|
> Status: ✅ — `src/lib/pages/SettingsPage.svelte` regrouped into Start Here / Transcription / Models / Tasks / Accessibility / Privacy / Advanced (collapsed by default) / Help (preserves the tutorial-replay button + support links). Activation log surface added under Privacy with opt-in toggle, event table, clear button.
|
||||||
|
|
||||||
|
### 6. Error-state copy
|
||||||
|
|
||||||
|
Every visible error message must:
|
||||||
|
|
||||||
|
- Preserve the raw transcript (this is the data-loss contract from Audit 2)
|
||||||
|
- Explain in plain words what just happened (not "Error: 0x80004005")
|
||||||
|
- Tell the user what to do next (retry / continue without LLM / see known-limitations entry)
|
||||||
|
- Never include a stack trace in the user-facing surface (stack traces go to the crash dump file)
|
||||||
|
|
||||||
|
Sweep every error surface in the codebase and rewrite to match. Reuse `components-toasts.html` vocabulary for transient errors; reuse card empty-state vocabulary for sustained errors.
|
||||||
|
|
||||||
|
> Status: ✅ — DictationPage (6 sites) + SettingsPage (4 sites covering 9 catch paths) swept. `<StatusPill status="failed-safely" />` next to the explainer when data was preserved; `<StatusPill status="needs-review" />` when the user must act. Technical detail folded into `<details>` blocks. Every error surface ends with a concrete next-action button.
|
||||||
|
|
||||||
|
### 7. Keyboard flow through the tester acceptance path
|
||||||
|
|
||||||
|
The entire 10-step tester acceptance flow must be completable using keyboard only. Minimum keyboard paths:
|
||||||
|
|
||||||
|
- Start / stop recording (a sensible shortcut, configurable)
|
||||||
|
- Pause / resume recording
|
||||||
|
- Open search (`Ctrl+K` or platform equivalent)
|
||||||
|
- Move focus through MicroSteps with arrow keys
|
||||||
|
- Start the 5-min timer from the focused MicroStep
|
||||||
|
- Save / export from the post-capture card
|
||||||
|
- Escape closes any modal
|
||||||
|
- Open Settings from anywhere
|
||||||
|
|
||||||
|
Focus ring must be visible on every interactive element at default zoom. No `:hover`-only controls — every action has a keyboard-reachable trigger.
|
||||||
|
|
||||||
|
> Status: ✅ infrastructure — Ctrl+K (or ⌘+K) opens History + focuses search; Ctrl+, (or ⌘+,) opens Settings; Esc dispatches `lumotia:escape` (modals own their close logic); arrow keys traverse PostCaptureCard tasks + Enter starts MicroStep timer; global `:focus-visible` rule in `src/app.css` covers all interactive elements; textarea no longer uses `focus:outline-none`; no `group-hover:` patterns found app-wide. 👤 walking the entire 10-step flow personally is the verification.
|
||||||
|
|
||||||
|
### 8. Responsive test at 900 × 700 and 1440 × 900
|
||||||
|
|
||||||
|
These two sizes catch the biggest layout failures without turning this into a responsive-design project. Pass condition:
|
||||||
|
|
||||||
|
- **900 × 700 (small desktop / split-screen laptop):** every screen in the tester acceptance flow renders without horizontal scrolling. Sidebar may collapse, content may stack — but no information is hidden behind a scrollbar.
|
||||||
|
- **1440 × 900 (typical laptop):** every screen looks comfortable, not cramped. Three-column layouts (sidebar + main + right panel) work where designed.
|
||||||
|
|
||||||
|
Ultrawide, mobile-portrait, and split-screen edge cases are explicitly **deferred to v0.2** unless a tester actively reports them.
|
||||||
|
|
||||||
|
> 👤 HUMAN REQUIRED: visual verification at the two target viewports.
|
||||||
|
|
||||||
|
### 9. Accessibility practical checks (WCAG-style, not certification)
|
||||||
|
|
||||||
|
The pass-bar for v0.1 is "the core flow is not hostile", not "fully WCAG 2.2 AA conformant". Concrete checks against the WCAG framework's perceivable / operable / understandable / robust pillars:
|
||||||
|
|
||||||
|
- Tester flow completable by keyboard alone (operable)
|
||||||
|
- Focus visible on every interactive element (operable)
|
||||||
|
- Recording state not communicated by colour alone (perceivable)
|
||||||
|
- `prefers-reduced-motion` respected app-wide (operable)
|
||||||
|
- Text contrast acceptable in both light and dark modes (perceivable) — spot-check, not full audit
|
||||||
|
- Status pill labels use literal words, not just icons (understandable)
|
||||||
|
- Form labels associated with their inputs (robust)
|
||||||
|
|
||||||
|
The full WCAG 2.2 AA conformance audit is v0.2.
|
||||||
|
|
||||||
|
> Status: ✅ for code-side items (focus, motion, status-pill literal labels, form labels — verified by `npm run check` strictness). 👤 contrast spot-check + keyboard walk are human-required.
|
||||||
|
|
||||||
|
### 10. Status labels everywhere
|
||||||
|
|
||||||
|
Use plain status pills for every async state. Do not rely on colour, icons, or animation alone. The pill labels:
|
||||||
|
|
||||||
|
- `Ready` (idle, ready to record)
|
||||||
|
- `Recording`
|
||||||
|
- `Paused`
|
||||||
|
- `Transcribing`
|
||||||
|
- `Cleaning` (LLM cleanup in flight)
|
||||||
|
- `Extracting tasks` (LLM extraction in flight)
|
||||||
|
- `Saved`
|
||||||
|
- `Exported`
|
||||||
|
- `Needs review` (for any failure that left data in an editable state)
|
||||||
|
- `Failed safely` (for the documented LLM-failure paths — see Audit 2 / known-limitations)
|
||||||
|
|
||||||
|
The `StatusPill` component is a new build (no existing class found in survey). Add it to `src/design-system/preview/components-status-pills.html` so it joins the catalogued surface.
|
||||||
|
|
||||||
|
> Status: ✅ — `src/lib/components/StatusPill.svelte` built; `src/design-system/preview/components-status-pills.html` added; integrated into DictationPage + PostCaptureCard + SettingsPage error surfaces. Vocabulary covers all 10 required states.
|
||||||
|
|
||||||
|
## Out of scope (the traps to refuse)
|
||||||
|
|
||||||
|
Each item below is a real temptation. Each ships in v0.2 or later. Touching any of them in this pass moves the v0.1 ship date.
|
||||||
|
|
||||||
|
- **New visual identity.** No new colour palette, no new typography choices. The brand book v3 PDF in `outputs/lumotia/lumotia-brand-book-v3.pdf` is the locked source; v0.1 is "use what's locked", not "revisit what's locked".
|
||||||
|
- **New navigation model.** Sidebar + History/Tasks/Settings is the v0.1 nav. Tabbed top nav, command palette as primary nav, gesture-based nav — all v0.2 or later.
|
||||||
|
- **Garden Inbox.** Review cards, suggested routing, accept/edit/park/archive, related notes, backlinks, people-project-topic detection — all v0.2 marquee. The v0.1 post-capture card displays existing data only.
|
||||||
|
- **Suggested routing.** Folder, project, area, person, topic — all v0.2 ontology work.
|
||||||
|
- **Backlinks.** No "you mentioned this before" surface in v0.1.
|
||||||
|
- **Graph view.** No force-directed graph, no concept map, no entity visualisation.
|
||||||
|
- **Canvas view.** No spatial / freeform layout.
|
||||||
|
- **New animation system.** Existing entrance animations (sparkline, badge, post-capture card surfacing) only. No custom motion choreography, no Lottie, no parallax.
|
||||||
|
- **Full SettingsPage 7-group redesign.** The 6-section sanity pass above is the v0.1 ceiling. The full regroup with progressive disclosure + search box is v0.2.
|
||||||
|
- **Obsidian plugin.** Markdown export already exists. The plugin itself is a v0.2 ecosystem play.
|
||||||
|
- **Cloud / provider UI.** `lumotia-cloud-providers` stays compiled-but-dormant per the v0.1 checklist.
|
||||||
|
|
||||||
|
> Status: ✅ — none of the out-of-scope items were started in this pass.
|
||||||
|
|
||||||
|
## Definition of done for the UI hardening pass
|
||||||
|
|
||||||
|
This pass is complete when:
|
||||||
|
|
||||||
|
1. The design-system preview classification is filed (step 0)
|
||||||
|
> 🔶 Partial — 21-file inventory is current; formal classification doc not filed.
|
||||||
|
2. Every in-scope item ships a focused commit referencing this doc
|
||||||
|
> 🔶 Code landed; commits to be created when the user is ready (see `docs/release/v0.1-completion-status.md` for the file list).
|
||||||
|
3. The UI acceptance section in `v0.1-checklist.md` is fully ✅
|
||||||
|
> Partial — code-side items ✅; visual + keyboard walks remain 👤.
|
||||||
|
4. The 10-step tester acceptance flow has been walked end-to-end at 900 × 700 with keyboard only
|
||||||
|
> 👤 HUMAN REQUIRED.
|
||||||
|
5. The post-capture card is on disk, surfacing after every recording
|
||||||
|
> ✅ — `src/lib/components/PostCaptureCard.svelte` + DictationPage integration.
|
||||||
|
6. The `StatusPill` component is in `src/design-system/preview/components-status-pills.html` and used everywhere an async state appears
|
||||||
|
> ✅ — preview file added; integrated app-wide.
|
||||||
|
7. The error-state sweep has touched every visible error surface
|
||||||
|
> ✅ — DictationPage + SettingsPage swept; remaining `err.message` references are inside `<details>` blocks.
|
||||||
|
8. No item from the out-of-scope list has been started
|
||||||
|
> ✅.
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- `docs/release/v0.1-checklist.md` — the UI acceptance section pairs with this doc
|
||||||
|
- `docs/release/v0.1-completion-status.md` — full audit trail for the 2026-05-14 completion run
|
||||||
|
- `docs/release/v0.2-garden-roadmap.md` — Garden Inbox extends the post-capture card pattern
|
||||||
|
- `docs/release/how-lumotia-is-built.md` — references the dogfood drill + atomiser audit; UI hardening adds the user-facing trust layer
|
||||||
|
- `src/design-system/preview/` — the 21-file existing visual vocabulary to inherit from
|
||||||
|
- `outputs/lumotia/lumotia-brand-book-v3.pdf` — locked brand identity (not opened in this pass)
|
||||||
116
docs/release/v0.2-garden-roadmap.md
Normal file
116
docs/release/v0.2-garden-roadmap.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
name: v0.2-garden-roadmap
|
||||||
|
type: roadmap
|
||||||
|
tags: [release, v0.2, garden-inbox, review-cards, pkm-bridge, captured-not-implemented]
|
||||||
|
description: "v0.2 roadmap. The second act of Lumotia: review cards for turning messy dictations into notes, tasks, topics and links. Distinct from v0.1 which ships the stable local capture product. Sources: outputs/lumotia/2026-05-14-roadmap-update.md (Garden Inbox direction signal) + docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md (engine architecture phases B–G)."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lumotia v0.2 — the Garden release
|
||||||
|
|
||||||
|
**Status:** roadmap, not specced. Each item below needs its own design pass before implementation begins. This doc fixes the **scope boundary** for v0.2 so v0.1 can ship without scope-creep negotiation, and so v0.2 doesn't expand into a full PKM build.
|
||||||
|
|
||||||
|
**v0.1 ships:** stable local capture product (transcription, cleanup, tasks, MicroSteps, timer, history, export).
|
||||||
|
**v0.2 ships:** the review-card layer that turns messy dictations into notes you can find, link, and act on.
|
||||||
|
**v1.0:** PKM-complete + commercial track (see roadmap notes).
|
||||||
|
|
||||||
|
## The headline
|
||||||
|
|
||||||
|
> **Lumotia v0.2 — review cards for turning messy dictations into notes, tasks, topics and links.**
|
||||||
|
|
||||||
|
Not "voice-first PKM". Not "AI second brain". A specific, tangible thing: a review-card flow.
|
||||||
|
|
||||||
|
## What v0.2 includes
|
||||||
|
|
||||||
|
### Garden Inbox / review cards (the marquee)
|
||||||
|
|
||||||
|
Each new capture surfaces as a review card. The card shows:
|
||||||
|
|
||||||
|
- Raw transcript (always preserved, can collapse)
|
||||||
|
- Cleaned note (LLM cleanup output, editable)
|
||||||
|
- Suggested title (LLM, editable)
|
||||||
|
- Suggested type (Note / Task / Idea / Journal / Meeting — closed set, configurable later)
|
||||||
|
- Suggested folder / project / area / person / topic (LLM-suggested from a user-defined ontology, editable)
|
||||||
|
- Extracted tasks (already shipped in v0.1, surfaced inline)
|
||||||
|
- Suggested LLM tags (already shipped in v0.1, promote-to-manual on click)
|
||||||
|
- Possible links to existing transcripts (similarity-based, deferred from "related notes" item below if needed)
|
||||||
|
- Confidence score per suggestion (so the user knows when to second-guess)
|
||||||
|
- Action: **Accept / Edit / Park / Archive**
|
||||||
|
|
||||||
|
The card is the bridge. Accept routes the capture into the ontology with all suggestions applied. Edit lets the user fix anything. Park sends it back to inbox for later. Archive removes it without routing.
|
||||||
|
|
||||||
|
### Suggested routing (extends v0.1 LLM content tags)
|
||||||
|
|
||||||
|
The shipped `extract_content_tags_cmd` returns `topic:*` and `intent:*`. v0.2 extends this to **folder / project / person / area** suggestions drawn from a user-defined ontology stored in SQLite. Users seed the ontology themselves; Lumotia learns from accept/edit signals (reusing the existing feedback loop from Phase 2).
|
||||||
|
|
||||||
|
### Related notes / backlinks
|
||||||
|
|
||||||
|
For an accepted capture, surface "transcripts that look similar" via local embedding similarity. No graph view. No canvas. Just: "you mentioned this topic in 3 other captures — here they are." Click navigates to the linked transcript in History.
|
||||||
|
|
||||||
|
This is **not** a full backlink graph in v0.2. It's a "you've talked about this before" surface.
|
||||||
|
|
||||||
|
### People / project / topic detection
|
||||||
|
|
||||||
|
Reuses the LLM suggestion pipeline from routing. Surfaces detected entities in the review card so the user can confirm or correct. Detected entities update the ontology (with explicit user consent on first surface).
|
||||||
|
|
||||||
|
### Obsidian-ready Markdown export
|
||||||
|
|
||||||
|
v0.1 already ships Markdown export with frontmatter union (auto + manual + LLM tags). v0.2 hardens this for Obsidian compatibility specifically:
|
||||||
|
|
||||||
|
- `[[wiki-link]]` syntax in the body for detected entity references
|
||||||
|
- Frontmatter fields Obsidian's Dataview plugin can query
|
||||||
|
- Vault-folder-aware export (pick a vault root, mirror the user's folder structure)
|
||||||
|
- Optional: an Obsidian plugin (later sub-item, may slip to v0.3)
|
||||||
|
|
||||||
|
### Engine architecture work landing alongside v0.2
|
||||||
|
|
||||||
|
These were ROADMAPPED items in the engine architecture spec that pair naturally with the Garden release:
|
||||||
|
|
||||||
|
- **Phase B — Filter chain refactor** (`Filter` trait, stage 1/2/3 cleanup pipeline) — needed for vocabulary work below
|
||||||
|
- **Phase C — Vocabulary crate** (`crates/vocabulary`, FTS5 + regex cache, `vocabulary_proposals` table, edit-diff `extract_corrections`) — user dictionary
|
||||||
|
- **Phase D — Model warmup coordinator** (warmup state machine, synthetic audio, `EngineStatusPill.svelte`) — perceived speed improvement
|
||||||
|
- **Phase E — Dictionary quick-add** (`Ctrl+Alt+D` panel, settings vocabulary section)
|
||||||
|
|
||||||
|
These four engine phases are not strictly required for the review-card UX but they unlock a noticeably better v0.2 experience.
|
||||||
|
|
||||||
|
## What v0.2 does NOT include
|
||||||
|
|
||||||
|
To stop v0.2 from sprawling, these are pinned **out**:
|
||||||
|
|
||||||
|
- **No graph view.** Backlinks surface as a list, not a force-directed graph.
|
||||||
|
- **No canvas.** No spatial / freeform layout.
|
||||||
|
- **No multi-user / sync.** Still single-device.
|
||||||
|
- **No mobile companion.** Still desktop-only.
|
||||||
|
- **No full PKM "second brain" marketing pitch.** Lumotia is a voice-first gardener with a review-card flow. We are not Obsidian.
|
||||||
|
- **No cloud transcription provider.** Engine Phase G stays out by default; if it lands in v0.2 it's BYOK + off-by-default + clearly labelled + never required.
|
||||||
|
- **No premium voices / paid expansion.** Still single £39 Founding tier on the licensing side (see `project_lumotia_licensing_strategy` memory).
|
||||||
|
- **No commercial / OEM track.** Engine Phase I stays v1.0.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- `outputs/lumotia/2026-05-14-roadmap-update.md` — Garden Inbox direction signal distilled from three 2026/05/14 captures (Wispr Flow / Superwhisper / AudioPen / Granola landscape + "voice-first gardener" positioning)
|
||||||
|
- `docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md` — Phase 1–10 plan + post-v0.1 ideas section
|
||||||
|
- `outputs/lumotia/2026-05-10-engine-architecture-spec.md` — engine architecture Phases A–J
|
||||||
|
- `project_lumotia_licensing_strategy` memory — locked pricing / dual-licence / OEM exception
|
||||||
|
|
||||||
|
## Open decisions for v0.2 scope freeze
|
||||||
|
|
||||||
|
These will be decided when v0.1 ships, not before:
|
||||||
|
|
||||||
|
1. **Ontology bootstrapping** — do users define their ontology by hand, or does Lumotia seed it from the first 10 captures?
|
||||||
|
2. **Confidence-score surfacing** — numeric (0.0–1.0), banded (high/medium/low), or implicit (only show suggestions above threshold)?
|
||||||
|
3. **Park vs Archive semantics** — Park goes back to inbox for later, Archive removes from inbox but keeps in History? Or another shape?
|
||||||
|
4. **Obsidian plugin scope** — full plugin with two-way sync, or one-way export only?
|
||||||
|
|
||||||
|
None of these block v0.1.
|
||||||
|
|
||||||
|
## How v0.2 lifts out of v0.1
|
||||||
|
|
||||||
|
The path:
|
||||||
|
|
||||||
|
1. v0.1 ships and gets 20+ testers
|
||||||
|
2. Watch what testers actually do with their captures (what they accept, edit, retry, abandon)
|
||||||
|
3. Lock the review-card scope based on tester behaviour, not on this doc's speculation
|
||||||
|
4. Ship v0.2 incrementally: Garden Inbox first, suggested routing second, related notes third
|
||||||
|
5. Each sub-feature gets its own design pass before code is written
|
||||||
|
|
||||||
|
The shape above is the boundary, not the spec.
|
||||||
83
docs/release/virtual-audio-setup.md
Normal file
83
docs/release/virtual-audio-setup.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: virtual-audio-setup
|
||||||
|
type: release
|
||||||
|
tags: [release, testing, smoke-test, audio, linux, macos, windows]
|
||||||
|
description: "How to set up a virtual audio source so the smoke harness can run audio-dependent cells unattended — without you speaking into a microphone."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Virtual audio setup for smoke testing
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Lumotia's smoke driver (`scripts/smoke-linux-driver.sh`) can automate the Capture cell — pressing the record hotkey, waiting, stopping — but the recording pipeline needs an audio signal to produce a transcript. Without one it records silence, Whisper returns nothing, and the Cleanup and History cells have nothing to assert against.
|
||||||
|
|
||||||
|
A virtual audio source feeds a synthetic signal into the recording pipeline so the smoke harness can run the audio-dependent cells without you talking. You set it up once before a test run and tear it down after.
|
||||||
|
|
||||||
|
## Setup on Linux (PulseAudio or PipeWire-pulse)
|
||||||
|
|
||||||
|
PipeWire ships a PulseAudio compatibility layer on all major distros since 2022, so the `pactl` commands below work on both.
|
||||||
|
|
||||||
|
**Option A — sine-wave tone (always available, no extra files)**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pactl load-module module-sine-source source_name=lumotia-test frequency=440
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a virtual microphone that emits a 440 Hz tone continuously. Whisper will transcribe it as something like "A" or ambient noise — enough to produce a non-empty transcript row and exercise the pipeline.
|
||||||
|
|
||||||
|
**Option B — WAV file (closer to real speech)**
|
||||||
|
|
||||||
|
First generate a test file. If `espeak` is installed you get a synthetic voice; otherwise `ffmpeg` generates a tone:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# With espeak (sounds like speech — better for Whisper)
|
||||||
|
espeak -v en -s 150 "Lumotia smoke test one two three" \
|
||||||
|
--stdout | ffmpeg -i pipe:0 -ar 16000 -ac 1 -f s16le /tmp/lumotia-test.raw 2>/dev/null
|
||||||
|
ffmpeg -f s16le -ar 16000 -ac 1 -i /tmp/lumotia-test.raw /tmp/lumotia-test.wav 2>/dev/null
|
||||||
|
|
||||||
|
# Without espeak (synthetic tone — still exercises the pipeline)
|
||||||
|
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 16000 -ac 1 /tmp/lumotia-test.wav 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load the pipe-source:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pactl load-module module-pipe-source \
|
||||||
|
source_name=lumotia-test \
|
||||||
|
file=/tmp/lumotia-test.wav \
|
||||||
|
format=s16le rate=16000 channels=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wire it into Lumotia**
|
||||||
|
|
||||||
|
Open Lumotia → Settings → Start Here → Microphone and pick **lumotia-test** from the dropdown. Save. Then run the smoke driver:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/smoke-linux-driver.sh [/path/to/AppImage]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tear down after the run**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pactl unload-module $(pactl list short modules | grep lumotia-test | awk '{print $1}')
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke driver does not automatically unload the module, so your regular microphone is unaffected by the test run.
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
Use **BlackHole** (open-source, free from Existential Audio). Install via Homebrew:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew install --cask blackhole-2ch
|
||||||
|
```
|
||||||
|
|
||||||
|
After installing, open **Audio MIDI Setup** (Applications → Utilities), create a **Multi-Output Device** that includes both BlackHole 2ch and your speakers if you want to hear output. Set BlackHole 2ch as the input in Lumotia → Settings → Start Here → Microphone.
|
||||||
|
|
||||||
|
To feed audio into BlackHole during the test, route any audio player's output to the BlackHole device, or use `ffmpeg` with the AVFoundation backend (note: macOS xdotool equivalents are outside the current smoke-driver scope — these steps are manual on macOS).
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
Use **VB-CABLE** (free from VB-Audio). Download from [vb-audio.com/Cable](https://vb-audio.com/Cable/), run the installer with admin rights, reboot. Set **CABLE Output** as the recording device in Lumotia → Settings → Start Here → Microphone. Route audio to **CABLE Input** from any player to feed the pipeline.
|
||||||
|
|
||||||
|
Windows smoke-test automation (UI driving) is not currently in scope for v0.1 — these instructions document the audio-loopback pattern for when it is.
|
||||||
@@ -12,7 +12,7 @@ author: Wren (CORBEL's resident agent) on behalf of Jake Sames
|
|||||||
|
|
||||||
> **What Lumotia is.** A local-first, cognitive-load-aware dictation + task-capture desktop app. Vulkan-accelerated Whisper / Parakeet speech-to-text, a local LLM (Qwen3 tiers) for transcript cleanup and task extraction, an MCP server for integration with Claude Desktop / Cline / Cursor, and a UI designed around ADHD / executive-dysfunction needs. Tauri 2 + Svelte 5 + Rust. Zero telemetry.
|
> **What Lumotia is.** A local-first, cognitive-load-aware dictation + task-capture desktop app. Vulkan-accelerated Whisper / Parakeet speech-to-text, a local LLM (Qwen3 tiers) for transcript cleanup and task extraction, an MCP server for integration with Claude Desktop / Cline / Cursor, and a UI designed around ADHD / executive-dysfunction needs. Tauri 2 + Svelte 5 + Rust. Zero telemetry.
|
||||||
>
|
>
|
||||||
> **Formerly known as Lumotia.** Rebrand in flight; repo names at `jakejars/lumotia` + `git.corbel.consulting/jake/lumotia` still carry the Lumotia name and will rename together with the codebase sweep in the final phase.
|
> **Formerly known as Lumotia.** Rebrand in flight; repo names at `jakeadriansames/lumotia` + `git.corbel.consulting/jake/lumotia` still carry the Lumotia name and will rename together with the codebase sweep in the final phase.
|
||||||
|
|
||||||
## Baseline — where we are (2026/04/23)
|
## Baseline — where we are (2026/04/23)
|
||||||
|
|
||||||
@@ -303,7 +303,7 @@ Runs **after** Phase 10a QC, **after** Jake has renamed the two repos in GitHub
|
|||||||
- Event names: `lumotia:start-timer`, `lumotia:task-completed`, `lumotia:open-wind-down`, `lumotia:preferences-changed`, `lumotia:hotkey-pressed`, `lumotia:llm-download-progress` → `lumotia:*`. Single commit; one find-replace; both emitter and listener in the same diff.
|
- Event names: `lumotia:start-timer`, `lumotia:task-completed`, `lumotia:open-wind-down`, `lumotia:preferences-changed`, `lumotia:hotkey-pressed`, `lumotia:llm-download-progress` → `lumotia:*`. Single commit; one find-replace; both emitter and listener in the same diff.
|
||||||
- Logs, error messages, user-facing copy (including toast strings that mention "Lumotia").
|
- Logs, error messages, user-facing copy (including toast strings that mention "Lumotia").
|
||||||
- Settings SQLite key: `lumotia_preferences` → `lumotia_preferences`. Migration reads old key on first launch, writes new key, deletes old.
|
- Settings SQLite key: `lumotia_preferences` → `lumotia_preferences`. Migration reads old key on first launch, writes new key, deletes old.
|
||||||
- Remotes: `ssh://git.corbel.consulting:2222/jake/lumotia.git` + `github.com:jakejars/lumotia.git` → `…/lumotia.git`. `git remote set-url` locally after web-UI renames.
|
- Remotes: `ssh://git.corbel.consulting:2222/jake/lumotia.git` + `github.com:jakeadriansames/lumotia.git` → `…/lumotia.git`. `git remote set-url` locally after web-UI renames.
|
||||||
|
|
||||||
### Phase 10c — Release (estimated half day)
|
### Phase 10c — Release (estimated half day)
|
||||||
|
|
||||||
|
|||||||
200
docs/superpowers/plans/2026-05-14-phase-b-dogfood-plan.md
Normal file
200
docs/superpowers/plans/2026-05-14-phase-b-dogfood-plan.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
---
|
||||||
|
name: 2026-05-14-phase-b-dogfood-plan
|
||||||
|
type: plan
|
||||||
|
tags: [phase-b, dogfood, code-atomiser-fix, verification, lumotia]
|
||||||
|
description: "Plan for Phase B of the post-rebrand dogfood pass — verify the 25+ code-atomiser-fix commits (race conditions, lifecycle, trust-boundary, time bombs, observability) against gaps the original commits did not close. Per-item methodology: orient on the commit, survey existing coverage, identify real residuals, surgical fix or documented pass, commit. Phase A (rebrand-migration verification) shipped six commits including a real silent-data-loss bug fix surfaced by the dogfood drill."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase B dogfood plan — code-atomiser-fix wave
|
||||||
|
|
||||||
|
**Started:** 2026/05/14
|
||||||
|
**Owner:** Wren (with Jake oversight)
|
||||||
|
**Status:** Complete 2026/05/14. B.1–B.15 audited. Nine surgical fixes shipped (B.1, B.2, B.3, B.4, B.5, B.6, B.7, B.8, B.9, B.10). Five documented passes (B.11, B.12, B.13, B.14, B.15) with verdict reasoning recorded below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phase A (rebrand-migration verification) shipped six commits including a real silent-data-loss bug fix surfaced by `scripts/dogfood-rebrand-drill.sh`. Phase B applies the same methodology to the 25+ code-atomiser-fix commits that landed between the rebrand cascade and the supply-chain pre-flight: examine each, confirm what the commit claims to fix, identify gaps the original test+fix pass missed, write tests or fix residuals where real, and commit per-item so the audit trail stays surgical.
|
||||||
|
|
||||||
|
**Phase A baseline gates carried into Phase B:**
|
||||||
|
|
||||||
|
- `cargo test --workspace` — 409 pass / 0 fail
|
||||||
|
- `cargo fmt --check` — clean
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings` — clean
|
||||||
|
- `npm run test` — 12 / 12
|
||||||
|
- `npm run check` — 0 / 0
|
||||||
|
- `scripts/dogfood-rebrand-drill.sh` — 8 / 8 probes
|
||||||
|
- Rust toolchain pinned to 1.94.1 stable
|
||||||
|
- npm dev deps pinned exact
|
||||||
|
|
||||||
|
Each B.x commit must keep all of the above green.
|
||||||
|
|
||||||
|
## Methodology per item
|
||||||
|
|
||||||
|
For each B.x:
|
||||||
|
|
||||||
|
1. **Orient.** Read the commit metadata (subject, body, files changed) and the relevant code in its current state.
|
||||||
|
2. **Survey.** Catalogue existing test coverage and identify what is *not* tested.
|
||||||
|
3. **Decide.** Real residual found? Surgical fix or test. No residual? Document the verdict and move on. Hard-to-fixture gap acknowledged in code? Honour the original author's SAFETY annotation and document the pass.
|
||||||
|
4. **Verify.** Run the per-crate or per-area test gate plus workspace clippy + fmt before committing.
|
||||||
|
5. **Commit.** Format: `agent: lumotia — Phase B.x <one-line description>`. Include findings + verdict + verification gates in the body.
|
||||||
|
|
||||||
|
## Items
|
||||||
|
|
||||||
|
| # | Surface | Commit(s) | Status | Outcome |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **B.1** | Cancellable Whisper inference + bounded drain + lock-over-await | `5725836` | **Done** (`6c212a0`) | 8 existing unit tests cover the atomiser-targetable surface. Race-B end-to-end test acknowledged as hard-to-fixture in the SAFETY comment and left as-is. One real residual fixed: misleading comment on `start_live_transcription_session` claiming lifecycle is "Released explicitly before the RunningLiveSession is installed" — the code does the safer opposite (install-while-locked, drop after). Future reader would have trusted the comment and "fixed" the code into a regression. |
|
||||||
|
| **B.2** | Hotkey supervisor rearchitecture | `1068ad9` (Race-1, Race-2, TOCTOU) | **Done** (`643985d`) | 6 unit + 2 integration tests already cover Race-1 / Race-2; TOCTOU honoured by author's `// TODO(test):` SAFETY annotation. Real residual fixed: `SHUTDOWN_TIMEOUT` const doc + test name `shutdown_force_aborts_stuck_tasks_after_timeout` both claim "force-abort" semantics, but `timeout(d, handle).await` consumes the JoinHandle by value — drop detaches, doesn't abort. Reworded doc + renamed test to match the actual detach contract. |
|
||||||
|
| **B.3** | Atomic model download + manifest | `9f67ab2` (Rev-1, Rev-5) | **Done** (`31e3f5a`) | Transcription side fully covered (resume, restart-on-200, SHA mismatch cleanup, 5xx rejection, Rev-1 preserve, Rev-5 manifest atomicity). One real residual in `crates/llm/src/model_manager.rs`: `ResumeUnsupported` returned without unlinking `.part`, so a transient server-downgrade leaves the download wedged forever. Fix unlinks `.part` before returning so the next retry starts fresh; new test `resume_unsupported_unlinks_part_so_retry_starts_fresh`. |
|
||||||
|
| **B.4** | Soft-delete + trash + restore | `15b74db`, `87e6248`, `50d0715`, `99f4ecd` (Rev-2, Rev-3) | **Done** (`20ef6c4`) | 4 backend tests cover soft-delete, audio cleanup, list-excludes/restore, purge. Stale TODO(test): vitest-not-installed comments on the Svelte UI components are now obsolete (vitest landed in Phase A.5). One real residual fixed: `purge_deleted_transcripts` was a SELECT-then-DELETE-WHERE-id-IN pair; a `restore_transcript` between the two statements would let the DELETE hard-delete the now-LIVE row + remove its audio, bypassing Rev-2's safety contract. Refactored to single `DELETE … RETURNING audio_path` for atomicity; new test `purge_audio_cleanup_only_fires_for_hard_deleted_rows`. |
|
||||||
|
| **B.5** | `write_text_file_cmd` path scoping + `transcribe_file` extension allowlist + size cap | `a2b47db`, `a48653c`, `b3da58c` (Trust-1) + `9653e25` (Trust-5) + `ed449cc` (Trust-2) | **Done** (`d8fa4ff`) | Trust-1 had 6 path-scope tests, Trust-5 had 7 extension/size tests, Trust-2 had 7 tests including symlink-pointing-out coverage. Real residual in Trust-1: asymmetric symlink handling vs Trust-2. `fs.rs` canonicalised only the parent of the target (file usually didn't exist yet), so a symlink AT the target path bypassed the containment check — `tokio::fs::write` then follows the symlink and writes outside the allowlist. Trust-2 already used full-path canonicalize on existing paths. Fix mirrors Trust-2's discipline: canonicalize full path if exists, fall back to parent canonicalize on NotFound. Two new symlink regression tests (in/out). |
|
||||||
|
| **B.6** | Main-window guards on clipboard / extract-tags / file-write | `12b413d`, `f7af7b0` (Trust-4), `7aee534` (Trust-3, Trust-6) | **Done** (`7f0e1b0`) | Strong coverage on size caps, terminal classification, paste-cap == clipboard-cap invariant. Real residual: the `12b413d` commit message claimed the `CLIPBOARD_ALLOWED_WINDOWS` / `PASTE_REPLACING_ALLOWED_WINDOWS` Rust consts mirror `secondary-windows.json`'s `windows` array, but no test pinned the invariant. A future drift between the Rust allowlists and the capability JSON silently disagrees the IPC trust boundary with the permission grant. Promoted both consts to `pub(crate)` and added `commands::security::tests_capability_mirror::allowlists_match_capability_jsons` which reads + parses the capability JSONs at test-time and asserts every Rust allowlist label is declared. |
|
||||||
|
| **B.7** | LlmEngine critical-section narrowing + drop-old-model-first | `cde985d` (Race-3, Lifecycle-1) | **Done** (`f252c1b`) | Two existing tests pin Race-3 (probes don't block on slow load) + Race-3/4 TOCTOU (parallel load refused with `AlreadyLoading`). Real residual: `unload()` did not consult the `loading` flag. Mid-load, `model` + `loaded` are already None (step 3 cleared them); concurrent unload no-op-clears, returns Ok, then step 5 installs the new state — caller saw unload-success but engine ends up loaded. Same flag now guards both directions. New test `unload_during_load_is_refused`; `EngineError::AlreadyLoading` message generalised to cover both directions. |
|
||||||
|
| **B.8** | Span propagation across live + model-load spawns; `lumotia.log` writer + EnvFilter coverage; drop `lumotia_live` literal target | `65abfa2` (Obs-3), `8becb1a` (Obs-4, Obs-5), `d1391b3` (Obs-1, Obs-2) | **Done** (`813f024`) | Obs-1/2 pinned by `no_lumotia_live_literal_target_in_live_rs`. Obs-4/5 pinned by `init_tracing_creates_log_file`. Obs-3 span propagation not directly tested; honour author's "everything else fans out from the 4 instrumented sites" SAFETY note. Real residual: storage crate emitted via `log::*!` macros (not `tracing::*!`), and no `tracing-log::LogTracer` bridge was installed — so events tagged `lumotia_storage` listed in `DEFAULT_STDERR_FILTER` never reached any layer. Migration progress + audio-cleanup warnings missing from `lumotia.log` forensic stream. Storage crate swapped from `log = "0.4"` to `tracing = "0.1"` (matching every other workspace crate); 4 call sites converted, 2 reformatted as structured tracing events. |
|
||||||
|
| **B.9** | JSON-envelope LLM extractor (GBNF removal) | `1d71e8e` | **Done** (`401b6c3`) | 7 existing tests cover the parser. Real residual: `extract_json_envelope_skips_qwen_thinking_prefix` only used an EMPTY `<think></think>` block. Qwen3.5's reasoning mode emits non-empty content; if it contains JSON-looking text or unbalanced braces, the "find first '{' or '['" extractor either returns the reasoning literal or returns None (pollutes the brace-stack scanning past `</think>`). Fix: strip the first `</think>` before scanning. Falls back to whole text when no thinking-tag is present (covers non-reasoning models and the empty-thinking case). Two new regression tests for the JSON-in-thinking + unbalanced-braces-in-thinking cases. |
|
||||||
|
| **B.10** | `focusTimer` rehydrate `startTick` invariant | `5ba761a` (Race-10) | **Done** (`1c4ac98`) | The Race-10 fix was a comment-only "load-bearing comment" on the already-expired branch of `rehydrate()` saying "startTick() is REQUIRED here." The commit couldn't add a vitest test at the time because vitest scaffold landed the next day (Phase A.5 commit `206ac62`). Now that vitest is wired (jsdom env, `.svelte.ts` rune transformer, fake timers), the invariant is straightforwardly testable. New `src/lib/stores/focusTimer.test.ts` with one test `auto-clears the completion flash after the 3s window via the tick loop` that pins the contract — if a future edit drops the `startTick()` call, the test fails on the auto-clear assertion. |
|
||||||
|
| **B.11** | `FirstRunPage` unlisten on all exits | `6aa6a43` (Race-9) | **Documented pass** | No real residual. The Race-9 fix (let-declared handles outside try, finally guards each `if (h) h();`) is correctly applied and consistently mirrored across all multi-listen surfaces: `FirstRunPage.svelte`, `SettingsPage.svelte:874-902`, `FilesPage.svelte:25-44`. DownloadProgress payload shape matches the Rust emitter (`percent` field present in `DownloadProgress`). Hypothetical residuals considered + rejected: wrapping each `unlisten()` in try/catch (Tauri's UnlistenFn is a sync local-registry deregistration; throwing is implausible; would be the over-defensive-for-testability anti-pattern); extracting a unit-testable utility (over-refactor-for-testability anti-pattern). Component testing requires `@testing-library/svelte` — too heavy for one test. |
|
||||||
|
| **B.12** | `Transcriber::transcribe_sync_with_abort` required | `e0e9a6e` (Lifecycle-2) | **Documented pass** | No real residual. Three existing tests cover the trait-level requirement (`FlagSnoopingBackend`) + `SpeechModelAdapter` pre-dispatch short-circuit + dispatch-when-clear. The compile-time gate (no default impl) is the strongest guarantee; all 4 `impl Transcriber` sites (`SpeechModelAdapter`, `WhisperRsBackend`, `FlagSnoopingBackend`, `FakeTranscriber`) have explicit implementations. The "uncancellable middle" race in `SpeechModelAdapter` is explicitly documented with `// SAFETY:` + architectural reasoning — honour the author's annotation. |
|
||||||
|
| **B.13** | `drain_inference` deadline from `task.duration_secs` | `07f6755` (Time-bomb-1) | **Documented pass** | No real residual. Four existing tests pin every branch of `drain_timeout_for_inflight` (5x scaling, floor for short chunks, None fallback, NaN/inf/0/negative defensive). The drain loop's actual firing path is explicitly acknowledged via `// SAFETY:` at lines 557-561 as requiring a wedged whisper-rs that can't be fixtured. Honoured. |
|
||||||
|
| **B.14** | Surface capture-thread drops + bypass validation requeue cap | `094b533` | **Documented pass** | No real residual. `poll_capture_drops` arithmetic is correct (rollback-deferred-until-dimensions-known semantics, saturating-sub preserves the delta for re-observation, `.max(1)` guards sample_rate=0). `replay_buffer` consumer-side drain bypasses the 32-slot channel cap as the commit claimed. Unit-testing the full pipeline requires cpal hardware which can't be mocked without invasive refactoring — honour the implicit "this can't be fixtured cheaply" boundary. |
|
||||||
|
| **B.15** | `test_llm_model` respects caller GPU preference | `afbd33d` (Race-8) | **Documented pass** | No real residual. The fix itself is minimal + correct (`Option<bool>` parameter with `unwrap_or(true)` matching `load_llm_model`'s default; inline comment explains the (id, path, use_gpu) triple-mismatch race). Phase B.7's `AlreadyLoading` guard now provides additional protection — the second concurrent load is refused rather than silently overwritten. The remaining latent concern (`SettingsPage.svelte:694` doesn't pass `use_gpu` because the frontend has no UI for it yet) is a future feature-work item, not a B.15 residual. 8 existing classifier tests cover the error-categorisation surface. |
|
||||||
|
|
||||||
|
## Anti-patterns to avoid
|
||||||
|
|
||||||
|
- **Over-refactoring for testability.** B.1 had a legitimate Race-B gap that would require restructuring `LiveSessionRuntime` to test in isolation. The original author flagged the trade-off and chose to keep the production shape simple. We honour that — invasive refactor for one test risks introducing the bug the test would have caught.
|
||||||
|
- **Re-running the dogfood drill mid-Phase-B.** The drill is in place for Phase A's domain (rebrand migration). Phase B touches different surfaces. Adding new drills is in scope if a real residual demands one (per B.1 — none did).
|
||||||
|
- **Batch-survey-then-fix.** One-at-a-time per Jake's "utmost care" instruction. Each item gets a focused commit; future reviewer can stop at any boundary without untangling a multi-item changeset.
|
||||||
|
|
||||||
|
## Done items
|
||||||
|
|
||||||
|
### B.1 — start_live lifecycle comment fix (`6c212a0`)
|
||||||
|
|
||||||
|
**Surface:** `src-tauri/src/commands/live.rs`. The `start_live_transcription_session` function's upper comment (lines 705-711) claimed `Released explicitly before the RunningLiveSession is installed in live_state.running`. The actual code installs RunningLiveSession on lines 801-806 and only drops `lifecycle` on line 811. The safer ordering — install-while-locked, then release — was already in place; only the comment was wrong.
|
||||||
|
|
||||||
|
**Why it matters:** A future reader auditing the locking discipline would have trusted the comment over the code and "fixed" the code to match the buggier description — reintroducing the half-initialised window where a concurrent `stop_live` could observe `running == None` while a `start_live` is mid-install.
|
||||||
|
|
||||||
|
**Other findings (no fix needed):**
|
||||||
|
|
||||||
|
* 8 existing unit tests cover the atomiser-targetable surface (`dropping_inference_task_sets_abort_flag`, `drain_timeout_scales_with_inflight_chunk_duration_secs`, `drain_timeout_honours_floor_for_short_chunks`, `drain_timeout_uses_floor_when_no_inflight_task`, `drain_timeout_rejects_non_finite_duration`, `result_listener_loss_is_warned_once_and_not_treated_as_inference_failure`, `dead_result_and_status_channels_self_assert_stop_flag`, `no_lumotia_live_literal_target_in_live_rs`).
|
||||||
|
* The Race-B drain-timeout end-to-end test gap is genuine but acknowledged in the SAFETY comment inside `drain_inference`. Closing it requires either extracting the inner logic into a pure function with explicit dependencies (refactor of working production code) or constructing a full synthetic `LiveSessionRuntime` (impractical — needs a real engine + model + audio device). Honour the original author's call.
|
||||||
|
* `stop_live_transcription_session` comments + code consistent. No fix needed.
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia --lib commands::live` 17 / 17 (unchanged, comment-only edit); clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.2 — supervisor doc + test name match detach semantics (`643985d`)
|
||||||
|
|
||||||
|
**Surface:** `crates/hotkey/src/supervisor.rs`. `SHUTDOWN_TIMEOUT` const doc and test `shutdown_force_aborts_stuck_tasks_after_timeout` both claimed "force-abort" semantics. The production code consumes the `JoinHandle` via `timeout(d, handle).await` — when the timeout fires, the inner future (the JoinHandle) is dropped. Dropping a JoinHandle DETACHES the task; it does NOT abort. The log message on the timeout branch already correctly says "detaching", and the `shutdown()` doc-comment says "detached and logged" — so the contract was always detach, but two satellite places said "abort".
|
||||||
|
|
||||||
|
**Why it matters:** B.1-class hazard. A future maintainer trusting either "force-abort" claim would either add `handle.abort()` to make the implementation match (changing shutdown semantics — abort skips cooperative cleanup) or conclude the doc is wrong and need to retrace which is authoritative.
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia-hotkey --lib --tests` 6 unit + 2 integration = 8/8 pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.3 — `download_impl` unlinks `.part` on ResumeUnsupported (`31e3f5a`)
|
||||||
|
|
||||||
|
**Surface:** `crates/llm/src/model_manager.rs`. When a stale `.part` exists and the server returns 200 (full body) to a Range request, `download_impl` returned `DownloadError::ResumeUnsupported` without unlinking the `.part`. Every subsequent `download_model()` call sees the same `resume_from > 0`, sends the same Range request, gets the same 200 → wedged forever until the user calls `delete_model()`.
|
||||||
|
|
||||||
|
**Why it matters:** Same reversibility-kill family as Rev-1: stale partial state stuck on disk, no automatic recovery, requires out-of-band intervention.
|
||||||
|
|
||||||
|
**Fix:** `tokio::fs::remove_file(&tmp).await.ok()` before returning `ResumeUnsupported`. Single retry now recovers. New test `resume_unsupported_unlinks_part_so_retry_starts_fresh` with a server that ignores Range and returns 200.
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia-llm --lib model_manager` 5/5 pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.4 — atomic `DELETE … RETURNING` in `purge_deleted_transcripts` (`20ef6c4`)
|
||||||
|
|
||||||
|
**Surface:** `crates/storage/src/database.rs`. The prior form was a two-statement SELECT-then-DELETE-WHERE-id-IN pair. A `restore_transcript(id)` between (1) and (2) cleared `deleted_at` on a row in the chunk, but the DELETE had no `deleted_at IS NOT NULL` filter — so the now-LIVE row got hard-deleted alongside its audio file.
|
||||||
|
|
||||||
|
**Why it matters:** Bypasses the entire Rev-2 soft-delete safety contract — the user can lose data without the 30-day retention window the contract promised. In current code the purge runs at startup before the user can issue a restore, so the race window is narrow in practice. Should be structural, not operational — especially if a future change moves the purge to a daily cron.
|
||||||
|
|
||||||
|
**Fix:** single `DELETE FROM transcripts WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?) RETURNING audio_path`. SQLite evaluates the WHERE clause atomically with row removal; returned `audio_path`s are guaranteed to belong to rows this call hard-deleted. Also removes the chunking concern (no IN-clause, no SQLITE_MAX_VARIABLE_NUMBER ceiling). New test `purge_audio_cleanup_only_fires_for_hard_deleted_rows`.
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia-storage --lib database::tests` 53/53 pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
**Other finding (not fixed in this commit, recorded for triage):** UI components added by `87e6248` and `50d0715` carry stale `TODO(test): no Svelte component test framework wired in the repo (vitest not installed)` comments. Vitest landed in Phase A.5 (`206ac62`). Adding Svelte component tests for the Trash view + clearAll modal is real follow-up work but outside the per-item B.4 methodology.
|
||||||
|
|
||||||
|
### B.5 — `resolve_export_path` follows symlink target before containment check (`d8fa4ff`)
|
||||||
|
|
||||||
|
**Surface:** `src-tauri/src/commands/fs.rs`. Trust-1 canonicalised only the parent of the requested path because the file usually didn't exist yet. But if a symlink ALREADY existed at the target path pointing outside the allowlist, `tokio::fs::write` follows it on `File::create -> open(2)` and silently writes to the outside target. The path-string containment check passed because the SYMLINK lived inside the base, even though its target did not.
|
||||||
|
|
||||||
|
**Why it matters:** Concrete bypass: a symlink at `~/Downloads/notes.md -> ~/.bashrc` (innocently created by the user, or planted via another vulnerability) lets a compromised webview overwrite shell startup via `write_text_file_cmd`. Trust-2 (audio.rs) already handled this for recordings via full-path canonicalize; Trust-1 had the asymmetric gap.
|
||||||
|
|
||||||
|
**Fix:** two-mode canonicalisation in `resolve_export_path`. If the target exists → canonicalize the FULL path (follows symlink at the target before the containment check). On NotFound → fall back to parent canonicalize + join filename (the original save-dialog path). Two new symlink tests: `rejects_symlink_target_outside_allowlist` and `accepts_symlink_target_inside_allowlist` (both `#[cfg(unix)]`).
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia --lib commands::fs` 8/8 pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.6 — capability JSON mirror invariant pinned (`7f0e1b0`)
|
||||||
|
|
||||||
|
**Surface:** `src-tauri/src/commands/{clipboard.rs,paste.rs,security.rs}`. The `12b413d` commit message claimed the `CLIPBOARD_ALLOWED_WINDOWS` / `PASTE_REPLACING_ALLOWED_WINDOWS` Rust consts mirror the `windows` array in `capabilities/secondary-windows.json`, but no test pinned the relationship. A future change could add a new secondary window to the JSON but forget to update Rust (or vice versa, or typo a label) — silently drifting the IPC trust boundary against the capability grant.
|
||||||
|
|
||||||
|
**Fix (test-only):** promoted both consts to `pub(crate)`, cross-linked their docstrings to the new test, and added `commands::security::tests_capability_mirror::allowlists_match_capability_jsons`. The test reads both `main.json` + `secondary-windows.json` at `CARGO_MANIFEST_DIR` and asserts every label in either Rust allowlist is declared in one of the JSONs' `windows` arrays.
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia --lib commands::security` 5/5 pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.7 — `unload()` honours the `loading` flag (`f252c1b`)
|
||||||
|
|
||||||
|
**Surface:** `crates/llm/src/lib.rs`. `unload()` did not consult the `loading` AtomicBool that `cde985d` introduced for Race-3/4 guard. Mid-load, `load_model_with`'s step 3 has already cleared `model` + `loaded`; a concurrent `unload()` takes the inner mutex, sees them already None, no-op-clears, returns Ok. Then step 5 installs the new state — the caller saw unload-success but the engine ends up loaded.
|
||||||
|
|
||||||
|
**Why it matters:** Concrete scenario: app startup auto-loads default LLM in the background. User opens Settings, clicks "Delete Model X". `delete_llm_model` checks `loaded_model_id()` (returns None mid-load), skips the unload branch, calls `model_manager::delete_model(X)` → file deleted. The load completes via mmap (Linux inode held alive after unlink) and installs state pointing at a deleted file. UI shows "Model X loaded" even though the user just deleted it.
|
||||||
|
|
||||||
|
**Fix:** `unload()` now checks `is_loading()` at entry, returns `EngineError::AlreadyLoading` when a load is mid-flight. Caller retries once `is_loading()` reports false. `EngineError::AlreadyLoading` message generalised to cover both directions. New test `unload_during_load_is_refused`.
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia-llm --lib` 26/26 pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.8 — storage crate emits via `tracing` so events reach `lumotia.log` (`813f024`)
|
||||||
|
|
||||||
|
**Surface:** `crates/storage/{Cargo.toml,src/database.rs,src/migrations.rs}`. `DEFAULT_STDERR_FILTER` + `DEFAULT_FILE_FILTER` both advertise `lumotia_storage=info` / `=debug` — operator intent: storage events surface in stderr AND the rolling `lumotia.log` forensic stream. Reality: storage used `log::*!` macros, no `tracing-log::LogTracer` bridge was installed, so every storage event vanished. Missing from diagnostic reports: migration progress on every schema bump, audio-cleanup warnings from `delete_transcript` + `purge_deleted_transcripts` (the two log lines Rev-3 specifically added).
|
||||||
|
|
||||||
|
**Fix:** swap `log = "0.4"` for `tracing = "0.1"` in `crates/storage/Cargo.toml` (matching every other workspace crate). Convert the 4 call sites; reformat the two migration log lines as structured tracing events (version + description fields).
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia-storage --lib` 70/70 pass (unchanged); workspace clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.9 — strip leading `<think>…</think>` reasoning before JSON-envelope scan (`401b6c3`)
|
||||||
|
|
||||||
|
**Surface:** `crates/llm/src/lib.rs`. The `extract_json_envelope_skips_qwen_thinking_prefix` regression only covered an EMPTY `<think></think>` block. Qwen3.5's reasoning mode emits non-empty content; if it contains JSON-looking literals (the model thinking out loud about schema), the naive "find first '{' or '['" extractor returned the reasoning's literal as the envelope. If the thinking contained unbalanced braces, the brace-stack scan pollutes past `</think>` and the function returns None — losing the answer entirely.
|
||||||
|
|
||||||
|
**Fix:** `text.split_once("</think>")` and scan only the substring after. Falls back to whole text when no thinking tag is present (covers non-reasoning models AND the empty-thinking case). Backwards-compatible: empty thinking, no thinking, and trailing stop tokens all behave as before. Two new tests: `extract_json_envelope_skips_thinking_block_with_json_looking_content` and `extract_json_envelope_survives_unbalanced_braces_in_thinking`.
|
||||||
|
|
||||||
|
**Verification:** `cargo test -p lumotia-llm --lib` 28/28 pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
### B.10 — vitest regression for `focusTimer` expired-rehydrate (`1c4ac98`)
|
||||||
|
|
||||||
|
**Surface:** `src/lib/stores/focusTimer.test.ts` (new). The `5ba761a` commit added a load-bearing comment saying "startTick() is REQUIRED here" on the already-expired branch of `rehydrate()`. The commit couldn't add a test because vitest scaffold landed the day after (Phase A.5 — `206ac62`). With vitest now wired, the invariant is straightforwardly testable.
|
||||||
|
|
||||||
|
**Fix:** new test seeds localStorage with an already-expired timer, calls `focusTimer.rehydrate()`, asserts the completion flash is visible, advances `vi.advanceTimersByTime(3_500)`, and asserts the flash auto-cleared (active=false, remainingMs=0, localStorage wiped). If a future edit removes the `startTick()` call, the tick loop won't run, the flash won't clear, and the test fires.
|
||||||
|
|
||||||
|
**Verification:** `npm run test` 13/13 (was 12, +1 from this commit); `npm run check` 0 errors / 0 warnings across 4015 files.
|
||||||
|
|
||||||
|
### B.11 — documented pass (no commit)
|
||||||
|
|
||||||
|
**Surface:** `src/lib/pages/FirstRunPage.svelte`. The `6aa6a43` fix correctly applies the let-handles-outside-try + finally-guards pattern. Same pattern consistently mirrored across `SettingsPage.svelte:874-902` and `FilesPage.svelte:25-44`. DownloadProgress payload shape matches the Rust emitter.
|
||||||
|
|
||||||
|
**Hypothetical residuals considered + rejected:**
|
||||||
|
|
||||||
|
* Wrapping each `unlisten()` call in try/catch in case `unlisten()` itself throws — Tauri's `UnlistenFn` is a sync local-registry deregistration; throwing is implausible. Wrapping would be the over-defensive-for-testability anti-pattern flagged in the plan.
|
||||||
|
* Extracting a unit-testable listen-pair utility — over-refactor-for-testability anti-pattern.
|
||||||
|
* Adding `@testing-library/svelte` component tests — too heavy a dep for one component-shape test.
|
||||||
|
|
||||||
|
### B.12 — documented pass (no commit)
|
||||||
|
|
||||||
|
**Surface:** `crates/transcription/src/{transcriber.rs,local_engine.rs,whisper_rs_backend.rs}`. Three existing tests pin the trait-level contract (`FlagSnoopingBackend` + `SpeechModelAdapter` pre-dispatch + dispatch-when-clear). The compile-time gate (no default impl) is the strongest guarantee; all four `impl Transcriber` sites have explicit implementations. The "uncancellable middle" race in `SpeechModelAdapter` is documented with a `// SAFETY:` comment and architectural reasoning (live session drops receiver, orphan exits on `tx.send` failure) — honour the original author's annotation.
|
||||||
|
|
||||||
|
### B.13 — documented pass (no commit)
|
||||||
|
|
||||||
|
**Surface:** `src-tauri/src/commands/live.rs::drain_timeout_for_inflight`. Four existing tests cover every branch: 5x scaling, floor for short chunks, None fallback, defensive against NaN/inf/0/negative. The drain loop's actual firing path (set abort_flag, drop inflight, return error) is acknowledged as hard-to-fixture via the `// SAFETY:` comment at lines 557-561 (requires a wedged whisper-rs that can't be constructed without a real model). Honoured.
|
||||||
|
|
||||||
|
### B.14 — documented pass (no commit)
|
||||||
|
|
||||||
|
**Surface:** `crates/audio/src/capture.rs` + `src-tauri/src/commands/live.rs::poll_capture_drops` + `recv_audio`'s replay_buffer drain. The rollback-deferred-until-dimensions-known semantics correctly handle validation-window drops with saturating arithmetic. `replay_buffer` bypasses the 32-slot channel cap as the commit claimed. Unit-testing the full capture pipeline requires real cpal hardware which can't be mocked without invasive refactoring — honour the implicit "this can't be fixtured cheaply" boundary.
|
||||||
|
|
||||||
|
### B.15 — documented pass (no commit)
|
||||||
|
|
||||||
|
**Surface:** `src-tauri/src/commands/llm.rs::test_llm_model`. The `afbd33d` fix is minimal + correct (`Option<bool> use_gpu` with `unwrap_or(true)` matching `load_llm_model`'s default). Phase B.7's `AlreadyLoading` guard now provides defence-in-depth — a parallel load attempt is refused with `AlreadyLoading` rather than silently overwritten on a (id, path, use_gpu) triple mismatch.
|
||||||
|
|
||||||
|
**Latent concern (not B.15-scope):** the frontend `SettingsPage.svelte:694` caller doesn't pass `use_gpu` because no UI for GPU on/off exists. When such UI is built, the test button should pass the user's preference through to `test_llm_model` (and `load_llm_model`). Future-feature work.
|
||||||
|
|
||||||
|
### Phase B summary
|
||||||
|
|
||||||
|
* Nine surgical commits in the audit pass: `643985d`, `31e3f5a`, `20ef6c4`, `d8fa4ff`, `7f0e1b0`, `f252c1b`, `813f024`, `401b6c3`, `1c4ac98` (B.2 through B.10).
|
||||||
|
* Five documented passes: B.11, B.12, B.13, B.14, B.15.
|
||||||
|
* New tests added: 8 Rust unit tests (B.2 rename, B.3 +1, B.4 +1, B.5 +2, B.6 +1, B.7 +1, B.9 +2) + 1 vitest test (B.10).
|
||||||
|
* All Phase A baseline gates remain green: `cargo test --workspace` 417 / 0, `cargo fmt --check` clean, `cargo clippy --workspace --all-targets -- -D warnings` clean, `npm run test` 13 / 13, `npm run check` 0 / 0 across 4015 files.
|
||||||
812
docs/superpowers/plans/2026-05-14-v0.1-release-completion.md
Normal file
812
docs/superpowers/plans/2026-05-14-v0.1-release-completion.md
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
# Lumotia v0.1 release-completion implementation plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan. Each task below is dispatched to a fresh sonnet subagent with a self-contained brief, then verified before checking off. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Complete every code-side item in `docs/release/v0.1-checklist.md` and `docs/release/v0.1-ui-hardening.md`. Document everything that fundamentally requires the human (signing certs, real-hardware probes, smoke-test on platforms we don't have, tester recruitment) so the user can pick those up immediately after this session.
|
||||||
|
|
||||||
|
**Architecture:** Three layers preserved (Svelte UI → Tauri commands → Rust crates). New work lands as: SQLite migration v17 (`onboarding_events`, `lumotia_events`), one new Tauri commands module (`commands/onboarding.rs`), new Svelte components (`StatusPill.svelte`, `PostCaptureCard.svelte`, settings restructure), surgical edits to `FirstRunPage.svelte` / `DictationPage.svelte` / `SettingsPage.svelte` / `+layout.svelte`, and release-doc additions (CHANGELOG.md, release notes, privacy disclosure page, README updates).
|
||||||
|
|
||||||
|
**Tech Stack:** Tauri 2 + Svelte 5 runes + Rust workspace + SQLite via sqlx 0.8 + FTS5. No new dependencies introduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline verified before starting (2026-05-14 22:32)
|
||||||
|
|
||||||
|
- ✅ `cargo check --workspace --all-targets` clean
|
||||||
|
- ✅ `npm run check` — 4015 files / 0 errors / 0 warnings
|
||||||
|
- ✅ `npm run test` — 13/13 vitest passing
|
||||||
|
- ✅ `cargo fmt --check` clean
|
||||||
|
- ✅ `scripts/dogfood-rebrand-drill.sh` exists + executable
|
||||||
|
- Not yet run on baseline: `cargo test --workspace`, `cargo clippy -D warnings`, dogfood drill (will run after Tier 1+2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recon findings — what's already done vs missing
|
||||||
|
|
||||||
|
### ✅ Already complete (verify + tick only)
|
||||||
|
- Trust + security boundary: MCP read-only (`crates/mcp/src/main.rs:18` uses `init_readonly`); MCP stdio-only (no TCP listener); `lumotia-cloud-providers` has zero `#[tauri::command]`; `npm audit signatures` runs in `run.sh:30`.
|
||||||
|
- LLM cleanup + tag extraction failure paths preserve raw transcript (`crates/ai-formatting/src/llm_client.rs:142-159`, `src-tauri/src/commands/llm.rs:422-439`).
|
||||||
|
- Design-system preview catalog: 20 HTML files under `src/design-system/preview/` (matches doc inventory).
|
||||||
|
- `prefers-reduced-motion`: respected in CompletionSparkline, ToastViewport, SettingsGroup, TasksPage, DictationPage, app.css.
|
||||||
|
- Tauri commands invoke handler: 95+ commands wired in `src-tauri/src/lib.rs:678+`.
|
||||||
|
- `KNOWN-ISSUES.md`: KI-01 → KI-06 + RB-08 entries present.
|
||||||
|
- `rust-toolchain.toml`: pinned to 1.94.1.
|
||||||
|
- `dogfood-rebrand-drill.sh`: exists + executable.
|
||||||
|
- `FirstRunPage.svelte` (348 lines): exists with skip option (lines 339-345) and probe/download flows.
|
||||||
|
- Versions synced (3-of-4): `src-tauri/Cargo.toml` = `package.json` = `tauri.conf.json` = `0.1.0`. Workspace `Cargo.toml` lacks an explicit version field — decide policy in Tier 2.
|
||||||
|
|
||||||
|
### 🔧 Partial (needs targeted fix)
|
||||||
|
- First-run gate (`src/routes/+layout.svelte:345-353`) — gates on model-presence, should also check `onboarding_events` record.
|
||||||
|
- Recording-as-sacred-state — `page.recording` boolean is tracked but nav doesn't simplify during recording.
|
||||||
|
- Error-state copy — error strings exist but leak raw `err.message` (e.g. `SettingsPage.svelte` audio/vocabulary/diagnostic errors); needs plain-language sweep.
|
||||||
|
- Keyboard shortcuts + focus ring — hotkey infrastructure exists, but `:focus-visible` coverage is minimal in `app.css`, textarea uses `focus:outline-none` (line 1058 of DictationPage), no Ctrl+K / Esc-closes-modal documented.
|
||||||
|
- Task-extraction LLM call (`crates/llm/src/lib.rs:525-554`) — returns Err on parse failure with no rule-based fallback. Verify whether a separate frontend regex extractor exists; if not, add backend fallback.
|
||||||
|
- `how-lumotia-is-built.md` exists at `docs/release/` but is not linked from `README.md`.
|
||||||
|
- README mentions install paths + first-run but does not brand v0.1 as the launch release or link the GitHub issues URL explicitly.
|
||||||
|
|
||||||
|
### ❌ Missing entirely (build from scratch)
|
||||||
|
- `StatusPill.svelte` Svelte component + `components-status-pills.html` preview entry + app-wide integration (Ready / Recording / Paused / Transcribing / Cleaning / Extracting tasks / Saved / Exported / Needs review / Failed safely).
|
||||||
|
- Post-capture card component — surfaces after every recording with raw transcript / cleaned / extracted tasks / MicroSteps / Save / Export / Start-first-MicroStep / Open-in-History.
|
||||||
|
- `onboarding_events` SQLite table (`completed_at`, `skipped`, `version`) + migration v17.
|
||||||
|
- `lumotia_events` SQLite table for activation log.
|
||||||
|
- `src-tauri/src/commands/onboarding.rs` Tauri commands module.
|
||||||
|
- Settings → Help section with manual-launch tutorial trigger.
|
||||||
|
- Pre-supplied test-recording prompt step in FirstRunPage.
|
||||||
|
- Settings 6-section sanity pass: current 7-group structure (Audio / Vocabulary / Transcription / AI & Processing / Tasks & Rituals / Output & Capture / Appearance & System) does not match v0.1 spec (Start Here / Transcription / Models / Tasks / Accessibility / Privacy / Advanced).
|
||||||
|
- `CHANGELOG.md` (does not exist at repo root).
|
||||||
|
- Release notes draft (does not exist).
|
||||||
|
- Privacy + AI-use disclosure page (does not exist).
|
||||||
|
- AppImage SHA-256 publication in `.github/workflows/build.yml`.
|
||||||
|
- Per-platform "first install warning you may see" doc.
|
||||||
|
- Diagnostic bundle command (logs + system info + redacted preferences, skip transcripts/audio).
|
||||||
|
- `Settings → Diagnostics → Activation log` UI surfacing the local activation events.
|
||||||
|
- LLM hang timeout wrap (per known-limitations soft edge — confirm if v0.1 must-fix or v0.2 deferral).
|
||||||
|
- npm dev deps exact-pin sweep (10 ^/~ ranges remaining in `devDependencies`).
|
||||||
|
- Workspace `Cargo.toml` version-field policy decision.
|
||||||
|
|
||||||
|
### 👤 Human-required (cannot complete in this session — Tier 4 will document)
|
||||||
|
- Windows code-signing certificate procurement + wiring secrets into `.github/workflows/build.yml`.
|
||||||
|
- macOS notarisation with Apple Developer ID + secrets wiring.
|
||||||
|
- App Nap real-hardware verification on Apple Silicon (`RB-08`).
|
||||||
|
- Manual smoke-test matrix on 5 platforms (Linux Fedora, Linux Ubuntu LTS, macOS Apple Silicon, macOS Intel, Windows 11).
|
||||||
|
- 10-step tester acceptance flow personally on Linux.
|
||||||
|
- Recruiting 20 testers + private-beta activation metrics + public-launch metrics.
|
||||||
|
- Decision recorded for KI-02 (Linux idle inhibit) and KI-03 (Windows sleep prevention) — fix-if-tiny vs document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 — Foundation (sequential, must complete before Tier 2 parallels)
|
||||||
|
|
||||||
|
### Task 1.1: SQLite migration v17 — onboarding_events + lumotia_events tables
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/storage/src/migrations.rs` (add migration v17)
|
||||||
|
- Reference: `crates/storage/src/lib.rs` (storage entry point — confirm migration registration pattern)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Add migration v17 to `crates/storage/src/migrations.rs`. Two tables:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- onboarding_events: gates first-run, supplies time-to-first-capture metric
|
||||||
|
CREATE TABLE IF NOT EXISTS onboarding_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event TEXT NOT NULL, -- 'started' | 'permissions_granted' | 'model_ready' | 'test_recording' | 'cleaned_transcript_seen' | 'completed' | 'skipped'
|
||||||
|
completed_at INTEGER NOT NULL, -- unix epoch seconds
|
||||||
|
version TEXT NOT NULL, -- onboarding flow version, e.g. '0.1.0'
|
||||||
|
skipped INTEGER NOT NULL DEFAULT 0, -- 1 if step skipped, 0 if completed
|
||||||
|
notes TEXT -- optional free-text; e.g. selected model name, error reason
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_onboarding_events_event ON onboarding_events(event);
|
||||||
|
|
||||||
|
-- lumotia_events: opt-in local activation log (Settings → Diagnostics → Activation log surface)
|
||||||
|
CREATE TABLE IF NOT EXISTS lumotia_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL, -- 'first_capture' | 'first_export' | 'first_search' | 'first_task_extract' | 'capture_completed' | 'task_extracted'
|
||||||
|
occurred_at INTEGER NOT NULL, -- unix epoch seconds
|
||||||
|
payload TEXT -- JSON blob, optional, NEVER includes transcript text
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lumotia_events_kind ON lumotia_events(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lumotia_events_occurred ON lumotia_events(occurred_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
Match the existing migration registration pattern (look at v16 to see how migrations are added to the migration list). Add a unit test in the same file pattern as existing migration tests that opens an in-memory SQLite, runs migrations through v17, and confirms both tables exist.
|
||||||
|
|
||||||
|
Run `cargo test -p lumotia-storage` and report pass/fail. If fail, fix and re-run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.2: Onboarding Tauri commands module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/commands/onboarding.rs`
|
||||||
|
- Modify: `src-tauri/src/commands/mod.rs` (register new module)
|
||||||
|
- Modify: `src-tauri/src/lib.rs` (register commands in invoke handler)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Create `src-tauri/src/commands/onboarding.rs` exposing these `#[tauri::command]` functions, each calling into `lumotia-storage`:
|
||||||
|
|
||||||
|
- `record_onboarding_event(event: String, version: String, skipped: bool, notes: Option<String>) -> Result<(), String>`
|
||||||
|
- `list_onboarding_events() -> Result<Vec<OnboardingEvent>, String>`
|
||||||
|
- `has_completed_onboarding() -> Result<bool, String>` — returns true if any row with event='completed' or event='skipped' exists
|
||||||
|
- `record_lumotia_event(kind: String, payload: Option<String>) -> Result<(), String>`
|
||||||
|
- `list_lumotia_events() -> Result<Vec<LumotiaEvent>, String>`
|
||||||
|
- `clear_lumotia_events() -> Result<(), String>` — for the user opt-out path
|
||||||
|
|
||||||
|
Define `OnboardingEvent` and `LumotiaEvent` as `serde::Serialize` structs in the same file. Use `chrono::Utc::now().timestamp()` for occurred_at. Keep functions thin — the storage helpers do the SQL work.
|
||||||
|
|
||||||
|
Add the corresponding helpers to `crates/storage/src/lib.rs` (or an `events.rs` module): `insert_onboarding_event`, `list_onboarding_events`, `has_completed_onboarding`, `insert_lumotia_event`, `list_lumotia_events`, `clear_lumotia_events`. Use the existing `Storage` struct's connection pool.
|
||||||
|
|
||||||
|
Add unit tests in the storage crate for each helper (in-memory SQLite, insert + retrieve round-trip).
|
||||||
|
|
||||||
|
Register the module in `src-tauri/src/commands/mod.rs` and add the six commands to the `tauri::generate_handler!` macro list in `src-tauri/src/lib.rs`.
|
||||||
|
|
||||||
|
Run `cargo build -p lumotia` and `cargo test -p lumotia-storage`. Both must pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.3: StatusPill Svelte component + preview entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/components/StatusPill.svelte`
|
||||||
|
- Create: `src/design-system/preview/components-status-pills.html`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Create a `StatusPill.svelte` component using Svelte 5 runes. Props (use `$props()`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Status =
|
||||||
|
| 'ready'
|
||||||
|
| 'recording'
|
||||||
|
| 'paused'
|
||||||
|
| 'transcribing'
|
||||||
|
| 'cleaning'
|
||||||
|
| 'extracting-tasks'
|
||||||
|
| 'saved'
|
||||||
|
| 'exported'
|
||||||
|
| 'needs-review'
|
||||||
|
| 'failed-safely';
|
||||||
|
|
||||||
|
let { status, label = undefined } = $props<{ status: Status; label?: string }>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Render a pill (rounded-full span) with:
|
||||||
|
- Plain-text label (use the prop, fall back to a default map: `ready → "Ready"`, `recording → "Recording"`, `paused → "Paused"`, `transcribing → "Transcribing"`, `cleaning → "Cleaning"`, `extracting-tasks → "Extracting tasks"`, `saved → "Saved"`, `exported → "Exported"`, `needs-review → "Needs review"`, `failed-safely → "Failed safely"`).
|
||||||
|
- A small leading dot (color-coded but never the only signal — the literal label is always present).
|
||||||
|
- Color tokens from `src/design-system/preview/colors-semantic.html` — read that file to pick the right semantic colour per state. (`ready` → neutral, `recording` → accent/danger, `paused` → warning, `transcribing/cleaning/extracting-tasks` → progress, `saved/exported` → success, `needs-review/failed-safely` → warning/danger).
|
||||||
|
- `aria-live="polite"` so screen readers announce state changes.
|
||||||
|
- Honour `prefers-reduced-motion` — no animation on the dot if the media query matches.
|
||||||
|
|
||||||
|
Create `src/design-system/preview/components-status-pills.html` mirroring the structure of the existing `components-toasts.html` and `components-buttons.html` files in the same directory: render every status with its label so the catalogued surface is visible.
|
||||||
|
|
||||||
|
Do NOT integrate into the app yet — that happens in Tier 2 task 2.5.
|
||||||
|
|
||||||
|
Smoke-test by running `npm run check` — must stay 0 errors / 0 warnings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 — Independent UI + onboarding + LLM work (parallelisable after Tier 1)
|
||||||
|
|
||||||
|
### Task 2.1: First-run gate fix + tutorial relaunch from Settings → Help
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/+layout.svelte` (gate logic around lines 345-353)
|
||||||
|
- Modify: `src/lib/pages/SettingsPage.svelte` (add Help section with "Replay first-run tutorial" button)
|
||||||
|
- Modify: `src/lib/pages/FirstRunPage.svelte` (call `record_onboarding_event` on each completion)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Two changes:
|
||||||
|
|
||||||
|
1. **First-run gate** in `src/routes/+layout.svelte`: after the existing model-presence check around lines 345-353, ALSO call `invoke<boolean>('has_completed_onboarding')`. If `true`, do NOT route to first-run regardless of model state — that's the migration-aware bypass. If `false` AND no models, route to first-run as today.
|
||||||
|
|
||||||
|
2. **FirstRunPage** (`src/lib/pages/FirstRunPage.svelte`): after each successful step in the existing flow (probe success, model download success, ready-screen reached, skip), call `invoke('record_onboarding_event', { event, version: '0.1.0', skipped, notes })` with the appropriate event name. Use the event vocabulary from migration v17 (`started`, `permissions_granted`, `model_ready`, `test_recording`, `cleaned_transcript_seen`, `completed`, `skipped`). On the final success path call `record_onboarding_event` with `event='completed'`. On the skip path call with `event='skipped'`.
|
||||||
|
|
||||||
|
3. **SettingsPage Help section**: add a new top-level section titled "Help" near the bottom of the settings list. One button: "Replay first-run tutorial". On click, set a session flag and navigate to first-run (`page.current = 'first-run'`). The flag tells FirstRunPage to skip the migration-bypass.
|
||||||
|
|
||||||
|
Do NOT do the full settings 6-section regroup here — that's Task 2.4. Just add the Help section as-is.
|
||||||
|
|
||||||
|
Run `npm run check` — must stay 0 errors / 0 warnings. Run `cargo build -p lumotia` to make sure the Rust side still compiles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.2: Pre-supplied test-recording prompt + failure recovery in FirstRunPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/pages/FirstRunPage.svelte`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Add two improvements to FirstRunPage:
|
||||||
|
|
||||||
|
1. **Pre-supplied test-recording prompt step** — between the existing "model ready" state and the "rituals" preference prompts, add a "Try a test recording" step. Show the user a short, neutral pre-supplied sentence to read aloud, e.g.:
|
||||||
|
|
||||||
|
> "Try saying: 'Today is a good day to test my microphone and see how the transcription looks.'"
|
||||||
|
|
||||||
|
Render a record button (reuse the existing toggleRecording wiring from DictationPage if extractable, otherwise inline a minimal recorder). After a successful test recording, call `record_onboarding_event` with `event='test_recording'`, then proceed to a "Here's your cleaned transcript" preview step (event='cleaned_transcript_seen'). User clicks "Looks good" to continue.
|
||||||
|
|
||||||
|
2. **Failure recovery** — every existing error path (probe failure, download failure, test-recording failure) must show: error description in plain words, a **Retry** button that re-runs the same step, and a **Skip this step** secondary button that records the event with `skipped: true` and proceeds. No dead-end "something went wrong" with no buttons.
|
||||||
|
|
||||||
|
Run `npm run check` and `npm run test`. Both must stay green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.3: Post-capture card component + integration into capture flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/components/PostCaptureCard.svelte`
|
||||||
|
- Modify: `src/lib/pages/DictationPage.svelte` (render the card after recording stops)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Create `PostCaptureCard.svelte` (Svelte 5 runes). Props:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Props = {
|
||||||
|
transcriptId: string;
|
||||||
|
rawTranscript: string;
|
||||||
|
cleanedTranscript: string;
|
||||||
|
cleanedSource: 'llm' | 'rule-based'; // labels which path produced the cleaned version
|
||||||
|
extractedTasks: string[];
|
||||||
|
microSteps?: string[]; // surfaced when a task is selected
|
||||||
|
onSelectTask?: (idx: number) => void;
|
||||||
|
onExport?: () => void;
|
||||||
|
onStartFirstMicroStep?: () => void;
|
||||||
|
onOpenInHistory?: () => void;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Render as a card (reuse classes from `src/design-system/preview/components-cards.html`):
|
||||||
|
- Heading: "Captured — saved" with a `<StatusPill status="saved" />` to the right.
|
||||||
|
- Cleaned transcript prominent; raw transcript collapsible (`<details><summary>Show raw transcript</summary>`).
|
||||||
|
- A label "(cleaned by local model)" or "(cleaned by rules)" depending on `cleanedSource`.
|
||||||
|
- Extracted tasks as a list; clicking one calls `onSelectTask` and reveals MicroSteps.
|
||||||
|
- Action buttons: Save (already happened — show as confirmation `<StatusPill status="saved" />`), Export, Start first MicroStep (only enabled if microSteps present), Open in History.
|
||||||
|
- DO NOT include: suggested title, suggested folder/project/area/person/topic, possible-links, accept/edit/park/archive, confidence scores. Those are v0.2 (per `docs/release/v0.1-ui-hardening.md` "post-capture card display-only" boundary).
|
||||||
|
|
||||||
|
Integrate into `DictationPage.svelte`: after a recording stops and cleanup completes, show the card below or in place of the textarea. Wire the action props to existing functions where they exist (export → existing export flow, openInHistory → router push to `/history?id=...`).
|
||||||
|
|
||||||
|
Also: on first successful capture, fire `invoke('record_lumotia_event', { kind: 'first_capture' })` — but only if no `first_capture` event already exists. (Cheap check: read activation events on app start, cache.)
|
||||||
|
|
||||||
|
Run `npm run check` and `npm run test`. Both must stay green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.4: Settings 6-section sanity pass + Help section integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/pages/SettingsPage.svelte`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Restructure SettingsPage.svelte's section grouping to match the v0.1 spec from `docs/release/v0.1-ui-hardening.md` section 5 — preserve every existing setting, just regroup. Final order (top to bottom):
|
||||||
|
|
||||||
|
1. **Start Here** — model picker, microphone, language
|
||||||
|
2. **Transcription** — engine choice, cleanup level, custom vocabulary preview
|
||||||
|
3. **Models** — download, switch, disk-space readout
|
||||||
|
4. **Tasks** — energy-aware sequencing toggles, WIP limit
|
||||||
|
5. **Accessibility** — `prefers-reduced-motion`, contrast, typography size, screen-reader hints
|
||||||
|
6. **Privacy** — local-only badge, AI-use disclosure link (link to `/docs/privacy.md` once Task 3.3 lands; for now use `/docs/release/how-lumotia-is-built.md`), local activation log toggle, data-dir location
|
||||||
|
7. **Advanced** — everything else, hidden under a click (collapsed by default)
|
||||||
|
8. **Help** (added in Task 2.1, keep at bottom) — replay first-run tutorial, link to known-limitations doc, link to GitHub issues
|
||||||
|
|
||||||
|
Existing settings to relocate (current → new section):
|
||||||
|
- Audio settings → split: device picker → Start Here, advanced gain/sample-rate → Advanced
|
||||||
|
- Vocabulary → Transcription
|
||||||
|
- AI & Processing → split: cleanup-level toggle → Transcription, model-warmup toggles → Models
|
||||||
|
- Tasks & Rituals → split: WIP/sequencing → Tasks, ritual settings → Advanced
|
||||||
|
- Output & Capture → Advanced (paste matrix, hotkey)
|
||||||
|
- Appearance & System → split: contrast/motion/typography → Accessibility, theme/window opacity → Advanced
|
||||||
|
|
||||||
|
The full progressive-disclosure regroup with search box is **deferred to v0.2** per the boundary doc — this pass is "the basics are findable", NOT "every setting is grouped beautifully". Do not invent new settings or remove existing ones. Just move them.
|
||||||
|
|
||||||
|
Add a one-line `<p>` under "Privacy": "Local activation log: this is stored on your device only and never sent anywhere. You can clear it any time." With a "Clear activation log" button wired to `invoke('clear_lumotia_events')`.
|
||||||
|
|
||||||
|
Run `npm run check` — must stay 0 errors / 0 warnings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.5: StatusPill app-wide integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/pages/DictationPage.svelte` (replace status string + colour with StatusPill)
|
||||||
|
- Modify: any sidebar status chip component (recon noted `LlmStatusChip` — find it and integrate, or replace with StatusPill)
|
||||||
|
- Modify: `src/lib/components/PostCaptureCard.svelte` (already wired in Task 2.3 but verify)
|
||||||
|
- Modify: any other surface that surfaces async state strings — sweep with `rg "Recording\.\.\.|Cleaning|Transcribing|Saved|Exported"` and replace where appropriate
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Sweep the frontend for every place async/transcription state is surfaced as a hand-rolled label or coloured dot. Replace each with `<StatusPill status="..." />`. The Recon turned up these surfaces explicitly — start there:
|
||||||
|
|
||||||
|
- `src/lib/pages/DictationPage.svelte` lines 84-311 — `page.status` string + `page.statusColor` hex pairs
|
||||||
|
- Any `LlmStatusChip.svelte` or `StatusChip.svelte` in `src/lib/components/`
|
||||||
|
- `src/lib/pages/HistoryPage.svelte` if it surfaces per-row state (check)
|
||||||
|
- Toast text where async state is being narrated to the user
|
||||||
|
|
||||||
|
Map the old status strings to the new vocabulary:
|
||||||
|
- "Ready" → `ready`
|
||||||
|
- "Recording..." or "Recording" → `recording`
|
||||||
|
- "Loading model..." or "Transcribing..." → `transcribing`
|
||||||
|
- "Cleaning up..." → `cleaning`
|
||||||
|
- "Extracting tasks..." → `extracting-tasks`
|
||||||
|
- "Saved" → `saved`
|
||||||
|
- "Exported" → `exported`
|
||||||
|
- "Error" + raw err.message → `failed-safely` and the err.message goes into a sibling explainer line (not into the pill)
|
||||||
|
- "Paused" → `paused`
|
||||||
|
|
||||||
|
Preserve any custom user-visible label by passing `label={...}` if the existing copy is more specific than the default.
|
||||||
|
|
||||||
|
Run `npm run check`. Must stay 0 errors / 0 warnings. Do a manual visual check by running `npm run dev:frontend` if helpful.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.6: Recording-as-sacred-state — nav simplification during active recording
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the layout file that renders the secondary nav (likely `src/routes/+layout.svelte` or `src/lib/components/Sidebar.svelte` — find via `rg "History" src/`)
|
||||||
|
- Modify: `src/lib/stores/page.svelte.ts` (expose `recording` state if not already exposed for layout consumption)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
When `page.recording === true`, the sidebar/secondary nav must visibly de-emphasise (greyed, lower opacity, non-interactive — but NOT removed from DOM, so screen-reader users can still navigate).
|
||||||
|
|
||||||
|
Specifically during recording:
|
||||||
|
- Settings / History / Tasks nav items: `opacity-30 pointer-events-none aria-disabled="true"`
|
||||||
|
- Only-visible primary action: the recording timer and Pause / Stop / Cancel buttons
|
||||||
|
- Cancel must show a confirm prompt before discarding
|
||||||
|
|
||||||
|
Add a small fade transition (200ms) wrapped in `prefers-reduced-motion` so it instantly snaps for users who opt out.
|
||||||
|
|
||||||
|
DO NOT remove DOM nodes — accessibility intent is "de-emphasise visually" not "hide from assistive tech". Use `aria-disabled` and `tabindex="-1"` to remove from tab order while recording.
|
||||||
|
|
||||||
|
Run `npm run check`. Must stay clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.7: Home capture clarity — big record button + status pill + last-capture preview
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/pages/DictationPage.svelte`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Apply the v0.1-ui-hardening.md section 1 "Home capture clarity" mantra to DictationPage.svelte. Within the existing structure, ensure:
|
||||||
|
|
||||||
|
1. **Big record button** is the visually dominant element on initial render — minimum 80px diameter, centred or top-of-content, primary CTA colour. Visible within 1 second of landing on Home (no hover, no scroll required).
|
||||||
|
|
||||||
|
2. **Status pill** rendered prominently near the record button using `<StatusPill status="..." />` (already wired in Task 2.5).
|
||||||
|
|
||||||
|
3. **Profile + model summary** — single read-only line near the top: `<small>Profile: {{name}} · Model: {{model}}</small>` so the user can see what they're capturing as.
|
||||||
|
|
||||||
|
4. **Last-capture preview** — collapsed card showing: the last cleaned transcript (first 80 chars + ellipsis), timestamp, click expands or jumps to History. Hidden if no captures exist.
|
||||||
|
|
||||||
|
5. No more than 3 visible secondary CTAs at any time. If there are more than 3, fold the rest behind a "More" disclosure.
|
||||||
|
|
||||||
|
Don't redesign the page — additive hardening only. Existing template-selector / live-warning / save confirmation stay. Just hoist the primary action and add the preview.
|
||||||
|
|
||||||
|
Run `npm run check` and `npm run test`. Both must stay green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.8: Error-state copy sweep
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Sweep the frontend with `rg -n "Error|err\.message|error\.message|Could not|Failed" src/` and address every visible-to-user surface
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Sweep every error surface in `src/` and rewrite to match this contract (per `docs/release/v0.1-ui-hardening.md` section 6):
|
||||||
|
|
||||||
|
1. **Preserve raw transcript** — any error path triggered during/after a recording must NOT clear the textarea or post-capture card data. If the failing function returns an error, surface the error but leave the captured data intact.
|
||||||
|
|
||||||
|
2. **Plain words** — replace raw `err.message` exposure with a human sentence + the raw cause folded into a `<details>` for the curious. Example transformation:
|
||||||
|
|
||||||
|
- Before: `error = 'Could not enumerate audio devices: ' + err.message`
|
||||||
|
- After: `error = 'We couldn't list your audio devices. Plug a microphone in or grant Lumotia microphone permission, then try again.'` with `<details><summary>Technical details</summary>{err.message}</details>` underneath.
|
||||||
|
|
||||||
|
3. **Tell the user what to do next** — every error surface ends with one concrete next action: "Try again", "Continue without LLM", "Open Settings → Privacy", "See known limitations".
|
||||||
|
|
||||||
|
4. **No stack traces in the user-facing surface** — stack traces go to the existing crash dump / log files only.
|
||||||
|
|
||||||
|
Use `<StatusPill status="failed-safely" />` next to the explainer when the data was preserved through the failure (LLM cleanup error, task-extract error, tag-extract error). Use `<StatusPill status="needs-review" />` when the user has to act.
|
||||||
|
|
||||||
|
Files known to need rewrites (from recon):
|
||||||
|
- `src/lib/pages/DictationPage.svelte` — recording / transcription error surfaces
|
||||||
|
- `src/lib/pages/SettingsPage.svelte` — `audioDevicesError`, `vocabularyError`, `diagnosticReportError`, `ttsVoicesError`
|
||||||
|
- Toast components — review every toast call site
|
||||||
|
|
||||||
|
Run `npm run check` and `npm run test`. Both must stay green. Run `rg "err\.message" src/` after the sweep — every remaining match should be inside a `<details>` block, not in primary copy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.9: Keyboard flow + focus ring restoration + 10-step keyboard test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app.css` (broaden `:focus-visible` coverage)
|
||||||
|
- Modify: `src/lib/pages/DictationPage.svelte` (remove `focus:outline-none` on textarea, line ~1058)
|
||||||
|
- Modify: layout/keybinding registration — find existing hotkey wiring, add Ctrl+K (search) and Esc (close modal) bindings
|
||||||
|
- Modify: any modal components — confirm Esc closes them
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Per v0.1-ui-hardening.md section 7 the entire 10-step tester acceptance flow must be completable via keyboard alone.
|
||||||
|
|
||||||
|
Concrete changes:
|
||||||
|
|
||||||
|
1. **Focus ring** — in `src/app.css`, add a global `:focus-visible` rule that's visible on every interactive element at default zoom. Use the existing accent token. Do NOT use `:focus` (always-on) — `:focus-visible` only fires for keyboard focus, which is what we want. Remove any `focus:outline-none` Tailwind utility on visible interactive elements (textarea on DictationPage line ~1058 is the known violator — sweep for others).
|
||||||
|
|
||||||
|
2. **Ctrl+K opens search** — wire a global keyboard listener in the root layout. On Ctrl+K (or ⌘+K on macOS), navigate to History and focus the search input. If already on History, focus the search input.
|
||||||
|
|
||||||
|
3. **Escape closes modal** — every dialog/modal component must respond to Escape with close. Check existing modals (template menu, ritual config, etc.) and add the listener if missing.
|
||||||
|
|
||||||
|
4. **Open Settings from anywhere** — Ctrl+, (or ⌘+,) navigates to Settings.
|
||||||
|
|
||||||
|
5. **Recording start/stop** — already wired via `lumotia:toggle-recording` event. Verify the keyboard binding is configurable in Settings → Advanced (or Help) and labelled there.
|
||||||
|
|
||||||
|
6. **Arrow keys through MicroSteps** — in PostCaptureCard.svelte, when MicroSteps are visible, ↑/↓ move focus between them, Enter starts the timer on the focused MicroStep.
|
||||||
|
|
||||||
|
7. **No `:hover`-only controls** — sweep `rg "group-hover|hover:" src/` and verify every hover-only affordance has a keyboard equivalent (focus-visible variant or always-visible).
|
||||||
|
|
||||||
|
After implementing, manually walk the 10-step flow with keyboard only in `npm run dev:frontend` if helpful; otherwise `npm run check` clean is the gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.10: Task-extraction LLM rule-based fallback
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/llm/src/lib.rs` (around `extract_tasks_with_feedback`, lines 525-554)
|
||||||
|
- Modify: `crates/llm/src/lib.rs` (add a `rule_based_extract_tasks` function or move existing if present)
|
||||||
|
- Modify: `src-tauri/src/commands/tasks.rs` (line 350-375 — fall back to rule-based on LLM error)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Per `docs/release/v0.1-known-limitations.md` ("AI cleanup + extraction failure modes" — Task extraction throws → rule-based regex+verb-list extractor takes over), the contract is that task extraction NEVER returns no tasks just because the LLM failed.
|
||||||
|
|
||||||
|
First, recon: search the codebase for an existing rule-based task extractor. `rg -n "extract_tasks|rule_based|verb_list|imperative" crates/ src-tauri/`. If one exists, wire it in. If not, build a minimal one in `crates/llm/src/lib.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn rule_based_extract_tasks(transcript: &str) -> Vec<String> {
|
||||||
|
// Split on sentence boundaries (. ? ! and newlines).
|
||||||
|
// Keep sentences that start with an imperative verb (need to / I need to / let me / I should /
|
||||||
|
// remember to / don't forget to / make sure / call / send / write / fix / update / review).
|
||||||
|
// Cap at 10 to avoid wall-of-text.
|
||||||
|
// Trim, dedupe.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `extract_tasks_with_feedback` (or wherever the cmd-side calls happen, around `commands/tasks.rs:350-375`): on `Err` from the LLM call, log a warning, call `rule_based_extract_tasks(&transcript)`, return those. Surface to the frontend with a flag so the UI can label them "(rule-based)" — extend the existing return type with an `extracted_via: 'llm' | 'rule-based'` field if helpful, or use a sentinel prefix in the strings.
|
||||||
|
|
||||||
|
Add unit tests: a transcript containing "I need to send Sarah the report tomorrow. Don't forget the slide deck." should produce 2 rule-based tasks.
|
||||||
|
|
||||||
|
Run `cargo test -p lumotia-llm` and `cargo test --workspace`. Both must pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.11: LLM hang timeout wrap (decision: v0.1 vs v0.2)
|
||||||
|
|
||||||
|
**Decision needed:** The `v0.1-known-limitations.md` documents the LLM-hang case as a v0.2 hygiene track item ("the only soft edge"). Two options:
|
||||||
|
|
||||||
|
- **Option A (v0.1 fix):** Wrap the LLM cleanup / extraction calls in a `tokio::time::timeout(Duration::from_secs(120), ...)`. On timeout, propagate to the rule-based fallback. Status pill shows `failed-safely`.
|
||||||
|
- **Option B (v0.2 deferral):** Leave the soft edge as documented. No code change in this task.
|
||||||
|
|
||||||
|
**Default choice for this plan:** Option A. The 120s timeout is a one-line change per call site and closes a real user-trust issue with no architectural risk. If the user prefers Option B, this task becomes a no-op + a tick on the known-limitations entry.
|
||||||
|
|
||||||
|
**Files (if Option A):**
|
||||||
|
- Modify: `src-tauri/src/commands/llm.rs` (cleanup_text command + extract_tasks command + extract_content_tags command)
|
||||||
|
|
||||||
|
**Brief for subagent (Option A):**
|
||||||
|
For each of the three LLM Tauri commands (cleanup_text, extract_tasks_cmd, extract_content_tags_cmd) wrap the LLM call in `tokio::time::timeout(Duration::from_secs(120), ...)`. On timeout, return `Err("LLM call timed out — using fallback or preserved input.")`. The frontend already receives this and surfaces via Task 2.8 error-copy contract.
|
||||||
|
|
||||||
|
For `extract_tasks_cmd`, after the timeout fires, call `rule_based_extract_tasks` from Task 2.10 and return those tasks instead of an error.
|
||||||
|
|
||||||
|
Run `cargo build -p lumotia` and `cargo test --workspace`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 — Release artefacts + docs (parallelisable, can start anytime)
|
||||||
|
|
||||||
|
### Task 3.1: CHANGELOG.md seeded with Phase 1-8 outcomes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `CHANGELOG.md` (repo root)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Create `CHANGELOG.md` at repo root following Keep a Changelog format. Seed it with the v0.1.0 entry written in end-user voice (NOT commit-log style). Read recent commits via `git log --oneline -50` and `docs/release/how-lumotia-is-built.md` to harvest material, then write 5-10 bullet points under sections:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable user-facing changes to Lumotia are documented here.
|
||||||
|
Format: Keep a Changelog. Versioning: SemVer.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-MM-DD
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Local-first dictation with Whisper or Parakeet on your device
|
||||||
|
- Automatic transcript cleanup (rule-based + optional local LLM)
|
||||||
|
- Task extraction with rule-based fallback when the LLM is unavailable
|
||||||
|
- MicroSteps + 5-minute focus timer
|
||||||
|
- History with full-text search
|
||||||
|
- Markdown export with frontmatter (single + bulk + collision-suffixing)
|
||||||
|
- Read-only MCP server (lumotia-mcp) for connecting external agents to your transcript history
|
||||||
|
- First-run onboarding flow with optional skip
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
- All transcription, cleanup, and task extraction runs on your device.
|
||||||
|
- No telemetry. Optional local-only activation log lives in the Settings → Diagnostics surface and never leaves the machine.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
- See docs/release/v0.1-known-limitations.md for the honest list.
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep it under one screen per section. End-user voice means: "task extraction" not "extract_content_tags GBNF grammar wiring". Date placeholder `2026-MM-DD` — fill in on tag day.
|
||||||
|
|
||||||
|
No code change to verify. After writing, view it via `cat CHANGELOG.md` and confirm structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.2: Release notes draft (one page max, plain language)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/release/v0.1-release-notes.md`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Write a one-page (≤ 600 word) release notes draft for the public download page. Cover:
|
||||||
|
|
||||||
|
- **What Lumotia is** — one paragraph, no jargon. "Local-first dictation that turns your voice notes into clean transcripts and actionable tasks. Everything runs on your device."
|
||||||
|
- **What's in v0.1** — 5-7 bullet points in user-benefit voice ("Talk into the app. Get a clean transcript. See your to-dos. Search your history.")
|
||||||
|
- **Privacy + AI use** — one paragraph linking to `how-lumotia-is-built.md` and `v0.1-known-limitations.md`. "We use AI tools to write Lumotia. We disclose how. We test what matters. Read the trust page."
|
||||||
|
- **First-install warnings** — one short section: macOS Gatekeeper, Windows SmartScreen, Linux AppImage SHA-256 instructions. Two sentences each. (Cross-link: see `docs/release/install-warnings.md` from Task 3.5.)
|
||||||
|
- **Supported platforms** — table from the checklist. Linux primary; macOS Apple Silicon + Windows 11 best-effort; macOS Intel only-if-smoke-tested.
|
||||||
|
- **Known limitations link** — single line: "See `docs/release/v0.1-known-limitations.md` for the honest list."
|
||||||
|
- **Reporting issues** — GitHub URL placeholder.
|
||||||
|
|
||||||
|
Match the tone of `docs/release/how-lumotia-is-built.md`. No emoji. No marketing fluff. Calm, specific.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.3: Privacy + AI-use disclosure page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/release/privacy-and-ai-use.md`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Create `docs/release/privacy-and-ai-use.md`. Sections:
|
||||||
|
|
||||||
|
1. **What stays local** — explicit list (audio, transcripts, tasks, MicroSteps, history, models, ontology, activation log).
|
||||||
|
2. **What optionally reaches the network** — explicit list of every outbound network call the app can make (model downloads from HuggingFace, version-update check if wired, npm audit signatures during dev). For each: when it happens, what data is sent, what consent gate exists.
|
||||||
|
3. **What NEVER leaves the machine** — explicit list (audio, transcripts, tasks, voice).
|
||||||
|
4. **AI use disclosure** — local LLM is used for cleanup + task extraction; runs on device; downloaded once. The implementation itself is AI-assisted (per `how-lumotia-is-built.md`). Link to that doc.
|
||||||
|
5. **MCP server caveat** — the read-only MCP surface gives any wired client read access to all transcripts and tasks. Treat with the same care as a folder of personal notes. (Mirror the wording from `v0.1-known-limitations.md`.)
|
||||||
|
6. **Activation log** — opt-in, local-only, never sent. Settings → Diagnostics → Clear button.
|
||||||
|
7. **Crash dumps + logs** — where they live, what they contain, whether they're auto-deleted.
|
||||||
|
8. **Your data, your machine** — one closing paragraph. AGPL-3.0-or-later licence, public repo, audit it yourself.
|
||||||
|
|
||||||
|
Audience: end users, not engineers. Tone: calm, specific, no fluff. Length: ≤ 600 words.
|
||||||
|
|
||||||
|
Link from: `SettingsPage.svelte` Privacy section (already wired to point here in Task 2.4), and `README.md` (added in Task 3.4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.4: README updates — link how-built, GitHub issues, v0.1 framing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Targeted README updates:
|
||||||
|
|
||||||
|
1. Replace the "Pre-alpha" status line with a short "v0.1 release" section that points to:
|
||||||
|
- `docs/release/v0.1-release-notes.md`
|
||||||
|
- `docs/release/v0.1-known-limitations.md`
|
||||||
|
- `docs/release/how-lumotia-is-built.md`
|
||||||
|
- `docs/release/privacy-and-ai-use.md`
|
||||||
|
2. Add a "Reporting issues" section near the bottom: GitHub issues URL `https://github.com/jakeadriansames/lumotia/issues`.
|
||||||
|
3. Confirm install paths per platform are still correct (AppImage / .deb / .dmg / .msi / .exe).
|
||||||
|
4. Confirm first-run expectations are accurate (model download, microphone permission prompt).
|
||||||
|
5. Do NOT touch the project pitch / design principles sections — they're locked.
|
||||||
|
|
||||||
|
After editing, run `npm run check` (no Svelte impact, but checks markdown links if configured). Read the final file and confirm all four release-doc links resolve to actual files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.5: Per-platform first-install warning doc
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/release/install-warnings.md`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
One-page doc enumerating what users will see on first install per platform and how to proceed safely. Sections:
|
||||||
|
|
||||||
|
- **macOS** — Gatekeeper warning ("App can't be opened because it is from an unidentified developer"). Workaround: System Settings → Privacy & Security → "Open Anyway". This warning will disappear once we ship a notarised build with an Apple Developer ID. Tracked: see `KNOWN-ISSUES.md` (RB-08-related).
|
||||||
|
- **Windows** — SmartScreen "Windows protected your PC" warning. Workaround: click "More info" → "Run anyway". This warning will disappear once we ship an EV-signed installer.
|
||||||
|
- **Linux AppImage** — verify the SHA-256 checksum published next to the AppImage on the release page. Show the verification one-liner: `sha256sum lumotia-0.1.0-linux-x86_64.AppImage` then compare to the published value. Optional: import the GPG key once published.
|
||||||
|
|
||||||
|
Tone: matter-of-fact, "this is what to do, don't panic, it's expected for a new app". Length: ≤ 400 words.
|
||||||
|
|
||||||
|
Link from `v0.1-release-notes.md` (Task 3.2) and `README.md` (Task 3.4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.6: AppImage SHA-256 publication wired into build.yml
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/build.yml`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
After the AppImage build step in `.github/workflows/build.yml`, add a step that:
|
||||||
|
|
||||||
|
1. Computes `sha256sum lumotia-*.AppImage > lumotia-*.AppImage.sha256`
|
||||||
|
2. Uploads the `.sha256` file alongside the AppImage as a release artefact
|
||||||
|
|
||||||
|
Use the existing `actions/upload-artifact@v4` pattern in the workflow. Do not modify the macOS or Windows steps — they have signing pending (Task 4 — human-required documented).
|
||||||
|
|
||||||
|
Reference: GitHub Actions native `runner.os == 'Linux'` conditional. Use `sha256sum` (Linux) — there's no need for a cross-platform variant because this only runs on the Linux job.
|
||||||
|
|
||||||
|
After editing, validate the YAML parses by running `yq eval . .github/workflows/build.yml > /dev/null` or just inspect manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.7: Workspace Cargo.toml version policy + npm dev deps exact-pin sweep
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Cargo.toml` (workspace root)
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Two parts:
|
||||||
|
|
||||||
|
1. **Workspace version policy:** Add `[workspace.package]` section to root `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
repository = "https://github.com/jakeadriansames/lumotia"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then for each crate's `Cargo.toml` (under `crates/*` and `src-tauri/`), change the `[package]` section to inherit:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "lumotia-foo"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "..." # keep crate-specific descriptions
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the version source-of-truth in one place. Run `cargo check --workspace --all-targets` after — must stay green.
|
||||||
|
|
||||||
|
2. **npm dev deps exact-pin:** In `package.json`, find every `^X.Y.Z` and `~X.Y.Z` in `devDependencies`. For each, run `npm view <pkg> version` to get the latest X.Y.Z (or pin to the version that's currently installed per `package-lock.json` — safer). Replace `^X.Y.Z` with `X.Y.Z`. Do NOT change ranges in `dependencies` (runtime deps are usually OK to leave caret-ranged for security patches).
|
||||||
|
|
||||||
|
Recon noted these 10 ranges in devDependencies — pin each:
|
||||||
|
- `@sveltejs/adapter-static`, `@sveltejs/kit`, `@tauri-apps/cli`, plus 7 others — discover via `node -e "const p = require('./package.json'); for (const [k,v] of Object.entries(p.devDependencies||{})) if (/[\^~]/.test(v)) console.log(k, v)"`.
|
||||||
|
|
||||||
|
After editing, run `npm ci --ignore-scripts` to confirm the lockfile resolves cleanly. Run `npm run check` and `npm run test` — both must stay green.
|
||||||
|
|
||||||
|
Report any version mismatch surprises (if a current devDep version is behind a published security patch, flag it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.8: Diagnostic bundle Tauri command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/commands/diagnostics.rs` (or extend if already exists per recon — module list shows `diagnostics` exists)
|
||||||
|
- Modify: `src-tauri/src/lib.rs` (register if new command)
|
||||||
|
- Modify: `src/lib/pages/SettingsPage.svelte` Diagnostics section (add "Generate diagnostic bundle" button)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
The existing `commands/diagnostics.rs` already does some work (recon noted `diagnosticReportError` in SettingsPage). Extend it (or add) `generate_diagnostic_bundle()` that produces a zip file at a user-chosen path containing:
|
||||||
|
|
||||||
|
- App version, Tauri version, Rust toolchain, OS + version
|
||||||
|
- Last 7 days of logs (whatever Tauri-app `tracing` is writing, capped at 5 MB)
|
||||||
|
- Recent crash dumps (cap at 3 most recent files)
|
||||||
|
- Redacted preferences — `preferences.json` with sensitive fields blanked (no API keys if any exist; mask user-set vocabulary entries)
|
||||||
|
|
||||||
|
**Critical:** must NEVER include audio files or transcript text. Sweep the bundle assembler with a deny-list of file globs (`**/*.wav`, `**/*.opus`, `**/transcripts/**`, `**/audio/**`).
|
||||||
|
|
||||||
|
Use `zip` crate (already in workspace if present, otherwise add). Pop a save dialog via `tauri-plugin-dialog`. After save, surface a toast: "Diagnostic bundle saved. You can attach this to a GitHub issue."
|
||||||
|
|
||||||
|
Wire into SettingsPage.svelte under the Help (or Diagnostics) section: a "Generate diagnostic bundle" button that calls the command.
|
||||||
|
|
||||||
|
Run `cargo build -p lumotia` and `cargo test --workspace`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.9: Activation log surface in Settings → Diagnostics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/pages/SettingsPage.svelte` (add Activation log subsection under Privacy or Help)
|
||||||
|
|
||||||
|
**Brief for subagent:**
|
||||||
|
Add a small Activation log subsection in SettingsPage.svelte. On render, call `invoke('list_lumotia_events')` and display:
|
||||||
|
|
||||||
|
- A short paragraph: "This log lives on your device only. We never send it anywhere. It records anonymous milestones (first capture, first export, first task extracted) so you can see your own usage shape."
|
||||||
|
- A table: kind | occurred_at (formatted) | payload (if any)
|
||||||
|
- A "Clear activation log" button → `invoke('clear_lumotia_events')` then refresh the table.
|
||||||
|
- A toggle: "Record activation events" — when off, frontend doesn't fire any new `record_lumotia_event` calls. Persist the toggle in `preferences.json` (use existing preferences plumbing).
|
||||||
|
|
||||||
|
Run `npm run check`. Must stay clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 4 — Documentation of human-required residual
|
||||||
|
|
||||||
|
### Task 4.1: Annotate the checklist with residual + human-required notes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/release/v0.1-checklist.md`
|
||||||
|
- Modify: `docs/release/v0.1-ui-hardening.md`
|
||||||
|
|
||||||
|
**Brief for subagent (or me directly at end):**
|
||||||
|
After Tier 1-3 land and Tier 5 gates pass, walk every unchecked box in the two release docs and either:
|
||||||
|
|
||||||
|
- Tick if completed (with a one-liner reference to the commit / file that satisfies it)
|
||||||
|
- Leave unchecked + add a `> 👤 HUMAN REQUIRED:` note explaining why this can't be automated and what specific action the user takes (e.g. "purchase EV signing certificate from DigiCert", "run smoke-test on Apple Silicon hardware", "recruit 5 testers").
|
||||||
|
|
||||||
|
Specific items expected to land in the human-required bucket:
|
||||||
|
- Windows code-signing certificate sourcing + signing wiring
|
||||||
|
- macOS notarisation with Apple Developer ID
|
||||||
|
- macOS Apple Silicon App Nap real-hardware verification (RB-08)
|
||||||
|
- The 5-platform smoke-test matrix
|
||||||
|
- The 10-step tester acceptance flow personally on Linux
|
||||||
|
- Private-beta tester recruitment + activation metrics measurement
|
||||||
|
- Public v0.1 launch metrics (20/15/10/5 strangers)
|
||||||
|
- Decision log for KI-02 (Linux idle inhibit) + KI-03 (Windows sleep prevention) — fix-if-tiny vs document
|
||||||
|
|
||||||
|
Do NOT silently tick a box that hasn't been verified. If a code-side item lands but a manual verification step is also required, leave the box unticked and add both a `Code: ✅` and a `Manual: 👤 pending` note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 5 — Quality gates + dogfood drill (final pass)
|
||||||
|
|
||||||
|
### Task 5.1: Run all quality gates green
|
||||||
|
|
||||||
|
**Brief for subagent (or me directly):**
|
||||||
|
After Tier 1-3 lands, in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
npm run check
|
||||||
|
npm run test
|
||||||
|
scripts/dogfood-rebrand-drill.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Each must exit 0. Fix any failure root-cause; do not paper over with `#[allow(...)]` or `npm-check-ignore`. Any new clippy lints introduced by Tier 1-3 work get fixed at the call site.
|
||||||
|
|
||||||
|
Report the full output (or summary if green) of each command. The session is done when all six are green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review of plan
|
||||||
|
|
||||||
|
**Spec coverage check** (matched to `v0.1-checklist.md` + `v0.1-ui-hardening.md`):
|
||||||
|
- Product surface: covered by recon (already shipped) + Task 4.1 (verify + tick).
|
||||||
|
- First-run onboarding: Task 1.1 (table) + Task 1.2 (commands) + Task 2.1 (gate) + Task 2.2 (test prompt + recovery) + Task 2.4 (Help section) — all 6 sub-items addressed.
|
||||||
|
- Release artefacts: Task 3.1 (CHANGELOG) + Task 3.2 (release notes) + Task 3.4 (README) + Task 3.5 (warnings doc) + Task 3.6 (AppImage SHA) + Task 3.7 (version sync + npm pin). Code-signing → Task 4.1 human-required.
|
||||||
|
- Documentation: Task 3.3 (privacy page) + Task 3.4 (README link) — known-limitations + how-built already exist.
|
||||||
|
- UI acceptance: Tasks 1.3 + 2.3 + 2.5 + 2.6 + 2.7 + 2.8 + 2.9 — all 13 checklist sub-items addressed.
|
||||||
|
- Quality gates: Task 5.1 — all 8 gates run.
|
||||||
|
- Trust + security: already verified by recon (Task 4.1 ticks).
|
||||||
|
- Release-blocker resolution: Task 4.1 documents RB-08 + KI-02 + KI-03 as human decisions.
|
||||||
|
- Supported platforms + smoke matrix: Task 4.1 documents as human-required.
|
||||||
|
- Activation metrics: Task 1.1 (table) + Task 3.9 (UI surface) + Task 4.1 documents the human measurement.
|
||||||
|
|
||||||
|
**Placeholder scan:** none. Every task has concrete files, brief, gate. The Apple-ID / Windows-cert items live in Tier 4 deliberately as documented residual.
|
||||||
|
|
||||||
|
**Type-consistency check:** `record_onboarding_event` / `list_onboarding_events` / `has_completed_onboarding` / `record_lumotia_event` / `list_lumotia_events` / `clear_lumotia_events` are used identically across Tasks 1.2, 2.1, 2.3, 2.4, 3.9. `StatusPill` props (`status: Status; label?: string`) are used identically across Tasks 1.3, 2.3, 2.5, 2.7, 2.8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution model
|
||||||
|
|
||||||
|
Subagent-Driven mode: each task is dispatched to a fresh sonnet subagent with a self-contained brief lifted from the task body above. The dispatcher (this main session) verifies the work between tasks via:
|
||||||
|
|
||||||
|
- Re-running the gate the task brief specified (e.g. `cargo test -p lumotia-storage` after Task 1.1)
|
||||||
|
- Spot-reading the changed files
|
||||||
|
- Confirming no regression in the global gates set
|
||||||
|
|
||||||
|
Tier 1 runs sequentially (Tasks 1.1 → 1.2 → 1.3) because Task 1.2 depends on 1.1's tables and Task 2.x depends on 1.1-1.3 foundations.
|
||||||
|
|
||||||
|
Tier 2 fans out: 2.1, 2.2, 2.3, 2.4, 2.6, 2.7, 2.8, 2.9, 2.10, 2.11 can run in parallel batches of 3-4 (avoid touching the same files concurrently — DictationPage.svelte is touched by 2.3, 2.5, 2.7, 2.8, 2.9 so those serialise).
|
||||||
|
|
||||||
|
Tier 3 fans out completely: 3.1-3.9 are independent of each other and of Tier 2.
|
||||||
|
|
||||||
|
Tier 4 + 5 are end-of-session.
|
||||||
887
package-lock.json
generated
887
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -11,6 +11,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -26,15 +28,17 @@
|
|||||||
"svelte-i18n": "^4.0.1"
|
"svelte-i18n": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "3.0.10",
|
||||||
"@sveltejs/kit": "^2.58.0",
|
"@sveltejs/kit": "2.58.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "5.1.1",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "4.2.1",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "2.10.1",
|
||||||
"svelte": "^5.0.0",
|
"jsdom": "29.1.1",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte": "5.53.12",
|
||||||
"tailwindcss": "^4.2.1",
|
"svelte-check": "4.4.5",
|
||||||
"typescript": "~5.6.2",
|
"tailwindcss": "4.2.1",
|
||||||
"vite": "^6.4.2"
|
"typescript": "5.6.3",
|
||||||
|
"vite": "6.4.2",
|
||||||
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
run.sh
15
run.sh
@@ -20,6 +20,21 @@ case "$(uname -s)" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Supply-chain pre-flight. Verify npm registry signatures whenever the
|
||||||
|
# lockfile has changed since the last successful audit. Skip with
|
||||||
|
# LUMOTIA_SKIP_AUDIT=1 (e.g. offline dev). Fails loud on signature mismatch.
|
||||||
|
audit_stamp=".lumotia-last-audit"
|
||||||
|
if [ "${LUMOTIA_SKIP_AUDIT:-0}" != "1" ]; then
|
||||||
|
if [ ! -f "$audit_stamp" ] || [ "package-lock.json" -nt "$audit_stamp" ]; then
|
||||||
|
printf 'Lockfile changed since last audit. Verifying npm signatures...\n' >&2
|
||||||
|
if ! npm audit signatures; then
|
||||||
|
printf 'npm audit signatures FAILED. Possible supply-chain compromise. Investigate before launching. Override: LUMOTIA_SKIP_AUDIT=1\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
touch "$audit_stamp"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
printf 'Starting Vite dev server...\n' >&2
|
printf 'Starting Vite dev server...\n' >&2
|
||||||
npm run dev:frontend &
|
npm run dev:frontend &
|
||||||
VITE_PID=$!
|
VITE_PID=$!
|
||||||
|
|||||||
10
rust-toolchain.toml
Normal file
10
rust-toolchain.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[toolchain]
|
||||||
|
# Pinned to stop rustc / rustfmt / clippy drift across contributor machines
|
||||||
|
# and CI runners. Bumping this version requires:
|
||||||
|
# - cargo fmt --check clean on the new toolchain
|
||||||
|
# - cargo clippy --workspace --all-targets -- -D warnings clean
|
||||||
|
# - cargo test --workspace green
|
||||||
|
# in that order, committed as a hygiene sweep separate from feature work.
|
||||||
|
channel = "1.94.1"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
|
profile = "minimal"
|
||||||
272
scripts/dogfood-rebrand-drill.sh
Executable file
272
scripts/dogfood-rebrand-drill.sh
Executable file
@@ -0,0 +1,272 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Lumotia rebrand-migration dogfood drill.
|
||||||
|
#
|
||||||
|
# Launches the real lumotia binary against synthetic legacy magnotia state
|
||||||
|
# planted on disk, then probes the post-startup state to confirm both
|
||||||
|
# migration paths ran:
|
||||||
|
# 1. paths.rs: ~/.local/share/magnotia/ -> ~/.local/share/lumotia/
|
||||||
|
# magnotia.db -> lumotia.db
|
||||||
|
# 2. tauri_app_data_migration.rs:
|
||||||
|
# ~/.local/share/uk.co.corbel.magnotia/ copied via staging to
|
||||||
|
# ~/.local/share/consulting.corbel.lumotia/ (legacy preserved
|
||||||
|
# as a backup)
|
||||||
|
#
|
||||||
|
# Modes:
|
||||||
|
# (default) Sandbox. Sets HOME=<tempdir>, plants legacy state
|
||||||
|
# inside, launches the binary, verifies, tears down.
|
||||||
|
# Faithful on Linux. NOT faithful on macOS (Tauri 2
|
||||||
|
# uses NSSearchPathForDirectoriesInDomains which
|
||||||
|
# ignores HOME overrides and would write to your
|
||||||
|
# real Application Support tree).
|
||||||
|
# --against-real-home Real $HOME. Refuses to run if any lumotia data
|
||||||
|
# already exists at the real paths. Backs up the
|
||||||
|
# legacy planting before running so cleanup can
|
||||||
|
# restore your real-home tree to its pre-drill
|
||||||
|
# state.
|
||||||
|
#
|
||||||
|
# Flags:
|
||||||
|
# --keep Keep the sandbox dir / preserved backups after the
|
||||||
|
# run for manual inspection.
|
||||||
|
# --timeout SECS How long to wait for the binary to come up and run
|
||||||
|
# the migration. Default 20s. Bump on slow hardware
|
||||||
|
# or when running under a debugger.
|
||||||
|
# --binary PATH Override the default ./target/debug/lumotia.
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 all probes passed
|
||||||
|
# 1 a probe failed (migration did not produce the expected on-disk state)
|
||||||
|
# 2 argument error
|
||||||
|
# 3 preflight check failed (real-home already has lumotia data, binary
|
||||||
|
# missing, unsupported platform for sandbox mode, ...)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODE="sandbox"
|
||||||
|
KEEP=false
|
||||||
|
TIMEOUT_SECS=20
|
||||||
|
BINARY="./target/debug/lumotia"
|
||||||
|
|
||||||
|
# ---- arg parsing -----------------------------------------------------------
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--against-real-home) MODE="real-home"; shift ;;
|
||||||
|
--keep) KEEP=true; shift ;;
|
||||||
|
--timeout) TIMEOUT_SECS="$2"; shift 2 ;;
|
||||||
|
--binary) BINARY="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- preflight -------------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ ! -x "$BINARY" ]]; then
|
||||||
|
echo "FAIL: binary not found at $BINARY. Run 'cargo build -p lumotia' first." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
PLATFORM="$(uname -s)"
|
||||||
|
if [[ "$MODE" == "sandbox" && "$PLATFORM" == "Darwin" ]]; then
|
||||||
|
echo "FAIL: sandbox mode is not faithful on macOS." >&2
|
||||||
|
echo " Tauri 2 uses NSSearchPathForDirectoriesInDomains which ignores HOME overrides." >&2
|
||||||
|
echo " Either run on Linux or use --against-real-home (with backup discipline)." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Real-home mode: refuse to run if the user already has lumotia data on
|
||||||
|
# disk. We'd risk merging fake planted state into real data on cleanup.
|
||||||
|
if [[ "$MODE" == "real-home" ]]; then
|
||||||
|
REAL_HOME="$HOME"
|
||||||
|
REAL_LUMOTIA_DATA="${XDG_DATA_HOME:-$REAL_HOME/.local/share}/lumotia"
|
||||||
|
REAL_LUMOTIA_TAURI="${XDG_DATA_HOME:-$REAL_HOME/.local/share}/consulting.corbel.lumotia"
|
||||||
|
for p in "$REAL_LUMOTIA_DATA" "$REAL_LUMOTIA_TAURI" "$REAL_HOME/.lumotia"; do
|
||||||
|
if [[ -e "$p" ]]; then
|
||||||
|
echo "FAIL: $p already exists. Refusing to run in real-home mode against existing data." >&2
|
||||||
|
echo " Either back it up manually and remove the original, or run in sandbox mode." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- sandbox setup ---------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ "$MODE" == "sandbox" ]]; then
|
||||||
|
SANDBOX="$(mktemp -d -t lumotia-dogfood-XXXXXX)"
|
||||||
|
export HOME="$SANDBOX"
|
||||||
|
unset XDG_DATA_HOME
|
||||||
|
PLANT_ROOT="$SANDBOX/.local/share"
|
||||||
|
echo "Sandbox: $SANDBOX (HOME overridden)"
|
||||||
|
else
|
||||||
|
SANDBOX="" # signal: don't tear down a sandbox at the end
|
||||||
|
PLANT_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||||
|
echo "Real home: planting under $PLANT_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOG_FILE="$(mktemp -t lumotia-dogfood-binary-XXXXXX.log)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${BINARY_PID:-}" ]] && kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$BINARY_PID" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
kill -KILL "$BINARY_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "$KEEP" == "false" && -n "$SANDBOX" && -d "$SANDBOX" ]]; then
|
||||||
|
rm -rf "$SANDBOX"
|
||||||
|
fi
|
||||||
|
if [[ "$KEEP" == "false" && "$MODE" == "real-home" ]]; then
|
||||||
|
# Real-home cleanup: remove planted legacy state AND any
|
||||||
|
# post-migration artefacts we created. We refused to start if
|
||||||
|
# any of these existed, so removing them now is safe.
|
||||||
|
rm -rf \
|
||||||
|
"$PLANT_ROOT/magnotia" \
|
||||||
|
"$PLANT_ROOT/uk.co.corbel.magnotia" \
|
||||||
|
"$PLANT_ROOT/lumotia" \
|
||||||
|
"$PLANT_ROOT/consulting.corbel.lumotia"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# ---- plant synthetic legacy state -----------------------------------------
|
||||||
|
|
||||||
|
mkdir -p "$PLANT_ROOT/magnotia/recordings/2026-05-13"
|
||||||
|
# Plant a sentinel non-DB file inside the legacy data dir to confirm the
|
||||||
|
# migration sweeps the whole tree, not just magnotia.db.
|
||||||
|
echo "fake-wav-bytes-for-dogfood-drill" > "$PLANT_ROOT/magnotia/recordings/2026-05-13/clip.wav"
|
||||||
|
# An empty file at magnotia.db is enough for the file-level rename probe.
|
||||||
|
# The Rust integration test (crates/storage/tests/legacy_db_migration.rs)
|
||||||
|
# covers the "DB still openable after rename" path with a real SQLite;
|
||||||
|
# this drill is about the LIVE BINARY at startup, not schema integrity.
|
||||||
|
: > "$PLANT_ROOT/magnotia/magnotia.db"
|
||||||
|
|
||||||
|
mkdir -p "$PLANT_ROOT/uk.co.corbel.magnotia/localStorage/leveldb"
|
||||||
|
echo "sentinel-leveldb-bytes" > "$PLANT_ROOT/uk.co.corbel.magnotia/localStorage/leveldb/000003.log"
|
||||||
|
echo '{"main":{"x":100,"y":200,"width":1280,"height":720}}' > "$PLANT_ROOT/uk.co.corbel.magnotia/window-state.json"
|
||||||
|
|
||||||
|
echo "Planted synthetic legacy state. Launching $BINARY..."
|
||||||
|
|
||||||
|
# ---- launch binary in background, give it time to run migrations ----------
|
||||||
|
|
||||||
|
# RUST_LOG forces lumotia_startup tracing events to disk regardless of any
|
||||||
|
# default filter. The setup hook logs `migrated legacy magnotia data dir`
|
||||||
|
# at info level on a successful rename.
|
||||||
|
RUST_LOG="lumotia_startup=info,lumotia=info" \
|
||||||
|
"$BINARY" >"$LOG_FILE" 2>&1 &
|
||||||
|
BINARY_PID=$!
|
||||||
|
|
||||||
|
# Wait for either: the migration log line to appear, OR the timeout.
|
||||||
|
DEADLINE=$(( $(date +%s) + TIMEOUT_SECS ))
|
||||||
|
MIGRATED_DATA_DIR_SEEN=false
|
||||||
|
MIGRATED_TAURI_DIR_SEEN=false
|
||||||
|
while (( $(date +%s) < DEADLINE )); do
|
||||||
|
if grep -q "migrated legacy magnotia data dir to lumotia" "$LOG_FILE" 2>/dev/null; then
|
||||||
|
MIGRATED_DATA_DIR_SEEN=true
|
||||||
|
fi
|
||||||
|
if grep -qE "Migrated|migrated.*tauri.*app_data|copied legacy Tauri" "$LOG_FILE" 2>/dev/null; then
|
||||||
|
MIGRATED_TAURI_DIR_SEEN=true
|
||||||
|
fi
|
||||||
|
if [[ "$MIGRATED_DATA_DIR_SEEN" == "true" ]]; then
|
||||||
|
# Give the Tauri side another moment after the data-dir migration.
|
||||||
|
sleep 1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
# SIGTERM the binary cleanly. We don't need it running for the probes —
|
||||||
|
# all probes inspect the post-migration filesystem state.
|
||||||
|
if kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$BINARY_PID" || true
|
||||||
|
fi
|
||||||
|
wait "$BINARY_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ---- probes ----------------------------------------------------------------
|
||||||
|
|
||||||
|
PROBES_PASSED=0
|
||||||
|
PROBES_FAILED=0
|
||||||
|
report_probe() {
|
||||||
|
local name="$1"; local outcome="$2"; local detail="${3:-}"
|
||||||
|
if [[ "$outcome" == "PASS" ]]; then
|
||||||
|
printf ' PASS %s\n' "$name"
|
||||||
|
PROBES_PASSED=$(( PROBES_PASSED + 1 ))
|
||||||
|
else
|
||||||
|
printf ' FAIL %s %s\n' "$name" "$detail"
|
||||||
|
PROBES_FAILED=$(( PROBES_FAILED + 1 ))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '\nProbe results:\n'
|
||||||
|
|
||||||
|
# 1. Data-dir migration: target exists at the new path.
|
||||||
|
if [[ -d "$PLANT_ROOT/lumotia" ]]; then
|
||||||
|
report_probe "data-dir rename produced ~/.local/share/lumotia/" PASS
|
||||||
|
else
|
||||||
|
report_probe "data-dir rename produced ~/.local/share/lumotia/" FAIL "(missing)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Data-dir migration: lumotia.db is at the new path.
|
||||||
|
if [[ -f "$PLANT_ROOT/lumotia/lumotia.db" ]]; then
|
||||||
|
report_probe "magnotia.db renamed to lumotia.db at new path" PASS
|
||||||
|
else
|
||||||
|
report_probe "magnotia.db renamed to lumotia.db at new path" FAIL "(missing)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Data-dir migration: legacy magnotia tree is gone (rename moved it).
|
||||||
|
if [[ ! -d "$PLANT_ROOT/magnotia" ]]; then
|
||||||
|
report_probe "legacy ~/.local/share/magnotia/ removed by rename" PASS
|
||||||
|
else
|
||||||
|
report_probe "legacy ~/.local/share/magnotia/ removed by rename" FAIL "(still on disk)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Data-dir migration: non-DB companion file carried along.
|
||||||
|
if [[ -f "$PLANT_ROOT/lumotia/recordings/2026-05-13/clip.wav" ]]; then
|
||||||
|
report_probe "non-DB companion file carried along by directory rename" PASS
|
||||||
|
else
|
||||||
|
report_probe "non-DB companion file carried along by directory rename" FAIL "(missing)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Tauri migration: webview/localStorage copied to new bundle id path.
|
||||||
|
if [[ -f "$PLANT_ROOT/consulting.corbel.lumotia/localStorage/leveldb/000003.log" ]]; then
|
||||||
|
report_probe "Tauri app_data_dir copied to consulting.corbel.lumotia/" PASS
|
||||||
|
else
|
||||||
|
report_probe "Tauri app_data_dir copied to consulting.corbel.lumotia/" FAIL "(localStorage missing)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Tauri migration: legacy uk.co.corbel.magnotia preserved as backup.
|
||||||
|
if [[ -f "$PLANT_ROOT/uk.co.corbel.magnotia/localStorage/leveldb/000003.log" ]]; then
|
||||||
|
report_probe "legacy Tauri dir preserved as backup" PASS
|
||||||
|
else
|
||||||
|
report_probe "legacy Tauri dir preserved as backup" FAIL "(missing — migration should NOT delete legacy)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Tauri migration: staging dir cleaned up.
|
||||||
|
if [[ ! -d "$PLANT_ROOT/consulting.corbel.lumotia.migrating" ]]; then
|
||||||
|
report_probe "staging dir consulting.corbel.lumotia.migrating cleaned up" PASS
|
||||||
|
else
|
||||||
|
report_probe "staging dir consulting.corbel.lumotia.migrating cleaned up" FAIL "(staging leaked)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8. Log line: lumotia_startup migration event appeared.
|
||||||
|
if [[ "$MIGRATED_DATA_DIR_SEEN" == "true" ]]; then
|
||||||
|
report_probe "lumotia_startup logged the data-dir migration" PASS
|
||||||
|
else
|
||||||
|
report_probe "lumotia_startup logged the data-dir migration" FAIL "(no log line within ${TIMEOUT_SECS}s)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- summary --------------------------------------------------------------
|
||||||
|
|
||||||
|
printf '\nLog file: %s\n' "$LOG_FILE"
|
||||||
|
if [[ "$KEEP" == "true" && -n "$SANDBOX" ]]; then
|
||||||
|
printf 'Sandbox preserved at: %s\n' "$SANDBOX"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nPassed: %s / Failed: %s\n' "$PROBES_PASSED" "$PROBES_FAILED"
|
||||||
|
|
||||||
|
if (( PROBES_FAILED > 0 )); then
|
||||||
|
printf '\nDrill FAILED. Inspect %s for clues.\n' "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nDrill PASSED. Rebrand migration runs end-to-end against real OS paths.\n'
|
||||||
157
scripts/parse-activation-log.py
Executable file
157
scripts/parse-activation-log.py
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Summarise a Lumotia activation log for tester review. Stdlib only.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/parse-activation-log.py path/to/tester-activation.json
|
||||||
|
python3 scripts/parse-activation-log.py --paste # reads stdin (tab/pipe table or JSON)
|
||||||
|
"""
|
||||||
|
import json, re, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Activation metric thresholds (v0.1 tester runbook)
|
||||||
|
WARMUP_MINUTES = 3 # first capture < N min from open = activated
|
||||||
|
CORE_VALUE_COUNT = 3 # captures in first 24h
|
||||||
|
DAY = 86400
|
||||||
|
|
||||||
|
# Event kinds that count as "capture completed"
|
||||||
|
CAPTURE_KINDS = {
|
||||||
|
'first_capture', 'recording_completed', 'recording_saved',
|
||||||
|
'capture_completed', 'transcript_saved',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny patterns — scrub payload strings before printing anything
|
||||||
|
_DENY = re.compile(
|
||||||
|
r'transcripts?/|captures?/|audio/|\.wav\b|\.mp3\b|\.opus\b|\.ogg\b|\.flac\b|\.db\b',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _utc(ts): return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
|
||||||
|
def _delta(a, b):
|
||||||
|
d = abs(b - a)
|
||||||
|
if d < 60: return f"{d}s"
|
||||||
|
if d < 3600: return f"{d//60} min"
|
||||||
|
if d < DAY: return f"{d//3600}h {(d%3600)//60}m"
|
||||||
|
return f"{d//DAY}d {(d%DAY)//3600}h"
|
||||||
|
|
||||||
|
def _parse_json(text):
|
||||||
|
data = json.loads(text)
|
||||||
|
if not isinstance(data, list): raise ValueError("Expected JSON array")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _parse_table(text):
|
||||||
|
"""Best-effort: extract rows from a pipe/tab-delimited table or HTML paste."""
|
||||||
|
try: return _parse_json(text)
|
||||||
|
except (json.JSONDecodeError, ValueError): pass
|
||||||
|
html = re.compile(r'<[^>]+>')
|
||||||
|
events = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = html.sub('', line).strip()
|
||||||
|
cells = [c.strip() for c in re.split(r'\||\t', line) if c.strip()]
|
||||||
|
if len(cells) < 3 or cells[0].lower() in ('id', '#'): continue
|
||||||
|
try:
|
||||||
|
raw_ts = cells[2]
|
||||||
|
if re.match(r'^\d+$', raw_ts):
|
||||||
|
ts = int(raw_ts)
|
||||||
|
else:
|
||||||
|
ts = int(datetime.fromisoformat(raw_ts.replace('Z','+00:00'))
|
||||||
|
.astimezone(timezone.utc).timestamp())
|
||||||
|
events.append({'id': int(cells[0]), 'kind': cells[1], 'occurred_at': ts,
|
||||||
|
'payload': cells[3] if len(cells) > 3 else None})
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _metrics(events):
|
||||||
|
if not events: return {}
|
||||||
|
ev = sorted(events, key=lambda e: e.get('occurred_at', 0))
|
||||||
|
by_kind = {}
|
||||||
|
for e in ev: by_kind.setdefault(e.get('kind',''), []).append(e)
|
||||||
|
|
||||||
|
first_ts, last_ts = ev[0]['occurred_at'], ev[-1]['occurred_at']
|
||||||
|
|
||||||
|
def _first(*kinds):
|
||||||
|
for e in ev:
|
||||||
|
if e.get('kind') in set().union(*kinds): return e['occurred_at']
|
||||||
|
return None
|
||||||
|
|
||||||
|
fc = _first({'first_capture'}, CAPTURE_KINDS)
|
||||||
|
fe = _first({'first_export','transcript_exported','export_completed'})
|
||||||
|
ft = _first({'first_task_extract','task_extracted','tasks_extracted'})
|
||||||
|
|
||||||
|
cap_24h = sum(1 for e in ev if e['kind'] in CAPTURE_KINDS and e['occurred_at']-first_ts <= DAY)
|
||||||
|
cap_7d = sum(1 for e in ev if e['kind'] in CAPTURE_KINDS and e['occurred_at']-first_ts <= 7*DAY)
|
||||||
|
returned = (last_ts - first_ts) <= 7*DAY if last_ts != first_ts else None
|
||||||
|
return dict(ev=ev, by_kind=by_kind, first_ts=first_ts, last_ts=last_ts,
|
||||||
|
fc=fc, fe=fe, ft=ft, cap_24h=cap_24h, cap_7d=cap_7d, returned=returned)
|
||||||
|
|
||||||
|
def _render(m, label):
|
||||||
|
if not m: return "ERROR: no events found — nothing to summarise.\n"
|
||||||
|
L = []
|
||||||
|
hdr = f"LUMOTIA ACTIVATION LOG SUMMARY ({label})"
|
||||||
|
L += [hdr, '='*len(hdr), '']
|
||||||
|
L.append(f"Total events: {len(m['ev'])}")
|
||||||
|
L.append(f"First event: {_utc(m['first_ts'])}")
|
||||||
|
L.append(f"Last event: {_utc(m['last_ts'])} (span: {_delta(m['first_ts'], m['last_ts'])})")
|
||||||
|
L.append('')
|
||||||
|
|
||||||
|
def _ms(label, ts, ref=None):
|
||||||
|
if ts is None: return f"{label:<22} (not recorded)"
|
||||||
|
s = f"{label:<22} {_utc(ts)}"
|
||||||
|
if ref and ref != ts: s += f" ({_delta(ref, ts)} after first capture)"
|
||||||
|
return s
|
||||||
|
|
||||||
|
L += [_ms("First capture:", m['fc']),
|
||||||
|
_ms("First export:", m['fe'], m['fc']),
|
||||||
|
_ms("First task extract:", m['ft'], m['fc']), '']
|
||||||
|
L.append(f"Captures (24h): {m['cap_24h']} (target: >= {CORE_VALUE_COUNT})")
|
||||||
|
L.append(f"Captures (7-day): {m['cap_7d']}")
|
||||||
|
ret = m['returned']
|
||||||
|
if ret is True: ret_s = f"yes (last event {_utc(m['last_ts'])})"
|
||||||
|
elif ret is False: ret_s = f"no (last seen {_utc(m['last_ts'])} — {_delta(m['first_ts'],m['last_ts'])} after first)"
|
||||||
|
else: ret_s = "n/a (only one event recorded)"
|
||||||
|
L += [f"Returned within 7d: {ret_s}", '', "ACTIVATION METRICS:"]
|
||||||
|
|
||||||
|
def ok(flag, label, detail):
|
||||||
|
t = " ✓" if flag else " ×"
|
||||||
|
return f"{t} {label}: {detail}"
|
||||||
|
def q(label, detail): return f" ? {label}: {detail}"
|
||||||
|
|
||||||
|
L.append(ok(m['fc'] is not None, "Activation",
|
||||||
|
"first_capture event present" if m['fc'] else "no capture event recorded"))
|
||||||
|
L.append(ok(m['cap_24h'] >= CORE_VALUE_COUNT, "Core value",
|
||||||
|
f"{m['cap_24h']} captures in first 24h (target: >= {CORE_VALUE_COUNT})"))
|
||||||
|
if ret is True: L.append(ok(True, "Retention", "returned within 7 days"))
|
||||||
|
elif ret is False: L.append(ok(False, "Retention", "no return event within 7 days"))
|
||||||
|
else: L.append(q("Retention", "only one session — check day-3"))
|
||||||
|
L.append(q("Quality", "extracted tasks accepted/edited (not in activation log — ask tester)"))
|
||||||
|
L.append(q("Trust", "can articulate 'what stays local' (not in activation log — ask tester)"))
|
||||||
|
L += ['', "EVENT BREAKDOWN:"]
|
||||||
|
for kind, evs in sorted(m['by_kind'].items()):
|
||||||
|
L.append(f" {kind:<35} x{len(evs)}")
|
||||||
|
L += ['', "NOTE: ? items require qualitative follow-up per the tester-onboarding-kit."]
|
||||||
|
return '\n'.join(L) + '\n'
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args: print(__doc__); sys.exit(1)
|
||||||
|
if args[0] == '--paste':
|
||||||
|
raw, label = sys.stdin.read(), 'stdin (paste)'
|
||||||
|
parse = _parse_table
|
||||||
|
else:
|
||||||
|
p = Path(args[0])
|
||||||
|
if not p.exists(): print(f"ERROR: file not found: {p}", file=sys.stderr); sys.exit(2)
|
||||||
|
raw, label = p.read_text(encoding='utf-8', errors='replace'), p.name
|
||||||
|
parse = _parse_table # tries JSON first, then table
|
||||||
|
try:
|
||||||
|
events = parse(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: could not parse input — {exc}", file=sys.stderr); sys.exit(2)
|
||||||
|
for ev in events:
|
||||||
|
if isinstance(ev.get('payload'), str):
|
||||||
|
ev['payload'] = _DENY.sub('[redacted]', ev['payload'])
|
||||||
|
print(_render(_metrics(events), label))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
153
scripts/parse-diagnostic-bundle.sh
Executable file
153
scripts/parse-diagnostic-bundle.sh
Executable file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# parse-diagnostic-bundle.sh — summarise a Lumotia diagnostic bundle zip.
|
||||||
|
# Usage: ./scripts/parse-diagnostic-bundle.sh path/to/tester-bundle.zip
|
||||||
|
# Requires: unzip (required), jq (preferred; graceful grep fallback).
|
||||||
|
# Privacy: never prints transcript text, audio content, or .db content.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BOLD='\033[1m'; GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; RESET='\033[0m'
|
||||||
|
pass() { printf "${GREEN} ✓${RESET} %s\n" "$*"; }
|
||||||
|
fail() { printf "${RED} ×${RESET} %s\n" "$*"; OVERALL_FAIL=1; }
|
||||||
|
warn() { printf "${YELLOW} !${RESET} %s\n" "$*"; }
|
||||||
|
info() { printf " %s\n" "$*"; }
|
||||||
|
header(){ printf "\n${BOLD}%s${RESET}\n" "$*"; }
|
||||||
|
OVERALL_FAIL=0
|
||||||
|
|
||||||
|
# Argument + file validation
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: $0 path/to/tester-bundle.zip" >&2; exit 1; }
|
||||||
|
BUNDLE="$1"; BUNDLE_NAME="$(basename "$BUNDLE")"
|
||||||
|
[[ ! -f "$BUNDLE" ]] && { echo "ERROR: file not found: $BUNDLE" >&2; exit 2; }
|
||||||
|
if ! file "$BUNDLE" 2>/dev/null | grep -qi 'zip'; then
|
||||||
|
(dd if="$BUNDLE" bs=2 count=2 2>/dev/null | grep -q 'PK') \
|
||||||
|
|| { echo "ERROR: $BUNDLE does not appear to be a zip archive." >&2; exit 2; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# jq check
|
||||||
|
HAS_JQ=0
|
||||||
|
command -v jq &>/dev/null && HAS_JQ=1 \
|
||||||
|
|| echo "WARNING: jq not found — falling back to grep-based extraction. Install jq for best results." >&2
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
TMPDIR_WORK="$(mktemp -d)"; trap 'rm -rf "$TMPDIR_WORK"' EXIT
|
||||||
|
unzip -q "$BUNDLE" -d "$TMPDIR_WORK" 2>/dev/null \
|
||||||
|
|| { echo "ERROR: failed to extract $BUNDLE" >&2; exit 3; }
|
||||||
|
|
||||||
|
# Header
|
||||||
|
TITLE="LUMOTIA DIAGNOSTIC BUNDLE SUMMARY: $BUNDLE_NAME"
|
||||||
|
printf "\n${BOLD}%s${RESET}\n" "$TITLE"
|
||||||
|
printf '%0.s=' $(seq 1 ${#TITLE}); printf '\n'
|
||||||
|
|
||||||
|
# metadata.json → version + timestamps
|
||||||
|
META="$TMPDIR_WORK/metadata.json"; GEN_AT=""; VERSION=""
|
||||||
|
if [[ -f "$META" ]]; then
|
||||||
|
if [[ $HAS_JQ -eq 1 ]]; then
|
||||||
|
GEN_AT="$(jq -r '.generated_at // empty' "$META" 2>/dev/null)"
|
||||||
|
VERSION="$(jq -r '.lumotia_version // empty' "$META" 2>/dev/null)"
|
||||||
|
else
|
||||||
|
GEN_AT="$(grep -o '"generated_at":[^,}]*' "$META" | grep -o '[0-9]*' | head -1)"
|
||||||
|
VERSION="$(grep -o '"lumotia_version":"[^"]*"' "$META" | cut -d'"' -f4)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
GEN_HUMAN=""
|
||||||
|
if [[ -n "$GEN_AT" && "$GEN_AT" =~ ^[0-9]+$ ]]; then
|
||||||
|
GEN_HUMAN="$(date -u -d "@$GEN_AT" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
|
||||||
|
|| date -u -r "$GEN_AT" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
|
||||||
|
|| echo "$GEN_AT (epoch)")"
|
||||||
|
fi
|
||||||
|
info "Generated: ${GEN_HUMAN:-unknown}"
|
||||||
|
info "Lumotia version: ${VERSION:-unknown}"
|
||||||
|
|
||||||
|
# system_info.txt → platform
|
||||||
|
SYSINFO="$TMPDIR_WORK/system_info.txt"
|
||||||
|
if [[ -f "$SYSINFO" ]]; then
|
||||||
|
OS_LINE="$(grep -i '^OS:' "$SYSINFO" | head -1 | sed 's/^OS: *//')"
|
||||||
|
ARCH_LINE="$(grep -i '^Arch:' "$SYSINFO" | head -1 | sed 's/^Arch: *//')"
|
||||||
|
info "Platform: ${OS_LINE:-unknown} / ${ARCH_LINE:-unknown}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Content inventory
|
||||||
|
header "CONTENT INVENTORY:"
|
||||||
|
check_path() {
|
||||||
|
[[ -e "$TMPDIR_WORK/$2" ]] && pass "$1" || warn "$1 (not present)"
|
||||||
|
}
|
||||||
|
check_path "system_info.txt" "system_info.txt"
|
||||||
|
check_path "preferences-redacted.json" "preferences-redacted.json"
|
||||||
|
check_path "metadata.json" "metadata.json"
|
||||||
|
|
||||||
|
LOG_DIR="$TMPDIR_WORK/logs"; LOG_COUNT=0
|
||||||
|
if [[ -d "$LOG_DIR" ]]; then
|
||||||
|
LOG_COUNT="$(find "$LOG_DIR" -type f | wc -l)"
|
||||||
|
pass "logs/ ($LOG_COUNT files, $(du -sh "$LOG_DIR" 2>/dev/null | cut -f1))"
|
||||||
|
else warn "logs/ (directory not present)"; fi
|
||||||
|
|
||||||
|
CRASH_DIR="$TMPDIR_WORK/crashes"
|
||||||
|
if [[ -d "$CRASH_DIR" ]]; then
|
||||||
|
CRASH_COUNT="$(find "$CRASH_DIR" -type f | wc -l)"
|
||||||
|
[[ "$CRASH_COUNT" -eq 0 ]] \
|
||||||
|
&& pass "crashes/ (0 files) — no crash dumps, good" \
|
||||||
|
|| warn "crashes/ ($CRASH_COUNT files) — crash dumps present, review carefully"
|
||||||
|
else pass "crashes/ (directory absent) — no crash dumps, good"; fi
|
||||||
|
|
||||||
|
# Redaction check — bundle MUST NEVER contain these
|
||||||
|
header "REDACTION CHECK (bundle MUST NEVER include these):"
|
||||||
|
DENY_CLEAN=1
|
||||||
|
check_absent() {
|
||||||
|
local hits; hits="$(find "$TMPDIR_WORK" -type f | { grep -iE "$2" 2>/dev/null || true; })"
|
||||||
|
[[ -z "$hits" ]] && pass "$1" \
|
||||||
|
|| { fail "$1 — FOUND: $(printf '%s' "$hits" | head -3 | tr '\n' ' ')"; DENY_CLEAN=0; }
|
||||||
|
}
|
||||||
|
check_absent "no .wav files" '\.wav$'
|
||||||
|
check_absent "no .mp3/.opus/.ogg/.flac files" '\.(mp3|opus|ogg|flac)$'
|
||||||
|
check_absent "no transcripts/ paths" '/transcripts/'
|
||||||
|
check_absent "no captures/ paths" '/captures/'
|
||||||
|
check_absent "no .db files" '\.(db|db-wal|db-shm)$'
|
||||||
|
check_absent "no .env files" '(^|/)\.env(\.|$)'
|
||||||
|
if [[ $DENY_CLEAN -eq 1 ]]; then
|
||||||
|
printf "\n${GREEN} PASS: bundle passed redaction check.${RESET}\n"
|
||||||
|
else
|
||||||
|
printf "\n${RED} FAIL: bundle contains DENIED content — do not share this bundle.${RESET}\n"
|
||||||
|
OVERALL_FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Log analysis
|
||||||
|
header "LOG ANALYSIS (last 7 days):"
|
||||||
|
if [[ -d "$LOG_DIR" && "$LOG_COUNT" -gt 0 ]]; then
|
||||||
|
ALL_LOGS="$(find "$LOG_DIR" -type f -exec cat {} \;)"
|
||||||
|
ERR_COUNT="$(printf '%s' "$ALL_LOGS" | { grep -iE '(ERROR|ERRO|\bERR\b)' 2>/dev/null || true; } | wc -l)"
|
||||||
|
WARN_COUNT="$(printf '%s' "$ALL_LOGS" | { grep -iE '(WARN|WARNING)' 2>/dev/null || true; } | wc -l)"
|
||||||
|
info "Errors: $ERR_COUNT"
|
||||||
|
info "Warnings: $WARN_COUNT"
|
||||||
|
if [[ "$ERR_COUNT" -gt 0 ]]; then
|
||||||
|
info ""; info "TOP 3 ERROR PATTERNS:"
|
||||||
|
printf '%s' "$ALL_LOGS" \
|
||||||
|
| { grep -iE '(ERROR|ERRO|\bERR\b)' || true; } \
|
||||||
|
| sed 's/^[0-9TZ:. -]*//' | sort | uniq -c | sort -rn | head -3 \
|
||||||
|
| while IFS= read -r line; do info " × $line"; done
|
||||||
|
fi
|
||||||
|
else warn "No log files found."; fi
|
||||||
|
|
||||||
|
# Preferences (non-secret values)
|
||||||
|
PREFS="$TMPDIR_WORK/preferences-redacted.json"
|
||||||
|
header "PREFERENCES (redacted secrets):"
|
||||||
|
if [[ ! -f "$PREFS" ]]; then
|
||||||
|
warn "preferences-redacted.json not found"
|
||||||
|
elif [[ $HAS_JQ -eq 1 ]]; then
|
||||||
|
jq -r 'paths(scalars) as $p | getpath($p)
|
||||||
|
| select(. != "[redacted]" and . != null and . != "")
|
||||||
|
| "\($p | join(".")): \(.)"' "$PREFS" 2>/dev/null | head -30 \
|
||||||
|
| while IFS= read -r line; do info " $line"; done
|
||||||
|
REDACTED_COUNT="$(jq '[.. | strings | select(. == "[redacted]")] | length' "$PREFS" 2>/dev/null || echo '?')"
|
||||||
|
info " ($REDACTED_COUNT field(s) redacted by bundler)"
|
||||||
|
else
|
||||||
|
{ grep -oE '"[^"]+": *("[^"]*"|[0-9.]+|true|false)' "$PREFS" || true; } \
|
||||||
|
| grep -v '\[redacted\]' | head -30 | while IFS= read -r line; do info " $line"; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verdict
|
||||||
|
header "VERDICT:"
|
||||||
|
if [[ $OVERALL_FAIL -eq 0 ]]; then
|
||||||
|
printf "${GREEN} PASS: bundle looks healthy. Safe to inspect for bug triage.${RESET}\n\n"
|
||||||
|
else
|
||||||
|
printf "${RED} FAIL: one or more checks failed — review items marked × above.${RESET}\n\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
170
scripts/pre-tag-verify.sh
Executable file
170
scripts/pre-tag-verify.sh
Executable file
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pre-tag-verify.sh — Lumotia v0.1 pre-tag verification script
|
||||||
|
#
|
||||||
|
# Automates the 7-step morning-of ritual from docs/release/v0.1-checklist.md.
|
||||||
|
# Exit 0 = safe to tag. Any failure exits immediately with a clear message.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/pre-tag-verify.sh
|
||||||
|
#
|
||||||
|
# Requires: git, cargo, npm (all already required to build the project).
|
||||||
|
# No new dependencies introduced.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
pass() { printf "${GREEN} ✓ PASS${RESET} %s\n" "$1"; }
|
||||||
|
fail() {
|
||||||
|
local step="$1"
|
||||||
|
shift
|
||||||
|
printf "\n${RED}${BOLD}✗ STEP %s FAILED: %s${RESET}\n\n" "$step" "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
step_header() {
|
||||||
|
printf "\n${BOLD}[%s] %s${RESET}\n" "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 1: clean checkout ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step_header "1/7" "Confirming clean checkout..."
|
||||||
|
|
||||||
|
if ! git diff --quiet; then
|
||||||
|
fail "1" "Working tree has unstaged changes. Stash or commit before tagging."
|
||||||
|
fi
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
fail "1" "Working tree has staged-but-uncommitted changes. Commit or reset before tagging."
|
||||||
|
fi
|
||||||
|
pass "Working tree is clean."
|
||||||
|
|
||||||
|
# ── Step 2: three-way version sync ──────────────────────────────────────────
|
||||||
|
|
||||||
|
step_header "2/7" "Confirming version sync (Cargo.toml / package.json / tauri.conf.json)..."
|
||||||
|
|
||||||
|
# Extract [workspace.package].version from root Cargo.toml
|
||||||
|
cargo_ver=$(grep -A10 '^\[workspace\.package\]' Cargo.toml \
|
||||||
|
| grep '^version' \
|
||||||
|
| head -1 \
|
||||||
|
| grep -oP '"\K[^"]+')
|
||||||
|
|
||||||
|
# Extract "version" from package.json (first occurrence, top-level field)
|
||||||
|
npm_ver=$(grep -m1 '"version"' package.json | grep -oP '"\K[0-9][^"]+')
|
||||||
|
|
||||||
|
# Extract "version" from tauri.conf.json
|
||||||
|
tauri_ver=$(grep -m1 '"version"' src-tauri/tauri.conf.json | grep -oP '"\K[0-9][^"]+')
|
||||||
|
|
||||||
|
if [[ -z "$cargo_ver" ]]; then
|
||||||
|
fail "2" "Could not parse version from Cargo.toml [workspace.package]."
|
||||||
|
fi
|
||||||
|
if [[ -z "$npm_ver" ]]; then
|
||||||
|
fail "2" "Could not parse version from package.json."
|
||||||
|
fi
|
||||||
|
if [[ -z "$tauri_ver" ]]; then
|
||||||
|
fail "2" "Could not parse version from src-tauri/tauri.conf.json."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$cargo_ver" != "$npm_ver" || "$cargo_ver" != "$tauri_ver" ]]; then
|
||||||
|
fail "2" "Version mismatch: Cargo.toml='$cargo_ver' package.json='$npm_ver' tauri.conf.json='$tauri_ver'. Sync all three before tagging."
|
||||||
|
fi
|
||||||
|
pass "All three version files agree: $cargo_ver"
|
||||||
|
|
||||||
|
# ── Step 3: CHANGELOG date placeholder ──────────────────────────────────────
|
||||||
|
|
||||||
|
step_header "3/7" "Confirming CHANGELOG.md has no date placeholder..."
|
||||||
|
|
||||||
|
if ! [[ -f CHANGELOG.md ]]; then
|
||||||
|
fail "3" "CHANGELOG.md not found at repo root. Create it before tagging."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The placeholder is the literal string used in the file: "2026-MM-DD"
|
||||||
|
if grep -qE '[0-9]{4}-MM-DD' CHANGELOG.md; then
|
||||||
|
fail "3" "CHANGELOG.md still has a 2026-MM-DD placeholder. Replace with the tag date before re-running."
|
||||||
|
fi
|
||||||
|
pass "CHANGELOG.md contains no date placeholder."
|
||||||
|
|
||||||
|
# ── Step 4: known-limitations doc has no TBD ────────────────────────────────
|
||||||
|
|
||||||
|
step_header "4/7" "Confirming docs/release/v0.1-known-limitations.md has no unresolved items..."
|
||||||
|
|
||||||
|
KL_DOC="docs/release/v0.1-known-limitations.md"
|
||||||
|
if ! [[ -f "$KL_DOC" ]]; then
|
||||||
|
fail "4" "$KL_DOC not found. Create it (or verify the path) before tagging."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qiE '\bTBD\b' "$KL_DOC"; then
|
||||||
|
fail "4" "$KL_DOC contains 'TBD'. Resolve or document every open item before tagging."
|
||||||
|
fi
|
||||||
|
if grep -qiE 'pending decision' "$KL_DOC"; then
|
||||||
|
fail "4" "$KL_DOC contains 'pending decision'. Resolve all such items before tagging."
|
||||||
|
fi
|
||||||
|
pass "No TBD or 'pending decision' found in $KL_DOC."
|
||||||
|
|
||||||
|
# ── Step 5: quality gates ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step_header "5/7" "Running quality gates..."
|
||||||
|
|
||||||
|
printf " • cargo fmt --check\n"
|
||||||
|
if ! cargo fmt --check 2>&1; then
|
||||||
|
fail "5" "'cargo fmt --check' reports formatting issues. Run 'cargo fmt' and commit before tagging."
|
||||||
|
fi
|
||||||
|
pass "cargo fmt --check"
|
||||||
|
|
||||||
|
printf " • cargo clippy\n"
|
||||||
|
if ! cargo clippy --workspace --all-targets -- -D warnings 2>&1; then
|
||||||
|
fail "5" "'cargo clippy' reported warnings (treated as errors). Fix all clippy issues before tagging."
|
||||||
|
fi
|
||||||
|
pass "cargo clippy --workspace --all-targets -- -D warnings"
|
||||||
|
|
||||||
|
printf " • cargo test\n"
|
||||||
|
if ! cargo test --workspace 2>&1; then
|
||||||
|
fail "5" "'cargo test --workspace' had failures. All tests must pass before tagging."
|
||||||
|
fi
|
||||||
|
pass "cargo test --workspace"
|
||||||
|
|
||||||
|
printf " • npm run check\n"
|
||||||
|
if ! npm run check 2>&1; then
|
||||||
|
fail "5" "'npm run check' (svelte-check) reported errors. Fix all type/svelte errors before tagging."
|
||||||
|
fi
|
||||||
|
pass "npm run check"
|
||||||
|
|
||||||
|
printf " • npm run test\n"
|
||||||
|
if ! npm run test 2>&1; then
|
||||||
|
fail "5" "'npm run test' (vitest) had failures. All frontend tests must pass before tagging."
|
||||||
|
fi
|
||||||
|
pass "npm run test"
|
||||||
|
|
||||||
|
# ── Step 6: dogfood rebrand drill ───────────────────────────────────────────
|
||||||
|
|
||||||
|
step_header "6/7" "Running dogfood rebrand drill (sandbox mode)..."
|
||||||
|
|
||||||
|
DRILL="scripts/dogfood-rebrand-drill.sh"
|
||||||
|
if ! [[ -f "$DRILL" ]]; then
|
||||||
|
fail "6" "$DRILL not found. Restore the script before tagging."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! bash "$DRILL" 2>&1; then
|
||||||
|
fail "6" "'$DRILL' reported failures. All 8/8 probes must pass before tagging."
|
||||||
|
fi
|
||||||
|
pass "dogfood-rebrand-drill.sh passed."
|
||||||
|
|
||||||
|
# ── Step 7: release build sanity check ──────────────────────────────────────
|
||||||
|
|
||||||
|
step_header "7/7" "Building lumotia crate in release mode (compilation sanity check)..."
|
||||||
|
|
||||||
|
if ! cargo build -p lumotia --release 2>&1; then
|
||||||
|
fail "7" "'cargo build -p lumotia --release' failed. Fix compilation errors before tagging."
|
||||||
|
fi
|
||||||
|
pass "cargo build -p lumotia --release succeeded."
|
||||||
|
|
||||||
|
# ── All steps passed ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
printf "\n${GREEN}${BOLD}✓ ALL 7 PRE-TAG STEPS PASSED. Safe to run: git tag v0.1.0 && git push --tags${RESET}\n\n"
|
||||||
469
scripts/smoke-linux-driver.sh
Executable file
469
scripts/smoke-linux-driver.sh
Executable file
@@ -0,0 +1,469 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# smoke-linux-driver.sh — Lumotia Linux UI smoke driver
|
||||||
|
#
|
||||||
|
# Automates more cells of the smoke-test matrix than smoke-linux.sh by driving
|
||||||
|
# the X11 UI via xdotool + reading the SQLite store directly for History
|
||||||
|
# assertions.
|
||||||
|
#
|
||||||
|
# Prereqs:
|
||||||
|
# - xdotool (apt/dnf/pacman: xdotool)
|
||||||
|
# - sqlite3 CLI (apt/dnf/pacman: sqlite or sqlite3)
|
||||||
|
# - X11 session (Wayland users: launch with GDK_BACKEND=x11 — already set by run.sh)
|
||||||
|
# - Optional: virtual audio source (see docs/release/virtual-audio-setup.md) for the
|
||||||
|
# Capture cell. Without it, Capture stays MANUAL.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/smoke-linux-driver.sh [/path/to/lumotia-0.1.0-linux-x86_64.AppImage]
|
||||||
|
#
|
||||||
|
# If no argument is given, the script builds a debug binary via cargo and tests
|
||||||
|
# against that instead.
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 all automated cells passed (manual cells still need human sign-off)
|
||||||
|
# 1 one or more automated cells failed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
APPIMAGE="${1:-}"
|
||||||
|
BINARY=""
|
||||||
|
USE_APPIMAGE=false
|
||||||
|
|
||||||
|
CELL_PASS=0
|
||||||
|
CELL_FAIL=0
|
||||||
|
CELL_MANUAL=0
|
||||||
|
|
||||||
|
# Track whether Capture succeeded so dependent cells know whether to run
|
||||||
|
CAPTURE_RAN=false
|
||||||
|
CAPTURE_STARTED_AT=""
|
||||||
|
|
||||||
|
cell_header() {
|
||||||
|
printf "\n${BOLD}[CELL %s] %s${RESET}\n" "$1" "$2"
|
||||||
|
}
|
||||||
|
cell_pass() {
|
||||||
|
CELL_PASS=$((CELL_PASS + 1))
|
||||||
|
printf "${GREEN} ✓ PASS${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
cell_fail() {
|
||||||
|
CELL_FAIL=$((CELL_FAIL + 1))
|
||||||
|
printf "${RED} ✗ FAIL${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
cell_manual() {
|
||||||
|
CELL_MANUAL=$((CELL_MANUAL + 1))
|
||||||
|
printf "${YELLOW} ⚠ MANUAL${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Capability checks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HAS_XDOTOOL=false
|
||||||
|
HAS_SQLITE3=false
|
||||||
|
HAS_VIRTUAL_AUDIO=false
|
||||||
|
|
||||||
|
if command -v xdotool >/dev/null 2>&1; then
|
||||||
|
HAS_XDOTOOL=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
HAS_SQLITE3=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for virtual audio source named "lumotia-test"
|
||||||
|
if command -v pactl >/dev/null 2>&1; then
|
||||||
|
if pactl list sources short 2>/dev/null | grep -q "lumotia-test"; then
|
||||||
|
HAS_VIRTUAL_AUDIO=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! $HAS_XDOTOOL; then
|
||||||
|
printf "${YELLOW}${BOLD}xdotool not found.${RESET} Falling back to smoke-linux.sh behaviour for UI-dependent cells.\n"
|
||||||
|
printf " Install xdotool (apt/dnf/pacman: xdotool) to enable UI automation.\n"
|
||||||
|
printf " Cells 2–6 will be MANUAL except Install and the dogfood drill.\n\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! $HAS_SQLITE3; then
|
||||||
|
printf "${YELLOW}${BOLD}sqlite3 not found.${RESET} History search cell (6/7) will be MANUAL.\n"
|
||||||
|
printf " Install sqlite3 (apt/dnf/pacman: sqlite or sqlite3) to enable SQLite automation.\n\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Resolve data dir ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Lumotia uses the platform data dir. On Linux this is ~/.local/share/lumotia.
|
||||||
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/lumotia"
|
||||||
|
DB_PATH="$DATA_DIR/transcripts.db"
|
||||||
|
|
||||||
|
# ── Cell 1/7: Install ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "1/7" "Install"
|
||||||
|
|
||||||
|
if [[ -n "$APPIMAGE" ]]; then
|
||||||
|
if [[ ! -f "$APPIMAGE" ]]; then
|
||||||
|
cell_fail "AppImage not found at: $APPIMAGE"
|
||||||
|
elif [[ ! -x "$APPIMAGE" ]]; then
|
||||||
|
printf " AppImage is not executable — setting bit now...\n"
|
||||||
|
chmod +x "$APPIMAGE"
|
||||||
|
if [[ -x "$APPIMAGE" ]]; then
|
||||||
|
BINARY="$APPIMAGE"
|
||||||
|
USE_APPIMAGE=true
|
||||||
|
cell_pass "AppImage executable bit set: $APPIMAGE"
|
||||||
|
else
|
||||||
|
cell_fail "chmod +x failed on: $APPIMAGE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
BINARY="$APPIMAGE"
|
||||||
|
USE_APPIMAGE=true
|
||||||
|
cell_pass "AppImage already executable: $APPIMAGE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf " No AppImage supplied — running cargo build (debug)...\n"
|
||||||
|
if cargo build -p lumotia 2>&1; then
|
||||||
|
BINARY="$REPO_ROOT/target/debug/lumotia"
|
||||||
|
if [[ -f "$BINARY" ]]; then
|
||||||
|
cell_pass "cargo build succeeded. Binary: $BINARY"
|
||||||
|
else
|
||||||
|
cell_fail "cargo build succeeded but binary not found at: $BINARY"
|
||||||
|
BINARY=""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
cell_fail "cargo build -p lumotia failed. Fix compilation errors before smoke-testing."
|
||||||
|
BINARY=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 2/7: First-run ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "2/7" "First-run (launch + window detection)"
|
||||||
|
|
||||||
|
BINARY_PID=""
|
||||||
|
|
||||||
|
if [[ -z "$BINARY" ]]; then
|
||||||
|
cell_fail "Skipping — no valid binary from Cell 1."
|
||||||
|
elif ! $HAS_XDOTOOL; then
|
||||||
|
# Fallback: plain process-liveness check (same as smoke-linux.sh)
|
||||||
|
LUMOTIA_SMOKE_MODE=1 \
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 \
|
||||||
|
GDK_BACKEND=x11 \
|
||||||
|
DISPLAY="${DISPLAY:-:99}" \
|
||||||
|
"$BINARY" &>/tmp/lumotia-smoke-driver-launch.log &
|
||||||
|
BINARY_PID=$!
|
||||||
|
|
||||||
|
printf " Waiting 10 seconds (PID %s)...\n" "$BINARY_PID"
|
||||||
|
ALIVE=true
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
sleep 1
|
||||||
|
if ! kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
ALIVE=false
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$BINARY_PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
kill -KILL "$BINARY_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
wait "$BINARY_PID" 2>/dev/null || true
|
||||||
|
BINARY_PID=""
|
||||||
|
|
||||||
|
if $ALIVE; then
|
||||||
|
cell_pass "Binary stayed alive 10 s (no xdotool — window title not verified)."
|
||||||
|
else
|
||||||
|
EXIT_LOG=/tmp/lumotia-smoke-driver-launch.log
|
||||||
|
if grep -qiE 'panic|SIGSEGV|Segmentation fault|thread.*panicked' "$EXIT_LOG" 2>/dev/null; then
|
||||||
|
cell_fail "Binary crashed within 10 s. See /tmp/lumotia-smoke-driver-launch.log"
|
||||||
|
else
|
||||||
|
cell_pass "Binary exited cleanly in headless mode. No panic in log."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# xdotool available — launch and verify window title
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 \
|
||||||
|
GDK_BACKEND=x11 \
|
||||||
|
DISPLAY="${DISPLAY:-:0}" \
|
||||||
|
"$BINARY" &>/tmp/lumotia-smoke-driver-launch.log &
|
||||||
|
BINARY_PID=$!
|
||||||
|
|
||||||
|
printf " Waiting 5 s for window to appear (PID %s)...\n" "$BINARY_PID"
|
||||||
|
FOUND_WINDOW=""
|
||||||
|
for i in $(seq 1 5); do
|
||||||
|
sleep 1
|
||||||
|
if ! kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
FOUND_WINDOW=$(DISPLAY="${DISPLAY:-:0}" xdotool search --name "Lumotia" 2>/dev/null | head -1 || true)
|
||||||
|
if [[ -n "$FOUND_WINDOW" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$FOUND_WINDOW" ]]; then
|
||||||
|
cell_pass "Window 'Lumotia' found via xdotool (window ID: $FOUND_WINDOW)."
|
||||||
|
# Leave the process running for Capture cell
|
||||||
|
else
|
||||||
|
# Process may still be alive but window not found — check for crash
|
||||||
|
if ! kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
EXIT_LOG=/tmp/lumotia-smoke-driver-launch.log
|
||||||
|
if grep -qiE 'panic|SIGSEGV|Segmentation fault|thread.*panicked' "$EXIT_LOG" 2>/dev/null; then
|
||||||
|
cell_fail "Binary crashed before window appeared. See /tmp/lumotia-smoke-driver-launch.log"
|
||||||
|
else
|
||||||
|
cell_pass "Binary exited cleanly (headless/no-display mode). No panic in log."
|
||||||
|
fi
|
||||||
|
BINARY_PID=""
|
||||||
|
else
|
||||||
|
# Window not found but process alive — likely no DISPLAY or WebKit not ready
|
||||||
|
cell_manual "Binary alive but 'Lumotia' window not detected within 5 s. Verify manually that the window opens and the app loads."
|
||||||
|
# Kill it — Capture can't run without a confirmed window
|
||||||
|
kill -TERM "$BINARY_PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
kill -KILL "$BINARY_PID" 2>/dev/null || true
|
||||||
|
wait "$BINARY_PID" 2>/dev/null || true
|
||||||
|
BINARY_PID=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 3/7: Capture ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "3/7" "Capture (record via hotkey)"
|
||||||
|
|
||||||
|
if ! $HAS_XDOTOOL; then
|
||||||
|
printf " MANUAL: Open the app, talk into the microphone for 5 seconds.\n"
|
||||||
|
printf " Confirm a live transcript appears in the dictation area.\n"
|
||||||
|
cell_manual "xdotool not found — audio capture requires a human tester."
|
||||||
|
elif [[ -z "$BINARY_PID" ]]; then
|
||||||
|
printf " MANUAL: Cell 2 did not leave a running app window.\n"
|
||||||
|
printf " Open the app manually, record for 5 seconds, confirm a transcript appears.\n"
|
||||||
|
cell_manual "No confirmed running window — Capture cell cannot be automated."
|
||||||
|
elif ! $HAS_VIRTUAL_AUDIO; then
|
||||||
|
printf " MANUAL: No virtual audio source named 'lumotia-test' detected.\n"
|
||||||
|
printf " Set one up (see docs/release/virtual-audio-setup.md), then in Lumotia →\n"
|
||||||
|
printf " Settings → Start Here → Microphone, pick 'lumotia-test'.\n"
|
||||||
|
printf " Once configured, re-run this script to automate the Capture cell.\n"
|
||||||
|
cell_manual "Virtual audio source 'lumotia-test' not found — audio capture requires a human tester."
|
||||||
|
else
|
||||||
|
# All prereqs met: focus the window and drive the hotkey
|
||||||
|
# Default record hotkey is Super+Shift+Space; adjust if the user has changed it.
|
||||||
|
RECORD_HOTKEY="${LUMOTIA_RECORD_HOTKEY:-super+shift+space}"
|
||||||
|
|
||||||
|
FOUND_WINDOW=$(DISPLAY="${DISPLAY:-:0}" xdotool search --name "Lumotia" 2>/dev/null | head -1 || true)
|
||||||
|
if [[ -z "$FOUND_WINDOW" ]]; then
|
||||||
|
printf " MANUAL: Could not re-locate the Lumotia window to send the hotkey.\n"
|
||||||
|
cell_manual "xdotool search failed at Capture time — drive recording manually."
|
||||||
|
else
|
||||||
|
printf " Focusing window and pressing record hotkey (%s)...\n" "$RECORD_HOTKEY"
|
||||||
|
DISPLAY="${DISPLAY:-:0}" xdotool windowfocus --sync "$FOUND_WINDOW" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
DISPLAY="${DISPLAY:-:0}" xdotool key --clearmodifiers "$RECORD_HOTKEY" 2>/dev/null || true
|
||||||
|
|
||||||
|
printf " Recording for 5 seconds...\n"
|
||||||
|
CAPTURE_STARTED_AT="$(date +%s)"
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
printf " Pressing record hotkey again to stop...\n"
|
||||||
|
DISPLAY="${DISPLAY:-:0}" xdotool key --clearmodifiers "$RECORD_HOTKEY" 2>/dev/null || true
|
||||||
|
|
||||||
|
printf " Waiting up to 15 s for transcription to complete...\n"
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
CAPTURE_RAN=true
|
||||||
|
cell_pass "Record hotkey sent twice (start + stop). Transcription settling time elapsed."
|
||||||
|
printf " Note: Actual transcript content is NOT printed here (privacy invariant).\n"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tear down the app if it is still running and we own the PID
|
||||||
|
if [[ -n "$BINARY_PID" ]] && kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$BINARY_PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
kill -KILL "$BINARY_PID" 2>/dev/null || true
|
||||||
|
wait "$BINARY_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 4/7: Cleanup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "4/7" "Cleanup (LLM-cleaned transcript in DB)"
|
||||||
|
|
||||||
|
if ! $CAPTURE_RAN; then
|
||||||
|
printf " MANUAL: Stop recording. Confirm the cleaned transcript\n"
|
||||||
|
printf " appears beneath the raw transcript in the PostCaptureCard.\n"
|
||||||
|
cell_manual "Capture cell did not run automatically — Cleanup requires a prior automated capture."
|
||||||
|
elif ! $HAS_SQLITE3; then
|
||||||
|
printf " MANUAL: sqlite3 not found — cannot query the database directly.\n"
|
||||||
|
printf " Inspect the PostCaptureCard in the app to confirm cleaned text is present.\n"
|
||||||
|
cell_manual "sqlite3 not found — Cleanup cell assertion requires manual verification."
|
||||||
|
elif [[ ! -f "$DB_PATH" ]]; then
|
||||||
|
printf " Database not found at: %s\n" "$DB_PATH"
|
||||||
|
cell_fail "transcripts.db not found — was the app launched with the correct data dir?"
|
||||||
|
else
|
||||||
|
# Poll for a transcript row created in the last 30 seconds with cleaned_text set
|
||||||
|
printf " Querying %s for a recent cleaned transcript...\n" "$DB_PATH"
|
||||||
|
THIRTY_SECONDS_AGO=$(( $(date +%s) - 30 ))
|
||||||
|
# SQLite stores timestamps as ISO-8601 text. We compare as strings (they sort correctly).
|
||||||
|
THRESHOLD_ISO=$(date -u -d "@$THIRTY_SECONDS_AGO" '+%Y-%m-%dT%H:%M:%S' 2>/dev/null \
|
||||||
|
|| date -u -r "$THIRTY_SECONDS_AGO" '+%Y-%m-%dT%H:%M:%S' 2>/dev/null \
|
||||||
|
|| date -u '+%Y-%m-%dT%H:%M:%S' --date="-30 seconds" 2>/dev/null \
|
||||||
|
|| echo "")
|
||||||
|
|
||||||
|
if [[ -z "$THRESHOLD_ISO" ]]; then
|
||||||
|
cell_manual "Could not compute ISO timestamp threshold — Cleanup assertion skipped."
|
||||||
|
else
|
||||||
|
CLEANED_COUNT=$(sqlite3 "$DB_PATH" \
|
||||||
|
"SELECT COUNT(*) FROM transcripts WHERE created_at >= '$THRESHOLD_ISO' AND cleaned_text IS NOT NULL AND cleaned_text != '';" \
|
||||||
|
2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [[ "$CLEANED_COUNT" -gt 0 ]]; then
|
||||||
|
cell_pass "Found $CLEANED_COUNT transcript(s) with cleaned_text in the last 30 seconds."
|
||||||
|
else
|
||||||
|
RAW_COUNT=$(sqlite3 "$DB_PATH" \
|
||||||
|
"SELECT COUNT(*) FROM transcripts WHERE created_at >= '$THRESHOLD_ISO';" \
|
||||||
|
2>/dev/null || echo "0")
|
||||||
|
if [[ "$RAW_COUNT" -gt 0 ]]; then
|
||||||
|
cell_fail "Found $RAW_COUNT recent transcript(s) but cleaned_text is NULL — LLM cleanup may have failed."
|
||||||
|
else
|
||||||
|
cell_fail "No recent transcripts in the DB (threshold: $THRESHOLD_ISO). Capture may not have saved."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 5/7: Export ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "5/7" "Export (Markdown file on disk)"
|
||||||
|
|
||||||
|
if ! $CAPTURE_RAN; then
|
||||||
|
printf " MANUAL: From the PostCaptureCard or History, export the transcript\n"
|
||||||
|
printf " as Markdown via the native save dialog. Confirm the .md file\n"
|
||||||
|
printf " is created on disk with the expected content.\n"
|
||||||
|
cell_manual "Capture cell did not run — Export requires a prior automated capture."
|
||||||
|
elif ! $HAS_XDOTOOL; then
|
||||||
|
printf " MANUAL: Use the app's export action to save the transcript as Markdown.\n"
|
||||||
|
printf " Confirm the .md file appears in your home directory.\n"
|
||||||
|
cell_manual "xdotool not found — Export keystroke cannot be sent automatically."
|
||||||
|
else
|
||||||
|
# The export shortcut is Ctrl+E (default). The save dialog is native OS — we cannot
|
||||||
|
# drive it with xdotool reliably across all desktop environments. Instead, we check
|
||||||
|
# whether any .md file was created in the last 30 seconds in the user's home directory.
|
||||||
|
printf " Checking for a Markdown export file created in the last 30 seconds...\n"
|
||||||
|
EXPORT_FILE=$(find "$HOME" -maxdepth 3 -name "*.md" -newer /tmp/lumotia-smoke-driver-launch.log \
|
||||||
|
2>/dev/null | head -1 || true)
|
||||||
|
|
||||||
|
if [[ -n "$EXPORT_FILE" ]]; then
|
||||||
|
# Verify frontmatter is present (non-empty --- block at the start of the file)
|
||||||
|
if grep -q "^---" "$EXPORT_FILE" 2>/dev/null; then
|
||||||
|
cell_pass "Export file found with frontmatter present."
|
||||||
|
printf " Path redacted (privacy invariant). Filename suffix: …%s\n" "${EXPORT_FILE: -20}"
|
||||||
|
else
|
||||||
|
cell_fail "Export file found but no YAML frontmatter (--- block) detected at the start."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf " No .md file found. If the native save dialog appeared, accept it and re-run.\n"
|
||||||
|
printf " MANUAL: In Lumotia → PostCaptureCard, press the Export button (or Ctrl+E),\n"
|
||||||
|
printf " save the file, then re-run this script to check Cell 5.\n"
|
||||||
|
cell_manual "No .md export file detected — Export cell requires manual action."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 6/7: History search ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "6/7" "History search (FTS5 index)"
|
||||||
|
|
||||||
|
if ! $HAS_SQLITE3; then
|
||||||
|
printf " MANUAL: Open History (sidebar). Type a word from your dictation\n"
|
||||||
|
printf " in the search box. Confirm the transcript appears in results.\n"
|
||||||
|
cell_manual "sqlite3 not found — FTS5 search requires manual verification."
|
||||||
|
elif [[ ! -f "$DB_PATH" ]]; then
|
||||||
|
printf " Database not found at: %s\n" "$DB_PATH"
|
||||||
|
if $CAPTURE_RAN; then
|
||||||
|
cell_fail "transcripts.db not found after an automated capture completed — data dir may be wrong."
|
||||||
|
else
|
||||||
|
printf " No capture has run yet. Complete a recording first to populate the index.\n"
|
||||||
|
cell_manual "No database found and no capture ran — History search cannot be verified automatically."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# We cannot read transcript text (privacy invariant). Instead we verify:
|
||||||
|
# 1. The FTS5 virtual table exists.
|
||||||
|
# 2. At least one row exists in the index (implying the index is populated).
|
||||||
|
printf " Checking FTS5 index in %s...\n" "$DB_PATH"
|
||||||
|
|
||||||
|
FTS_TABLE=$(sqlite3 "$DB_PATH" \
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%fts%' LIMIT 1;" \
|
||||||
|
2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$FTS_TABLE" ]]; then
|
||||||
|
cell_fail "No FTS5 table found in the database. History search is likely broken."
|
||||||
|
else
|
||||||
|
FTS_ROW_COUNT=$(sqlite3 "$DB_PATH" \
|
||||||
|
"SELECT COUNT(*) FROM \"$FTS_TABLE\";" \
|
||||||
|
2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [[ "$FTS_ROW_COUNT" -gt 0 ]]; then
|
||||||
|
cell_pass "FTS5 table '$FTS_TABLE' exists and contains $FTS_ROW_COUNT indexed row(s)."
|
||||||
|
elif $CAPTURE_RAN; then
|
||||||
|
cell_fail "FTS5 table '$FTS_TABLE' exists but is empty after a capture was completed — indexing may be broken."
|
||||||
|
else
|
||||||
|
printf " FTS5 table exists but is empty (no capture ran to populate it).\n"
|
||||||
|
printf " MANUAL: After completing a recording, verify that History search returns it.\n"
|
||||||
|
cell_manual "FTS5 table is empty — no capture ran to populate the index."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 7/7: Uninstall + reinstall preserves transcripts ────────────────────
|
||||||
|
|
||||||
|
cell_header "7/7" "Uninstall + reinstall preserves transcripts (dogfood rebrand drill)"
|
||||||
|
|
||||||
|
DRILL="$REPO_ROOT/scripts/dogfood-rebrand-drill.sh"
|
||||||
|
if [[ ! -f "$DRILL" ]]; then
|
||||||
|
cell_fail "scripts/dogfood-rebrand-drill.sh not found — restore it before smoke-testing."
|
||||||
|
elif [[ ! -x "$DRILL" ]]; then
|
||||||
|
cell_fail "scripts/dogfood-rebrand-drill.sh is not executable — run: chmod +x scripts/dogfood-rebrand-drill.sh"
|
||||||
|
else
|
||||||
|
if bash "$DRILL" >/tmp/lumotia-smoke-driver-drill.log 2>&1; then
|
||||||
|
cell_pass "dogfood-rebrand-drill.sh passed — data-dir migration + preservation verified (8/8 probes)."
|
||||||
|
else
|
||||||
|
cell_fail "dogfood-rebrand-drill.sh reported failures. See /tmp/lumotia-smoke-driver-drill.log for details."
|
||||||
|
tail -20 /tmp/lumotia-smoke-driver-drill.log | sed 's/^/ /' || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TOTAL=$((CELL_PASS + CELL_FAIL + CELL_MANUAL))
|
||||||
|
|
||||||
|
printf "\n"
|
||||||
|
printf "${BOLD}── Smoke driver summary ──────────────────────────────────────────────${RESET}\n"
|
||||||
|
printf " Automated PASS : %d / %d\n" "$CELL_PASS" "$TOTAL"
|
||||||
|
printf " MANUAL : %d / %d\n" "$CELL_MANUAL" "$TOTAL"
|
||||||
|
printf " FAIL : %d / %d\n" "$CELL_FAIL" "$TOTAL"
|
||||||
|
|
||||||
|
if $HAS_VIRTUAL_AUDIO && $HAS_XDOTOOL && $HAS_SQLITE3; then
|
||||||
|
printf "\n All prereqs present. Cells closed automatically: Install, First-run,\n"
|
||||||
|
printf " Capture, Cleanup, History search, Uninstall+reinstall (6/7 when audio\n"
|
||||||
|
printf " source is wired to the app). Export is semi-automated (file detection).\n"
|
||||||
|
else
|
||||||
|
printf "\n Missing prereqs:\n"
|
||||||
|
$HAS_XDOTOOL || printf " • xdotool — enables First-run window detection + Capture hotkey driving\n"
|
||||||
|
$HAS_SQLITE3 || printf " • sqlite3 — enables Cleanup + History search assertions\n"
|
||||||
|
$HAS_VIRTUAL_AUDIO || printf " • lumotia-test audio source — see docs/release/virtual-audio-setup.md\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "\n"
|
||||||
|
|
||||||
|
if [[ $CELL_FAIL -eq 0 ]]; then
|
||||||
|
printf "${GREEN}${BOLD}✓ Linux smoke-driver complete: %d/%d automated PASS, %d/%d require manual verification.${RESET}\n\n" \
|
||||||
|
"$CELL_PASS" "$TOTAL" "$CELL_MANUAL" "$TOTAL"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
printf "${RED}${BOLD}✗ Linux smoke-driver: %d/%d PASS, %d/%d FAILED, %d/%d manual.${RESET}\n\n" \
|
||||||
|
"$CELL_PASS" "$TOTAL" "$CELL_FAIL" "$TOTAL" "$CELL_MANUAL" "$TOTAL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
206
scripts/smoke-linux.sh
Executable file
206
scripts/smoke-linux.sh
Executable file
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# smoke-linux.sh — Lumotia Linux smoke harness
|
||||||
|
#
|
||||||
|
# Automates the install/launch/first-run/uninstall cells of the smoke-test
|
||||||
|
# matrix for the Linux primary platform. Audio-dependent cells (Capture,
|
||||||
|
# Cleanup, Export, History search) are flagged for manual verification.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/smoke-linux.sh [/path/to/lumotia-0.1.0-linux-x86_64.AppImage]
|
||||||
|
#
|
||||||
|
# If no argument is given, the script builds a debug binary via cargo and tests
|
||||||
|
# against that instead.
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 all automated cells passed (manual cells still need human sign-off)
|
||||||
|
# 1 one or more automated cells failed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
APPIMAGE="${1:-}"
|
||||||
|
BINARY=""
|
||||||
|
USE_APPIMAGE=false
|
||||||
|
|
||||||
|
CELL_PASS=0
|
||||||
|
CELL_FAIL=0
|
||||||
|
CELL_MANUAL=0
|
||||||
|
|
||||||
|
cell_header() {
|
||||||
|
printf "\n${BOLD}[CELL %s] %s${RESET}\n" "$1" "$2"
|
||||||
|
}
|
||||||
|
cell_pass() {
|
||||||
|
CELL_PASS=$((CELL_PASS + 1))
|
||||||
|
printf "${GREEN} ✓ PASS${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
cell_fail() {
|
||||||
|
CELL_FAIL=$((CELL_FAIL + 1))
|
||||||
|
printf "${RED} ✗ FAIL${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
cell_manual() {
|
||||||
|
CELL_MANUAL=$((CELL_MANUAL + 1))
|
||||||
|
printf "${YELLOW} ⚠ MANUAL${RESET} %s\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cell 1/7: Install ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "1/7" "Install"
|
||||||
|
|
||||||
|
if [[ -n "$APPIMAGE" ]]; then
|
||||||
|
if [[ ! -f "$APPIMAGE" ]]; then
|
||||||
|
cell_fail "AppImage not found at: $APPIMAGE"
|
||||||
|
elif [[ ! -x "$APPIMAGE" ]]; then
|
||||||
|
printf " AppImage is not executable — setting bit now...\n"
|
||||||
|
chmod +x "$APPIMAGE"
|
||||||
|
if [[ -x "$APPIMAGE" ]]; then
|
||||||
|
BINARY="$APPIMAGE"
|
||||||
|
USE_APPIMAGE=true
|
||||||
|
cell_pass "AppImage executable bit set: $APPIMAGE"
|
||||||
|
else
|
||||||
|
cell_fail "chmod +x failed on: $APPIMAGE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
BINARY="$APPIMAGE"
|
||||||
|
USE_APPIMAGE=true
|
||||||
|
cell_pass "AppImage already executable: $APPIMAGE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf " No AppImage supplied — running cargo build (debug)...\n"
|
||||||
|
if cargo build -p lumotia 2>&1; then
|
||||||
|
BINARY="$REPO_ROOT/target/debug/lumotia"
|
||||||
|
if [[ -f "$BINARY" ]]; then
|
||||||
|
cell_pass "cargo build succeeded. Binary: $BINARY"
|
||||||
|
else
|
||||||
|
cell_fail "cargo build succeeded but binary not found at: $BINARY"
|
||||||
|
BINARY=""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
cell_fail "cargo build -p lumotia failed. Fix compilation errors before smoke-testing."
|
||||||
|
BINARY=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 2/7: First-run ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "2/7" "First-run (process stays alive for 10 seconds)"
|
||||||
|
|
||||||
|
if [[ -z "$BINARY" ]]; then
|
||||||
|
cell_fail "Skipping — no valid binary from Cell 1."
|
||||||
|
else
|
||||||
|
# Launch in background; AppImages need DISPLAY or --no-sandbox workarounds on
|
||||||
|
# headless CI. We use LUMOTIA_SMOKE_MODE=1 so the binary can short-circuit GUI
|
||||||
|
# init when the env var is set (harmless if the binary ignores it). We also
|
||||||
|
# suppress stderr to avoid noise from WebKit/GLib on headless runs.
|
||||||
|
LUMOTIA_SMOKE_MODE=1 \
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 \
|
||||||
|
GDK_BACKEND=x11 \
|
||||||
|
DISPLAY="${DISPLAY:-:99}" \
|
||||||
|
"$BINARY" &>/tmp/lumotia-smoke-launch.log &
|
||||||
|
BINARY_PID=$!
|
||||||
|
|
||||||
|
printf " Waiting 10 seconds (PID %s)...\n" "$BINARY_PID"
|
||||||
|
ALIVE=true
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
sleep 1
|
||||||
|
if ! kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
ALIVE=false
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Terminate gracefully
|
||||||
|
if kill -0 "$BINARY_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$BINARY_PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
kill -KILL "$BINARY_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
wait "$BINARY_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
if $ALIVE; then
|
||||||
|
cell_pass "Binary stayed alive for 10 seconds without crash."
|
||||||
|
else
|
||||||
|
# A clean exit (code 0) during early startup is acceptable in headless mode
|
||||||
|
# (no display attached → Tauri exits non-zero but that's a display issue,
|
||||||
|
# not a crash). Check the log for a panic/SIGSEGV signature instead.
|
||||||
|
EXIT_LOG=/tmp/lumotia-smoke-launch.log
|
||||||
|
if grep -qiE 'panic|SIGSEGV|Segmentation fault|thread.*panicked' "$EXIT_LOG" 2>/dev/null; then
|
||||||
|
cell_fail "Binary crashed with panic/segfault within 10 seconds. See /tmp/lumotia-smoke-launch.log"
|
||||||
|
else
|
||||||
|
cell_pass "Binary exited cleanly (headless mode — no display). No panic in log. Treating as PASS."
|
||||||
|
printf " Log tail:\n"
|
||||||
|
tail -5 "$EXIT_LOG" 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cell 3/7: Capture ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "3/7" "Capture (MANUAL — audio loopback required)"
|
||||||
|
printf " MANUAL: Open the app, talk into the microphone for 5 seconds.\n"
|
||||||
|
printf " Confirm a live transcript appears in the dictation area.\n"
|
||||||
|
cell_manual "Audio capture requires a human tester with a live microphone."
|
||||||
|
|
||||||
|
# ── Cell 4/7: Cleanup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "4/7" "Cleanup (MANUAL — audio loopback required)"
|
||||||
|
printf " MANUAL: Stop recording. Confirm the cleaned transcript\n"
|
||||||
|
printf " appears beneath the raw transcript in the PostCaptureCard.\n"
|
||||||
|
cell_manual "Cleanup display requires a completed recording — needs human verification."
|
||||||
|
|
||||||
|
# ── Cell 5/7: Export ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "5/7" "Export (MANUAL — requires completed transcript)"
|
||||||
|
printf " MANUAL: From the PostCaptureCard or History, export the transcript\n"
|
||||||
|
printf " as Markdown via the native save dialog. Confirm the .md file\n"
|
||||||
|
printf " is created on disk with the expected content.\n"
|
||||||
|
cell_manual "Export requires an existing transcript — needs human verification."
|
||||||
|
|
||||||
|
# ── Cell 6/7: History search ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cell_header "6/7" "History search (MANUAL — requires at least one saved transcript)"
|
||||||
|
printf " MANUAL: Open History (sidebar). Type a word from your dictation\n"
|
||||||
|
printf " in the search box. Confirm the transcript appears in results.\n"
|
||||||
|
cell_manual "FTS5 search requires a saved transcript — needs human verification."
|
||||||
|
|
||||||
|
# ── Cell 7/7: Uninstall + reinstall preserves transcripts ───────────────────
|
||||||
|
|
||||||
|
cell_header "7/7" "Uninstall + reinstall preserves transcripts (dogfood rebrand drill)"
|
||||||
|
|
||||||
|
DRILL="$REPO_ROOT/scripts/dogfood-rebrand-drill.sh"
|
||||||
|
if [[ ! -f "$DRILL" ]]; then
|
||||||
|
cell_fail "scripts/dogfood-rebrand-drill.sh not found — restore it before smoke-testing."
|
||||||
|
elif [[ ! -x "$DRILL" ]]; then
|
||||||
|
cell_fail "scripts/dogfood-rebrand-drill.sh is not executable — run: chmod +x scripts/dogfood-rebrand-drill.sh"
|
||||||
|
else
|
||||||
|
if bash "$DRILL" >/tmp/lumotia-smoke-drill.log 2>&1; then
|
||||||
|
cell_pass "dogfood-rebrand-drill.sh passed — data-dir migration + preservation verified (8/8 probes)."
|
||||||
|
else
|
||||||
|
cell_fail "dogfood-rebrand-drill.sh reported failures. See /tmp/lumotia-smoke-drill.log for details."
|
||||||
|
tail -20 /tmp/lumotia-smoke-drill.log | sed 's/^/ /' || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TOTAL=$((CELL_PASS + CELL_FAIL + CELL_MANUAL))
|
||||||
|
|
||||||
|
printf "\n"
|
||||||
|
if [[ $CELL_FAIL -eq 0 ]]; then
|
||||||
|
printf "${GREEN}${BOLD}✓ Linux smoke-test partial-automation complete: %d/%d automated PASS, %d/%d require manual verification.${RESET}\n\n" \
|
||||||
|
"$CELL_PASS" "$TOTAL" "$CELL_MANUAL" "$TOTAL"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
printf "${RED}${BOLD}✗ Linux smoke-test: %d/%d automated PASS, %d/%d FAILED, %d/%d require manual verification.${RESET}\n\n" \
|
||||||
|
"$CELL_PASS" "$TOTAL" "$CELL_FAIL" "$TOTAL" "$CELL_MANUAL" "$TOTAL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
221
scripts/tag-day.sh
Executable file
221
scripts/tag-day.sh
Executable file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tag-day.sh — Lumotia tag-day orchestrator
|
||||||
|
#
|
||||||
|
# One command runs the entire morning-of release dance:
|
||||||
|
# 1. Run scripts/pre-tag-verify.sh (all 7 gates must be green)
|
||||||
|
# 2. Read the release version from src-tauri/Cargo.toml (or argv)
|
||||||
|
# 3. Check CHANGELOG.md for the 2026-MM-DD date placeholder — offer to fill
|
||||||
|
# it with today's date, prompt for a custom date, or abort
|
||||||
|
# 4. Print git status + proposed tag command; prompt y/n
|
||||||
|
# 5. git tag -a vX.Y.Z -m "Release vX.Y.Z" + git push origin vX.Y.Z
|
||||||
|
# 6. Watch CI via `gh run watch` if gh CLI is present + authenticated
|
||||||
|
# 7. Print smoke-test next steps + link to tester-onboarding-kit.md
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/tag-day.sh [vX.Y.Z]
|
||||||
|
#
|
||||||
|
# If the version argument is omitted the script reads it from
|
||||||
|
# [workspace.package].version in Cargo.toml.
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 tag created and pushed (or all steps completed cleanly)
|
||||||
|
# 1 user aborted or a gate failed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
info() { printf "${BOLD}%s${RESET}\n" "$*"; }
|
||||||
|
ok() { printf "${GREEN} ✓${RESET} %s\n" "$*"; }
|
||||||
|
warn() { printf "${YELLOW} ⚠${RESET} %s\n" "$*"; }
|
||||||
|
abort() { printf "\n${RED}${BOLD}✗ ABORTED: %s${RESET}\n\n" "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
# Usage: confirm "prompt text" → returns 0 if user enters y/Y, exits otherwise
|
||||||
|
local prompt="$1"
|
||||||
|
read -r -p "$prompt [y/N] " ans
|
||||||
|
case "$ans" in
|
||||||
|
y|Y) return 0 ;;
|
||||||
|
*) abort "User chose not to proceed." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
printf "\n${BOLD}╔══════════════════════════════════════════════╗${RESET}\n"
|
||||||
|
printf "${BOLD}║ Lumotia tag-day orchestrator ║${RESET}\n"
|
||||||
|
printf "${BOLD}╚══════════════════════════════════════════════╝${RESET}\n\n"
|
||||||
|
|
||||||
|
# ── Step 1: pre-tag verification ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Step 1/7 — Running scripts/pre-tag-verify.sh (all 7 gates must pass)..."
|
||||||
|
|
||||||
|
VERIFY="$REPO_ROOT/scripts/pre-tag-verify.sh"
|
||||||
|
if [[ ! -f "$VERIFY" ]]; then
|
||||||
|
abort "scripts/pre-tag-verify.sh not found. Restore it before running tag-day."
|
||||||
|
fi
|
||||||
|
if [[ ! -x "$VERIFY" ]]; then
|
||||||
|
abort "scripts/pre-tag-verify.sh is not executable. Run: chmod +x scripts/pre-tag-verify.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# pre-tag-verify.sh already exits non-zero on any failure; we let it print its
|
||||||
|
# own output so the user sees exactly what failed.
|
||||||
|
if ! bash "$VERIFY"; then
|
||||||
|
abort "pre-tag-verify.sh reported failures — see output above. Fix and re-run tag-day.sh."
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "All 7 pre-tag gates passed."
|
||||||
|
|
||||||
|
# ── Step 2: resolve version ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Step 2/7 — Resolving release version..."
|
||||||
|
|
||||||
|
if [[ $# -ge 1 && -n "$1" ]]; then
|
||||||
|
VERSION="${1#v}" # strip leading 'v' if supplied
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
ok "Version from argv: ${TAG}"
|
||||||
|
else
|
||||||
|
# Extract from [workspace.package].version in root Cargo.toml
|
||||||
|
VERSION=$(grep -A10 '^\[workspace\.package\]' Cargo.toml \
|
||||||
|
| grep '^version' \
|
||||||
|
| head -1 \
|
||||||
|
| grep -oP '"\K[^"]+' 2>/dev/null || true)
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
abort "Could not parse version from Cargo.toml [workspace.package]. Pass it as an argument: ./scripts/tag-day.sh v0.1.0"
|
||||||
|
fi
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
ok "Version from Cargo.toml: ${TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 3: CHANGELOG date check ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Step 3/7 — Checking CHANGELOG.md for date placeholder..."
|
||||||
|
|
||||||
|
if [[ ! -f CHANGELOG.md ]]; then
|
||||||
|
abort "CHANGELOG.md not found at repo root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
TODAY="$(date -u +%Y-%m-%d)"
|
||||||
|
|
||||||
|
if grep -qE '[0-9]{4}-MM-DD' CHANGELOG.md; then
|
||||||
|
printf "\n"
|
||||||
|
warn "CHANGELOG.md still has the date placeholder. Today is ${TODAY}."
|
||||||
|
printf "Replace placeholder with today's date? [y/N/abort] "
|
||||||
|
read -r changelog_ans
|
||||||
|
case "$changelog_ans" in
|
||||||
|
y|Y)
|
||||||
|
sed -i "s/[0-9]\{4\}-MM-DD/${TODAY}/g" CHANGELOG.md
|
||||||
|
ok "Replaced date placeholder in CHANGELOG.md with ${TODAY}."
|
||||||
|
# Verify replacement
|
||||||
|
if grep -qE '[0-9]{4}-MM-DD' CHANGELOG.md; then
|
||||||
|
abort "sed replacement did not remove all placeholders. Check CHANGELOG.md manually."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
n|N)
|
||||||
|
printf " Enter the release date to use (YYYY-MM-DD): "
|
||||||
|
read -r custom_date
|
||||||
|
if [[ ! "$custom_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||||
|
abort "Date '${custom_date}' is not in YYYY-MM-DD format."
|
||||||
|
fi
|
||||||
|
sed -i "s/[0-9]\{4\}-MM-DD/${custom_date}/g" CHANGELOG.md
|
||||||
|
ok "Replaced date placeholder in CHANGELOG.md with ${custom_date}."
|
||||||
|
if grep -qE '[0-9]{4}-MM-DD' CHANGELOG.md; then
|
||||||
|
abort "sed replacement did not remove all placeholders. Check CHANGELOG.md manually."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
abort|ABORT|a)
|
||||||
|
abort "User chose to abort at CHANGELOG date step."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
abort "Unrecognised response '${changelog_ans}'. Aborting. Valid responses: y / n / abort"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
ok "CHANGELOG.md has no date placeholder — ready."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 4: git status + confirm tag command ──────────────────────────────────
|
||||||
|
|
||||||
|
info "Step 4/7 — Reviewing git status and confirming tag..."
|
||||||
|
|
||||||
|
printf "\n── git status ───────────────────────────────────────────────────────────\n"
|
||||||
|
git status --short
|
||||||
|
printf "─────────────────────────────────────────────────────────────────────────\n\n"
|
||||||
|
|
||||||
|
printf "Proposed commands:\n"
|
||||||
|
printf " ${BOLD}git tag -a %s -m \"Release %s\"${RESET}\n" "$TAG" "$TAG"
|
||||||
|
printf " ${BOLD}git push origin %s${RESET}\n\n" "$TAG"
|
||||||
|
|
||||||
|
# Check if the tag already exists locally
|
||||||
|
if git tag --list "$TAG" | grep -q "$TAG"; then
|
||||||
|
warn "Tag ${TAG} already exists locally."
|
||||||
|
confirm "Delete the existing local tag and re-create it?"
|
||||||
|
git tag -d "$TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
confirm "Confirm: create tag ${TAG} and push to origin?"
|
||||||
|
|
||||||
|
# ── Step 5: tag + push ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Step 5/7 — Creating tag and pushing..."
|
||||||
|
|
||||||
|
git tag -a "$TAG" -m "Release ${TAG}"
|
||||||
|
ok "Tag ${TAG} created locally."
|
||||||
|
|
||||||
|
git push origin "$TAG"
|
||||||
|
ok "Tag ${TAG} pushed to origin."
|
||||||
|
|
||||||
|
# ── Step 6: CI watch (optional) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Step 6/7 — CI watch..."
|
||||||
|
|
||||||
|
GH_OK=false
|
||||||
|
if command -v gh &>/dev/null; then
|
||||||
|
if gh auth status &>/dev/null; then
|
||||||
|
GH_OK=true
|
||||||
|
else
|
||||||
|
warn "gh CLI found but not authenticated (gh auth status failed). Skipping CI watch."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "gh CLI not installed. Skipping CI watch."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $GH_OK; then
|
||||||
|
printf "\n Opening CI run for tag ${TAG}...\n"
|
||||||
|
# Wait a moment for the push to register with GitHub
|
||||||
|
sleep 3
|
||||||
|
# gh run watch will block until the run completes; Ctrl-C to detach.
|
||||||
|
if ! gh run watch --exit-status 2>&1; then
|
||||||
|
warn "CI run watch exited non-zero or was interrupted. Check manually:"
|
||||||
|
printf " https://github.com/jakeadriansames/lumotia/actions\n\n"
|
||||||
|
else
|
||||||
|
ok "CI completed successfully."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf "\n Open this URL to watch CI:\n"
|
||||||
|
printf " ${BOLD}https://github.com/jakeadriansames/lumotia/actions${RESET}\n\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 7: next steps ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Step 7/7 — Smoke-test next steps"
|
||||||
|
|
||||||
|
printf "\n"
|
||||||
|
printf " Once CI has produced artefacts for all platforms:\n\n"
|
||||||
|
printf " 1. Download the Linux AppImage from the CI release artefacts.\n"
|
||||||
|
printf " 2. Run the smoke harness against it:\n"
|
||||||
|
printf " ${BOLD}./scripts/smoke-linux.sh /path/to/lumotia-%s-linux-x86_64.AppImage${RESET}\n" "$VERSION"
|
||||||
|
printf " 3. Complete the manual cells (Capture, Cleanup, Export, History search).\n"
|
||||||
|
printf " 4. Repeat on macOS / Windows if testers are available.\n"
|
||||||
|
printf " 5. Fill in the smoke-test matrix in docs/release/v0.1-checklist.md.\n\n"
|
||||||
|
printf " Tester onboarding kit:\n"
|
||||||
|
printf " ${BOLD}docs/release/tester-onboarding-kit.md${RESET}\n\n"
|
||||||
|
|
||||||
|
printf "${GREEN}${BOLD}✓ Tag day complete. Tag %s is live on origin.${RESET}\n\n" "$TAG"
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lumotia"
|
name = "lumotia"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
description = "Lumotia — Think out loud"
|
description = "Lumotia — Think out loud"
|
||||||
authors = ["CORBEL Ltd"]
|
authors = ["CORBEL Ltd"]
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "lumotia_lib"
|
name = "lumotia_lib"
|
||||||
@@ -61,7 +63,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tracing-appender = "0.2.5"
|
tracing-appender = "0.2.5"
|
||||||
|
|
||||||
# Async runtime (spawn_blocking for inference)
|
# Async runtime (spawn_blocking for inference)
|
||||||
tokio = { version = "1", features = ["rt", "sync"] }
|
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||||
arboard = "3.6.1"
|
arboard = "3.6.1"
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ arboard = "3.6.1"
|
|||||||
# migrate / any / json which this crate doesn't use. Only names SqlitePool.
|
# migrate / any / json which this crate doesn't use. Only names SqlitePool.
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
zip = "8.6.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Phase 9 fs::write_text_file_cmd tests use a temp directory so we don't
|
# Phase 9 fs::write_text_file_cmd tests use a temp directory so we don't
|
||||||
@@ -104,6 +107,10 @@ webkit2gtk = "2.0"
|
|||||||
# transitively depends on (GTK 3).
|
# transitively depends on (GTK 3).
|
||||||
gtk = "0.18"
|
gtk = "0.18"
|
||||||
gdk = "0.18"
|
gdk = "0.18"
|
||||||
|
# KI-02: systemd-logind idle inhibit via org.freedesktop.login1.Manager.Inhibit.
|
||||||
|
# The blocking API avoids spawning an extra async runtime inside an already-async
|
||||||
|
# Tauri command handler (spawn_blocking wraps the call site).
|
||||||
|
zbus = { version = "5", default-features = false, features = ["blocking"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6.4"
|
objc2 = "0.6.4"
|
||||||
@@ -113,3 +120,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = ["s
|
|||||||
# Phase 4 TTS: PowerShell -EncodedCommand expects UTF-16-LE base64.
|
# Phase 4 TTS: PowerShell -EncodedCommand expects UTF-16-LE base64.
|
||||||
# Windows-only because the other platforms' TTS paths pass text via argv.
|
# Windows-only because the other platforms' TTS paths pass text via argv.
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
# KI-03: SetThreadExecutionState for sleep prevention during recording.
|
||||||
|
# The `windows` crate is already a transitive dep (via tauri); listing it
|
||||||
|
# here explicitly pulls in only the Win32_System_Power feature we need.
|
||||||
|
windows = { version = "0.62", features = ["Win32_System_Power"] }
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ pub(crate) const MAX_CLIPBOARD_BYTES: usize = 1024 * 1024;
|
|||||||
/// viewer and transcription preview both have legitimate "copy raw text"
|
/// viewer and transcription preview both have legitimate "copy raw text"
|
||||||
/// buttons; mirror the secondary-windows capability grant in
|
/// buttons; mirror the secondary-windows capability grant in
|
||||||
/// `src-tauri/capabilities/secondary-windows.json` so the IPC trust
|
/// `src-tauri/capabilities/secondary-windows.json` so the IPC trust
|
||||||
/// boundary and the permission set stay in lock-step.
|
/// boundary and the permission set stay in lock-step. The mirror
|
||||||
const CLIPBOARD_ALLOWED_WINDOWS: &[&str] =
|
/// invariant is pinned by
|
||||||
|
/// `commands::security::tests_capability_mirror::allowlists_match_capability_jsons`.
|
||||||
|
pub(crate) const CLIPBOARD_ALLOWED_WINDOWS: &[&str] =
|
||||||
&["main", "transcript-viewer", "transcription-preview"];
|
&["main", "transcript-viewer", "transcription-preview"];
|
||||||
|
|
||||||
/// Copy text to the system clipboard via arboard. Restricted to the
|
/// Copy text to the system clipboard via arboard. Restricted to the
|
||||||
|
|||||||
@@ -6,11 +6,16 @@
|
|||||||
//! - The manual report bundler shows the user exactly what would be
|
//! - The manual report bundler shows the user exactly what would be
|
||||||
//! shared and lets them choose to copy/save/email it.
|
//! shared and lets them choose to copy/save/email it.
|
||||||
//! - No remote endpoint, no Sentry, no opt-out telemetry.
|
//! - No remote endpoint, no Sentry, no opt-out telemetry.
|
||||||
|
//!
|
||||||
|
//! The `generate_diagnostic_bundle` command produces a zip archive
|
||||||
|
//! containing logs, crash dumps, redacted preferences, and system info.
|
||||||
|
//! It NEVER includes audio files, transcripts, the SQLite database, or
|
||||||
|
//! any `.env*` file — a deny-list is applied to every candidate path.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::panic;
|
use std::panic;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -532,3 +537,678 @@ pub async fn save_diagnostic_report(
|
|||||||
fs::write(&path, &report).map_err(|e| format!("write file: {e}"))?;
|
fs::write(&path, &report).map_err(|e| format!("write file: {e}"))?;
|
||||||
Ok(path.to_string_lossy().to_string())
|
Ok(path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Diagnostic bundle (zip archive) — Task 3.8
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Summary returned to the frontend after bundling.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DiagnosticBundleSummary {
|
||||||
|
/// Absolute path to the zip that was written.
|
||||||
|
pub path: String,
|
||||||
|
/// Size of the zip in bytes.
|
||||||
|
pub bytes: u64,
|
||||||
|
/// Human-readable labels for sections that made it into the zip.
|
||||||
|
pub included: Vec<String>,
|
||||||
|
/// Human-readable labels for sections deliberately excluded.
|
||||||
|
pub excluded: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deny-list: extensions and path components that MUST NEVER appear in a
|
||||||
|
/// diagnostic bundle. Checked case-insensitively against every candidate
|
||||||
|
/// path before any bytes are written to the zip.
|
||||||
|
///
|
||||||
|
/// Rules:
|
||||||
|
/// - Audio extensions: wav, mp3, opus, ogg, flac
|
||||||
|
/// - Transcript / capture directories: transcripts, captures
|
||||||
|
/// - The SQLite database (transcripts.db and its WAL/SHM siblings)
|
||||||
|
/// - Dot-env files (.env, .env.local, …)
|
||||||
|
/// - Anything under an `audio/` path component
|
||||||
|
const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "opus", "ogg", "flac"];
|
||||||
|
const DENIED_PATH_COMPONENTS: &[&str] = &["transcripts", "captures", "audio"];
|
||||||
|
|
||||||
|
/// Return `true` when the file at `path` must be excluded from any bundle.
|
||||||
|
/// This is the contract; it is tested by the unit tests below.
|
||||||
|
pub(crate) fn is_denied(path: &Path) -> bool {
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
let path_lower = path_str.to_ascii_lowercase();
|
||||||
|
|
||||||
|
// 1. Audio by extension
|
||||||
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||||
|
if AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Denied path components (directory names that must not appear)
|
||||||
|
for component in path.components() {
|
||||||
|
if let std::path::Component::Normal(c) = component {
|
||||||
|
let c_lower = c.to_string_lossy().to_ascii_lowercase();
|
||||||
|
if DENIED_PATH_COMPONENTS.contains(&c_lower.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. SQLite database files (*.db, *.db-wal, *.db-shm)
|
||||||
|
if path_lower.ends_with(".db")
|
||||||
|
|| path_lower.ends_with(".db-wal")
|
||||||
|
|| path_lower.ends_with(".db-shm")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Dot-env files
|
||||||
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
let name_lower = name.to_ascii_lowercase();
|
||||||
|
if name_lower == ".env" || name_lower.starts_with(".env.") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Literal "transcripts.db" anywhere in the path (belt-and-suspenders)
|
||||||
|
if path_lower.contains("transcripts.db") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redact preference JSON before including in the bundle.
|
||||||
|
///
|
||||||
|
/// Secrets are any field whose key contains:
|
||||||
|
/// api_key, token, secret, password, credential (case-insensitive)
|
||||||
|
/// Vocabulary entries are replaced with `[redacted]` per the spec.
|
||||||
|
fn redact_preferences_for_bundle(raw: &str) -> String {
|
||||||
|
fn is_secret_key(key: &str) -> bool {
|
||||||
|
let k = key.to_ascii_lowercase();
|
||||||
|
k.contains("api_key")
|
||||||
|
|| k.contains("token")
|
||||||
|
|| k.contains("secret")
|
||||||
|
|| k.contains("password")
|
||||||
|
|| k.contains("credential")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_value(value: &mut serde_json::Value, key_hint: Option<&str>) {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
for (k, v) in map.iter_mut() {
|
||||||
|
if is_secret_key(k) {
|
||||||
|
*v = serde_json::Value::String("[redacted]".to_string());
|
||||||
|
} else {
|
||||||
|
redact_value(v, Some(k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(items) => {
|
||||||
|
// Vocabulary-style arrays: if the parent key looks like vocabulary,
|
||||||
|
// redact each entry individually.
|
||||||
|
let is_vocab = key_hint
|
||||||
|
.map(|k| {
|
||||||
|
let k = k.to_ascii_lowercase();
|
||||||
|
k.contains("vocab") || k.contains("dictionary") || k.contains("terms")
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if is_vocab {
|
||||||
|
for item in items.iter_mut() {
|
||||||
|
*item = serde_json::Value::String("[redacted]".to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for item in items.iter_mut() {
|
||||||
|
redact_value(item, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strings: mask home-dir paths (same logic as existing redact_home)
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
*s = redact_home(s);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::from_str::<serde_json::Value>(raw) {
|
||||||
|
Ok(mut v) => {
|
||||||
|
redact_value(&mut v, None);
|
||||||
|
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
Err(_) => "_preferences could not be parsed as JSON_".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a single in-memory byte slice to the zip under `zip_path`.
|
||||||
|
/// Skips silently if `zip_path` is denied (should not happen for
|
||||||
|
/// synthesised files, but belt-and-suspenders).
|
||||||
|
fn zip_add_bytes(
|
||||||
|
zip: &mut zip::ZipWriter<fs::File>,
|
||||||
|
zip_path: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
zip.start_file(zip_path, options)
|
||||||
|
.map_err(|e| format!("zip start_file({zip_path}): {e}"))?;
|
||||||
|
zip.write_all(data)
|
||||||
|
.map_err(|e| format!("zip write({zip_path}): {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a real file from `src_path` to the zip under `zip_path`.
|
||||||
|
/// Applies the deny-list; logs a warning and skips if denied.
|
||||||
|
/// Returns `true` if the file was added, `false` if skipped.
|
||||||
|
fn zip_add_file(
|
||||||
|
zip: &mut zip::ZipWriter<fs::File>,
|
||||||
|
src_path: &Path,
|
||||||
|
zip_path: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
if is_denied(src_path) {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %src_path.display(),
|
||||||
|
"diagnostic bundle: deny-list match — skipping file"
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = fs::read(src_path).map_err(|e| format!("read {}: {e}", src_path.display()))?;
|
||||||
|
zip_add_bytes(zip, zip_path, &data)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command: produce a zip diagnostic bundle at `output_path`.
|
||||||
|
///
|
||||||
|
/// The caller (frontend) is responsible for obtaining the output path via
|
||||||
|
/// the Tauri dialog plugin before invoking this command.
|
||||||
|
///
|
||||||
|
/// Privacy contract:
|
||||||
|
/// - NEVER includes audio, transcripts, the SQLite database, or .env files.
|
||||||
|
/// - Preferences are redacted before inclusion (secrets + vocabulary).
|
||||||
|
/// - Home-directory paths are replaced with `~`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn generate_diagnostic_bundle(
|
||||||
|
output_path: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DiagnosticBundleSummary, String> {
|
||||||
|
let dest = PathBuf::from(&output_path);
|
||||||
|
|
||||||
|
// Ensure parent directory exists.
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = fs::File::create(&dest).map_err(|e| format!("create bundle file: {e}"))?;
|
||||||
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
|
|
||||||
|
let mut included: Vec<String> = Vec::new();
|
||||||
|
// Excluded is always the same — audio + transcripts are always denied.
|
||||||
|
let excluded: Vec<String> = vec!["transcripts".to_string(), "audio".to_string()];
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 1. system_info.txt
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
let data_dir_display = redact_home(&app_data_dir().display().to_string());
|
||||||
|
let now_secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let system_info = format!(
|
||||||
|
"Lumotia diagnostic bundle\n\
|
||||||
|
========================\n\
|
||||||
|
App version: {ver}\n\
|
||||||
|
OS: {os}\n\
|
||||||
|
Arch: {arch}\n\
|
||||||
|
Data dir: {data_dir}\n\
|
||||||
|
Generated: {ts} (UTC epoch seconds)\n\
|
||||||
|
Rust: {rust_ver}\n",
|
||||||
|
ver = LUMOTIA_VERSION,
|
||||||
|
os = std::env::consts::OS,
|
||||||
|
arch = std::env::consts::ARCH,
|
||||||
|
data_dir = data_dir_display,
|
||||||
|
ts = now_secs,
|
||||||
|
rust_ver = option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown"),
|
||||||
|
);
|
||||||
|
zip_add_bytes(&mut zip, "system_info.txt", system_info.as_bytes())?;
|
||||||
|
included.push("system_info".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2. logs/ — last 7 days of log files, capped at 5 MB total
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024; // 5 MB
|
||||||
|
const MAX_LOG_DAYS: usize = 7;
|
||||||
|
|
||||||
|
let log_dir = logs_dir();
|
||||||
|
let mut log_count: usize = 0;
|
||||||
|
|
||||||
|
if log_dir.is_dir() {
|
||||||
|
// Collect log files sorted newest-first by mtime.
|
||||||
|
let mut log_files: Vec<(u64, PathBuf)> = Vec::new();
|
||||||
|
if let Ok(entries) = fs::read_dir(&log_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Deny-list check on each candidate
|
||||||
|
if is_denied(&path) {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
"diagnostic bundle: deny-list match in logs dir — skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mtime = entry
|
||||||
|
.metadata()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
log_files.push((mtime, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort newest-first
|
||||||
|
log_files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
|
||||||
|
let mut bytes_so_far: u64 = 0;
|
||||||
|
for (_, path) in log_files.iter().take(MAX_LOG_DAYS) {
|
||||||
|
let file_size = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
if bytes_so_far + file_size > MAX_LOG_BYTES {
|
||||||
|
tracing::info!(
|
||||||
|
"diagnostic bundle: log cap reached at {} bytes; stopping",
|
||||||
|
bytes_so_far
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("log");
|
||||||
|
let zip_path = format!("logs/{name}");
|
||||||
|
match zip_add_file(&mut zip, path, &zip_path) {
|
||||||
|
Ok(true) => {
|
||||||
|
bytes_so_far += file_size;
|
||||||
|
log_count += 1;
|
||||||
|
}
|
||||||
|
Ok(false) => {} // denied — already warned
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"diagnostic bundle: could not add log {}: {e}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if log_count > 0 {
|
||||||
|
included.push(format!("logs ({log_count} files)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 3. crashes/ — most recent 3 crash dumps
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const MAX_CRASHES: usize = 3;
|
||||||
|
let crash_dir = crashes_dir();
|
||||||
|
let mut crash_count: usize = 0;
|
||||||
|
|
||||||
|
if crash_dir.is_dir() {
|
||||||
|
let mut crash_files: Vec<(u64, PathBuf)> = Vec::new();
|
||||||
|
if let Ok(entries) = fs::read_dir(&crash_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("crash") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_denied(&path) {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
"diagnostic bundle: deny-list match in crashes dir — skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mtime = entry
|
||||||
|
.metadata()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
crash_files.push((mtime, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crash_files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
|
||||||
|
for (_, path) in crash_files.iter().take(MAX_CRASHES) {
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("crash");
|
||||||
|
let zip_path = format!("crashes/{name}");
|
||||||
|
match zip_add_file(&mut zip, path, &zip_path) {
|
||||||
|
Ok(true) => crash_count += 1,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"diagnostic bundle: could not add crash {}: {e}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if crash_count > 0 {
|
||||||
|
included.push(format!("crash_dumps ({crash_count})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 4. preferences-redacted.json
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
let prefs_raw = lumotia_storage::get_setting(&state.db, "lumotia_preferences")
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let redacted = match prefs_raw {
|
||||||
|
Some(raw) => redact_preferences_for_bundle(&raw),
|
||||||
|
None => "{}".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
zip_add_bytes(&mut zip, "preferences-redacted.json", redacted.as_bytes())?;
|
||||||
|
included.push("preferences (redacted)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 5. metadata.json
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
let now_secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"generated_at": now_secs,
|
||||||
|
"lumotia_version": LUMOTIA_VERSION,
|
||||||
|
"included": included,
|
||||||
|
"excluded": excluded,
|
||||||
|
"redaction_policy": "no audio, no transcripts, vocabulary redacted, secrets redacted"
|
||||||
|
});
|
||||||
|
let metadata_bytes =
|
||||||
|
serde_json::to_vec_pretty(&metadata).unwrap_or_else(|_| b"{}".to_vec());
|
||||||
|
zip_add_bytes(&mut zip, "metadata.json", &metadata_bytes)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalise zip
|
||||||
|
zip.finish().map_err(|e| format!("zip finalise: {e}"))?;
|
||||||
|
|
||||||
|
// Report bundle size
|
||||||
|
let bytes = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
path = %dest.display(),
|
||||||
|
bytes,
|
||||||
|
"diagnostic bundle written"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(DiagnosticBundleSummary {
|
||||||
|
path: output_path,
|
||||||
|
bytes,
|
||||||
|
included,
|
||||||
|
excluded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unit tests for the deny-list and preference redaction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bundle_tests {
|
||||||
|
use super::*;
|
||||||
|
/// Helper: create a temp dir, populate with fake files, run the deny-list,
|
||||||
|
/// and return (allowed_names, denied_names).
|
||||||
|
fn classify(files: &[&str]) -> (Vec<String>, Vec<String>) {
|
||||||
|
let mut allowed = Vec::new();
|
||||||
|
let mut denied = Vec::new();
|
||||||
|
for name in files {
|
||||||
|
let p = PathBuf::from(name);
|
||||||
|
if is_denied(&p) {
|
||||||
|
denied.push(name.to_string());
|
||||||
|
} else {
|
||||||
|
allowed.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(allowed, denied)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_audio_extensions() {
|
||||||
|
let (allowed, denied) = classify(&[
|
||||||
|
"lumotia.log",
|
||||||
|
"recording.wav",
|
||||||
|
"clip.mp3",
|
||||||
|
"capture.opus",
|
||||||
|
"sound.ogg",
|
||||||
|
"track.flac",
|
||||||
|
"system_info.txt",
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
denied.contains(&"recording.wav".to_string()),
|
||||||
|
"wav must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.contains(&"clip.mp3".to_string()),
|
||||||
|
"mp3 must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.contains(&"capture.opus".to_string()),
|
||||||
|
"opus must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.contains(&"sound.ogg".to_string()),
|
||||||
|
"ogg must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.contains(&"track.flac".to_string()),
|
||||||
|
"flac must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
allowed.contains(&"lumotia.log".to_string()),
|
||||||
|
"log must be allowed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
allowed.contains(&"system_info.txt".to_string()),
|
||||||
|
"txt must be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_transcript_path_component() {
|
||||||
|
let (allowed, denied) = classify(&[
|
||||||
|
"/home/user/.local/share/lumotia/transcripts/2024-01-01.txt",
|
||||||
|
"/home/user/.local/share/lumotia/logs/lumotia.log",
|
||||||
|
"/home/user/audio/recording.flac",
|
||||||
|
"/home/user/.local/share/lumotia/captures/cap001.bin",
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
denied.iter().any(|p| p.contains("transcripts")),
|
||||||
|
"transcripts dir must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.iter().any(|p| p.contains("captures")),
|
||||||
|
"captures dir must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.iter().any(|p| p.contains("audio")),
|
||||||
|
"audio dir must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
allowed.iter().any(|p| p.contains("logs")),
|
||||||
|
"logs dir must be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_sqlite_database() {
|
||||||
|
let (_, denied) = classify(&[
|
||||||
|
"/data/lumotia.db",
|
||||||
|
"/data/lumotia.db-wal",
|
||||||
|
"/data/lumotia.db-shm",
|
||||||
|
"/data/transcripts.db",
|
||||||
|
]);
|
||||||
|
assert_eq!(denied.len(), 4, "all db files must be denied: {denied:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_dotenv_files() {
|
||||||
|
let (allowed, denied) = classify(&[
|
||||||
|
".env",
|
||||||
|
".env.local",
|
||||||
|
".env.production",
|
||||||
|
"env.txt", // should be allowed
|
||||||
|
"my.env.backup", // should be allowed (doesn't start with .env)
|
||||||
|
]);
|
||||||
|
assert!(denied.contains(&".env".to_string()), ".env must be denied");
|
||||||
|
assert!(
|
||||||
|
denied.contains(&".env.local".to_string()),
|
||||||
|
".env.local must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.contains(&".env.production".to_string()),
|
||||||
|
".env.production must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
allowed.contains(&"env.txt".to_string()),
|
||||||
|
"env.txt must be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferences_secret_keys_redacted() {
|
||||||
|
let raw = serde_json::json!({
|
||||||
|
"display_name": "Jake",
|
||||||
|
"api_key": "sk-supersecret",
|
||||||
|
"auth_token": "tok_abc123",
|
||||||
|
"password": "hunter2",
|
||||||
|
"some_credential": "cred_xyz",
|
||||||
|
"theme": "dark"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let redacted: serde_json::Value =
|
||||||
|
serde_json::from_str(&redact_preferences_for_bundle(&raw)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
redacted["api_key"], "[redacted]",
|
||||||
|
"api_key must be redacted"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
redacted["auth_token"], "[redacted]",
|
||||||
|
"token must be redacted"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
redacted["password"], "[redacted]",
|
||||||
|
"password must be redacted"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
redacted["some_credential"], "[redacted]",
|
||||||
|
"credential must be redacted"
|
||||||
|
);
|
||||||
|
// Non-secret fields must survive
|
||||||
|
assert_eq!(
|
||||||
|
redacted["display_name"], "Jake",
|
||||||
|
"display_name must survive"
|
||||||
|
);
|
||||||
|
assert_eq!(redacted["theme"], "dark", "theme must survive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferences_vocabulary_entries_redacted() {
|
||||||
|
let raw = serde_json::json!({
|
||||||
|
"vocab": ["CORBEL", "Lumotia", "Jake"],
|
||||||
|
"user_dictionary": ["word1", "word2"],
|
||||||
|
"settings_list": ["keep_me"]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let redacted: serde_json::Value =
|
||||||
|
serde_json::from_str(&redact_preferences_for_bundle(&raw)).unwrap();
|
||||||
|
|
||||||
|
// Vocabulary arrays must be fully redacted
|
||||||
|
let vocab = redacted["vocab"].as_array().unwrap();
|
||||||
|
assert!(
|
||||||
|
vocab.iter().all(|v| v == "[redacted]"),
|
||||||
|
"vocab entries must be redacted: {vocab:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let dict = redacted["user_dictionary"].as_array().unwrap();
|
||||||
|
assert!(
|
||||||
|
dict.iter().all(|v| v == "[redacted]"),
|
||||||
|
"user_dictionary entries must be redacted: {dict:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-vocabulary arrays survive
|
||||||
|
let settings = redacted["settings_list"].as_array().unwrap();
|
||||||
|
assert_eq!(settings[0], "keep_me", "settings_list must survive");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end: write a real zip to a tempfile with a fake dir tree.
|
||||||
|
/// Verify that:
|
||||||
|
/// - `system_info.txt` is present
|
||||||
|
/// - `metadata.json` is present
|
||||||
|
/// - A fake `.wav` is NOT in the zip
|
||||||
|
/// - A fake `transcript.txt` inside a `transcripts/` path is NOT in the zip
|
||||||
|
/// - A fake log file IS in the zip (via zip_add_bytes — this tests the
|
||||||
|
/// deny-list path directly)
|
||||||
|
#[test]
|
||||||
|
fn deny_list_in_zip_add_file() {
|
||||||
|
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
|
||||||
|
let wav_path = tmp.path().join("test_recording.wav");
|
||||||
|
let log_path = tmp.path().join("lumotia.log");
|
||||||
|
let transcript_dir = tmp.path().join("transcripts");
|
||||||
|
fs::create_dir_all(&transcript_dir).unwrap();
|
||||||
|
let transcript_path = transcript_dir.join("my_note.txt");
|
||||||
|
|
||||||
|
fs::write(&wav_path, b"RIFF fake wav data").unwrap();
|
||||||
|
fs::write(&log_path, b"2024-01-01 INFO test log line").unwrap();
|
||||||
|
fs::write(&transcript_path, b"Top secret transcript").unwrap();
|
||||||
|
|
||||||
|
let out_path = tmp.path().join("bundle.zip");
|
||||||
|
let zip_file = fs::File::create(&out_path).unwrap();
|
||||||
|
let mut zip = zip::ZipWriter::new(zip_file);
|
||||||
|
|
||||||
|
// wav — must be denied
|
||||||
|
let wav_added = zip_add_file(&mut zip, &wav_path, "test_recording.wav").unwrap();
|
||||||
|
assert!(!wav_added, "wav must not be added");
|
||||||
|
|
||||||
|
// transcript inside transcripts/ — must be denied
|
||||||
|
let tx_added = zip_add_file(&mut zip, &transcript_path, "transcripts/my_note.txt").unwrap();
|
||||||
|
assert!(!tx_added, "transcript must not be added");
|
||||||
|
|
||||||
|
// log file — must be allowed
|
||||||
|
let log_added = zip_add_file(&mut zip, &log_path, "logs/lumotia.log").unwrap();
|
||||||
|
assert!(log_added, "log must be added");
|
||||||
|
|
||||||
|
zip.finish().unwrap();
|
||||||
|
|
||||||
|
// Verify zip contents
|
||||||
|
let zip_file = fs::File::open(&out_path).unwrap();
|
||||||
|
let mut archive = zip::ZipArchive::new(zip_file).unwrap();
|
||||||
|
let names: Vec<String> = (0..archive.len())
|
||||||
|
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
names.contains(&"logs/lumotia.log".to_string()),
|
||||||
|
"log must be in zip: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!names.contains(&"test_recording.wav".to_string()),
|
||||||
|
"wav must NOT be in zip: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!names.contains(&"transcripts/my_note.txt".to_string()),
|
||||||
|
"transcript must NOT be in zip: {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,33 +72,58 @@ fn allowed_export_bases(app: &tauri::AppHandle) -> Vec<PathBuf> {
|
|||||||
bases
|
bases
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the requested write target. The target's PARENT directory
|
/// Resolve the requested write target. The canonicalised path must sit
|
||||||
/// must already exist (we canonicalise the parent, then re-join the
|
/// inside one of `bases`.
|
||||||
/// filename) and the canonicalised path must sit inside one of `bases`.
|
|
||||||
///
|
///
|
||||||
/// We canonicalise the parent rather than the full path because the
|
/// Two-mode canonicalisation:
|
||||||
/// file itself typically does not exist yet — canonicalise() returns
|
///
|
||||||
/// NotFound in that case on Linux/macOS. The parent must exist for
|
/// - **If the target already exists**, canonicalise the WHOLE path. This
|
||||||
/// `tokio::fs::write` to succeed anyway, so checking it is no extra
|
/// follows any symlink at the target itself, so a symlink that sits
|
||||||
/// cost.
|
/// inside an allowlisted base but points outside it gets rejected
|
||||||
|
/// here. `tokio::fs::write` follows symlinks on open(2), so without
|
||||||
|
/// this branch a symlink at `~/Downloads/notes.md -> ~/.bashrc` would
|
||||||
|
/// pass the containment check (the path string is inside Downloads)
|
||||||
|
/// and the subsequent write would silently land in `~/.bashrc`.
|
||||||
|
/// Trust-1 audit residual closed in Phase B.5 (2026-05-14); parallels
|
||||||
|
/// `validate_output_folder`'s full-path-canonicalize in
|
||||||
|
/// `commands/audio.rs` (Trust-2).
|
||||||
|
///
|
||||||
|
/// - **If the target does not yet exist** (the typical save-dialog
|
||||||
|
/// case), canonicalise the parent and re-join the filename. The
|
||||||
|
/// parent must exist for `tokio::fs::write` to succeed anyway, so
|
||||||
|
/// checking it is no extra cost.
|
||||||
pub(crate) fn resolve_export_path(path: &Path, bases: &[PathBuf]) -> Result<PathBuf, String> {
|
pub(crate) fn resolve_export_path(path: &Path, bases: &[PathBuf]) -> Result<PathBuf, String> {
|
||||||
let parent = path.parent().ok_or_else(|| {
|
let canon_path = match std::fs::canonicalize(path) {
|
||||||
format!(
|
Ok(canon) => canon,
|
||||||
"Refusing to write {}: path has no parent directory.",
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
path.display()
|
let parent = path.parent().ok_or_else(|| {
|
||||||
)
|
format!(
|
||||||
})?;
|
"Refusing to write {}: path has no parent directory.",
|
||||||
let file_name = path
|
path.display()
|
||||||
.file_name()
|
)
|
||||||
.ok_or_else(|| format!("Refusing to write {}: path has no filename.", path.display()))?;
|
})?;
|
||||||
|
let file_name = path.file_name().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Refusing to write {}: path has no filename.",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let canon_parent = std::fs::canonicalize(parent).map_err(|e| {
|
let canon_parent = std::fs::canonicalize(parent).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Refusing to write {}: cannot resolve parent dir ({e}).",
|
"Refusing to write {}: cannot resolve parent dir ({e}).",
|
||||||
path.display()
|
path.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let canon_path = canon_parent.join(file_name);
|
canon_parent.join(file_name)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Refusing to write {}: cannot resolve path ({e}).",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !is_inside_any_base(&canon_path, bases) {
|
if !is_inside_any_base(&canon_path, bases) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -206,4 +231,65 @@ mod tests {
|
|||||||
assert!(is_inside_any_base(&a.join("nested/x.md"), &bases));
|
assert!(is_inside_any_base(&a.join("nested/x.md"), &bases));
|
||||||
assert!(!is_inside_any_base(Path::new("/tmp/other/x.md"), &bases));
|
assert!(!is_inside_any_base(Path::new("/tmp/other/x.md"), &bases));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase B.5 audit regression (2026-05-14). The Trust-1 fix
|
||||||
|
/// canonicalised only the parent of the target path because the file
|
||||||
|
/// typically does not exist yet. But if a symlink ALREADY exists at
|
||||||
|
/// the target path pointing outside the allowlisted base,
|
||||||
|
/// `tokio::fs::write` follows it on `File::create -> open(2)` and
|
||||||
|
/// silently writes through to the outside target. The path-string
|
||||||
|
/// containment check passed because the SYMLINK lives inside the
|
||||||
|
/// base, even though the symlink's target does not.
|
||||||
|
///
|
||||||
|
/// The fix canonicalises the full path when it exists (so the
|
||||||
|
/// symlink resolves before the containment check) and only falls
|
||||||
|
/// back to parent-canonicalize on NotFound. This regression test
|
||||||
|
/// pins that contract.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn rejects_symlink_target_outside_allowlist() {
|
||||||
|
let (_keep_base, base) = tempdir_canon("base");
|
||||||
|
let (_keep_out, outside) = tempdir_canon("outside");
|
||||||
|
// Real, writable file outside the base — the symlink target.
|
||||||
|
let outside_target = outside.join("victim.txt");
|
||||||
|
std::fs::write(&outside_target, b"original").expect("seed outside target");
|
||||||
|
|
||||||
|
// Inside-base symlink pointing to the outside file.
|
||||||
|
let symlink_path = base.join("notes.md");
|
||||||
|
std::os::unix::fs::symlink(&outside_target, &symlink_path)
|
||||||
|
.expect("create symlink inside base pointing outside");
|
||||||
|
|
||||||
|
let bases = vec![base.clone()];
|
||||||
|
let result = resolve_export_path(&symlink_path, &bases);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"symlink target outside allowlist must be rejected, got {result:?}"
|
||||||
|
);
|
||||||
|
let err = result.err().unwrap();
|
||||||
|
assert!(
|
||||||
|
err.contains("outside the allowed export directories"),
|
||||||
|
"unexpected error shape: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symmetric to the rejection above: a symlink whose target stays
|
||||||
|
/// inside the same base must still be accepted, so legitimate users
|
||||||
|
/// of in-base symlinks (e.g. a tilde-expansion alias inside the base)
|
||||||
|
/// are not regressed.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn accepts_symlink_target_inside_allowlist() {
|
||||||
|
let (_keep, base) = tempdir_canon("base");
|
||||||
|
let real_path = base.join("real.md");
|
||||||
|
std::fs::write(&real_path, b"original").expect("seed real target");
|
||||||
|
|
||||||
|
let symlink_path = base.join("alias.md");
|
||||||
|
std::os::unix::fs::symlink(&real_path, &symlink_path)
|
||||||
|
.expect("create in-base alias symlink");
|
||||||
|
|
||||||
|
let bases = vec![base.clone()];
|
||||||
|
let resolved = resolve_export_path(&symlink_path, &bases)
|
||||||
|
.expect("in-base symlink must resolve and pass");
|
||||||
|
assert!(resolved.starts_with(&base));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,9 +427,7 @@ impl LiveSessionRuntime {
|
|||||||
// the dimensions are real. Validation-window drops can fire
|
// the dimensions are real. Validation-window drops can fire
|
||||||
// before any chunk reaches `process_audio_chunk`; they get
|
// before any chunk reaches `process_audio_chunk`; they get
|
||||||
// attributed at the next reconciliation once we know the rate.
|
// attributed at the next reconciliation once we know the rate.
|
||||||
if self.state.last_chunk_sample_rate == 0
|
if self.state.last_chunk_sample_rate == 0 || self.state.last_chunk_samples_per_chan == 0 {
|
||||||
|| self.state.last_chunk_samples_per_chan == 0
|
|
||||||
{
|
|
||||||
// Roll back the consumed delta so we re-observe it once
|
// Roll back the consumed delta so we re-observe it once
|
||||||
// we have dimensions to convert it with.
|
// we have dimensions to convert it with.
|
||||||
self.state.last_dropped_chunks = self.state.last_dropped_chunks.saturating_sub(delta);
|
self.state.last_dropped_chunks = self.state.last_dropped_chunks.saturating_sub(delta);
|
||||||
@@ -704,13 +702,19 @@ pub async fn start_live_transcription_session(
|
|||||||
status_channel: Channel<LiveStatusMessage>,
|
status_channel: Channel<LiveStatusMessage>,
|
||||||
) -> Result<StartLiveTranscriptionResponse, String> {
|
) -> Result<StartLiveTranscriptionResponse, String> {
|
||||||
ensure_main_window(&window)?;
|
ensure_main_window(&window)?;
|
||||||
// Phase 1: acquire the lifecycle lock long enough to reserve the
|
// Phase 1: acquire the lifecycle lock and hold it across the full
|
||||||
// single live-session slot. Held across `ensure_model_loaded`
|
// startup sequence — model load, audio-path resolution, worker
|
||||||
// because we want start/stop concurrency to remain serialised; the
|
// spawn, AND installation of the RunningLiveSession in
|
||||||
// dangerous pattern (lock held across a JoinHandle.await) was on
|
// `live_state.running`. The lock is released by the explicit drop
|
||||||
// the stop path, not here. Released explicitly before the
|
// in Phase 2 (see comment near the bottom of this function).
|
||||||
// RunningLiveSession is installed in `live_state.running` so the
|
//
|
||||||
// symmetric stop path doesn't observe a half-initialised state.
|
// Holding through the install is intentional: a concurrent
|
||||||
|
// `stop_live_transcription_session` acquiring lifecycle MUST
|
||||||
|
// observe either a fully-installed `running` slot or none at all,
|
||||||
|
// never a half-initialised state. The dangerous pattern of holding
|
||||||
|
// the lock across a `JoinHandle.await` is on the stop path, not
|
||||||
|
// here — start hands the handle off to RunningLiveSession and
|
||||||
|
// never awaits it from inside this function.
|
||||||
let lifecycle = live_state.lifecycle.lock().await;
|
let lifecycle = live_state.lifecycle.lock().await;
|
||||||
{
|
{
|
||||||
let running = live_state.running.lock().unwrap();
|
let running = live_state.running.lock().unwrap();
|
||||||
@@ -1111,11 +1115,8 @@ fn maybe_dispatch_chunk(
|
|||||||
let parent_span = tracing::Span::current();
|
let parent_span = tracing::Span::current();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let _parent = parent_span.enter();
|
let _parent = parent_span.enter();
|
||||||
let inference_span = tracing::info_span!(
|
let inference_span =
|
||||||
"inference",
|
tracing::info_span!("inference", chunk_id = current_chunk_id, duration_secs,);
|
||||||
chunk_id = current_chunk_id,
|
|
||||||
duration_secs,
|
|
||||||
);
|
|
||||||
let _enter = inference_span.enter();
|
let _enter = inference_span.enter();
|
||||||
let audio = AudioSamples::mono_16khz(chunk_samples);
|
let audio = AudioSamples::mono_16khz(chunk_samples);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use tauri::{Emitter, State};
|
use tauri::{Emitter, State};
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
use crate::commands::power::PowerAssertion;
|
use crate::commands::power::PowerAssertion;
|
||||||
use crate::commands::security::ensure_main_window;
|
use crate::commands::security::ensure_main_window;
|
||||||
@@ -8,6 +9,8 @@ use lumotia_core::hardware;
|
|||||||
use lumotia_llm::model_manager::{self, model_info};
|
use lumotia_llm::model_manager::{self, model_info};
|
||||||
use lumotia_llm::{ContentTags, LlmModelId};
|
use lumotia_llm::{ContentTags, LlmModelId};
|
||||||
|
|
||||||
|
const LLM_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LlmModelStatusDto {
|
pub struct LlmModelStatusDto {
|
||||||
@@ -395,16 +398,24 @@ pub async fn cleanup_transcript_text_cmd(
|
|||||||
.unwrap_or(LlmPromptPreset::Default);
|
.unwrap_or(LlmPromptPreset::Default);
|
||||||
|
|
||||||
let engine = state.llm_engine.clone();
|
let engine = state.llm_engine.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
let cleanup_future = tokio::task::spawn_blocking(move || {
|
||||||
// macOS: pin a power assertion for the duration of the LLM
|
// macOS: pin a power assertion for the duration of the LLM
|
||||||
// generation so App Nap can't decide to throttle us mid-token.
|
// generation so App Nap can't decide to throttle us mid-token.
|
||||||
// No-op on every other OS. Item #9.
|
// No-op on every other OS. Item #9.
|
||||||
let _power_guard = PowerAssertion::begin("lumotia LLM cleanup");
|
let _power_guard = PowerAssertion::begin("lumotia LLM cleanup");
|
||||||
llm_cleanup_text(&engine, &transcript, &profile_terms, resolved_preset)
|
llm_cleanup_text(&engine, &transcript, &profile_terms, resolved_preset)
|
||||||
})
|
});
|
||||||
.await
|
match timeout(LLM_TIMEOUT, cleanup_future).await {
|
||||||
.map_err(|e| e.to_string())?
|
Ok(Ok(value)) => value.map_err(|e| e.to_string()),
|
||||||
.map_err(|e| e.to_string())
|
Ok(Err(e)) => Err(e.to_string()),
|
||||||
|
Err(_elapsed) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"LLM cleanup_transcript_text_cmd timed out after {:?}",
|
||||||
|
LLM_TIMEOUT
|
||||||
|
);
|
||||||
|
Err("Cleanup took too long. The raw transcript is preserved — try again, or continue without cleanup.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 9 LLM-powered content tags. On-demand from the History page;
|
/// Phase 9 LLM-powered content tags. On-demand from the History page;
|
||||||
@@ -429,11 +440,19 @@ pub async fn extract_content_tags_cmd(
|
|||||||
return Err("LLM not loaded. Download an AI model in Settings.".to_string());
|
return Err("LLM not loaded. Download an AI model in Settings.".to_string());
|
||||||
}
|
}
|
||||||
let engine = state.llm_engine.clone();
|
let engine = state.llm_engine.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
let tag_future = tokio::task::spawn_blocking(move || {
|
||||||
let _power_guard = PowerAssertion::begin("lumotia LLM content-tag extraction");
|
let _power_guard = PowerAssertion::begin("lumotia LLM content-tag extraction");
|
||||||
engine.extract_content_tags(&transcript)
|
engine.extract_content_tags(&transcript)
|
||||||
})
|
});
|
||||||
.await
|
match timeout(LLM_TIMEOUT, tag_future).await {
|
||||||
.map_err(|e| e.to_string())?
|
Ok(Ok(value)) => value.map_err(|e| e.to_string()),
|
||||||
.map_err(|e| e.to_string())
|
Ok(Err(e)) => Err(e.to_string()),
|
||||||
|
Err(_elapsed) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"LLM extract_content_tags_cmd timed out after {:?}",
|
||||||
|
LLM_TIMEOUT
|
||||||
|
);
|
||||||
|
Err("Tagging took too long. Your transcript is unchanged — you can retry from the post-capture card.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod llm;
|
|||||||
pub mod meeting;
|
pub mod meeting;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod nudges;
|
pub mod nudges;
|
||||||
|
pub mod onboarding;
|
||||||
pub mod paste;
|
pub mod paste;
|
||||||
pub mod power;
|
pub mod power;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
|
|||||||
97
src-tauri/src/commands/onboarding.rs
Normal file
97
src-tauri/src/commands/onboarding.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Tauri commands for onboarding flow + opt-in activation log.
|
||||||
|
//
|
||||||
|
// These are thin adapters over the storage helpers — no business logic lives
|
||||||
|
// here. Every sqlx::Error is mapped to a String so Tauri can serialise it
|
||||||
|
// to the frontend as a rejected Promise.
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use lumotia_storage::{
|
||||||
|
clear_lumotia_events as db_clear_lumotia_events,
|
||||||
|
has_completed_onboarding as db_has_completed_onboarding,
|
||||||
|
insert_lumotia_event as db_insert_lumotia_event,
|
||||||
|
insert_onboarding_event as db_insert_onboarding_event,
|
||||||
|
list_lumotia_events as db_list_lumotia_events,
|
||||||
|
list_onboarding_events as db_list_onboarding_events, LumotiaEventRow, OnboardingEventRow,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Record a single onboarding step.
|
||||||
|
///
|
||||||
|
/// `event` — short snake_case identifier, e.g. `"started"`, `"completed"`, `"skipped"`.
|
||||||
|
/// `version` — app version string, e.g. `"0.1.0"`.
|
||||||
|
/// `skipped` — `true` if the user bypassed this step.
|
||||||
|
/// `notes` — optional freeform annotation.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn record_onboarding_event(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
event: String,
|
||||||
|
version: String,
|
||||||
|
skipped: bool,
|
||||||
|
notes: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
db_insert_onboarding_event(&state.db, &event, &version, skipped, notes.as_deref(), now)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all recorded onboarding events, oldest first.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_onboarding_events(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Vec<OnboardingEventRow>, String> {
|
||||||
|
db_list_onboarding_events(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the user has ever recorded a `completed` or `skipped`
|
||||||
|
/// onboarding event — i.e. first-run onboarding should not be shown again.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn has_completed_onboarding(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
db_has_completed_onboarding(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a single entry to the opt-in local activation log.
|
||||||
|
///
|
||||||
|
/// `kind` — event kind, e.g. `"app_launched"`, `"recording_started"`.
|
||||||
|
/// `payload` — optional JSON blob with event-specific context.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn record_lumotia_event(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
kind: String,
|
||||||
|
payload: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
db_insert_lumotia_event(&state.db, &kind, payload.as_deref(), now)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all activation log events, oldest first.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_lumotia_events(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Vec<LumotiaEventRow>, String> {
|
||||||
|
db_list_lumotia_events(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all rows from the activation log.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_lumotia_events(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||||
|
db_clear_lumotia_events(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -26,8 +26,10 @@ use crate::commands::security::{ensure_main_window, ensure_window_in_set};
|
|||||||
/// Windows allowed to invoke `paste_text_replacing`. The
|
/// Windows allowed to invoke `paste_text_replacing`. The
|
||||||
/// `transcription-preview` window's paste-to-foreground flow legitimately
|
/// `transcription-preview` window's paste-to-foreground flow legitimately
|
||||||
/// uses this command; mirror the secondary-windows capability grant in
|
/// uses this command; mirror the secondary-windows capability grant in
|
||||||
/// `src-tauri/capabilities/secondary-windows.json`.
|
/// `src-tauri/capabilities/secondary-windows.json`. The mirror invariant
|
||||||
const PASTE_REPLACING_ALLOWED_WINDOWS: &[&str] = &["main", "transcription-preview"];
|
/// is pinned by
|
||||||
|
/// `commands::security::tests_capability_mirror::allowlists_match_capability_jsons`.
|
||||||
|
pub(crate) const PASTE_REPLACING_ALLOWED_WINDOWS: &[&str] = &["main", "transcription-preview"];
|
||||||
|
|
||||||
/// Refuse-to-paste limit. Matches `commands::clipboard::MAX_CLIPBOARD_BYTES`
|
/// Refuse-to-paste limit. Matches `commands::clipboard::MAX_CLIPBOARD_BYTES`
|
||||||
/// (1 MiB) so paste and copy surfaces share a single rejection rule.
|
/// (1 MiB) so paste and copy surfaces share a single rejection rule.
|
||||||
|
|||||||
@@ -13,27 +13,27 @@
|
|||||||
//! Runtime verification on Apple Silicon against actual idle-throttling
|
//! Runtime verification on Apple Silicon against actual idle-throttling
|
||||||
//! is still pending. See `KNOWN-ISSUES.md` (KI-01).
|
//! is still pending. See `KNOWN-ISSUES.md` (KI-01).
|
||||||
//!
|
//!
|
||||||
//! On Linux and Windows, `PowerAssertion::begin` is currently a no-op
|
//! On Linux (KI-02) we call `org.freedesktop.login1.Manager.Inhibit` via
|
||||||
//! that registers a snapshot in the process-wide registry for diagnostics
|
//! D-Bus (zbus blocking API). The returned file descriptor is the inhibit
|
||||||
//! but does not inhibit OS-level idle throttling. The planned
|
//! lock; closing it releases the lock. A global `OnceLock<Mutex<Option<Fd>>>`
|
||||||
//! implementations are:
|
//! holds the descriptor for the duration of recording.
|
||||||
//!
|
//!
|
||||||
//! - Linux: systemd-logind / GNOME session idle inhibit via
|
//! On Windows (KI-03) we call
|
||||||
//! `org.freedesktop.login1.Inhibit` where available.
|
//! `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` to prevent
|
||||||
//! - Windows: `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED
|
//! the system from sleeping. `ES_CONTINUOUS` alone resets that on release.
|
||||||
//! | ES_AWAYMODE_REQUIRED)` on begin and `ES_CONTINUOUS` alone on end.
|
|
||||||
//!
|
//!
|
||||||
//! Until those land, long sessions on Linux and Windows can still be
|
//! All paths return `Ok(())` on failure — errors are logged but never block
|
||||||
//! idled by the OS. See `KNOWN-ISSUES.md` (KI-02, KI-03) for workarounds.
|
//! recording. The workarounds in `KNOWN-ISSUES.md` remain valid for edge cases
|
||||||
//!
|
//! (non-systemd Linux containers, policy-locked Windows images).
|
||||||
//! All paths return a guard so the caller's code is unchanged. Failures
|
|
||||||
//! to acquire a real assertion are logged so the diagnostics bundle has
|
|
||||||
//! a breadcrumb.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared snapshot registry (all platforms)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PowerAssertionSnapshot {
|
pub struct PowerAssertionSnapshot {
|
||||||
pub id: usize,
|
pub id: usize,
|
||||||
@@ -78,7 +78,8 @@ pub fn active_assertions_snapshot() -> Vec<PowerAssertionSnapshot> {
|
|||||||
impl PowerAssertion {
|
impl PowerAssertion {
|
||||||
/// Begin a power assertion for the given reason. On macOS this
|
/// Begin a power assertion for the given reason. On macOS this
|
||||||
/// pins beginActivityWithOptions; on Linux/Windows it logs only
|
/// pins beginActivityWithOptions; on Linux/Windows it logs only
|
||||||
/// today (stub).
|
/// (the OS-level inhibit is managed separately via the
|
||||||
|
/// `acquire_idle_inhibit` / `release_idle_inhibit` Tauri commands).
|
||||||
pub fn begin(reason: &'static str) -> Self {
|
pub fn begin(reason: &'static str) -> Self {
|
||||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
@@ -96,18 +97,23 @@ impl PowerAssertion {
|
|||||||
tracing::warn!(reason, "macOS App Nap guard could not begin activity");
|
tracing::warn!(reason, "macOS App Nap guard could not begin activity");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
let backend = "linux-logind";
|
||||||
// No-op on non-macOS today; #9 acceptance text only cites
|
#[cfg(target_os = "linux")]
|
||||||
// macOS App Nap. Linux/Windows placeholder handled if
|
let acquired = true; // actual inhibit is managed by acquire_idle_inhibit command
|
||||||
// future feedback requires it.
|
|
||||||
let _ = reason;
|
#[cfg(target_os = "windows")]
|
||||||
}
|
let backend = "windows-ste";
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let acquired = true; // actual STE call is managed by acquire_idle_inhibit command
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||||
|
let backend = "noop";
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||||
|
let acquired = false;
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
let backend = "noop";
|
let _ = reason;
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let acquired = false;
|
|
||||||
|
|
||||||
assertion_registry().lock().unwrap().insert(
|
assertion_registry().lock().unwrap().insert(
|
||||||
id,
|
id,
|
||||||
@@ -147,6 +153,10 @@ impl Drop for PowerAssertion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// macOS: NSProcessInfo activity (unchanged — KI-01, not touched here)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod objc_bridge {
|
mod objc_bridge {
|
||||||
use objc2::rc::Retained;
|
use objc2::rc::Retained;
|
||||||
@@ -175,6 +185,164 @@ mod objc_bridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Linux: systemd-logind D-Bus inhibit (KI-02)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The global inhibit lock file descriptor. `Some(fd)` while recording;
|
||||||
|
/// `None` when not inhibiting. Dropping the inner `OwnedFd` releases the
|
||||||
|
/// systemd-logind inhibit lock automatically.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_inhibit_lock() -> &'static Mutex<Option<std::os::unix::io::OwnedFd>> {
|
||||||
|
static LOCK: OnceLock<Mutex<Option<std::os::unix::io::OwnedFd>>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux_inhibit {
|
||||||
|
use std::os::unix::io::OwnedFd;
|
||||||
|
use zbus::blocking::Connection;
|
||||||
|
use zbus::zvariant::OwnedFd as ZOwnedFd;
|
||||||
|
|
||||||
|
/// Acquires a systemd-logind idle+sleep inhibit lock.
|
||||||
|
/// Returns the file descriptor whose lifetime IS the lock.
|
||||||
|
/// Drops (closes) the fd to release.
|
||||||
|
pub fn acquire() -> Result<OwnedFd, String> {
|
||||||
|
let conn =
|
||||||
|
Connection::system().map_err(|e| format!("zbus: connect to system bus failed: {e}"))?;
|
||||||
|
|
||||||
|
let reply = conn
|
||||||
|
.call_method(
|
||||||
|
Some("org.freedesktop.login1"),
|
||||||
|
"/org/freedesktop/login1",
|
||||||
|
Some("org.freedesktop.login1.Manager"),
|
||||||
|
"Inhibit",
|
||||||
|
&(
|
||||||
|
"idle:sleep:handle-lid-switch",
|
||||||
|
"Lumotia",
|
||||||
|
"Active dictation in progress",
|
||||||
|
"block",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("zbus: Inhibit call failed: {e}"))?;
|
||||||
|
|
||||||
|
let fd: ZOwnedFd = reply
|
||||||
|
.body()
|
||||||
|
.deserialize()
|
||||||
|
.map_err(|e| format!("zbus: Inhibit reply deserialize failed: {e}"))?;
|
||||||
|
|
||||||
|
// Convert zvariant's OwnedFd to std's OwnedFd
|
||||||
|
Ok(fd.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Windows: SetThreadExecutionState (KI-03)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_inhibit {
|
||||||
|
use windows::Win32::System::Power::{
|
||||||
|
SetThreadExecutionState, ES_CONTINUOUS, ES_SYSTEM_REQUIRED,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Prevents the system from sleeping by setting ES_CONTINUOUS | ES_SYSTEM_REQUIRED.
|
||||||
|
/// Display sleep is intentionally NOT blocked — the user is dictating, not watching.
|
||||||
|
pub fn acquire() {
|
||||||
|
unsafe {
|
||||||
|
SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the sleep prevention by resetting to ES_CONTINUOUS alone.
|
||||||
|
pub fn release() {
|
||||||
|
unsafe {
|
||||||
|
SetThreadExecutionState(ES_CONTINUOUS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tauri commands: acquire_idle_inhibit / release_idle_inhibit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Acquire an OS-level idle/sleep inhibit lock for the duration of recording.
|
||||||
|
///
|
||||||
|
/// - Linux: calls `org.freedesktop.login1.Manager.Inhibit` and holds the fd.
|
||||||
|
/// - Windows: calls `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)`.
|
||||||
|
/// - macOS: no-op here; App Nap is handled by `PowerAssertion::begin`.
|
||||||
|
/// - Other: no-op.
|
||||||
|
///
|
||||||
|
/// Errors are logged and swallowed — recording must never be blocked by a
|
||||||
|
/// failed power assertion.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn acquire_idle_inhibit() -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// The zbus blocking call must not run on the Tokio executor thread.
|
||||||
|
let result = tokio::task::spawn_blocking(linux_inhibit::acquire)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| Err(format!("spawn_blocking panic: {e}")));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(fd) => {
|
||||||
|
*linux_inhibit_lock().lock().unwrap() = Some(fd);
|
||||||
|
tracing::info!("Linux idle inhibit acquired (logind block)");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"Linux idle inhibit not acquired — recording continues unaffected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
windows_inhibit::acquire();
|
||||||
|
tracing::info!("Windows sleep prevention engaged (ES_CONTINUOUS | ES_SYSTEM_REQUIRED)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS: App Nap guard is managed by PowerAssertion in the live session path.
|
||||||
|
// Other platforms: no-op.
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release the OS-level idle/sleep inhibit lock acquired during recording.
|
||||||
|
///
|
||||||
|
/// Safe to call even if no lock was held (idempotent).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn release_idle_inhibit() -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Dropping the OwnedFd closes the file descriptor, which releases
|
||||||
|
// the systemd-logind inhibit lock.
|
||||||
|
let prev = linux_inhibit_lock().lock().unwrap().take();
|
||||||
|
if prev.is_some() {
|
||||||
|
tracing::info!("Linux idle inhibit released (fd closed)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
windows_inhibit::release();
|
||||||
|
tracing::info!("Windows sleep prevention released (ES_CONTINUOUS)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -21,15 +21,12 @@ pub fn ensure_main_window_label(label: &str) -> Result<(), String> {
|
|||||||
/// `&[&'static str]` and should mirror an entry in
|
/// `&[&'static str]` and should mirror an entry in
|
||||||
/// `src-tauri/capabilities/*.json` — keeping the IPC trust boundary and the
|
/// `src-tauri/capabilities/*.json` — keeping the IPC trust boundary and the
|
||||||
/// permission grant in lock-step.
|
/// permission grant in lock-step.
|
||||||
pub fn ensure_window_in_set(
|
pub fn ensure_window_in_set(window: &tauri::WebviewWindow, allowed: &[&str]) -> Result<(), String> {
|
||||||
window: &tauri::WebviewWindow,
|
|
||||||
allowed: &[&str],
|
|
||||||
) -> Result<(), String> {
|
|
||||||
ensure_window_in_set_label(window.label(), allowed)
|
ensure_window_in_set_label(window.label(), allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_window_in_set_label(label: &str, allowed: &[&str]) -> Result<(), String> {
|
pub fn ensure_window_in_set_label(label: &str, allowed: &[&str]) -> Result<(), String> {
|
||||||
if allowed.iter().any(|a| *a == label) {
|
if allowed.contains(&label) {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
@@ -69,3 +66,71 @@ mod tests {
|
|||||||
assert!(ensure_window_in_set_label("attacker-popup", allowed).is_err());
|
assert!(ensure_window_in_set_label("attacker-popup", allowed).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins the invariant flagged in the 12b413d commit message and the
|
||||||
|
/// Phase B.6 audit (2026-05-14): every label that the Rust IPC layer
|
||||||
|
/// allow-lists for clipboard / paste-replacing must also appear in one
|
||||||
|
/// of the Tauri capability JSONs' "windows" arrays. If the two halves
|
||||||
|
/// drift, the IPC trust boundary silently disagrees with the permission
|
||||||
|
/// grant — either the IPC layer permits a window the capability system
|
||||||
|
/// rejects (call dies with a Tauri permission error), or the capability
|
||||||
|
/// system permits a window the IPC layer rejects (call dies with the
|
||||||
|
/// Lumotia rejection message). Either is a maintenance footgun the
|
||||||
|
/// commit message claimed to address but did not actually pin.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_capability_mirror {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Read all `"windows"` labels declared across the capability JSONs
|
||||||
|
/// at `src-tauri/capabilities/`. The Tauri permission system uses
|
||||||
|
/// these arrays to scope capability grants per window; the Rust IPC
|
||||||
|
/// `ensure_window_in_set` callers must reference the same labels.
|
||||||
|
fn declared_window_labels() -> HashSet<String> {
|
||||||
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||||
|
let capabilities_dir = Path::new(manifest_dir).join("capabilities");
|
||||||
|
|
||||||
|
let mut labels: HashSet<String> = HashSet::new();
|
||||||
|
for filename in ["main.json", "secondary-windows.json"] {
|
||||||
|
let body = std::fs::read_to_string(capabilities_dir.join(filename))
|
||||||
|
.unwrap_or_else(|e| panic!("read {filename}: {e}"));
|
||||||
|
let json: serde_json::Value =
|
||||||
|
serde_json::from_str(&body).unwrap_or_else(|e| panic!("parse {filename}: {e}"));
|
||||||
|
let windows = json["windows"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap_or_else(|| panic!("{filename} missing 'windows' array"));
|
||||||
|
for w in windows {
|
||||||
|
if let Some(label) = w.as_str() {
|
||||||
|
labels.insert(label.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowlists_match_capability_jsons() {
|
||||||
|
let declared = declared_window_labels();
|
||||||
|
|
||||||
|
for label in crate::commands::clipboard::CLIPBOARD_ALLOWED_WINDOWS {
|
||||||
|
assert!(
|
||||||
|
declared.contains(*label),
|
||||||
|
"CLIPBOARD_ALLOWED_WINDOWS label {label:?} is not declared in any \
|
||||||
|
capabilities JSON ({}). Either remove the label from the Rust const \
|
||||||
|
or add the window to capabilities/secondary-windows.json so the IPC \
|
||||||
|
trust boundary and the capability grant stay in lock-step.",
|
||||||
|
declared.iter().cloned().collect::<Vec<_>>().join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for label in crate::commands::paste::PASTE_REPLACING_ALLOWED_WINDOWS {
|
||||||
|
assert!(
|
||||||
|
declared.contains(*label),
|
||||||
|
"PASTE_REPLACING_ALLOWED_WINDOWS label {label:?} is not declared in any \
|
||||||
|
capabilities JSON ({}). Either remove the label from the Rust const \
|
||||||
|
or add the window to capabilities/secondary-windows.json.",
|
||||||
|
declared.iter().cloned().collect::<Vec<_>>().join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ use lumotia_storage::{
|
|||||||
FeedbackRow, FeedbackTargetType, TaskRow,
|
FeedbackRow, FeedbackTargetType, TaskRow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
use crate::commands::power::PowerAssertion;
|
use crate::commands::power::PowerAssertion;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
const LLM_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
/// Frontend-facing task shape. Matches the in-memory object in page.svelte.js.
|
/// Frontend-facing task shape. Matches the in-memory object in page.svelte.js.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -365,13 +369,28 @@ pub async fn extract_tasks_from_transcript_cmd(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let engine = state.llm_engine.clone();
|
let engine = state.llm_engine.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
let transcript_for_fallback = transcript.clone();
|
||||||
|
let extract_future = tokio::task::spawn_blocking(move || {
|
||||||
let _power_guard = PowerAssertion::begin("lumotia LLM task extraction");
|
let _power_guard = PowerAssertion::begin("lumotia LLM task extraction");
|
||||||
engine.extract_tasks_with_feedback(&transcript, &examples)
|
// extract_tasks_with_fallback NEVER returns Err: on LLM failure it
|
||||||
})
|
// silently falls back to the rule-based extractor and logs a warning.
|
||||||
.await
|
// The return tuple is (tasks, source); the Tauri command only forwards
|
||||||
.map_err(|e| e.to_string())?
|
// the task strings — the UI already has a frontend safety net and
|
||||||
.map_err(|e| e.to_string())
|
// doesn't need to distinguish the source for v0.1.
|
||||||
|
let (tasks, _source) = engine.extract_tasks_with_fallback(&transcript, &examples);
|
||||||
|
tasks
|
||||||
|
});
|
||||||
|
match timeout(LLM_TIMEOUT, extract_future).await {
|
||||||
|
Ok(Ok(tasks)) => Ok(tasks),
|
||||||
|
Ok(Err(e)) => Err(e.to_string()),
|
||||||
|
Err(_elapsed) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"LLM extract_tasks_from_transcript_cmd timed out; falling back to rule-based"
|
||||||
|
);
|
||||||
|
let tasks = lumotia_llm::rule_based_extract_tasks(&transcript_for_fallback);
|
||||||
|
Ok(tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -50,10 +50,7 @@ const MAX_TRANSCRIBE_BYTES: u64 = 1024 * 1024 * 1024;
|
|||||||
/// rejects most obvious attacks), then size (one stat call), then
|
/// rejects most obvious attacks), then size (one stat call), then
|
||||||
/// path canonicalisation (slowest, only meaningful once the cheaper
|
/// path canonicalisation (slowest, only meaningful once the cheaper
|
||||||
/// gates pass).
|
/// gates pass).
|
||||||
pub(crate) fn validate_transcribe_input(
|
pub(crate) fn validate_transcribe_input(path: &Path, metadata_len: u64) -> Result<(), String> {
|
||||||
path: &Path,
|
|
||||||
metadata_len: u64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let ext = path
|
let ext = path
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
@@ -67,7 +64,10 @@ pub(crate) fn validate_transcribe_input(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !ALLOWED_AUDIO_EXTENSIONS.iter().any(|allowed| *allowed == ext) {
|
if !ALLOWED_AUDIO_EXTENSIONS
|
||||||
|
.iter()
|
||||||
|
.any(|allowed| *allowed == ext)
|
||||||
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Refusing to transcribe {}: extension '.{}' is not in the \
|
"Refusing to transcribe {}: extension '.{}' is not in the \
|
||||||
allowlist. Supported: {}.",
|
allowlist. Supported: {}.",
|
||||||
@@ -237,8 +237,7 @@ pub async fn transcribe_file(
|
|||||||
// Trust-5: extension + size gate before we hand the path to the
|
// Trust-5: extension + size gate before we hand the path to the
|
||||||
// audio decoder. `std::fs::metadata` resolves symlinks so the size
|
// audio decoder. `std::fs::metadata` resolves symlinks so the size
|
||||||
// check sees the actual blob, not a symlink-target lie.
|
// check sees the actual blob, not a symlink-target lie.
|
||||||
let metadata = std::fs::metadata(&path)
|
let metadata = std::fs::metadata(&path).map_err(|e| format!("Cannot stat {path}: {e}"))?;
|
||||||
.map_err(|e| format!("Cannot stat {path}: {e}"))?;
|
|
||||||
validate_transcribe_input(Path::new(&path), metadata.len())?;
|
validate_transcribe_input(Path::new(&path), metadata.len())?;
|
||||||
|
|
||||||
let resolved_profile_id =
|
let resolved_profile_id =
|
||||||
@@ -347,7 +346,9 @@ mod tests_trust5 {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_each_allowed_extension() {
|
fn accepts_each_allowed_extension() {
|
||||||
for ext in ["wav", "mp3", "m4a", "mp4", "flac", "ogg", "opus", "webm", "aac"] {
|
for ext in [
|
||||||
|
"wav", "mp3", "m4a", "mp4", "flac", "ogg", "opus", "webm", "aac",
|
||||||
|
] {
|
||||||
let path_string = format!("/tmp/clip.{ext}");
|
let path_string = format!("/tmp/clip.{ext}");
|
||||||
let path = Path::new(&path_string);
|
let path = Path::new(&path_string);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -376,10 +377,8 @@ mod tests_trust5 {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_oversize_file() {
|
fn rejects_oversize_file() {
|
||||||
let result = validate_transcribe_input(
|
let result =
|
||||||
Path::new("/tmp/huge.wav"),
|
validate_transcribe_input(Path::new("/tmp/huge.wav"), MAX_TRANSCRIBE_BYTES + 1);
|
||||||
MAX_TRANSCRIBE_BYTES + 1,
|
|
||||||
);
|
|
||||||
let err = result.expect_err("must reject oversize file");
|
let err = result.expect_err("must reject oversize file");
|
||||||
assert!(err.contains("1 GiB"), "unexpected error: {err}");
|
assert!(err.contains("1 GiB"), "unexpected error: {err}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ use lumotia_storage::{
|
|||||||
delete_transcript as db_delete_transcript, get_transcript as db_get_transcript,
|
delete_transcript as db_delete_transcript, get_transcript as db_get_transcript,
|
||||||
insert_transcript as db_insert_transcript, list_transcripts_paged,
|
insert_transcript as db_insert_transcript, list_transcripts_paged,
|
||||||
list_trashed_transcripts as db_list_trashed_transcripts,
|
list_trashed_transcripts as db_list_trashed_transcripts,
|
||||||
restore_transcript as db_restore_transcript,
|
restore_transcript as db_restore_transcript, search_transcripts as db_search_transcripts,
|
||||||
search_transcripts as db_search_transcripts, update_transcript as db_update_transcript,
|
update_transcript as db_update_transcript, update_transcript_meta as db_update_transcript_meta,
|
||||||
update_transcript_meta as db_update_transcript_meta, InsertTranscriptParams, TranscriptRow,
|
InsertTranscriptParams, TranscriptRow,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
|||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
use tracing_subscriber::{EnvFilter, Layer};
|
use tracing_subscriber::{EnvFilter, Layer};
|
||||||
|
|
||||||
use lumotia_core::paths::{
|
use lumotia_core::paths::{check_target_ambiguity, migrate_legacy_data_dir, MigrationStatus};
|
||||||
check_target_ambiguity, migrate_legacy_data_dir, MigrationStatus,
|
|
||||||
};
|
|
||||||
use lumotia_core::types::EngineName;
|
use lumotia_core::types::EngineName;
|
||||||
use lumotia_llm::LlmEngine;
|
use lumotia_llm::LlmEngine;
|
||||||
use lumotia_storage::{
|
use lumotia_storage::{
|
||||||
@@ -239,7 +237,7 @@ fn build_rolling_appender(logs_dir: &Path) -> std::io::Result<RollingFileAppende
|
|||||||
.filename_suffix("log")
|
.filename_suffix("log")
|
||||||
.max_log_files(7)
|
.max_log_files(7)
|
||||||
.build(logs_dir)
|
.build(logs_dir)
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
.map_err(std::io::Error::other)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install a `tracing` subscriber that writes to stderr (developer
|
/// Install a `tracing` subscriber that writes to stderr (developer
|
||||||
@@ -278,9 +276,7 @@ pub fn install_subscriber(logs_dir: &Path) -> Option<WorkerGuard> {
|
|||||||
// Best-effort: keep stderr logging even if the file path is
|
// Best-effort: keep stderr logging even if the file path is
|
||||||
// unwritable, and surface the failure to stderr so dogfooders
|
// unwritable, and surface the failure to stderr so dogfooders
|
||||||
// notice the missing forensic stream.
|
// notice the missing forensic stream.
|
||||||
let _ = tracing_subscriber::registry()
|
let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
|
||||||
.with(stderr_layer)
|
|
||||||
.try_init();
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"lumotia: failed to install rolling file log appender at {}: {e}",
|
"lumotia: failed to install rolling file log appender at {}: {e}",
|
||||||
logs_dir.display()
|
logs_dir.display()
|
||||||
@@ -302,8 +298,136 @@ fn init_tracing() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-shot data migration that runs BEFORE anything else in `run()`.
|
||||||
|
///
|
||||||
|
/// CRITICAL ORDERING: this must come before `init_tracing`,
|
||||||
|
/// `install_panic_hook`, AND `tauri::Builder::default()`. Each of those
|
||||||
|
/// either calls `create_dir_all` on a child of `app_data_dir()` or causes
|
||||||
|
/// Tauri/WebKitGTK to create its own bundle-identifier-keyed dir for
|
||||||
|
/// plugin state. Either way, by the time the migration would otherwise
|
||||||
|
/// run from inside the Tauri setup hook, the destination already exists
|
||||||
|
/// and the migration short-circuits via `TargetAlreadyExists` /
|
||||||
|
/// `BothExistLegacyPreserved` — silently leaving the user's legacy
|
||||||
|
/// Magnotia data orphaned next to a fresh empty Lumotia install. The
|
||||||
|
/// `scripts/dogfood-rebrand-drill.sh` integration probe is what surfaced
|
||||||
|
/// this race; see commit history for the original buggy ordering.
|
||||||
|
///
|
||||||
|
/// Tracing isn't initialised yet, so migration outcomes are written to
|
||||||
|
/// stderr via `eprintln!`. The format mirrors the structured fields the
|
||||||
|
/// setup-hook tracing layer would have emitted — same content, different
|
||||||
|
/// transport. systemd-journald + a foreground terminal both capture
|
||||||
|
/// stderr, which is the audit surface that matters at boot.
|
||||||
|
///
|
||||||
|
/// Fatal failures (data-dir migration error, ambiguous lumotia paths on
|
||||||
|
/// disk) call `process::exit(1)` rather than panicking. We refuse to
|
||||||
|
/// start with the wrong path resolved — silently continuing would
|
||||||
|
/// orphan user data, which is the failure mode this fix is closing.
|
||||||
|
fn migrate_user_data_pre_runtime() {
|
||||||
|
// 1. Hand-rolled data-dir migration: ~/.local/share/magnotia (and
|
||||||
|
// macOS / Windows equivalents) -> ~/.local/share/lumotia. Drives
|
||||||
|
// every legacy candidate independently so multi-legacy Linux
|
||||||
|
// users (`~/.magnotia` AND `~/.local/share/magnotia` from
|
||||||
|
// different historical builds) get all of them migrated, not
|
||||||
|
// just the first one probed.
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
match migrate_legacy_data_dir() {
|
||||||
|
Ok(statuses) => {
|
||||||
|
for status in &statuses {
|
||||||
|
match status {
|
||||||
|
MigrationStatus::Migrated {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
renamed_db,
|
||||||
|
} => {
|
||||||
|
eprintln!(
|
||||||
|
"[lumotia-startup] migrated legacy magnotia data dir to lumotia: \
|
||||||
|
{from} -> {to} (renamed_db={renamed_db}, elapsed_ms={ms})",
|
||||||
|
from = from.display(),
|
||||||
|
to = to.display(),
|
||||||
|
renamed_db = *renamed_db,
|
||||||
|
ms = t.elapsed().as_millis(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
MigrationStatus::TargetAlreadyExists { .. }
|
||||||
|
| MigrationStatus::NoLegacyFound => {
|
||||||
|
// Steady state on the second-or-later boot. Chatty
|
||||||
|
// logging here would dwarf the genuine first-boot
|
||||||
|
// event, so we stay silent.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[lumotia-startup] FATAL: legacy data dir migration failed — refusing \
|
||||||
|
to start (would orphan user data): {e}"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tauri app_data_dir migration: ~/.local/share/uk.co.corbel.magnotia
|
||||||
|
// -> ~/.local/share/consulting.corbel.lumotia. Copy-via-staging so
|
||||||
|
// a half-written destination cannot appear on disk. Legacy is
|
||||||
|
// preserved as a backup.
|
||||||
|
use crate::tauri_app_data_migration::{
|
||||||
|
current_tauri_app_data_dir, legacy_tauri_app_data_dir,
|
||||||
|
migrate_tauri_app_data_dir_with_paths, AppDataMigrationStatus,
|
||||||
|
};
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
if let (Some(legacy), Some(current)) =
|
||||||
|
(legacy_tauri_app_data_dir(), current_tauri_app_data_dir())
|
||||||
|
{
|
||||||
|
match migrate_tauri_app_data_dir_with_paths(&legacy, ¤t) {
|
||||||
|
AppDataMigrationStatus::Migrated { old, new } => {
|
||||||
|
eprintln!(
|
||||||
|
"[lumotia-startup] migrated Tauri app_data_dir from legacy bundle \
|
||||||
|
identifier: {old} -> {new} (elapsed_ms={ms})",
|
||||||
|
old = old.display(),
|
||||||
|
new = new.display(),
|
||||||
|
ms = t.elapsed().as_millis(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AppDataMigrationStatus::BothExistLegacyPreserved { old, new } => {
|
||||||
|
// Reachable on the second-or-later boot OR if the user
|
||||||
|
// ran a side-by-side install. Either is benign — we
|
||||||
|
// preserve legacy, use new — so log at INFO not WARN.
|
||||||
|
eprintln!(
|
||||||
|
"[lumotia-startup] Tauri app_data_dir present at new path; legacy \
|
||||||
|
preserved: old={old} new={new}",
|
||||||
|
old = old.display(),
|
||||||
|
new = new.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AppDataMigrationStatus::NoLegacyFound => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ambiguity guard. After migrations, if more than one lumotia
|
||||||
|
// target candidate exists on disk (`~/.lumotia` AND
|
||||||
|
// `~/.local/share/lumotia` from split runs), refuse to start
|
||||||
|
// rather than silently pick one. The user must consolidate
|
||||||
|
// manually.
|
||||||
|
if let Err(amb) = check_target_ambiguity() {
|
||||||
|
eprintln!(
|
||||||
|
"[lumotia-startup] FATAL: ambiguous lumotia data directory — refusing to \
|
||||||
|
start: {amb}"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// Step 1: migrate legacy magnotia data BEFORE anything else touches
|
||||||
|
// the lumotia data dir. init_tracing, install_panic_hook, and
|
||||||
|
// tauri::Builder::default() all lazily create directories under
|
||||||
|
// app_data_dir on first use; if any of those run before migration,
|
||||||
|
// every migrate_one() probe returns TargetAlreadyExists / both-exist
|
||||||
|
// and the legacy data is silently orphaned.
|
||||||
|
migrate_user_data_pre_runtime();
|
||||||
|
|
||||||
|
// Step 2: structured logging on the (now correctly migrated) logs dir.
|
||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -349,117 +473,14 @@ pub fn run() {
|
|||||||
|
|
||||||
builder
|
builder
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Tauri 2 keys both `app_data_dir` and the webview's data
|
// Data migrations + ambiguity guard ran in
|
||||||
// store (localStorage, IndexedDB, cookies, service worker
|
// `migrate_user_data_pre_runtime()` BEFORE tauri::Builder
|
||||||
// storage, cache) plus all Tauri plugin state files
|
// was constructed — see the doc comment on that function
|
||||||
// (window-state geometry, autostart enable flag) by the
|
// for why setup-hook timing is too late. By the time we get
|
||||||
// bundle identifier. Commit `14313cf` renamed the bundle
|
// here every app_data_dir path is the post-rebrand one and
|
||||||
// from `uk.co.corbel.magnotia` to `consulting.corbel.lumotia`,
|
// any legacy magnotia state has either been migrated or
|
||||||
// which would silently orphan every existing user's
|
// preserved as a backup.
|
||||||
// webview state under the new identifier. Migrate the
|
let _ = app;
|
||||||
// legacy identifier-keyed app_data_dir first thing in
|
|
||||||
// setup, before the webview loads its first page and
|
|
||||||
// touches the on-disk store. Failure is logged but does
|
|
||||||
// NOT block startup: the hand-rolled `paths.rs` migration
|
|
||||||
// below covers the user's transcripts and models, so a
|
|
||||||
// total failure here only loses webview-keyed state.
|
|
||||||
//
|
|
||||||
// This is a separate concern from the
|
|
||||||
// `~/.local/share/magnotia` → `~/.local/share/lumotia`
|
|
||||||
// migration just below — that one lives under a
|
|
||||||
// hand-rolled path the app picks itself; this one lives
|
|
||||||
// under Tauri's identifier-keyed convention.
|
|
||||||
let t_app_data = Instant::now();
|
|
||||||
match app.path().app_data_dir() {
|
|
||||||
Ok(new_app_data) => {
|
|
||||||
use crate::tauri_app_data_migration::{
|
|
||||||
legacy_tauri_app_data_dir, migrate_tauri_app_data_dir_with_paths,
|
|
||||||
AppDataMigrationStatus,
|
|
||||||
};
|
|
||||||
if let Some(legacy) = legacy_tauri_app_data_dir() {
|
|
||||||
match migrate_tauri_app_data_dir_with_paths(&legacy, &new_app_data) {
|
|
||||||
AppDataMigrationStatus::Migrated { old, new } => {
|
|
||||||
tracing::info!(
|
|
||||||
target: "lumotia_startup",
|
|
||||||
elapsed_ms = t_app_data.elapsed().as_millis(),
|
|
||||||
old = %old.display(),
|
|
||||||
new = %new.display(),
|
|
||||||
"migrated Tauri app_data_dir from legacy bundle identifier; legacy preserved as backup"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
AppDataMigrationStatus::BothExistLegacyPreserved { old, new } => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "lumotia_startup",
|
|
||||||
old = %old.display(),
|
|
||||||
new = %new.display(),
|
|
||||||
"both legacy and new Tauri app_data_dir exist; using new, legacy preserved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
AppDataMigrationStatus::NoLegacyFound => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "lumotia_startup",
|
|
||||||
error = %e,
|
|
||||||
"could not resolve Tauri app_data_dir; skipping bundle-identifier migration"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-shot legacy data-dir migration: rename ~/.local/share/magnotia
|
|
||||||
// (and macOS/Windows equivalents) to the convention-preserving
|
|
||||||
// lumotia path on first launch after the rebrand. Idempotent —
|
|
||||||
// safe to call on every boot. A migration error is fatal: silently
|
|
||||||
// continuing past it would orphan the user's transcripts and
|
|
||||||
// settings behind a fresh empty lumotia dir.
|
|
||||||
let t_migrate = Instant::now();
|
|
||||||
match migrate_legacy_data_dir() {
|
|
||||||
Ok(statuses) => {
|
|
||||||
// Drive every legacy candidate independently: on Linux a
|
|
||||||
// user may have both `~/.magnotia` and
|
|
||||||
// `~/.local/share/magnotia`, and migrating only one
|
|
||||||
// would orphan the other forever.
|
|
||||||
for status in &statuses {
|
|
||||||
match status {
|
|
||||||
MigrationStatus::Migrated { from, to, renamed_db } => {
|
|
||||||
tracing::info!(
|
|
||||||
target: "lumotia_startup",
|
|
||||||
elapsed_ms = t_migrate.elapsed().as_millis(),
|
|
||||||
from = %from.display(),
|
|
||||||
to = %to.display(),
|
|
||||||
renamed_db = *renamed_db,
|
|
||||||
"migrated legacy magnotia data dir to lumotia"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
MigrationStatus::TargetAlreadyExists { .. } => {}
|
|
||||||
MigrationStatus::NoLegacyFound => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
target: "lumotia_startup",
|
|
||||||
error = %e,
|
|
||||||
"legacy data dir migration failed — refusing to start (would orphan user data)"
|
|
||||||
);
|
|
||||||
return Err(Box::new(e) as Box<dyn std::error::Error>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After migration, refuse to start if more than one lumotia
|
|
||||||
// target candidate exists on disk (e.g. both `~/.lumotia` AND
|
|
||||||
// `~/.local/share/lumotia`). Silently picking one would point
|
|
||||||
// the app at the wrong half of a split data directory.
|
|
||||||
if let Err(amb) = check_target_ambiguity() {
|
|
||||||
tracing::error!(
|
|
||||||
target: "lumotia_startup",
|
|
||||||
error = %amb,
|
|
||||||
"ambiguous lumotia data directory — refusing to start"
|
|
||||||
);
|
|
||||||
return Err(Box::new(amb) as Box<dyn std::error::Error>);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialise database and startup settings in one runtime entry.
|
// Initialise database and startup settings in one runtime entry.
|
||||||
let db_path = database_path();
|
let db_path = database_path();
|
||||||
@@ -708,6 +729,13 @@ pub fn run() {
|
|||||||
commands::rituals::mark_morning_triage_shown,
|
commands::rituals::mark_morning_triage_shown,
|
||||||
// Nudges (Phase 6 roadmap)
|
// Nudges (Phase 6 roadmap)
|
||||||
commands::nudges::deliver_nudge,
|
commands::nudges::deliver_nudge,
|
||||||
|
// Onboarding flow + opt-in activation log
|
||||||
|
commands::onboarding::record_onboarding_event,
|
||||||
|
commands::onboarding::list_onboarding_events,
|
||||||
|
commands::onboarding::has_completed_onboarding,
|
||||||
|
commands::onboarding::record_lumotia_event,
|
||||||
|
commands::onboarding::list_lumotia_events,
|
||||||
|
commands::onboarding::clear_lumotia_events,
|
||||||
// Implementation intentions (Phase 7 roadmap)
|
// Implementation intentions (Phase 7 roadmap)
|
||||||
commands::intentions::list_implementation_rules,
|
commands::intentions::list_implementation_rules,
|
||||||
commands::intentions::create_implementation_rule,
|
commands::intentions::create_implementation_rule,
|
||||||
@@ -740,6 +768,7 @@ pub fn run() {
|
|||||||
commands::diagnostics::generate_diagnostic_report,
|
commands::diagnostics::generate_diagnostic_report,
|
||||||
commands::diagnostics::save_diagnostic_report,
|
commands::diagnostics::save_diagnostic_report,
|
||||||
commands::diagnostics::get_os_info,
|
commands::diagnostics::get_os_info,
|
||||||
|
commands::diagnostics::generate_diagnostic_bundle,
|
||||||
commands::live::start_live_transcription_session,
|
commands::live::start_live_transcription_session,
|
||||||
commands::live::stop_live_transcription_session,
|
commands::live::stop_live_transcription_session,
|
||||||
// Windows
|
// Windows
|
||||||
@@ -769,6 +798,9 @@ pub fn run() {
|
|||||||
commands::hotkey::stop_evdev_hotkey,
|
commands::hotkey::stop_evdev_hotkey,
|
||||||
// Updater
|
// Updater
|
||||||
commands::update::check_for_update,
|
commands::update::check_for_update,
|
||||||
|
// Power (KI-02 Linux idle inhibit, KI-03 Windows sleep prevention)
|
||||||
|
commands::power::acquire_idle_inhibit,
|
||||||
|
commands::power::release_idle_inhibit,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Lumotia");
|
.expect("error while running Lumotia");
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ use lumotia_core::paths::copy_dir_recursive;
|
|||||||
/// (commit `14313cf`).
|
/// (commit `14313cf`).
|
||||||
pub const OLD_BUNDLE_ID: &str = "uk.co.corbel.magnotia";
|
pub const OLD_BUNDLE_ID: &str = "uk.co.corbel.magnotia";
|
||||||
|
|
||||||
|
/// Bundle identifier in use post-rebrand. MUST match the `identifier`
|
||||||
|
/// field in `src-tauri/tauri.conf.json` — if those two ever drift,
|
||||||
|
/// the pre-runtime migration would copy data into a path Tauri never
|
||||||
|
/// looks at. There is no compile-time check for this invariant;
|
||||||
|
/// reviewer's job to catch a tauri.conf.json edit that doesn't update
|
||||||
|
/// this const.
|
||||||
|
pub const NEW_BUNDLE_ID: &str = "consulting.corbel.lumotia";
|
||||||
|
|
||||||
/// Outcome of an `app_data_dir` migration attempt. Mostly informational
|
/// Outcome of an `app_data_dir` migration attempt. Mostly informational
|
||||||
/// for the setup-hook tracing layer; the function never aborts startup.
|
/// for the setup-hook tracing layer; the function never aborts startup.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -41,21 +49,16 @@ pub enum AppDataMigrationStatus {
|
|||||||
/// after the first successful migration (we preserve legacy as a
|
/// after the first successful migration (we preserve legacy as a
|
||||||
/// backup), so the warning is informational rather than an
|
/// backup), so the warning is informational rather than an
|
||||||
/// indication of trouble.
|
/// indication of trouble.
|
||||||
BothExistLegacyPreserved {
|
BothExistLegacyPreserved { old: PathBuf, new: PathBuf },
|
||||||
old: PathBuf,
|
|
||||||
new: PathBuf,
|
|
||||||
},
|
|
||||||
/// Migration succeeded: legacy copied to new path via atomic
|
/// Migration succeeded: legacy copied to new path via atomic
|
||||||
/// staging rename, legacy preserved as a backup.
|
/// staging rename, legacy preserved as a backup.
|
||||||
Migrated {
|
Migrated { old: PathBuf, new: PathBuf },
|
||||||
old: PathBuf,
|
|
||||||
new: PathBuf,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the OLD Tauri `app_data_dir` from platform conventions. The
|
/// Resolve the OLD Tauri `app_data_dir` from platform conventions.
|
||||||
/// AppHandle no longer knows the legacy identifier, so this is
|
/// Used by the pre-runtime migration; the AppHandle isn't available
|
||||||
/// hand-rolled per platform.
|
/// yet at that stage AND the AppHandle is keyed by the NEW identifier
|
||||||
|
/// anyway, so it couldn't surface the legacy path even if we had it.
|
||||||
///
|
///
|
||||||
/// Returns `None` when the environment variable required to anchor the
|
/// Returns `None` when the environment variable required to anchor the
|
||||||
/// path is missing or empty — `HOME` on Unix, `APPDATA` on Windows. In
|
/// path is missing or empty — `HOME` on Unix, `APPDATA` on Windows. In
|
||||||
@@ -63,10 +66,32 @@ pub enum AppDataMigrationStatus {
|
|||||||
/// fallback (the new path also depends on the same env vars and would
|
/// fallback (the new path also depends on the same env vars and would
|
||||||
/// be equally unrooted).
|
/// be equally unrooted).
|
||||||
pub fn legacy_tauri_app_data_dir() -> Option<PathBuf> {
|
pub fn legacy_tauri_app_data_dir() -> Option<PathBuf> {
|
||||||
legacy_tauri_app_data_dir_for(OLD_BUNDLE_ID)
|
tauri_app_data_dir_for(OLD_BUNDLE_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn legacy_tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
|
/// Resolve the CURRENT (post-rebrand) Tauri `app_data_dir` from
|
||||||
|
/// platform conventions. Used by the pre-runtime migration as the
|
||||||
|
/// destination for the copy — pre-runtime means we run before
|
||||||
|
/// `tauri::Builder::default()` so we can't call `app.path().app_data_dir()`.
|
||||||
|
///
|
||||||
|
/// CRITICAL: this resolver MUST agree with Tauri 2's own resolution at
|
||||||
|
/// runtime, otherwise the migration copies data into a path Tauri never
|
||||||
|
/// reads. Both sides use the same XDG / Library / APPDATA conventions
|
||||||
|
/// keyed by the bundle identifier, so they agree by construction as
|
||||||
|
/// long as `NEW_BUNDLE_ID` matches `tauri.conf.json#identifier`.
|
||||||
|
pub fn current_tauri_app_data_dir() -> Option<PathBuf> {
|
||||||
|
tauri_app_data_dir_for(NEW_BUNDLE_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform-aware resolver shared by [`legacy_tauri_app_data_dir`] and
|
||||||
|
/// [`current_tauri_app_data_dir`]. Public so integration tests in
|
||||||
|
/// sibling crates can substitute an arbitrary identifier — production
|
||||||
|
/// callers should prefer the two wrappers above.
|
||||||
|
pub fn tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
|
||||||
|
// Exactly one of the four cfg blocks below is present per target
|
||||||
|
// compile. Each is a tail expression that becomes the function's
|
||||||
|
// return value. Avoiding explicit `return` keeps clippy's
|
||||||
|
// needless_return lint happy on every platform.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
// XDG_DATA_HOME wins when set and non-empty, per the XDG Base
|
// XDG_DATA_HOME wins when set and non-empty, per the XDG Base
|
||||||
@@ -79,29 +104,29 @@ fn legacy_tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?;
|
let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?;
|
||||||
return Some(
|
Some(
|
||||||
PathBuf::from(home)
|
PathBuf::from(home)
|
||||||
.join(".local")
|
.join(".local")
|
||||||
.join("share")
|
.join("share")
|
||||||
.join(identifier),
|
.join(identifier),
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?;
|
let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?;
|
||||||
return Some(
|
Some(
|
||||||
PathBuf::from(home)
|
PathBuf::from(home)
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join(identifier),
|
.join(identifier),
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let appdata = std::env::var("APPDATA").ok().filter(|s| !s.is_empty())?;
|
let appdata = std::env::var("APPDATA").ok().filter(|s| !s.is_empty())?;
|
||||||
return Some(PathBuf::from(appdata).join(identifier));
|
Some(PathBuf::from(appdata).join(identifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
@@ -123,10 +148,7 @@ fn legacy_tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
|
|||||||
/// `paths.rs` migration already covers the user's transcripts and
|
/// `paths.rs` migration already covers the user's transcripts and
|
||||||
/// models, so even a total-failure here only loses webview-keyed state
|
/// models, so even a total-failure here only loses webview-keyed state
|
||||||
/// (preferences, session storage, plugin geometry).
|
/// (preferences, session storage, plugin geometry).
|
||||||
pub fn migrate_tauri_app_data_dir_with_paths(
|
pub fn migrate_tauri_app_data_dir_with_paths(old: &Path, new: &Path) -> AppDataMigrationStatus {
|
||||||
old: &Path,
|
|
||||||
new: &Path,
|
|
||||||
) -> AppDataMigrationStatus {
|
|
||||||
let old_exists = old.exists();
|
let old_exists = old.exists();
|
||||||
let new_exists = new.exists();
|
let new_exists = new.exists();
|
||||||
|
|
||||||
@@ -265,7 +287,10 @@ mod tests {
|
|||||||
assert!(leveldb.exists());
|
assert!(leveldb.exists());
|
||||||
|
|
||||||
// Staging directory is cleaned up.
|
// Staging directory is cleaned up.
|
||||||
assert!(!tmp.path().join("consulting.corbel.lumotia.migrating").exists());
|
assert!(!tmp
|
||||||
|
.path()
|
||||||
|
.join("consulting.corbel.lumotia.migrating")
|
||||||
|
.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -329,6 +354,9 @@ mod tests {
|
|||||||
AppDataMigrationStatus::BothExistLegacyPreserved { .. }
|
AppDataMigrationStatus::BothExistLegacyPreserved { .. }
|
||||||
));
|
));
|
||||||
|
|
||||||
assert_eq!(fs::read(new.join("file.txt")).unwrap(), b"v2-edited-by-user");
|
assert_eq!(
|
||||||
|
fs::read(new.join("file.txt")).unwrap(),
|
||||||
|
b"v2-edited-by-user"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,7 @@ fn init_tracing_creates_log_file() {
|
|||||||
let entries: Vec<_> = fs::read_dir(&logs_dir)
|
let entries: Vec<_> = fs::read_dir(&logs_dir)
|
||||||
.expect("read tempdir")
|
.expect("read tempdir")
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
.filter(|e| {
|
.filter(|e| e.file_name().to_string_lossy().starts_with("lumotia"))
|
||||||
e.file_name()
|
|
||||||
.to_string_lossy()
|
|
||||||
.starts_with("lumotia")
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
29
src/app.css
29
src/app.css
@@ -141,6 +141,15 @@
|
|||||||
.btn-md { @apply px-3 py-1.5 text-[12px]; }
|
.btn-md { @apply px-3 py-1.5 text-[12px]; }
|
||||||
.btn-lg { @apply px-4 py-2 text-[13px]; }
|
.btn-lg { @apply px-4 py-2 text-[13px]; }
|
||||||
|
|
||||||
|
/* === Filled-button text colour — a11y CA-1/CA-2 fix ===
|
||||||
|
Dark mode: bg-page (#0f0e0c) on accent (#d68450) = 6.68:1 PASS
|
||||||
|
bg-page (#0f0e0c) on danger (#e85f5f) = 5.73:1 PASS
|
||||||
|
Light mode: white on accent (#a3683a) = 4.57:1 PASS
|
||||||
|
white on danger (#b32626) = 6.52:1 PASS
|
||||||
|
Use .btn-filled-text in place of text-white on bg-accent / bg-danger buttons. */
|
||||||
|
.btn-filled-text { color: var(--color-bg); }
|
||||||
|
:root[data-theme="light"] .btn-filled-text { color: #ffffff; }
|
||||||
|
|
||||||
/* === Light theme overrides === */
|
/* === Light theme overrides === */
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--color-bg: #faf8f5;
|
--color-bg: #faf8f5;
|
||||||
@@ -337,13 +346,31 @@ textarea, input, [data-no-transition] {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Focus ring — consistent, accessible === */
|
/* === Focus ring — consistent, accessible ===
|
||||||
|
Low-specificity `:where()` wrapper lets component-level overrides win.
|
||||||
|
Covers all standard interactive elements + anything with a non-negative
|
||||||
|
tabindex. Uses `:focus-visible` (not `:focus`) so the ring only appears
|
||||||
|
for keyboard navigation, not mouse clicks. */
|
||||||
|
:where(button, a, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback: anything else that gets :focus-visible still gets a ring */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-accent);
|
outline: 2px solid var(--color-accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Suppress transitions on focus-visible for reduced-motion users */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:focus-visible {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === Selection colour === */
|
/* === Selection colour === */
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-accent-glow);
|
background: var(--color-accent-glow);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Lumotia is a local-first dictation + thought-capture app. You press a hotkey, sp
|
|||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- **Repo:** `github.com/jakejars/lumotia` — Tauri + Svelte 5 + Tailwind v4 desktop app. Imported into `lumotia-source/` (lib components + routes).
|
- **Repo:** `github.com/jakeadriansames/lumotia` — Tauri + Svelte 5 + Tailwind v4 desktop app. Imported into `lumotia-source/` (lib components + routes).
|
||||||
- **Design brief:** Pasted in the initial message — complete token system, type scale, motion guidelines, ideology.
|
- **Design brief:** Pasted in the initial message — complete token system, type scale, motion guidelines, ideology.
|
||||||
- **Key reads in `lumotia-source/`:**
|
- **Key reads in `lumotia-source/`:**
|
||||||
- `app.css` — @theme tokens, global base, zone + motion system
|
- `app.css` — @theme tokens, global base, zone + motion system
|
||||||
|
|||||||
41
src/design-system/preview/components-status-pills.html
Normal file
41
src/design-system/preview/components-status-pills.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="../colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;background:var(--bg);padding:24px;font-family:var(--font-body);color:var(--text)}
|
||||||
|
.eb{font:500 10px var(--font-body);color:var(--text-tertiary);letter-spacing:.12em;text-transform:uppercase;margin-bottom:14px;margin-top:24px}
|
||||||
|
.eb:first-child{margin-top:0}
|
||||||
|
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:10px}
|
||||||
|
.l{font:400 10px var(--font-mono);color:var(--text-tertiary);min-width:120px}
|
||||||
|
/* pill component styles */
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:6px;padding:2px 8px 2px 6px;border-radius:9999px;background:var(--bg-elevated, rgba(255,255,255,.06));border:1px solid var(--border-subtle);font:500 11px/1 var(--font-body);color:var(--text-secondary);white-space:nowrap;user-select:none}
|
||||||
|
.dot{display:inline-block;width:6px;height:6px;border-radius:9999px;flex-shrink:0}
|
||||||
|
/* semantic colour mappings — see StatusPill.svelte for rationale */
|
||||||
|
.dot-neutral{background:var(--text-tertiary)}
|
||||||
|
.dot-danger{background:var(--danger)}
|
||||||
|
.dot-warning{background:var(--warning)}
|
||||||
|
.dot-accent{background:var(--accent)}
|
||||||
|
.dot-success{background:var(--success)}
|
||||||
|
/* pulse animation — recording / in-flight states */
|
||||||
|
@keyframes dot-pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||||
|
.dot-pulse{animation:dot-pulse 1.4s ease-in-out infinite}
|
||||||
|
@media(prefers-reduced-motion:reduce){.dot-pulse{animation:none}}
|
||||||
|
</style></head><body>
|
||||||
|
|
||||||
|
<div class="eb">Status pills · all 10 states · default labels</div>
|
||||||
|
<div class="row"><div class="l">ready</div><span class="pill"><span class="dot dot-neutral"></span>Ready</span></div>
|
||||||
|
<div class="row"><div class="l">recording</div><span class="pill"><span class="dot dot-danger dot-pulse"></span>Recording</span></div>
|
||||||
|
<div class="row"><div class="l">paused</div><span class="pill"><span class="dot dot-warning"></span>Paused</span></div>
|
||||||
|
<div class="row"><div class="l">transcribing</div><span class="pill"><span class="dot dot-accent dot-pulse"></span>Transcribing</span></div>
|
||||||
|
<div class="row"><div class="l">cleaning</div><span class="pill"><span class="dot dot-accent dot-pulse"></span>Cleaning</span></div>
|
||||||
|
<div class="row"><div class="l">extracting-tasks</div><span class="pill"><span class="dot dot-accent dot-pulse"></span>Extracting tasks</span></div>
|
||||||
|
<div class="row"><div class="l">saved</div><span class="pill"><span class="dot dot-success"></span>Saved</span></div>
|
||||||
|
<div class="row"><div class="l">exported</div><span class="pill"><span class="dot dot-success"></span>Exported</span></div>
|
||||||
|
<div class="row"><div class="l">needs-review</div><span class="pill"><span class="dot dot-warning"></span>Needs review</span></div>
|
||||||
|
<div class="row"><div class="l">failed-safely</div><span class="pill"><span class="dot dot-danger"></span>Failed safely</span></div>
|
||||||
|
|
||||||
|
<div class="eb">Status pills · custom label prop</div>
|
||||||
|
<div class="row"><div class="l">cleaning · custom</div><span class="pill"><span class="dot dot-accent dot-pulse"></span>Cleaning your transcript…</span></div>
|
||||||
|
<div class="row"><div class="l">recording · custom</div><span class="pill"><span class="dot dot-danger dot-pulse"></span>Recording (2m 14s)</span></div>
|
||||||
|
<div class="row"><div class="l">failed-safely · custom</div><span class="pill"><span class="dot dot-danger"></span>LLM cleanup skipped — raw transcript kept</span></div>
|
||||||
|
<div class="row"><div class="l">saved · custom</div><span class="pill"><span class="dot dot-success"></span>Saved · 3 tasks extracted</span></div>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
@@ -98,7 +98,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="flex flex-col gap-0.5 {collapsed ? 'px-1' : 'px-3'}">
|
<!--
|
||||||
|
Recording-as-sacred-state: when recording is active, nav items are
|
||||||
|
de-emphasised (lower opacity, non-interactive) so the recording controls
|
||||||
|
on DictationPage are the unambiguous focus. DOM is preserved for
|
||||||
|
screen-reader users (aria-disabled, not hidden).
|
||||||
|
The transition is wrapped by prefers-reduced-motion below.
|
||||||
|
-->
|
||||||
|
<nav
|
||||||
|
class="flex flex-col gap-0.5 {collapsed ? 'px-1' : 'px-3'}"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
style="transition: opacity 200ms ease; {page.recording ? 'opacity: 0.3; pointer-events: none;' : 'opacity: 1;'}"
|
||||||
|
>
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
{@const Icon = item.icon}
|
{@const Icon = item.icon}
|
||||||
{@const isActive = page.current === item.id}
|
{@const isActive = page.current === item.id}
|
||||||
@@ -111,6 +122,8 @@
|
|||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
onclick={() => navigate(item.id)}
|
onclick={() => navigate(item.id)}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
|
aria-disabled={page.recording ? "true" : undefined}
|
||||||
|
tabindex={page.recording ? -1 : 0}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={16}
|
size={16}
|
||||||
@@ -176,3 +189,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Instant snap instead of 200ms fade for users who prefer reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
nav {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeRule(rule) {
|
async function removeRule(rule) {
|
||||||
|
if (!confirm("Remove this if-then rule? Future captures won't be filtered by it. This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await deleteImplementationRule(rule.id);
|
await deleteImplementationRule(rule.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="px-6 py-2.5 rounded-xl text-[14px] font-medium text-white bg-accent hover:bg-accent-hover
|
class="px-6 py-2.5 rounded-xl text-[14px] font-medium btn-filled-text bg-accent hover:bg-accent-hover
|
||||||
shadow-[var(--shadow-accent-md)] transition-all duration-150"
|
shadow-[var(--shadow-accent-md)] transition-all duration-150"
|
||||||
onclick={startDownload}
|
onclick={startDownload}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -298,7 +298,7 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="mt-0.5 w-4 h-4 rounded-sm border flex items-center justify-center flex-shrink-0
|
class="mt-0.5 w-4 h-4 rounded-sm border flex items-center justify-center flex-shrink-0
|
||||||
{picked ? 'bg-accent border-accent text-white' : 'border-border'}"
|
{picked ? 'bg-accent border-accent btn-filled-text' : 'border-border'}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{#if picked}
|
{#if picked}
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-2 rounded-lg text-[12px] font-medium transition-colors
|
class="px-4 py-2 rounded-lg text-[12px] font-medium transition-colors
|
||||||
{selected.size >= 1 && !applying
|
{selected.size >= 1 && !applying
|
||||||
? 'bg-accent text-white hover:bg-accent-hover'
|
? 'bg-accent btn-filled-text hover:bg-accent-hover'
|
||||||
: 'bg-bg-input text-text-tertiary cursor-not-allowed'}"
|
: 'bg-bg-input text-text-tertiary cursor-not-allowed'}"
|
||||||
onclick={startTheDay}
|
onclick={startTheDay}
|
||||||
disabled={selected.size === 0 || applying}
|
disabled={selected.size === 0 || applying}
|
||||||
|
|||||||
399
src/lib/components/PostCaptureCard.svelte
Normal file
399
src/lib/components/PostCaptureCard.svelte
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// PostCaptureCard — v0.1 UI-hardening deliverable (section 3: "Post-capture card").
|
||||||
|
//
|
||||||
|
// Display-only surface surfaced after every recording. Shows the cleaned
|
||||||
|
// transcript, optionally the raw transcript behind a <details> disclosure,
|
||||||
|
// extracted tasks (each clickable to reveal MicroSteps), and action
|
||||||
|
// buttons for export / start-first-MicroStep / open-in-history.
|
||||||
|
//
|
||||||
|
// Intentionally scope-limited to what v0.1 ships:
|
||||||
|
// ✓ cleaned + raw transcript
|
||||||
|
// ✓ extracted tasks list
|
||||||
|
// ✓ MicroSteps for the selected task
|
||||||
|
// ✗ suggested title / type / folder / project / area / person / topic
|
||||||
|
// ✗ accept / edit / park / archive
|
||||||
|
// ✗ confidence scores
|
||||||
|
// ✗ related-notes / possible-links
|
||||||
|
// All of the above are explicitly v0.2+.
|
||||||
|
//
|
||||||
|
// All data arrives via props — no store imports, no fetching, no mutations.
|
||||||
|
|
||||||
|
import StatusPill from '$lib/components/StatusPill.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transcriptId: string;
|
||||||
|
rawTranscript: string;
|
||||||
|
cleanedTranscript: string;
|
||||||
|
cleanedSource: 'llm' | 'rule-based';
|
||||||
|
extractedTasks: string[];
|
||||||
|
microSteps?: string[];
|
||||||
|
onSelectTask?: (idx: number) => void;
|
||||||
|
onExport?: () => void;
|
||||||
|
onStartFirstMicroStep?: () => void;
|
||||||
|
onOpenInHistory?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
transcriptId,
|
||||||
|
rawTranscript,
|
||||||
|
cleanedTranscript,
|
||||||
|
cleanedSource,
|
||||||
|
extractedTasks,
|
||||||
|
microSteps,
|
||||||
|
onSelectTask,
|
||||||
|
onExport,
|
||||||
|
onStartFirstMicroStep,
|
||||||
|
onOpenInHistory,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Which task the user has selected (null = none). Drives MicroSteps reveal.
|
||||||
|
let selectedTaskIdx: number | null = $state(null);
|
||||||
|
|
||||||
|
// Bound elements for roving-tabindex keyboard navigation.
|
||||||
|
let taskBtnEls: (HTMLButtonElement | null)[] = $state([]);
|
||||||
|
|
||||||
|
function handleTaskClick(idx: number) {
|
||||||
|
selectedTaskIdx = idx;
|
||||||
|
onSelectTask?.(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation on the task list (roving tabindex pattern).
|
||||||
|
function handleTaskListKeydown(e: KeyboardEvent) {
|
||||||
|
if (extractedTasks.length === 0) return;
|
||||||
|
const current = selectedTaskIdx ?? 0;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = Math.min(current + 1, extractedTasks.length - 1);
|
||||||
|
selectedTaskIdx = next;
|
||||||
|
taskBtnEls[next]?.focus();
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = Math.max(current - 1, 0);
|
||||||
|
selectedTaskIdx = prev;
|
||||||
|
taskBtnEls[prev]?.focus();
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTaskClick(current);
|
||||||
|
if (canStartMicroStep) {
|
||||||
|
onStartFirstMicroStep?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep focused button in sync when selectedTaskIdx changes via click.
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedTaskIdx !== null) {
|
||||||
|
taskBtnEls[selectedTaskIdx]?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasMicroSteps = $derived(
|
||||||
|
selectedTaskIdx !== null && microSteps !== undefined && microSteps.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
let canStartMicroStep = $derived(
|
||||||
|
microSteps !== undefined && microSteps.length > 0
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="post-capture-card" aria-label="Captured transcript">
|
||||||
|
<!-- Header: title + saved pill -->
|
||||||
|
<header class="card-header">
|
||||||
|
<span class="card-title">Captured</span>
|
||||||
|
<StatusPill status="saved" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Cleaned transcript — primary surface -->
|
||||||
|
<section class="card-section cleaned-section">
|
||||||
|
<p class="section-meta">
|
||||||
|
{cleanedSource === 'llm' ? 'Cleaned by local model' : 'Cleaned by rules'}
|
||||||
|
</p>
|
||||||
|
<p class="transcript-text">{cleanedTranscript}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Raw transcript — progressive disclosure -->
|
||||||
|
<details class="raw-disclosure">
|
||||||
|
<summary class="raw-summary">Show raw transcript</summary>
|
||||||
|
<p class="transcript-text raw-text">{rawTranscript}</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Extracted tasks — only shown when there is at least one -->
|
||||||
|
{#if extractedTasks.length > 0}
|
||||||
|
<section class="card-section tasks-section">
|
||||||
|
<h3 class="section-heading">Tasks</h3>
|
||||||
|
<!-- Roving tabindex: first item in tab order; arrow keys move focus within the list. -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<ul
|
||||||
|
class="task-list"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Extracted tasks"
|
||||||
|
onkeydown={handleTaskListKeydown}
|
||||||
|
>
|
||||||
|
{#each extractedTasks as task, idx}
|
||||||
|
<li class="task-item" role="option" aria-selected={selectedTaskIdx === idx}>
|
||||||
|
<button
|
||||||
|
class="task-btn"
|
||||||
|
class:task-btn-selected={selectedTaskIdx === idx}
|
||||||
|
onclick={() => handleTaskClick(idx)}
|
||||||
|
type="button"
|
||||||
|
tabindex={idx === 0 || selectedTaskIdx === idx ? 0 : -1}
|
||||||
|
bind:this={taskBtnEls[idx]}
|
||||||
|
>
|
||||||
|
{task}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- MicroSteps for selected task — only shown once a task is selected -->
|
||||||
|
{#if hasMicroSteps}
|
||||||
|
<section class="card-section microsteps-section">
|
||||||
|
<h3 class="section-heading">MicroSteps</h3>
|
||||||
|
<ol class="microstep-list">
|
||||||
|
{#each microSteps! as step}
|
||||||
|
<li class="microstep-item">{step}</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<footer class="card-footer">
|
||||||
|
<button class="action-btn" onclick={onExport} type="button">
|
||||||
|
Export…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn-primary"
|
||||||
|
onclick={onStartFirstMicroStep}
|
||||||
|
disabled={!canStartMicroStep}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Start first MicroStep
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" onclick={onOpenInHistory} type="button">
|
||||||
|
Open in History
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* === Card shell === */
|
||||||
|
/* Mirrors bg-card · 1px subtle border · radius 20 · shadow-sm from
|
||||||
|
components-cards.html and Card.svelte (rounded-2xl, border-border-subtle). */
|
||||||
|
.post-capture-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Header === */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font: 600 15px/1 var(--font-family-body);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Sections === */
|
||||||
|
.card-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
font: 600 12px/1 var(--font-family-body);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Transcript text (shared by cleaned + raw) === */
|
||||||
|
.transcript-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Raw disclosure === */
|
||||||
|
.raw-disclosure {
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
/* Remove default triangle — matches SettingsGroup pattern */
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-summary:hover {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-summary:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Task list === */
|
||||||
|
.task-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
transition: background 100ms, border-color 100ms, color 100ms;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn-selected {
|
||||||
|
background: color-mix(in oklch, var(--color-accent) 12%, var(--color-bg-elevated));
|
||||||
|
border-color: color-mix(in oklch, var(--color-accent) 40%, var(--color-border-subtle));
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MicroSteps === */
|
||||||
|
.microstep-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 1.25em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.microstep-item {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Footer actions === */
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font: 500 12px/1 var(--font-family-body);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 100ms, border-color 100ms, color 100ms;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.38;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary action gets brand accent treatment */
|
||||||
|
.action-btn-primary {
|
||||||
|
background: color-mix(in oklch, var(--color-accent) 14%, var(--color-bg-elevated));
|
||||||
|
border-color: color-mix(in oklch, var(--color-accent) 35%, var(--color-border-subtle));
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary:hover:not(:disabled) {
|
||||||
|
background: color-mix(in oklch, var(--color-accent) 20%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honour prefers-reduced-motion for transitions */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.task-btn,
|
||||||
|
.action-btn {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.reduce-motion) .task-btn,
|
||||||
|
:global(html.reduce-motion) .action-btn {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
src/lib/components/StatusPill.svelte
Normal file
123
src/lib/components/StatusPill.svelte
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// StatusPill — v0.1 UI-hardening deliverable (section 10: "Status labels everywhere").
|
||||||
|
//
|
||||||
|
// Renders a small rounded-full pill with a leading dot + visible text label for
|
||||||
|
// every async / transcription state in Lumotia. Colour is SUPPLEMENTARY — the
|
||||||
|
// text label is always visible (WCAG perceivable).
|
||||||
|
//
|
||||||
|
// Animation (pulse on recording) honours prefers-reduced-motion via both the
|
||||||
|
// global html.reduce-motion class (set by preferences.svelte.js) and a plain
|
||||||
|
// @media query so it works even before that class is applied.
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| 'ready'
|
||||||
|
| 'recording'
|
||||||
|
| 'paused'
|
||||||
|
| 'transcribing'
|
||||||
|
| 'cleaning'
|
||||||
|
| 'extracting-tasks'
|
||||||
|
| 'saved'
|
||||||
|
| 'exported'
|
||||||
|
| 'needs-review'
|
||||||
|
| 'failed-safely';
|
||||||
|
|
||||||
|
let { status, label = undefined }: { status: Status; label?: string } = $props();
|
||||||
|
|
||||||
|
const DEFAULT_LABELS: Record<Status, string> = {
|
||||||
|
'ready': 'Ready',
|
||||||
|
'recording': 'Recording',
|
||||||
|
'paused': 'Paused',
|
||||||
|
'transcribing': 'Transcribing',
|
||||||
|
'cleaning': 'Cleaning',
|
||||||
|
'extracting-tasks':'Extracting tasks',
|
||||||
|
'saved': 'Saved',
|
||||||
|
'exported': 'Exported',
|
||||||
|
'needs-review': 'Needs review',
|
||||||
|
'failed-safely': 'Failed safely',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tailwind bg classes for the leading dot.
|
||||||
|
// Colour rationale (from semantic palette + existing DictationPage usage):
|
||||||
|
// ready → neutral (text-tertiary, no semantic colour — idle state)
|
||||||
|
// recording → danger (#e87171 — matches page.statusColor="#e87171" in DictationPage)
|
||||||
|
// paused → warning (#e8c86e — honey)
|
||||||
|
// transcribing → accent (progress / in-flight, uses brand warm accent)
|
||||||
|
// cleaning → accent (LLM in flight — same bucket as transcribing)
|
||||||
|
// extracting-tasks → accent (LLM in flight)
|
||||||
|
// saved → success (#7ec89a — moss green)
|
||||||
|
// exported → success
|
||||||
|
// needs-review → warning (#e8c86e — soft signal, data still editable)
|
||||||
|
// failed-safely → danger (#e87171 — failure, but labelled "safely" in copy)
|
||||||
|
const DOT_CLASS: Record<Status, string> = {
|
||||||
|
'ready': 'bg-text-tertiary',
|
||||||
|
'recording': 'bg-danger dot-pulse',
|
||||||
|
'paused': 'bg-warning',
|
||||||
|
'transcribing': 'bg-accent dot-pulse',
|
||||||
|
'cleaning': 'bg-accent dot-pulse',
|
||||||
|
'extracting-tasks':'bg-accent dot-pulse',
|
||||||
|
'saved': 'bg-success',
|
||||||
|
'exported': 'bg-success',
|
||||||
|
'needs-review': 'bg-warning',
|
||||||
|
'failed-safely': 'bg-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
let displayLabel = $derived(label ?? DEFAULT_LABELS[status]);
|
||||||
|
let dotClass = $derived(DOT_CLASS[status]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- aria-live="polite": screen readers announce state changes without interrupting -->
|
||||||
|
<span
|
||||||
|
class="pill"
|
||||||
|
aria-live="polite"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="dot {dotClass}"></span>
|
||||||
|
<span class="pill-label">{displayLabel}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem; /* 6px */
|
||||||
|
padding: 2px 8px 2px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--bg-elevated, rgba(255,255,255,0.06));
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
font: 500 11px/1 var(--font-body);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for active-process states (recording, transcribing, cleaning, extracting) */
|
||||||
|
@keyframes dot-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-pulse {
|
||||||
|
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honour prefers-reduced-motion via @media (covers cold load before
|
||||||
|
preferences.svelte.js sets html.reduce-motion) */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dot-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Also honour the class-based gate set by preferences.svelte.js */
|
||||||
|
:global(html.reduce-motion) .dot-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-text-secondary hover:text-danger opacity-0 group-hover:opacity-100 text-[12px]"
|
class="text-text-secondary hover:text-danger opacity-0 group-hover:opacity-100 text-[12px]"
|
||||||
onclick={() => deleteTask(task.id)}
|
onclick={() => { if (confirm("Delete this task? This cannot be undone.")) deleteTask(task.id); }}
|
||||||
aria-label="Delete task"
|
aria-label="Delete task"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
import { page, settings, templates, profiles, addToHistory, addTask, tasks, history } from "$lib/stores/page.svelte.js";
|
||||||
import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js";
|
import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js";
|
||||||
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
|
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
|
||||||
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
||||||
|
import StatusPill from "$lib/components/StatusPill.svelte";
|
||||||
|
import PostCaptureCard from "$lib/components/PostCaptureCard.svelte";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
|
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
let modelLoading = $state(false);
|
let modelLoading = $state(false);
|
||||||
let needsDownload = $state(false);
|
let needsDownload = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
let errorDetail = $state(""); // technical detail shown in <details>, separate from user-facing copy
|
||||||
let showExportMenu = $state(false);
|
let showExportMenu = $state(false);
|
||||||
let transcribing = $state(false);
|
let transcribing = $state(false);
|
||||||
let saved = $state(false);
|
let saved = $state(false);
|
||||||
@@ -204,7 +207,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status.type === "error") {
|
if (status.type === "error") {
|
||||||
error = status.message || "Live transcription failed";
|
// Sub-task D: plain-language error with technical detail behind <details>
|
||||||
|
error = "The live transcription hit a problem. Your audio up to this point is preserved — you can stop and review what was captured, or try again.";
|
||||||
|
errorDetail = status.message || "Live transcription failed";
|
||||||
transcriptionFailed = true;
|
transcriptionFailed = true;
|
||||||
page.status = "Error";
|
page.status = "Error";
|
||||||
page.statusColor = "#e87171";
|
page.statusColor = "#e87171";
|
||||||
@@ -231,7 +236,9 @@
|
|||||||
settings.language = "en";
|
settings.language = "en";
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = typeof err === "string" ? err : err.message || "Failed to check model";
|
// Sub-task D: plain-language error with technical detail behind <details>
|
||||||
|
error = "Lumotia couldn't check the transcription model. Try restarting the app.";
|
||||||
|
errorDetail = typeof err === "string" ? err : err?.message || "Failed to check model";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +287,9 @@
|
|||||||
page.status = "Ready";
|
page.status = "Ready";
|
||||||
page.statusColor = "#7ec89a";
|
page.statusColor = "#7ec89a";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = typeof err === "string" ? err : err.message || "Failed to load model";
|
// Sub-task D: plain-language error with technical detail behind <details>
|
||||||
|
error = "The transcription model couldn't load. Check Settings → Model and try again, or restart Lumotia.";
|
||||||
|
errorDetail = typeof err === "string" ? err : err?.message || "Failed to load model";
|
||||||
modelLoading = false;
|
modelLoading = false;
|
||||||
page.status = "Error";
|
page.status = "Error";
|
||||||
page.statusColor = "#e87171";
|
page.statusColor = "#e87171";
|
||||||
@@ -302,9 +311,11 @@
|
|||||||
|
|
||||||
async function startRecording() {
|
async function startRecording() {
|
||||||
error = "";
|
error = "";
|
||||||
|
errorDetail = "";
|
||||||
liveWarning = "";
|
liveWarning = "";
|
||||||
saved = false;
|
saved = false;
|
||||||
transcriptionFailed = false;
|
transcriptionFailed = false;
|
||||||
|
showPostCaptureCard = false; // Sub-task C: hide post-capture card when new recording begins
|
||||||
if (!tauriRuntimeAvailable) {
|
if (!tauriRuntimeAvailable) {
|
||||||
error = browserPreviewMessage;
|
error = browserPreviewMessage;
|
||||||
page.status = "Desktop app required";
|
page.status = "Desktop app required";
|
||||||
@@ -371,6 +382,10 @@
|
|||||||
page.statusColor = "#e87171";
|
page.statusColor = "#e87171";
|
||||||
timerInterval = setInterval(updateTimer, 1000);
|
timerInterval = setInterval(updateTimer, 1000);
|
||||||
|
|
||||||
|
// KI-02 / KI-03: inhibit OS idle/sleep for the duration of recording.
|
||||||
|
// Best-effort — a failure here must never block the recording path.
|
||||||
|
invoke("acquire_idle_inhibit").catch(() => {});
|
||||||
|
|
||||||
// Start cue (brief item #20). Off by default; volume user-set.
|
// Start cue (brief item #20). Off by default; volume user-set.
|
||||||
playStartCue(settings.soundCueVolume, settings.soundCues);
|
playStartCue(settings.soundCueVolume, settings.soundCues);
|
||||||
|
|
||||||
@@ -387,11 +402,13 @@
|
|||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = typeof err === "string" ? err : err?.message || "Microphone error";
|
// Sub-task D: plain-language error with technical detail behind <details>
|
||||||
|
errorDetail = typeof err === "string" ? err : err?.message || "Microphone error";
|
||||||
|
error = "Couldn't start the recording. Make sure your microphone is plugged in and Lumotia has permission, then try again.";
|
||||||
// Surface the failure as a sticky error toast so it does not get
|
// Surface the failure as a sticky error toast so it does not get
|
||||||
// swallowed if the inline `error` panel is not visible.
|
// swallowed if the inline `error` panel is not visible.
|
||||||
// (Day 3 of the upgrade plan)
|
// (Day 3 of the upgrade plan)
|
||||||
toasts.error("Could not start recording", error);
|
toasts.error("Could not start recording", errorDetail);
|
||||||
page.status = "Error";
|
page.status = "Error";
|
||||||
page.statusColor = "#e87171";
|
page.statusColor = "#e87171";
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -405,6 +422,10 @@
|
|||||||
page.status = "Finalising...";
|
page.status = "Finalising...";
|
||||||
page.statusColor = "#e8c86e";
|
page.statusColor = "#e8c86e";
|
||||||
|
|
||||||
|
// KI-02 / KI-03: release the OS idle/sleep inhibit acquired on start.
|
||||||
|
// Best-effort — a failure here must never block the stop path.
|
||||||
|
invoke("release_idle_inhibit").catch(() => {});
|
||||||
|
|
||||||
// Stop cue (brief item #20). "Recording over, finalising now."
|
// Stop cue (brief item #20). "Recording over, finalising now."
|
||||||
playStopCue(settings.soundCueVolume, settings.soundCues);
|
playStopCue(settings.soundCueVolume, settings.soundCues);
|
||||||
|
|
||||||
@@ -424,7 +445,9 @@
|
|||||||
await finaliseTranscription(response?.audioPath || null);
|
await finaliseTranscription(response?.audioPath || null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = typeof err === "string" ? err : err?.message || "Failed to stop live transcription";
|
// Sub-task D: plain-language error with technical detail behind <details>
|
||||||
|
errorDetail = typeof err === "string" ? err : err?.message || "Failed to stop live transcription";
|
||||||
|
error = "Something went wrong while stopping the recording. Any audio captured before this point is preserved in History.";
|
||||||
page.status = "Error";
|
page.status = "Error";
|
||||||
page.statusColor = "#e87171";
|
page.statusColor = "#e87171";
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -530,7 +553,11 @@
|
|||||||
if (settings.transcriptionPreview && settings.aiTier !== "off" && settings.formatMode !== "Raw") {
|
if (settings.transcriptionPreview && settings.aiTier !== "off" && settings.formatMode !== "Raw") {
|
||||||
emit("preview-cleanup").catch(() => {});
|
emit("preview-cleanup").catch(() => {});
|
||||||
}
|
}
|
||||||
|
const rawSnapshot = transcript; // preserve raw for PostCaptureCard
|
||||||
const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript);
|
const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript);
|
||||||
|
// Track whether LLM was used for cleanup (for PostCaptureCard cleanedSource prop).
|
||||||
|
// TODO v0.2: expose this from cleanupTranscriptIfEnabled return value.
|
||||||
|
const wasLlmCleaned = settings.aiTier !== "off" && settings.formatMode !== "Raw" && cleanedTranscript !== rawSnapshot;
|
||||||
if (cleanedTranscript !== transcript) {
|
if (cleanedTranscript !== transcript) {
|
||||||
transcript = cleanedTranscript;
|
transcript = cleanedTranscript;
|
||||||
replaceSegmentsWithCleanedText(cleanedTranscript);
|
replaceSegmentsWithCleanedText(cleanedTranscript);
|
||||||
@@ -598,6 +625,30 @@
|
|||||||
// aligned with the history entry + paste landing.
|
// aligned with the history entry + paste landing.
|
||||||
playCompleteCue(settings.soundCueVolume, settings.soundCues);
|
playCompleteCue(settings.soundCueVolume, settings.soundCues);
|
||||||
|
|
||||||
|
// Sub-task C: populate and show PostCaptureCard.
|
||||||
|
lastCapture = {
|
||||||
|
id: historyId,
|
||||||
|
raw: rawSnapshot, // pre-cleanup text captured before cleanupTranscriptIfEnabled
|
||||||
|
cleaned: transcript, // transcript is now the cleaned version (or raw if cleanup skipped)
|
||||||
|
cleanedSource: wasLlmCleaned ? 'llm' : 'rule-based',
|
||||||
|
tasks: extracted.map((t) => t.text),
|
||||||
|
};
|
||||||
|
showPostCaptureCard = true;
|
||||||
|
|
||||||
|
// Sub-task F: fire first_capture event (best-effort, gated on user opt-in).
|
||||||
|
if (prefs.recordActivationEvents) {
|
||||||
|
try {
|
||||||
|
const events = await invoke<Array<{ kind: string }>>("list_lumotia_events");
|
||||||
|
const isFirst = !events.some((e) => e.kind === "first_capture");
|
||||||
|
if (isFirst) {
|
||||||
|
await invoke("record_lumotia_event", { kind: "first_capture", payload: null });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not record first_capture event:", err);
|
||||||
|
// best-effort; never blocks the user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saved = true;
|
saved = true;
|
||||||
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
||||||
} else if (settings.transcriptionPreview) {
|
} else if (settings.transcriptionPreview) {
|
||||||
@@ -631,6 +682,7 @@
|
|||||||
shouldStickToBottom = false;
|
shouldStickToBottom = false;
|
||||||
page.timerText = "00:00";
|
page.timerText = "00:00";
|
||||||
error = "";
|
error = "";
|
||||||
|
errorDetail = "";
|
||||||
liveWarning = "";
|
liveWarning = "";
|
||||||
saved = false;
|
saved = false;
|
||||||
insertPos = -1;
|
insertPos = -1;
|
||||||
@@ -659,7 +711,9 @@
|
|||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("dictation export failed", err);
|
console.error("dictation export failed", err);
|
||||||
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
|
// Sub-task D: plain-language error with technical detail behind <details>
|
||||||
|
errorDetail = typeof err === "string" ? err : err?.message || String(err);
|
||||||
|
error = `The ${format.toUpperCase()} export didn't complete. Check that you have write access to the selected folder and try again.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -816,6 +870,18 @@
|
|||||||
|
|
||||||
let transcriptionFailed = $state(false);
|
let transcriptionFailed = $state(false);
|
||||||
|
|
||||||
|
// Post-capture card state
|
||||||
|
let showPostCaptureCard = $state(false);
|
||||||
|
// lastCapture holds the most recent finished recording data for PostCaptureCard.
|
||||||
|
// Shape matches TranscriptEntry fields (id, text, preview) plus extracted tasks.
|
||||||
|
let lastCapture = $state<{
|
||||||
|
id: string;
|
||||||
|
raw: string;
|
||||||
|
cleaned: string;
|
||||||
|
cleanedSource: 'llm' | 'rule-based';
|
||||||
|
tasks: string[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Screen-reader announcer for recording state transitions.
|
// Screen-reader announcer for recording state transitions.
|
||||||
// Announces both start and stop, then clears so the live region does not
|
// Announces both start and stop, then clears so the live region does not
|
||||||
// hold stale text. prevRecording is a plain let (not $state) because it is
|
// hold stale text. prevRecording is a plain let (not $state) because it is
|
||||||
@@ -846,9 +912,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- Control strip -->
|
<!-- Control strip -->
|
||||||
<div class="flex items-center gap-3 px-5 h-[56px] border-b border-border-subtle flex-shrink-0">
|
<div class="flex items-center gap-3 px-5 h-[56px] border-b border-border-subtle flex-shrink-0">
|
||||||
<!-- Record button -->
|
<!-- Record button — Sub-task B: enlarged to 80px (≥ 80px, primary CTA, impossible to miss) -->
|
||||||
<button
|
<button
|
||||||
class="relative flex items-center justify-center w-[48px] h-[48px] min-w-[48px] flex-shrink-0 rounded-full text-white
|
class="relative flex items-center justify-center w-[80px] h-[80px] min-w-[80px] flex-shrink-0 rounded-full btn-filled-text
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
{page.recording
|
{page.recording
|
||||||
? 'bg-danger animate-pulse-warm'
|
? 'bg-danger animate-pulse-warm'
|
||||||
: modelLoading
|
: modelLoading
|
||||||
@@ -861,11 +928,11 @@
|
|||||||
aria-label={page.recording ? "Stop recording" : "Start recording"}
|
aria-label={page.recording ? "Stop recording" : "Start recording"}
|
||||||
>
|
>
|
||||||
{#if page.recording}
|
{#if page.recording}
|
||||||
<span class="w-[16px] h-[16px] rounded-[3px] bg-white"></span>
|
<span class="w-[24px] h-[24px] rounded-[4px] bg-white"></span>
|
||||||
{:else if modelLoading}
|
{:else if modelLoading}
|
||||||
<Loader2 size={18} class="animate-spin" aria-hidden="true" />
|
<Loader2 size={28} class="animate-spin" aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="w-[16px] h-[16px] rounded-full bg-white/90"></span>
|
<span class="w-[24px] h-[24px] rounded-full bg-white/90"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<span class="text-[12px] font-medium flex-shrink-0 {page.recording ? 'text-danger' : 'text-text-secondary'}">
|
<span class="text-[12px] font-medium flex-shrink-0 {page.recording ? 'text-danger' : 'text-text-secondary'}">
|
||||||
@@ -912,17 +979,24 @@
|
|||||||
{page.timerText}
|
{page.timerText}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Status indicator -->
|
<!-- Status indicator — Sub-task A: <StatusPill> replaces hand-rolled pill -->
|
||||||
<span class="text-[12px] text-text-secondary flex-shrink-0 min-w-[60px] text-right">
|
<span class="flex-shrink-0">
|
||||||
{#if page.recording}
|
{#if page.recording}
|
||||||
<span class="inline-flex items-center gap-1.5">
|
<StatusPill status="recording" />
|
||||||
<span class="w-[6px] h-[6px] rounded-full bg-danger animate-pulse-soft"></span>
|
|
||||||
<span class="font-medium text-danger tracking-wide">REC</span>
|
|
||||||
</span>
|
|
||||||
{:else if modelLoading}
|
{:else if modelLoading}
|
||||||
<span class="text-warning">Loading</span>
|
<StatusPill status="transcribing" label="Loading model…" />
|
||||||
|
{:else if transcribing}
|
||||||
|
<StatusPill status="transcribing" />
|
||||||
|
{:else if saved}
|
||||||
|
<StatusPill status="saved" />
|
||||||
|
{:else if error}
|
||||||
|
{#if transcriptionFailed}
|
||||||
|
<StatusPill status="failed-safely" />
|
||||||
|
{:else}
|
||||||
|
<StatusPill status="needs-review" />
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-success">Ready</span>
|
<StatusPill status="ready" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -941,13 +1015,41 @@
|
|||||||
<span class="text-[12px] {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}">Tasks</span>
|
<span class="text-[12px] {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}">Tasks</span>
|
||||||
</span>
|
</span>
|
||||||
{#if taskCount > 0}
|
{#if taskCount > 0}
|
||||||
<span class="absolute -top-0.5 -right-0.5 text-[12px] px-1 py-0 rounded-full bg-accent text-white font-medium min-w-[14px] text-center leading-[14px]">
|
<span class="absolute -top-0.5 -right-0.5 text-[12px] px-1 py-0 rounded-full bg-accent btn-filled-text font-medium min-w-[14px] text-center leading-[14px]">
|
||||||
{taskCount}
|
{taskCount}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-task B: Profile + model summary line — answers "What is recording?" -->
|
||||||
|
{#if !page.recording}
|
||||||
|
<div class="px-5 py-1 border-b border-border-subtle flex-shrink-0 flex items-center justify-between">
|
||||||
|
<p class="profile-summary m-0 text-[11px] text-text-tertiary leading-none">
|
||||||
|
<small>Profile: {profilesStore.active?.name ?? page.activeProfile ?? '—'} · Model: {selectedModelId()}</small>
|
||||||
|
</p>
|
||||||
|
<!-- Sub-task B: Last-capture preview — answers "Where did my last capture go?" -->
|
||||||
|
{#if history.length > 0}
|
||||||
|
<details class="text-[11px] text-text-tertiary max-w-[50%]">
|
||||||
|
<summary class="cursor-pointer select-none hover:text-text-secondary list-none flex items-center gap-1">
|
||||||
|
<span class="text-accent">↩</span>
|
||||||
|
<span>Last capture</span>
|
||||||
|
</summary>
|
||||||
|
<div class="absolute right-5 mt-1 z-10 bg-bg-card border border-border rounded-lg shadow-lg p-3 max-w-[360px] animate-fade-in">
|
||||||
|
<p class="text-[11px] text-text-secondary m-0 mb-1">{history[0]?.date ?? ''}</p>
|
||||||
|
<p class="text-[12px] text-text m-0 line-clamp-2">
|
||||||
|
{(history[0]?.text ?? '').slice(0, 80)}{(history[0]?.text ?? '').length > 80 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="mt-2 text-[11px] text-accent hover:underline"
|
||||||
|
onclick={() => page.current = 'history'}
|
||||||
|
>Open in History →</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex items-center justify-end gap-1 px-5 py-2 border-b border-border-subtle flex-shrink-0">
|
<div class="flex items-center justify-end gap-1 px-5 py-2 border-b border-border-subtle flex-shrink-0">
|
||||||
<!-- Template selector -->
|
<!-- Template selector -->
|
||||||
@@ -1024,11 +1126,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error — Sub-task D: structured copy with technical detail behind <details> -->
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
||||||
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
|
<div class="px-4 py-3 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger space-y-2">
|
||||||
{error}
|
<div class="flex items-start gap-2">
|
||||||
|
{#if transcriptionFailed}
|
||||||
|
<StatusPill status="failed-safely" />
|
||||||
|
{:else}
|
||||||
|
<StatusPill status="needs-review" />
|
||||||
|
{/if}
|
||||||
|
<p class="m-0">{error}</p>
|
||||||
|
</div>
|
||||||
|
{#if errorDetail}
|
||||||
|
<details class="text-[11px] text-danger/70">
|
||||||
|
<summary class="cursor-pointer select-none hover:text-danger">Technical details</summary>
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-words">{errorDetail}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="text-[11px] underline hover:no-underline text-danger/80 hover:text-danger"
|
||||||
|
onclick={() => { error = ""; errorDetail = ""; }}
|
||||||
|
>Dismiss</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1041,6 +1162,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sub-task C: Post-capture card — shown after recording completes, hidden when new recording starts -->
|
||||||
|
{#if showPostCaptureCard && lastCapture && !page.recording}
|
||||||
|
<div class="px-5 pt-3 flex-shrink-0 animate-fade-in">
|
||||||
|
<PostCaptureCard
|
||||||
|
transcriptId={lastCapture.id}
|
||||||
|
rawTranscript={lastCapture.raw}
|
||||||
|
cleanedTranscript={lastCapture.cleaned}
|
||||||
|
cleanedSource={lastCapture.cleanedSource ?? 'rule-based'}
|
||||||
|
extractedTasks={lastCapture.tasks}
|
||||||
|
onSelectTask={(idx) => {
|
||||||
|
page.taskSidebarOpen = true;
|
||||||
|
}}
|
||||||
|
onExport={() => handleExport('txt')}
|
||||||
|
onStartFirstMicroStep={() => {}}
|
||||||
|
onOpenInHistory={() => { page.current = 'history'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Transcript area -->
|
<!-- Transcript area -->
|
||||||
<div class="flex-1 px-5 pt-3 pb-3 min-h-0" style="--text-transcript: {prefs.accessibility.transcriptSize}px; font-size: var(--text-transcript)">
|
<div class="flex-1 px-5 pt-3 pb-3 min-h-0" style="--text-transcript: {prefs.accessibility.transcriptSize}px; font-size: var(--text-transcript)">
|
||||||
<Card classes="h-full flex flex-col">
|
<Card classes="h-full flex flex-col">
|
||||||
@@ -1055,7 +1195,8 @@
|
|||||||
<textarea
|
<textarea
|
||||||
bind:this={textareaEl}
|
bind:this={textareaEl}
|
||||||
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
||||||
resize-none focus:outline-none placeholder:text-text-tertiary min-h-0 hidden"
|
resize-none placeholder:text-text-tertiary min-h-0 hidden
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||||
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
||||||
bind:value={transcript}
|
bind:value={transcript}
|
||||||
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
||||||
@@ -1068,7 +1209,8 @@
|
|||||||
<textarea
|
<textarea
|
||||||
bind:this={textareaEl}
|
bind:this={textareaEl}
|
||||||
class="font-transcript h-full w-full bg-transparent text-text p-6
|
class="font-transcript h-full w-full bg-transparent text-text p-6
|
||||||
resize-none focus:outline-none placeholder:text-text-tertiary"
|
resize-none placeholder:text-text-tertiary
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||||
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
||||||
bind:value={transcript}
|
bind:value={transcript}
|
||||||
onscroll={onTextareaScroll}
|
onscroll={onTextareaScroll}
|
||||||
@@ -1079,7 +1221,7 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
{#if showScrollHint}
|
{#if showScrollHint}
|
||||||
<button
|
<button
|
||||||
class="absolute bottom-3 right-6 px-3 py-1.5 rounded-full bg-accent text-white text-[12px] font-medium shadow-md hover:bg-accent-hover animate-fade-in"
|
class="absolute bottom-3 right-6 px-3 py-1.5 rounded-full bg-accent btn-filled-text text-[12px] font-medium shadow-md hover:bg-accent-hover animate-fade-in"
|
||||||
onclick={scrollToBottom}
|
onclick={scrollToBottom}
|
||||||
aria-label="Scroll to latest"
|
aria-label="Scroll to latest"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user