Projects: shareable, registry-backed, container-mounted; drop ticket board
Nightly Build / build (push) Successful in 6m27s

Rework projects from single-user leftovers into shareable endeavours.

- DB: move `projects` from the owner bucket to the registry (system.db,
  not encrypted); add `owner_user_id` + `slug` (drop free `path`); new
  `project_members(project_id, user_id, can_write)` mirroring
  `shared_folder_members`. Drop `project_tickets` entirely. Only user↔agent
  conversations stay encrypted (per-user DB) — each member keeps a private
  project chat. Registry home dissolves the cross-DB-FK problem.
- Filesystem/container: on disk `{WD}/projects/{owner_userid}/{slug}`,
  agent/container path `projects/{owner_username}/{slug}`. Two-segment routing
  in UserFs (ProjectMount + host_base_and_tail arm) and a second loop in
  build_user_fs; read-only members get a :ro mount. Reuse the shared-folder
  remount machinery (refresh_user_shared_folders -> refresh_user_mounts).
- Remove the ticket system: ProjectTicketManager, UserContext.tickets, its
  wiring, and the project_tickets references in scheduled_jobs/cron.
- API: repoint handlers to the registry pool + membership scoping. Sharing is
  self-service — owner or any write-member may add/remove members and set
  read/write; only the owner deletes; the owner cannot be removed. New
  POST/DELETE /api/projects/{id}/members[/{user_id}]. Seed `@fs_any allow
  projects/*`; build_runtime_run_context sets working_directory to the agent
  path and drops the host-path allow_fs_writes.
- Frontend: create form without the free path field, owner/read-write badges;
  the detail page becomes header + description + sharing panel + Open chat + a
  file-explorer placeholder (the future primary surface). i18n en/it/fr.
