Files
shturman/docs/specs/plans/01-workspace-and-common.md
T
kk0t9 d443fb479b docs(specs): спека реализации v0.1/v0.6 (v1) + План 1
Спека после adversarial-ревью (17 находок) + позиция Slint (вариант A, финал отложен к v4).
План 1 — воркспейс + governance + shturman-common (TDD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:47:49 +03:00

26 KiB
Raw Blame History

План 1 — Воркспейс + governance + shturman-common

For agentic workers: REQUIRED SUB-SKILL: используй superpowers:subagent-driven-development (рекомендуется) или superpowers:executing-plans для исполнения по задачам. Шаги — чекбоксы (- [ ]) для трекинга.

Goal: поднять Rust-воркспейс «Штурмана» с зелёным гейтом (build/test/lint/deny), governance-файлами (MIT/DCO/CONTRIBUTING/README) и общей библиотекой shturman-common (layout /data, durable-write #5, монотонные часы, tracing→journald) под TDD.

Architecture: один Cargo-workspace; первый крейт shturman-common — фундамент всех сервисов/апов (не зависит ни от кого). Governance + CI-гейт с дня 1 (principles #12). Без Lima/systemd (они в Плане 5).

Tech Stack: Rust (workspace), tracing+tracing-journald (логи A10), tempfile (dev-тесты), cargo-deny (лицензии), just (команды), GitHub Actions ARM64.

Контекст: источник правды — спека v0.1/v0.6. Работаем в ветке от main (напр. feat/v0-foundation); в main не коммитим без явного «ок». Каждый коммит завершается Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>.


Task 1: Воркспейс + скелет shturman-common

Files:

  • Create: Cargo.toml (workspace)

  • Create: rust-toolchain.toml

  • Create: crates/shturman-common/Cargo.toml

  • Create: crates/shturman-common/src/lib.rs

  • Step 1: Создать Cargo.toml (workspace-манифест)

[workspace]
resolver = "2"
members = ["crates/*", "crates/core/*", "crates/apps/*", "crates/tools/*"]

[workspace.package]
edition = "2021"
license = "MIT"
rust-version = "1.83"

[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
zbus = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-journald = "0.3"
anyhow = "1"
thiserror = "1"
clap = { version = "4", features = ["derive"] }
# dev
tempfile = "3"
# slint — добавляется в Плане 4 (вместе с slint-exception в deny.toml)

Версии — опорные; точные пины фиксируются в Cargo.lock при первой сборке.

  • Step 2: Создать rust-toolchain.toml
[toolchain]
channel = "1.83.0"
components = ["rustfmt", "clippy"]
  • Step 3: Создать crates/shturman-common/Cargo.toml
[package]
name = "shturman-common"
version = "0.0.0"
edition.workspace = true
license.workspace = true

[dependencies]
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-journald.workspace = true
thiserror.workspace = true

[dev-dependencies]
tempfile.workspace = true
  • Step 4: Создать crates/shturman-common/src/lib.rs (скелет)
//! Общая инфраструктура Штурмана: layout `/data`, durable-write, монотонные часы, логи.

pub mod atomic;
pub mod clock;
pub mod log;
pub mod paths;

pub use atomic::write_atomic;
pub use clock::monotonic_secs;
pub use log::init_tracing;
pub use paths::Layout;

Создать пустые модули, чтобы собиралось (наполним в задачах 2–5):

crates/shturman-common/src/paths.rs, atomic.rs, clock.rs, log.rs — пока с заглушкой // TODO task N заменять нельзя по правилу плана; вместо этого создаём их сразу с минимальным валидным содержимым в следующих задачах. Для Step 4 создать 4 файла с одной строкой каждый:

// paths.rs / atomic.rs / clock.rs / log.rs — наполняется в задачах 2–5
  • Step 5: Проверить сборку

Run: cargo build --workspace Expected: Compiling shturman-common v0.0.0 … Finished (модули пустые — ок; lib.rs ссылается на pub-use из пустых модулей → ошибка «unresolved import»).

⚠️ Если Step 5 падает на unresolved import — это ожидаемо: переходим к задачам 2–5, которые добавят реальные символы. Чтобы Step 5 был зелёным изолированно, временно закомментируй pub use-строки в lib.rs и раскомментируй их по мере наполнения модулей.

  • Step 6: Commit
git add Cargo.toml rust-toolchain.toml crates/shturman-common
git commit -m "chore(workspace): Rust-воркспейс + скелет shturman-common

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: paths::Layout — раскладка /data (TDD)

Files:

  • Modify: crates/shturman-common/src/paths.rs

  • Test: тот же файл (#[cfg(test)])

  • Step 1: Написать падающий тест

crates/shturman-common/src/paths.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn subdirs_are_under_root() {
        let l = Layout::new("/tmp/x");
        assert_eq!(l.root(), Path::new("/tmp/x"));
        assert_eq!(l.apps(), Path::new("/tmp/x/apps"));
        assert_eq!(l.settings(), Path::new("/tmp/x/settings"));
        assert_eq!(l.state(), Path::new("/tmp/x/state"));
        assert_eq!(l.log(), Path::new("/tmp/x/log"));
        assert_eq!(l.provisioned_marker(), Path::new("/tmp/x/.shturman-provisioned"));
    }
}
  • Step 2: Запустить — убедиться, что падает

Run: cargo test -p shturman-common paths Expected: FAIL — cannot find type Layout / методы не определены.

  • Step 3: Реализовать Layout

В начало crates/shturman-common/src/paths.rs (над #[cfg(test)]):

//! Канонические пути персистентного раздела `/data` (a-base §3, §11).
//! Корень настраивается (тесты/dev) — по умолчанию `/data`, либо env `SHTURMAN_DATA_DIR`.

use std::path::{Path, PathBuf};

/// Раскладка `/data`. Все писатели в `/data` берут пути отсюда (нет хардкода).
#[derive(Debug, Clone)]
pub struct Layout {
    root: PathBuf,
}

impl Layout {
    /// Явный корень (используется в тестах и при known-mount).
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    /// Корень из окружения (`SHTURMAN_DATA_DIR`) или дефолт `/data`.
    pub fn from_env() -> Self {
        let root = std::env::var_os("SHTURMAN_DATA_DIR")
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from("/data"));
        Self { root }
    }

    pub fn root(&self) -> &Path {
        &self.root
    }
    pub fn apps(&self) -> PathBuf {
        self.root.join("apps")
    }
    pub fn settings(&self) -> PathBuf {
        self.root.join("settings")
    }
    pub fn state(&self) -> PathBuf {
        self.root.join("state")
    }
    pub fn log(&self) -> PathBuf {
        self.root.join("log")
    }
    /// Маркер завершённого first-boot (§7.2).
    pub fn provisioned_marker(&self) -> PathBuf {
        self.root.join(".shturman-provisioned")
    }
}
  • Step 4: Запустить — убедиться, что проходит

Run: cargo test -p shturman-common paths Expected: PASS (1 test).

  • Step 5: Commit
git add crates/shturman-common/src/paths.rs
git commit -m "feat(common): Layout — канонические пути /data

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: atomic::write_atomic — durable-write (TDD, доказательство #5)

Files:

  • Modify: crates/shturman-common/src/atomic.rs

  • Step 1: Написать падающие тесты (контракт + инвариант #5)

crates/shturman-common/src/atomic.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn persists_full_contents() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("settings.json");
        write_atomic(&p, b"{\"v\":1}").unwrap();
        assert_eq!(fs::read(&p).unwrap(), b"{\"v\":1}");
    }

    #[test]
    fn overwrites_and_leaves_no_tmp() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("settings.json");
        write_atomic(&p, b"old").unwrap();
        write_atomic(&p, b"new").unwrap();
        assert_eq!(fs::read(&p).unwrap(), b"new");
        assert!(!tmp_path(&p).exists(), "осиротевший .tmp не должен оставаться");
    }

    // Доказательство power-safe #5: прерывание ПОСЛЕ записи tmp, но ДО rename
    // не должно повреждать основной файл (остаётся прошлая полная версия).
    #[test]
    fn interrupted_before_rename_keeps_previous_version() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("settings.json");
        write_atomic(&p, b"v1").unwrap();
        // эмулируем краш между create(tmp) и rename: оставляем orphan tmp
        fs::write(tmp_path(&p), b"v2-incomplete").unwrap();
        // основной файл всё ещё v1 — rename не случился (главный инвариант #5)
        assert_eq!(fs::read(&p).unwrap(), b"v1");
    }
}
  • Step 2: Запустить — убедиться, что падает

Run: cargo test -p shturman-common atomic Expected: FAIL — cannot find function write_atomic / tmp_path.

  • Step 3: Реализовать durable-write

В начало crates/shturman-common/src/atomic.rs:

//! Durable atomic write (a-base §3 / principle #5):
//! `write-temp → fsync(file) → rename → fsync(dir)`.
//! Гарантия: после успеха `path` содержит новые данные целиком; при сбое до `rename`
//! сохраняется прежняя версия (или отсутствие файла) — никогда не частично записанный файл.
//! Расчёт на единственного писателя поддерева (Settings/State, architecture §3) — фикс. имя `.tmp`.

use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};

/// Путь временного файла рядом с целевым (`settings.json` → `settings.json.tmp`).
pub fn tmp_path(path: &Path) -> PathBuf {
    let name = path
        .file_name()
        .map(|n| format!("{}.tmp", n.to_string_lossy()))
        .unwrap_or_else(|| "shturman.tmp".to_string());
    path.with_file_name(name)
}

pub fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
    let dir = path.parent().ok_or_else(|| {
        io::Error::new(io::ErrorKind::InvalidInput, "путь без родительского каталога")
    })?;
    let tmp = tmp_path(path);

    {
        let mut f = File::create(&tmp)?;
        f.write_all(contents)?;
        f.sync_all()?; // fsync(file)
    }

    fs::rename(&tmp, path)?; // atomic в пределах одной ФС

    // fsync(dir) делает сам rename durable
    let dir_f = File::open(dir)?;
    dir_f.sync_all()?;
    Ok(())
}
  • Step 4: Запустить — убедиться, что проходит

