Files
Skald-Circle/crates/skald-core/src/db/projects.rs
T
dguiducci 2ec394c17c
Nightly Build / build (push) Successful in 6m27s
Projects: shareable, registry-backed, container-mounted; drop ticket board
Rework projects from single-user leftovers into shareable endeavours.

- DB: move `projects` from the owner bucket to the registry (system.db,
  not encrypted); add `owner_user_id` + `slug` (drop free `path`); new
  `project_members(project_id, user_id, can_write)` mirroring
  `shared_folder_members`. Drop `project_tickets` entirely. Only user↔agent
  conversations stay encrypted (per-user DB) — each member keeps a private
  project chat. Registry home dissolves the cross-DB-FK problem.
- Filesystem/container: on disk `{WD}/projects/{owner_userid}/{slug}`,
  agent/container path `projects/{owner_username}/{slug}`. Two-segment routing
  in UserFs (ProjectMount + host_base_and_tail arm) and a second loop in
  build_user_fs; read-only members get a :ro mount. Reuse the shared-folder
  remount machinery (refresh_user_shared_folders -> refresh_user_mounts).
- Remove the ticket system: ProjectTicketManager, UserContext.tickets, its
  wiring, and the project_tickets references in scheduled_jobs/cron.
- API: repoint handlers to the registry pool + membership scoping. Sharing is
  self-service — owner or any write-member may add/remove members and set
  read/write; only the owner deletes; the owner cannot be removed. New
  POST/DELETE /api/projects/{id}/members[/{user_id}]. Seed `@fs_any allow
  projects/*`; build_runtime_run_context sets working_directory to the agent
  path and drops the host-path allow_fs_writes.
- Frontend: create form without the free path field, owner/read-write badges;
  the detail page becomes header + description + sharing panel + Open chat + a
  file-explorer placeholder (the future primary surface). i18n en/it/fr.
2026-07-20 22:21:10 +01:00

165 lines
5.3 KiB
Rust

//! Projects: shareable endeavours over an on-disk folder (registry / `system.db`).
//!
//! A project is an *endeavour* (owner + membership + metadata) that HAS a *place*:
//! a folder `{WD}/projects/{owner_userid}/{slug}` bind-mounted into each member's
//! container (the membership lives in [`super::project_members`]). This module owns
//! the `projects` row itself. Registry table — metadata is **not** encrypted (§2/§6);
//! only user↔agent conversations stay in the per-user encrypted DB.
use anyhow::Result;
use sqlx::SqlitePool;
/// A project row.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Project {
pub id: i64,
pub owner_user_id: String,
/// Display name (free text).
pub name: String,
/// Path component — the on-disk folder + agent-visible segment. Immutable.
pub slug: String,
pub description: String,
pub run_context: Option<String>,
pub created_at: String,
pub updated_at: String,
}
const SELECT: &str =
"SELECT id, owner_user_id, name, slug, description, run_context, created_at, updated_at
FROM projects";
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Project>> {
let row = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Creates a project and returns the new row. The caller must ensure `slug` is valid
/// ([`is_valid_slug`]) and unique for `owner_user_id` ([`unique_slug`]); the owner is
/// added to `project_members` separately (so mounts are uniform).
pub async fn create(
pool: &SqlitePool,
owner_user_id: &str,
name: &str,
slug: &str,
description: &str,
run_context: Option<&str>,
) -> Result<Project> {
let id = sqlx::query(
"INSERT INTO projects (owner_user_id, name, slug, description, run_context)
VALUES (?, ?, ?, ?, ?)",
)
.bind(owner_user_id)
.bind(name)
.bind(slug)
.bind(description)
.bind(run_context)
.execute(pool)
.await?
.last_insert_rowid();
let row = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
.bind(id)
.fetch_one(pool)
.await?;
Ok(row)
}
/// Updates the mutable fields. `slug` and `owner_user_id` are immutable — changing the
/// slug would move the on-disk folder and break every member's path.
pub async fn update(
pool: &SqlitePool,
id: i64,
name: &str,
description: &str,
run_context: Option<&str>,
) -> Result<bool> {
let n = sqlx::query(
"UPDATE projects
SET name = ?, description = ?, run_context = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(name)
.bind(description)
.bind(run_context)
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
/// Touch `updated_at` so recency ordering works.
pub async fn touch(pool: &SqlitePool, id: i64) -> Result<()> {
sqlx::query("UPDATE projects SET updated_at = datetime('now') WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
let n = sqlx::query("DELETE FROM projects WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
// ── Slug helpers ───────────────────────────────────────────────────────────────
/// A slug must be a single safe path component: it becomes a real directory
/// `{WD}/projects/{owner}/{slug}` and a `docker` mount target, so it may not be
/// empty, be a `.`/`..` traversal, or contain a separator. Same rule as
/// [`super::shared_folders::is_valid_folder_name`].
pub fn is_valid_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug != "."
&& slug != ".."
&& !slug.contains('/')
&& !slug.contains('\\')
&& !slug.contains('\0')
}
/// Best-effort slugify of a display name: lowercase ASCII alphanumerics, every other
/// run collapsed to a single `-`, trimmed. Falls back to `project` when nothing is left.
pub fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_dash = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() { "project".to_string() } else { trimmed.to_string() }
}
/// Returns a slug unique within `owner_user_id`, appending `-2`, `-3`, … on collision.
pub async fn unique_slug(pool: &SqlitePool, owner_user_id: &str, base: &str) -> Result<String> {
let existing: Vec<String> = sqlx::query_scalar(
"SELECT slug FROM projects WHERE owner_user_id = ?",
)
.bind(owner_user_id)
.fetch_all(pool)
.await?;
if !existing.iter().any(|s| s == base) {
return Ok(base.to_string());
}
let mut n = 2;
loop {
let cand = format!("{base}-{n}");
if !existing.iter().any(|s| s == &cand) {
return Ok(cand);
}
n += 1;
}
}