This commit is contained in:
2026-07-20 22:21:10 +01:00
parent bd88f02226
commit 2ec394c17c
26 changed files with 963 additions and 1245 deletions
+48 -3
View File
@@ -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<SharedMount>,
/// Projects this user can reach (owned + shared-with-them), by owner then slug.
pub projects: Vec<ProjectMount>,
}
impl UserFs {
@@ -55,6 +77,7 @@ impl UserFs {
container_name: impl Into<String>,
container_home: PathBuf,
shared: Vec<SharedMount>,
projects: Vec<ProjectMount>,
) -> 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() {
+10 -3
View File
@@ -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/`
+22 -2
View File
@@ -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<UserFs>
})
.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,
-10
View File
@@ -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})"
)))
+41 -37
View File
@@ -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();
+241
View File
@@ -0,0 +1,241 @@
//! Project membership (registry / `system.db`): who can reach a project and with what
//! capability. Mirrors [`super::shared_folders`]'s membership model — a junction table
//! so a member can be read-only, and so both the container mount topology and the
//! "shared with me / owner badge" list can query it in either direction.
//!
//! Two path segments distinguish it from a shared folder: a project lives at
//! `projects/{owner_username}/{slug}`, so the mount rows carry the owner's **userid**
//! (the host path segment, stable) and **username** (the agent-visible segment).
//! FK `user_id → users(id)` is registry→registry (same file) — allowed.
use anyhow::Result;
use serde::Serialize;
use sqlx::SqlitePool;
/// One project a user can reach, resolved for building their container mounts.
#[derive(Debug, Clone)]
pub struct ProjectMountRow {
pub project_id: i64,
/// Owner's userid — the **host** path segment (`{WD}/projects/{owner_userid}/{slug}`).
pub owner_user_id: String,
/// Owner's username — the **agent-visible / container** path segment.
pub owner_username: String,
pub slug: String,
pub can_write: bool,
}
/// A project as it appears in a user's list: identity, owner, the caller's capability,
/// and whether the caller owns it. `owner_name` is `display_name || username`.
#[derive(Debug, Clone, Serialize)]
pub struct ProjectAccess {
pub id: i64,
pub name: String,
pub slug: String,
pub description: String,
pub owner_user_id: String,
pub owner_name: String,
pub is_owner: bool,
pub can_write: bool,
pub updated_at: String,
}
/// One member of a project — used by the share panel and the mount topology.
#[derive(Debug, Clone, Serialize)]
pub struct ProjectMember {
pub user_id: String,
pub can_write: bool,
}
// ── Reads ──────────────────────────────────────────────────────────────────────
/// Every project a user belongs to, resolved for their container mounts (owner's
/// userid + username + slug + capability). Drives `build_user_fs`.
pub async fn list_for_user_mounts(pool: &SqlitePool, user_id: &str) -> Result<Vec<ProjectMountRow>> {
let rows = sqlx::query_as::<_, (i64, String, String, String, i64)>(
"SELECT p.id, p.owner_user_id, u.username, p.slug, m.can_write
FROM project_members m
JOIN projects p ON p.id = m.project_id
JOIN users u ON u.id = p.owner_user_id
WHERE m.user_id = ?
ORDER BY u.username, p.slug",
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(project_id, owner_user_id, owner_username, slug, can_write)| ProjectMountRow {
project_id,
owner_user_id,
owner_username,
slug,
can_write: can_write != 0,
})
.collect())
}
/// The projects a user can see (owned + shared-with-them), for the UI list. Ordered by
/// recency. `is_owner` distinguishes owned from shared (the owner-badge signal).
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<ProjectAccess>> {
let rows = sqlx::query_as::<_, (i64, String, String, String, String, String, i64, i64, String)>(
"SELECT p.id, p.name, p.slug, p.description, p.owner_user_id,
COALESCE(NULLIF(ou.display_name, ''), ou.username) AS owner_name,
(p.owner_user_id = ?) AS is_owner,
m.can_write, p.updated_at
FROM project_members m
JOIN projects p ON p.id = m.project_id
JOIN users ou ON ou.id = p.owner_user_id
WHERE m.user_id = ?
ORDER BY p.updated_at DESC",
)
.bind(user_id)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, name, slug, description, owner_user_id, owner_name, is_owner, can_write, updated_at)| {
ProjectAccess {
id,
name,
slug,
description,
owner_user_id,
owner_name,
is_owner: is_owner != 0,
can_write: can_write != 0,
updated_at,
}
})
.collect())
}
/// The members of a project — the set of users whose containers mount it.
pub async fn members(pool: &SqlitePool, project_id: i64) -> Result<Vec<ProjectMember>> {
let rows = sqlx::query_as::<_, (String, i64)>(
"SELECT user_id, can_write FROM project_members WHERE project_id = ?",
)
.bind(project_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(user_id, can_write)| ProjectMember { user_id, can_write: can_write != 0 })
.collect())
}
/// The caller's capability on a project: `None` when not a member, `Some(can_write)`
/// otherwise. The authority check for reads (member) and writes/share (write-member).
pub async fn capability_of(pool: &SqlitePool, project_id: i64, user_id: &str) -> Result<Option<bool>> {
let row: Option<(i64,)> = sqlx::query_as(
"SELECT can_write FROM project_members WHERE project_id = ? AND user_id = ?",
)
.bind(project_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(w,)| w != 0))
}
// ── Writes ─────────────────────────────────────────────────────────────────────
/// Adds (or updates the capability of) a member. Idempotent on the PK.
pub async fn add_member(
pool: &SqlitePool,
project_id: i64,
user_id: &str,
can_write: bool,
) -> Result<()> {
sqlx::query(
"INSERT INTO project_members (project_id, user_id, can_write)
VALUES (?, ?, ?)
ON CONFLICT (project_id, user_id) DO UPDATE SET can_write = excluded.can_write",
)
.bind(project_id)
.bind(user_id)
.bind(can_write as i64)
.execute(pool)
.await?;
Ok(())
}
pub async fn remove_member(pool: &SqlitePool, project_id: i64, user_id: &str) -> Result<()> {
sqlx::query("DELETE FROM project_members WHERE project_id = ? AND user_id = ?")
.bind(project_id)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
async fn registry_pool(tag: &str) -> (SqlitePool, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("skald-projectmembers-{}-{tag}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pool = crate::db::init_system_pool(&dir.join("system.db").to_string_lossy())
.await
.unwrap();
(pool, dir)
}
/// Proves the registry-→registry FK `project_members.user_id → users(id)` inserts
/// with `PRAGMA foreign_keys=ON`, and that owned/shared are distinguished.
#[tokio::test]
async fn membership_list_distinguishes_owner_and_shared() {
let (pool, dir) = registry_pool("list").await;
for (id, name, display) in
[("u1", "alice", None), ("u2", "bob", Some("Bob"))]
{
sqlx::query("INSERT INTO users (id, username, display_name, role_id, encrypted) VALUES (?, ?, ?, 'admin', 0)")
.bind(id)
.bind(name)
.bind(display)
.execute(&pool)
.await
.unwrap();
}
// Alice owns "budget", is a write-member of her own project.
let p = super::super::projects::create(&pool, "u1", "Budget", "budget", "the money", None)
.await
.unwrap();
add_member(&pool, p.id, "u1", true).await.unwrap();
// Shared read-only with Bob.
add_member(&pool, p.id, "u2", false).await.unwrap();
let alice = list_for_user(&pool, "u1").await.unwrap();
assert_eq!(alice.len(), 1);
assert!(alice[0].is_owner);
assert!(alice[0].can_write);
assert_eq!(alice[0].owner_name, "alice");
let bob = list_for_user(&pool, "u2").await.unwrap();
assert_eq!(bob.len(), 1);
assert!(!bob[0].is_owner);
assert!(!bob[0].can_write);
assert_eq!(bob[0].owner_name, "alice"); // owner is alice (no display name)
// Mount rows carry both userid and username of the owner.
let mounts = list_for_user_mounts(&pool, "u2").await.unwrap();
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].owner_user_id, "u1");
assert_eq!(mounts[0].owner_username, "alice");
assert_eq!(mounts[0].slug, "budget");
assert!(!mounts[0].can_write);
assert_eq!(capability_of(&pool, p.id, "u2").await.unwrap(), Some(false));
assert_eq!(capability_of(&pool, p.id, "nobody").await.unwrap(), None);
drop(pool);
let _ = std::fs::remove_dir_all(&dir);
}
}
-149
View File
@@ -1,149 +0,0 @@
use anyhow::Result;
use sqlx::SqlitePool;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ProjectTicket {
pub id: i64,
pub project_id: i64,
pub title: String,
pub description: String,
pub status: String,
pub agent_id: String,
pub run_context: Option<String>,
pub job_id: Option<i64>,
pub result: Option<String>,
pub error: Option<String>,
pub created_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub session_id: Option<i64>,
}
const SELECT: &str =
"SELECT pt.id, pt.project_id, pt.title, pt.description, pt.status, pt.agent_id,
pt.run_context, pt.job_id, pt.result, pt.error, pt.created_at,
pt.started_at, pt.completed_at,
COALESCE(sj.running_session_id,
(SELECT session_id FROM job_runs
WHERE job_id = pt.job_id ORDER BY id DESC LIMIT 1)
) AS session_id
FROM project_tickets pt
LEFT JOIN scheduled_jobs sj ON sj.id = pt.job_id";
pub async fn list_for_project(pool: &SqlitePool, project_id: i64) -> Result<Vec<ProjectTicket>> {
let rows = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
"{SELECT} WHERE pt.project_id = ? ORDER BY pt.id"
)))
.bind(project_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<ProjectTicket>> {
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
"{SELECT} WHERE pt.id = ?"
)))
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn create(
pool: &SqlitePool,
project_id: i64,
title: &str,
description: &str,
agent_id: &str,
run_context: Option<&str>,
) -> Result<ProjectTicket> {
let id = sqlx::query(
"INSERT INTO project_tickets (project_id, title, description, agent_id, run_context)
VALUES (?, ?, ?, ?, ?)",
)
.bind(project_id)
.bind(title)
.bind(description)
.bind(agent_id)
.bind(run_context)
.execute(pool)
.await?
.last_insert_rowid();
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
"{SELECT} WHERE pt.id = ?"
)))
.bind(id)
.fetch_one(pool)
.await?;
Ok(row)
}
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
let n = sqlx::query("DELETE FROM project_tickets WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
pub async fn set_status(pool: &SqlitePool, id: i64, status: &str) -> Result<()> {
sqlx::query("UPDATE project_tickets SET status = ? WHERE id = ?")
.bind(status)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Mark as in_progress and record the scheduled job that is running it.
pub async fn start(pool: &SqlitePool, id: i64, job_id: i64) -> Result<()> {
sqlx::query(
"UPDATE project_tickets
SET status = 'in_progress', job_id = ?, started_at = datetime('now')
WHERE id = ?",
)
.bind(job_id)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Mark as done or failed, recording result/error and timestamp.
pub async fn complete(
pool: &SqlitePool,
id: i64,
result: Option<&str>,
error: Option<&str>,
) -> Result<()> {
let status = if error.is_some() { "failed" } else { "done" };
sqlx::query(
"UPDATE project_tickets
SET status = ?, result = ?, error = ?, completed_at = datetime('now')
WHERE id = ?",
)
.bind(status)
.bind(result)
.bind(error)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Reset a ticket back to todo, clearing all run state.
pub async fn reset(pool: &SqlitePool, id: i64) -> Result<()> {
sqlx::query(
"UPDATE project_tickets
SET status = 'todo', job_id = NULL, result = NULL, error = NULL,
started_at = NULL, completed_at = NULL
WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
+91 -30
View File
@@ -1,30 +1,33 @@
//! Projects: shareable endeavours over an on-disk folder (registry / `system.db`).
//!
//! A project is an *endeavour* (owner + membership + metadata) that HAS a *place*:
//! a folder `{WD}/projects/{owner_userid}/{slug}` bind-mounted into each member's
//! container (the membership lives in [`super::project_members`]). This module owns
//! the `projects` row itself. Registry table — metadata is **not** encrypted (§2/§6);
//! only user↔agent conversations stay in the per-user encrypted DB.
use anyhow::Result;
use sqlx::SqlitePool;
/// A project row.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Project {
pub id: i64,
pub name: String,
pub path: String,
pub description: String,
pub run_context: Option<String>,
pub created_at: String,
pub updated_at: String,
pub id: i64,
pub owner_user_id: String,
/// Display name (free text).
pub name: String,
/// Path component — the on-disk folder + agent-visible segment. Immutable.
pub slug: String,
pub description: String,
pub run_context: Option<String>,
pub created_at: String,
pub updated_at: String,
}
const SELECT: &str =
"SELECT id, name, path, description, run_context, created_at, updated_at
"SELECT id, owner_user_id, name, slug, description, run_context, created_at, updated_at
FROM projects";
pub async fn list(pool: &SqlitePool) -> Result<Vec<Project>> {
let rows = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!(
"{SELECT} ORDER BY updated_at DESC"
)))
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Project>> {
let row = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
.bind(id)
@@ -33,19 +36,24 @@ pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Project>> {
Ok(row)
}
/// Creates a project and returns the new row. The caller must ensure `slug` is valid
/// ([`is_valid_slug`]) and unique for `owner_user_id` ([`unique_slug`]); the owner is
/// added to `project_members` separately (so mounts are uniform).
pub async fn create(
pool: &SqlitePool,
name: &str,
path: &str,
description: &str,
run_context: Option<&str>,
pool: &SqlitePool,
owner_user_id: &str,
name: &str,
slug: &str,
description: &str,
run_context: Option<&str>,
) -> Result<Project> {
let id = sqlx::query(
"INSERT INTO projects (name, path, description, run_context)
VALUES (?, ?, ?, ?)",
"INSERT INTO projects (owner_user_id, name, slug, description, run_context)
VALUES (?, ?, ?, ?, ?)",
)
.bind(owner_user_id)
.bind(name)
.bind(path)
.bind(slug)
.bind(description)
.bind(run_context)
.execute(pool)
@@ -59,22 +67,21 @@ pub async fn create(
Ok(row)
}
/// Updates the mutable fields. `slug` and `owner_user_id` are immutable — changing the
/// slug would move the on-disk folder and break every member's path.
pub async fn update(
pool: &SqlitePool,
id: i64,
name: &str,
path: &str,
description: &str,
run_context: Option<&str>,
) -> Result<bool> {
let n = sqlx::query(
"UPDATE projects
SET name = ?, path = ?, description = ?, run_context = ?,
updated_at = datetime('now')
SET name = ?, description = ?, run_context = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(name)
.bind(path)
.bind(description)
.bind(run_context)
.bind(id)
@@ -84,7 +91,7 @@ pub async fn update(
Ok(n > 0)
}
/// Touch updated_at — called after every ticket operation so ordering by recency works.
/// Touch `updated_at` so recency ordering works.
pub async fn touch(pool: &SqlitePool, id: i64) -> Result<()> {
sqlx::query("UPDATE projects SET updated_at = datetime('now') WHERE id = ?")
.bind(id)
@@ -101,3 +108,57 @@ pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
.rows_affected();
Ok(n > 0)
}
// ── Slug helpers ───────────────────────────────────────────────────────────────
/// A slug must be a single safe path component: it becomes a real directory
/// `{WD}/projects/{owner}/{slug}` and a `docker` mount target, so it may not be
/// empty, be a `.`/`..` traversal, or contain a separator. Same rule as
/// [`super::shared_folders::is_valid_folder_name`].
pub fn is_valid_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug != "."
&& slug != ".."
&& !slug.contains('/')
&& !slug.contains('\\')
&& !slug.contains('\0')
}
/// Best-effort slugify of a display name: lowercase ASCII alphanumerics, every other
/// run collapsed to a single `-`, trimmed. Falls back to `project` when nothing is left.
pub fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_dash = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() { "project".to_string() } else { trimmed.to_string() }
}
/// Returns a slug unique within `owner_user_id`, appending `-2`, `-3`, … on collision.
pub async fn unique_slug(pool: &SqlitePool, owner_user_id: &str, base: &str) -> Result<String> {
let existing: Vec<String> = sqlx::query_scalar(
"SELECT slug FROM projects WHERE owner_user_id = ?",
)
.bind(owner_user_id)
.fetch_all(pool)
.await?;
if !existing.iter().any(|s| s == base) {
return Ok(base.to_string());
}
let mut n = 2;
loop {
let cand = format!("{base}-{n}");
if !existing.iter().any(|s| s == &cand) {
return Ok(cand);
}
n += 1;
}
}
@@ -122,13 +122,6 @@ pub async fn create(
}
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
// Clear the soft back-reference from project_tickets first: its job_id FK has
// no ON DELETE action, so a ticket still pointing at this job would block the
// scheduled_jobs DELETE with a FOREIGN KEY constraint failure.
sqlx::query("UPDATE project_tickets SET job_id = NULL WHERE job_id = ?")
.bind(id)
.execute(pool)
.await?;
sqlx::query("DELETE FROM job_runs WHERE job_id = ?")
.bind(id)
.execute(pool)
+21 -93
View File
@@ -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<SqlitePool>,
}
impl ProjectManager {
pub fn new(db: Arc<SqlitePool>) -> Self {
Self { db }
}
pub async fn list(&self) -> Result<Vec<Project>> {
projects::list(&self.db).await
}
pub async fn get(&self, id: i64) -> Result<Option<Project>> {
projects::get(&self.db, id).await
}
pub async fn create(
&self,
name: &str,
path: &str,
description: &str,
run_context: Option<&RunContext>,
) -> Result<Project> {
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<bool> {
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<bool> {
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>) -> 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>,
) -> 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;
-178
View File
@@ -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<SqlitePool>,
task_mgr: std::sync::OnceLock<Arc<TaskManager>>,
}
impl ProjectTicketManager {
pub fn new(db: Arc<SqlitePool>) -> Arc<Self> {
Arc::new(Self {
db,
task_mgr: std::sync::OnceLock::new(),
})
}
pub fn set_task_manager(&self, tm: Arc<TaskManager>) {
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<Self>,
system_bus: Arc<SystemEventBus>,
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::<i64>().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<Vec<ProjectTicket>> {
project_tickets::list_for_project(&self.db, project_id).await
}
pub async fn get(&self, id: i64) -> Result<Option<ProjectTicket>> {
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<ProjectTicket> {
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<bool> {
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<RunContext> =
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(())
}
}
+4 -5
View File
@@ -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<TaskManager> { &self.tasks.cron }
pub fn projects(&self) -> &Arc<ProjectManager> { &self.tasks.projects }
pub fn ticket_manager(&self) -> &Arc<ProjectTicketManager> { &self.tasks.ticket_manager }
// Conversation
pub fn manager(&self) -> &Arc<ChatSessionManager> { &self.conversation.manager }
+4 -11
View File
@@ -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<TaskManager>,
pub(super) projects: Arc<ProjectManager>,
pub(super) ticket_manager: Arc<ProjectTicketManager>,
pub(super) cron: Arc<TaskManager>,
}
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(
@@ -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<ChatSessionManager>,
pub chat_hub: Arc<ChatHub>,
pub cron: Arc<TaskManager>,
pub tickets: Arc<ProjectTicketManager>,
pub approval: Arc<ApprovalManager>,
pub clarification: Arc<ClarificationManager>,
pub elicitation: Arc<ElicitationManager>,
@@ -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,
-1
View File
@@ -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");
+19 -1
View File
@@ -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());