Run: cargo test -p shturman-common atomic Expected: PASS (3 tests).

  • Step 5: Commit
git add crates/shturman-common/src/atomic.rs
git commit -m "feat(common): durable atomic write (power-safe #5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: clock::monotonic_secs — монотонные часы (TDD)

Files:

  • Modify: crates/shturman-common/src/clock.rs

  • Step 1: Написать падающий тест

crates/shturman-common/src/clock.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn non_decreasing() {
        let a = monotonic_secs();
        let b = monotonic_secs();
        assert!(b >= a, "монотонные секунды не должны идти назад");
    }
}
  • Step 2: Запустить — убедиться, что падает

Run: cargo test -p shturman-common clock Expected: FAIL — cannot find function monotonic_secs.

  • Step 3: Реализовать

В начало crates/shturman-common/src/clock.rs:

//! Монотонные часы (B §8): lifecycle-таймеры/`Uptime` — только вперёд, не прыгают при NTP/GPS-синке.
//! На Linux `Instant` опирается на `CLOCK_MONOTONIC`.

use std::sync::OnceLock;
use std::time::Instant;

static EPOCH: OnceLock<Instant> = OnceLock::new();

/// Секунды от первого обращения в процессе (монотонно). Достаточно для стаб-`Uptime` (v0).
pub fn monotonic_secs() -> u64 {
    let epoch = *EPOCH.get_or_init(Instant::now);
    epoch.elapsed().as_secs()
}
  • Step 4: Запустить — убедиться, что проходит

