textus 0.29.0 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +2 -235
- data/CHANGELOG.md +169 -0
- data/README.md +85 -64
- data/SPEC.md +366 -201
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +93 -76
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +8 -8
- data/lib/textus/cli.rb +21 -18
- data/lib/textus/container.rb +1 -2
- data/lib/textus/dispatcher.rb +11 -3
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check.rb +8 -5
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +18 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope/io/reader.rb +4 -0
- data/lib/textus/envelope/io/writer.rb +8 -0
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +12 -24
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +24 -18
- data/lib/textus/maintenance/zone_mv.rb +1 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +16 -8
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/policy.rb +62 -19
- data/lib/textus/manifest/rules.rb +25 -14
- data/lib/textus/manifest/schema.rb +78 -38
- data/lib/textus/manifest.rb +6 -5
- data/lib/textus/mcp/server.rb +2 -10
- data/lib/textus/mcp/session.rb +7 -23
- data/lib/textus/mcp/tool_schemas.rb +3 -3
- data/lib/textus/mcp/tools.rb +7 -7
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +8 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/policy_explain.rb +19 -10
- data/lib/textus/read/pulse.rb +5 -4
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role_scope.rb +3 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +15 -17
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/mv.rb +17 -15
- data/lib/textus/write/put.rb +15 -17
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus/write/retention_sweep.rb +55 -0
- metadata +32 -18
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
data/docs/conventions.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Conventions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Reference** · for integrators · **read when** you're shaping a `.textus/` tree and want the idiomatic choices
|
|
4
|
+
> **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-05 (v0.31)
|
|
5
|
+
|
|
6
|
+
Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build automation. The spec ([`../SPEC.md`](../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
|
|
4
7
|
|
|
5
8
|
## Key naming
|
|
6
9
|
|
|
@@ -18,14 +21,14 @@ Recommended top-level layout — the spec allows alternatives, but this is what
|
|
|
18
21
|
manifest.yaml
|
|
19
22
|
schemas/ # YAML schema definitions
|
|
20
23
|
zones/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
knowledge/ # authored truth: identity, voice, decisions — author-holders write
|
|
25
|
+
notebook/ # agent's own durable lane (workspace) — keep-holders write
|
|
26
|
+
feeds/ # automation-fed external inputs (fetch)
|
|
27
|
+
proposals/ # AI proposals awaiting accept (propose)
|
|
28
|
+
artifacts/ # generated by build automation — never edit by hand
|
|
26
29
|
```
|
|
27
30
|
|
|
28
|
-
Inside `
|
|
31
|
+
Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
|
|
29
32
|
|
|
30
33
|
## Schema design
|
|
31
34
|
|
|
@@ -46,75 +49,77 @@ Tooling around `git blame` or audit logs may filter on owner; the gem itself onl
|
|
|
46
49
|
|
|
47
50
|
## Derived entries
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
|
|
50
53
|
|
|
51
|
-
**`projection
|
|
54
|
+
**`compute: { kind: projection }`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
|
|
52
55
|
|
|
53
56
|
```yaml
|
|
54
|
-
- key:
|
|
55
|
-
path:
|
|
56
|
-
zone:
|
|
57
|
+
- key: artifacts.catalogs.people
|
|
58
|
+
path: artifacts/catalogs/people.md
|
|
59
|
+
zone: artifacts
|
|
57
60
|
schema: null
|
|
58
61
|
owner: build:catalog-people
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
compute:
|
|
63
|
+
kind: projection
|
|
64
|
+
select: knowledge.network.org # prefix or list of prefixes
|
|
61
65
|
pluck: [name, relationship, org]
|
|
62
66
|
sort_by: name
|
|
63
|
-
template: people.mustache
|
|
64
|
-
publish_to: [docs/people.md]
|
|
67
|
+
template: people.mustache # under .textus/templates/
|
|
68
|
+
publish_to: [docs/people.md] # optional repo-relative byte-copy targets
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
**`
|
|
71
|
+
**`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
|
|
68
72
|
|
|
69
73
|
```yaml
|
|
70
|
-
- key:
|
|
71
|
-
path:
|
|
72
|
-
zone:
|
|
74
|
+
- key: artifacts.catalogs.skills
|
|
75
|
+
path: artifacts/catalogs/skills.md
|
|
76
|
+
zone: artifacts
|
|
73
77
|
owner: build:catalog-skills
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
compute:
|
|
79
|
+
kind: external
|
|
80
|
+
command: "rake catalog:skills" # informational; the automation invokes it
|
|
81
|
+
sources: [knowledge.projects, knowledge.network]
|
|
77
82
|
```
|
|
78
83
|
|
|
79
|
-
The build
|
|
84
|
+
The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
|
|
80
85
|
|
|
81
|
-
Full contract for both shapes is in [`../SPEC.md` §5.2 and §5.2.
|
|
86
|
+
Full contract for both shapes is in [`../SPEC.md` §5.2.1 and §5.2.2](../SPEC.md). Transforms (`compute.transform:`) and per-leaf publishing (`publish_each:`) are also covered there.
|
|
82
87
|
|
|
83
88
|
## Intake and freshness
|
|
84
89
|
|
|
85
|
-
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler;
|
|
90
|
+
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; fetch is on demand:
|
|
86
91
|
|
|
87
92
|
```sh
|
|
88
|
-
textus
|
|
89
|
-
textus
|
|
93
|
+
textus fetch feeds.notion.roadmap --as=automation
|
|
94
|
+
textus fetch stale --zone=feeds --as=automation # everything past its TTL
|
|
90
95
|
```
|
|
91
96
|
|
|
92
97
|
Freshness budgets live in the top-level `rules:` block, matched by glob:
|
|
93
98
|
|
|
94
99
|
```yaml
|
|
95
100
|
rules:
|
|
96
|
-
- match:
|
|
97
|
-
|
|
101
|
+
- match: feeds.notion.**
|
|
102
|
+
fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
|
|
98
103
|
```
|
|
99
104
|
|
|
100
|
-
A typical scheduled-
|
|
105
|
+
A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
|
|
101
106
|
|
|
102
107
|
```sh
|
|
103
|
-
textus
|
|
108
|
+
textus fetch stale --zone=intake --as=automation # in cron / CI
|
|
104
109
|
```
|
|
105
110
|
|
|
106
111
|
See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
|
|
107
112
|
|
|
108
|
-
### Read vs.
|
|
113
|
+
### Read vs. fetch
|
|
109
114
|
|
|
110
115
|
There are two read operations, and the difference matters in custom code:
|
|
111
116
|
|
|
112
|
-
| Operation | Triggers
|
|
113
|
-
|
|
117
|
+
| Operation | Triggers fetch? | Use for |
|
|
118
|
+
|-----------|-----------------|---------|
|
|
114
119
|
| `ops.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
|
|
115
|
-
| `ops.
|
|
120
|
+
| `ops.get_or_fetch` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
|
|
116
121
|
|
|
117
|
-
Build always uses the pure path; injecting
|
|
122
|
+
Build always uses the pure path; injecting fetch into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_fetch` only when you genuinely want side effects on read.
|
|
118
123
|
|
|
119
124
|
## Body content
|
|
120
125
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -8,46 +8,56 @@ module Textus
|
|
|
8
8
|
module Boot
|
|
9
9
|
PROTOCOL_ID = PROTOCOL
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"working" => "active project state; humans, AI, and scripts share this surface",
|
|
16
|
-
"intake" => "declared external inputs; script-refreshed via actions",
|
|
17
|
-
"review" => "AI proposals awaiting human accept",
|
|
18
|
-
"output" => "build-computed outputs; never hand-edited",
|
|
19
|
-
}.freeze
|
|
20
|
-
|
|
21
|
-
# Per-kind write-flow templates. Each lambda receives the user-facing role
|
|
22
|
-
# name and returns a guidance string for that role. Roles whose kind has
|
|
23
|
-
# no template (e.g. unknown future kinds) are omitted from write_flows.
|
|
11
|
+
# Per-capability write-flow templates. Each lambda receives the user-facing
|
|
12
|
+
# role name and the manifest, and returns guidance for that verb with the
|
|
13
|
+
# live zone named by kind (ADR 0034). A role holding multiple verbs gets one
|
|
14
|
+
# joined string; roles whose verbs have no template are omitted.
|
|
24
15
|
WRITE_FLOW_TEMPLATES = {
|
|
25
|
-
|
|
26
|
-
"edit files in
|
|
16
|
+
author: lambda do |name, manifest|
|
|
17
|
+
"edit files in #{zone_label(manifest, :canon, "your canon zone")}, " \
|
|
18
|
+
"then 'textus put KEY --as=#{name}'"
|
|
27
19
|
end,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"the #{authority} role runs 'textus accept' to apply"
|
|
20
|
+
keep: lambda do |name, manifest|
|
|
21
|
+
"keep durable notes in #{zone_label(manifest, :workspace, "your workspace")}: " \
|
|
22
|
+
"'textus put KEY --as=#{name}' (no accept needed)"
|
|
32
23
|
end,
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
propose: lambda do |name, manifest|
|
|
25
|
+
authority = manifest.policy.roles_with_capability("author").first || "the author-holder"
|
|
26
|
+
"propose changes by writing #{manifest.policy.queue_zone}.* entries with --as=#{name} " \
|
|
27
|
+
"and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
|
|
35
28
|
end,
|
|
36
|
-
|
|
37
|
-
"
|
|
29
|
+
fetch: lambda do |name, manifest|
|
|
30
|
+
"fetch #{zone_label(manifest, :quarantine, "quarantine")} entries with " \
|
|
31
|
+
"'textus fetch KEY --as=#{name}' (uses the entry's declared action)"
|
|
32
|
+
end,
|
|
33
|
+
build: lambda do |_name, manifest|
|
|
34
|
+
derived = zone_label(manifest, :derived, "derived")
|
|
35
|
+
"'textus build' computes #{derived} entries from projections; " \
|
|
36
|
+
"#{derived} files are never hand-edited"
|
|
38
37
|
end,
|
|
39
38
|
}.freeze
|
|
40
39
|
|
|
41
40
|
def self.write_flows_for(manifest)
|
|
42
|
-
manifest.
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
manifest.data.role_caps.each_with_object({}) do |(name, caps), acc|
|
|
42
|
+
flows = caps.filter_map do |verb|
|
|
43
|
+
tmpl = WRITE_FLOW_TEMPLATES[verb.to_sym]
|
|
44
|
+
tmpl&.call(name, manifest)
|
|
45
|
+
end
|
|
46
|
+
acc[name] = flows.join(" / ") unless flows.empty?
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# Human-readable name(s) for the live zone(s) of a given kind, or `fallback`
|
|
51
|
+
# when the manifest declares none. Lets write-flow guidance name the live
|
|
52
|
+
# zone by kind instead of a hardcoded instance name (ADR 0034).
|
|
53
|
+
def self.zone_label(manifest, kind, fallback)
|
|
54
|
+
zones = manifest.policy.zones_of_kind(kind)
|
|
55
|
+
zones.empty? ? fallback : zones.join(", ")
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
# Static, store-independent parts of the agent-facing protocol. The
|
|
49
|
-
# `role_resolution`
|
|
50
|
-
# because role names are user-configurable.
|
|
59
|
+
# `recipes` and `role_resolution` blocks are derived per-manifest in
|
|
60
|
+
# agent_protocol(...) because zone and role names are user-configurable.
|
|
51
61
|
AGENT_PROTOCOL_TEMPLATE = {
|
|
52
62
|
"envelope_shape" => {
|
|
53
63
|
"summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
|
|
@@ -59,39 +69,6 @@ module Textus
|
|
|
59
69
|
},
|
|
60
70
|
"ref" => "SPEC.md §8",
|
|
61
71
|
},
|
|
62
|
-
"recipes" => {
|
|
63
|
-
"read" => {
|
|
64
|
-
"purpose" => "find and read an entry",
|
|
65
|
-
"steps" => [
|
|
66
|
-
"textus list --zone=ZONE --prefix=PREFIX # discover keys",
|
|
67
|
-
"textus get KEY # returns envelope JSON",
|
|
68
|
-
],
|
|
69
|
-
},
|
|
70
|
-
"write" => {
|
|
71
|
-
"purpose" => "create or update an entry",
|
|
72
|
-
"steps" => [
|
|
73
|
-
"textus schema get FAMILY # learn the _meta field shape",
|
|
74
|
-
"build an envelope JSON: {_meta: {...}, body: \"...\"}",
|
|
75
|
-
"echo ENVELOPE | textus put KEY --as=ROLE --stdin",
|
|
76
|
-
],
|
|
77
|
-
},
|
|
78
|
-
"propose" => {
|
|
79
|
-
"purpose" => "agent suggests a change for human review",
|
|
80
|
-
"agent_steps" => [
|
|
81
|
-
"echo ENVELOPE | textus put review.KEY --as=agent --stdin",
|
|
82
|
-
],
|
|
83
|
-
"human_steps" => [
|
|
84
|
-
"textus accept review.KEY --as=human # promotes the proposal to its target zone",
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
"refresh" => {
|
|
88
|
-
"purpose" => "rebuild stale intake-zone caches from their declared actions",
|
|
89
|
-
"steps" => [
|
|
90
|
-
"textus freshness --zone=intake # report fresh/stale per entry",
|
|
91
|
-
"textus refresh stale --zone=intake --as=runner",
|
|
92
|
-
],
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
72
|
}.freeze
|
|
96
73
|
|
|
97
74
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
@@ -104,11 +81,11 @@ module Textus
|
|
|
104
81
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
105
82
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
106
83
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
107
|
-
{ "name" => "accept", "summary" => "apply a
|
|
84
|
+
{ "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
|
|
108
85
|
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
109
86
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
110
|
-
{ "name" => "build", "summary" => "materialize
|
|
111
|
-
{ "name" => "
|
|
87
|
+
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
|
|
88
|
+
{ "name" => "fetch", "summary" => "run an action for a quarantine entry" },
|
|
112
89
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
113
90
|
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
114
91
|
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
@@ -117,18 +94,17 @@ module Textus
|
|
|
117
94
|
{ "name" => "hook",
|
|
118
95
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
119
96
|
{ "name" => "pulse",
|
|
120
|
-
"summary" => "delta since cursor — changed entries, stale, pending
|
|
97
|
+
"summary" => "delta since cursor — changed entries, stale, pending proposals, doctor summary" },
|
|
121
98
|
].freeze
|
|
122
99
|
|
|
123
100
|
def self.agent_quickstart(manifest, audit_log)
|
|
124
|
-
|
|
125
|
-
agent_role = proposer_roles.first
|
|
101
|
+
agent_role = manifest.policy.proposer_role
|
|
126
102
|
|
|
127
|
-
writable_zones = manifest.data.
|
|
128
|
-
acc << zname if agent_role &&
|
|
103
|
+
writable_zones = manifest.data.declared_zone_kinds.keys.each_with_object([]) do |zname, acc|
|
|
104
|
+
acc << zname if agent_role && manifest.policy.zone_writers(zname).include?(agent_role)
|
|
129
105
|
end
|
|
130
106
|
|
|
131
|
-
propose_zone =
|
|
107
|
+
propose_zone = manifest.policy.propose_zone_for(agent_role)
|
|
132
108
|
|
|
133
109
|
{
|
|
134
110
|
"read_verbs" => %w[boot get list audit pulse freshness doctor],
|
|
@@ -139,12 +115,51 @@ module Textus
|
|
|
139
115
|
}
|
|
140
116
|
end
|
|
141
117
|
|
|
118
|
+
def self.recipes(manifest)
|
|
119
|
+
queue = manifest.policy.queue_zone
|
|
120
|
+
feeds = zone_label(manifest, :quarantine, "the quarantine zone")
|
|
121
|
+
{
|
|
122
|
+
"read" => {
|
|
123
|
+
"purpose" => "find and read an entry",
|
|
124
|
+
"steps" => [
|
|
125
|
+
"textus list --zone=ZONE --prefix=PREFIX # discover keys",
|
|
126
|
+
"textus get KEY # returns envelope JSON",
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
"write" => {
|
|
130
|
+
"purpose" => "create or update an entry",
|
|
131
|
+
"steps" => [
|
|
132
|
+
"textus schema get FAMILY # learn the _meta field shape",
|
|
133
|
+
"build an envelope JSON: {_meta: {...}, body: \"...\"}",
|
|
134
|
+
"echo ENVELOPE | textus put KEY --as=ROLE --stdin",
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
"propose" => {
|
|
138
|
+
"purpose" => "agent suggests a change for human review",
|
|
139
|
+
"agent_steps" => [
|
|
140
|
+
"echo ENVELOPE | textus put #{queue}.KEY --as=agent --stdin",
|
|
141
|
+
],
|
|
142
|
+
"human_steps" => [
|
|
143
|
+
"textus accept #{queue}.KEY --as=human # promotes the proposal to its target zone",
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
"fetch" => {
|
|
147
|
+
"purpose" => "rebuild stale quarantine-zone caches from their declared actions",
|
|
148
|
+
"steps" => [
|
|
149
|
+
"textus freshness --zone=#{feeds} # report fresh/stale per entry",
|
|
150
|
+
"textus fetch stale --zone=#{feeds} --as=automation",
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
142
156
|
def self.agent_protocol(manifest)
|
|
143
157
|
AGENT_PROTOCOL_TEMPLATE.merge(
|
|
158
|
+
"recipes" => recipes(manifest),
|
|
144
159
|
"role_resolution" => {
|
|
145
160
|
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
|
|
146
161
|
"default 'human'",
|
|
147
|
-
"roles" => manifest.
|
|
162
|
+
"roles" => manifest.data.role_caps.keys,
|
|
148
163
|
"ref" => "SPEC.md §5",
|
|
149
164
|
},
|
|
150
165
|
)
|
|
@@ -167,17 +182,19 @@ module Textus
|
|
|
167
182
|
end
|
|
168
183
|
|
|
169
184
|
def self.zones_for(manifest)
|
|
170
|
-
manifest.data.
|
|
171
|
-
row = { "name" => name, "writers" =>
|
|
172
|
-
|
|
173
|
-
row["
|
|
185
|
+
manifest.data.declared_zone_kinds.keys.map do |name|
|
|
186
|
+
row = { "name" => name, "writers" => manifest.policy.zone_writers(name) }
|
|
187
|
+
kind = manifest.policy.declared_kind(name)
|
|
188
|
+
row["kind"] = kind.to_s if kind
|
|
189
|
+
purpose = manifest.data.zone_descs[name]
|
|
190
|
+
row["purpose"] = purpose if purpose && !purpose.empty?
|
|
174
191
|
row
|
|
175
192
|
end
|
|
176
193
|
end
|
|
177
194
|
|
|
178
195
|
def self.entries_for(manifest)
|
|
179
196
|
manifest.data.entries.map do |e|
|
|
180
|
-
derived = manifest.policy.
|
|
197
|
+
derived = manifest.policy.derived_zone?(e.zone)
|
|
181
198
|
{
|
|
182
199
|
"key" => e.key,
|
|
183
200
|
"zone" => e.zone,
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Group
|
|
4
|
-
class
|
|
5
|
-
command_name "
|
|
4
|
+
class Fetch < Group
|
|
5
|
+
command_name "fetch"
|
|
6
6
|
|
|
7
7
|
def parse(argv)
|
|
8
8
|
if argv.first == "stale"
|
|
9
9
|
argv.shift
|
|
10
|
-
@sub_klass = Verb::
|
|
10
|
+
@sub_klass = Verb::FetchStale
|
|
11
11
|
else
|
|
12
|
-
@sub_klass = Verb::
|
|
12
|
+
@sub_klass = Verb::Fetch
|
|
13
13
|
end
|
|
14
14
|
@sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
|
|
15
15
|
@sub.parse(argv)
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
Textus::Ports::BuildLock.with(root: store.root) do
|
|
11
|
-
role = store.manifest.policy.
|
|
11
|
+
role = store.manifest.policy.roles_with_capability("build").first || "automation"
|
|
12
12
|
ops = store.as(role)
|
|
13
13
|
result = ops.publish(prefix: prefix)
|
|
14
14
|
emit(result)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Fetch < Verb
|
|
5
|
+
option :as_flag, "--as=ROLE"
|
|
6
|
+
|
|
7
|
+
def call(store)
|
|
8
|
+
key = positional.shift or raise UsageError.new("fetch requires a key")
|
|
9
|
+
emit(session_for(store).fetch(key).to_h_for_wire)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class
|
|
4
|
+
class FetchStale < Verb
|
|
5
5
|
command_name "stale"
|
|
6
|
-
parent_group Group::
|
|
6
|
+
parent_group Group::Fetch
|
|
7
7
|
|
|
8
8
|
option :prefix, "--prefix=KEY"
|
|
9
9
|
option :zone, "--zone=Z"
|
|
10
10
|
option :as_flag, "--as=ROLE"
|
|
11
11
|
|
|
12
12
|
def call(store)
|
|
13
|
-
result = session_for(store).
|
|
13
|
+
result = session_for(store).fetch_all(prefix: prefix, zone: zone)
|
|
14
14
|
emit(result)
|
|
15
15
|
result["ok"] ? 0 : 1
|
|
16
16
|
end
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
11
|
-
result = session_for(store).
|
|
11
|
+
result = session_for(store).get_or_fetch(key)
|
|
12
12
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
|
|
13
13
|
|
|
14
14
|
emit(result.to_h_for_wire)
|
|
@@ -29,12 +29,8 @@ module Textus
|
|
|
29
29
|
Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
30
30
|
|
|
31
31
|
begin
|
|
32
|
-
|
|
33
|
-
store.rpc
|
|
34
|
-
end
|
|
35
|
-
rescue Timeout::Error
|
|
36
|
-
raise UsageError.new(
|
|
37
|
-
"hook run '#{name}' exceeded #{Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
|
|
32
|
+
Textus::Write::IntakeFetch.invoke(
|
|
33
|
+
rpc: store.rpc, handler: name, config: {}, args: args, label: "hook run",
|
|
38
34
|
)
|
|
39
35
|
rescue Textus::Error
|
|
40
36
|
raise
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -17,24 +17,15 @@ module Textus
|
|
|
17
17
|
raw = @stdin.read
|
|
18
18
|
payload =
|
|
19
19
|
if fetch_name
|
|
20
|
-
result =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
caps: nil,
|
|
25
|
-
config: { "bytes" => raw },
|
|
26
|
-
args: {})
|
|
27
|
-
end
|
|
28
|
-
rescue Timeout::Error
|
|
29
|
-
raise UsageError.new(
|
|
30
|
-
"fetch '#{fetch_name}' exceeded #{Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
|
|
31
|
-
)
|
|
32
|
-
end
|
|
20
|
+
result = Textus::Write::IntakeFetch.invoke(
|
|
21
|
+
rpc: store.rpc, handler: fetch_name,
|
|
22
|
+
config: { "bytes" => raw }, args: {}, label: "fetch"
|
|
23
|
+
)
|
|
33
24
|
basename = key.split(".").last
|
|
34
25
|
{
|
|
35
26
|
"_meta" => {
|
|
36
27
|
"name" => basename,
|
|
37
|
-
"
|
|
28
|
+
"last_fetched_at" => Time.now.utc.iso8601,
|
|
38
29
|
"fetched_with" => fetch_name,
|
|
39
30
|
}.merge(result[:_meta] || result["_meta"] || {}),
|
|
40
31
|
"body" => result[:body] || result["body"] || "",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Retain < Verb
|
|
5
|
+
command_name "retain"
|
|
6
|
+
|
|
7
|
+
option :prefix, "--prefix=KEY"
|
|
8
|
+
option :zone, "--zone=Z"
|
|
9
|
+
option :as_flag, "--as=ROLE"
|
|
10
|
+
|
|
11
|
+
def call(store)
|
|
12
|
+
result = session_for(store).retention_sweep(prefix: prefix, zone: zone)
|
|
13
|
+
emit(result)
|
|
14
|
+
result["ok"] ? 0 : 1
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -8,17 +8,17 @@ module Textus
|
|
|
8
8
|
def call(store)
|
|
9
9
|
policies = store.manifest.rules.blocks.map do |b|
|
|
10
10
|
row = { "match" => b.match }
|
|
11
|
-
if b.
|
|
12
|
-
row["
|
|
13
|
-
"ttl_seconds" => b.
|
|
14
|
-
"on_stale" => b.
|
|
15
|
-
"sync_budget_ms" => b.
|
|
16
|
-
"fetch_timeout_seconds" => b.
|
|
11
|
+
if b.fetch
|
|
12
|
+
row["fetch"] = {
|
|
13
|
+
"ttl_seconds" => b.fetch.ttl_seconds,
|
|
14
|
+
"on_stale" => b.fetch.on_stale,
|
|
15
|
+
"sync_budget_ms" => b.fetch.sync_budget_ms,
|
|
16
|
+
"fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
|
|
17
17
|
}
|
|
18
18
|
end
|
|
19
19
|
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
20
|
-
row["
|
|
21
|
-
row["retention"] = b.retention if b.retention
|
|
20
|
+
row["guard"] = b.guard if b.guard
|
|
21
|
+
row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
|
|
22
22
|
row
|
|
23
23
|
end
|
|
24
24
|
emit({ "verb" => "policy_list", "policies" => policies })
|
data/lib/textus/cli.rb
CHANGED
|
@@ -27,22 +27,25 @@ module Textus
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def run(argv)
|
|
30
|
-
|
|
30
|
+
# Define --version/--help ourselves so OptionParser doesn't intercept them
|
|
31
|
+
# with its built-in handlers (which print "version unknown" and a bare usage
|
|
32
|
+
# line, then exit before we ever reach the verb dispatch below).
|
|
33
|
+
show_version = false
|
|
34
|
+
show_help = false
|
|
35
|
+
OptionParser.new do |o|
|
|
36
|
+
o.on("--root=PATH") { |v| @root_arg = v }
|
|
37
|
+
o.on("--version", "-v") { show_version = true }
|
|
38
|
+
o.on("--help", "-h") { show_help = true }
|
|
39
|
+
end.order!(argv)
|
|
40
|
+
|
|
41
|
+
return @stdout.puts(VERSION) || 0 if show_version
|
|
42
|
+
return print_help || 0 if show_help
|
|
43
|
+
|
|
31
44
|
verb = argv.shift
|
|
32
45
|
raise UsageError.new("missing verb") if verb.nil?
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
when "--version", "-v" then @stdout.puts(VERSION)
|
|
37
|
-
0
|
|
38
|
-
when "--help", "-h" then print_help
|
|
39
|
-
0
|
|
40
|
-
else
|
|
41
|
-
klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
|
|
42
|
-
dispatch(klass, argv)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
coerce_exit_code(result)
|
|
47
|
+
klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
|
|
48
|
+
coerce_exit_code(dispatch(klass, argv))
|
|
46
49
|
rescue Textus::Error => e
|
|
47
50
|
emit_error(e)
|
|
48
51
|
end
|
|
@@ -87,16 +90,16 @@ module Textus
|
|
|
87
90
|
textus get KEY
|
|
88
91
|
textus put KEY --stdin [--fetch=NAME] --as=ROLE
|
|
89
92
|
textus freshness [--prefix=KEY] [--zone=Z]
|
|
90
|
-
textus
|
|
91
|
-
textus
|
|
93
|
+
textus fetch KEY
|
|
94
|
+
textus fetch stale [--prefix=KEY] [--zone=Z]
|
|
92
95
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
93
96
|
textus blame KEY [--limit=N]
|
|
94
97
|
textus doctor
|
|
95
98
|
textus boot
|
|
96
99
|
|
|
97
|
-
textus key {mv,uid
|
|
98
|
-
textus rule {list
|
|
99
|
-
textus schema {
|
|
100
|
+
textus key {delete,mv,uid}
|
|
101
|
+
textus rule {explain,lint,list}
|
|
102
|
+
textus schema {diff,init,migrate,show}
|
|
100
103
|
textus hook {list,run}
|
|
101
104
|
HELP
|
|
102
105
|
end
|
data/lib/textus/container.rb
CHANGED
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
# ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
|
|
4
4
|
Container = Data.define(
|
|
5
5
|
:manifest, :file_store, :schemas, :root,
|
|
6
|
-
:audit_log, :events, :rpc
|
|
6
|
+
:audit_log, :events, :rpc
|
|
7
7
|
)
|
|
8
8
|
|
|
9
9
|
class Container
|
|
@@ -16,7 +16,6 @@ module Textus
|
|
|
16
16
|
audit_log: store.audit_log,
|
|
17
17
|
events: store.events,
|
|
18
18
|
rpc: store.rpc,
|
|
19
|
-
authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest),
|
|
20
19
|
)
|
|
21
20
|
end
|
|
22
21
|
end
|