6 Commits

Author SHA1 Message Date
2c48c75387 fix: use FindDataRoot in FindOrInitSQLiteStore for consistent database location
Some checks failed
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Has been cancelled
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Has been cancelled
Build and Push Docker Container / build-and-push (push) Successful in 17m44s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Failing after 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Failing after 1m0s
2026-04-02 14:26:13 +02:00
e04a44cdcf remove line count from readme 2026-04-02 13:41:47 +02:00
b6c8a158af docs: simplify README and extract full reference to USAGE.md
- Condensed README to quick overview (~40 lines) with key features
- Added install instructions for Alpine, Arch, source, and Docker
- Created USAGE.md with full command reference, server mode, OIDC, and permission model docs
- New features documented: server mode, OIDC authentication, per-node permissions, multiplatform auto-builds

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:38:38 +02:00
5f0f8f3396 fix: update Dockerfile Go version to 1.25 and disable toolchain auto-download
All checks were successful
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m4s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m6s
Build and Push Docker Container / build-and-push (push) Successful in 17m50s
- Bump golang:1.24-alpine to golang:1.25-alpine to match go.mod requirement
- Set GOTOOLCHAIN=local to prevent segfault from toolchain download in Docker

This fixes the multiplatform build failure where go mod download would
attempt to download Go 1.25.0 in the container and crash.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:25:19 +02:00
24fb3a8b62 fix: prevent segfault in login command when session doesn't exist
LoadSession() was returning nil when the session file didn't exist (first login).
The login command then tried to dereference nil when setting session.Token,
causing a panic. Now LoadSession() returns an empty Session with the path set,
so callers can always use the returned session.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:23:34 +02:00
89432e608b feat: replace in_namespace relation with ownership-based namespace membership
Remove the in_namespace edge relation. A node now belongs to a namespace if that
namespace has has_ownership on it. This simplifies the model: namespace membership
is determined by the ownership chain rather than a separate relation type.

Changes:
- Remove RelInNamespace constant
- Add Namespace fields to AddInput, UpdateInput, and ListFilter
- Update Add() to resolve namespace from input and assign it as owner
- Update List() to filter by namespace ownership instead of in_namespace edges
- Update() can now transfer nodes between namespaces via ownership transfer
- Remove in_namespace self-references from ensureNamespace/ensureGlobalNamespace

The ownership chain now fully describes both permissions and namespace membership,
reducing redundancy. All tests pass with the new model.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:20:03 +02:00
17 changed files with 329 additions and 374 deletions

View File

@@ -1,8 +1,8 @@
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
ENV GOTOOLCHAIN=auto ENV GOTOOLCHAIN=local
COPY src/go.mod src/go.sum ./ COPY src/go.mod src/go.sum ./
RUN go mod download RUN go mod download

280
README.md
View File