Run: cargo test -p shturman-common clock Expected: PASS (1 test).

  • Step 5: Commit
git add crates/shturman-common/src/clock.rs
git commit -m "feat(common): монотонные часы (B §8)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: log::init_tracing — tracing→journald (A10)

Files:

  • Modify: crates/shturman-common/src/log.rs

  • Step 1: Написать smoke-тест

crates/shturman-common/src/log.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn init_does_not_panic() {
        // глобальный subscriber ставится один раз; повторный вызов безопасен (try_init)
        init_tracing("shturman-test");
        tracing::info!("smoke");
    }
}
  • Step 2: Запустить — убедиться, что падает

Run: cargo test -p shturman-common log Expected: FAIL — cannot find function init_tracing.

  • Step 3: Реализовать

В начало crates/shturman-common/src/log.rs:

//! Инициализация логирования (A10): `tracing` → journald, если доступен; иначе stderr
//! (dev на macOS-хосте без journald). Политика volatile/rate-limit — на стороне journald (Плана 5).

use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;

/// Идемпотентно (повторный вызов проглатывается `try_init`). Уровень — из `RUST_LOG`, дефолт `info`.
pub fn init_tracing(service: &str) {
    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
    let registry = tracing_subscriber::registry().with(filter);

    match tracing_journald::layer() {
        Ok(journald) => {
            let _ = registry
                .with(journald.with_syslog_identifier(service.to_string()))
                .try_init();
        }
        Err(_) => {
            let _ = registry.with(tracing_subscriber::fmt::layer()).try_init();
        }
    }
}
  • Step 4: Запустить — весь крейт зелёный

