A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add task export and accept across namespaces

Tasks can now be sent to another namespace's inbox in the same git repo
and accepted there as new local tasks.

Storage: each pending item lives at refs/tsk/<dest-ns>/inbox/<src-ns>-<src-id>
as a single blob with the source coordinates, attrs, title, and body.
Stable inbox key means re-exporting overwrites the same slot.

Workflow:
- tsk export <target-ns> [-T <id>] send a task; sets `assigned=[[<target-ns>/tsk-N]]` on the source
- tsk inbox list pending items in the current namespace
- tsk accept [<key>] create a local task from inbox; copies title/body/attrs and sets `source=[[<src-ns>/tsk-N]]`. Removes the inbox blob.

Renamed the old zip-export command to `tsk bundle` so `tsk export` is
free for the cross-namespace sense the user describes.

Logs: source gets an `exported` entry pointing at the assignee, accepted
copy gets `accepted` pointing at the source. Inbox blobs are part of
all_keys so they roll into bundle/migrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+328 -6
+74
src/backend.rs
··· 438 438 .collect()) 439 439 } 440 440 441 + /// Serialized representation of a task pending in another namespace's inbox. 442 + /// 443 + /// On-blob format (inbox/<inbox-id>): 444 + /// ```text 445 + /// source\t<src-namespace>\t<src-id> 446 + /// attr\t<key>\t<value> 447 + /// attr\t<key>\t<value> 448 + /// --- 449 + /// <title> 450 + /// 451 + /// <body> 452 + /// ``` 453 + pub struct InboxPayload { 454 + pub source_namespace: String, 455 + pub source_id: u32, 456 + pub title: String, 457 + pub body: String, 458 + pub attrs: BTreeMap<String, String>, 459 + } 460 + 461 + impl InboxPayload { 462 + pub fn serialize(&self) -> String { 463 + let mut s = format!("source\t{}\t{}\n", self.source_namespace, self.source_id); 464 + for (k, v) in &self.attrs { 465 + s.push_str(&format!("attr\t{k}\t{v}\n")); 466 + } 467 + s.push_str("---\n"); 468 + s.push_str(&format!("{}\n\n{}", self.title.trim(), self.body.trim())); 469 + s 470 + } 471 + 472 + pub fn parse(text: &str) -> Result<Self> { 473 + let mut lines = text.lines(); 474 + let mut source_namespace = String::new(); 475 + let mut source_id: u32 = 0; 476 + let mut attrs = BTreeMap::new(); 477 + for line in &mut lines { 478 + if line == "---" { 479 + break; 480 + } 481 + let parts: Vec<&str> = line.splitn(3, '\t').collect(); 482 + match parts.as_slice() { 483 + ["source", ns, id] => { 484 + source_namespace = ns.to_string(); 485 + source_id = id 486 + .parse() 487 + .map_err(|_| Error::Parse(format!("invalid inbox source id: {id}")))?; 488 + } 489 + ["attr", k, v] => { 490 + attrs.insert(k.to_string(), v.to_string()); 491 + } 492 + _ => {} 493 + } 494 + } 495 + let rest: String = lines.collect::<Vec<_>>().join("\n"); 496 + let mut split = rest.splitn(2, "\n\n"); 497 + let title = split.next().unwrap_or("").trim().to_string(); 498 + let body = split.next().unwrap_or("").trim().to_string(); 499 + Ok(Self { 500 + source_namespace, 501 + source_id, 502 + title, 503 + body, 504 + attrs, 505 + }) 506 + } 507 + } 508 + 509 + /// Stable inbox key for a (namespace, source-id) pair so re-exports overwrite 510 + /// the same slot rather than piling up. 511 + pub fn inbox_key(src_namespace: &str, src_id: u32) -> String { 512 + format!("inbox/{src_namespace}-{src_id}") 513 + } 514 + 441 515 /// Read every per-task log in the workspace and merge into a single feed 442 516 /// sorted by timestamp ascending. 443 517 pub fn read_all_logs(store: &dyn Store) -> Result<Vec<LogEntry>> {
+71 -5
src/main.rs
··· 207 207 remote: String, 208 208 }, 209 209 210 - /// Export the entire workspace (tasks, archive, attrs, backlinks, index, 211 - /// next, remotes) into a zip archive. Works for both file-backed and 212 - /// git-backed workspaces. 210 + /// Send a task to another namespace's inbox. Defaults to the top-of-stack 211 + /// task; use -T to pick a different one. Sets `assigned=[[<ns>/tsk-N]]` 212 + /// on the source. 213 213 Export { 214 + /// Target namespace. 215 + target: String, 216 + #[command(flatten)] 217 + task_id: TaskId, 218 + }, 219 + 220 + /// List tasks pending in the current namespace's inbox. 221 + Inbox, 222 + 223 + /// Accept a pending inbox item, creating a new local task with copied 224 + /// content + properties and `source=[[<src-ns>/tsk-N]]` set. 225 + Accept { 226 + /// Inbox key (e.g. `alice-3` or `inbox/alice-3`). With no argument, 227 + /// accepts the first item in the inbox. 228 + key: Option<String>, 229 + }, 230 + 231 + /// Bundle the entire workspace into a zip archive. 232 + Bundle { 214 233 /// Output path. Defaults to ./tsk.zip. 215 234 #[arg(short = 'o')] 216 235 output: Option<PathBuf>, ··· 427 446 Commands::GitSetup { gitignore, remote } => command_git_setup(dir, gitignore, remote), 428 447 Commands::GitPush { remote } => command_git_push(dir, remote), 429 448 Commands::GitPull { remote } => command_git_pull(dir, remote), 430 - Commands::Export { output } => command_export(dir, output), 449 + Commands::Export { target, task_id } => command_export_to_ns(dir, target, task_id), 450 + Commands::Inbox => command_inbox(dir), 451 + Commands::Accept { key } => command_accept(dir, key), 452 + Commands::Bundle { output } => command_bundle(dir, output), 431 453 Commands::Migrate => command_migrate(dir), 432 454 Commands::Reopen { task_id } => command_reopen(dir, task_id), 433 455 Commands::Log { tsk_id } => command_log(dir, tsk_id), ··· 753 775 Ok(()) 754 776 } 755 777 756 - fn command_export(dir: PathBuf, output: Option<PathBuf>) -> Result<()> { 778 + fn command_bundle(dir: PathBuf, output: Option<PathBuf>) -> Result<()> { 757 779 let workspace = Workspace::from_path(dir)?; 758 780 let dest = output.unwrap_or_else(|| PathBuf::from("tsk.zip")); 759 781 workspace.export_zip(&dest)?; 760 782 eprintln!("Wrote {}", dest.display()); 783 + Ok(()) 784 + } 785 + 786 + fn command_export_to_ns(dir: PathBuf, target: String, task_id: TaskId) -> Result<()> { 787 + let ws = Workspace::from_path(dir)?; 788 + let id = ws.task(task_id.into())?.id; 789 + let key = ws.export_to_namespace(&target, id)?; 790 + eprintln!("Sent {id} to namespace '{target}' (inbox key: {key})"); 791 + Ok(()) 792 + } 793 + 794 + fn command_inbox(dir: PathBuf) -> Result<()> { 795 + let ws = Workspace::from_path(dir)?; 796 + let items = ws.list_inbox()?; 797 + if items.is_empty() { 798 + println!("Inbox is empty."); 799 + return Ok(()); 800 + } 801 + for item in items { 802 + println!( 803 + "{}\t{}/tsk-{}\t{}", 804 + item.inbox_key.trim_start_matches("inbox/"), 805 + item.source_namespace, 806 + item.source_id, 807 + item.title 808 + ); 809 + } 810 + Ok(()) 811 + } 812 + 813 + fn command_accept(dir: PathBuf, key: Option<String>) -> Result<()> { 814 + let ws = Workspace::from_path(dir)?; 815 + let key = match key { 816 + Some(k) => k, 817 + None => { 818 + ws.list_inbox()? 819 + .into_iter() 820 + .next() 821 + .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))? 822 + .inbox_key 823 + } 824 + }; 825 + let id = ws.accept_inbox(&key)?; 826 + eprintln!("Accepted as {id}"); 761 827 Ok(()) 762 828 } 763 829
+183 -1
src/workspace.rs
··· 81 81 Ok(()) 82 82 } 83 83 84 + /// Summary of one item in a namespace inbox. 85 + pub struct InboxItem { 86 + pub inbox_key: String, 87 + pub source_namespace: String, 88 + pub source_id: u32, 89 + pub title: String, 90 + } 91 + 84 92 pub struct Workspace { 85 93 /// The path to the .tsk marker directory. 86 94 pub path: PathBuf, ··· 662 670 /// Every logical blob key that currently exists in the workspace. 663 671 fn all_keys(&self) -> Result<Vec<String>> { 664 672 let mut keys: Vec<String> = Vec::new(); 665 - for prefix in ["tasks", "archive", "attrs", "backlinks", "log"] { 673 + for prefix in ["tasks", "archive", "attrs", "backlinks", "log", "inbox"] { 666 674 keys.extend(self.store().list(prefix)?); 667 675 } 668 676 for top in ["index", "next", "remotes"] { ··· 697 705 698 706 /// Migrate a file-backed workspace to a git-backed one. Returns Err if the 699 707 /// workspace is already git-backed or if no enclosing git repo is found. 708 + /// Send a task to another namespace's inbox in the same git repo. Sets 709 + /// `assigned=[[<target_ns>/tsk-<id>]]` on the source after a successful 710 + /// write so it can be tracked. Returns the inbox key used in the target. 711 + pub fn export_to_namespace(&self, target_ns: &str, src_id: Id) -> Result<String> { 712 + if !self.is_git_backed() { 713 + return Err(Error::Parse( 714 + "Cross-namespace export only works on git-backed workspaces".into(), 715 + )); 716 + } 717 + validate_namespace(target_ns)?; 718 + let cur = self.namespace(); 719 + if target_ns == cur { 720 + return Err(Error::Parse( 721 + "Refusing to export a task to its own namespace".into(), 722 + )); 723 + } 724 + let task = self.task(TaskIdentifier::Id(src_id))?; 725 + let attrs = backend::read_attrs(self.store(), src_id)?; 726 + let payload = backend::InboxPayload { 727 + source_namespace: cur, 728 + source_id: src_id.0, 729 + title: task.title.clone(), 730 + body: task.body.clone(), 731 + attrs, 732 + }; 733 + let marker = std::fs::read_to_string(self.path.join(backend::GIT_BACKED_MARKER))?; 734 + let target = 735 + backend::GitStore::open_namespace(PathBuf::from(marker.trim()), target_ns.to_string())?; 736 + let key = backend::inbox_key(&payload.source_namespace, payload.source_id); 737 + <dyn Store>::write(&target, &key, payload.serialize().as_bytes())?; 738 + 739 + // Mark the source with where it was sent. 740 + let assigned_link = format!("[[{target_ns}/tsk-{}]]", src_id.0); 741 + let mut my_attrs = backend::read_attrs(self.store(), src_id)?; 742 + my_attrs.insert("assigned".into(), assigned_link.clone()); 743 + backend::write_attrs(self.store(), src_id, &my_attrs)?; 744 + self.log(src_id, "exported", Some(&assigned_link))?; 745 + Ok(key) 746 + } 747 + 748 + /// Item pending in the current namespace's inbox. 749 + pub fn list_inbox(&self) -> Result<Vec<InboxItem>> { 750 + let mut out = Vec::new(); 751 + for key in self.store().list("inbox")? { 752 + if let Some(data) = self.store().read(&key)? { 753 + let payload = backend::InboxPayload::parse(&String::from_utf8_lossy(&data))?; 754 + out.push(InboxItem { 755 + inbox_key: key, 756 + source_namespace: payload.source_namespace, 757 + source_id: payload.source_id, 758 + title: payload.title, 759 + }); 760 + } 761 + } 762 + out.sort_by(|a, b| a.inbox_key.cmp(&b.inbox_key)); 763 + Ok(out) 764 + } 765 + 766 + /// Accept a pending inbox item: create a new local task with copied 767 + /// title/body/attrs, set `source=[[<src-ns>/tsk-<src-id>]]`, push it on 768 + /// the stack, and remove the inbox blob. 769 + pub fn accept_inbox(&self, inbox_key: &str) -> Result<Id> { 770 + let key = if inbox_key.starts_with("inbox/") { 771 + inbox_key.to_string() 772 + } else { 773 + format!("inbox/{inbox_key}") 774 + }; 775 + let data = self 776 + .store() 777 + .read(&key)? 778 + .ok_or_else(|| Error::Parse(format!("Inbox item '{inbox_key}' not found")))?; 779 + let payload = backend::InboxPayload::parse(&String::from_utf8_lossy(&data))?; 780 + 781 + let task = self.new_task(payload.title.clone(), payload.body.clone())?; 782 + let new_id = task.id; 783 + self.push_task(task)?; 784 + 785 + let mut attrs = payload.attrs; 786 + attrs.insert( 787 + "source".into(), 788 + format!("[[{}/tsk-{}]]", payload.source_namespace, payload.source_id), 789 + ); 790 + // Drop any "assigned" carried over — it was set by the source workspace 791 + // before export; the new local copy isn't itself assigned anywhere. 792 + attrs.remove("assigned"); 793 + backend::write_attrs(self.store(), new_id, &attrs)?; 794 + self.store().delete(&key)?; 795 + self.log( 796 + new_id, 797 + "accepted", 798 + Some(&format!( 799 + "[[{}/tsk-{}]]", 800 + payload.source_namespace, payload.source_id 801 + )), 802 + )?; 803 + Ok(new_id) 804 + } 805 + 700 806 pub fn migrate_to_git(&self) -> Result<PathBuf> { 701 807 if self.is_git_backed() { 702 808 return Err(Error::Parse("Workspace is already git-backed".into())); ··· 1540 1646 // Unset of non-existent is fine. 1541 1647 ws.unset_property(id1, "nope").unwrap(); 1542 1648 } 1649 + } 1650 + 1651 + #[test] 1652 + fn test_export_and_accept_across_namespaces() { 1653 + let dir = tempfile::tempdir().unwrap(); 1654 + let root = dir.path().to_path_buf(); 1655 + run_git_init(&root); 1656 + Workspace::init(root.clone()).unwrap(); 1657 + let ws = Workspace::from_path(root.clone()).unwrap(); 1658 + 1659 + // Source task in default namespace. 1660 + let t = ws 1661 + .new_task("send me".into(), "see [[tsk-1]]".into()) 1662 + .unwrap(); 1663 + let src_id = t.id; 1664 + ws.push_task(t).unwrap(); 1665 + ws.set_property(src_id, "priority", "high").unwrap(); 1666 + 1667 + // Export to alice. 1668 + let key = ws.export_to_namespace("alice", src_id).unwrap(); 1669 + // Source got the assigned property. 1670 + let src_attrs = ws.properties(src_id).unwrap(); 1671 + assert_eq!( 1672 + src_attrs.get("assigned").map(String::as_str), 1673 + Some("[[alice/tsk-1]]") 1674 + ); 1675 + 1676 + // Switch to alice and inspect the inbox. 1677 + ws.switch_namespace("alice").unwrap(); 1678 + let alice = Workspace::from_path(root.clone()).unwrap(); 1679 + let items = alice.list_inbox().unwrap(); 1680 + assert_eq!(items.len(), 1); 1681 + assert_eq!(items[0].source_namespace, "default"); 1682 + assert_eq!(items[0].source_id, src_id.0); 1683 + assert_eq!(items[0].title, "send me"); 1684 + 1685 + // Accept it. 1686 + let new_id = alice.accept_inbox(&key).unwrap(); 1687 + let accepted = alice.task(TaskIdentifier::Id(new_id)).unwrap(); 1688 + assert_eq!(accepted.title, "send me"); 1689 + let accepted_props = alice.properties(new_id).unwrap(); 1690 + assert_eq!( 1691 + accepted_props.get("source").map(String::as_str), 1692 + Some(&format!("[[default/tsk-{}]]", src_id.0)[..]) 1693 + ); 1694 + // priority property carried over. 1695 + assert_eq!( 1696 + accepted_props.get("priority").map(String::as_str), 1697 + Some("high") 1698 + ); 1699 + // 'assigned' should NOT be inherited on the new copy. 1700 + assert!(!accepted_props.contains_key("assigned")); 1701 + // Inbox cleared. 1702 + assert!(alice.list_inbox().unwrap().is_empty()); 1703 + 1704 + // Cannot export to your own namespace. 1705 + let t2 = alice.new_task("local".into(), "".into()).unwrap(); 1706 + let local_id = t2.id; 1707 + alice.push_task(t2).unwrap(); 1708 + assert!(alice.export_to_namespace("alice", local_id).is_err()); 1709 + 1710 + // Logs include the cross-namespace events. 1711 + let src_log_events: Vec<String> = ws 1712 + .read_log(src_id) 1713 + .unwrap() 1714 + .iter() 1715 + .map(|e| e.event.clone()) 1716 + .collect(); 1717 + assert!(src_log_events.contains(&"exported".to_string())); 1718 + let dst_log_events: Vec<String> = alice 1719 + .read_log(new_id) 1720 + .unwrap() 1721 + .iter() 1722 + .map(|e| e.event.clone()) 1723 + .collect(); 1724 + assert!(dst_log_events.contains(&"accepted".to_string())); 1543 1725 } 1544 1726 1545 1727 #[test]