@@ -1,266 +1,52 @@
# Axolotl # Axolotl
CLI-native lightweight issue tracker for you and your agents. A SQLite-based CLI-native issue tracker for you and your agents. Single binary, SQLite-backed, a few lines of Go.
single portable binary, built from ~1300 lines of Go code.
## Features ## Install
- **Issues with dependencies** - blocks, subtask, related relations **Alpine Linux** (apk):
- **Tagging system** - flexible tags with `_key::value` property pattern Download from the Gitea package registry. Have a look
- **Namespacing** - organize issues by project or team [here](https://g.eliaskohout.de/eliaskohout/-/packages/alpine/axolotl/).
- **Due dates** - track deadlines
- **Thread-safe** - WAL mode for concurrent access
- **Multiuser support** - @mentions and assignments, inbox per user
- **JSON output** - all commands support `--json` for agent integration
- **Alias system** - define custom command shortcuts with argument expansion
- **Single binary** - no dependencies, portable `.ax.db` file
## Installation **Arch Linux** (pacman):
Download from the Gitea package registry. Have a look
[here](https://g.eliaskohout.de/eliaskohout/-/packages/arch/axolotl/).
**From source:**
```bash ```bash
go build -o ax . go build -o ax ./src
``` ```
**Docker (server mode):**
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
Packages are built automatically on every version tag for `linux/amd64` and `linux/arm64`.
## Quick Start ## Quick Start
```bash ```bash
# Initialize a new database ax init . # create .ax.db in current dir
ax init . ax add "Fix login bug" --prio high # create an issue
ax list --status open # list open issues
# Create an issue ax show abc12 # show issue details
ax add "Implement feature X" --tag backend --prio high ax update abc12 --status done # close issue
ax inbox # your @mention inbox
# Create with relations
ax add "Fix bug in auth" --rel blocks:abc12
# List open issues
ax list --status open
# Show issue details
ax show abc12
# Update an issue
ax update abc12 --status done
# View your inbox
ax inbox
# Define an alias
ax alias mywork "list --namespace myproject --status open" --desc "My project tasks"
``` ```
## Commands ## Key Features
### `ax init [path]` - **Graph relations** — `blocks`, `subtask`, `related`, `assignee`
- **Namespaces** — organize issues by project or team
- **Permissions** — per-node access control (`can_read`, `can_write`, `has_ownership`)
- **Aliases** — custom shortcuts with `$me`, `$1`, `$@` expansion
- **JSON output** — `--json` flag on all commands for agent integration
- **Multiuser** — `@mention` auto-creates inbox entries; `AX_USER` to switch users
- **Server mode** — HTTP JSON API with optional OIDC authentication (`ax serve` / `ax login`)
- **Portable** — single `.ax.db` file, no server required
Create a new `.ax.db` database in the specified directory (default: current). For full command reference and examples, see [USAGE.md](USAGE.md).
### `ax add <title> [flags]`
Create a new node.
| Flag | Description |
|------|-------------|
| `--type` | Node type: `issue` (default), `note`, `user`, `namespace` |
| `--status` | Status: `open` (default), `done` |
| `--prio` | Priority: `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Content/body text |
| `--rel` | Add relation `type:id` (repeatable) |
### `ax update <id> [flags]`
Update a node.
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | New namespace |
| `--assignee` | New assignee |
| `--due` | New due date |
| `--clear-due` | Clear due date |
| `--content` | New content |
| `--tag` | Add tag (repeatable) |
| `--tag-remove` | Remove tag (repeatable) |
| `--rel` | Add relation `type:id` (repeatable) |
| `--rel-remove` | Remove relation `type:id` (repeatable) |
### `ax show <id>`
Display node details.
### `ax list [flags]`
Query and list nodes.
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f|--force]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command] [flags]`
Manage aliases.
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create alias
ax alias mywork # show alias command
ax alias mywork "list --tag work2" # update alias
ax alias del mywork # delete alias
```
**Default aliases:**
| Alias | Command | Description |
|-------|---------|-------------|
| `mine` | `list --assignee $me --type issue --status open` | Show open issues assigned to you |
| `due` | `list --type issue --status open` | Show open issues |
| `inbox` | `list --mention $me` | Show your inbox |
**Alias argument expansion:**
| Variable | Expands to |
|----------|------------|
| `$me` | Current username |
| `$@` | All arguments |
| `$1`, `$2`, ... | Positional arguments |
```bash
# Create alias with argument expansion
ax alias find "list --tag $1 --status $2"
ax find backend open # expands to: list --tag backend --status open
```
## Relations
Relations connect nodes together:
| Type | Meaning | Behavior |
|------|---------|----------|
| `blocks` | A blocks B — B can't close until A is done | Enforced on status=done |
| `subtask` | A is a subtask of B | |
| `related` | A is related to B | |
| `assignee` | A is assigned to user | Single-value; set via `--assignee` flag |
| `in_namespace` | A belongs to namespace | Single-value; set via `--namespace` flag |
```bash
# Block an issue (B can't close until A is done)
ax update A --rel blocks:B
# Assign to user
ax update abc12 --assignee alice
# Create subtask
ax update abc12 --rel subtask:parent12
```
## Tags and Properties
Tags are flexible labels. Tags with pattern `_key::value` are properties:
```bash
# Regular tag
ax add "Task" --tag backend
# Property tags (set via flags)
ax add "Task" --type issue --status open --prio high
# Equivalent to: --tag _type::issue --tag _status::open --tag _prio::high
```
**Built-in properties:**
| Property | Values | Required |
|----------|--------|----------|
| `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) |
| `_status` | `open`, `done` | No |
| `_prio` | `high`, `medium`, `low` | No |
## Mentions and Inbox
Use `@username` in title or content to automatically add to user's inbox:
```bash
ax add "Review PR @alice" --content "@bob please check"
# Both alice and bob get this in their inbox
```
View inbox:
```bash
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json` for machine-readable output:
```bash
ax list --status open --json
ax show abc12 --json
```
Example output:
```json
{
"id": "abc12",
"title": "Implement feature",
"content": "Description here",
"created_at": "2026-03-25T10:00:00Z",
"updated_at": "2026-03-25T10:00:00Z",
"tags": ["_type::issue", "_status::open", "backend"],
"relations": {
"blocks": ["def34"]
}
}
```
## Configuration
`ax` stores user configuration in a JSON file. It searches for `.axconfig` in the
current directory and parent directories (like git finds `.git`), falling back to
`~/.config/ax/config.json`.
**Config file format:**
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Database Location
`ax` searches for `.ax.db` in the current directory and parent directories,
similar to how git finds `.git`. This allows you to run commands from any
subdirectory.
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
## License ## License

194
USAGE.md Normal file
View File

@@ -0,0 +1,194 @@
# Axolotl Usage Reference
## Commands
### `ax init [path]`
Create a new `.ax.db` in the specified directory (default: current).
### `ax add <title> [flags]`
| Flag | Description |
|------|-------------|
| `--type` | `issue` (default), `note`, `user`, `namespace` |
| `--status` | `open` (default), `done` |
| `--prio` | `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Body text |
| `--rel` | Relation `type:id` (repeatable) |
### `ax update <id> [flags]`
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | Transfer to namespace |
| `--assignee` | Assign to user |
| `--due` / `--clear-due` | Set or clear due date |
| `--content` | New body text |
| `--tag` / `--tag-remove` | Add or remove tag |
| `--rel` / `--rel-remove` | Add or remove relation `type:id` |
### `ax show <id>`
Display node details.
### `ax list [flags]`
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command]`
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create/update alias
ax alias del mywork # delete alias
```
**Built-in aliases:** `mine`, `due`, `inbox`
**Argument expansion:** `$me` → current user, `$@` → all args, `$1`/`$2`/… → positional
## Relations
| Type | Description |
|------|-------------|
| `blocks` | Prevents target from closing until this is done |
| `subtask` | Marks as subtask of target |
| `related` | General association |
| `assignee` | Assigns to a user (single-value) |
```bash
ax update A --rel blocks:B # A blocks B
ax update abc12 --assignee alice # assign to alice
```
## Tags and Properties
Tags follow the `_key::value` pattern for properties:
| Property | Values |
|----------|--------|
| `_type` | `issue`, `note`, `user`, `namespace` |
| `_status` | `open`, `done` |
| `_prio` | `high`, `medium`, `low` |
## Mentions and Inbox
Use `@username` in title or content to add to a user's inbox:
```bash
ax add "Review PR @alice" # alice gets an inbox entry
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json`:
```bash
ax list --status open --json
ax show abc12 --json
```
## Configuration
`ax` searches upward from CWD for `.axconfig`, falling back to `~/.config/ax/config.json`.
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Server Mode
`ax` can run as a shared HTTP JSON API server:
```bash
ax serve # starts server on configured host:port (default: 0.0.0.0:7000)
```
The server exposes the same operations (add, list, show, update, delete) over HTTP. Clients connect by setting `remote.host` / `remote.port` in their config — the CLI then transparently routes calls to the server instead of a local database.
### OIDC Authentication
The server supports OIDC for authentication. Configure in `.axconfig`:
```json
{
"serve": { "host": "0.0.0.0", "port": 7000 },
"oidc": {
"issuer": "https://your-idp.example.com",
"client_id": "axolotl",
"client_secret": "secret",
"public_url": "https://ax.example.com",
"user_claim": "preferred_username"
}
}
```
Client login:
```bash
ax login # opens browser for OIDC flow, saves session token
```
Without OIDC configured, the server accepts an `X-Ax-User` header for the username (development/trusted networks only).
### Docker
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
The image runs `ax serve` and exposes port 7000. Mount a volume at `/data` to persist the database.
## Permission Model
Every node has per-node access control. Permissions are transitive via BFS from the requesting user's own node.
| Level | Relation | Grants |
|-------|----------|--------|
| 1 | `can_read` | Read / show / list |
| 2 | `can_create_rel` | Create relations pointing to this node |
| 3 | `can_write` | Update title, content, tags |
| 4 | `has_ownership` | Full control including delete and granting access |
- Creators automatically get `has_ownership` on nodes they create.
- Namespace nodes own regular nodes within them; users own their namespaces.
- Deleting an owner cascades to all nodes it owns.
- User nodes and namespace nodes are globally readable.
```bash
# Grant bob read access to a node
ax update <bob-user-id> --rel can_read:<node-id>
# Grant bob write access
ax update <bob-user-id> --rel can_write:<node-id>
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
## Database Location
`ax` searches for `.ax.db` upward from CWD (like git finds `.git`), so commands work from any subdirectory.

View File

@@ -43,7 +43,7 @@ var addCmd = &cobra.Command{
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""}) input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""})
} }
if cNamespace != "" { if cNamespace != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelInNamespace, Target: cNamespace}) input.Namespace = cNamespace
} }
if cAssignee != "" { if cAssignee != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee}) input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee})

