generated from eblume/project-template
The `--project <name>` argument matched titles case-sensitively and exactly, so `--project hephaestus` or `--project heph` failed against a `Hephaestus` project. Make project-name resolution forgiving but deterministic, via a tiered match in `resolve_project_id`: 1. exact (case-sensitive) — the historical behavior; always wins 2. case-insensitive exact — only when unambiguous 3. case-insensitive prefix — only when unambiguous Ambiguous fuzzy matches resolve to None (callers report "no project named X") rather than silently picking one. This single resolver already backed `heph list --project` (via project_scope); route the CLI's task/edit/promote/parent path through it too with a new `project.resolve` RPC + `Store::resolve_project`, so every `--project` surface behaves the same. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
482 lines
14 KiB
Rust
482 lines
14 KiB
Rust
//! JSON-RPC request/response types and the synchronous method dispatcher.
|
|
//!
|
|
//! The daemon speaks **line-delimited JSON-RPC** over a unix socket (tech-spec
|
|
//! §6, §10): one JSON object per line. [`dispatch`] is the pure, synchronous
|
|
//! heart — it maps a method name + params onto a [`heph_core::Store`] call and
|
|
//! is what the async transport runs on the blocking pool. The daemon is
|
|
//! **mode-agnostic**: Tactical/Strategic/Organizational are plugin-side
|
|
//! compositions of these primitives, not daemon concepts.
|
|
|
|
use std::path::Path;
|
|
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
|
|
use heph_core::{
|
|
Attention, LinkType, ListFilter, NewNode, NewTask, Node, NodeKind, SchedulePatch, Store, Task,
|
|
TaskState,
|
|
};
|
|
|
|
/// A JSON-RPC request line.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Request {
|
|
/// Correlation id, echoed in the response (any JSON value).
|
|
#[serde(default)]
|
|
pub id: Value,
|
|
/// The method name (e.g. `task.create`).
|
|
pub method: String,
|
|
/// Method parameters (an object); defaults to null when omitted.
|
|
#[serde(default)]
|
|
pub params: Value,
|
|
}
|
|
|
|
/// A JSON-RPC response line — exactly one of `result`/`error` is present.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Response {
|
|
/// The request id this answers.
|
|
pub id: Value,
|
|
/// The successful result.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub result: Option<Value>,
|
|
/// The error, if the call failed.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<RpcError>,
|
|
}
|
|
|
|
impl Response {
|
|
/// A success response.
|
|
pub fn ok(id: Value, result: Value) -> Response {
|
|
Response {
|
|
id,
|
|
result: Some(result),
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
/// An error response.
|
|
pub fn failed(id: Value, error: RpcError) -> Response {
|
|
Response {
|
|
id,
|
|
result: None,
|
|
error: Some(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A JSON-RPC error object.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RpcError {
|
|
/// Machine-readable code (JSON-RPC conventions where applicable).
|
|
pub code: i64,
|
|
/// Human-readable message.
|
|
pub message: String,
|
|
}
|
|
|
|
// Standard JSON-RPC codes plus a couple of app codes.
|
|
/// The request line was not valid JSON.
|
|
pub const PARSE_ERROR: i64 = -32700;
|
|
/// Params failed to deserialize for the method.
|
|
pub const INVALID_PARAMS: i64 = -32602;
|
|
/// No such method.
|
|
pub const METHOD_NOT_FOUND: i64 = -32601;
|
|
/// A store/internal failure.
|
|
pub const INTERNAL_ERROR: i64 = -32603;
|
|
/// A referenced node was not found.
|
|
pub const NOT_FOUND: i64 = -32004;
|
|
|
|
impl RpcError {
|
|
fn new(code: i64, message: impl Into<String>) -> RpcError {
|
|
RpcError {
|
|
code,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<heph_core::Error> for RpcError {
|
|
fn from(e: heph_core::Error) -> RpcError {
|
|
match e {
|
|
heph_core::Error::NodeNotFound(_) => RpcError::new(NOT_FOUND, e.to_string()),
|
|
other => RpcError::new(INTERNAL_ERROR, other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse<T: DeserializeOwned>(params: Value) -> Result<T, RpcError> {
|
|
serde_json::from_value(params).map_err(|e| RpcError::new(INVALID_PARAMS, e.to_string()))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct IdParam {
|
|
id: String,
|
|
}
|
|
|
|
/// `list` params: a [`ListFilter`] plus an optional `project` NAME the daemon
|
|
/// resolves (subtree-expanded) into the filter's `scope`.
|
|
#[derive(Deserialize)]
|
|
struct ListParams {
|
|
#[serde(flatten)]
|
|
filter: ListFilter,
|
|
#[serde(default)]
|
|
project: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct GetNodeParams {
|
|
id: String,
|
|
/// Prepend the editable YAML frontmatter projection to the body (§8.3).
|
|
#[serde(default)]
|
|
frontmatter: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ResolveParams {
|
|
title: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct NodeListParams {
|
|
#[serde(default)]
|
|
kind: Option<NodeKind>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct UpdateParams {
|
|
id: String,
|
|
#[serde(default)]
|
|
title: Option<String>,
|
|
#[serde(default)]
|
|
body: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetStateParams {
|
|
id: String,
|
|
state: TaskState,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetAttentionParams {
|
|
id: String,
|
|
attention: Attention,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TagParams {
|
|
node_id: String,
|
|
/// The tag name. Unused by `tag.list` (which sends only `node_id`).
|
|
#[serde(default)]
|
|
tag: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetProjectParams {
|
|
id: String,
|
|
/// Target project node id; `null`/absent unfiles the task.
|
|
#[serde(default)]
|
|
project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PromoteParams {
|
|
container_id: String,
|
|
item_ref: usize,
|
|
#[serde(default)]
|
|
attention: Option<Attention>,
|
|
#[serde(default)]
|
|
project: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct NextParams {
|
|
#[serde(default)]
|
|
scope: Option<String>,
|
|
#[serde(default)]
|
|
limit: Option<usize>,
|
|
}
|
|
|
|
/// `list` takes a [`ListFilter`] directly as its params (tech-spec §8.2); an
|
|
/// empty object is the whole outstanding set.
|
|
|
|
#[derive(Deserialize)]
|
|
struct ViewParams {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct JournalParams {
|
|
date: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SearchParams {
|
|
query: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LinkParams {
|
|
id: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AddLinkParams {
|
|
src: String,
|
|
dst: String,
|
|
link_type: LinkType,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LogAppendParams {
|
|
task_id: String,
|
|
text: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LogTailParams {
|
|
task_id: String,
|
|
#[serde(default)]
|
|
n: Option<usize>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ExportParams {
|
|
path: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ConflictResolveParams {
|
|
id: String,
|
|
/// `"local"` or `"remote"` — the value the user chooses to keep.
|
|
choice: String,
|
|
}
|
|
|
|
/// Default `next`/`list` result size (tech-spec §6).
|
|
const DEFAULT_LIMIT: usize = 5;
|
|
/// Default `log.tail` size.
|
|
const DEFAULT_TAIL: usize = 10;
|
|
|
|
/// The task whose frontmatter `node` carries: the node itself if it's a task,
|
|
/// else the task whose canonical-context doc this node is (§8.3). `None` for a
|
|
/// standalone doc/journal.
|
|
fn subject_task(store: &dyn Store, node: &Node) -> Result<Option<Task>, RpcError> {
|
|
if node.kind == NodeKind::Task {
|
|
return Ok(store.get_task(&node.id)?);
|
|
}
|
|
for link in store.backlinks(&node.id)? {
|
|
if link.link_type == LinkType::CanonicalContext {
|
|
return Ok(store.get_task(&link.src_id)?);
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Expand bare `[[id]]` links in `body` to `[[id|Current Name]]` for display
|
|
/// (§8.4), looking each id up via the store (best-effort: an unknown/tombstoned
|
|
/// id or a legacy `[[Name]]` link is left untouched).
|
|
fn expand_wikilinks(store: &dyn Store, body: &str) -> String {
|
|
heph_core::wikilink::expand(body, |id| {
|
|
store
|
|
.get_node(id)
|
|
.ok()
|
|
.flatten()
|
|
.filter(|n| !n.tombstoned)
|
|
.map(|n| n.title)
|
|
})
|
|
}
|
|
|
|
/// The name of the project a task is filed under (its `in-project` link), if any.
|
|
fn project_name_of(store: &dyn Store, task_id: &str) -> Result<Option<String>, RpcError> {
|
|
for link in store.outgoing_links(task_id)? {
|
|
if link.link_type == LinkType::InProject {
|
|
return Ok(store.get_node(&link.dst_id)?.map(|n| n.title));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Dispatch one method call against `store`. Synchronous — the transport runs
|
|
/// this on a blocking pool.
|
|
pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Value, RpcError> {
|
|
Ok(match method {
|
|
"node.get" => {
|
|
let p: GetNodeParams = parse(params)?;
|
|
match store.get_node(&p.id)? {
|
|
None => Value::Null,
|
|
Some(mut node) => {
|
|
// Expand `[[id]]` → `[[id|Current Name]]` for readability
|
|
// (§8.4); the body collapses back to bare ids on write.
|
|
if let Some(b) = node.body.take() {
|
|
node.body = Some(expand_wikilinks(store, &b));
|
|
}
|
|
if p.frontmatter {
|
|
// Prepend the editable frontmatter projection (§8.3) from
|
|
// the node's own fields + its (owning) task.
|
|
let task = subject_task(store, &node)?;
|
|
let tags = store.tags_of(&node.id)?;
|
|
let project = match &task {
|
|
Some(t) => project_name_of(store, &t.node_id)?,
|
|
None => None,
|
|
};
|
|
let fm = crate::frontmatter::render(
|
|
&node,
|
|
task.as_ref(),
|
|
project.as_deref(),
|
|
&tags,
|
|
);
|
|
node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or("")));
|
|
}
|
|
json!(node)
|
|
}
|
|
}
|
|
}
|
|
"node.create" => {
|
|
let p: NewNode = parse(params)?;
|
|
json!(store.create_node(p)?)
|
|
}
|
|
"node.linkable" => json!(store.list_linkable_nodes()?),
|
|
"node.update" => {
|
|
let p: UpdateParams = parse(params)?;
|
|
json!(store.update_node(&p.id, p.title, p.body)?)
|
|
}
|
|
"node.tombstone" => {
|
|
let p: IdParam = parse(params)?;
|
|
store.tombstone_node(&p.id)?;
|
|
json!({ "ok": true })
|
|
}
|
|
"node.resolve" => {
|
|
let p: ResolveParams = parse(params)?;
|
|
json!(store.resolve_node(&p.title)?)
|
|
}
|
|
"node.list" => {
|
|
let p: NodeListParams = parse(params)?;
|
|
json!(store.list_nodes(p.kind)?)
|
|
}
|
|
"task.create" => {
|
|
let p: NewTask = parse(params)?;
|
|
json!(store.create_task(p)?)
|
|
}
|
|
"task.get" => {
|
|
let p: IdParam = parse(params)?;
|
|
json!(store.get_task(&p.id)?)
|
|
}
|
|
"task.set_state" => {
|
|
let p: SetStateParams = parse(params)?;
|
|
json!(store.set_task_state(&p.id, p.state)?)
|
|
}
|
|
"task.set_attention" => {
|
|
let p: SetAttentionParams = parse(params)?;
|
|
json!(store.set_task_attention(&p.id, p.attention)?)
|
|
}
|
|
"task.set_schedule" => {
|
|
// `id` + the flattened `SchedulePatch` arrive in one object; parse
|
|
// the id, then the patch (which ignores the extra `id` key).
|
|
let id: IdParam = parse(params.clone())?;
|
|
let patch: SchedulePatch = parse(params)?;
|
|
json!(store.set_task_schedule(&id.id, patch)?)
|
|
}
|
|
"task.set_project" => {
|
|
let p: SetProjectParams = parse(params)?;
|
|
json!(store.set_task_project(&p.id, p.project_id.as_deref())?)
|
|
}
|
|
"project.delete" => {
|
|
let p: IdParam = parse(params)?;
|
|
store.delete_project(&p.id)?;
|
|
Value::Null
|
|
}
|
|
"task.skip" => {
|
|
let p: IdParam = parse(params)?;
|
|
json!(store.skip_recurrence(&p.id)?)
|
|
}
|
|
"task.promote" => {
|
|
let p: PromoteParams = parse(params)?;
|
|
json!(store.promote(&p.container_id, p.item_ref, p.attention, p.project)?)
|
|
}
|
|
"next" => {
|
|
let p: NextParams = parse(params)?;
|
|
json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?)
|
|
}
|
|
"list" => {
|
|
let p: ListParams = parse(params)?;
|
|
let mut filter = p.filter;
|
|
// `--project <name>` resolves to its subtree scope server-side.
|
|
if let Some(name) = p.project {
|
|
filter.scope = store.project_scope(&name)?;
|
|
}
|
|
json!(store.list(&filter)?)
|
|
}
|
|
"view" => {
|
|
let p: ViewParams = parse(params)?;
|
|
json!(store.view(&p.name)?)
|
|
}
|
|
"project.scope" => {
|
|
let p: ViewParams = parse(params)?;
|
|
json!(store.project_scope(&p.name)?)
|
|
}
|
|
"project.resolve" => {
|
|
let p: ViewParams = parse(params)?;
|
|
json!(store.resolve_project(&p.name)?)
|
|
}
|
|
"health" => json!(store.health()?),
|
|
"search" => {
|
|
let p: SearchParams = parse(params)?;
|
|
json!(store.search(&p.query)?)
|
|
}
|
|
"journal.open_or_create" => {
|
|
let p: JournalParams = parse(params)?;
|
|
json!(store.journal_open_or_create(&p.date)?)
|
|
}
|
|
"links.add" => {
|
|
let p: AddLinkParams = parse(params)?;
|
|
json!(store.add_link(&p.src, &p.dst, p.link_type)?)
|
|
}
|
|
"links.outgoing" => {
|
|
let p: LinkParams = parse(params)?;
|
|
json!(store.outgoing_links(&p.id)?)
|
|
}
|
|
"links.backlinks" => {
|
|
let p: LinkParams = parse(params)?;
|
|
json!(store.backlinks(&p.id)?)
|
|
}
|
|
"tag.add" => {
|
|
let p: TagParams = parse(params)?;
|
|
json!(store.add_tag(&p.node_id, &p.tag)?)
|
|
}
|
|
"tag.remove" => {
|
|
let p: TagParams = parse(params)?;
|
|
store.remove_tag(&p.node_id, &p.tag)?;
|
|
json!({ "ok": true })
|
|
}
|
|
"tag.list" => {
|
|
let p: TagParams = parse(params)?;
|
|
json!(store.tags_of(&p.node_id)?)
|
|
}
|
|
"migrate.wikilinks" => json!(store.migrate_wikilinks_to_ids()?),
|
|
"log.append" => {
|
|
let p: LogAppendParams = parse(params)?;
|
|
store.log_append(&p.task_id, &p.text)?;
|
|
json!({ "ok": true })
|
|
}
|
|
"log.tail" => {
|
|
let p: LogTailParams = parse(params)?;
|
|
json!(store.log_tail(&p.task_id, p.n.unwrap_or(DEFAULT_TAIL))?)
|
|
}
|
|
"export" => {
|
|
let p: ExportParams = parse(params)?;
|
|
let count = store.export(Path::new(&p.path))?;
|
|
json!({ "count": count })
|
|
}
|
|
"conflicts.list" => json!(store.conflicts_list()?),
|
|
"conflicts.resolve" => {
|
|
let p: ConflictResolveParams = parse(params)?;
|
|
store.conflicts_resolve(&p.id, &p.choice)?;
|
|
json!({ "ok": true })
|
|
}
|
|
other => {
|
|
return Err(RpcError::new(
|
|
METHOD_NOT_FOUND,
|
|
format!("unknown method: {other}"),
|
|
))
|
|
}
|
|
})
|
|
}
|