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
15 changes: 14 additions & 1 deletion internal/cmd/agentic/agentic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ non-zero naming the nodes it already created, each complete with its edges.
hadron auth login | logout | whoami | status | token create|list|validate|revoke <id>
hadron usage events [--memory <ref>] [--loc <loc>] [--type <type>] [--limit 1-200] [--offset N]
hadron memory list [--shared-with-me | [--owned-by-me] [--include-agent-system]] | get <id-or-urn> (cli#1014: shows the stored last-sync outcome, Memory.lastSyncReport v1; `--json` adds `lastSyncedAt`, `syncError` (URL credentials masked), `syncReport` (the parsed v1 object {version,outcome,recordedAt,entries[{kind,loc,fromLoc?,code}],code?}, every entry, unknown kind/code passed through) and `syncReportStatus` recorded|none|unavailable; an unreadable report is `syncReport: null` + `unavailable`, never clean; text prints a `sync:` block only for a memory with a source or sync history, quoting server strings and capping at 50 entries) | set [<id-or-urn>] [--org <ref> | --owner-me | --app <ref> --agent <ref>] [--class <c>] [--max-rev-count <n>] [--draft-corpus] [--schema <json> | --schema-file <path>] [--listed-on-marketplace[=false]] (update only, cli#592: the opt-in marketplace catalogue flag, cor:acl:080:04, advertisement not access; omitted preserves it; `get` shows it as `marketplace: listed|not listed` and `--json` `listedOnMarketplace`, also on a `set` update's receipt) | attach <memory> --app <ref> --agent <ref> | set-active <id-or-urn> | rm <id-or-urn> | clone <id-or-urn> --target-urn hrn:mem:<root>:<slug> | transfer <memoryRef> (--org <ref> | --user <ref>) [--class <c>] [--reset-group-members] [--apply | --yes] | extract <parentRef> <targetUrn> [--move] | export <id-or-urn> [--out <dir>] | member list|add|set-role|rm <memory> --user <id> [--role <r>] | share list|create|set-role|revoke <memory> --grantee <user-ref> [--role <r>] | subscription list|create|set-role|rm <memory> --org <id> [--role <r>] | encrypt <memory> --data-key - | link-user <memoryRef> --external-user <id> [--data-key -] --yes | approve-all -m <memory> [--yes] | validate <memoryRef> [--check <kind>]... [--limit N] [--fail-on-findings] | reembed-failed <memoryRef> | config get <memoryRef> | config rule add|update <memoryRef> <role> [--author-task <ref>] [--validation-task <ref>] [--description-node <ref>] [--writers all|admin|owner] [--validate-by agent|platform] [--strict-sub-roles[=false]] [--enabled[=false]] | config rule rm <memoryRef> <role> --yes | config rule unlock <memoryRef> <role> [--yes] | config template list [--owner-server | --owner-org <ref> | --owner-me | --owner-app <ref>] | config template get <templateId> | config template create --file <path> (--owner-server | --owner-org <ref> | --owner-me | --owner-app <ref>) | config template update <templateId> --file <path> [--expected-revision <n>] | config template rm <templateId> --yes | config template apply <templateId> <memoryRef> [--dry-run] [--expected-revision <n>] [--yes] | usage <memoryRef>
hadron node list [-m <memory>] [--prefix <loc>] [--type <t>] [--object-type <t>] [--tag <t>]... [--where <json>] [--sort-property <json>] [--with-properties] [--with-data] [--sort-seq asc|desc] [--seq-gt N] | get <urn>... [--raw] | get <loc>... -m <memory> [--raw] | get <urn>|<loc> -m <memory> --body-only | get --prefix <loc> -m <memory> | add [--type <t>] [--object-type <t>] [--data <json>|--data-file <path>] [--properties <json>|--properties-file <path>] | update <urn> [--content <text|-> | --content-file <path|->] [--force] [--dry-run] [--type <t>] [--object-type <t>|""] [--data <json>|--data-file <path>|--data-merge <json>|--data-merge-file <path>] [--properties <json>|--properties-file <path>] | move <urn> (--to-urn <urn> | --to-memory <memory>) | clone <urn> (--to-urn <urn> | --to-memory <memory>) | merge <urn> --into <urn> [--field <f>]... [--delete-source] --yes | rm <urn> [--hard] [--recursive|-r] | export <urn> [-o <file>] [--format md|json|pdf] | import <file|-|--url <u>> [-m <memory>] [--with-edges] [--task <ref> [--task-args <json>] [--app <ref>]] | approve <node-ref> [-m <memory>] [--revision N] | verify <node-ref> [-m <memory>] | mint -m <memory> [--dry-run] [--yes] | sign <node-ref> [-m <memory>] [--revision N] [--signer <user-id>] | signatures <node-ref> [-m <memory>] [--check] | signing-request get <request-id> | signing-request list <node-ref> [-m <memory>] [--state open|completed|declined|expired|stale|withdrawn] [--limit 1-100] | signing-request withdraw <request-id> | revision list <node-ref> [-m <memory>] [--limit N] | revision get <revision-id> | revision restore <revision-id> [--truncate [--yes]] | revision label <revision-id> --label <text> | revision delete <revision-id> [--yes] | revision clear <node-ref> [-m <memory>] [--yes] | recovery list -m <memory> [--limit 1-200] [--offset N | --all] | recovery get <node-urn>|<node-id>|<loc> [-m <memory>] [--content] | recovery graph -m <memory> [--limit 1-200] [--offset N | --all] | restore <node-urn>|<node-id>|<loc> [-m <memory>] | next <parent> [--after <current>] [-m <memory>] [--raw] | open <node-ref> [-m <memory>] [--print]
hadron node list [-m <memory>] [--prefix <loc>] [--type <t>] [--object-type <t>] [--tag <t>]... [--where <json>] [--sort-property <json>] [--with-properties] [--with-data] [--sort-seq asc|desc] [--seq-gt N] | get <urn>... [--raw] | get <loc>... -m <memory> [--raw] | get <urn>|<loc> -m <memory> --body-only | get --prefix <loc> -m <memory> | add [--type <t>] [--object-type <t>] [--data <json>|--data-file <path>] [--properties <json>|--properties-file <path>] | update <urn> [--expected-revision N] [--content <text|-> | --content-file <path|->] [--force] [--dry-run] [--type <t>] [--object-type <t>|""] [--data <json>|--data-file <path>|--data-merge <json>|--data-merge-file <path>] [--properties <json>|--properties-file <path>] | move <urn> (--to-urn <urn> | --to-memory <memory>) | clone <urn> (--to-urn <urn> | --to-memory <memory>) | merge <urn> --into <urn> [--field <f>]... [--delete-source] --yes | rm <urn> [--hard] [--recursive|-r] | export <urn> [-o <file>] [--format md|json|pdf] | import <file|-|--url <u>> [-m <memory>] [--with-edges] [--task <ref> [--task-args <json>] [--app <ref>]] | approve <node-ref> [-m <memory>] [--revision N] | verify <node-ref> [-m <memory>] | mint -m <memory> [--dry-run] [--yes] | sign <node-ref> [-m <memory>] [--revision N] [--signer <user-id>] | signatures <node-ref> [-m <memory>] [--check] | signing-request get <request-id> | signing-request list <node-ref> [-m <memory>] [--state open|completed|declined|expired|stale|withdrawn] [--limit 1-100] | signing-request withdraw <request-id> | revision list <node-ref> [-m <memory>] [--limit N] | revision get <revision-id> | revision restore <revision-id> [--truncate [--yes]] | revision label <revision-id> --label <text> | revision delete <revision-id> [--yes] | revision clear <node-ref> [-m <memory>] [--yes] | recovery list -m <memory> [--limit 1-200] [--offset N | --all] | recovery get <node-urn>|<node-id>|<loc> [-m <memory>] [--content] | recovery graph -m <memory> [--limit 1-200] [--offset N | --all] | restore <node-urn>|<node-id>|<loc> [-m <memory>] | next <parent> [--after <current>] [-m <memory>] [--raw] | open <node-ref> [-m <memory>] [--print]
hadron object create -m <memory> --type <t> --fields <json>|--fields-file <path> [--key <k>] [--name <n>] | get <ref> | update <ref> --fields <json>|--fields-file <path> [--reason <r>] | delete <ref> [--hard] --yes | find -m <memory> --type <t> [--match <json>] [--where <json>] [--sort <json>] [--limit N] [--offset N]
hadron asset list -m <memory> [--mine] [--mime <type>] [--include-deleted] [--limit N] [--offset N] | get <asset-ref> [-o <path>|-] [--force] | url <asset-ref> [-m <memory>] | upload <file> -m <memory> [--mime <t>] [--name <n>] [--description <d>] | rm <asset-ref> [--yes] | restore <asset-ref> | public-link <asset-ref> (--on | --off) [--yes] | link <asset-ref> --node <new-node-urn> [--name <n>] [--description <d>]
hadron task run <task-urn>|<loc> -m <memory> [--arg k=v]... [--app <ref> [--as-self]]
Expand Down Expand Up @@ -3474,3 +3474,16 @@ root node read, no transcript fetch or cursor/binding write. The root read recor
a read and may decrypt internally. A concealed/missing/failed root read leaves
the URL null; initial lookup failures remain errors. Existing channel get/list
commands are unchanged.

## Guarded node field updates (#820)

`hadron node update <ref> --expected-revision N <field-flags>` forwards exactly
your positive 32-bit baseline (1..2147483647) to the current kind's update door,
including role clears. Omit it for last-write-wins. The flag alone is not an
edit. Guarded data merges (--data-merge / --data-merge-file), including mixed
field+merge calls, refuse before any request or input read; --data replacement
uses the guarded door. Dry-run previews the supplied expectedRevision (nullable
JSON key) and explicitly says it is not validated by the server. A mismatch
keeps NODE_WRITE_CONFLICT/exit5 and provider currentRevision metadata; re-read
and retry deliberately. Drift can advance a revision without another user's
edit. No automatic refresh/retry or selector-identity guarantee is added.
88 changes: 57 additions & 31 deletions internal/cmd/node/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"os"
"strings"

Expand All @@ -19,27 +20,28 @@ import (

func newCmdUpdate(f *cmdutil.Factory) *cobra.Command {
var (
memory string
name string
content string
contentFile string
nodeType string
objectType string
role string
description string
abstract string
abstractFile string
data string
dataFile string
dataMerge string
dataMergeFile string
properties string
propertiesFile string
runnable bool
force bool
dryRun bool
reason string
tags []string
memory string
name string
content string
contentFile string
nodeType string
objectType string
role string
description string
abstract string
abstractFile string
data string
dataFile string
dataMerge string
dataMergeFile string
properties string
propertiesFile string
runnable bool
force bool
dryRun bool
expectedRevision int
reason string
tags []string
)
cmd := &cobra.Command{
Use: "update <node-urn> | <loc> -m <memory>",
Expand All @@ -63,6 +65,14 @@ The data bag can be written two ways:
Replace and merge are different operations, so --data and --data-merge are
mutually exclusive.

--expected-revision requires the node's live revision to match your supplied
positive 32-bit baseline. Omitted means last-write-wins. A mismatch is a conflict
(exit 5); re-read and retry deliberately. Drift can advance a revision without
another user edit. The CLI never refreshes the baseline or retries a write.
This guard cannot be used with --data-merge or --data-merge-file; use --data
for a guarded full replacement. In --dry-run it is advisory, not validated by
the server, and reserves no revision.

EDITING EXISTING CONTENT: read it with ` + "`node get <ref> --body-only`" + `, which
prints the stored body byte for byte (#873). A plain ` + "`node get`" + ` compiles the
node's Mustache templates, so writing that text back deletes every {{…}}
Expand Down Expand Up @@ -126,6 +136,14 @@ schema-governed memory the server validates the result and rejects a violation.)
if replaceData && mergeData {
return exitcode.Newf(exitcode.Usage, "--data (replace) and --data-merge (merge) are mutually exclusive")
}
if changed("expected-revision") {
if expectedRevision < 1 || expectedRevision > math.MaxInt32 {
return exitcode.Newf(exitcode.Usage, "--expected-revision must be between 1 and 2147483647")
}
if mergeData {
return exitcode.Newf(exitcode.Usage, "--expected-revision cannot guard --data-merge or --data-merge-file; use --data to replace the bag through the guarded update")
}
}
// The inline/-file pairs are mutually exclusive. Guard on Changed()
// (not the resolved value): an explicit --X "" would otherwise slip past
// the value check and let the file silently win.
Expand Down Expand Up @@ -271,6 +289,9 @@ schema-governed memory the server validates the result and rejects a violation.)
input := gen.UpdateNodeInput{
Id: &nodeID,
}
if changed("expected-revision") {
input.ExpectedRevision = &expectedRevision
}
if changed("name") {
input.Name = &name
}
Expand Down Expand Up @@ -350,7 +371,7 @@ schema-governed memory the server validates the result and rejects a violation.)
}
}
if dryRun {
return writeUpdatePreview(f, stored, changed, body, contentChanged, warnings)
return writeUpdatePreview(f, stored, changed, body, contentChanged, warnings, input.ExpectedRevision)
}
for _, w := range warnings {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: %s\n", w)
Expand Down Expand Up @@ -426,6 +447,7 @@ schema-governed memory the server validates the result and rejects a violation.)
cmd.Flags().StringVar(&properties, "properties", "", `replace the structured-storage JSON properties (#725; "null" clears)`)
cmd.Flags().StringVar(&propertiesFile, "properties-file", "", "read the replacement JSON properties object from a file")
cmd.Flags().BoolVar(&runnable, "runnable", false, "mark the node runnable by 'hadron task run' (--runnable=false clears it; omit to preserve)")
cmd.Flags().IntVar(&expectedRevision, "expected-revision", 0, "require the supplied live revision (1-2147483647); omit for last-write-wins; unavailable with data merge")
cmd.Flags().StringVar(&reason, "reason", "", "why this change was made (recorded in version history)")
cmd.Flags().StringArrayVar(&tags, "tag", nil, "replace tags (repeatable)")
return cmd
Expand Down Expand Up @@ -485,13 +507,14 @@ var updateFields = []struct {
// updatePreviewDTO is `node update --dry-run --json` (#873). content is null
// unless the body changes; warnings is always a list.
type updatePreviewDTO struct {
DryRun bool `json:"dryRun"`
ID string `json:"id"`
URN string `json:"urn"`
Loc string `json:"loc"`
Fields []string `json:"fields"`
Content *contentPreviewDTO `json:"content"`
Warnings []string `json:"warnings"`
DryRun bool `json:"dryRun"`
ID string `json:"id"`
URN string `json:"urn"`
Loc string `json:"loc"`
Fields []string `json:"fields"`
Content *contentPreviewDTO `json:"content"`
Warnings []string `json:"warnings"`
ExpectedRevision *int `json:"expectedRevision"`
}

// contentPreviewDTO is the body change: stored (raw) → proposed, and their
Expand All @@ -502,8 +525,11 @@ type contentPreviewDTO struct {
Diff string `json:"diff"`
}

func writeUpdatePreview(f *cmdutil.Factory, stored *gen.GetNodeNode, changed func(string) bool, body string, contentChanged bool, warnings []string) error {
dto := updatePreviewDTO{DryRun: true, ID: stored.Id, URN: stored.Urn, Loc: stored.Loc, Fields: []string{}, Warnings: warnings}
func writeUpdatePreview(f *cmdutil.Factory, stored *gen.GetNodeNode, changed func(string) bool, body string, contentChanged bool, warnings []string, expectedRevision *int) error {
if expectedRevision != nil {
warnings = append(warnings, fmt.Sprintf("expected revision %d is advisory and not validated by the server in a dry run", *expectedRevision))
}
dto := updatePreviewDTO{DryRun: true, ID: stored.Id, URN: stored.Urn, Loc: stored.Loc, Fields: []string{}, Warnings: warnings, ExpectedRevision: expectedRevision}
for _, uf := range updateFields {
for _, fl := range uf.flags {
if changed(fl) {
Expand Down
Loading
Loading