Version 0.0.1 #2
@@ -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.
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})"
|
||||
)))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod approval_rules;
|
||||
pub mod project_tickets;
|
||||
pub mod project_members;
|
||||
pub mod projects;
|
||||
pub mod chat_history;
|
||||
pub mod chat_llm_tools;
|
||||
@@ -488,6 +488,42 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// ── Projects: shareable endeavours over an on-disk folder (blueprint §5 memory
|
||||
// note / §6). A project is an *endeavour* with an owner + membership that HAS a
|
||||
// *place*: a folder `{WD}/projects/{owner_userid}/{slug}` bind-mounted into each
|
||||
// member's container (like a shared folder, but two path segments — owner + slug).
|
||||
// Registry tables — metadata is NOT encrypted (only user↔agent conversations are);
|
||||
// this lets a project be shared across members without the cross-DB-FK problem that
|
||||
// an owner-bucket table would hit. `owner_user_id → users(id)` is registry→registry.
|
||||
// The owner is also inserted as a `project_members` row (can_write=1) so mounts are
|
||||
// uniform (a private project = a project with one member).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
run_context TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (owner_user_id, slug)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
can_write INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// ── MCP catalog + globally-active instances (blueprint §7/§14/§15) ──────────
|
||||
//
|
||||
// Registry tables: instance-wide MCP config, listable without any user key so
|
||||
@@ -901,40 +937,10 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
run_context TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS project_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'todo'
|
||||
CHECK(status IN ('todo','pending','in_progress','done','failed')),
|
||||
agent_id TEXT NOT NULL DEFAULT 'main',
|
||||
run_context TEXT,
|
||||
job_id INTEGER REFERENCES scheduled_jobs(id),
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// NOTE: `projects` + `project_members` are **registry** tables (see
|
||||
// `create_registry_tables`) — shareable, not encrypted. The old owner-bucket
|
||||
// `projects`/`project_tickets` tables (single-user Skald leftover) were removed
|
||||
// when projects became a shareable, container-mounted endeavour.
|
||||
|
||||
// Full request/response payloads for telemetry. Lives in the owner bucket
|
||||
// (per-user, encrypted) because it is conversation content. Correlated with
|
||||
@@ -1060,8 +1066,6 @@ mod tests {
|
||||
one("INSERT INTO mcp_events (source, method, payload) VALUES ('s', 'm', '{}')").await.unwrap();
|
||||
one("INSERT INTO sources (id, active_session_id) VALUES ('web', 1)").await.unwrap();
|
||||
one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap();
|
||||
one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap();
|
||||
one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap();
|
||||
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
|
||||
// Fires the AFTER INSERT trigger into the external-content FTS5 table.
|
||||
one("INSERT INTO memory_docs (path, content) VALUES ('notes/x.md', 'hello world')").await.unwrap();
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Project membership (registry / `system.db`): who can reach a project and with what
|
||||
//! capability. Mirrors [`super::shared_folders`]'s membership model — a junction table
|
||||
//! so a member can be read-only, and so both the container mount topology and the
|
||||
//! "shared with me / owner badge" list can query it in either direction.
|
||||
//!
|
||||
//! Two path segments distinguish it from a shared folder: a project lives at
|
||||
//! `projects/{owner_username}/{slug}`, so the mount rows carry the owner's **userid**
|
||||
//! (the host path segment, stable) and **username** (the agent-visible segment).
|
||||
//! FK `user_id → users(id)` is registry→registry (same file) — allowed.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// One project a user can reach, resolved for building their container mounts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectMountRow {
|
||||
pub project_id: i64,
|
||||
/// Owner's userid — the **host** path segment (`{WD}/projects/{owner_userid}/{slug}`).
|
||||
pub owner_user_id: String,
|
||||
/// Owner's username — the **agent-visible / container** path segment.
|
||||
pub owner_username: String,
|
||||
pub slug: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
/// A project as it appears in a user's list: identity, owner, the caller's capability,
|
||||
/// and whether the caller owns it. `owner_name` is `display_name || username`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectAccess {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub owner_user_id: String,
|
||||
pub owner_name: String,
|
||||
pub is_owner: bool,
|
||||
pub can_write: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// One member of a project — used by the share panel and the mount topology.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectMember {
|
||||
pub user_id: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Every project a user belongs to, resolved for their container mounts (owner's
|
||||
/// userid + username + slug + capability). Drives `build_user_fs`.
|
||||
pub async fn list_for_user_mounts(pool: &SqlitePool, user_id: &str) -> Result<Vec<ProjectMountRow>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String, i64)>(
|
||||
"SELECT p.id, p.owner_user_id, u.username, p.slug, m.can_write
|
||||
FROM project_members m
|
||||
JOIN projects p ON p.id = m.project_id
|
||||
JOIN users u ON u.id = p.owner_user_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY u.username, p.slug",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(project_id, owner_user_id, owner_username, slug, can_write)| ProjectMountRow {
|
||||
project_id,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
slug,
|
||||
can_write: can_write != 0,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The projects a user can see (owned + shared-with-them), for the UI list. Ordered by
|
||||
/// recency. `is_owner` distinguishes owned from shared (the owner-badge signal).
|
||||
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<ProjectAccess>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String, String, String, i64, i64, String)>(
|
||||
"SELECT p.id, p.name, p.slug, p.description, p.owner_user_id,
|
||||
COALESCE(NULLIF(ou.display_name, ''), ou.username) AS owner_name,
|
||||
(p.owner_user_id = ?) AS is_owner,
|
||||
m.can_write, p.updated_at
|
||||
FROM project_members m
|
||||
JOIN projects p ON p.id = m.project_id
|
||||
JOIN users ou ON ou.id = p.owner_user_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.updated_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, slug, description, owner_user_id, owner_name, is_owner, can_write, updated_at)| {
|
||||
ProjectAccess {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
owner_user_id,
|
||||
owner_name,
|
||||
is_owner: is_owner != 0,
|
||||
can_write: can_write != 0,
|
||||
updated_at,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The members of a project — the set of users whose containers mount it.
|
||||
pub async fn members(pool: &SqlitePool, project_id: i64) -> Result<Vec<ProjectMember>> {
|
||||
let rows = sqlx::query_as::<_, (String, i64)>(
|
||||
"SELECT user_id, can_write FROM project_members WHERE project_id = ?",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(user_id, can_write)| ProjectMember { user_id, can_write: can_write != 0 })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The caller's capability on a project: `None` when not a member, `Some(can_write)`
|
||||
/// otherwise. The authority check for reads (member) and writes/share (write-member).
|
||||
pub async fn capability_of(pool: &SqlitePool, project_id: i64, user_id: &str) -> Result<Option<bool>> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT can_write FROM project_members WHERE project_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(w,)| w != 0))
|
||||
}
|
||||
|
||||
// ── Writes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Adds (or updates the capability of) a member. Idempotent on the PK.
|
||||
pub async fn add_member(
|
||||
pool: &SqlitePool,
|
||||
project_id: i64,
|
||||
user_id: &str,
|
||||
can_write: bool,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO project_members (project_id, user_id, can_write)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (project_id, user_id) DO UPDATE SET can_write = excluded.can_write",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(user_id)
|
||||
.bind(can_write as i64)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_member(pool: &SqlitePool, project_id: i64, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM project_members WHERE project_id = ? AND user_id = ?")
|
||||
.bind(project_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
async fn registry_pool(tag: &str) -> (SqlitePool, PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("skald-projectmembers-{}-{tag}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pool = crate::db::init_system_pool(&dir.join("system.db").to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
(pool, dir)
|
||||
}
|
||||
|
||||
/// Proves the registry-→registry FK `project_members.user_id → users(id)` inserts
|
||||
/// with `PRAGMA foreign_keys=ON`, and that owned/shared are distinguished.
|
||||
#[tokio::test]
|
||||
async fn membership_list_distinguishes_owner_and_shared() {
|
||||
let (pool, dir) = registry_pool("list").await;
|
||||
for (id, name, display) in
|
||||
[("u1", "alice", None), ("u2", "bob", Some("Bob"))]
|
||||
{
|
||||
sqlx::query("INSERT INTO users (id, username, display_name, role_id, encrypted) VALUES (?, ?, ?, 'admin', 0)")
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(display)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Alice owns "budget", is a write-member of her own project.
|
||||
let p = super::super::projects::create(&pool, "u1", "Budget", "budget", "the money", None)
|
||||
.await
|
||||
.unwrap();
|
||||
add_member(&pool, p.id, "u1", true).await.unwrap();
|
||||
// Shared read-only with Bob.
|
||||
add_member(&pool, p.id, "u2", false).await.unwrap();
|
||||
|
||||
let alice = list_for_user(&pool, "u1").await.unwrap();
|
||||
assert_eq!(alice.len(), 1);
|
||||
assert!(alice[0].is_owner);
|
||||
assert!(alice[0].can_write);
|
||||
assert_eq!(alice[0].owner_name, "alice");
|
||||
|
||||
let bob = list_for_user(&pool, "u2").await.unwrap();
|
||||
assert_eq!(bob.len(), 1);
|
||||
assert!(!bob[0].is_owner);
|
||||
assert!(!bob[0].can_write);
|
||||
assert_eq!(bob[0].owner_name, "alice"); // owner is alice (no display name)
|
||||
|
||||
// Mount rows carry both userid and username of the owner.
|
||||
let mounts = list_for_user_mounts(&pool, "u2").await.unwrap();
|
||||
assert_eq!(mounts.len(), 1);
|
||||
assert_eq!(mounts[0].owner_user_id, "u1");
|
||||
assert_eq!(mounts[0].owner_username, "alice");
|
||||
assert_eq!(mounts[0].slug, "budget");
|
||||
assert!(!mounts[0].can_write);
|
||||
|
||||
assert_eq!(capability_of(&pool, p.id, "u2").await.unwrap(), Some(false));
|
||||
assert_eq!(capability_of(&pool, p.id, "nobody").await.unwrap(), None);
|
||||
|
||||
drop(pool);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct ProjectTicket {
|
||||
pub id: i64,
|
||||
pub project_id: i64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub agent_id: String,
|
||||
pub run_context: Option<String>,
|
||||
pub job_id: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: String,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub session_id: Option<i64>,
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT pt.id, pt.project_id, pt.title, pt.description, pt.status, pt.agent_id,
|
||||
pt.run_context, pt.job_id, pt.result, pt.error, pt.created_at,
|
||||
pt.started_at, pt.completed_at,
|
||||
COALESCE(sj.running_session_id,
|
||||
(SELECT session_id FROM job_runs
|
||||
WHERE job_id = pt.job_id ORDER BY id DESC LIMIT 1)
|
||||
) AS session_id
|
||||
FROM project_tickets pt
|
||||
LEFT JOIN scheduled_jobs sj ON sj.id = pt.job_id";
|
||||
|
||||
pub async fn list_for_project(pool: &SqlitePool, project_id: i64) -> Result<Vec<ProjectTicket>> {
|
||||
let rows = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.project_id = ? ORDER BY pt.id"
|
||||
)))
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<ProjectTicket>> {
|
||||
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.id = ?"
|
||||
)))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
project_id: i64,
|
||||
title: &str,
|
||||
description: &str,
|
||||
agent_id: &str,
|
||||
run_context: Option<&str>,
|
||||
) -> Result<ProjectTicket> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO project_tickets (project_id, title, description, agent_id, run_context)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.bind(agent_id)
|
||||
.bind(run_context)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.id = ?"
|
||||
)))
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
let n = sqlx::query("DELETE FROM project_tickets WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
pub async fn set_status(pool: &SqlitePool, id: i64, status: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE project_tickets SET status = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark as in_progress and record the scheduled job that is running it.
|
||||
pub async fn start(pool: &SqlitePool, id: i64, job_id: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = 'in_progress', job_id = ?, started_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark as done or failed, recording result/error and timestamp.
|
||||
pub async fn complete(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
result: Option<&str>,
|
||||
error: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let status = if error.is_some() { "failed" } else { "done" };
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = ?, result = ?, error = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(result)
|
||||
.bind(error)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset a ticket back to todo, clearing all run state.
|
||||
pub async fn reset(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = 'todo', job_id = NULL, result = NULL, error = NULL,
|
||||
started_at = NULL, completed_at = NULL
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
//! Projects: shareable endeavours over an on-disk folder (registry / `system.db`).
|
||||
//!
|
||||
//! A project is an *endeavour* (owner + membership + metadata) that HAS a *place*:
|
||||
//! a folder `{WD}/projects/{owner_userid}/{slug}` bind-mounted into each member's
|
||||
//! container (the membership lives in [`super::project_members`]). This module owns
|
||||
//! the `projects` row itself. Registry table — metadata is **not** encrypted (§2/§6);
|
||||
//! only user↔agent conversations stay in the per-user encrypted DB.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// A project row.
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct Project {
|
||||
pub id: i64,
|
||||
pub owner_user_id: String,
|
||||
/// Display name (free text).
|
||||
pub name: String,
|
||||
pub path: 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,
|
||||
@@ -13,18 +25,9 @@ pub struct Project {
|
||||
}
|
||||
|
||||
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,
|
||||
owner_user_id: &str,
|
||||
name: &str,
|
||||
path: &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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -102,10 +102,8 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
// 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))
|
||||
|
||||
+228
-205
@@ -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<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub struct MemberView {
|
||||
pub user_id: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
impl From<Project> 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<ProjectMember> 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<MemberView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProjectBody {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: Option<String>,
|
||||
pub security_group: Option<String>,
|
||||
}
|
||||
|
||||
impl ProjectBody {
|
||||
fn rc_json(&self) -> Option<String> {
|
||||
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<String>,
|
||||
pub job_id: Option<i64>,
|
||||
pub session_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>,
|
||||
}
|
||||
|
||||
impl From<ProjectTicket> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TicketBody {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub agent_id: Option<String>,
|
||||
pub security_group: Option<String>,
|
||||
}
|
||||
|
||||
impl TicketBody {
|
||||
fn rc_json(&self) -> Option<String> {
|
||||
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: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
@@ -119,182 +87,233 @@ impl<'de> Deserialize<'de> for ProjectPath {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TicketPath {
|
||||
impl<'de> Deserialize<'de> for MemberPath {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[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<PathBuf, ApiError> {
|
||||
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<ProjectDetail, ApiError> {
|
||||
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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<Json<Vec<ProjectResponse>>, 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<Json<Vec<ProjectAccess>>, 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Json(body): Json<ProjectBody>,
|
||||
) -> Result<(StatusCode, Json<ProjectResponse>), ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let rc_json = body.rc_json();
|
||||
) -> Result<(StatusCode, Json<ProjectDetail>), 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<ProjectPath>,
|
||||
) -> Result<Json<ProjectResponse>, 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<Json<ProjectDetail>, 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<ProjectPath>,
|
||||
Json(body): Json<ProjectBody>,
|
||||
) -> Result<Json<ProjectResponse>, 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<Json<ProjectDetail>, 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<ProjectPath>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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))) }
|
||||
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"));
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ── Ticket handlers ───────────────────────────────────────────────────────────
|
||||
// ── Membership (sharing) handlers ──────────────────────────────────────────────
|
||||
|
||||
pub async fn list_tickets(
|
||||
/// POST /api/projects/{id}/members — add (or re-grant) a member. Owner or write-member.
|
||||
pub async fn add_member(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<ProjectPath>,
|
||||
) -> Result<Json<Vec<TicketResponse>>, 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<ProjectPath>,
|
||||
Json(body): Json<TicketBody>,
|
||||
) -> Result<(StatusCode, Json<TicketResponse>), 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(tp): Path<TicketPath>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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)))
|
||||
Json(body): Json<MemberBody>,
|
||||
) -> Result<Json<Vec<MemberView>>, 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()))
|
||||
}
|
||||
|
||||
pub async fn start_ticket(
|
||||
/// 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(tp): Path<TicketPath>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<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 = 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<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(tp): Path<TicketPath>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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?;
|
||||
Path(mp): Path<MemberPath>,
|
||||
) -> Result<Json<Vec<MemberView>>, 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"));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@@ -307,12 +326,13 @@ 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,
|
||||
skald: &Skald,
|
||||
user_id: &str,
|
||||
source: &str,
|
||||
) -> Result<(String, Option<RunContext>), ApiError> {
|
||||
let Some(id) = source
|
||||
@@ -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<Json<SessionResponse>, 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?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
_users: { state: true },
|
||||
_add: { state: true },
|
||||
_error: { state: true },
|
||||
_expanded: { state: true },
|
||||
_expandedDesc: { state: true },
|
||||
_agents: { state: true },
|
||||
_groups: { state: true },
|
||||
_activeTab: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._project = null;
|
||||
this._tickets = [];
|
||||
this._modal = null;
|
||||
this._form = this._emptyForm();
|
||||
this._saving = false;
|
||||
this._users = [];
|
||||
this._add = { user_id: '', can_write: false };
|
||||
this._error = null;
|
||||
this._expanded = null;
|
||||
this._expandedDesc = {};
|
||||
this._pollTimer = null;
|
||||
this._projectId = null;
|
||||
this._agents = [];
|
||||
this._groups = [];
|
||||
this._activeTab = 'tickets';
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="${cardClass}">
|
||||
<div class="ticket-card-header">
|
||||
<span class="ticket-card-title">${ticket.title}</span>
|
||||
${isRunning ? html`
|
||||
<span class="spinner-border spinner-border-sm text-primary"
|
||||
style="width:0.7rem;height:0.7rem;flex-shrink:0"></span>
|
||||
` : nothing}
|
||||
<div class="d-flex align-items-center gap-2 py-1">
|
||||
<span style="min-width:10rem">${this._userLabel(m.user_id)}
|
||||
${isOwner ? html`<span class="badge text-bg-light ms-1">${t('projects.share.owner')}</span>` : nothing}
|
||||
</span>
|
||||
${isOwner ? html`
|
||||
<span class="text-muted" style="font-size:0.8rem">${t('projects.share.access.readwrite')}</span>
|
||||
` : manage ? html`
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn ${!m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
|
||||
@click=${() => m.can_write && this._setAccess(m.user_id, false)}>
|
||||
<i class="bi bi-eye me-1"></i>${t('projects.share.access.read')}
|
||||
</button>
|
||||
<button class="btn ${m.can_write ? 'btn-secondary' : 'btn-outline-secondary'}"
|
||||
@click=${() => !m.can_write && this._setAccess(m.user_id, true)}>
|
||||
<i class="bi bi-pencil me-1"></i>${t('projects.share.access.write')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${ticket.description
|
||||
? html`<div class="ticket-card-desc ${this._expandedDesc[ticket.id] ? 'ticket-card-desc--expanded' : ''}"
|
||||
@click=${() => { if (!window.getSelection().toString()) this._toggleDesc(ticket.id); }}>${ticket.description}</div>`
|
||||
: nothing}
|
||||
<div class="ticket-card-meta">
|
||||
<span><i class="bi bi-person me-1"></i>${ticket.agent_id}</span>
|
||||
${ticket.started_at ? html`
|
||||
<span><i class="bi bi-clock me-1"></i>${formatDate(ticket.started_at)}</span>
|
||||
<button class="btn btn-sm btn-outline-danger" title=${t('projects.share.remove')}
|
||||
@click=${() => this._removeMember(m.user_id)}>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
` : html`
|
||||
<span><i class="bi bi-calendar me-1"></i>${formatDate(ticket.created_at)}</span>
|
||||
<span class="text-muted" style="font-size:0.8rem">
|
||||
${m.can_write ? t('projects.share.access.readwrite') : t('projects.share.access.readonly')}
|
||||
</span>
|
||||
`}
|
||||
${isCompleted && ticket.completed_at ? html`
|
||||
<span><i class="bi bi-check2 me-1"></i>${formatDate(ticket.completed_at)}</span>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="ticket-card-actions">
|
||||
${ticket.status === 'todo' ? html`
|
||||
<button class="btn btn-sm btn-outline-primary ticket-card-btn"
|
||||
@click=${() => this._startTicket(ticket)}>
|
||||
<i class="bi bi-play-fill me-1"></i>${t('project_board.ticket.start')}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger ticket-card-btn"
|
||||
@click=${() => this._deleteTicket(ticket)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
|
||||
${isRunning ? html`
|
||||
<span class="ticket-card-running-label">${t('project_board.ticket.running')}</span>
|
||||
${ticket.session_id != null ? html`
|
||||
<a href="#session/${ticket.session_id}" class="ticket-card-session-link">
|
||||
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
|
||||
</a>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
|
||||
${isCompleted ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary ticket-card-btn"
|
||||
@click=${() => this._resetTicket(ticket)}>
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>${t('project_board.ticket.reset')}
|
||||
</button>
|
||||
<button class="btn btn-sm ticket-card-btn ${isDone ? 'btn-outline-success' : 'btn-outline-danger'}"
|
||||
@click=${() => this._toggleExpand(ticket.id)}>
|
||||
<i class="bi bi-${isExpanded ? 'chevron-up' : 'chevron-down'} me-1"></i>
|
||||
${isDone ? t('project_board.ticket.result') : t('project_board.ticket.error')}
|
||||
</button>
|
||||
${ticket.session_id != null ? html`
|
||||
<a href="#session/${ticket.session_id}"
|
||||
class="btn btn-sm btn-outline-secondary ticket-card-btn ticket-card-session-btn">
|
||||
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
|
||||
</a>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${isCompleted && isExpanded ? html`
|
||||
<div class="ticket-card-result ticket-card-result--${isDone ? 'success' : 'error'}">
|
||||
${isDone
|
||||
? html`<div class="ticket-result-markdown copilot-markdown">
|
||||
${unsafeHTML(renderMarkdown(ticket.result ?? t('project_board.ticket.no_output')))}
|
||||
</div>`
|
||||
: html`<pre class="ticket-result-error">${ticket.error ?? t('project_board.ticket.no_error')}</pre>`}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderSection(label, icon, colorClass, tickets, emptyLabel) {
|
||||
_renderSharePanel() {
|
||||
const candidates = this._candidates();
|
||||
const manage = this._canManage();
|
||||
return html`
|
||||
<div class="ticket-section">
|
||||
<div class="ticket-section-header ${colorClass}">
|
||||
<span><i class="bi bi-${icon} me-1"></i>${label}</span>
|
||||
<span class="badge bg-secondary ms-2">${tickets.length}</span>
|
||||
</div>
|
||||
${tickets.length === 0
|
||||
? html`<div class="ticket-section-empty">${emptyLabel}</div>`
|
||||
: tickets.map(t => this._renderTicketCard(t))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-semibold mb-3"><i class="bi bi-people me-1"></i>${t('projects.share.title')}</h6>
|
||||
${(this._project.members ?? []).map(m => this._renderMember(m))}
|
||||
|
||||
_renderTabBar() {
|
||||
return html`
|
||||
<div class="project-tab-bar">
|
||||
<button
|
||||
class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}"
|
||||
@click=${() => { this._activeTab = 'tickets'; }}>
|
||||
<i class="bi bi-card-list me-1"></i>${t('project_board.tab.tickets')}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTicketsTab() {
|
||||
const { running, todo, completed } = this._groupTickets();
|
||||
return html`
|
||||
<div class="ticket-list">
|
||||
${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'))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop">
|
||||
<div class="agent-dialog agent-dialog--ticket">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
|
||||
<i class="bi bi-card-text"></i>
|
||||
<span style="font-weight:600">${t('project_board.modal.title')}</span>
|
||||
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
|
||||
@click=${() => this._modal = null}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
<form @submit=${e => this._createTicket(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.title_label')}</label>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder=${t('project_board.modal.title_ph')}
|
||||
.value=${this._form.title}
|
||||
@input=${e => this._form = { ...this._form, title: e.target.value }} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.desc_label')}</label>
|
||||
<textarea class="form-control form-control-sm" rows="4"
|
||||
placeholder=${t('project_board.modal.desc_ph')}
|
||||
.value=${this._form.description}
|
||||
@input=${e => this._form = { ...this._form, description: e.target.value }}></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.agent')}</label>
|
||||
<select class="form-select form-select-sm"
|
||||
.value=${this._form.agent_id}
|
||||
@change=${e => this._form = { ...this._form, agent_id: e.target.value }}>
|
||||
${this._agents.filter(a => a.type === 'task').map(a => html`
|
||||
<option value=${a.id} ?selected=${this._form.agent_id === a.id}>${a.name || a.id}</option>
|
||||
`)}
|
||||
${manage ? html`
|
||||
<hr class="my-3" />
|
||||
${candidates.length > 0 ? html`
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<select class="form-select form-select-sm" style="max-width:16rem"
|
||||
@change=${e => this._add = { ...this._add, user_id: e.target.value }}>
|
||||
<option value="" ?selected=${!this._add.user_id}>${t('projects.share.choose_user')}</option>
|
||||
${candidates.map(u => html`
|
||||
<option value=${u.id} ?selected=${this._add.user_id === u.id}>${u.display_name || u.username}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('project_board.modal.security_group')}</label>
|
||||
<select class="form-select form-select-sm"
|
||||
.value=${this._form.security_group}
|
||||
@change=${e => this._form = { ...this._form, security_group: e.target.value }}>
|
||||
<option value="">${t('project_board.modal.inherit')}</option>
|
||||
${this._groups.map(g => html`
|
||||
<option value=${g.id} ?selected=${this._form.security_group === g.id}>${g.name}</option>
|
||||
`)}
|
||||
<select class="form-select form-select-sm" style="max-width:11rem"
|
||||
@change=${e => this._add = { ...this._add, can_write: e.target.value === 'write' }}>
|
||||
<option value="read" ?selected=${!this._add.can_write}>${t('projects.share.access.readonly')}</option>
|
||||
<option value="write" ?selected=${this._add.can_write}>${t('projects.share.access.readwrite')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
@click=${() => this._modal = null}>${t('project_board.modal.cancel')}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('project_board.modal.saving')}`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>${t('project_board.modal.create')}`}
|
||||
<button class="btn btn-sm btn-primary" ?disabled=${!this._add.user_id}
|
||||
@click=${() => this._addMember()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>${t('projects.share.add')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
` : html`<div class="text-muted" style="font-size:0.85rem">${t('projects.share.all_added')}</div>`}
|
||||
` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<div class="card mb-3">
|
||||
<div class="card-body text-center text-muted py-4">
|
||||
<i class="bi bi-folder2-open" style="font-size:1.6rem"></i>
|
||||
<p class="mb-0 mt-2" style="font-size:0.9rem">${t('projects.files.placeholder')}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -453,27 +235,28 @@ export class ProjectBoardSection extends LightElement {
|
||||
<h2 class="project-page-title">
|
||||
<i class="bi bi-folder2"></i>${this._project.name}
|
||||
</h2>
|
||||
${this._project.is_owner
|
||||
? html`<span class="badge text-bg-light"><i class="bi bi-person me-1"></i>${t('projects.badge.owned')}</span>`
|
||||
: html`<span class="badge text-bg-light"><i class="bi bi-people me-1"></i>${t('projects.badge.shared_by', { name: this._project.owner_name })}</span>`}
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<button class="btn btn-sm btn-outline-primary" @click=${() => this._openChat()}>
|
||||
<i class="bi bi-chat-dots me-1"></i>${t('project_board.open_chat')}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary"
|
||||
@click=${() => { this._form = this._emptyForm(); this._error = null; this._modal = { mode: 'add' }; this._loadModalData(); }}>
|
||||
<i class="bi bi-plus-lg me-1"></i>${t('project_board.new_ticket')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._renderTabBar()}
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._activeTab === 'tickets' ? this._renderTicketsTab() : nothing}
|
||||
|
||||
${this._modal ? this._renderModal() : nothing}
|
||||
<div class="p-3">
|
||||
${this._project.description
|
||||
? html`<p class="text-muted" style="font-size:0.9rem">${this._project.description}</p>`
|
||||
: nothing}
|
||||
${this._renderFilesPanel()}
|
||||
${this._renderSharePanel()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.path')}</label>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder=${t('projects.modal.path_ph')}
|
||||
.value=${this._form.path}
|
||||
@input=${e => this._setField('path', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.modal.desc')}</label>
|
||||
<textarea class="form-control form-control-sm" rows="2"
|
||||
@@ -170,17 +163,26 @@ export class ProjectListSection extends LightElement {
|
||||
<div class="project-card-header">
|
||||
<div class="project-card-title">${project.name}</div>
|
||||
<div class="project-card-actions" @click=${e => e.stopPropagation()}>
|
||||
${project.can_write ? html`
|
||||
<button class="project-card-icon-btn" title=${t('projects.action.edit')}
|
||||
@click=${() => this._openEdit(project)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</button>` : nothing}
|
||||
${project.is_owner ? html`
|
||||
<button class="project-card-icon-btn project-card-icon-btn--danger" title=${t('projects.action.delete')}
|
||||
@click=${() => this._delete(project)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</button>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-card-path"><i class="bi bi-folder2 me-1"></i>${project.path}</div>
|
||||
<div class="project-card-path">
|
||||
${project.is_owner
|
||||
? html`<span class="badge text-bg-light"><i class="bi bi-person me-1"></i>${t('projects.badge.owned')}</span>`
|
||||
: html`<span class="badge text-bg-light"><i class="bi bi-people me-1"></i>${t('projects.badge.shared_by', { name: project.owner_name })}</span>`}
|
||||
${!project.can_write
|
||||
? html`<span class="badge text-bg-light ms-1" title=${t('projects.badge.readonly')}><i class="bi bi-eye"></i></span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${project.description
|
||||
? html`<div class="project-card-desc">${project.description}</div>`
|
||||
: nothing}
|
||||
|
||||
+18
-31
@@ -184,14 +184,15 @@ export default {
|
||||
'projects.action.edit': 'Edit',
|
||||
'projects.action.delete': 'Delete',
|
||||
'projects.card.updated': 'Updated',
|
||||
'projects.confirm.delete': 'Delete project "{name}"?\nAll tickets will also be deleted.',
|
||||
'projects.confirm.delete': 'Delete project "{name}"? This removes it for everyone it is shared with.',
|
||||
'projects.badge.owned': 'Owned',
|
||||
'projects.badge.shared_by': 'Shared by {name}',
|
||||
'projects.badge.readonly': 'Read-only',
|
||||
|
||||
'projects.modal.title_edit': 'Edit Project',
|
||||
'projects.modal.title_new': 'New Project',
|
||||
'projects.modal.name': 'Name',
|
||||
'projects.modal.name_ph': 'My Project',
|
||||
'projects.modal.path': 'Path',
|
||||
'projects.modal.path_ph': '/path/to/project',
|
||||
'projects.modal.desc': 'Description',
|
||||
'projects.modal.desc_ph': 'What this project is about',
|
||||
'projects.modal.cancel': 'Cancel',
|
||||
@@ -199,36 +200,22 @@ export default {
|
||||
'projects.modal.save': 'Save',
|
||||
'projects.modal.create': 'Create',
|
||||
|
||||
// ── Project board ───────────────────────────────────────────────────────────
|
||||
// ── Project sharing ─────────────────────────────────────────────────────────
|
||||
'projects.share.title': 'Sharing',
|
||||
'projects.share.owner': 'owner',
|
||||
'projects.share.choose_user': 'Choose a person…',
|
||||
'projects.share.add': 'Add',
|
||||
'projects.share.all_added': 'Everyone already has access.',
|
||||
'projects.share.remove': 'Remove',
|
||||
'projects.share.access.read': 'Read',
|
||||
'projects.share.access.write': 'Write',
|
||||
'projects.share.access.readonly': 'Read-only',
|
||||
'projects.share.access.readwrite':'Read & write',
|
||||
'projects.files.placeholder': 'The project files live here. Open the chat to work in this folder — a file explorer is coming.',
|
||||
|
||||
// ── Project detail ──────────────────────────────────────────────────────────
|
||||
'project_board.back': 'Projects',
|
||||
'project_board.open_chat': 'Open Chat',
|
||||
'project_board.new_ticket': 'New Ticket',
|
||||
'project_board.tab.tickets': 'Tickets',
|
||||
'project_board.section.running': 'Running',
|
||||
'project_board.section.running_empty': 'No tickets running',
|
||||
'project_board.section.todo': 'Todo',
|
||||
'project_board.section.todo_empty': 'No tickets to do',
|
||||
'project_board.section.completed': 'Completed',
|
||||
'project_board.section.completed_empty': 'No completed tickets',
|
||||
'project_board.ticket.start': 'Start',
|
||||
'project_board.ticket.running': 'Running…',
|
||||
'project_board.ticket.reset': 'Reset',
|
||||
'project_board.ticket.result': 'Result',
|
||||
'project_board.ticket.error': 'Error',
|
||||
'project_board.ticket.no_output': '(no output)',
|
||||
'project_board.ticket.no_error': '(no error message)',
|
||||
'project_board.modal.title': 'New Ticket',
|
||||
'project_board.modal.title_label': 'Title',
|
||||
'project_board.modal.title_ph': 'What needs to be done',
|
||||
'project_board.modal.desc_label': 'Description / Prompt',
|
||||
'project_board.modal.desc_ph': 'Detailed instructions for the agent…',
|
||||
'project_board.modal.agent': 'Agent',
|
||||
'project_board.modal.security_group':'Security Group',
|
||||
'project_board.modal.inherit': '— inherit from project —',
|
||||
'project_board.modal.cancel': 'Cancel',
|
||||
'project_board.modal.saving': 'Saving…',
|
||||
'project_board.modal.create': 'Create',
|
||||
'project_board.confirm.delete': 'Delete ticket "{title}"?',
|
||||
|
||||
// ── Session detail ──────────────────────────────────────────────────────────
|
||||
'session.back': 'Back',
|
||||
|
||||
+18
-31
@@ -184,14 +184,15 @@ export default {
|
||||
'projects.action.edit': 'Modifier',
|
||||
'projects.action.delete': 'Supprimer',
|
||||
'projects.card.updated': 'Mis à jour',
|
||||
'projects.confirm.delete': 'Supprimer le projet "{name}" ?\nTous les tickets seront également supprimés.',
|
||||
'projects.confirm.delete': 'Supprimer le projet "{name}" ? Il sera retiré pour toutes les personnes avec qui il est partagé.',
|
||||
'projects.badge.owned': 'Le vôtre',
|
||||
'projects.badge.shared_by': 'Partagé par {name}',
|
||||
'projects.badge.readonly': 'Lecture seule',
|
||||
|
||||
'projects.modal.title_edit': 'Modifier le projet',
|
||||
'projects.modal.title_new': 'Nouveau projet',
|
||||
'projects.modal.name': 'Nom',
|
||||
'projects.modal.name_ph': 'Mon projet',
|
||||
'projects.modal.path': 'Chemin',
|
||||
'projects.modal.path_ph': '/chemin/vers/le/projet',
|
||||
'projects.modal.desc': 'Description',
|
||||
'projects.modal.desc_ph': 'À propos de ce projet',
|
||||
'projects.modal.cancel': 'Annuler',
|
||||
@@ -199,36 +200,22 @@ export default {
|
||||
'projects.modal.save': 'Enregistrer',
|
||||
'projects.modal.create': 'Créer',
|
||||
|
||||
// ── Project board ───────────────────────────────────────────────────────────
|
||||
// ── Partage de projet ───────────────────────────────────────────────────────
|
||||
'projects.share.title': 'Partage',
|
||||
'projects.share.owner': 'propriétaire',
|
||||
'projects.share.choose_user': 'Choisir une personne…',
|
||||
'projects.share.add': 'Ajouter',
|
||||
'projects.share.all_added': 'Tout le monde a déjà accès.',
|
||||
'projects.share.remove': 'Retirer',
|
||||
'projects.share.access.read': 'Lecture',
|
||||
'projects.share.access.write': 'Écriture',
|
||||
'projects.share.access.readonly': 'Lecture seule',
|
||||
'projects.share.access.readwrite':'Lecture et écriture',
|
||||
'projects.files.placeholder': 'Les fichiers du projet vivent ici. Ouvrez la discussion pour travailler dans ce dossier — un explorateur de fichiers arrive bientôt.',
|
||||
|
||||
// ── Détail du projet ────────────────────────────────────────────────────────
|
||||
'project_board.back': 'Projets',
|
||||
'project_board.open_chat': 'Ouvrir la discussion',
|
||||
'project_board.new_ticket': 'Nouveau ticket',
|
||||
'project_board.tab.tickets': 'Tickets',
|
||||
'project_board.section.running': 'En cours',
|
||||
'project_board.section.running_empty': 'Aucun ticket en cours',
|
||||
'project_board.section.todo': 'À faire',
|
||||
'project_board.section.todo_empty': 'Aucun ticket à faire',
|
||||
'project_board.section.completed': 'Terminé',
|
||||
'project_board.section.completed_empty': 'Aucun ticket terminé',
|
||||
'project_board.ticket.start': 'Démarrer',
|
||||
'project_board.ticket.running': 'En cours…',
|
||||
'project_board.ticket.reset': 'Réinitialiser',
|
||||
'project_board.ticket.result': 'Résultat',
|
||||
'project_board.ticket.error': 'Erreur',
|
||||
'project_board.ticket.no_output': '(aucune sortie)',
|
||||
'project_board.ticket.no_error': '(aucun message d\'erreur)',
|
||||
'project_board.modal.title': 'Nouveau ticket',
|
||||
'project_board.modal.title_label': 'Titre',
|
||||
'project_board.modal.title_ph': 'Ce qui doit être fait',
|
||||
'project_board.modal.desc_label': 'Description / Prompt',
|
||||
'project_board.modal.desc_ph': 'Instructions détaillées pour l\'agent…',
|
||||
'project_board.modal.agent': 'Agent',
|
||||
'project_board.modal.security_group':'Groupe de sécurité',
|
||||
'project_board.modal.inherit': '— hériter du projet —',
|
||||
'project_board.modal.cancel': 'Annuler',
|
||||
'project_board.modal.saving': 'Enregistrement…',
|
||||
'project_board.modal.create': 'Créer',
|
||||
'project_board.confirm.delete': 'Supprimer le ticket "{title}" ?',
|
||||
|
||||
// ── Session detail ──────────────────────────────────────────────────────────
|
||||
'session.back': 'Retour',
|
||||
|
||||
+18
-31
@@ -208,14 +208,15 @@ export default {
|
||||
'projects.action.edit': 'Modifica',
|
||||
'projects.action.delete': 'Elimina',
|
||||
'projects.card.updated': 'Aggiornato',
|
||||
'projects.confirm.delete': 'Eliminare il progetto "{name}"?\nTutti i ticket verranno eliminati.',
|
||||
'projects.confirm.delete': 'Eliminare il progetto "{name}"? Verrà rimosso per tutti coloro con cui è condiviso.',
|
||||
'projects.badge.owned': 'Tuo',
|
||||
'projects.badge.shared_by': 'Condiviso da {name}',
|
||||
'projects.badge.readonly': 'Sola lettura',
|
||||
|
||||
'projects.modal.title_edit': 'Modifica progetto',
|
||||
'projects.modal.title_new': 'Nuovo progetto',
|
||||
'projects.modal.name': 'Nome',
|
||||
'projects.modal.name_ph': 'Mio progetto',
|
||||
'projects.modal.path': 'Percorso',
|
||||
'projects.modal.path_ph': '/percorso/del/progetto',
|
||||
'projects.modal.desc': 'Descrizione',
|
||||
'projects.modal.desc_ph': 'Di cosa tratta questo progetto',
|
||||
'projects.modal.cancel': 'Annulla',
|
||||
@@ -223,36 +224,22 @@ export default {
|
||||
'projects.modal.save': 'Salva',
|
||||
'projects.modal.create': 'Crea',
|
||||
|
||||
// ── Project board ───────────────────────────────────────────────────────────
|
||||
// ── Condivisione progetto ───────────────────────────────────────────────────
|
||||
'projects.share.title': 'Condivisione',
|
||||
'projects.share.owner': 'proprietario',
|
||||
'projects.share.choose_user': 'Scegli una persona…',
|
||||
'projects.share.add': 'Aggiungi',
|
||||
'projects.share.all_added': 'Tutti hanno già accesso.',
|
||||
'projects.share.remove': 'Rimuovi',
|
||||
'projects.share.access.read': 'Lettura',
|
||||
'projects.share.access.write': 'Scrittura',
|
||||
'projects.share.access.readonly': 'Sola lettura',
|
||||
'projects.share.access.readwrite':'Lettura e scrittura',
|
||||
'projects.files.placeholder': 'Qui vivono i file del progetto. Apri la chat per lavorare in questa cartella — un file explorer è in arrivo.',
|
||||
|
||||
// ── Dettaglio progetto ──────────────────────────────────────────────────────
|
||||
'project_board.back': 'Progetti',
|
||||
'project_board.open_chat': 'Apri chat',
|
||||
'project_board.new_ticket': 'Nuovo ticket',
|
||||
'project_board.tab.tickets': 'Ticket',
|
||||
'project_board.section.running': 'In esecuzione',
|
||||
'project_board.section.running_empty': 'Nessun ticket in esecuzione',
|
||||
'project_board.section.todo': 'Da fare',
|
||||
'project_board.section.todo_empty': 'Nessun ticket da fare',
|
||||
'project_board.section.completed': 'Completati',
|
||||
'project_board.section.completed_empty': 'Nessun ticket completato',
|
||||
'project_board.ticket.start': 'Avvia',
|
||||
'project_board.ticket.running': 'In esecuzione…',
|
||||
'project_board.ticket.reset': 'Reimposta',
|
||||
'project_board.ticket.result': 'Risultato',
|
||||
'project_board.ticket.error': 'Errore',
|
||||
'project_board.ticket.no_output': '(nessun output)',
|
||||
'project_board.ticket.no_error': '(nessun errore)',
|
||||
'project_board.modal.title': 'Nuovo ticket',
|
||||
'project_board.modal.title_label': 'Titolo',
|
||||
'project_board.modal.title_ph': 'Cosa bisogna fare',
|
||||
'project_board.modal.desc_label': 'Descrizione / Prompt',
|
||||
'project_board.modal.desc_ph': 'Istruzioni dettagliate per l\'agente…',
|
||||
'project_board.modal.agent': 'Agente',
|
||||
'project_board.modal.security_group':'Gruppo di sicurezza',
|
||||
'project_board.modal.inherit': '— eredita dal progetto —',
|
||||
'project_board.modal.cancel': 'Annulla',
|
||||
'project_board.modal.saving': 'Salvataggio…',
|
||||
'project_board.modal.create': 'Crea',
|
||||
'project_board.confirm.delete': 'Eliminare il ticket "{title}"?',
|
||||
|
||||
// ── Richieste ──────────────────────────────────────────────────────────────
|
||||
'inbox.empty': 'Nessuna richiesta in attesa',
|
||||
|
||||
Reference in New Issue
Block a user