Run: cargo test -p shturman-common Expected: PASS (все тесты paths/atomic/clock/log). Затем убедиться, что lib.rs pub use-строки раскомментированы и cargo build --workspace зелёный.

  • Step 5: Commit
git add crates/shturman-common/src/log.rs crates/shturman-common/src/lib.rs
git commit -m "feat(common): init_tracing → journald (A10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: Governance-файлы (LICENSE / DCO / CONTRIBUTING / README)

Files:

  • Create: LICENSE, DCO, CONTRIBUTING.md, README.md

  • Step 1: LICENSE — дословно из спеки §10 (MIT, Copyright (c) 2026 K9 Shturman).

  • Step 2: DCO — текст Developer Certificate of Origin 1.1:

Developer Certificate of Origin
Version 1.1

Copyright (C) 2004, 2006 The Linux Foundation and its contributors.

Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.


Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
  • Step 3: CONTRIBUTING.md — развернуть спеку §11 в прозу (13 пунктов: философия; красные линии #1–#2; docs как источник правды; лицензионная гигиена #12 + LGPL гранулярно + slint-exception; рабочий цикл спека→TDD→verify→commit; стиль rustfmt/clippy; тесты #13; коммиты/ветки + Co-Authored-By; PR-чеклист; DCO -s; CoC-указатель; где обсуждать). Завершить ссылкой на CLAUDE.md и docs/.

  • Step 4: README.md — мини-спека (спека §11 п.13):

# Штурман

Open-source русскоязычный companion-слой («ОС поверх Linux») для авто на **RK3588**: быстрый Slint-UI +
голосовой RU-ассистент, читающий OBD/CAN **только на чтение**, + расширяемый Plugin API. Лицензия **MIT**.

## Красные линии (нерушимы)
- **Никогда не safety-critical** (двигатель/тормоза/ABS/ESP/руль/подушки — нет actuator-путей).
- **CAN только на чтение** (OBD-II read; запрещены write/actuator/Mode-04/UDS-write).

## Документация
- Точка входа: [`CLAUDE.md`](CLAUDE.md) · дизайн (источник правды): [`docs/`](docs/) ·
  план реализации: [`docs/roadmap.md`](docs/roadmap.md), спеки — [`docs/specs/`](docs/specs/).

## Быстрый старт (dev, Lima-VM — появляется в Плане 5)

just vm-up # поднять dev-VM (Lima) just run # boot → стаб-сервисы на D-Bus → первый Slint-кадр just ci # lint + test + deny


## Лицензия
MIT (см. [`LICENSE`](LICENSE)). Контрибьюции — по DCO (`git commit -s`, см. [`CONTRIBUTING.md`](CONTRIBUTING.md)).
**Примечание:** UI-тулкит Slint для embedded доступен бесплатно под GPL-3.0 → шипимый UI-бинарь прод-образа
(v4) будет под GPL-3.0; решение по тулкиту/лицензии отложено к v4 (см. `docs/specs/`).
  • Step 5: Commit
git add LICENSE DCO CONTRIBUTING.md README.md
git commit -m "docs(governance): LICENSE (MIT) + DCO + CONTRIBUTING + README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 7: deny.toml + cargo-deny зелёный

Files:

  • Create: deny.toml

  • Step 1: Создать deny.toml

# Лицензионная гигиена (#12) + advisories. slint GPL-3.0-exception добавится в Плане 4.
[advisories]
version = 2

[licenses]
version = 2
allow = [
    "MIT",
    "Apache-2.0",
    "BSD-2-Clause",
    "BSD-3-Clause",
    "ISC",
    "Unicode-3.0",
    "Unicode-DFS-2016",
    "Zlib",
]
confidence-threshold = 0.9
# LGPL — гранулярно (не blanket-deny): рассматриваем точечно при появлении (#12, spec §3).

[bans]
multiple-versions = "warn"

[sources]
unknown-registry = "deny"
unknown-git = "deny"
  • Step 2: Установить cargo-deny (если нет) и проверить

Run: cargo install cargo-deny --locked (один раз) · затем cargo deny check Expected: licenses ok, advisories ok, bans ok, sources ok — на текущем графе (только пермиссивные зависимости shturman-common). Если какой-то транзитив имеет лицензию вне allow (напр. Unicode-3.0 у unicode-ident) — добавить её в allow (она пермиссивная) и перезапустить.

  • Step 3: Commit
git add deny.toml
git commit -m "chore(license): deny.toml — allow-list + advisories (#12)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 8: justfile (ядро команд) + CI-workflow

Files:

  • Create: justfile

  • Create: .github/workflows/ci.yml

  • Step 1: Создать justfile (ядро; vm-*/run/e2e — в следующих планах)

# Штурман — единые dev-команды (расширяется по планам).
set shell := ["bash", "-uc"]

# список целей
default:
    @just --list

# собрать весь воркспейс
build:
    cargo build --workspace

# тесты (unit + integration)
test:
    cargo test --workspace

# линт: формат + clippy (warnings = ошибки)
lint:
    cargo fmt --all --check
    cargo clippy --workspace --all-targets -- -D warnings

# лицензии + advisories
deny:
    cargo deny check

# полный локальный гейт
ci: lint test deny
  • Step 2: Создать .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy
      - run: cargo fmt --all --check
      - run: cargo clippy --workspace --all-targets -- -D warnings

  test:
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test --workspace

  license:
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@v4
      - uses: EmbarkStudios/cargo-deny-action@v2

⚠️ ARM64-раннеры (ubuntu-24.04-arm) — если вне бесплатного тира, фолбэк: ubuntu-latest (x86) + кросс/--target (dev-environment §CI). prod-build-gate (--no-default-features, §8.3 спеки) добавится в Плане 3/4, когда появится фича dev-mocks.

  • Step 3: Проверить полный гейт локально

Run: just ci Expected: lint (fmt+clippy чисто) → test (все тесты common зелёные) → deny (ok). Всё зелёное.

  • Step 4: Commit
git add justfile .github/workflows/ci.yml
git commit -m "chore(dev): justfile (ядро) + CI-гейт (lint/test/deny)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Self-review (по спеке)

  • Покрытие спеки: Task 1–8 закрывают из §2.1 «Governance» (LICENSE/CONTRIBUTING/DCO/README + deny.toml), часть «v0.6 dev-харнесс» (justfile-ядро, CI) и фундамент крейтов (shturman-common — §4.2: layout /data, durable-write, монотонные часы, tracing→journald). Lima/systemd/сервисы — Планы 3–5 (не forward-ref здесь).
  • Power-safe #5: durable-write реализован и доказан unit-тестом прерывания до rename (спека §3/§9.1) — не reboot.
  • #12: deny.toml + CI-гейт с дня 1; slint-exception явно отложен в Task (Плана 4), когда slint войдёт в граф.
  • Плейсхолдеры: код полный; CONTRIBUTING.md (Task 6 Step 3) ссылается на §11 спеки как на источник содержания — развернуть в прозу при исполнении (не оставлять списком).
  • Типы/имена: Layout, write_atomic/tmp_path, monotonic_secs, init_tracing — согласованы с lib.rs re-export и будущими потребителями (firstboot/settings/power — План 3).

Готово, когда (acceptance Плана 1)

  • just ci зелёный локально (lint + test + deny).
  • cargo test -p shturman-common — paths/atomic/clock/log проходят; тест прерывания durable-write зелёный.
  • Governance-файлы на месте (LICENSE/DCO/CONTRIBUTING.md/README.md/deny.toml).
  • CI-workflow присутствует (зелёный на ARM64-раннере или x86-фолбэке).

Дальше

План 2 — shturman-ipc (контракт: Error/имена/энумы/#[proxy] Power1+Settings1) + shturman-sdk (connect/SettingsClient/PowerClient/manifest).