Skip to content
Merged
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
33 changes: 29 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,13 +465,13 @@ rollback), and the message ends with how many, or "Nothing was written.".

| `reason` | Meaning | Fields |
|----------|---------|--------|
| `open_in_editor` | The write would change the block open in Logseq's editor, where someone is typing | `block`, `page` |
| `open_in_editor` | The write would change the block open in Logseq's editor, where someone is typing | `block`, `page`; `copied_id` from `copy-block` |
| `editor_state_unknown` | Logseq's answer to which block is open was not understood. The write was not sent; asked after a multi-block write, its blocks landed and count in `writes_landed` | `answer` |
| `logseq_error` | Logseq threw on the write and answered with an error object | `method`, `logseq_message` |
| `page_exists` | `create-page` on a page that exists, whose properties Logseq would drop | `page` |
| `rename_refused` | `rename-page` onto a name another page has (Logseq would merge the two) or an empty one | `old`, `new`, `why` (`exists` or `empty`) |
| `block_changed` | `set-todo-status`, `update-block --expect-hash`, `add-journal-block --upsert-heading --expect-hash`, `replace-text`, `set-property` and `remove-property` (every write that replaces a block's text) read the block again right before writing, and it no longer holds the text they read: someone changed it in between, and the write would have replaced that change. Nothing was written | `block` |
| `write_not_verified` | The write does not show in Logseq: not written, or not all of it | `method`, `target`, `expected`, `got` |
| `block_changed` | `set-todo-status`, `update-block --expect-hash`, `add-journal-block --upsert-heading --expect-hash`, `replace-text`, `set-property` and `remove-property` (every write that replaces a block's text) read the block again right before writing, and it no longer holds the text they read: someone changed it in between, and the write would have replaced that change. Nothing was written to the block. `copy-block --remove`, `remove-block` and `remove-property` (removing the page's property block) read the block with everything under it again right before the delete, and something under it changed: nothing was removed. For `copy-block` the copy is already written and stays | `block`; `copied_id` from `copy-block` |
| `write_not_verified` | The write does not show in Logseq: not written, or not all of it | `method`, `target`, `expected`, `got`; `copied_id` from `copy-block` |
| `read_only` | Writes are switched off by `[safety] read_only`, `LOGSEQ_CLI_READ_ONLY` or `--read-only`; every command that writes refuses, `--dry-run` included, before its first request | `source` (list of `config`, `env`, `flag`), `config_path` (`null` when no config file was found) |
| `precondition_required` | A precondition is required and the call gave none: `[safety] require_preconditions`, `LOGSEQ_CLI_REQUIRE_PRECONDITIONS` or `--require-preconditions` is on, and the command got none of the options it takes: `--expect-hash` (`update-block` takes it too; `set-todo-status` also takes `--expect-marker`; `add-journal-block --upsert-heading` owes it only when it matches a block, otherwise it creates one and owes nothing), or `--expect-tree-hash` for `remove-block` / `delete-block`. Refused before the first request, nothing was written | `source` (list of `config`, `env`, `flag`), `config_path` (`null` when no config file was found), `options` |
| `config_error` | The config file cannot be used: it does not parse, `LOGSEQ_CLI_CONFIG` names a file that is not there, or `[safety]` does not check out: a key it does not know, a `[safety]` key outside `[safety]`, a value that is not a boolean, or a `[graph] keep_empty_blocks_last` that is not. A command that writes refuses rather than run without the limits the file may hold. Nothing was sent | none |
Expand All @@ -493,6 +493,22 @@ failed between a write and its proof, `unproven_write` names that write: it
may have landed or not. Read the target before retrying; a retry of the whole
command writes again what landed.

`copy-block --remove` whose source was deleted elsewhere during the copy
succeeds (exit 0, `"action": "moved"`) with `"source_gone": true` (`false`
otherwise): the copy is there and the source is gone, which is the move. Do
not remove the copy to clean up: it may be the only version left. If another
call moved the same block at the same time, its copy is there too; check the
target page before removing anything.

`copy-block` adds `copied_id` to every failure after the root of its copy
landed, whatever its `reason`: a refusal, a write not verified, an error
Logseq threw, a connection failure, at the removal too. The message says "The copy is <uuid>.". The copy stays (no rollback), so
repeating `copy-block` makes a second one: read the copy first. When the root
itself failed, there is no `copied_id`, and neither when the root went before
the empty blocks at the end of the page (`keep_empty_blocks_last`) and the
step that proves its place failed: then `writes_landed` says that it landed,
so read the target page before retrying.

On `read_only`: the user switched writes off. Do not retry, do not edit the
config file, unset `LOGSEQ_CLI_READ_ONLY`, change `HOME` or `LOGSEQ_CLI_CONFIG`,
write the Markdown files, or call the HTTP API yourself; each of those goes
Expand All @@ -504,7 +520,9 @@ call. [docs/safety.md](docs/safety.md) describes the switch.

On `open_in_editor`: the refused write did not happen; earlier writes of the
same call may have (the message says how many). Ask the user to leave the
block, check, then retry. Do not retry in a loop.
block, check, then retry. Do not retry in a loop. After `copy-block --remove`
the copy stands (`copied_id`): once the source is free, remove it with
`remove-block` instead of repeating the move.

What counts as changing the open block: a write to it, a removal or a move of
it or of a block above it, a page deletion or a rename of its page, and a
Expand Down Expand Up @@ -537,6 +555,13 @@ elsewhere stands. Read the block again and repeat the command if it still
makes sense. The check narrows the time between reading and writing to one
request; it does not close it.

Not after `copy-block --remove`: there the copy is already written and
stays (`copied_id`), only the source was not removed, because something under
it changed during the copy. Repeating the command makes a second copy. Read
the copy and the source, then either keep the copy and remove the source with
`remove-block`, or remove the copy and move again. After `remove-block` the
block was not removed; read it again and decide.

`replace-text` writes the other blocks when one is refused, and names each
refused block's reason in `failed_reasons` (`{id: reason}`) beside `failed`.

Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ file carries most of the reasoning behind the tool.

### Added

- `copy-block` names its copy when it fails after the copy's root landed:
`copied_id` in the error object and "The copy is <uuid>." in the message,
for a refused removal (`open_in_editor`), a block of the copy not verified
(`write_not_verified`) and a connection failure, at the removal too. The copy
stays, as before (no rollback), but a caller had only `writes_landed` and no
way to find it, and a retry made a second copy. A root that did not land
names nothing.

- A write can say which state of a block it was decided on, and is refused when
the block is another by then. `get-block --json` gives a block a `hash` (and
`tree_hash` for the block with everything under it); `get-page`,
Expand Down Expand Up @@ -146,6 +154,15 @@ file carries most of the reasoning behind the tool.

### Changed

- `remove-block` (and its alias `delete-block`) reads the block with its
children again just before the delete and refuses with `block_changed`,
removing nothing, when anything under it changed since the command read it.
It used to delete whatever stood there by then, a child added in between
included, while the count it reported was the one it had read. A block gone
by then is reported as `block_not_found`, exit 1, where the command used to
report it removed. The same check guards the page's property block that
`remove-property` removes with its last key. One read more per removal.

- `analyze-journal-patterns` counts tasks from the database, one count per
journal day. `tasks` holds `open`, `done`, `cancelled` and `done_rate`, and
the `tasks` of each entry holds `open`, `done` and `cancelled`. A task is a
Expand Down Expand Up @@ -275,6 +292,26 @@ file carries most of the reasoning behind the tool.

### Fixed

- `copy-block --remove` deleted a block it had not copied. It reads the source
once, copies it block by block and then removes it, and the removal takes
everything under the source with it: a child added in Logseq while the copy
ran was deleted, missing from the copy, and the command reported success.
The window grows with the tree. Found by an adversarial pass over the
precondition work and reproduced against the test double (a fourth child
added after the second copied block: exit 0, `"blocks": 4`, the fourth child
gone); the gap was already on `main`. The removal now goes through the check
every removal has (see Changed): the source is read again with everything
under it right before the delete and compared with the state that was
copied, and a difference refuses with `block_changed`, nothing removed and
the copy named in `copied_id`. The message no longer asks to repeat the
command, which would make a second copy. A source deleted elsewhere during
the copy used to be reported as moved without a word; it is still a success,
the move is done, and the result says `"source_gone": true`. Moving the
source with `moveBlock` instead would close the window, but it places a block
only before another or as a first child, so the end of a page would need a
stand-in with its own removal, and `copy-block --remove` would become
`move-block`, which exists.

- A change made to a block while a command was changing it was written over
without a word. `set-todo-status`, `replace-text`, `set-property` and
`remove-property` read the block, build the new text from it and send the
Expand Down
7 changes: 6 additions & 1 deletion GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ _Avoid_: version, etag, checksum
**Tree Hash**:
The Hash of a Block together with everything under it, content and order
(`tree_hash` of `get-block --json`). The Precondition of `remove-block`, since
the hash of the block alone would not see a child added after the read.
the hash of the block alone would not see a child added after the read. Also
the check before every removal of a block a command read: a removal cascades,
so the block is read again just before the delete and refused with
`block_changed` when its Tree Hash is another (`copy-block --remove`, which
compares with the state it copied, `remove-block`, and the page's property
block that `remove-property` removes with its last key).
_Avoid_: subtree hash, deep hash

**Dry Run**:
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ A write that ends a section (`add-journal-block`, `add-journal-content`,
| `insert-block --child-of UUID --first` | Insert as FIRST child instead of appending last (works with `--content` and `--tree`; order preserved). Only valid with `--child-of` |
| `insert-block --tree "<tab-or-json>" [--child-of UUID \| --page NAME --top-level]` | Batch-insert a hierarchy in one call (DFS pre-order UUIDs returned). `--tree-file FILE` reads the same tab-indented text or JSON from a file |
| `insert-block --tree ... --keep-ids` | Keep the `id::` values in the content instead of letting Logseq mint new ones, for moving or restoring an outline — top-level blocks included. Without it they are dropped and the count is reported on stderr; content that is nothing but `id::` lines is refused, since nothing would be left to write. With it, an id a block or page still has is refused before anything is written (a copy would put one uuid on two blocks), and so is a second `id::` line in one block. One that survives only as a `((ref))` target is restored: the block takes over the placeholder Logseq keeps under that uuid, and the ref resolves again. An `id::` line inside a code block (```` ``` ```` or `~~~`) is code and is written as is. The same flag and rule apply to `--content` and to `add-note-content`, `add-journal-block` and `add-journal-content` |
| `copy-block --id UUID --to-page NAME [--remove] [--ignore-refs] [--expect-hash H] [--dry-run]` | Copy/move block with children to another page. `--expect-hash H` (the `hash` of `get-block --json`, with `--remove` only; without it a usage error) refuses with `precondition_failed` unless the block is still the one that was read, before `--dry-run` and before the first copy; the copy and the removal come from that read. The copy gets new UUIDs and leaves the source's `id::` lines out, so `--remove` refuses while `((block-refs))` point into the source (`--ignore-refs` overrides); `move-block` keeps them |
| `copy-block --id UUID --to-page NAME [--remove] [--ignore-refs] [--expect-hash H] [--dry-run]` | Copy/move block with children to another page. `--expect-hash H` (the `hash` of `get-block --json`, with `--remove` only; without it a usage error) refuses with `precondition_failed` unless the block is still the one that was read, before `--dry-run` and before the first copy; the copy and the removal come from that read. With `--remove` the source is removed only as it was copied: a block added or changed under it during the copy refuses the removal with `block_changed`, and a source deleted elsewhere in the meantime is a move done (`"source_gone": true`, exit 0). Every failure after the copy's root landed and was proven names the copy in `copied_id` ("The copy is <uuid>."): it stays, and a retry makes a second one. A root placed before the empty blocks at the end of the page whose place could not be proven is not named; `writes_landed` says it landed. The copy gets new UUIDs and leaves the source's `id::` lines out, so `--remove` refuses while `((block-refs))` point into the source (`--ignore-refs` overrides); `move-block` keeps them |
| `move-block --id UUID (--under UUID \| --before UUID) [--expect-hash H] [--dry-run]` | Structural move (`--expect-hash H`: refuses with `precondition_failed`, before `--dry-run` and the move, unless the block is still the one that was read; it sees the content, not the position): the block keeps its UUID, so `((block-refs))` to it survive. Prefer over `copy-block --remove`, which writes a new block and deletes the original. `--under` nests as first child, `--before` places it in front as a sibling |

### Meta (4)
Expand Down
10 changes: 10 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ adds it when a ref to the block is written earlier in the same call. This
shrinks the window to the requests between that read and the write; it does
not close it, as no read can.

A removal has the same gap, and a wider loss: `removeBlock` takes everything
under the block with it, so a child added after the command read the block
would be deleted unseen. Every caller of the API's removal says which state it
read, as a tree hash, or says explicitly that it read none (a stand-in the
same call wrote a moment before). The API reads the block with its children
again, past the cache, just before the delete and refuses with `block_changed`
when the tree is another, or `block_not_found` when the block is gone. The
parameter has no default, so a new caller cannot leave the check out by
forgetting it.

The second half of the sentence answers a loss no read-back can catch.
Measured on Logseq 0.10.15: a write to the block being edited replaced the
editor content at once and dropped what had not been saved; Logseq answered
Expand Down
26 changes: 20 additions & 6 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ protection holds only for calls that pass a hash; a call that passes none is
written as before.

A precondition and `block_changed` guard different things. `block_changed` is
the command's own re-read: between its read and its write, did the block change?
the command's own re-read: between its read and its write (or its removal),
did the block change?
A precondition is the caller's: is the block still what I read, minutes ago,
when I decided what to write? A precondition that fails is `precondition_failed`;
after it, read the block again and decide again. Repeating the same change with
Expand All @@ -149,11 +150,24 @@ way.
caught by the precondition. `update-block` narrows that gap with its own
re-read (`block_changed`); nothing closes it, because the API has no
conditional write.
- **It does not cover the copy in `copy-block --remove`.** The command copies
the subtree block by block and deletes the source at the end, so a child
added under the source while the copy runs is deleted with it. The window
grows with the size of the tree, and `--expect-hash` checks the state before
the copy starts, not during it.
- **A removal is checked, not made atomic.** `copy-block --remove` copies the
subtree block by block and deletes the source at the end; `--expect-hash`
checks the state before the copy starts, not during it. Before the delete
the command reads the source with everything under it again and refuses
with `block_changed` if that is no longer what it copied, so a child added
while the copy runs stays. `remove-block` and the page's property block that
`remove-property` removes do the same. What stays open is the few requests
between that read and the delete: the editor check, with a block open also
its read of the subtree, which takes longer the larger the tree is.
- **A copy that breaks off stays.** There is no rollback: when the removal is
refused, or a block of the copy or the connection fails after the copy's
root landed, the copy (or the part of it that landed) remains, and the
error names it in `copied_id`. A root placed before the empty blocks at the
end of a page (`keep_empty_blocks_last`) that lands while the step after it
fails is not named; `writes_landed` says that something did.
- **A ref set during a copy is not seen.** `copy-block --remove` looks for
`((block-refs))` into the source before it copies; one added while the copy
runs points at nothing after the move. Nothing is lost, but the ref dangles.
- **The hash sees content, not position.** `move-block --expect-hash` does not
notice that the block was moved since it was read; it notices that its text
or properties changed. `remove-block` takes the hash of the block and
Expand Down
Loading
Loading