Projects: shareable, registry-backed, container-mounted; drop ticket board
Nightly Build / build (push) Successful in 6m27s
Nightly Build / build (push) Successful in 6m27s
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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
pub mod approval_rules;
|
||||
pub mod project_tickets;
|
||||
pub mod project_members;
|
||||
pub mod projects;
|
||||
pub mod chat_history;
|
||||
pub mod chat_llm_tools;
|
||||
@@ -488,6 +488,42 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// ── Projects: shareable endeavours over an on-disk folder (blueprint §5 memory
|
||||
// note / §6). A project is an *endeavour* with an owner + membership that HAS a
|
||||
// *place*: a folder `{WD}/projects/{owner_userid}/{slug}` bind-mounted into each
|
||||
// member's container (like a shared folder, but two path segments — owner + slug).
|
||||
// Registry tables — metadata is NOT encrypted (only user↔agent conversations are);
|
||||
// this lets a project be shared across members without the cross-DB-FK problem that
|
||||
// an owner-bucket table would hit. `owner_user_id → users(id)` is registry→registry.
|
||||
// The owner is also inserted as a `project_members` row (can_write=1) so mounts are
|
||||
// uniform (a private project = a project with one member).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
run_context TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (owner_user_id, slug)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
can_write INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// ── MCP catalog + globally-active instances (blueprint §7/§14/§15) ──────────
|
||||
//
|
||||
// Registry tables: instance-wide MCP config, listable without any user key so
|
||||
@@ -901,40 +937,10 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
run_context TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS project_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'todo'
|
||||
CHECK(status IN ('todo','pending','in_progress','done','failed')),
|
||||
agent_id TEXT NOT NULL DEFAULT 'main',
|
||||
run_context TEXT,
|
||||
job_id INTEGER REFERENCES scheduled_jobs(id),
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// NOTE: `projects` + `project_members` are **registry** tables (see
|
||||
// `create_registry_tables`) — shareable, not encrypted. The old owner-bucket
|
||||
// `projects`/`project_tickets` tables (single-user Skald leftover) were removed
|
||||
// when projects became a shareable, container-mounted endeavour.
|
||||
|
||||
// Full request/response payloads for telemetry. Lives in the owner bucket
|
||||
// (per-user, encrypted) because it is conversation content. Correlated with
|
||||
@@ -1060,8 +1066,6 @@ mod tests {
|
||||
one("INSERT INTO mcp_events (source, method, payload) VALUES ('s', 'm', '{}')").await.unwrap();
|
||||
one("INSERT INTO sources (id, active_session_id) VALUES ('web', 1)").await.unwrap();
|
||||
one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap();
|
||||
one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap();
|
||||
one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap();
|
||||
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
|
||||
// Fires the AFTER INSERT trigger into the external-content FTS5 table.
|
||||
one("INSERT INTO memory_docs (path, content) VALUES ('notes/x.md', 'hello world')").await.unwrap();
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Project membership (registry / `system.db`): who can reach a project and with what
|
||||
//! capability. Mirrors [`super::shared_folders`]'s membership model — a junction table
|
||||
//! so a member can be read-only, and so both the container mount topology and the
|
||||
//! "shared with me / owner badge" list can query it in either direction.
|
||||
//!
|
||||
//! Two path segments distinguish it from a shared folder: a project lives at
|
||||
//! `projects/{owner_username}/{slug}`, so the mount rows carry the owner's **userid**
|
||||
//! (the host path segment, stable) and **username** (the agent-visible segment).
|
||||
//! FK `user_id → users(id)` is registry→registry (same file) — allowed.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// One project a user can reach, resolved for building their container mounts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectMountRow {
|
||||
pub project_id: i64,
|
||||
/// Owner's userid — the **host** path segment (`{WD}/projects/{owner_userid}/{slug}`).
|
||||
pub owner_user_id: String,
|
||||
/// Owner's username — the **agent-visible / container** path segment.
|
||||
pub owner_username: String,
|
||||
pub slug: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
/// A project as it appears in a user's list: identity, owner, the caller's capability,
|
||||
/// and whether the caller owns it. `owner_name` is `display_name || username`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectAccess {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub owner_user_id: String,
|
||||
pub owner_name: String,
|
||||
pub is_owner: bool,
|
||||
pub can_write: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// One member of a project — used by the share panel and the mount topology.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectMember {
|
||||
pub user_id: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Every project a user belongs to, resolved for their container mounts (owner's
|
||||
/// userid + username + slug + capability). Drives `build_user_fs`.
|
||||
pub async fn list_for_user_mounts(pool: &SqlitePool, user_id: &str) -> Result<Vec<ProjectMountRow>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String, i64)>(
|
||||
"SELECT p.id, p.owner_user_id, u.username, p.slug, m.can_write
|
||||
FROM project_members m
|
||||
JOIN projects p ON p.id = m.project_id
|
||||
JOIN users u ON u.id = p.owner_user_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY u.username, p.slug",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(project_id, owner_user_id, owner_username, slug, can_write)| ProjectMountRow {
|
||||
project_id,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
slug,
|
||||
can_write: can_write != 0,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The projects a user can see (owned + shared-with-them), for the UI list. Ordered by
|
||||
/// recency. `is_owner` distinguishes owned from shared (the owner-badge signal).
|
||||
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<ProjectAccess>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String, String, String, i64, i64, String)>(
|
||||
"SELECT p.id, p.name, p.slug, p.description, p.owner_user_id,
|
||||
COALESCE(NULLIF(ou.display_name, ''), ou.username) AS owner_name,
|
||||
(p.owner_user_id = ?) AS is_owner,
|
||||
m.can_write, p.updated_at
|
||||
FROM project_members m
|
||||
JOIN projects p ON p.id = m.project_id
|
||||
JOIN users ou ON ou.id = p.owner_user_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.updated_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, slug, description, owner_user_id, owner_name, is_owner, can_write, updated_at)| {
|
||||
ProjectAccess {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
owner_user_id,
|
||||
owner_name,
|
||||
is_owner: is_owner != 0,
|
||||
can_write: can_write != 0,
|
||||
updated_at,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The members of a project — the set of users whose containers mount it.
|
||||
pub async fn members(pool: &SqlitePool, project_id: i64) -> Result<Vec<ProjectMember>> {
|
||||
let rows = sqlx::query_as::<_, (String, i64)>(
|
||||
"SELECT user_id, can_write FROM project_members WHERE project_id = ?",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(user_id, can_write)| ProjectMember { user_id, can_write: can_write != 0 })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The caller's capability on a project: `None` when not a member, `Some(can_write)`
|
||||
/// otherwise. The authority check for reads (member) and writes/share (write-member).
|
||||
pub async fn capability_of(pool: &SqlitePool, project_id: i64, user_id: &str) -> Result<Option<bool>> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT can_write FROM project_members WHERE project_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(w,)| w != 0))
|
||||
}
|
||||
|
||||
// ── Writes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Adds (or updates the capability of) a member. Idempotent on the PK.
|
||||
pub async fn add_member(
|
||||
pool: &SqlitePool,
|
||||
project_id: i64,
|
||||
user_id: &str,
|
||||
can_write: bool,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO project_members (project_id, user_id, can_write)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (project_id, user_id) DO UPDATE SET can_write = excluded.can_write",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(user_id)
|
||||
.bind(can_write as i64)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_member(pool: &SqlitePool, project_id: i64, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM project_members WHERE project_id = ? AND user_id = ?")
|
||||
.bind(project_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
async fn registry_pool(tag: &str) -> (SqlitePool, PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("skald-projectmembers-{}-{tag}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pool = crate::db::init_system_pool(&dir.join("system.db").to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
(pool, dir)
|
||||
}
|
||||
|
||||
/// Proves the registry-→registry FK `project_members.user_id → users(id)` inserts
|
||||
/// with `PRAGMA foreign_keys=ON`, and that owned/shared are distinguished.
|
||||
#[tokio::test]
|
||||
async fn membership_list_distinguishes_owner_and_shared() {
|
||||
let (pool, dir) = registry_pool("list").await;
|
||||
for (id, name, display) in
|
||||
[("u1", "alice", None), ("u2", "bob", Some("Bob"))]
|
||||
{
|
||||
sqlx::query("INSERT INTO users (id, username, display_name, role_id, encrypted) VALUES (?, ?, ?, 'admin', 0)")
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(display)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Alice owns "budget", is a write-member of her own project.
|
||||
let p = super::super::projects::create(&pool, "u1", "Budget", "budget", "the money", None)
|
||||
.await
|
||||
.unwrap();
|
||||
add_member(&pool, p.id, "u1", true).await.unwrap();
|
||||
// Shared read-only with Bob.
|
||||
add_member(&pool, p.id, "u2", false).await.unwrap();
|
||||
|
||||
let alice = list_for_user(&pool, "u1").await.unwrap();
|
||||
assert_eq!(alice.len(), 1);
|
||||
assert!(alice[0].is_owner);
|
||||
assert!(alice[0].can_write);
|
||||
assert_eq!(alice[0].owner_name, "alice");
|
||||
|
||||
let bob = list_for_user(&pool, "u2").await.unwrap();
|
||||
assert_eq!(bob.len(), 1);
|
||||
assert!(!bob[0].is_owner);
|
||||
assert!(!bob[0].can_write);
|
||||
assert_eq!(bob[0].owner_name, "alice"); // owner is alice (no display name)
|
||||
|
||||
// Mount rows carry both userid and username of the owner.
|
||||
let mounts = list_for_user_mounts(&pool, "u2").await.unwrap();
|
||||
assert_eq!(mounts.len(), 1);
|
||||
assert_eq!(mounts[0].owner_user_id, "u1");
|
||||
assert_eq!(mounts[0].owner_username, "alice");
|
||||
assert_eq!(mounts[0].slug, "budget");
|
||||
assert!(!mounts[0].can_write);
|
||||
|
||||
assert_eq!(capability_of(&pool, p.id, "u2").await.unwrap(), Some(false));
|
||||
assert_eq!(capability_of(&pool, p.id, "nobody").await.unwrap(), None);
|
||||
|
||||
drop(pool);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct ProjectTicket {
|
||||
pub id: i64,
|
||||
pub project_id: i64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub agent_id: String,
|
||||
pub run_context: Option<String>,
|
||||
pub job_id: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: String,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub session_id: Option<i64>,
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT pt.id, pt.project_id, pt.title, pt.description, pt.status, pt.agent_id,
|
||||
pt.run_context, pt.job_id, pt.result, pt.error, pt.created_at,
|
||||
pt.started_at, pt.completed_at,
|
||||
COALESCE(sj.running_session_id,
|
||||
(SELECT session_id FROM job_runs
|
||||
WHERE job_id = pt.job_id ORDER BY id DESC LIMIT 1)
|
||||
) AS session_id
|
||||
FROM project_tickets pt
|
||||
LEFT JOIN scheduled_jobs sj ON sj.id = pt.job_id";
|
||||
|
||||
pub async fn list_for_project(pool: &SqlitePool, project_id: i64) -> Result<Vec<ProjectTicket>> {
|
||||
let rows = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.project_id = ? ORDER BY pt.id"
|
||||
)))
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<ProjectTicket>> {
|
||||
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.id = ?"
|
||||
)))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
project_id: i64,
|
||||
title: &str,
|
||||
description: &str,
|
||||
agent_id: &str,
|
||||
run_context: Option<&str>,
|
||||
) -> Result<ProjectTicket> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO project_tickets (project_id, title, description, agent_id, run_context)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.bind(agent_id)
|
||||
.bind(run_context)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.id = ?"
|
||||
)))
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
let n = sqlx::query("DELETE FROM project_tickets WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
pub async fn set_status(pool: &SqlitePool, id: i64, status: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE project_tickets SET status = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark as in_progress and record the scheduled job that is running it.
|
||||
pub async fn start(pool: &SqlitePool, id: i64, job_id: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = 'in_progress', job_id = ?, started_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark as done or failed, recording result/error and timestamp.
|
||||
pub async fn complete(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
result: Option<&str>,
|
||||
error: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let status = if error.is_some() { "failed" } else { "done" };
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = ?, result = ?, error = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(result)
|
||||
.bind(error)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset a ticket back to todo, clearing all run state.
|
||||
pub async fn reset(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = 'todo', job_id = NULL, result = NULL, error = NULL,
|
||||
started_at = NULL, completed_at = NULL
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,30 +1,33 @@
|
||||
//! 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 name: String,
|
||||
pub path: String,
|
||||
pub description: String,
|
||||
pub run_context: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
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, name, path, description, run_context, created_at, updated_at
|
||||
"SELECT id, owner_user_id, name, slug, description, run_context, created_at, updated_at
|
||||
FROM projects";
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<Project>> {
|
||||
let rows = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} ORDER BY updated_at DESC"
|
||||
)))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -33,19 +36,24 @@ pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Project>> {
|
||||
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,
|
||||
name: &str,
|
||||
path: &str,
|
||||
description: &str,
|
||||
run_context: Option<&str>,
|
||||
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 (name, path, description, run_context)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO projects (owner_user_id, name, slug, description, run_context)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(owner_user_id)
|
||||
.bind(name)
|
||||
.bind(path)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(run_context)
|
||||
.execute(pool)
|
||||
@@ -59,22 +67,21 @@ pub async fn create(
|
||||
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,
|
||||
path: &str,
|
||||
description: &str,
|
||||
run_context: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE projects
|
||||
SET name = ?, path = ?, description = ?, run_context = ?,
|
||||
updated_at = datetime('now')
|
||||
SET name = ?, description = ?, run_context = ?, updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(path)
|
||||
.bind(description)
|
||||
.bind(run_context)
|
||||
.bind(id)
|
||||
@@ -84,7 +91,7 @@ pub async fn update(
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Touch updated_at — called after every ticket operation so ordering by recency works.
|
||||
/// 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)
|
||||
@@ -101,3 +108,57 @@ pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,13 +122,6 @@ pub async fn create(
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
// Clear the soft back-reference from project_tickets first: its job_id FK has
|
||||
// no ON DELETE action, so a ticket still pointing at this job would block the
|
||||
// scheduled_jobs DELETE with a FOREIGN KEY constraint failure.
|
||||
sqlx::query("UPDATE project_tickets SET job_id = NULL WHERE job_id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM job_runs WHERE job_id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
|
||||
Reference in New Issue
Block a user