View File

@@ -46,7 +46,7 @@ var listCmd = &cobra.Command{
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""})
} }
if lNamespace != "" { if lNamespace != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: lNamespace}) filter.Namespace = lNamespace
} }
if lAssignee != "" { if lAssignee != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee})

View File

@@ -49,7 +49,7 @@ var (
"low": {" ", "low", cDim}, "low": {" ", "low", cDim},
"": {" ", "n/a", cDim}, "": {" ", "n/a", cDim},
} }
relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007", "in_namespace": "\uf07b"} relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007"}
prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1} prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1}
statusRanks = map[string]int{"open": 2, "": 1, "done": 0} statusRanks = map[string]int{"open": 2, "": 1, "done": 0}
) )
@@ -94,24 +94,12 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
}) })
for _, n := range nodes { for _, n := range nodes {
n_rels := n.Relations fmt.Fprintf(w, " %s %s %s %s %s %s",
ns_rel_node_ids := n_rels[string(models.RelInNamespace)]
ns_rel_node_titles := make([]string, 0, len(ns_rel_node_ids))
for _, id := range ns_rel_node_ids {
ns_rel_node, err := svc.GetByID(id)
if err != nil {
ns_rel_node_titles = append(ns_rel_node_titles, id)
continue
}
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
}
fmt.Fprintf(w, " %s %s %s %s %s %s %s",
cDim.Sprint(n.ID), cDim.Sprint(n.ID),
render(prioRM, n.GetProperty("prio"), true), render(prioRM, n.GetProperty("prio"), true),
render(statusRM, n.GetProperty("status"), true), render(statusRM, n.GetProperty("status"), true),
render(typeRM, n.GetProperty("type"), true), render(typeRM, n.GetProperty("type"), true),
cTitle.Sprint(truncate(n.Title, 80)), cTitle.Sprint(truncate(n.Title, 80)),
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
dueDateShort(n.DueDate), dueDateShort(n.DueDate),
) )
tags := n.GetDisplayTags() tags := n.GetDisplayTags()

View File

@@ -61,7 +61,7 @@ var updateCmd = &cobra.Command{
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""}) input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""})
} }
if cmd.Flags().Changed("namespace") { if cmd.Flags().Changed("namespace") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelInNamespace, Target: uNamespace}) input.Namespace = &uNamespace
} }
if cmd.Flags().Changed("assignee") { if cmd.Flags().Changed("assignee") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee}) input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee})

