Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .granary/granary.db
Binary file not shown.
Binary file modified .granary/granary.db-shm
Binary file not shown.
Binary file modified .granary/granary.db-wal
Binary file not shown.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "granary"
version = "0.8.0"
version = "0.9.0"
edition = "2024"
description = "A CLI context hub for agentic work"
license = "MIT"
Expand Down
22 changes: 22 additions & 0 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub enum Commands {

/// Show any entity by ID (auto-detects type from ID pattern)
#[command(
visible_alias = "get",
visible_alias = "view",
after_help = "EXAMPLES:\n granary show my-project-abc1 # Show a project\n granary show my-project-abc1-task-1 # Show a task\n granary show sess-20260112-xyz1 # Show a session\n granary show chkpt-abc123 # Show a checkpoint\n\nID PATTERNS:\n project: <name>-<4chars> e.g., my-project-abc1\n task: <project-id>-task-<n> e.g., my-project-abc1-task-1\n session: sess-<date>-<4chars> e.g., sess-20260112-xyz1\n checkpoint: chkpt-<6chars> e.g., chkpt-abc123\n comment: <task-id>-comment-<n> e.g., my-proj-abc1-task-1-comment-1\n artifact: <task-id>-artifact-<n> e.g., my-proj-abc1-task-1-artifact-1"
)]
Show {
Expand Down Expand Up @@ -342,6 +344,7 @@ pub enum Commands {
#[derive(Subcommand)]
pub enum ProjectsAction {
/// Create a new project
#[command(visible_alias = "add", visible_alias = "new")]
Create {
/// Project name
name: String,
Expand Down Expand Up @@ -381,10 +384,22 @@ pub enum ProjectAction {
tags: Option<String>,
},

/// Mark project as done/complete
#[command(visible_alias = "complete")]
Done {
/// Also mark all tasks in the project as done
#[arg(long)]
complete_tasks: bool,
},

/// Archive project
Archive,

/// Unarchive project (restore from archived state)
Unarchive,

/// List or create tasks in project
#[command(visible_alias = "task")]
Tasks {
#[command(subcommand)]
action: Option<ProjectTasksAction>,
Expand Down Expand Up @@ -421,6 +436,7 @@ pub enum ProjectDepsAction {
#[derive(Subcommand)]
pub enum ProjectTasksAction {
/// Create a new task
#[command(visible_alias = "add", visible_alias = "new")]
Create {
/// Task title
title: String,
Expand Down Expand Up @@ -543,18 +559,21 @@ pub enum TaskAction {
},

/// List or create subtasks
#[command(visible_alias = "task")]
Tasks {
#[command(subcommand)]
action: Option<SubtaskAction>,
},

/// List or create comments
#[command(visible_alias = "comment")]
Comments {
#[command(subcommand)]
action: Option<CommentAction>,
},

/// List or manage artifacts
#[command(visible_alias = "artifact")]
Artifacts {
#[command(subcommand)]
action: Option<ArtifactAction>,
Expand Down Expand Up @@ -582,6 +601,7 @@ pub enum DepsAction {
#[derive(Subcommand)]
pub enum SubtaskAction {
/// Create a subtask
#[command(visible_alias = "add", visible_alias = "new")]
Create {
/// Subtask title
title: String,
Expand All @@ -603,6 +623,7 @@ pub enum SubtaskAction {
#[derive(Subcommand)]
pub enum CommentAction {
/// Create a comment
#[command(visible_alias = "add", visible_alias = "new")]
Create {
/// Comment content (positional argument)
content_positional: Option<String>,
Expand Down Expand Up @@ -880,6 +901,7 @@ pub enum SteeringAction {
#[derive(Subcommand)]
pub enum InitiativesAction {
/// Create a new initiative
#[command(visible_alias = "add", visible_alias = "new")]
Create {
/// Initiative name
name: String,
Expand Down
14 changes: 14 additions & 0 deletions src/cli/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ pub async fn project(id: &str, action: Option<ProjectAction>, format: OutputForm
println!("Archived project: {}", project.id);
}

Some(ProjectAction::Unarchive) => {
let project = services::unarchive_project(&pool, id).await?;
println!("Unarchived project: {}", project.id);
}

Some(ProjectAction::Done { complete_tasks }) => {
let project = services::complete_project(&pool, id, complete_tasks).await?;
if complete_tasks {
println!("Completed project and all tasks: {}", project.id);
} else {
println!("Completed project: {}", project.id);
}
}

Some(ProjectAction::Tasks { action }) => {
match action {
None => {
Expand Down
12 changes: 11 additions & 1 deletion src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub mod projects {
.await?
} else {
sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE status = 'active' ORDER BY created_at DESC",
"SELECT * FROM projects WHERE status IN ('active', 'done') ORDER BY created_at DESC",
)
.fetch_all(pool)
.await?
Expand Down Expand Up @@ -91,6 +91,16 @@ pub mod projects {
Ok(result.rows_affected() > 0)
}

pub async fn unarchive(pool: &SqlitePool, id: &str) -> Result<bool> {
let result =
sqlx::query("UPDATE projects SET status = 'active', updated_at = ? WHERE id = ?")
.bind(chrono::Utc::now().to_rfc3339())
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}

/// List projects that have at least one available (next) task.
/// A project is 'available' if it has tasks that are:
/// - Status is 'todo' or 'in_progress'
Expand Down
6 changes: 6 additions & 0 deletions src/models/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ pub enum EventType {
// Project events
ProjectCreated,
ProjectUpdated,
ProjectCompleted,
ProjectArchived,
ProjectUnarchived,

// Task events
TaskCreated,
Expand Down Expand Up @@ -58,7 +60,9 @@ impl EventType {
match self {
EventType::ProjectCreated => "project.created".to_string(),
EventType::ProjectUpdated => "project.updated".to_string(),
EventType::ProjectCompleted => "project.completed".to_string(),
EventType::ProjectArchived => "project.archived".to_string(),
EventType::ProjectUnarchived => "project.unarchived".to_string(),
EventType::TaskCreated => "task.created".to_string(),
EventType::TaskUpdated => "task.updated".to_string(),
EventType::TaskStatusChanged => "task.status_changed".to_string(),
Expand Down Expand Up @@ -96,7 +100,9 @@ impl std::str::FromStr for EventType {
Ok(match s {
"project.created" => EventType::ProjectCreated,
"project.updated" => EventType::ProjectUpdated,
"project.completed" => EventType::ProjectCompleted,
"project.archived" => EventType::ProjectArchived,
"project.unarchived" => EventType::ProjectUnarchived,
"task.created" => EventType::TaskCreated,
"task.updated" => EventType::TaskUpdated,
"task.status_changed" => EventType::TaskStatusChanged,
Expand Down
3 changes: 3 additions & 0 deletions src/models/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ use sqlx::FromRow;
pub enum ProjectStatus {
#[default]
Active,
Done,
Archived,
}

impl ProjectStatus {
pub fn as_str(&self) -> &'static str {
match self {
ProjectStatus::Active => "active",
ProjectStatus::Done => "done",
ProjectStatus::Archived => "archived",
}
}
Expand All @@ -24,6 +26,7 @@ impl std::str::FromStr for ProjectStatus {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"active" => Ok(ProjectStatus::Active),
"done" | "complete" | "completed" => Ok(ProjectStatus::Done),
"archived" => Ok(ProjectStatus::Archived),
_ => Err(()),
}
Expand Down
91 changes: 91 additions & 0 deletions src/services/project_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,94 @@ pub async fn archive_project(pool: &SqlitePool, id: &str) -> Result<Project> {

get_project(pool, id).await
}

/// Unarchive a project (restore from archived state)
pub async fn unarchive_project(pool: &SqlitePool, id: &str) -> Result<Project> {
let project = get_project(pool, id).await?;

if project.status == ProjectStatus::Active.as_str() {
return Err(GranaryError::Conflict(format!(
"Project {} is already active",
id
)));
}

db::projects::unarchive(pool, id).await?;

// Log event
db::events::create(
pool,
&CreateEvent {
event_type: EventType::ProjectUnarchived,
entity_type: EntityType::Project,
entity_id: id.to_string(),
actor: None,
session_id: None,
payload: serde_json::json!({}),
},
)
.await?;

get_project(pool, id).await
}

/// Complete a project (mark as done)
pub async fn complete_project(
pool: &SqlitePool,
id: &str,
complete_tasks: bool,
) -> Result<Project> {
let project = get_project(pool, id).await?;

if project.status == ProjectStatus::Done.as_str() {
return Err(GranaryError::Conflict(format!(
"Project {} is already done",
id
)));
}

// Optionally complete all tasks
let tasks_completed = if complete_tasks {
let tasks = crate::services::list_tasks_by_project(pool, id).await?;
let mut count = 0;
for task in tasks {
if task.status != "done" {
crate::services::complete_task(pool, &task.id, None).await?;
count += 1;
}
}
count
} else {
0
};

// Update project status
let project = update_project(
pool,
id,
UpdateProject {
status: Some(ProjectStatus::Done),
..Default::default()
},
)
.await?;

// Log event
db::events::create(
pool,
&CreateEvent {
event_type: EventType::ProjectCompleted,
entity_type: EntityType::Project,
entity_id: id.to_string(),
actor: None,
session_id: None,
payload: serde_json::json!({
"tasks_completed": tasks_completed,
"complete_tasks": complete_tasks,
}),
},
)
.await?;

Ok(project)
}
66 changes: 65 additions & 1 deletion src/services/task_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,34 @@ use crate::models::*;
/// Create a new task in a project
pub async fn create_task(pool: &SqlitePool, input: CreateTask) -> Result<Task> {
// Verify project exists
let _project = crate::services::get_project(pool, &input.project_id).await?;
let project = crate::services::get_project(pool, &input.project_id).await?;

// If project is done, revert to active when adding a new task
if project.status == ProjectStatus::Done.as_str() {
crate::services::update_project(
pool,
&input.project_id,
UpdateProject {
status: Some(ProjectStatus::Active),
..Default::default()
},
)
.await?;

// Log reactivation event
db::events::create(
pool,
&CreateEvent {
event_type: EventType::ProjectUpdated,
entity_type: EntityType::Project,
entity_id: input.project_id.clone(),
actor: None,
session_id: None,
payload: serde_json::json!({"action": "reactivated_by_new_task"}),
},
)
.await?;
}

// Get next task number
let scope = format!("project:{}:task", input.project_id);
Expand Down Expand Up @@ -315,6 +342,43 @@ pub async fn complete_task(pool: &SqlitePool, id: &str, comment: Option<&str>) -
)
.await?;

// Check if this was the last incomplete task in the project
// If so, auto-complete the project
let remaining_tasks = db::tasks::list_by_project(pool, &task.project_id).await?;
let all_done = remaining_tasks.iter().all(|t| t.status == "done");

if all_done {
let project = crate::services::get_project(pool, &task.project_id).await?;
if project.status == ProjectStatus::Active.as_str() {
crate::services::update_project(
pool,
&task.project_id,
UpdateProject {
status: Some(ProjectStatus::Done),
..Default::default()
},
)
.await?;

// Log implicit completion event
db::events::create(
pool,
&CreateEvent {
event_type: EventType::ProjectUpdated,
entity_type: EntityType::Project,
entity_id: task.project_id.clone(),
actor: None,
session_id: None,
payload: serde_json::json!({
"action": "implicit_completion",
"trigger_task": task.id,
}),
},
)
.await?;
}
}

get_task(pool, id).await
}

Expand Down