From 2ec394c17cb6d235447e0b9ca1bc040378f51111 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 20 Jul 2026 22:21:10 +0100 Subject: [PATCH] Projects: shareable, registry-backed, container-mounted; drop ticket board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agents/project-coordinator/AGENT.md | 2 +- crates/core-api/src/user_fs.rs | 51 +- crates/skald-core/src/approval/mod.rs | 13 +- crates/skald-core/src/container/mod.rs | 24 +- crates/skald-core/src/cron/mod.rs | 10 - crates/skald-core/src/db/mod.rs | 78 +-- crates/skald-core/src/db/project_members.rs | 241 ++++++++++ crates/skald-core/src/db/project_tickets.rs | 149 ------ crates/skald-core/src/db/projects.rs | 121 +++-- crates/skald-core/src/db/scheduled_jobs.rs | 7 - crates/skald-core/src/projects/mod.rs | 114 +---- crates/skald-core/src/projects/tickets.rs | 178 ------- crates/skald-core/src/skald/accessors.rs | 9 +- crates/skald-core/src/skald/bundles.rs | 15 +- crates/skald-core/src/skald/user_context.rs | 20 - crates/skald-core/src/skald/wiring.rs | 1 - crates/skald-core/src/tools/fs/mod.rs | 20 +- src/frontend/api/mod.rs | 6 +- src/frontend/api/projects.rs | 457 +++++++++--------- src/frontend/api/sessions.rs | 2 +- src/frontend/api/shared_folders.rs | 2 +- web/components/projects/project-board.js | 503 ++++++-------------- web/components/projects/project-list.js | 38 +- web/i18n/en.js | 49 +- web/i18n/fr.js | 49 +- web/i18n/it.js | 49 +- 26 files changed, 963 insertions(+), 1245 deletions(-) create mode 100644 crates/skald-core/src/db/project_members.rs delete mode 100644 crates/skald-core/src/db/project_tickets.rs delete mode 100644 crates/skald-core/src/projects/tickets.rs diff --git a/agents/project-coordinator/AGENT.md b/agents/project-coordinator/AGENT.md index 30805e9..8a52dad 100644 --- a/agents/project-coordinator/AGENT.md +++ b/agents/project-coordinator/AGENT.md @@ -28,7 +28,7 @@ Delegate work to these task specialists via `execute_task` / `execute_subtask`: Your system prompt already contains, without you asking: -- The project's **name**, **description**, and **working directory** (the project root — all relative file paths resolve there). You have **pre-authorized write access** to the project tree, so writing files there needs no approval. +- The project's **name**, **description**, and **working directory** (the project root — all relative file paths resolve there). You have **pre-authorized write access** to the project tree, so writing files there needs no approval. A project may be **shared** with other members (read-only or read-write): anything you write into the project folder is visible to everyone it is shared with, so keep private, user-specific notes in `user-memory/` rather than in a shared project. - **`user-memory/index.md`** and **`shared-memory/index.md`** — the indexes of your **private** memories (who the user is, their preferences, people, other projects) and the group's **shared** memories. Both are injected automatically. Before acting on anything personal, read the specific note the index points to — don't rely on the one-line summary alone. - **`SKALD.md`** at the project root — this project's **living diary** (see below). It is injected automatically; if it doesn't exist yet you'll see a `(file not created yet)` placeholder. diff --git a/crates/core-api/src/user_fs.rs b/crates/core-api/src/user_fs.rs index ffdbf76..1c3f745 100644 --- a/crates/core-api/src/user_fs.rs +++ b/crates/core-api/src/user_fs.rs @@ -8,6 +8,7 @@ //! | `user-memory/…` | SQLite (the user's pool) — routed *before* this | //! | `shared-memory/…` | SQLite (`system.db`) — routed *before* this | //! | `shared/{X}/…` | host `{WD}/shared/{X}`, mount `{home}/shared/{X}` | +//! | `projects/{O}/{S}`| host `{WD}/projects/{owner_userid}/{S}`, mount `{home}/projects/{O}/{S}` (O = owner username) | //! | `~/…`, relative | host `{WD}/homes/{userid}`, mount `{container_home}`| //! //! `UserFs` is a **pure value type** with no filesystem access: it carries the @@ -33,6 +34,25 @@ pub struct SharedMount { pub can_write: bool, } +/// One project folder mounted into a user's container. Unlike a shared folder its +/// agent path has **two** segments — `projects/{owner_username}/{slug}` — because a +/// project is namespaced by its owner (two members can each own a `budget`). The host +/// path keys on the owner's stable **userid**, the agent/container path on the +/// (mutable) **username**. +#[derive(Debug, Clone)] +pub struct ProjectMount { + /// The owner's username — the first agent-visible segment under `projects/`. + pub owner_username: String, + /// The project slug — the second agent-visible segment. + pub slug: String, + /// Absolute host directory that backs it (`{WD}/projects/{owner_userid}/{slug}`). + pub host: PathBuf, + /// Where it is mounted inside the container (`{home}/projects/{owner_username}/{slug}`). + pub container: PathBuf, + /// Whether this member may write to it. + pub can_write: bool, +} + /// The filesystem view of one user: their private home plus the shared folders /// they belong to, and the container those are mounted into. #[derive(Debug, Clone)] @@ -46,6 +66,8 @@ pub struct UserFs { pub container_home: PathBuf, /// Shared folders this user can reach, in name order. pub shared: Vec, + /// Projects this user can reach (owned + shared-with-them), by owner then slug. + pub projects: Vec, } impl UserFs { @@ -55,6 +77,7 @@ impl UserFs { container_name: impl Into, container_home: PathBuf, shared: Vec, + projects: Vec, ) -> Self { Self { user_id: user_id.into(), @@ -62,6 +85,7 @@ impl UserFs { container_name: container_name.into(), container_home, shared, + projects, } } @@ -70,12 +94,22 @@ impl UserFs { self.shared.iter().find(|m| m.name == name) } + /// Look up a project mount by its owner username + slug (the two agent segments). + pub fn project_mount(&self, owner_username: &str, slug: &str) -> Option<&ProjectMount> { + self.projects + .iter() + .find(|m| m.owner_username == owner_username && m.slug == slug) + } + /// The bind mounts for `docker create`: `(host, container, writable)`, home first. pub fn mounts(&self) -> Vec<(PathBuf, PathBuf, bool)> { let mut out = vec![(self.home_host.clone(), self.container_home.clone(), true)]; for m in &self.shared { out.push((m.host.clone(), m.container.clone(), m.can_write)); } + for m in &self.projects { + out.push((m.host.clone(), m.container.clone(), m.can_write)); + } out } @@ -100,14 +134,25 @@ impl UserFs { let mount = self.shared_mount(name)?; Some((mount.host.clone(), tail.to_string())) } + Some("projects") => { + // Two segments: `projects/{owner_username}/{slug}/{tail…}`. + let rest = parts.next().unwrap_or(""); + let mut seg = rest.splitn(3, ['/', '\\']); + let owner = seg.next().unwrap_or(""); + let slug = seg.next().unwrap_or(""); + let tail = seg.next().unwrap_or(""); + let mount = self.project_mount(owner, slug)?; + Some((mount.host.clone(), tail.to_string())) + } _ => Some((self.home_host.clone(), stripped.to_string())), } } /// Map an agent path to its **container** path (pure, lexical): `~`/relative → - /// under `container_home`; `shared/{X}` → under `container_home/shared/{X}`; an - /// already-absolute path is taken as a container path as-is. Used to set the - /// working directory of an `execute_cmd` inside the container. + /// under `container_home`; `shared/{X}` and `projects/{O}/{S}` → under + /// `container_home/…` (they mirror the container layout); an already-absolute path + /// is taken as a container path as-is. Used to set the working directory of an + /// `execute_cmd` inside the container. pub fn to_container(&self, agent_path: &str) -> PathBuf { let p = Path::new(agent_path); if p.is_absolute() { diff --git a/crates/skald-core/src/approval/mod.rs b/crates/skald-core/src/approval/mod.rs index 27b5cb2..5d9c623 100644 --- a/crates/skald-core/src/approval/mod.rs +++ b/crates/skald-core/src/approval/mod.rs @@ -355,6 +355,10 @@ impl ApprovalManager { ("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/"), ("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/"), ("@fs_any", Some("data/*"), "allow", "auto-allow data/"), + // Project folders (`projects/{owner}/{slug}`, blueprint §6): reads + writes + // frictionless, matching the working-project UX. A read-only member's mount + // is `:ro`, so a write physically fails regardless of this allow. + ("@fs_any", Some("projects/*"), "allow", "auto-allow projects/"), ("memory_search", None, "allow", "allow memory_search"), ]; @@ -1169,15 +1173,15 @@ mod tests { .unwrap(); assert_eq!(legacy, 0, "legacy fs rules should be removed by migration"); - // …and replaced by exactly the four @fs_* token rows (shared-memory has two: - // read-allow and write-require). + // …and replaced by exactly the five @fs_* token rows (shared-memory has two: + // read-allow and write-require; plus user-memory, data, and projects). let fs_rows: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'", ) .fetch_one(db.as_ref()) .await .unwrap(); - assert_eq!(fs_rows, 4, "user-memory + shared-memory(r/w) + data @fs_* rules should be seeded"); + assert_eq!(fs_rows, 5, "user-memory + shared-memory(r/w) + data + projects @fs_* rules should be seeded"); // Gate decisions through the real check() path. async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult { @@ -1192,6 +1196,9 @@ mod tests { assert!(matches!(decide(&mgr, "write_file", "shared-memory/casa.md").await, GateResult::Require)); assert!(matches!(decide(&mgr, "edit_file", "shared-memory/casa.md").await, GateResult::Require)); assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow)); + // project folders auto-allow reads and writes (subtree match on projects/*). + assert!(matches!(decide(&mgr, "write_file", "projects/alice/budget/x.md").await, GateResult::Allow)); + assert!(matches!(decide(&mgr, "read_file", "projects/alice/budget").await, GateResult::Allow)); // memory_search is allowed by a path-less tool rule (it has `query`, not `path`). assert!(matches!(decide(&mgr, "memory_search", "ignored").await, GateResult::Allow)); // The on-disk secrets store is gone, and with it its blanket deny: `secrets/` diff --git a/crates/skald-core/src/container/mod.rs b/crates/skald-core/src/container/mod.rs index d7bdc25..af9ba02 100644 --- a/crates/skald-core/src/container/mod.rs +++ b/crates/skald-core/src/container/mod.rs @@ -23,7 +23,7 @@ use std::time::Duration; use anyhow::{bail, Context, Result}; use sqlx::SqlitePool; -use core_api::user_fs::{SharedMount, UserFs}; +use core_api::user_fs::{ProjectMount, SharedMount, UserFs}; use crate::db; @@ -38,6 +38,9 @@ const DOCKERFILE: &str = include_str!("Dockerfile"); pub const HOMES_DIR: &str = "homes"; /// Subdirectory of the working directory holding shared folders. pub const SHARED_DIR: &str = "shared"; +/// Subdirectory of the working directory holding project folders +/// (`{WD}/projects/{owner_userid}/{slug}`). +pub const PROJECTS_DIR: &str = "projects"; /// Home mount point inside the container. pub const CONTAINER_HOME: &str = "/root"; /// Grace window `docker stop` gives in-container processes (SIGTERM → SIGKILL) @@ -69,7 +72,24 @@ pub async fn build_user_fs(system: &SqlitePool, user_id: &str) -> Result }) .collect(); - Ok(UserFs::new(user_id, home_host, container_name(user_id), container_home, shared)) + // Projects (owned + shared-with-them). Host keys on the owner's stable userid; the + // agent/container path keys on the owner's username (`projects/{owner_username}/{slug}`). + let project_rows = db::project_members::list_for_user_mounts(system, user_id).await?; + let projects = project_rows + .into_iter() + .map(|p| ProjectMount { + container: container_home + .join(PROJECTS_DIR) + .join(&p.owner_username) + .join(&p.slug), + host: wd.join(PROJECTS_DIR).join(&p.owner_user_id).join(&p.slug), + owner_username: p.owner_username, + slug: p.slug, + can_write: p.can_write, + }) + .collect(); + + Ok(UserFs::new(user_id, home_host, container_name(user_id), container_home, shared, projects)) } /// Owns the container lifecycle: the docker availability check, the runtime image, diff --git a/crates/skald-core/src/cron/mod.rs b/crates/skald-core/src/cron/mod.rs index eaf67d3..4d0b342 100644 --- a/crates/skald-core/src/cron/mod.rs +++ b/crates/skald-core/src/cron/mod.rs @@ -663,16 +663,6 @@ async fn cleanup_expired_single_runs(pool: &SqlitePool) -> Result<()> { AND enabled = 0 AND last_run_at < datetime('now', '-7 days')"; - // Clear the soft back-reference from project_tickets first: its job_id FK has - // no ON DELETE action, so a ticket still pointing at an expired runner job - // would block the DELETE below with a FOREIGN KEY constraint failure. The - // ticket keeps its result/error — only the (now-GC'd) job pointer is dropped. - sqlx::query(sqlx::AssertSqlSafe(format!( - "UPDATE project_tickets SET job_id = NULL WHERE job_id IN ({EXPIRED})" - ))) - .execute(pool) - .await?; - sqlx::query(sqlx::AssertSqlSafe(format!( "DELETE FROM job_runs WHERE job_id IN ({EXPIRED})" ))) diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index 33fa19b..aaa5373 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -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(); diff --git a/crates/skald-core/src/db/project_members.rs b/crates/skald-core/src/db/project_members.rs new file mode 100644 index 0000000..c969a3f --- /dev/null +++ b/crates/skald-core/src/db/project_members.rs @@ -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> { + 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> { + 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> { + 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> { + 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); + } +} diff --git a/crates/skald-core/src/db/project_tickets.rs b/crates/skald-core/src/db/project_tickets.rs deleted file mode 100644 index 87b8128..0000000 --- a/crates/skald-core/src/db/project_tickets.rs +++ /dev/null @@ -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, - pub job_id: Option, - pub result: Option, - pub error: Option, - pub created_at: String, - pub started_at: Option, - pub completed_at: Option, - pub session_id: Option, -} - -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> { - 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> { - 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 { - 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 { - 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(()) -} diff --git a/crates/skald-core/src/db/projects.rs b/crates/skald-core/src/db/projects.rs index 9d7b84a..a39c371 100644 --- a/crates/skald-core/src/db/projects.rs +++ b/crates/skald-core/src/db/projects.rs @@ -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, - 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, + 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> { - 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> { 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> { 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 { 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 { 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 { .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 { + let existing: Vec = 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; + } +} diff --git a/crates/skald-core/src/db/scheduled_jobs.rs b/crates/skald-core/src/db/scheduled_jobs.rs index ad09862..74bb403 100644 --- a/crates/skald-core/src/db/scheduled_jobs.rs +++ b/crates/skald-core/src/db/scheduled_jobs.rs @@ -122,13 +122,6 @@ pub async fn create( } pub async fn delete(pool: &SqlitePool, id: i64) -> Result { - // 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) diff --git a/crates/skald-core/src/projects/mod.rs b/crates/skald-core/src/projects/mod.rs index fcb2909..d416dfd 100644 --- a/crates/skald-core/src/projects/mod.rs +++ b/crates/skald-core/src/projects/mod.rs @@ -1,106 +1,34 @@ -pub mod tickets; - -use std::sync::Arc; - -use anyhow::Result; -use sqlx::SqlitePool; - -use crate::db::projects::{self, Project}; +use crate::db::projects::Project; use crate::run_context::RunContext; -pub struct ProjectManager { - db: Arc, -} - -impl ProjectManager { - pub fn new(db: Arc) -> Self { - Self { db } - } - - pub async fn list(&self) -> Result> { - projects::list(&self.db).await - } - - pub async fn get(&self, id: i64) -> Result> { - projects::get(&self.db, id).await - } - - pub async fn create( - &self, - name: &str, - path: &str, - description: &str, - run_context: Option<&RunContext>, - ) -> Result { - let rc_json = run_context.map(|rc| rc.to_db()); - projects::create(&self.db, name, path, description, rc_json.as_deref()).await - } - - pub async fn update( - &self, - id: i64, - name: &str, - path: &str, - description: &str, - run_context: Option<&RunContext>, - ) -> Result { - let rc_json = run_context.map(|rc| rc.to_db()); - projects::update(&self.db, id, name, path, description, rc_json.as_deref()).await - } - - pub async fn delete(&self, id: i64) -> Result { - projects::delete(&self.db, id).await - } -} - -/// Builds the runtime `RunContext` for working on `project`, layering project-runtime -/// fields over an optional pre-resolved `base` RC (which carries static config set at -/// creation time, e.g. `security_group`). +/// Builds the runtime `RunContext` for working on `project`, layering the project's +/// working directory + a context header over an optional pre-resolved `base` RC (which +/// carries static config set at creation time, e.g. `security_group`). /// -/// Runtime fields computed here: -/// - `working_directory` — always set to `project.path`. -/// - `allow_fs_writes` — project tree + Skald's own `data/` directory. -/// - `system_prompt` — project-context fragments prepended before any stored ones. -/// -/// Shared by `ProjectTicketManager::start` (background ticket jobs) and the interactive -/// project-chat session provisioning, so both work with identical context. -pub fn build_runtime_run_context(project: &Project, base: Option) -> RunContext { +/// `working_directory` is the **agent path** `projects/{owner_username}/{slug}` — the +/// same namespace the fs-tools and `execute_cmd` route through (the host/container +/// mapping is handled by `UserFs`). Writes there are auto-allowed by the seeded +/// `projects/*` approval rule and physically gated by the per-member read-only mount, +/// so no host-path `allow_fs_writes` grant is needed (that was the old single-user +/// model, which predated per-user containers). +pub fn build_runtime_run_context( + project: &Project, + owner_username: &str, + base: Option, +) -> RunContext { let mut rc = base.unwrap_or_default(); - // Working directory is always the project path, overwritten at build time. - rc.working_directory = Some(project.path.clone()); + rc.working_directory = Some(format!("projects/{owner_username}/{}", project.slug)); - // Absolute path to Skald's own data directory (user personal data store). - let skald_data = std::env::current_dir() - .unwrap_or_default() - .join("data") - .to_string_lossy() - .into_owned(); - - // Grant write access to the project tree and Skald's data directory. - if !rc.allow_fs_writes.contains(&project.path) { - rc.allow_fs_writes.push(project.path.clone()); - } - if !rc.allow_fs_writes.contains(&skald_data) { - rc.allow_fs_writes.push(skald_data.clone()); - } - - // Build runtime context fragments and prepend before any stored ones. - // Note: working directory is intentionally omitted here — the date/time/OS/WD - // tail block in MessageBuilder already reflects the effective WD from RunContext. let project_header = if project.description.is_empty() { format!("You are working on project \"{}\".", project.name) } else { - format!("You are working on project \"{}\". Description: {}", project.name, project.description) - }; - let mut injected = vec![ - project_header, format!( - "Personal user data is available at: {}. \ - Consult it when the task requires knowledge about the user.", - skald_data - ), - ]; + "You are working on project \"{}\". Description: {}", + project.name, project.description + ) + }; + let mut injected = vec![project_header]; injected.extend(std::mem::take(&mut rc.system_prompt)); rc.system_prompt = injected; diff --git a/crates/skald-core/src/projects/tickets.rs b/crates/skald-core/src/projects/tickets.rs deleted file mode 100644 index b10d946..0000000 --- a/crates/skald-core/src/projects/tickets.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use sqlx::SqlitePool; -use tokio_util::sync::CancellationToken; -use tracing::warn; - -use core_api::system_bus::{SystemEvent, SystemEventBus}; - -use crate::cron::TaskManager; -use crate::db::{project_tickets, project_tickets::ProjectTicket, projects}; -use crate::run_context::RunContext; - -pub struct ProjectTicketManager { - db: Arc, - task_mgr: std::sync::OnceLock>, -} - -impl ProjectTicketManager { - pub fn new(db: Arc) -> Arc { - Arc::new(Self { - db, - task_mgr: std::sync::OnceLock::new(), - }) - } - - pub fn set_task_manager(&self, tm: Arc) { - let _ = self.task_mgr.set(tm); - } - - /// Subscribe to the system bus and react to `JobCompleted` events whose - /// `origin_ref` starts with `"PROJECT_TASK:"`. Spawns a background task. - pub fn start_listener( - self: Arc, - system_bus: Arc, - shutdown: CancellationToken, - ) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let mut rx = system_bus.subscribe(); - loop { - tokio::select! { - _ = shutdown.cancelled() => break, - res = rx.recv() => { - match res { - Ok(SystemEvent::JobCompleted { origin_ref: Some(ref s), result, error, .. }) - if s.starts_with("PROJECT_TASK:") => - { - if let Some(tid) = s.strip_prefix("PROJECT_TASK:") - .and_then(|n| n.parse::().ok()) - { - if let Err(e) = self.on_job_completed( - tid, - result.as_deref(), - error.as_deref(), - ).await { - warn!(error = %e, ticket_id = tid, "ticket completion failed"); - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!("ProjectTicketManager: system_bus lagged by {n} events"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - _ => {} - } - } - } - } - }) - } - - // ── CRUD ───────────────────────────────────────────────────────────────── - - pub async fn list(&self, project_id: i64) -> Result> { - project_tickets::list_for_project(&self.db, project_id).await - } - - pub async fn get(&self, id: i64) -> Result> { - project_tickets::get(&self.db, id).await - } - - pub async fn create( - &self, - project_id: i64, - title: &str, - description: &str, - agent_id: &str, - run_context: Option<&RunContext>, - ) -> Result { - let rc_json = run_context.map(|rc| rc.to_db()); - let ticket = project_tickets::create( - &self.db, project_id, title, description, agent_id, rc_json.as_deref(), - ).await?; - projects::touch(&self.db, project_id).await?; - Ok(ticket) - } - - pub async fn delete(&self, id: i64) -> Result { - let ticket = project_tickets::get(&self.db, id).await?; - let found = project_tickets::delete(&self.db, id).await?; - if found { - if let Some(t) = ticket { - projects::touch(&self.db, t.project_id).await?; - } - } - Ok(found) - } - - // ── Lifecycle ───────────────────────────────────────────────────────────── - - /// Builds a runtime RunContext and starts the ticket as a background job. - /// - /// The stored RC (ticket → project) carries only static config set at creation - /// time (e.g. `security_group`). All runtime fields are computed here: - /// - `working_directory` — always set to `project.path` - /// - `allow_fs_writes` — project tree + Skald's own `data/` directory - /// - `system_prompt` — project context fragments prepended before any stored ones - pub async fn start(&self, ticket_id: i64) -> Result<()> { - let task_mgr = self.task_mgr.get() - .ok_or_else(|| anyhow!("ProjectTicketManager: task_manager not initialized"))?; - - let ticket = project_tickets::get(&self.db, ticket_id).await? - .ok_or_else(|| anyhow!("ticket {ticket_id} not found"))?; - let project = projects::get(&self.db, ticket.project_id).await? - .ok_or_else(|| anyhow!("project {} not found", ticket.project_id))?; - - // Resolve base RC (ticket override → project default → empty), then layer the - // project-runtime fields (WD, fs-write grants, project-context system prompt). - // The stored RC carries only static config (e.g. security_group set at creation). - let base: Option = - ticket.run_context.as_deref().and_then(RunContext::from_db) - .or_else(|| project.run_context.as_deref().and_then(RunContext::from_db)); - let rc = super::build_runtime_run_context(&project, base); - - let origin_ref = format!("PROJECT_TASK:{ticket_id}"); - let rc_json = rc.to_db(); - - let job = task_mgr.spawn_async_job( - &ticket.title, - &ticket.description, - &ticket.description, - &ticket.agent_id, - Some(&rc_json), - &origin_ref, - )?; - - project_tickets::start(&self.db, ticket_id, job.id).await?; - projects::touch(&self.db, ticket.project_id).await?; - Ok(()) - } - - /// Called when a `SystemEvent::JobCompleted` with matching `origin_ref` is received. - async fn on_job_completed( - &self, - ticket_id: i64, - result: Option<&str>, - error: Option<&str>, - ) -> Result<()> { - let project_id = project_tickets::get(&self.db, ticket_id).await? - .map(|t| t.project_id); - project_tickets::complete(&self.db, ticket_id, result, error).await?; - if let Some(pid) = project_id { - projects::touch(&self.db, pid).await?; - } - Ok(()) - } - - /// Reset a ticket back to todo, clearing all run state. - pub async fn reset(&self, ticket_id: i64) -> Result<()> { - let project_id = project_tickets::get(&self.db, ticket_id).await? - .map(|t| t.project_id); - project_tickets::reset(&self.db, ticket_id).await?; - if let Some(pid) = project_id { - projects::touch(&self.db, pid).await?; - } - Ok(()) - } -} diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index 1ec6e19..2b2897a 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -34,8 +34,6 @@ use crate::location::LocationManager; use crate::mcp::McpManager; use crate::memory::MemoryManager; use crate::plugin::PluginManager; -use crate::projects::tickets::ProjectTicketManager; -use crate::projects::ProjectManager; use crate::provider::ProviderRegistry; use crate::run_context::RunContextManager; use crate::secrets::SecretsStore; @@ -86,7 +84,10 @@ impl Skald { /// /// Best-effort by contract: the membership row is already committed, so a Docker /// hiccup must not fail the caller; the state settles at the next login/boot. - pub async fn refresh_user_shared_folders(&self, user_id: &str) -> anyhow::Result<()> { + /// + /// Covers both shared-folder and project membership changes — both feed + /// `build_user_fs`, so a recreate reflows either mount set. + pub async fn refresh_user_mounts(&self, user_id: &str) -> anyhow::Result<()> { // New mount topology (graceful stop → remove → recreate from current rows). self.container().recreate(user_id).await?; @@ -146,8 +147,6 @@ impl Skald { // Tasks pub fn cron(&self) -> &Arc { &self.tasks.cron } - pub fn projects(&self) -> &Arc { &self.tasks.projects } - pub fn ticket_manager(&self) -> &Arc { &self.tasks.ticket_manager } // Conversation pub fn manager(&self) -> &Arc { &self.conversation.manager } diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index f1c608d..f63c8e5 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -30,8 +30,6 @@ use crate::location::LocationManager; use crate::mcp::McpManager; use crate::memory::MemoryManager; use crate::plugin::PluginManager; -use crate::projects::tickets::ProjectTicketManager; -use crate::projects::ProjectManager; use crate::provider::ProviderRegistry; use crate::run_context::RunContextManager; use crate::secrets::SecretsStore; @@ -174,12 +172,10 @@ impl Integrations { } } -// ── Tasks: cron + projects/tickets ────────────────────────────────────────── +// ── Tasks: cron ────────────────────────────────────────────────────────────── pub(super) struct Tasks { - pub(super) cron: Arc, - pub(super) projects: Arc, - pub(super) ticket_manager: Arc, + pub(super) cron: Arc, } impl Tasks { @@ -193,11 +189,7 @@ impl Tasks { }); let cron = TaskManager::new(Arc::clone(&rt.db), cron_tz, Arc::clone(&rt.system_bus)); - let ticket_manager = ProjectTicketManager::new(Arc::clone(&rt.db)); - let projects = Arc::new(ProjectManager::new(Arc::clone(&rt.db))); - info!("project manager ready"); - - Tasks { cron, projects, ticket_manager } + Tasks { cron } } } @@ -353,6 +345,7 @@ impl Conversation { "skald-ownerless", std::path::PathBuf::from("/root"), Vec::new(), + Vec::new(), )); let manager = Arc::new(ChatSessionManager::new( diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index b3818d1..a53d8b2 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -51,7 +51,6 @@ use crate::inbox::Inbox; use crate::llm::LlmManager; use crate::mcp::{McpManager, McpProvider, UserMcpView}; use crate::memory::MemoryManager; -use crate::projects::tickets::ProjectTicketManager; use crate::run_context::RunContextManager; use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS}; use crate::session::manager::ChatSessionManager; @@ -74,7 +73,6 @@ pub struct UserContext { pub sessions: Arc, pub chat_hub: Arc, pub cron: Arc, - pub tickets: Arc, pub approval: Arc, pub clarification: Arc, pub elicitation: Arc, @@ -291,29 +289,12 @@ impl UserContextFactory { cron.set_self_arc(Arc::clone(&cron)); chat_hub.set_task_mgr(Arc::clone(&cron)); - // Per-user ticket manager — wired to the per-user TaskManager so - // `start_ticket` spawns jobs in the user's own pool. - let tickets = ProjectTicketManager::new(Arc::clone(&pool)); - tickets.set_task_manager(Arc::clone(&cron)); - // Per-user cron loop. `start()` observes the shutdown token, so it stops on // shutdown; adopting it lets the supervisor also join it. The name is leaked // to satisfy the `&'static str` label — bounded by the (small) user count. let name: &'static str = Box::leak(format!("cron:{user_id}").into_boxed_str()); self.supervisor.adopt(name, Arc::clone(&cron).start(self.shutdown_token.clone())); - // Per-user ticket-listener: reacts to JobCompleted events for this user's - // tickets. All users' listeners receive the event (global system bus); only - // the one that owns the ticket does the UPDATE — others no-op on 0 rows. - let tname: &'static str = Box::leak(format!("tickets:{user_id}").into_boxed_str()); - self.supervisor.adopt_one( - tname, - Arc::clone(&tickets).start_listener( - Arc::clone(&self.system_bus), - self.shutdown_token.clone(), - ), - ); - Ok(Arc::new(UserContext { user_id: user_id.to_string(), pool, @@ -322,7 +303,6 @@ impl UserContextFactory { sessions: manager, chat_hub, cron, - tickets, approval, clarification, elicitation, diff --git a/crates/skald-core/src/skald/wiring.rs b/crates/skald-core/src/skald/wiring.rs index 93e2da8..572a68f 100644 --- a/crates/skald-core/src/skald/wiring.rs +++ b/crates/skald-core/src/skald/wiring.rs @@ -32,7 +32,6 @@ pub(super) fn wire( tasks.cron.set_session(Arc::clone(&conversation.manager)); tasks.cron.set_hub(Arc::clone(&conversation.chat_hub)); tasks.cron.set_self_arc(Arc::clone(&tasks.cron)); - tasks.ticket_manager.set_task_manager(Arc::clone(&tasks.cron)); conversation.chat_hub.set_task_mgr(Arc::clone(&tasks.cron)); integrations.mcp.set_elicitation_handler(ElicitationBridge::new(Arc::clone(&interaction.elicitation))); info!("ChatHub initialised"); diff --git a/crates/skald-core/src/tools/fs/mod.rs b/crates/skald-core/src/tools/fs/mod.rs index b61b927..7f66acb 100644 --- a/crates/skald-core/src/tools/fs/mod.rs +++ b/crates/skald-core/src/tools/fs/mod.rs @@ -264,6 +264,7 @@ mod tests { "skald-test", PathBuf::from("/root"), vec![], + vec![], )) } @@ -273,14 +274,17 @@ mod tests { #[cfg(unix)] #[test] fn host_path_resolves_and_contains() { - use core_api::user_fs::SharedMount; + use core_api::user_fs::{ProjectMount, SharedMount}; let root = std::env::temp_dir().join(format!("skald-fsroot-{}", std::process::id())); let home = root.join("homes").join("u1"); let shared = root.join("shared").join("family"); + // Project owned by user `owner-id`, agent-visible as `projects/alice/budget`. + let project = root.join("projects").join("owner-id").join("budget"); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&home).unwrap(); std::fs::create_dir_all(&shared).unwrap(); + std::fs::create_dir_all(&project).unwrap(); let fs = UserFs::new( "u1", @@ -293,10 +297,18 @@ mod tests { container: PathBuf::from("/root/shared/family"), can_write: true, }], + vec![ProjectMount { + owner_username: "alice".into(), + slug: "budget".into(), + host: project.clone(), + container: PathBuf::from("/root/projects/alice/budget"), + can_write: false, + }], ); let home_canon = canonicalize_for_policy(&home.to_string_lossy(), Path::new("/")); let shared_canon = canonicalize_for_policy(&shared.to_string_lossy(), Path::new("/")); + let project_canon = canonicalize_for_policy(&project.to_string_lossy(), Path::new("/")); // ~/… → private home (containment holds for a not-yet-existing file). let p = resolve_host_path(&fs, "~/notes.md").unwrap(); @@ -306,9 +318,15 @@ mod tests { // shared/{member} → the shared host dir let s = resolve_host_path(&fs, "shared/family/list.md").unwrap(); assert!(path_under(&s, &shared_canon), "{s:?}"); + // projects/{owner}/{slug} → the project host dir (two-segment routing) + let pr = resolve_host_path(&fs, "projects/alice/budget/plan.md").unwrap(); + assert!(path_under(&pr, &project_canon), "{pr:?}"); // a shared folder the user is NOT a member of → error assert!(resolve_host_path(&fs, "shared/secret/x.md").is_err()); + // a project the user cannot reach (wrong owner/slug) → error + assert!(resolve_host_path(&fs, "projects/bob/budget/x.md").is_err()); + assert!(resolve_host_path(&fs, "projects/alice/secret/x.md").is_err()); // `..` cannot climb out of the home assert!(resolve_host_path(&fs, "~/../u2/secret.md").is_err()); diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 31cdac1..4dbe1ad 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -102,10 +102,8 @@ pub fn router() -> Router> { // Projects .route("/projects", get(projects::list).post(projects::create)) .route("/projects/{id}", get(projects::get_project).put(projects::update).delete(projects::delete)) - .route("/projects/{id}/tickets", get(projects::list_tickets).post(projects::create_ticket)) - .route("/projects/{id}/tickets/{tid}", delete(projects::delete_ticket)) - .route("/projects/{id}/tickets/{tid}/start", post(projects::start_ticket)) - .route("/projects/{id}/tickets/{tid}/reset", post(projects::reset_ticket)) + .route("/projects/{id}/members", post(projects::add_member)) + .route("/projects/{id}/members/{user_id}", delete(projects::remove_member)) .route("/projects/{id}/session", post(projects::open_session)) // Cron jobs .route("/cron/jobs", get(cron::list)) diff --git a/src/frontend/api/projects.rs b/src/frontend/api/projects.rs index 126b713..2431e2f 100644 --- a/src/frontend/api/projects.rs +++ b/src/frontend/api/projects.rs @@ -1,3 +1,14 @@ +//! Projects management API (blueprint §5 memory note / §6). +//! +//! A project is a **shareable endeavour** over an on-disk folder: metadata + membership +//! live in the registry (`system.db`, not encrypted), the folder lives at +//! `{WD}/projects/{owner_userid}/{slug}` and is bind-mounted into each member's +//! container (agent-visible as `projects/{owner_username}/{slug}`). Unlike shared +//! folders, sharing is **self-service**: the owner and any write-member can invite +//! others and grant read/write. Each member keeps their own private chat about the +//! project (conversations stay in their encrypted per-user DB). + +use std::path::PathBuf; use std::sync::Arc; use axum::{ @@ -6,11 +17,10 @@ use axum::{ http::StatusCode, }; use serde::{Deserialize, Serialize}; -use sqlx::SqlitePool; -use skald_core::db::project_tickets::ProjectTicket; +use skald_core::db::project_members::{ProjectAccess, ProjectMember}; use skald_core::db::projects::Project; -use skald_core::db::{project_tickets, projects}; +use skald_core::db::{project_members, projects, users}; use skald_core::run_context::RunContext; use skald_core::skald::Skald; use super::{ApiError, guard::AuthUser, require_context}; @@ -25,90 +35,48 @@ const PROJECT_COORDINATOR_AGENT: &str = "project-coordinator"; // ── Request/Response types ──────────────────────────────────────────────────── #[derive(Serialize)] -pub struct ProjectResponse { - pub id: i64, - pub name: String, - pub path: String, - pub description: String, - pub run_context: Option, - pub created_at: String, - pub updated_at: String, +pub struct MemberView { + pub user_id: String, + pub can_write: bool, } -impl From for ProjectResponse { - fn from(p: Project) -> Self { - Self { - id: p.id, name: p.name, path: p.path, - description: p.description, - run_context: p.run_context, created_at: p.created_at, updated_at: p.updated_at, - } +impl From for MemberView { + fn from(m: ProjectMember) -> Self { + Self { user_id: m.user_id, can_write: m.can_write } } } +/// A project's detail view: identity + owner + the caller's capability + members. +#[derive(Serialize)] +pub struct ProjectDetail { + 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 created_at: String, + pub updated_at: String, + pub members: Vec, +} + #[derive(Deserialize)] pub struct ProjectBody { - pub name: String, - pub path: String, - pub description: Option, - pub security_group: Option, -} - -impl ProjectBody { - fn rc_json(&self) -> Option { - self.security_group.as_ref().map(|sg| { - RunContext::with_security_group(Some(sg.clone())).to_db() - }) - } -} - -#[derive(Serialize)] -pub struct TicketResponse { - pub id: i64, - pub project_id: i64, - pub title: String, - pub description: String, - pub status: String, - pub agent_id: String, - pub run_context: Option, - pub job_id: Option, - pub session_id: Option, - pub result: Option, - pub error: Option, - pub created_at: String, - pub started_at: Option, - pub completed_at: Option, -} - -impl From for TicketResponse { - fn from(t: ProjectTicket) -> Self { - Self { - id: t.id, project_id: t.project_id, title: t.title, - description: t.description, status: t.status, agent_id: t.agent_id, - run_context: t.run_context, job_id: t.job_id, session_id: t.session_id, - result: t.result, error: t.error, created_at: t.created_at, - started_at: t.started_at, completed_at: t.completed_at, - } - } + pub name: String, + pub description: Option, } #[derive(Deserialize)] -pub struct TicketBody { - pub title: String, - pub description: Option, - pub agent_id: Option, - pub security_group: Option, -} - -impl TicketBody { - fn rc_json(&self) -> Option { - self.security_group.as_ref().map(|sg| { - RunContext::with_security_group(Some(sg.clone())).to_db() - }) - } +pub struct MemberBody { + pub user_id: String, + #[serde(default)] + pub can_write: bool, } pub struct ProjectPath { pub id: i64 } -pub struct TicketPath { pub id: i64, pub tid: i64 } +pub struct MemberPath { pub id: i64, pub user_id: String } impl<'de> Deserialize<'de> for ProjectPath { fn deserialize>(d: D) -> Result { @@ -119,184 +87,235 @@ impl<'de> Deserialize<'de> for ProjectPath { } } -impl<'de> Deserialize<'de> for TicketPath { +impl<'de> Deserialize<'de> for MemberPath { fn deserialize>(d: D) -> Result { #[derive(Deserialize)] - struct Inner { id: i64, tid: i64 } + struct Inner { id: i64, user_id: String } let inner = Inner::deserialize(d)?; - Ok(Self { id: inner.id, tid: inner.tid }) + Ok(Self { id: inner.id, user_id: inner.user_id }) } } +// ── helpers ──────────────────────────────────────────────────────────────────── + +/// `display_name || username` for a user id, or the id itself when the row is gone. +async fn user_label(skald: &Skald, user_id: &str) -> String { + match users::get(skald.db(), user_id).await { + Ok(Some(u)) => u.display_name.filter(|s| !s.is_empty()).unwrap_or(u.username), + _ => user_id.to_string(), + } +} + +/// Loads a project and the caller's capability on it. 404 when the project is gone, +/// 403 when the caller is not a member (reads require membership). +async fn require_member( + skald: &Skald, + id: i64, + user_id: &str, +) -> Result<(Project, bool), ApiError> { + let project = projects::get(skald.db(), id) + .await? + .ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?; + let can_write = project_members::capability_of(skald.db(), id, user_id) + .await? + .ok_or_else(|| ApiError::forbidden("you are not a member of this project"))?; + Ok((project, can_write)) +} + +/// Authority to manage membership / edit a project: the owner, or any write-member +/// (the sharing model is self-service, not admin-gated). +fn require_manage(project: &Project, user_id: &str, caller_can_write: bool) -> Result<(), ApiError> { + if project.owner_user_id == user_id || caller_can_write { + Ok(()) + } else { + Err(ApiError::forbidden("read-only members cannot modify or share this project")) + } +} + +/// The host directory backing a project (`{WD}/projects/{owner_userid}/{slug}`). +fn project_dir(owner_user_id: &str, slug: &str) -> Result { + Ok(std::env::current_dir()? + .join(skald_core::container::PROJECTS_DIR) + .join(owner_user_id) + .join(slug)) +} + +/// Recreate a user's container with the new mount set (best-effort — settles at their +/// next login/boot on failure). See [`Skald::refresh_user_mounts`]. +async fn remount(skald: &Skald, user_id: &str) { + if let Err(e) = skald.refresh_user_mounts(user_id).await { + tracing::warn!(user = %user_id, error = %e, + "project remount failed (settles at next login/boot)"); + } +} + +async fn detail(skald: &Skald, project: Project, caller: &str, can_write: bool) -> Result { + let members = project_members::members(skald.db(), project.id).await?; + let owner_name = user_label(skald, &project.owner_user_id).await; + Ok(ProjectDetail { + is_owner: project.owner_user_id == caller, + owner_name, + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + owner_user_id: project.owner_user_id, + can_write, + created_at: project.created_at, + updated_at: project.updated_at, + members: members.into_iter().map(Into::into).collect(), + }) +} + // ── Project handlers ────────────────────────────────────────────────────────── +/// GET /api/projects — the caller's projects (owned + shared-with-them). pub async fn list( State(skald): State>, Extension(auth): Extension, -) -> Result>, ApiError> { - let ctx = require_context(&skald, &auth.user_id).await?; - let items = projects::list(&ctx.pool).await?; - Ok(Json(items.into_iter().map(Into::into).collect())) +) -> Result>, ApiError> { + let items = project_members::list_for_user(skald.db(), &auth.user_id).await?; + Ok(Json(items)) } +/// POST /api/projects — create a project owned by the caller (a private project = one +/// member). The folder is created and the caller's container remounted so the agent +/// can reach it immediately. pub async fn create( State(skald): State>, Extension(auth): Extension, Json(body): Json, -) -> Result<(StatusCode, Json), ApiError> { - let ctx = require_context(&skald, &auth.user_id).await?; - let rc_json = body.rc_json(); +) -> Result<(StatusCode, Json), ApiError> { + let name = body.name.trim(); + if name.is_empty() { + return Err(ApiError::bad_request("project name is required")); + } + let base = projects::slugify(name); + let slug = projects::unique_slug(skald.db(), &auth.user_id, &base).await?; + let project = projects::create( - &ctx.pool, - &body.name, - &body.path, - body.description.as_deref().unwrap_or(""), - rc_json.as_deref(), - ).await?; - Ok((StatusCode::CREATED, Json(project.into()))) + skald.db(), + &auth.user_id, + name, + &slug, + body.description.as_deref().unwrap_or("").trim(), + None, + ) + .await?; + // The owner is a write-member, so mounts are uniform (private = one member). + project_members::add_member(skald.db(), project.id, &auth.user_id, true).await?; + // Create the bind-mount source, then remount so the container sees it. + std::fs::create_dir_all(project_dir(&auth.user_id, &slug)?) + .map_err(|e| ApiError::bad_request(format!("failed to create project directory: {e}")))?; + remount(&skald, &auth.user_id).await; + + let d = detail(&skald, project, &auth.user_id, true).await?; + Ok((StatusCode::CREATED, Json(d))) } +/// GET /api/projects/{id} — the project detail (members require membership to read). pub async fn get_project( State(skald): State>, Extension(auth): Extension, Path(p): Path, -) -> Result, ApiError> { - let ctx = require_context(&skald, &auth.user_id).await?; - let project = projects::get(&ctx.pool, p.id).await? - .ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?; - Ok(Json(project.into())) +) -> Result, ApiError> { + let (project, can_write) = require_member(&skald, p.id, &auth.user_id).await?; + Ok(Json(detail(&skald, project, &auth.user_id, can_write).await?)) } +/// PUT /api/projects/{id} — edit name/description (owner or write-member). The slug is +/// immutable (it backs the on-disk folder + every member's path). pub async fn update( State(skald): State>, Extension(auth): Extension, Path(p): Path, Json(body): Json, -) -> Result, ApiError> { - let ctx = require_context(&skald, &auth.user_id).await?; - let rc_json = body.rc_json(); - let found = projects::update( - &ctx.pool, p.id, - &body.name, &body.path, - body.description.as_deref().unwrap_or(""), - rc_json.as_deref(), - ).await?; - if !found { - return Err(ApiError::not_found(format!("project {} not found", p.id))); +) -> Result, ApiError> { + let (project, can_write) = require_member(&skald, p.id, &auth.user_id).await?; + require_manage(&project, &auth.user_id, can_write)?; + let name = body.name.trim(); + if name.is_empty() { + return Err(ApiError::bad_request("project name is required")); } - let project = projects::get(&ctx.pool, p.id).await? + projects::update( + skald.db(), + p.id, + name, + body.description.as_deref().unwrap_or("").trim(), + project.run_context.as_deref(), + ) + .await?; + let project = projects::get(skald.db(), p.id) + .await? .ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?; - Ok(Json(project.into())) + Ok(Json(detail(&skald, project, &auth.user_id, can_write).await?)) } +/// DELETE /api/projects/{id} — only the owner may delete. Cascades the membership, +/// removes the folder, and remounts every former member. pub async fn delete( State(skald): State>, Extension(auth): Extension, Path(p): Path, ) -> Result { - let ctx = require_context(&skald, &auth.user_id).await?; - let found = projects::delete(&ctx.pool, p.id).await?; - if found { Ok(StatusCode::NO_CONTENT) } - else { Err(ApiError::not_found(format!("project {} not found", p.id))) } -} - -// ── Ticket handlers ─────────────────────────────────────────────────────────── - -pub async fn list_tickets( - State(skald): State>, - Extension(auth): Extension, - Path(p): Path, -) -> Result>, ApiError> { - let ctx = require_context(&skald, &auth.user_id).await?; - let tickets = project_tickets::list_for_project(&ctx.pool, p.id).await?; - Ok(Json(tickets.into_iter().map(Into::into).collect())) -} - -pub async fn create_ticket( - State(skald): State>, - Extension(auth): Extension, - Path(p): Path, - Json(body): Json, -) -> Result<(StatusCode, Json), ApiError> { - let ctx = require_context(&skald, &auth.user_id).await?; - let rc_json = body.rc_json(); - let agent_id = body.agent_id.as_deref().map(str::trim).filter(|s| !s.is_empty()) - .ok_or_else(|| ApiError::bad_request("agent_id is required — pick a task agent for this ticket"))?; - let ticket = project_tickets::create( - &ctx.pool, p.id, - &body.title, - body.description.as_deref().unwrap_or(""), - agent_id, - rc_json.as_deref(), - ).await?; - projects::touch(&ctx.pool, p.id).await?; - Ok((StatusCode::CREATED, Json(ticket.into()))) -} - -pub async fn delete_ticket( - State(skald): State>, - Extension(auth): Extension, - Path(tp): Path, -) -> Result { - let ctx = require_context(&skald, &auth.user_id).await?; - let ticket = project_tickets::get(&ctx.pool, tp.tid).await?; - let found = project_tickets::delete(&ctx.pool, tp.tid).await?; - if found { - if let Some(t) = ticket { - projects::touch(&ctx.pool, t.project_id).await?; - } - Ok(StatusCode::NO_CONTENT) - } else { - Err(ApiError::not_found(format!("ticket {} not found", tp.tid))) + let (project, _) = require_member(&skald, p.id, &auth.user_id).await?; + if project.owner_user_id != auth.user_id { + return Err(ApiError::forbidden("only the project owner can delete it")); } -} - -pub async fn start_ticket( - State(skald): State>, - Extension(auth): Extension, - Path(tp): Path, -) -> Result { - let ctx = require_context(&skald, &auth.user_id).await?; - let ticket = project_tickets::get(&ctx.pool, tp.tid).await? - .ok_or_else(|| ApiError::not_found(format!("ticket {} not found", tp.tid)))?; - let project = projects::get(&ctx.pool, ticket.project_id).await? - .ok_or_else(|| ApiError::not_found(format!("project {} not found", ticket.project_id)))?; - - let base: Option = - ticket.run_context.as_deref().and_then(RunContext::from_db) - .or_else(|| project.run_context.as_deref().and_then(RunContext::from_db)); - let rc = skald_core::projects::build_runtime_run_context(&project, base); - - let origin_ref = format!("PROJECT_TASK:{}", tp.tid); - let rc_json = rc.to_db(); - let job = ctx.cron.spawn_async_job( - &ticket.title, - &ticket.description, - &ticket.description, - &ticket.agent_id, - Some(&rc_json), - &origin_ref, - )?; - - project_tickets::start(&ctx.pool, tp.tid, job.id).await?; - projects::touch(&ctx.pool, ticket.project_id).await?; - Ok(StatusCode::ACCEPTED) -} - -pub async fn reset_ticket( - State(skald): State>, - Extension(auth): Extension, - Path(tp): Path, -) -> Result { - let ctx = require_context(&skald, &auth.user_id).await?; - let project_id = project_tickets::get(&ctx.pool, tp.tid).await?.map(|t| t.project_id); - project_tickets::reset(&ctx.pool, tp.tid).await?; - if let Some(pid) = project_id { - projects::touch(&ctx.pool, pid).await?; + // Snapshot members before the cascade so we can remount them afterwards. + let members = project_members::members(skald.db(), p.id).await?; + projects::delete(skald.db(), p.id).await?; + // Best-effort: drop the folder; the DB row is already gone. + let _ = std::fs::remove_dir_all(project_dir(&project.owner_user_id, &project.slug)?); + for m in members { + remount(&skald, &m.user_id).await; } Ok(StatusCode::NO_CONTENT) } +// ── Membership (sharing) handlers ────────────────────────────────────────────── + +/// POST /api/projects/{id}/members — add (or re-grant) a member. Owner or write-member. +pub async fn add_member( + State(skald): State>, + Extension(auth): Extension, + Path(p): Path, + Json(body): Json, +) -> Result>, ApiError> { + let (project, can_write) = require_member(&skald, p.id, &auth.user_id).await?; + require_manage(&project, &auth.user_id, can_write)?; + // Turn an FK violation into a clean 400. + if users::get(skald.db(), &body.user_id).await?.is_none() { + return Err(ApiError::bad_request("no such user")); + } + project_members::add_member(skald.db(), p.id, &body.user_id, body.can_write).await?; + projects::touch(skald.db(), p.id).await?; + remount(&skald, &body.user_id).await; + + let members = project_members::members(skald.db(), p.id).await?; + Ok(Json(members.into_iter().map(Into::into).collect())) +} + +/// DELETE /api/projects/{id}/members/{user_id} — remove a member. Owner or write-member. +/// The owner cannot be removed (delete the project instead). +pub async fn remove_member( + State(skald): State>, + Extension(auth): Extension, + Path(mp): Path, +) -> Result>, ApiError> { + let (project, can_write) = require_member(&skald, mp.id, &auth.user_id).await?; + require_manage(&project, &auth.user_id, can_write)?; + if project.owner_user_id == mp.user_id { + return Err(ApiError::bad_request("the owner cannot be removed; delete the project instead")); + } + project_members::remove_member(skald.db(), mp.id, &mp.user_id).await?; + projects::touch(skald.db(), mp.id).await?; + remount(&skald, &mp.user_id).await; + + let members = project_members::members(skald.db(), mp.id).await?; + Ok(Json(members.into_iter().map(Into::into).collect())) +} + // ── Project chat session ────────────────────────────────────────────────────── #[derive(Serialize)] @@ -307,13 +326,14 @@ pub struct SessionResponse { /// Resolves which agent + `RunContext` a `source` should be provisioned with. /// -/// `project-{id}` → (`project-coordinator`, project runtime context); any other source -/// → (`main`, no context). This is the single place that maps a source to its -/// provisioning config, shared by session-open and session-reset so the two never -/// diverge. +/// `project-{id}` → (`project-coordinator`, project runtime context) **iff** the caller +/// is a member (else 403); any other source → (`main`, no context). The single place +/// that maps a source to its provisioning config, shared by session-open and +/// session-reset so the two never diverge. pub async fn provisioning_for_source( - pool: &SqlitePool, - source: &str, + skald: &Skald, + user_id: &str, + source: &str, ) -> Result<(String, Option), ApiError> { let Some(id) = source .strip_prefix(PROJECT_SOURCE_PREFIX) @@ -322,10 +342,13 @@ pub async fn provisioning_for_source( return Ok(("main".to_string(), None)); }; - let project = projects::get(pool, id).await? - .ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?; + let (project, _can_write) = require_member(skald, id, user_id).await?; + let owner_username = match users::get(skald.db(), &project.owner_user_id).await? { + Some(u) => u.username, + None => return Err(ApiError::not_found("project owner no longer exists")), + }; let base = project.run_context.as_deref().and_then(RunContext::from_db); - let rc = skald_core::projects::build_runtime_run_context(&project, base); + let rc = skald_core::projects::build_runtime_run_context(&project, &owner_username, base); Ok((PROJECT_COORDINATOR_AGENT.to_string(), Some(rc))) } @@ -339,7 +362,7 @@ pub async fn open_session( ) -> Result, ApiError> { let ctx = require_context(&skald, &auth.user_id).await?; let source = format!("{PROJECT_SOURCE_PREFIX}{}", p.id); - let (agent, rc) = provisioning_for_source(&ctx.pool, &source).await?; + let (agent, rc) = provisioning_for_source(&skald, &auth.user_id, &source).await?; let session_id = ctx.chat_hub .provision_session(&source, &agent, rc.as_ref(), false) .await?; diff --git a/src/frontend/api/sessions.rs b/src/frontend/api/sessions.rs index f19e78c..732106b 100644 --- a/src/frontend/api/sessions.rs +++ b/src/frontend/api/sessions.rs @@ -39,7 +39,7 @@ pub async fn create( let ctx = require_context(&skald, &auth.user_id).await?; // Resolve agent + RunContext from the source so project chats reset with the // coordinator agent (not the default `main`), then provision a fresh session. - let (agent, rc) = super::projects::provisioning_for_source(&ctx.pool, &q.source).await?; + let (agent, rc) = super::projects::provisioning_for_source(&skald, &auth.user_id, &q.source).await?; // A non-project chat inherits the caller role's default security-group, so a // restricted role starts scoped instead of on the catch-all `default` group. // Project chats already carry their own run-context and are left untouched. diff --git a/src/frontend/api/shared_folders.rs b/src/frontend/api/shared_folders.rs index 9a47812..d290173 100644 --- a/src/frontend/api/shared_folders.rs +++ b/src/frontend/api/shared_folders.rs @@ -58,7 +58,7 @@ fn create_shared_dir(name: &str) -> Result<(), ApiError> { /// is already committed, so a Docker hiccup is logged, not surfaced — it settles at /// the user's next login/boot. async fn remount(skald: &Skald, user_id: &str) { - if let Err(e) = skald.refresh_user_shared_folders(user_id).await { + if let Err(e) = skald.refresh_user_mounts(user_id).await { tracing::warn!(user = %user_id, error = %e, "shared-folder remount failed (settles at next login/boot)"); } diff --git a/web/components/projects/project-board.js b/web/components/projects/project-board.js index 86a261e..afe0b32 100644 --- a/web/components/projects/project-board.js +++ b/web/components/projects/project-board.js @@ -1,39 +1,25 @@ import { html, nothing } from 'lit'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -import { LightElement, renderMarkdown } from '../../lib/base.js'; +import { LightElement } from '../../lib/base.js'; import { t } from '../../lib/i18n.js'; -import { formatDate } from '../tasks/utils.js'; +/// A project's detail page: header + description, a sharing panel (member picker with +/// read/write, mirroring the shared-folders UI), Open chat, and a Files section (the +/// future primary surface — a file explorer over the project folder). No ticket board. export class ProjectBoardSection extends LightElement { static properties = { - _project: { state: true }, - _tickets: { state: true }, - _modal: { state: true }, - _form: { state: true }, - _saving: { state: true }, - _error: { state: true }, - _expanded: { state: true }, - _expandedDesc: { state: true }, - _agents: { state: true }, - _groups: { state: true }, - _activeTab: { state: true }, + _project: { state: true }, + _users: { state: true }, + _add: { state: true }, + _error: { state: true }, }; constructor() { super(); - this._project = null; - this._tickets = []; - this._modal = null; - this._form = this._emptyForm(); - this._saving = false; - this._error = null; - this._expanded = null; - this._expandedDesc = {}; - this._pollTimer = null; - this._projectId = null; - this._agents = []; - this._groups = []; - this._activeTab = 'tickets'; + this._project = null; + this._users = []; + this._add = { user_id: '', can_write: false }; + this._error = null; + this._projectId = null; } connectedCallback() { @@ -45,12 +31,6 @@ export class ProjectBoardSection extends LightElement { disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); - this._stopPolling(); - } - - _emptyForm() { - // No default agent — a ticket runs a `task` agent, picked once the list loads. - return { title: '', description: '', agent_id: '', security_group: '' }; } async load(projectId) { @@ -58,160 +38,83 @@ export class ProjectBoardSection extends LightElement { this._project = null; this._error = null; try { - const [projRes, tickRes] = await Promise.all([ + const [projRes, usersRes] = await Promise.all([ fetch(`/api/projects/${projectId}`), - fetch(`/api/projects/${projectId}/tickets`), + fetch('/api/users'), ]); if (!projRes.ok) throw new Error(`HTTP ${projRes.status}`); - if (!tickRes.ok) throw new Error(`HTTP ${tickRes.status}`); this._project = await projRes.json(); - this._tickets = await tickRes.json(); - this._updatePolling(); + if (usersRes.ok) this._users = await usersRes.json(); } catch (e) { this._error = e.message; } } - async _loadTickets() { - if (!this._projectId) return; + async _reload() { try { - const res = await fetch(`/api/projects/${this._projectId}/tickets`); - if (res.ok) { - this._tickets = await res.json(); - this._updatePolling(); - } - } catch { /* ignore transient errors during poll */ } + const res = await fetch(`/api/projects/${this._projectId}`); + if (res.ok) this._project = await res.json(); + } catch { /* transient */ } } - _hasActiveTickets() { - return this._tickets.some(t => t.status === 'pending' || t.status === 'in_progress'); + _canManage() { + return !!this._project && (this._project.is_owner || this._project.can_write); } - _updatePolling() { - if (this._hasActiveTickets()) { - this._startPolling(); - } else { - this._stopPolling(); - } + _userLabel(id) { + const u = this._users.find(u => u.id === id); + return u ? (u.display_name || u.username) : id; } - _startPolling() { - if (this._pollTimer) return; - this._pollTimer = setInterval(() => this._loadTickets(), 5000); + _candidates() { + const taken = new Set((this._project?.members ?? []).map(m => m.user_id)); + return this._users.filter(u => u.active !== false && !taken.has(u.id)); } - _stopPolling() { - if (this._pollTimer) { - clearInterval(this._pollTimer); - this._pollTimer = null; - } - } + // ── Membership actions ───────────────────────────────────────────────────────── - _groupTickets() { - const running = []; - const todo = []; - const completed = []; - - for (const t of this._tickets) { - if (t.status === 'pending' || t.status === 'in_progress') { - running.push(t); - } else if (t.status === 'todo') { - todo.push(t); - } else { - completed.push(t); - } - } - - todo.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '')); - completed.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? '')); - - return { running, todo, completed }; - } - - async _loadModalData() { + async _addMember() { + if (!this._add.user_id) return; try { - const [agentsRes, groupsRes] = await Promise.all([ - fetch('/api/agents'), - fetch('/api/tool-permission-groups'), - ]); - if (agentsRes.ok) this._agents = await agentsRes.json(); - if (groupsRes.ok) this._groups = await groupsRes.json(); - // Tickets run task agents only; pre-select the first one so a valid value is sent. - if (!this._form.agent_id) { - const first = this._agents.find(a => a.type === 'task'); - if (first) this._form = { ...this._form, agent_id: first.id }; - } - } catch { /* non-critical */ } - } - - // ── Actions ────────────────────────────────────────────────────────────────── - - async _startTicket(ticket) { - try { - const res = await fetch( - `/api/projects/${ticket.project_id}/tickets/${ticket.id}/start`, - { method: 'POST' }, - ); - if (!res.ok) throw new Error(await res.text()); - await this._loadTickets(); - } catch (e) { - this._error = e.message; - } - } - - async _resetTicket(ticket) { - try { - const res = await fetch( - `/api/projects/${ticket.project_id}/tickets/${ticket.id}/reset`, - { method: 'POST' }, - ); - if (!res.ok) throw new Error(await res.text()); - if (this._expanded === ticket.id) this._expanded = null; - await this._loadTickets(); - } catch (e) { - this._error = e.message; - } - } - - async _deleteTicket(ticket) { - if (!confirm(t('project_board.confirm.delete', { title: ticket.title }))) return; - try { - const res = await fetch( - `/api/projects/${ticket.project_id}/tickets/${ticket.id}`, - { method: 'DELETE' }, - ); - if (!res.ok) throw new Error(await res.text()); - await this._loadTickets(); - } catch (e) { - this._error = e.message; - } - } - - async _createTicket(e) { - e.preventDefault(); - if (this._saving) return; - this._saving = true; - this._error = null; - try { - const payload = { ...this._form }; - if (!payload.security_group) delete payload.security_group; - const res = await fetch(`/api/projects/${this._projectId}/tickets`, { + const res = await fetch(`/api/projects/${this._projectId}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify({ user_id: this._add.user_id, can_write: this._add.can_write }), }); if (!res.ok) throw new Error(await res.text()); - this._modal = null; - await this._loadTickets(); - } catch (err) { - this._error = err.message; - } finally { - this._saving = false; + this._project = { ...this._project, members: await res.json() }; + this._add = { user_id: '', can_write: false }; + } catch (e) { + this._error = e.message; + } + } + + async _setAccess(userId, canWrite) { + try { + const res = await fetch(`/api/projects/${this._projectId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, can_write: canWrite }), + }); + if (!res.ok) throw new Error(await res.text()); + this._project = { ...this._project, members: await res.json() }; + } catch (e) { + this._error = e.message; + } + } + + async _removeMember(userId) { + try { + const res = await fetch(`/api/projects/${this._projectId}/members/${encodeURIComponent(userId)}`, + { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); + this._project = { ...this._project, members: await res.json() }; + } catch (e) { + this._error = e.message; } } _back() { - this._stopPolling(); this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true })); } @@ -228,207 +131,86 @@ export class ProjectBoardSection extends LightElement { } } - _toggleExpand(id) { - this._expanded = this._expanded === id ? null : id; - } - - _toggleDesc(id) { - this._expandedDesc = { ...this._expandedDesc, [id]: !this._expandedDesc[id] }; - } - // ── Rendering ───────────────────────────────────────────────────────────────── - _renderTicketCard(ticket) { - const isRunning = ticket.status === 'pending' || ticket.status === 'in_progress'; - const isDone = ticket.status === 'done'; - const isFailed = ticket.status === 'failed'; - const isCompleted = isDone || isFailed; - const isExpanded = this._expanded === ticket.id; - - const cardClass = isRunning ? 'ticket-card ticket-card--running' - : isDone ? 'ticket-card ticket-card--done' - : isFailed ? 'ticket-card ticket-card--failed' - : 'ticket-card'; - + _renderMember(m) { + const isOwner = m.user_id === this._project.owner_user_id; + const manage = this._canManage(); return html` -
-
- ${ticket.title} - ${isRunning ? html` - - ` : nothing} -
- - ${ticket.description - ? html`
{ if (!window.getSelection().toString()) this._toggleDesc(ticket.id); }}>${ticket.description}
` - : nothing} -
- ${ticket.agent_id} - ${ticket.started_at ? html` - ${formatDate(ticket.started_at)} - ` : html` - ${formatDate(ticket.created_at)} - `} - ${isCompleted && ticket.completed_at ? html` - ${formatDate(ticket.completed_at)} - ` : nothing} -
- -
- ${ticket.status === 'todo' ? html` - - - ` : nothing} - - ${isRunning ? html` - ${t('project_board.ticket.running')} - ${ticket.session_id != null ? html` - - #${ticket.session_id} - - ` : nothing} - ` : nothing} - - ${isCompleted ? html` - - - ${ticket.session_id != null ? html` - - #${ticket.session_id} - - ` : nothing} - ` : nothing} -
- - ${isCompleted && isExpanded ? html` -
- ${isDone - ? html`
- ${unsafeHTML(renderMarkdown(ticket.result ?? t('project_board.ticket.no_output')))} -
` - : html`
${ticket.error ?? t('project_board.ticket.no_error')}
`} -
- ` : nothing} -
- `; - } - - _renderSection(label, icon, colorClass, tickets, emptyLabel) { - return html` -
-
- ${label} - ${tickets.length} -
- ${tickets.length === 0 - ? html`
${emptyLabel}
` - : tickets.map(t => this._renderTicketCard(t))} -
- `; - } - - _renderTabBar() { - return html` -
- -
- `; - } - - _renderTicketsTab() { - const { running, todo, completed } = this._groupTickets(); - return html` -
- ${this._renderSection(t('project_board.section.running'), 'activity', 'ticket-section-header--running', running, t('project_board.section.running_empty'))} - ${this._renderSection(t('project_board.section.todo'), 'circle', '', todo, t('project_board.section.todo_empty'))} - ${this._renderSection(t('project_board.section.completed'), 'check-circle', 'ticket-section-header--completed', completed, t('project_board.section.completed_empty'))} -
- `; - } - - _renderModal() { - return html` -
-
-
- - ${t('project_board.modal.title')} -
+ + ` : html` + + ${m.can_write ? t('projects.share.access.readwrite') : t('projects.share.access.readonly')} + + `} +
+ `; + } - ${this._error ? html` -
${this._error}
+ _renderSharePanel() { + const candidates = this._candidates(); + const manage = this._canManage(); + return html` +
+
+
${t('projects.share.title')}
+ ${(this._project.members ?? []).map(m => this._renderMember(m))} + + ${manage ? html` +
+ ${candidates.length > 0 ? html` +
+ + + +
+ ` : html`
${t('projects.share.all_added')}
`} ` : nothing} +
+
+ `; + } -
this._createTicket(e)}> -
- - this._form = { ...this._form, title: e.target.value }} /> -
-
- - -
-
- - -
-
- - -
-
- - -
-
+ _renderFilesPanel() { + // The file explorer is the future primary surface (a directory listing endpoint over + // the project folder is a follow-on). For now, the chat's agent works in the folder. + return html` +
+
+ +

${t('projects.files.placeholder')}

`; @@ -453,27 +235,28 @@ export class ProjectBoardSection extends LightElement {

${this._project.name}

+ ${this._project.is_owner + ? html`${t('projects.badge.owned')}` + : html`${t('projects.badge.shared_by', { name: this._project.owner_name })}`}
-
- ${this._renderTabBar()} - ${this._error ? html`
${this._error}
` : nothing} - ${this._activeTab === 'tickets' ? this._renderTicketsTab() : nothing} - - ${this._modal ? this._renderModal() : nothing} +
+ ${this._project.description + ? html`

${this._project.description}

` + : nothing} + ${this._renderFilesPanel()} + ${this._renderSharePanel()} +
`; } diff --git a/web/components/projects/project-list.js b/web/components/projects/project-list.js index 54c95fc..f900935 100644 --- a/web/components/projects/project-list.js +++ b/web/components/projects/project-list.js @@ -33,7 +33,7 @@ export class ProjectListSection extends LightElement { } _emptyForm() { - return { name: '', path: '', description: '' }; + return { name: '', description: '' }; } async load() { @@ -54,7 +54,7 @@ export class ProjectListSection extends LightElement { } _openEdit(project) { - this._form = { name: project.name, path: project.path, description: project.description ?? '' }; + this._form = { name: project.name, description: project.description ?? '' }; this._error = null; this._modal = { mode: 'edit', project }; } @@ -135,13 +135,6 @@ export class ProjectListSection extends LightElement { .value=${this._form.name} @input=${e => this._setField('name', e.target.value)} /> -
- - this._setField('path', e.target.value)} /> -