View File

@@ -33,9 +33,6 @@ func TestCRUD(t *testing.T) {
if len(n.Relations["created"]) == 0 { if len(n.Relations["created"]) == 0 {
t.Error("expected created relation to be set") t.Error("expected created relation to be set")
} }
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation to be set")
}
if n.CreatedAt == "" || n.UpdatedAt == "" { if n.CreatedAt == "" || n.UpdatedAt == "" {
t.Error("expected timestamps to be set") t.Error("expected timestamps to be set")
} }
@@ -73,9 +70,6 @@ func TestCRUD(t *testing.T) {
if n.Content != "some body" { if n.Content != "some body" {
t.Errorf("content: want %q, got %q", "some body", n.Content) t.Errorf("content: want %q, got %q", "some body", n.Content)
} }
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation")
}
if len(n.Relations["assignee"]) == 0 { if len(n.Relations["assignee"]) == 0 {
t.Error("expected assignee relation") t.Error("expected assignee relation")
} }

View File

@@ -168,10 +168,6 @@ func TestNamespaceExplicitCreate(t *testing.T) {
nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json")) nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json"))
if !nsNode.HasRelation("in_namespace", nsNode.ID) {
t.Errorf("expected namespace to have in_namespace pointing to itself, got relations: %v", nsNode.Relations)
}
users := env.parseNodes(env.mustAx("list", "--type", "user", "--json")) users := env.parseNodes(env.mustAx("list", "--type", "user", "--json"))
var userNode *NodeResponse var userNode *NodeResponse
for i := range users { for i := range users {

View File

@@ -89,7 +89,7 @@ func (n *Node) AddRelation(relType RelType, target string) {
if n.Relations == nil { if n.Relations == nil {
n.Relations = make(map[string][]string) n.Relations = make(map[string][]string)
} }
if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace { if relType == RelAssignee || relType == RelCreated {
n.Relations[string(relType)] = []string{target} n.Relations[string(relType)] = []string{target}
return return
} }

View File

@@ -8,13 +8,12 @@ type Rel struct {
} }
const ( const (
RelBlocks RelType = "blocks" RelBlocks RelType = "blocks"
RelSubtask RelType = "subtask" RelSubtask RelType = "subtask"
RelRelated RelType = "related" RelRelated RelType = "related"
RelCreated RelType = "created" RelCreated RelType = "created"
RelAssignee RelType = "assignee" RelAssignee RelType = "assignee"
RelInNamespace RelType = "in_namespace" RelMentions RelType = "mentions"
RelMentions RelType = "mentions"
// Permission rels (subject → object). Levels are inclusive and transitive. // Permission rels (subject → object). Levels are inclusive and transitive.
RelCanRead RelType = "can_read" // level 1: visible in list/show RelCanRead RelType = "can_read" // level 1: visible in list/show

View File

@@ -77,7 +77,7 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
} }
} }
if v := q.Get("namespace"); v != "" { if v := q.Get("namespace"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: v}) filter.Namespace = v
} }
if v := q.Get("assignee"); v != "" { if v := q.Get("assignee"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v})

