textus 0.26.0 → 0.30.0
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 +118 -68
- data/CHANGELOG.md +132 -0
- data/README.md +61 -19
- data/SPEC.md +107 -46
- data/docs/conventions.md +4 -4
- data/lib/textus/boot.rb +18 -12
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/verb/audit.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- 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 +1 -1
- data/lib/textus/cli/verb.rb +6 -6
- data/lib/textus/cli.rb +19 -23
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +57 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +10 -8
- data/lib/textus/doctor/check.rb +15 -5
- data/lib/textus/doctor.rb +7 -7
- data/lib/textus/domain/authorizer.rb +2 -2
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/policy/refresh.rb +1 -15
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +18 -10
- data/lib/textus/domain/staleness.rb +3 -3
- data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
- data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/event_bus.rb +8 -20
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +7 -6
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +9 -4
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +6 -6
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest/policy.rb +34 -7
- data/lib/textus/manifest/rules.rb +10 -1
- data/lib/textus/manifest/schema.rb +54 -4
- data/lib/textus/manifest.rb +4 -8
- data/lib/textus/mcp/server.rb +2 -11
- data/lib/textus/mcp/session.rb +13 -20
- data/lib/textus/mcp/tools.rb +2 -2
- data/lib/textus/mcp.rb +1 -1
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +42 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +50 -0
- data/lib/textus/schema/tools.rb +3 -3
- data/lib/textus/store.rb +16 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +40 -0
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +113 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +45 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +124 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus/write/retention_sweep.rb +55 -0
- data/lib/textus.rb +1 -2
- metadata +62 -50
- data/lib/textus/application/caps.rb +0 -49
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
- data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
- data/lib/textus/application/maintenance/migrate.rb +0 -59
- data/lib/textus/application/maintenance/rule_lint.rb +0 -65
- data/lib/textus/application/maintenance/zone_mv.rb +0 -60
- data/lib/textus/application/maintenance.rb +0 -17
- data/lib/textus/application/projection.rb +0 -93
- data/lib/textus/application/read/audit.rb +0 -106
- data/lib/textus/application/read/blame.rb +0 -91
- data/lib/textus/application/read/deps.rb +0 -34
- data/lib/textus/application/read/freshness.rb +0 -110
- data/lib/textus/application/read/get.rb +0 -75
- data/lib/textus/application/read/get_or_refresh.rb +0 -63
- data/lib/textus/application/read/list.rb +0 -25
- data/lib/textus/application/read/policy_explain.rb +0 -47
- data/lib/textus/application/read/published.rb +0 -25
- data/lib/textus/application/read/pulse.rb +0 -101
- data/lib/textus/application/read/rdeps.rb +0 -35
- data/lib/textus/application/read/schema_envelope.rb +0 -26
- data/lib/textus/application/read/stale.rb +0 -23
- data/lib/textus/application/read/uid.rb +0 -30
- data/lib/textus/application/read/validate_all.rb +0 -32
- data/lib/textus/application/read/validator.rb +0 -86
- data/lib/textus/application/read/where.rb +0 -26
- data/lib/textus/application/use_case.rb +0 -22
- data/lib/textus/application/write/accept.rb +0 -102
- data/lib/textus/application/write/authority_gate.rb +0 -26
- data/lib/textus/application/write/delete.rb +0 -45
- data/lib/textus/application/write/materializer.rb +0 -49
- data/lib/textus/application/write/mv.rb +0 -118
- data/lib/textus/application/write/publish.rb +0 -96
- data/lib/textus/application/write/put.rb +0 -49
- data/lib/textus/application/write/refresh_all.rb +0 -63
- data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
- data/lib/textus/application/write/refresh_worker.rb +0 -134
- data/lib/textus/application/write/reject.rb +0 -62
- data/lib/textus/session.rb +0 -84
data/SPEC.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
## 1. What textus is
|
|
12
12
|
|
|
13
|
-
A storage convention and JSON wire protocol
|
|
13
|
+
A storage convention and JSON wire protocol for humans, agents, and runners to read and write structured project memory **deterministically**. It provides addressable dotted keys, schema validation, role-based write gates, declarative compute, and copy-based publish targets.
|
|
14
14
|
|
|
15
15
|
The storage lives in a `.textus/` directory at the project root. Each entry is a Markdown file with YAML frontmatter. A manifest binds dotted keys to subtrees and declares which roles may write to each zone. Schemas (also YAML) define what frontmatter shape each entry must have. Derived entries are computed from other entries via pure projections and a vendored Mustache template engine, then optionally published to repo-relative paths as byte-for-byte file copies. The CLI surface (`textus get/put/list/where/schema/build/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
|
|
16
16
|
|
|
@@ -68,10 +68,10 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
68
68
|
.textus/
|
|
69
69
|
manifest.yaml # internal: key → subtree mapping + zones declarations
|
|
70
70
|
audit.log # internal, append-only NDJSON log of every successful write
|
|
71
|
-
role # internal, role token (one line, e.g. "human")
|
|
72
71
|
schemas/ # internal: YAML schema files
|
|
73
72
|
templates/ # internal: Mustache templates referenced by derived entries
|
|
74
|
-
|
|
73
|
+
hooks/ # internal: one Ruby file per hook
|
|
74
|
+
sentinels/ # internal: bookkeeping for byte-copied publish targets (see §5.3)
|
|
75
75
|
zones/ # ALL user content lives here
|
|
76
76
|
identity/ # zone: identity (human-only)
|
|
77
77
|
working/ # zone: working (human, agent, runner)
|
|
@@ -80,7 +80,7 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
80
80
|
output/ # zone: output (builder only — computed outputs)
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
Textus internals (`manifest.yaml`, `audit.log`, `
|
|
83
|
+
Textus internals (`manifest.yaml`, `audit.log`, `schemas/`, `templates/`, `hooks/`, `sentinels/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
|
|
84
84
|
|
|
85
85
|
Zone directories under `zones/` are conventional; their write semantics are declared in the manifest, not the directory name.
|
|
86
86
|
|
|
@@ -106,14 +106,19 @@ version: textus/3
|
|
|
106
106
|
|
|
107
107
|
zones:
|
|
108
108
|
- name: identity
|
|
109
|
+
kind: origin
|
|
109
110
|
write_policy: [human]
|
|
110
111
|
- name: working
|
|
112
|
+
kind: origin
|
|
111
113
|
write_policy: [human, agent, runner]
|
|
112
114
|
- name: intake
|
|
115
|
+
kind: quarantine
|
|
113
116
|
write_policy: [runner]
|
|
114
117
|
- name: review
|
|
118
|
+
kind: queue
|
|
115
119
|
write_policy: [agent, human]
|
|
116
120
|
- name: output
|
|
121
|
+
kind: derived
|
|
117
122
|
write_policy: [builder]
|
|
118
123
|
|
|
119
124
|
entries:
|
|
@@ -138,6 +143,10 @@ entries:
|
|
|
138
143
|
rules:
|
|
139
144
|
- match: intake.**
|
|
140
145
|
refresh: { ttl: 6h, on_stale: warn }
|
|
146
|
+
|
|
147
|
+
audit:
|
|
148
|
+
max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
|
|
149
|
+
keep: 5 # rotated files to retain (default: 5)
|
|
141
150
|
```
|
|
142
151
|
|
|
143
152
|
Zone names are conventional — the manifest is the source of truth for write permissions; rename freely.
|
|
@@ -197,16 +206,27 @@ A leaf at `working.skills.writing.voice-writer` (authored at `.textus/zones/work
|
|
|
197
206
|
|
|
198
207
|
Each zone declares which **roles** may write to it via `write_policy:` in the manifest. An optional `read_policy:` (default `[all]`) gates reads. Writes are gated; reads are unrestricted by default.
|
|
199
208
|
|
|
200
|
-
| Zone | `write_policy` | Use case |
|
|
201
|
-
|
|
202
|
-
| `identity` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
|
|
203
|
-
| `working` | `[human, agent, runner]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
|
|
204
|
-
| `intake` | `[runner]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or agents directly. |
|
|
205
|
-
| `review` | `[agent, human]` | Agent-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
|
|
206
|
-
| `output` | `[builder]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
|
|
209
|
+
| Zone | `kind` | `write_policy` | Use case |
|
|
210
|
+
|---|---|---|---|
|
|
211
|
+
| `identity` | `origin` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
|
|
212
|
+
| `working` | `origin` | `[human, agent, runner]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
|
|
213
|
+
| `intake` | `quarantine` | `[runner]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or agents directly. |
|
|
214
|
+
| `review` | `queue` | `[agent, human]` | Agent-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
|
|
215
|
+
| `output` | `derived` | `[builder]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
|
|
207
216
|
|
|
208
217
|
A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role is not in the target zone's `write_policy` list, the write returns `write_forbidden`.
|
|
209
218
|
|
|
219
|
+
Every zone MUST declare a `kind:` describing its role in the data-flow graph.
|
|
220
|
+
The vocabulary is closed: `origin` (authored truth), `quarantine` (external
|
|
221
|
+
bytes pending validation), `queue` (proposals awaiting promotion), `derived`
|
|
222
|
+
(computed from other zones). A manifest MUST declare at most one `queue` zone,
|
|
223
|
+
and a zone's `kind:` MUST agree with its writers (`derived` ⇒ a `generator`
|
|
224
|
+
writer, `queue` ⇒ a `proposer`, `quarantine` ⇒ a `runner`; `origin` is
|
|
225
|
+
unconstrained). Coordination is keyed off the declared kind: a zone is derived
|
|
226
|
+
only if it declares `kind: derived`, and proposals route to the declared
|
|
227
|
+
`queue` zone — there is no name-based fallback. A manifest with a kind-less
|
|
228
|
+
zone is rejected at load.
|
|
229
|
+
|
|
210
230
|
### 5.1 Role resolution
|
|
211
231
|
|
|
212
232
|
The effective role for any CLI invocation is resolved in this order; the first match wins:
|
|
@@ -397,7 +417,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
|
|
|
397
417
|
|
|
398
418
|
**Refresh paths.** Two are supported:
|
|
399
419
|
|
|
400
|
-
1. **In-process** — `textus refresh KEY --as=runner` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(
|
|
420
|
+
1. **In-process** — `textus refresh KEY --as=runner` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under role `runner`.
|
|
401
421
|
2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --output=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=runner --stdin`. The CLI verb `textus refresh stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
|
|
402
422
|
|
|
403
423
|
Both paths share the same role gate, audit-log entry, and `:entry_refreshed` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
|
|
@@ -439,6 +459,35 @@ Schema (one JSON object per line, no interior whitespace):
|
|
|
439
459
|
|
|
440
460
|
For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
|
|
441
461
|
|
|
462
|
+
**Rotation.** After every successful append the implementation checks whether `audit.log` exceeds `max_size` bytes (checked inside the held `flock`, so the check sees the post-write size). If it does, the active log is rotated:
|
|
463
|
+
|
|
464
|
+
1. The seq range (`min_seq`, `max_seq`) of the active log is scanned, and a JSON sidecar (`audit.log.1.meta.json`) is written with those values plus a `rotated_at` ISO 8601 timestamp.
|
|
465
|
+
2. Existing rotated files are shifted: `audit.log.(N)` → `audit.log.(N+1)` for N = `keep-1` down to 1 (with their `.meta.json` sidecars).
|
|
466
|
+
3. `audit.log` is renamed to `audit.log.1`.
|
|
467
|
+
4. The file that would be shifted to `audit.log.(keep+1)` — i.e., `audit.log.keep` and its sidecar — is deleted before the shift.
|
|
468
|
+
5. The next append creates a fresh `audit.log` via `O_CREAT`. Seq numbering continues from the previous maximum; there is no reset.
|
|
469
|
+
|
|
470
|
+
Rotation is triggered by **byte size only** — there is no row-count or time-based trigger.
|
|
471
|
+
|
|
472
|
+
**Rotation knobs** (configured via the optional `audit:` block in `manifest.yaml`):
|
|
473
|
+
|
|
474
|
+
| Key | Default | Meaning |
|
|
475
|
+
|------------|--------------|---------|
|
|
476
|
+
| `max_size` | `10485760` | Maximum size of `audit.log` in bytes (10 MiB) before rotation is triggered. |
|
|
477
|
+
| `keep` | `5` | Number of rotated files retained on disk. When this limit is exceeded the oldest rotated file and its sidecar are deleted. |
|
|
478
|
+
|
|
479
|
+
Both keys are optional. Omitting `audit:` entirely uses the defaults above.
|
|
480
|
+
|
|
481
|
+
**`CursorExpired`.** When `audit --seq-since=N` or `pulse --since=N` is called with a cursor `N`, the implementation checks whether `N` is below the oldest sequence number still available on disk (`min_available_seq`, derived from the oldest retained rotated file's sidecar). The condition that raises `CursorExpired` is:
|
|
482
|
+
|
|
483
|
+
```
|
|
484
|
+
N < min_available_seq - 1
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The error includes `requested` (the supplied cursor value) and `min_available` (the oldest seq still on disk).
|
|
488
|
+
|
|
489
|
+
**Recommended caller behavior on `CursorExpired`.** Call `textus boot` (without `--since`) to obtain a fresh `latest_seq` from the current audit log state, then resume `pulse` calls using that new cursor. Do not attempt to replay from an expired cursor — the intervening rows are gone.
|
|
490
|
+
|
|
442
491
|
### 5.7 Security bounds
|
|
443
492
|
|
|
444
493
|
textus enforces fixed bounds to keep behavior predictable under hostile or buggy input:
|
|
@@ -478,7 +527,7 @@ evolution:
|
|
|
478
527
|
|
|
479
528
|
**Defaults:** when `fields:` and `evolution:` are absent, `schema.maintained_by(field)` returns `nil` for every field and `schema.evolution` returns `{}`.
|
|
480
529
|
|
|
481
|
-
**Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner.
|
|
530
|
+
**Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. Humans override agent-maintained fields by design: schema field ownership (`maintained_by:`) makes the boundary explicit, not implicit. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
|
|
482
531
|
|
|
483
532
|
### 5.9 Row transforms
|
|
484
533
|
|
|
@@ -495,11 +544,11 @@ The subdirectory layout under `hooks/` is organizational only; the registered ev
|
|
|
495
544
|
```ruby
|
|
496
545
|
# Canonical form — works for every event:
|
|
497
546
|
Textus.hook do |reg|
|
|
498
|
-
reg.on(:resolve_intake, :my_source) { |config:, args:, **| … }
|
|
499
|
-
reg.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
|
|
500
|
-
reg.on(:validate, :storage_writable) { |
|
|
501
|
-
reg.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| … }
|
|
502
|
-
reg.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
|
|
547
|
+
reg.on(:resolve_intake, :my_source) { |caps:, config:, args:, **| … }
|
|
548
|
+
reg.on(:transform_rows, :rank_by_recency) { |caps:, rows:, **| … }
|
|
549
|
+
reg.on(:validate, :storage_writable) { |caps:| … }
|
|
550
|
+
reg.on(:entry_put, :audit, keys: ["working.*"]) { |ctx:, key:, envelope:, **| … }
|
|
551
|
+
reg.on(:file_published, :git_add, keys: ["derived.*"]) { |ctx:, target:, **| `git add #{target.shellescape}` }
|
|
503
552
|
end
|
|
504
553
|
```
|
|
505
554
|
|
|
@@ -509,21 +558,21 @@ end
|
|
|
509
558
|
|
|
510
559
|
| Event | Mode | Args | Return | Failure |
|
|
511
560
|
|-------------------------|---------|-----------------------------------------------------------|-----------------------|---------------|
|
|
512
|
-
| `:resolve_intake` | rpc |
|
|
513
|
-
| `:transform_rows` | rpc |
|
|
514
|
-
| `:validate` | rpc |
|
|
515
|
-
| `:entry_put` | pubsub |
|
|
516
|
-
| `:entry_deleted` | pubsub |
|
|
517
|
-
| `:entry_refreshed` | pubsub |
|
|
518
|
-
| `:build_completed` | pubsub |
|
|
519
|
-
| `:proposal_accepted` | pubsub |
|
|
520
|
-
| `:file_published` | pubsub |
|
|
521
|
-
| `:entry_renamed` | pubsub |
|
|
522
|
-
| `:proposal_rejected` | pubsub |
|
|
523
|
-
| `:store_loaded` | pubsub |
|
|
524
|
-
| `:refresh_started` | pubsub |
|
|
525
|
-
| `:refresh_failed` | pubsub |
|
|
526
|
-
| `:refresh_backgrounded` | pubsub |
|
|
561
|
+
| `:resolve_intake` | rpc | caps:, config:, args: | {_meta:, body:} | aborts op |
|
|
562
|
+
| `:transform_rows` | rpc | caps:, rows:, config: | rows array | aborts op |
|
|
563
|
+
| `:validate` | rpc | caps: | issues array | aborts doctor |
|
|
564
|
+
| `:entry_put` | pubsub | ctx:, key:, envelope: | (discarded) | logged |
|
|
565
|
+
| `:entry_deleted` | pubsub | ctx:, key: | (discarded) | logged |
|
|
566
|
+
| `:entry_refreshed` | pubsub | ctx:, key:, envelope:, change: | (discarded) | logged |
|
|
567
|
+
| `:build_completed` | pubsub | ctx:, key:, envelope:, sources: | (discarded) | logged |
|
|
568
|
+
| `:proposal_accepted` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
|
|
569
|
+
| `:file_published` | pubsub | ctx:, key:, envelope:, source:, target: | (discarded) | logged |
|
|
570
|
+
| `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
|
|
571
|
+
| `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
|
|
572
|
+
| `:store_loaded` | pubsub | ctx: | (discarded) | logged |
|
|
573
|
+
| `:refresh_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
|
|
574
|
+
| `:refresh_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
|
|
575
|
+
| `:refresh_backgrounded` | pubsub | ctx:, key:, started_at:, budget_ms: | (discarded) | logged |
|
|
527
576
|
|
|
528
577
|
The three `:refresh_*` lifecycle events report the progress and failures of background (timed_sync) refreshes.
|
|
529
578
|
|
|
@@ -533,14 +582,19 @@ The three `:refresh_*` lifecycle events report the progress and failures of back
|
|
|
533
582
|
|
|
534
583
|
**`:refresh_backgrounded`** fires when a `timed_sync` refresh exceeds its budget and is handed off to a background thread. `started_at:` is an ISO-8601 UTC string; `budget_ms:` is the configured deadline as an integer.
|
|
535
584
|
|
|
536
|
-
**Signature invariant** —
|
|
585
|
+
**Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
|
|
586
|
+
|
|
587
|
+
- **RPC hooks** (`rpc` mode) receive `caps:` — a `Textus::Container`. Event-specific kwargs (`config:`, `args:`, `rows:`) follow in the stable order shown in the table above.
|
|
588
|
+
- **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
|
|
589
|
+
|
|
590
|
+
Declaring `store:` instead of `caps:` in an RPC callable will pass registration but raise `UsageError` at call time (`Hooks::RpcRegistry#invoke` rejects `store:` — there is no shim).
|
|
591
|
+
|
|
592
|
+
The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry.
|
|
537
593
|
|
|
538
594
|
**RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`intake.handler: NAME`, `compute.transform: NAME`). Failure or timeout aborts the calling operation.
|
|
539
595
|
|
|
540
596
|
**Pub-sub mode** — zero or more handlers per event. All matching handlers fire. The `keys:` option restricts a handler to keys matching one of the given globs (`File.fnmatch?` rules). Absence of `keys:` fires on every event of that type. Handler failures and 2s timeouts are logged to `audit.log` as `event_error` rows; they NEVER abort the triggering operation.
|
|
541
597
|
|
|
542
|
-
The `store:` argument is always a read-only store proxy. Write attempts raise `UsageError`.
|
|
543
|
-
|
|
544
598
|
Each handler runs under `Timeout.timeout(2)`.
|
|
545
599
|
|
|
546
600
|
### 5.11 Rules
|
|
@@ -568,7 +622,15 @@ rules:
|
|
|
568
622
|
| `refresh` | `{ ttl, on_stale, sync_budget_ms }` | Freshness budget for intake entries. `on_stale` is `warn` (default), `sync`, or `timed_sync`. |
|
|
569
623
|
| `intake_handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
|
|
570
624
|
| `promotion` | `{ requires: [...] }` | Predicates a `review` entry must satisfy before `textus accept` will promote it. Built-in predicates: `schema_valid` (entry passes schema validation) and `human_accept` (the accepting role must be `human`). Additional predicates may be registered via `:validate` hooks. Enforced — `textus accept` refuses if any predicate fails. |
|
|
571
|
-
| `retention` |
|
|
625
|
+
| `retention` | `{ expire_after:, archive_after: }` | Pruning policy for matched leaves. Duration strings: `30s`, `90m`, `12h`, `30d`, or bare integer seconds. `textus retain --as=ROLE` sweeps matched leaves: `expire_after` is checked first, so a leaf older than `expire_after` is deleted (and audited); otherwise a leaf older than `archive_after` is copied to `<store>/archive/<relative-path>` and then deleted. Age is measured from the leaf file's modification time. The `--as` role must be allowed to write the matched zone. |
|
|
626
|
+
|
|
627
|
+
Both retention windows are optional, and `expire_after` is evaluated before
|
|
628
|
+
`archive_after` — so when both apply, a leaf past the (longer) `expire_after`
|
|
629
|
+
window is deleted rather than archived. The usual configuration is therefore
|
|
630
|
+
`archive_after < expire_after` (archive a leaf, then delete it once older).
|
|
631
|
+
`textus retain --as=ROLE` runs the sweep; `--prefix` and `--zone` narrow it, and
|
|
632
|
+
any leaf whose zone the `--as` role cannot write is reported as a failure rather
|
|
633
|
+
than aborting the run.
|
|
572
634
|
|
|
573
635
|
**Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
|
|
574
636
|
|
|
@@ -899,14 +961,13 @@ Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:res
|
|
|
899
961
|
|
|
900
962
|
Both read and write paths flow through the application layer:
|
|
901
963
|
|
|
902
|
-
- **Reads** flow through `
|
|
903
|
-
- **Writes** flow through `
|
|
904
|
-
- `
|
|
905
|
-
- `Textus::
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
single `UseCase.register(...)` line.
|
|
964
|
+
- **Reads** flow through `Textus::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `container:` and a `call:`.
|
|
965
|
+
- **Writes** flow through `Textus::Write::{Put,Delete,Mv,Accept,Reject,Publish,RefreshWorker}`. Permission checks happen at the use-case layer (via `Domain::Authorizer#authorize_write!`); the audit-append invariant lives in `Textus::Envelope::IO::Writer`.
|
|
966
|
+
- `Textus::Call` is the slim per-invocation record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from `Textus::Container`, not from the Call.
|
|
967
|
+
- `Textus::Store` is the composition root and verb dispatcher. CLI verbs and the
|
|
968
|
+
MCP gate call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`).
|
|
969
|
+
Verbs are looked up in the static `Textus::Dispatcher::VERBS` table; adding a
|
|
970
|
+
use case is a single entry in `VERBS` plus the class.
|
|
910
971
|
|
|
911
972
|
See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
|
|
912
973
|
|
|
@@ -914,7 +975,7 @@ See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
|
|
|
914
975
|
|
|
915
976
|
- **Locking on `put`:** the reference impl uses sha256 etags. Should the spec also define a file-lock fallback for systems where read-before-write is racy?
|
|
916
977
|
- **Schema imports:** can one schema reference another (`type: $ref: person`)?
|
|
917
|
-
- **Internationalization:** non-ASCII in keys? Spec currently restricts segments to `[a-z0-
|
|
978
|
+
- **Internationalization:** non-ASCII in keys? Spec currently restricts segments to `[a-z0-9][a-z0-9-]*`. Revisit if community wants Unicode.
|
|
918
979
|
- **Generated content in `derived/`:** the spec says `schema: null` is allowed, but should there be a separate marker (`generated: true`) for clarity?
|
|
919
980
|
|
|
920
981
|
## 15. Implementation checklist
|
data/docs/conventions.md
CHANGED
|
@@ -128,11 +128,11 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
|
|
|
128
128
|
|
|
129
129
|
## Application layering
|
|
130
130
|
|
|
131
|
-
The application layer is organised around three
|
|
131
|
+
The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0022](architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](architecture/decisions/0023-uniform-use-case-shape.md).
|
|
132
132
|
|
|
133
|
-
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
|
|
134
|
-
- **
|
|
135
|
-
- **Write path is split**: `
|
|
133
|
+
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
|
|
134
|
+
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
|
|
135
|
+
- **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
|
|
136
136
|
|
|
137
137
|
The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
|
|
138
138
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -120,7 +120,7 @@ module Textus
|
|
|
120
120
|
"summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
|
|
121
121
|
].freeze
|
|
122
122
|
|
|
123
|
-
def self.agent_quickstart(manifest,
|
|
123
|
+
def self.agent_quickstart(manifest, audit_log)
|
|
124
124
|
proposer_roles = manifest.policy.roles_with_kind(:proposer)
|
|
125
125
|
agent_role = proposer_roles.first
|
|
126
126
|
|
|
@@ -128,14 +128,14 @@ module Textus
|
|
|
128
128
|
acc << zname if agent_role && writers.include?(agent_role)
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
propose_zone =
|
|
131
|
+
propose_zone = manifest.policy.propose_zone_for(agent_role)
|
|
132
132
|
|
|
133
133
|
{
|
|
134
134
|
"read_verbs" => %w[boot get list audit pulse freshness doctor],
|
|
135
135
|
"write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
|
|
136
136
|
"writable_zones" => writable_zones,
|
|
137
137
|
"propose_zone" => propose_zone,
|
|
138
|
-
"latest_seq" =>
|
|
138
|
+
"latest_seq" => audit_log.latest_seq,
|
|
139
139
|
}
|
|
140
140
|
end
|
|
141
141
|
|
|
@@ -150,18 +150,18 @@ module Textus
|
|
|
150
150
|
)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
def self.
|
|
154
|
-
manifest =
|
|
153
|
+
def self.build(container:)
|
|
154
|
+
manifest = container.manifest
|
|
155
155
|
{
|
|
156
156
|
"protocol" => PROTOCOL_ID,
|
|
157
|
-
"store_root" =>
|
|
157
|
+
"store_root" => container.root,
|
|
158
158
|
"zones" => zones_for(manifest),
|
|
159
159
|
"entries" => entries_for(manifest),
|
|
160
|
-
"hooks" =>
|
|
160
|
+
"hooks" => hooks_for_container(container),
|
|
161
161
|
"write_flows" => write_flows_for(manifest),
|
|
162
162
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
163
163
|
"agent_protocol" => agent_protocol(manifest),
|
|
164
|
-
"agent_quickstart" => agent_quickstart(manifest,
|
|
164
|
+
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
165
165
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
166
166
|
}
|
|
167
167
|
end
|
|
@@ -169,6 +169,8 @@ module Textus
|
|
|
169
169
|
def self.zones_for(manifest)
|
|
170
170
|
manifest.data.zones.map do |name, writers|
|
|
171
171
|
row = { "name" => name, "writers" => Array(writers) }
|
|
172
|
+
kind = manifest.policy.declared_kind(name)
|
|
173
|
+
row["kind"] = kind.to_s if kind
|
|
172
174
|
purpose = ZONE_PURPOSES[name]
|
|
173
175
|
row["purpose"] = purpose if purpose
|
|
174
176
|
row
|
|
@@ -177,7 +179,7 @@ module Textus
|
|
|
177
179
|
|
|
178
180
|
def self.entries_for(manifest)
|
|
179
181
|
manifest.data.entries.map do |e|
|
|
180
|
-
derived = manifest.policy.
|
|
182
|
+
derived = manifest.policy.derived_zone?(e.zone)
|
|
181
183
|
{
|
|
182
184
|
"key" => e.key,
|
|
183
185
|
"zone" => e.zone,
|
|
@@ -193,13 +195,17 @@ module Textus
|
|
|
193
195
|
end
|
|
194
196
|
end
|
|
195
197
|
|
|
196
|
-
def self.
|
|
198
|
+
def self.hooks_for_container(container)
|
|
199
|
+
hooks_for_container_internal(rpc: container.rpc, events: container.events)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.hooks_for_container_internal(rpc:, events:)
|
|
197
203
|
sections = {}
|
|
198
204
|
Hooks::RpcRegistry::EVENTS.each_key do |event|
|
|
199
|
-
sections[event.to_s] =
|
|
205
|
+
sections[event.to_s] = rpc.names(event).map(&:to_s).sort
|
|
200
206
|
end
|
|
201
207
|
Hooks::EventBus::EVENTS.each_key do |event|
|
|
202
|
-
sections[event.to_s] =
|
|
208
|
+
sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
203
209
|
end
|
|
204
210
|
sections
|
|
205
211
|
end
|
|
@@ -53,6 +53,10 @@ module Textus
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
module Pipeline
|
|
56
|
+
Deps = Data.define(
|
|
57
|
+
:manifest, :reader, :lister, :rpc, :template_loader, :transform_context, :inject_boot
|
|
58
|
+
)
|
|
59
|
+
|
|
56
60
|
def self.renderers
|
|
57
61
|
@renderers ||= {
|
|
58
62
|
"markdown" => Renderer::Markdown,
|
|
@@ -62,37 +66,34 @@ module Textus
|
|
|
62
66
|
}
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
def self.run(mentry:, manifest:, reader:, lister:, rpc:, template_loader:,
|
|
67
|
-
transform_context: nil, inject_boot: nil)
|
|
69
|
+
def self.run(mentry:, deps:)
|
|
68
70
|
# 1. Load sources + project + reduce
|
|
69
71
|
data =
|
|
70
72
|
if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
|
|
71
|
-
|
|
72
|
-
reader: reader,
|
|
73
|
+
Textus::Projection.new(
|
|
74
|
+
reader: deps.reader,
|
|
73
75
|
spec: mentry.source.to_h.transform_keys(&:to_s),
|
|
74
|
-
lister: lister,
|
|
75
|
-
rpc: rpc,
|
|
76
|
-
transform_context: transform_context,
|
|
76
|
+
lister: deps.lister,
|
|
77
|
+
rpc: deps.rpc,
|
|
78
|
+
transform_context: deps.transform_context,
|
|
77
79
|
).run
|
|
78
80
|
else
|
|
79
81
|
{ "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
|
|
80
82
|
end
|
|
81
|
-
data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
|
|
83
|
+
data = data.merge("boot" => deps.inject_boot.call) if mentry.inject_boot && deps.inject_boot
|
|
82
84
|
|
|
83
85
|
# 2. Render
|
|
84
86
|
klass = renderers[mentry.format] or
|
|
85
87
|
raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
|
|
86
|
-
bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
|
|
88
|
+
bytes = klass.new(template_loader: deps.template_loader).call(mentry: mentry, data: data)
|
|
87
89
|
|
|
88
90
|
# 3. Write (idempotent: skip if only generated_at would differ)
|
|
89
|
-
target_path = Key::Path.resolve(manifest.data, mentry)
|
|
91
|
+
target_path = Key::Path.resolve(deps.manifest.data, mentry)
|
|
90
92
|
FileUtils.mkdir_p(File.dirname(target_path))
|
|
91
93
|
write_if_changed(target_path, bytes, mentry.format)
|
|
92
94
|
|
|
93
95
|
target_path
|
|
94
96
|
end
|
|
95
|
-
# rubocop:enable Metrics/ParameterLists
|
|
96
97
|
|
|
97
98
|
def self.write_if_changed(target_path, bytes, format)
|
|
98
99
|
if File.exist?(target_path)
|
data/lib/textus/call.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
# Immutable per-invocation value. Carries who is acting (role), the
|
|
5
|
+
# request correlation id, the wall clock, and the dry_run flag — the
|
|
6
|
+
# bits Use Cases need that are not part of the Container.
|
|
7
|
+
Call = Data.define(:role, :correlation_id, :now, :dry_run) do
|
|
8
|
+
def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
|
|
9
|
+
new(
|
|
10
|
+
role: role.to_s,
|
|
11
|
+
correlation_id: correlation_id || SecureRandom.uuid,
|
|
12
|
+
now: now || Textus::Ports::Clock.now,
|
|
13
|
+
dry_run: dry_run,
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dry_run? = dry_run
|
|
18
|
+
|
|
19
|
+
def with_role(new_role)
|
|
20
|
+
self.class.new(
|
|
21
|
+
role: new_role.to_s,
|
|
22
|
+
correlation_id: correlation_id,
|
|
23
|
+
now: now,
|
|
24
|
+
dry_run: dry_run,
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
|
|
16
16
|
def call(store)
|
|
17
17
|
ops = session_for(store)
|
|
18
|
-
since_time = since && Textus::
|
|
18
|
+
since_time = since && Textus::Read::Audit.parse_since(since, now: Time.now)
|
|
19
19
|
rows = ops.audit(
|
|
20
20
|
key: key_filter,
|
|
21
21
|
zone: zone,
|
data/lib/textus/cli/verb/boot.rb
CHANGED
|
@@ -7,9 +7,9 @@ module Textus
|
|
|
7
7
|
option :prefix, "--prefix=K"
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
|
-
Textus::
|
|
10
|
+
Textus::Ports::BuildLock.with(root: store.root) do
|
|
11
11
|
role = store.manifest.policy.roles_with_kind(:generator).first || "builder"
|
|
12
|
-
ops = store.
|
|
12
|
+
ops = store.as(role)
|
|
13
13
|
result = ops.publish(prefix: prefix)
|
|
14
14
|
emit(result)
|
|
15
15
|
end
|
|
@@ -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::Application::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,19 +17,10 @@ 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::Application::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" => {
|
|
@@ -46,7 +37,7 @@ module Textus
|
|
|
46
37
|
meta = payload["_meta"] || {}
|
|
47
38
|
body = payload["body"] || ""
|
|
48
39
|
if_etag = payload["if_etag"]
|
|
49
|
-
result = store.
|
|
40
|
+
result = store.as(role).put(key, meta: meta, body: body, if_etag: if_etag)
|
|
50
41
|
emit(result.to_h_for_wire)
|
|
51
42
|
end
|
|
52
43
|
end
|
|
@@ -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
|
|
@@ -18,7 +18,7 @@ module Textus
|
|
|
18
18
|
end
|
|
19
19
|
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
20
20
|
row["promotion"] = { "requires" => b.promote.requires } if b.promote
|
|
21
|
-
row["retention"] = b.retention if b.retention
|
|
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/verb.rb
CHANGED
|
@@ -96,16 +96,16 @@ module Textus
|
|
|
96
96
|
Role.resolve(flag: flag, env: ENV, root: store.root)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
# Returns
|
|
100
|
-
#
|
|
101
|
-
#
|
|
99
|
+
# Returns a Call value bound to the resolved role. Convenience for
|
|
100
|
+
# verbs whose only pre-call boilerplate is resolving the role and
|
|
101
|
+
# wrapping it in a Call.
|
|
102
102
|
def context_for(store)
|
|
103
|
-
|
|
103
|
+
Textus::Call.build(role: resolved_role(store))
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# Returns a
|
|
106
|
+
# Returns a RoleScope bound to the resolved role.
|
|
107
107
|
def session_for(store)
|
|
108
|
-
store.
|
|
108
|
+
store.as(resolved_role(store))
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
end
|