View File

@@ -72,6 +72,9 @@ func (c *apiClient) GetByID(id string) (*models.Node, error) {
func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) { func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q := url.Values{} q := url.Values{}
if filter.Namespace != "" {
q.Set("namespace", filter.Namespace)
}
for _, r := range filter.Rels { for _, r := range filter.Rels {
if r.Target == "" { if r.Target == "" {
q.Add("rel", string(r.Type)) q.Add("rel", string(r.Type))

View File

@@ -34,20 +34,23 @@ type NodeService interface {
// Type is "prefix::value"), and edge rels (Target is a node name or ID). // Type is "prefix::value"), and edge rels (Target is a node name or ID).
// The service applies defaults (type=issue, status=open for issues) and validates. // The service applies defaults (type=issue, status=open for issues) and validates.
type AddInput struct { type AddInput struct {
Title string Title string
Content string Content string
DueDate string DueDate string
Rels []RelInput Namespace string // namespace name or ID; defaults to the user's personal namespace
Rels []RelInput
} }
// UpdateInput describes changes to apply to an existing node. // UpdateInput describes changes to apply to an existing node.
// AddRels and RemoveRels accept both tag rels (Target == "") and edge rels. // AddRels and RemoveRels accept both tag rels (Target == "") and edge rels.
// Setting _status::done in AddRels is rejected when the node has open blockers. // Setting _status::done in AddRels is rejected when the node has open blockers.
// Adding assignee or in_namespace rels replaces the previous single target. // Adding an assignee rel replaces the previous single target.
// Setting Namespace transfers ownership from the current namespace to the new one.
type UpdateInput struct { type UpdateInput struct {
Title *string Title *string
Content *string Content *string
DueDate *string // nil = no change; pointer to "" = clear due date DueDate *string // nil = no change; pointer to "" = clear due date
Namespace *string // nil = no change; namespace name or ID to move node into
AddRels []RelInput AddRels []RelInput
RemoveRels []RelInput RemoveRels []RelInput
} }
@@ -56,6 +59,7 @@ type UpdateInput struct {
// Tag filters (Target == "") match by rel_name prefix. // Tag filters (Target == "") match by rel_name prefix.
// Edge filters (Target != "") are resolved to node IDs. // Edge filters (Target != "") are resolved to node IDs.
type ListFilter struct { type ListFilter struct {
Namespace string // when non-empty, only return nodes owned by this namespace
Rels []RelInput Rels []RelInput
HasDueDate bool // when true, only return nodes that have a due date set HasDueDate bool // when true, only return nodes that have a due date set
DueWithin *int // when non-nil, only return nodes due within this many days (includes overdue) DueWithin *int // when non-nil, only return nodes due within this many days (includes overdue)

View File

@@ -75,7 +75,7 @@ const (
// namespaces are globally readable and any node can reference them. // namespaces are globally readable and any node can reference them.
func isReferenceRel(t models.RelType) bool { func isReferenceRel(t models.RelType) bool {
switch t { switch t {
case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace: case models.RelAssignee, models.RelCreated, models.RelMentions:
return true return true
} }
return false return false
@@ -208,6 +208,28 @@ func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
} }
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
// Resolve namespace filter to owned node IDs.
var nsOwnedIDs map[string]bool
if filter.Namespace != "" {
nsID, _ := s.resolveIDByNameAndType(s.store, filter.Namespace, "namespace")
if nsID == "" {
if exists, _ := s.store.NodeExists(filter.Namespace); exists {
nsID = filter.Namespace
}
}
if nsID == "" {
return nil, nil // namespace not found
}
nsNode, err := s.store.GetNode(nsID)
if err != nil {
return nil, nil
}
nsOwnedIDs = make(map[string]bool)
for _, ownedID := range nsNode.Relations[string(models.RelHasOwnership)] {
nsOwnedIDs[ownedID] = true
}
}
var storeFilters []*models.Rel var storeFilters []*models.Rel
for _, ri := range filter.Rels { for _, ri := range filter.Rels {
if ri.Target == "" { if ri.Target == "" {
@@ -230,9 +252,13 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
} }
var result []*models.Node var result []*models.Node
for _, n := range nodes { for _, n := range nodes {
if pc.canRead(n.ID) { if !pc.canRead(n.ID) {
result = append(result, n) continue
} }
if nsOwnedIDs != nil && !nsOwnedIDs[n.ID] {
continue
}
result = append(result, n)
} }
if filter.HasDueDate || filter.DueWithin != nil { if filter.HasDueDate || filter.DueWithin != nil {
@@ -323,13 +349,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
hasNamespace := false
for _, ri := range input.Rels {
if ri.Type == models.RelInNamespace && ri.Target != "" {
hasNamespace = true
}
}
dueDate, err := parseDueDate(input.DueDate) dueDate, err := parseDueDate(input.DueDate)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -364,9 +383,8 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
// Edge rels. Track the namespace the node is placed in for ownership. // Edge rels.
hasCreated := false hasCreated := false
var actualNsID string
for _, ri := range input.Rels { for _, ri := range input.Rels {
if ri.Target == "" { if ri.Target == "" {
continue // already stored as tag continue // already stored as tag
@@ -378,9 +396,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
if err != nil { if err != nil {
return err return err
} }
if ri.Type == models.RelInNamespace {
actualNsID = resolved
}
if ri.Type == models.RelHasOwnership { if ri.Type == models.RelHasOwnership {
// Ownership transfer: remove existing owner of the target. // Ownership transfer: remove existing owner of the target.
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}}) existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
@@ -393,18 +408,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
// Default namespace.
if !hasNamespace {
nsID, err := s.resolveNamespaceRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
return err
}
actualNsID = nsID
}
// Default created. // Default created.
if !hasCreated { if !hasCreated {
userID, err := s.resolveUserRef(st, s.userID) userID, err := s.resolveUserRef(st, s.userID)
@@ -417,39 +420,30 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
// Grant ownership of the new node. // Grant ownership of the new node.
// Namespace nodes are owned by their creator. All other nodes are owned // Namespace nodes are owned by their creator (user node).
// by the namespace they belong to — the user retains transitive ownership // All other nodes are owned by the namespace they belong to — the user
// through the namespace's own ownership chain (e.g. user→owns→default-ns→owns→node). // retains transitive ownership through the namespace's own ownership chain
// (e.g. user→has_ownership→default-ns→has_ownership→node).
creatorID, err := s.resolveUserRef(st, s.userID) creatorID, err := s.resolveUserRef(st, s.userID)
if err != nil { if err != nil {
return err return err
} }
ownerID := creatorID ownerID := creatorID
if tmp.GetProperty("type") != "namespace" && actualNsID != "" { if tmp.GetProperty("type") != "namespace" {
ownerID = actualNsID nsRef := input.Namespace
if nsRef == "" {
nsRef = s.userID
}
nsID, err := s.resolveNamespaceRef(st, nsRef)
if err != nil {
return err
}
ownerID = nsID
} }
if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil { if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil {
return err return err
} }
// Namespace bootstrap: when creating a namespace node directly, apply the
// same setup as ensureNamespace — self in_namespace and creator ownership.
if tmp.GetProperty("type") == "namespace" {
if !hasNamespace {
// Replace the default namespace rel (user's ns) with self-reference.
userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace")
if userNsID != "" {
if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil {
return err
}
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return err
}
}
// Creator already gets ownership via the block above; nothing more to do.
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -470,8 +464,8 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return nil, err return nil, err
} }
// Field/tag changes and rel removals require can_write on the node. // Field/tag changes, rel removals, and namespace change require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil
for _, ri := range input.AddRels { for _, ri := range input.AddRels {
if ri.Target == "" { if ri.Target == "" {
needsWrite = true needsWrite = true
@@ -619,7 +613,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return err return err
} }
// Single-value relations replace the previous target. // Single-value relations replace the previous target.
if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace { if ri.Type == models.RelAssignee {
for _, oldTgt := range currentRels[string(ri.Type)] { for _, oldTgt := range currentRels[string(ri.Type)] {
if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil { if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil {
return err return err
@@ -651,6 +645,24 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
} }
} }
// Namespace change: transfer ownership from the current namespace to the new one.
if input.Namespace != nil {
newNsID, err := s.resolveNamespaceRef(st, *input.Namespace)
if err != nil {
return err
}
// Remove ownership from any current namespace owner.
currentOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: id}})
for _, owner := range currentOwners {
if owner.GetProperty("type") == "namespace" {
st.RemoveRel(owner.ID, string(models.RelHasOwnership), id) //nolint:errcheck
}
}
if err := st.AddRel(newNsID, string(models.RelHasOwnership), id); err != nil {
return err
}
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -770,8 +782,6 @@ func (s *nodeServiceImpl) resolveRelTarget(st store.GraphStore, ri RelInput) (st
switch ri.Type { switch ri.Type {
case models.RelAssignee, models.RelCreated, models.RelMentions: case models.RelAssignee, models.RelCreated, models.RelMentions:
return s.resolveUserRef(st, ri.Target) return s.resolveUserRef(st, ri.Target)
case models.RelInNamespace:
return s.resolveNamespaceRef(st, ri.Target)
default: default:
// Permission rels and all other edge rels expect raw node IDs. // Permission rels and all other edge rels expect raw node IDs.
return ri.Target, nil return ri.Target, nil
@@ -788,8 +798,6 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string)
switch relType { switch relType {
case models.RelAssignee, models.RelCreated, models.RelMentions: case models.RelAssignee, models.RelCreated, models.RelMentions:
nodeType = "user" nodeType = "user"
case models.RelInNamespace:
nodeType = "namespace"
default: default:
// Permission rels and other edge rels use raw node IDs. // Permission rels and other edge rels use raw node IDs.
return "", false return "", false
@@ -843,9 +851,6 @@ func (s *nodeServiceImpl) ensureGlobalNamespace(st store.GraphStore) (string, er
if err := st.AddRel(id, "_type::namespace", ""); err != nil { if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err return "", err
} }
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
// Self-owned so no single user controls it. // Self-owned so no single user controls it.
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil { if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err return "", err
@@ -916,9 +921,6 @@ func (s *nodeServiceImpl) ensureNamespace(st store.GraphStore, name string) (str
if err := st.AddRel(id, "_type::namespace", ""); err != nil { if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err return "", err
} }
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
userID, err := s.resolveUserRef(st, s.userID) userID, err := s.resolveUserRef(st, s.userID)
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -74,31 +74,20 @@ func FindAndOpenSQLiteStore() (GraphStore, error) {
} }
// FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server // FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server
// mode: if no .ax.db is found it creates and initialises one in the current // mode: if no database is found it creates and initialises one in the
// working directory instead of returning an error. // ~/.local/share/ax/ directory instead of returning an error.
func FindOrInitSQLiteStore() (GraphStore, error) { func FindOrInitSQLiteStore() (GraphStore, error) {
dir, err := filepath.Abs(".") dataRoot, err := FindDataRoot(".local", "share")
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to find data dir: %w", err)
} }
for { dbPath := filepath.Join(dataRoot, "ax.db")
dbpath := filepath.Join(dir, ".ax.db") if _, err := os.Stat(dbPath); err != nil {
if _, err := os.Stat(dbpath); err == nil { if err := InitSQLiteStore(dbPath); err != nil {
return NewSQLiteStore(dbpath) return nil, err
}
if parent := filepath.Dir(dir); parent == dir {
break
} else {
dir = parent
} }
} }
// Not found — create and initialise in CWD. return NewSQLiteStore(dbPath)
cwd, _ := filepath.Abs(".")
dbpath := filepath.Join(cwd, ".ax.db")
if err := InitSQLiteStore(dbpath); err != nil {
return nil, err
}
return NewSQLiteStore(dbpath)
} }
// NewSQLiteStore opens a SQLite database at the given path, runs a one-time // NewSQLiteStore opens a SQLite database at the given path, runs a one-time

View File

@@ -22,7 +22,7 @@ func LoadSession() (*Session, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, nil return &Session{path: path}, nil
} }
return nil, err return nil, err
} }