textus 0.2.0 → 0.4.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/CHANGELOG.md +30 -0
- data/README.md +80 -97
- data/SPEC.md +46 -26
- data/lib/textus/{builtin_fetchers.rb → builtin_actions.rb} +11 -6
- data/lib/textus/cli.rb +55 -11
- data/lib/textus/doctor.rb +41 -1
- data/lib/textus/extension_registry.rb +22 -9
- data/lib/textus/extensions.rb +6 -2
- data/lib/textus/init.rb +5 -4
- data/lib/textus/intro.rb +9 -7
- data/lib/textus/manifest.rb +10 -4
- data/lib/textus/refresh.rb +14 -13
- data/lib/textus/store.rb +14 -4
- data/lib/textus/store_view.rb +11 -2
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98c2ce5525bbf9c05ebdb5eeaacde8e208253ee878d7d0722ff192435168f69f
|
|
4
|
+
data.tar.gz: c16cd5657396884c646331912d988838e0f8ed2002fc76d47cc54383dc434f19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7478459f9672474c32f37b59a7134309cf12f6a7455a808c24b6ac717820ff8c8aad5a5187a6dbe5e0c73f98c04296ce95eff2cac8ea75144f559df8cea3fe80
|
|
7
|
+
data.tar.gz: '08a29e9090f6558a010009e3562a7ec8d6ddf73454bb09b10b5aeaf3def24d9d11905078d62807b8e1f0936b00889a66df8c55d68b7dfc411addd90e60ae1a93'
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,36 @@ is additive within a major; a new major would change the wire string.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## 0.4.0 — Extension API redesign (breaking)
|
|
14
|
+
|
|
15
|
+
- **Breaking:** `Textus.fetcher` removed. Use `Textus.action` instead. The block signature changes from `|config:, store:|` to `|config:, store:, args:|`.
|
|
16
|
+
- **Breaking:** Manifest field `source.fetcher` renamed to `source.action`. Legacy field is rejected with a migration error.
|
|
17
|
+
- **Breaking:** CLI flag `textus put --fetcher=NAME` renamed to `textus put --action=NAME`.
|
|
18
|
+
- **Breaking:** `BuiltinFetchers` module renamed to `BuiltinActions`.
|
|
19
|
+
- **Breaking:** Synthesized frontmatter key `fetched_with` renamed to `actioned_with` on `put --action`.
|
|
20
|
+
- **New:** `Textus.action` works in three invocation modes — intake refresh, the new `textus action NAME` verb, and `put --action`. See SPEC §5.11.
|
|
21
|
+
- **New:** `Textus.doctor_check(:name) { |store:| ... }` primitive; contributed checks merge into the doctor report.
|
|
22
|
+
- **New:** `textus action NAME [--key=val ...] [--as=ROLE]` CLI verb for invoking actions in verb mode.
|
|
23
|
+
- **New:** `StoreView` gains a writable mode (`writable: true, as: ROLE`); intake and verb-mode actions receive a writable view bound to the calling role.
|
|
24
|
+
- **New:** `extensions list` enumerates actions and doctor_checks.
|
|
25
|
+
|
|
26
|
+
Migration: in every `.textus/extensions/*.rb`, rename `Textus.fetcher(:x)` to `Textus.action(:x)` and add `args:` to the block signature. In every manifest, rename `source.fetcher:` to `source.action:`. In CI/scripts using `textus put --fetcher=`, switch to `--action=`.
|
|
27
|
+
|
|
28
|
+
## [0.3.0] — 2026-05-20 — Configurable store root
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `--root <path>` CLI flag and `TEXTUS_ROOT` environment variable for store
|
|
33
|
+
discovery. `Textus::Store.discover` now accepts an optional `root:` kwarg.
|
|
34
|
+
Unblocks embedding a textus store at non-default paths (e.g. nested under a
|
|
35
|
+
plugin directory like `plugins/<name>/.textus/`) where walking up from cwd
|
|
36
|
+
to find `.textus/` is undesirable or ambiguous.
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
- SPEC.md §3.1 documents the new store-location precedence:
|
|
41
|
+
1. `--root` / `root:` kwarg, 2. `TEXTUS_ROOT`, 3. cwd walk.
|
|
42
|
+
|
|
13
43
|
## [0.2.0] — 2026-05-20 — Storage rewrite, agent surface, extension DSL (BREAKING)
|
|
14
44
|
|
|
15
45
|
This release reshapes textus from a markdown-only frontmatter store into a
|
data/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# textus
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/patrick204nqh/textus/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/textus)
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](LICENSE)
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
A context store for codebases that humans and AI agents both have to read and write. Dotted keys, schema-validated entries, role-gated writes, byte-copy publish, an audit log of every change. Built so an agent landing in your repo can run one command (`textus intro`) and know what to read, what to write, and what's off-limits.
|
|
9
|
+
|
|
10
|
+
Reference implementation in Ruby. Wire format `textus/1`. SPEC: [`SPEC.md`](SPEC.md). Implementation notes: [`docs/`](docs/).
|
|
6
11
|
|
|
7
12
|
## Versioning
|
|
8
13
|
|
|
@@ -11,12 +16,12 @@ Two versions, deliberately independent:
|
|
|
11
16
|
- **Protocol wire string:** `textus/1`. Stable; breaking changes require `textus/2`.
|
|
12
17
|
- **Gem version:** semver, currently `0.2.0`. Gem `0.x.y` and `1.x` both speak `textus/1`.
|
|
13
18
|
|
|
14
|
-
Envelope payloads carry the `protocol` field
|
|
19
|
+
Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
|
|
15
20
|
|
|
16
21
|
## Install
|
|
17
22
|
|
|
18
23
|
```sh
|
|
19
|
-
gem install textus
|
|
24
|
+
gem install textus
|
|
20
25
|
```
|
|
21
26
|
|
|
22
27
|
Or from this repo:
|
|
@@ -28,141 +33,133 @@ bundle exec exe/textus --help
|
|
|
28
33
|
|
|
29
34
|
## Quick start
|
|
30
35
|
|
|
31
|
-
Bootstrap a fresh tree:
|
|
32
|
-
|
|
33
36
|
```sh
|
|
34
|
-
|
|
37
|
+
textus init
|
|
35
38
|
```
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
You get `.textus/` with all five zone directories, baseline schemas, an empty audit log, and a starter manifest:
|
|
38
41
|
|
|
39
42
|
```
|
|
40
43
|
.textus/
|
|
41
|
-
manifest.yaml
|
|
42
|
-
audit.log
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
manifest.yaml # zone declarations + key-to-path mapping
|
|
45
|
+
audit.log # append-only NDJSON, every write
|
|
46
|
+
schemas/ # YAML field shapes per entry family
|
|
47
|
+
templates/ # mustache templates for derived entries
|
|
48
|
+
extensions/ # one .rb per action / reducer / hook / doctor_check
|
|
49
|
+
sentinels/ # publish bookkeeping
|
|
47
50
|
zones/
|
|
48
|
-
canon/
|
|
49
|
-
working/
|
|
50
|
-
intake/
|
|
51
|
-
pending/
|
|
52
|
-
derived/
|
|
51
|
+
canon/ # human-only — identity, voice, decisions
|
|
52
|
+
working/ # human / ai / script — day-to-day catalog
|
|
53
|
+
intake/ # script — declared external inputs (actions)
|
|
54
|
+
pending/ # ai + human — proposals awaiting accept
|
|
55
|
+
derived/ # build only — computed outputs
|
|
53
56
|
```
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
```yaml
|
|
58
|
-
version: textus/1
|
|
59
|
-
|
|
60
|
-
zones:
|
|
61
|
-
- { name: canon, writable_by: [human] }
|
|
62
|
-
- { name: working, writable_by: [human, ai, script] }
|
|
63
|
-
- { name: intake, writable_by: [script] }
|
|
64
|
-
- { name: pending, writable_by: [ai] }
|
|
65
|
-
- { name: derived, writable_by: [build] }
|
|
66
|
-
|
|
67
|
-
entries:
|
|
68
|
-
- key: canon.identity
|
|
69
|
-
path: canon/identity.md
|
|
70
|
-
zone: canon
|
|
71
|
-
schema: identity
|
|
72
|
-
|
|
73
|
-
- key: working.network.org
|
|
74
|
-
path: working/network/org
|
|
75
|
-
zone: working
|
|
76
|
-
schema: person
|
|
77
|
-
owner: textus:network
|
|
78
|
-
nested: true
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
Manifest `path:` fields are relative to `.textus/zones/` — implementations prepend `zones/` when resolving. So `working.network.org.jane` lives at `.textus/zones/working/network/org/jane.md`.
|
|
58
|
+
Manifest `path:` fields are relative to `.textus/zones/`. So `working.network.org.jane` lives at `.textus/zones/working/network/org/jane.md`.
|
|
82
59
|
|
|
83
60
|
Read and write:
|
|
84
61
|
|
|
85
62
|
```sh
|
|
86
63
|
textus get working.network.org.jane --format=json
|
|
87
64
|
textus list --zone=working --format=json
|
|
88
|
-
echo '{"frontmatter":{"name":"bob","
|
|
65
|
+
echo '{"frontmatter":{"name":"bob","org":"acme"},"body":"hi\n"}' \
|
|
89
66
|
| textus put working.network.org.bob --as=human --stdin --format=json
|
|
90
67
|
textus stale --zone=derived --format=json
|
|
91
68
|
```
|
|
92
69
|
|
|
70
|
+
For the full shape — Claude plugin with agents, skills, commands, pending walkthrough, intake action — see [`examples/claude-plugin/`](examples/claude-plugin/).
|
|
71
|
+
|
|
72
|
+
## What 0.2 ships
|
|
73
|
+
|
|
74
|
+
- **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/derived/marketplace.json | jq .` works without going through textus — the in-store file *is* the consumer-shaped artifact. Structured outputs carry `_meta` at the top level (`generated_at`, `from`, `template`, `reducer`).
|
|
75
|
+
- **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
|
|
76
|
+
- **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
|
|
77
|
+
- **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus migrate-keys --dry-run|--write` rewrites existing stores with illegal segments deterministically.
|
|
78
|
+
- **`textus intro`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded extensions, write flows per role, the full CLI verb table. The boot signal for any agent — one tool call and it knows your store.
|
|
79
|
+
- **`textus doctor`.** Health check across 8 categories: missing schemas/templates, broken extensions, illegal nested keys, sentinel drift, audit log readability. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
|
|
80
|
+
- **Actionable hints on every error.** `UnknownKey` carries ranked "did you mean" suggestions. `WriteForbidden` names the role that *would* be allowed. `BadFrontmatter` tells you exactly what to rename. Printed to stderr alongside the JSON envelope on stdout.
|
|
81
|
+
|
|
82
|
+
Symlink-mode publish was removed; publish is `FileUtils.cp` + sentinel. Sentinels for published files live under `.textus/sentinels/<target_rel>.textus-managed.json` so consumer directories stay clean. Legacy sibling sentinels auto-migrate on next publish.
|
|
83
|
+
|
|
93
84
|
## CLI verbs
|
|
94
85
|
|
|
95
|
-
All verbs accept `--format=json` and
|
|
86
|
+
All verbs accept `--format=json` and return the envelope defined in SPEC §8. Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`).
|
|
96
87
|
|
|
97
|
-
**Read
|
|
88
|
+
**Read:**
|
|
98
89
|
|
|
99
90
|
| Verb | Purpose |
|
|
100
91
|
|---|---|
|
|
101
|
-
| `
|
|
92
|
+
| `intro` | Store orientation: zones, entries, extensions, write flows, CLI map |
|
|
93
|
+
| `list [--prefix=K] [--zone=Z]` | Enumerate keys |
|
|
102
94
|
| `where K` | Resolve a key to its filesystem path |
|
|
103
|
-
| `get K` |
|
|
104
|
-
| `schema K` |
|
|
105
|
-
| `stale [--prefix=K] [--zone=Z]
|
|
106
|
-
| `deps K` / `rdeps K` | Forward/reverse projection dependencies |
|
|
95
|
+
| `get K` | Full envelope (frontmatter, body, uid, etag, format) |
|
|
96
|
+
| `schema K` | Schema bound to an entry |
|
|
97
|
+
| `stale [--prefix=K] [--zone=Z]` | List stale derived/intake entries |
|
|
98
|
+
| `deps K` / `rdeps K` | Forward / reverse projection dependencies |
|
|
107
99
|
| `published` | List `publish_to:` targets and their backing keys |
|
|
108
|
-
| `validate-all` | Validate every entry against its schema
|
|
109
|
-
| `extensions list [--kind=K]` |
|
|
100
|
+
| `validate-all` | Validate every entry against its schema |
|
|
101
|
+
| `extensions list [--kind=K]` | Registered actions, reducers, hooks, doctor_checks |
|
|
110
102
|
|
|
111
|
-
**Write
|
|
103
|
+
**Write:**
|
|
112
104
|
|
|
113
105
|
| Verb | Role |
|
|
114
106
|
|---|---|
|
|
115
|
-
| `put K --stdin --as=R [--
|
|
107
|
+
| `put K --stdin --as=R [--action=NAME]` | per zone |
|
|
108
|
+
| `action NAME [--key=val] [--as=R]` | per zone written (invoke a registered action) |
|
|
116
109
|
| `delete K --if-etag=E --as=R` | per zone |
|
|
117
110
|
| `refresh K --as=script` | per zone (typically `script`) |
|
|
111
|
+
| `mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
|
|
118
112
|
| `build [--prefix=K] [--dry-run]` | `build` |
|
|
119
113
|
| `accept K --as=human` | `human` only |
|
|
120
114
|
|
|
115
|
+
**Health & maintenance:**
|
|
116
|
+
|
|
117
|
+
| Verb | Purpose |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `doctor` | 8 health checks; `ok: true` when clean |
|
|
120
|
+
| `migrate-keys [--dry-run]` | Rename files whose basenames violate the strict key grammar |
|
|
121
|
+
|
|
121
122
|
**Scaffolding (human-only):**
|
|
122
123
|
|
|
123
124
|
| Verb | Purpose |
|
|
124
125
|
|---|---|
|
|
125
|
-
| `init` | Scaffold a fresh `.textus/`
|
|
126
|
-
| `schema-init NAME` |
|
|
127
|
-
| `schema-diff NAME` | Compare
|
|
126
|
+
| `init` | Scaffold a fresh `.textus/` |
|
|
127
|
+
| `schema-init NAME` | Stub a schema |
|
|
128
|
+
| `schema-diff NAME` | Compare a schema against entries that claim it |
|
|
128
129
|
| `schema-migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
|
|
129
130
|
|
|
130
131
|
## Zones and roles
|
|
131
132
|
|
|
132
133
|
| Zone | `writable_by` | Purpose |
|
|
133
134
|
|---|---|---|
|
|
134
|
-
| `canon` | `[human]` | Identity, voice,
|
|
135
|
-
| `working` | `[human, ai, script]` | Active project state
|
|
136
|
-
| `intake` | `[script]` | Declared external inputs (
|
|
137
|
-
| `pending` | `[ai]` | AI proposals
|
|
135
|
+
| `canon` | `[human]` | Identity, voice, decisions — slow-changing |
|
|
136
|
+
| `working` | `[human, ai, script]` | Active project state |
|
|
137
|
+
| `intake` | `[script]` | Declared external inputs (actions) |
|
|
138
|
+
| `pending` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
|
|
138
139
|
| `derived` | `[build]` | Computed outputs from `textus build` |
|
|
139
140
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
## Compute layer
|
|
141
|
+
Mismatches return `write_forbidden` with a hint naming the role that *would* be allowed. Every write records the resolved role in `.textus/audit.log`.
|
|
143
142
|
|
|
144
|
-
|
|
143
|
+
## Compute and publish
|
|
145
144
|
|
|
146
|
-
Derived entries
|
|
145
|
+
Derived entries declare a `projection:` (`select`, `pluck`, `sort_by`, `limit`, optional `reducer`) and either a template under `.textus/templates/` (markdown/text) or a templateless path that lets a reducer shape the output directly (json/yaml). Projections cap at 1000 rows; the vendored Mustache subset caps at depth 8. No partials, no lambdas, no HTML escaping.
|
|
147
146
|
|
|
148
|
-
|
|
147
|
+
`publish_to: [path]` byte-copies a single derived file to one target. `publish_each: "template/{basename}.md"` on a nested entry byte-copies every leaf to its templated target — substitutes `{leaf}`, `{basename}`, `{key}`, `{ext}`. Sentinels for every published file live under `.textus/sentinels/`. See SPEC §5.2, §5.3, §5.12.
|
|
149
148
|
|
|
150
|
-
|
|
149
|
+
## Extensions
|
|
151
150
|
|
|
152
|
-
|
|
153
|
-
- **`Textus.reducer(:name) do |rows:, config:|`** — shapes rows in a derived projection. Pure function. Configured via `projection.reducer`.
|
|
154
|
-
- **`Textus.hook(:event, :name) do |kwargs|`** — reacts to a lifecycle event. Five events: `:put`, `:delete`, `:refresh`, `:build`, `:accept`.
|
|
151
|
+
Four DSL verbs, registered in `.textus/extensions/*.rb`. Each `Store` gets its own registry — no global state.
|
|
155
152
|
|
|
156
|
-
|
|
153
|
+
- **`Textus.action(:name) do |config:, store:, args:|`** — runs in three invocation modes (intake refresh, `textus action` verb, `put --action`). Returns `{frontmatter:, body:}`, `{content:}`, or `{body:}` when its return is consumed (intake and put-fetch); writes via `store.put` for side-effectful work (verb mode). The store normalizes all three return shapes. Configured via `source.action` in the manifest for intake. Five built-ins ship: `json`, `csv`, `markdown-links`, `ical-events`, `rss`.
|
|
154
|
+
- **`Textus.reducer(:name) do |rows:, config:|`** — shapes rows in a derived projection. Pure function. Configured via `projection.reducer`. May return an Array (templated builds) or a Hash (templateless json/yaml).
|
|
155
|
+
- **`Textus.hook(:event, :name) do |kwargs|`** — fires on `:put`, `:delete`, `:refresh`, `:build`, or `:accept`. In-process; 2 s timeout per hook; failures land in the audit log as `event_error` rows.
|
|
156
|
+
- **`Textus.doctor_check(:name) do |store:|`** — contributes whole-tree validators to `textus doctor`. Returns an array of issue hashes `{code, level, subject, message, fix}` that merge into the doctor report. Timeouts and exceptions surface as `doctor_check.*` issues; they do not abort the doctor run.
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
Schema fields may also declare `maintained_by:` and a top-level `evolution:` block (`added_in`, `deprecated_at`, `migrate_from`). SPEC §5.8.
|
|
158
|
+
Schemas (`.textus/schemas/<name>.yaml`) declare field shapes, per-field `maintained_by:` ownership, and an `evolution:` block (`added_in`, `deprecated_at`, `migrate_from`). Full contract in SPEC §5.8 and §5.11.
|
|
161
159
|
|
|
162
160
|
## Examples
|
|
163
161
|
|
|
164
|
-
|
|
165
|
-
- [`examples/mcp-server/`](examples/mcp-server/) — 50-line MCP server wrapping `textus get/put` as tools.
|
|
162
|
+
[`examples/claude-plugin/`](examples/claude-plugin/) — a Claude Code plugin (`voice-tools`) whose entire content surface — agents, skills, commands, `CLAUDE.md`, `plugin.json`, `marketplace.json` — is textus-managed. Demonstrates per-entry formats, `publish_each`, intake actions, in-process reducers and hooks, the AI-propose / human-accept loop, and the `inject_intro:` flag that puts an orientation preamble at the top of `CLAUDE.md`.
|
|
166
163
|
|
|
167
164
|
## Tests
|
|
168
165
|
|
|
@@ -170,7 +167,7 @@ Schema fields may also declare `maintained_by:` and a top-level `evolution:` blo
|
|
|
170
167
|
bundle exec rspec
|
|
171
168
|
```
|
|
172
169
|
|
|
173
|
-
|
|
170
|
+
240 examples; includes conformance fixtures A–I from SPEC §12.
|
|
174
171
|
|
|
175
172
|
## Code quality
|
|
176
173
|
|
|
@@ -179,21 +176,7 @@ bundle exec rubocop # lint
|
|
|
179
176
|
bundle exec rubocop -A # lint + autocorrect
|
|
180
177
|
```
|
|
181
178
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
```sh
|
|
185
|
-
brew bundle install # installs lefthook (see Brewfile)
|
|
186
|
-
lefthook install # writes .git/hooks/{pre-commit,pre-push}
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
Git hooks (defined in `lefthook.yml`):
|
|
190
|
-
- `pre-commit` — runs `rubocop` on staged Ruby files.
|
|
191
|
-
- `pre-push` — runs the full `rspec` suite and `rubocop` over the tree.
|
|
192
|
-
|
|
193
|
-
Bypass with `LEFTHOOK=0 git commit ...` when needed.
|
|
194
|
-
|
|
195
|
-
CI runs `rspec` (Ruby 3.3 / 3.4) and `rubocop` via GitHub Actions
|
|
196
|
-
([`.github/workflows/ci.yml`](.github/workflows/ci.yml)).
|
|
179
|
+
Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `pre-commit` and `rspec + rubocop` on `pre-push`. Bypass with `LEFTHOOK=0 git commit ...` when needed. CI runs `rspec` (Ruby 3.3 / 3.4) and `rubocop` via GitHub Actions.
|
|
197
180
|
|
|
198
181
|
## License
|
|
199
182
|
|
data/SPEC.md
CHANGED
|
@@ -46,7 +46,7 @@ textus is organized as five composable layers. Each layer has a single responsib
|
|
|
46
46
|
- Not a sync protocol. Single-writer per file, ETag-checked.
|
|
47
47
|
- Not a transport. Spawn the CLI or wrap it in MCP/HTTP downstream.
|
|
48
48
|
- Not a UI. Filesystem + CLI. Viewers ship elsewhere.
|
|
49
|
-
- Not a fetcher. textus declares sources; external runners
|
|
49
|
+
- Not a fetcher. textus declares sources; external runners invoke actions to materialize them.
|
|
50
50
|
- Not an executor. textus computes pure projections but never spawns shell commands.
|
|
51
51
|
|
|
52
52
|
## 3. Storage layout
|
|
@@ -75,6 +75,16 @@ Zone directories under `zones/` are conventional; their write semantics are decl
|
|
|
75
75
|
|
|
76
76
|
`.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `delete`, `accept`, and `build`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
|
|
77
77
|
|
|
78
|
+
### 3.1 Store location precedence (v0.3)
|
|
79
|
+
|
|
80
|
+
Implementations MUST resolve the store root in this order; the first match wins:
|
|
81
|
+
|
|
82
|
+
1. `--root <path>` flag passed to the CLI (or `root:` kwarg to `Store.discover`).
|
|
83
|
+
2. `TEXTUS_ROOT` environment variable.
|
|
84
|
+
3. Walk up from cwd looking for a `.textus/` directory containing `manifest.yaml`.
|
|
85
|
+
|
|
86
|
+
When (1) or (2) names a path that has no `manifest.yaml`, the CLI exits with `io_error` and a message naming the resolved absolute path. When (3) reaches the filesystem root without finding a store, the CLI exits with `io_error` naming the search start point.
|
|
87
|
+
|
|
78
88
|
## 4. Manifest
|
|
79
89
|
|
|
80
90
|
The manifest declares: (a) which zones exist and which roles may write to each, (b) the key-to-subtree mapping, (c) the schema applied to entries in each subtree, and (d) the owner string recorded in writes.
|
|
@@ -240,38 +250,38 @@ A sentinel is written for each published file at `<store_root>/sentinels/<target
|
|
|
240
250
|
|
|
241
251
|
**Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is byte-copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The build envelope grows a `published_leaves` array — one row per leaf, with `key`, `source`, and `target` — alongside the existing `built` array. Targets that would resolve outside the repo root are refused.
|
|
242
252
|
|
|
243
|
-
### 5.4 Intake (declared, refreshed via registered
|
|
253
|
+
### 5.4 Intake (declared, refreshed via registered action)
|
|
244
254
|
|
|
245
|
-
Intake entries declare an external source by naming
|
|
255
|
+
Intake entries declare an external source by naming an **action** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an action only runs in intake mode when explicitly invoked by `textus refresh KEY --as=script`. The declaration is data only:
|
|
246
256
|
|
|
247
257
|
```yaml
|
|
248
258
|
- key: intake.calendar.events
|
|
249
259
|
zone: intake
|
|
250
260
|
source:
|
|
251
|
-
|
|
261
|
+
action: ical-events
|
|
252
262
|
config:
|
|
253
263
|
url: "https://calendar.google.com/.../basic.ics"
|
|
254
264
|
ttl: 6h
|
|
255
265
|
```
|
|
256
266
|
|
|
257
|
-
`
|
|
267
|
+
`action` names a registered action; `config` is an opaque hash handed to the action; `ttl` is the staleness budget. Implementations MUST reject legacy `source.from`, `source.parse`, and `source.fetcher` with a clear usage error.
|
|
258
268
|
|
|
259
|
-
**
|
|
269
|
+
**Action contract (intake mode).** An action is registered via `Textus.action(:name) do |config:, store:, args:| ... end`. In intake mode the action MUST return one of three shapes, all normalized by the store into its internal `{frontmatter, body, content}` representation (§5.12):
|
|
260
270
|
|
|
261
|
-
- `{ frontmatter:, body: }` — markdown-friendly
|
|
271
|
+
- `{ frontmatter:, body: }` — markdown-friendly.
|
|
262
272
|
- `{ content: }` — for `format: json|yaml` entries; the parsed object becomes the entry's content.
|
|
263
273
|
- `{ body: }` — raw bytes for `text` or for any format that prefers verbatim writes; the store re-parses and validates per `format:`.
|
|
264
274
|
|
|
265
|
-
The `store:` argument is a
|
|
275
|
+
The `store:` argument is a writable `Textus::StoreView` (§5.11) bound to the calling role; the `args:` argument is `{}` in intake mode (it carries CLI flags in verb mode — §5.11). Every action call is wrapped in `Timeout.timeout(2)`; exceptions and timeouts surface as `usage` errors that abort the refresh.
|
|
266
276
|
|
|
267
|
-
**Built-in
|
|
277
|
+
**Built-in actions.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured frontmatter/body. Built-ins do not perform I/O themselves — the caller (or an outer action) is responsible for supplying bytes.
|
|
268
278
|
|
|
269
279
|
**Refresh paths.** Two are supported:
|
|
270
280
|
|
|
271
|
-
1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `source.
|
|
281
|
+
1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `source.action`, invokes it with `(config:, store:, args: {})`, and writes the result under role `script`.
|
|
272
282
|
2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`.
|
|
273
283
|
|
|
274
|
-
Both paths share the same role gate, audit-log entry, and `:refresh` event (§5.10). User-supplied
|
|
284
|
+
Both paths share the same role gate, audit-log entry, and `:refresh` event (§5.10). User-supplied actions live in `.textus/extensions/*.rb` and auto-load at `Store#initialize` (§5.11).
|
|
275
285
|
|
|
276
286
|
### 5.5 Pending / accept workflow
|
|
277
287
|
|
|
@@ -409,29 +419,39 @@ Textus does NOT invoke these — they surface only through `textus extensions li
|
|
|
409
419
|
|
|
410
420
|
**Removed.** The v1.1 `on_stale` event is removed in 0.2. Staleness is a poll, surfaced by `textus stale`. The `on_`-prefix convention from v1.1 is gone; events are bare symbols.
|
|
411
421
|
|
|
412
|
-
### 5.11 Extension surface (v1.
|
|
422
|
+
### 5.11 Extension surface (v1.3)
|
|
413
423
|
|
|
414
|
-
|
|
424
|
+
Four DSL verbs cover all user-supplied code:
|
|
415
425
|
|
|
416
426
|
```
|
|
417
|
-
Textus.
|
|
418
|
-
Textus.reducer(:name)
|
|
419
|
-
Textus.hook(:event, :name)
|
|
427
|
+
Textus.action(:name) do |config:, store:, args:| ... end # intake mode: returns content; verb mode: writes via store.put
|
|
428
|
+
Textus.reducer(:name) do |rows:, config:| ... end # returns rows
|
|
429
|
+
Textus.hook(:event, :name) do |**kwargs| ... end # side effects; return ignored
|
|
430
|
+
Textus.doctor_check(:name) do |store:| ... end # returns array of issue hashes
|
|
420
431
|
```
|
|
421
432
|
|
|
422
433
|
Files in `.textus/extensions/*.rb` are loaded at `Store#initialize`, in lexical order, with the registry installed as the current registry for that store. Registries are per-Store: two Store instances in the same process do not share state.
|
|
423
434
|
|
|
424
|
-
|
|
435
|
+
**Action invocation modes.**
|
|
436
|
+
|
|
437
|
+
| Mode | Invoked by | `config:` | `store:` | `args:` | Return |
|
|
438
|
+
|---------|---------------------------|------------------------|------------------------|----------------------|------------------------------------------|
|
|
439
|
+
| intake | `textus refresh KEY` | manifest `source.config` | writable view (role from `--as`) | `{}` | required; normalized into entry write |
|
|
440
|
+
| verb | `textus action NAME ...` | `{}` | writable view (role from `--as`) | parsed CLI kv hash | ignored |
|
|
441
|
+
| put-fetch | `textus put K --action=N --stdin` | `{ "bytes" => stdin }` | read-only view | `{}` | required; merged into the put payload |
|
|
442
|
+
|
|
443
|
+
**Failure modes:**
|
|
425
444
|
|
|
426
|
-
| Surface
|
|
427
|
-
|
|
428
|
-
|
|
|
429
|
-
| reducer
|
|
430
|
-
| hook
|
|
445
|
+
| Surface | Timeout | Exception | Bad return |
|
|
446
|
+
|-----------------|------------|---------------------------------------------|------------|
|
|
447
|
+
| action | aborts op | aborts op (wrapped as `UsageError`) | aborts op |
|
|
448
|
+
| reducer | aborts op | aborts op | aborts op |
|
|
449
|
+
| hook | logged | logged (audit `event_error` row) | n/a |
|
|
450
|
+
| doctor_check | reported as `doctor_check.timeout` issue | reported as `doctor_check.failed` issue | reported as `doctor_check.bad_return` |
|
|
431
451
|
|
|
432
|
-
|
|
452
|
+
Actions and reducers are pure transforms in modes where their return matters; their return values flow into the store. Hooks and doctor_checks shape side outputs (event chain, doctor report) — only doctor_check return values are merged.
|
|
433
453
|
|
|
434
|
-
The `store:` argument is always a `Textus::StoreView
|
|
454
|
+
The `store:` argument is always a `Textus::StoreView`. In intake and verb modes it is writable and bound to the calling role; in put-fetch mode and inside doctor_checks it is read-only. Write attempts on a read-only view raise `Textus::UsageError`.
|
|
435
455
|
|
|
436
456
|
### 5.12 Storage formats (v1.2)
|
|
437
457
|
|
|
@@ -575,7 +595,7 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
|
|
|
575
595
|
| `deps K` / `rdeps K` | read | any |
|
|
576
596
|
| `published` | read | any |
|
|
577
597
|
| `validate-all` | read | any |
|
|
578
|
-
| `put K --stdin --as=R [--
|
|
598
|
+
| `put K --stdin --as=R [--action=NAME]` | write | per zone |
|
|
579
599
|
| `delete K --if-etag=E --as=R` | write | per zone |
|
|
580
600
|
| `refresh K --as=script` | write | per zone (typically `script`) |
|
|
581
601
|
| `build [--prefix=K] [--dry-run]` | write | `build` (default) |
|
|
@@ -585,7 +605,7 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
|
|
|
585
605
|
| `migrate-keys [--dry-run\|--write]` | write (with `--write`) | `human` |
|
|
586
606
|
| `mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
|
|
587
607
|
| `uid K` | read | any |
|
|
588
|
-
| `extensions list [--kind=
|
|
608
|
+
| `extensions list [--kind=action\|reducer\|hook]` | read | any |
|
|
589
609
|
| `doctor [--format=json]` | read | any |
|
|
590
610
|
| `intro [--format=json]` | read | any |
|
|
591
611
|
|
|
@@ -4,31 +4,35 @@ require "yaml"
|
|
|
4
4
|
require "rexml/document"
|
|
5
5
|
|
|
6
6
|
module Textus
|
|
7
|
-
module
|
|
7
|
+
module BuiltinActions
|
|
8
8
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
9
9
|
def self.register_all
|
|
10
|
-
Textus.
|
|
10
|
+
Textus.action(:json) do |config:, store:, args:|
|
|
11
11
|
_ = store
|
|
12
|
+
_ = args
|
|
12
13
|
data = JSON.parse(config["bytes"].to_s)
|
|
13
14
|
{ frontmatter: {}, body: YAML.dump(data) }
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
Textus.
|
|
17
|
+
Textus.action(:csv) do |config:, store:, args:|
|
|
17
18
|
_ = store
|
|
19
|
+
_ = args
|
|
18
20
|
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
19
21
|
{ frontmatter: {}, body: YAML.dump(rows) }
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
Textus.
|
|
24
|
+
Textus.action(:"markdown-links") do |config:, store:, args:|
|
|
23
25
|
_ = store
|
|
26
|
+
_ = args
|
|
24
27
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
25
28
|
{ "text" => text, "href" => href }
|
|
26
29
|
end
|
|
27
30
|
{ frontmatter: {}, body: YAML.dump(links) }
|
|
28
31
|
end
|
|
29
32
|
|
|
30
|
-
Textus.
|
|
33
|
+
Textus.action(:"ical-events") do |config:, store:, args:|
|
|
31
34
|
_ = store
|
|
35
|
+
_ = args
|
|
32
36
|
events = []
|
|
33
37
|
current = nil
|
|
34
38
|
config["bytes"].to_s.each_line do |line|
|
|
@@ -45,8 +49,9 @@ module Textus
|
|
|
45
49
|
{ frontmatter: {}, body: YAML.dump(events) }
|
|
46
50
|
end
|
|
47
51
|
|
|
48
|
-
Textus.
|
|
52
|
+
Textus.action(:rss) do |config:, store:, args:|
|
|
49
53
|
_ = store
|
|
54
|
+
_ = args
|
|
50
55
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
51
56
|
items = doc.elements.to_a("//item").map do |item|
|
|
52
57
|
{
|
data/lib/textus/cli.rb
CHANGED
|
@@ -16,9 +16,13 @@ module Textus
|
|
|
16
16
|
@stdout = stdout
|
|
17
17
|
@stderr = stderr
|
|
18
18
|
@cwd = cwd
|
|
19
|
+
@root_arg = nil
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def run(argv) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
23
|
+
OptionParser.new do |o|
|
|
24
|
+
o.on("--root=PATH") { |v| @root_arg = v }
|
|
25
|
+
end.order!(argv)
|
|
22
26
|
verb = argv.shift
|
|
23
27
|
raise UsageError.new("missing verb") if verb.nil?
|
|
24
28
|
|
|
@@ -40,6 +44,7 @@ module Textus
|
|
|
40
44
|
when "schema-init" then verb_schema_init(argv)
|
|
41
45
|
when "schema-diff" then verb_schema_diff(argv)
|
|
42
46
|
when "schema-migrate" then verb_schema_migrate(argv)
|
|
47
|
+
when "action" then verb_action(argv)
|
|
43
48
|
when "refresh" then verb_refresh(argv)
|
|
44
49
|
when "extensions" then verb_extensions(argv)
|
|
45
50
|
when "migrate-keys" then verb_migrate_keys(argv)
|
|
@@ -60,7 +65,7 @@ module Textus
|
|
|
60
65
|
private
|
|
61
66
|
|
|
62
67
|
def store
|
|
63
|
-
@store ||= Store.discover(@cwd)
|
|
68
|
+
@store ||= Store.discover(@cwd, root: @root_arg)
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
def parse_format!(argv)
|
|
@@ -129,11 +134,11 @@ module Textus
|
|
|
129
134
|
key = argv.shift or raise UsageError.new("put requires a key")
|
|
130
135
|
as_flag = nil
|
|
131
136
|
use_stdin = false
|
|
132
|
-
|
|
137
|
+
action_name = nil
|
|
133
138
|
OptionParser.new do |o|
|
|
134
139
|
o.on("--stdin") { use_stdin = true }
|
|
135
140
|
o.on("--as=ROLE") { |v| as_flag = v }
|
|
136
|
-
o.on("--
|
|
141
|
+
o.on("--action=NAME") { |v| action_name = v }
|
|
137
142
|
o.on("--format=FMT") {}
|
|
138
143
|
end.permute!(argv)
|
|
139
144
|
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
@@ -142,16 +147,16 @@ module Textus
|
|
|
142
147
|
|
|
143
148
|
raw = @stdin.read
|
|
144
149
|
payload =
|
|
145
|
-
if
|
|
146
|
-
callable = store.registry.
|
|
150
|
+
if action_name
|
|
151
|
+
callable = store.registry.action(action_name)
|
|
147
152
|
result =
|
|
148
153
|
begin
|
|
149
|
-
Timeout.timeout(Textus::Refresh::
|
|
150
|
-
callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store))
|
|
154
|
+
Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
|
|
155
|
+
callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store), args: {})
|
|
151
156
|
end
|
|
152
157
|
rescue Timeout::Error
|
|
153
158
|
raise UsageError.new(
|
|
154
|
-
"
|
|
159
|
+
"action '#{action_name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
|
|
155
160
|
)
|
|
156
161
|
end
|
|
157
162
|
basename = key.split(".").last
|
|
@@ -159,7 +164,7 @@ module Textus
|
|
|
159
164
|
"frontmatter" => {
|
|
160
165
|
"name" => basename,
|
|
161
166
|
"last_refreshed_at" => Time.now.utc.iso8601,
|
|
162
|
-
"
|
|
167
|
+
"actioned_with" => action_name,
|
|
163
168
|
}.merge(result[:frontmatter] || result["frontmatter"] || {}),
|
|
164
169
|
"body" => result[:body] || result["body"] || "",
|
|
165
170
|
}
|
|
@@ -267,6 +272,43 @@ module Textus
|
|
|
267
272
|
emit(store.accept(key, as: role))
|
|
268
273
|
end
|
|
269
274
|
|
|
275
|
+
def verb_action(argv)
|
|
276
|
+
name = argv.shift
|
|
277
|
+
raise UsageError.new("action requires a name") if name.nil?
|
|
278
|
+
|
|
279
|
+
as_flag = nil
|
|
280
|
+
args = {}
|
|
281
|
+
argv.each do |tok|
|
|
282
|
+
case tok
|
|
283
|
+
when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
|
|
284
|
+
when /\A--format=/ then next
|
|
285
|
+
when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
|
|
286
|
+
else
|
|
287
|
+
raise UsageError.new("unknown arg to 'action #{name}': #{tok}")
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
292
|
+
callable = store.registry.action(name)
|
|
293
|
+
view = StoreView.new(store, writable: true, as: role)
|
|
294
|
+
|
|
295
|
+
begin
|
|
296
|
+
Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
|
|
297
|
+
callable.call(config: {}, store: view, args: args)
|
|
298
|
+
end
|
|
299
|
+
rescue Timeout::Error
|
|
300
|
+
raise UsageError.new(
|
|
301
|
+
"action '#{name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
|
|
302
|
+
)
|
|
303
|
+
rescue Textus::Error
|
|
304
|
+
raise
|
|
305
|
+
rescue StandardError => e
|
|
306
|
+
raise UsageError.new("action '#{name}' raised: #{e.class}: #{e.message}")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
emit({ "protocol" => Textus::PROTOCOL, "action" => name, "ok" => true })
|
|
310
|
+
end
|
|
311
|
+
|
|
270
312
|
def verb_refresh(argv)
|
|
271
313
|
key = argv.shift or raise UsageError.new("refresh requires a key")
|
|
272
314
|
as_flag = nil
|
|
@@ -289,7 +331,8 @@ module Textus
|
|
|
289
331
|
end.permute!(argv)
|
|
290
332
|
|
|
291
333
|
rows = []
|
|
292
|
-
rows += store.registry.
|
|
334
|
+
rows += store.registry.action_names.map { |n| { "kind" => "action", "name" => n.to_s } }
|
|
335
|
+
rows += store.registry.doctor_check_names.map { |n| { "kind" => "doctor_check", "name" => n.to_s } }
|
|
293
336
|
rows += store.registry.reducer_names.map { |n| { "kind" => "reducer", "name" => n.to_s } }
|
|
294
337
|
store.registry.hook_events.each do |evt|
|
|
295
338
|
store.registry.hooks(evt).each do |h|
|
|
@@ -384,9 +427,10 @@ module Textus
|
|
|
384
427
|
textus list [--prefix=KEY] --format=json
|
|
385
428
|
textus where KEY --format=json
|
|
386
429
|
textus get KEY --format=json
|
|
387
|
-
textus put KEY --stdin --format=json
|
|
430
|
+
textus put KEY --stdin [--action=NAME] --format=json
|
|
388
431
|
textus schema KEY --format=json
|
|
389
432
|
textus stale [--prefix=KEY] --format=json
|
|
433
|
+
textus action NAME [--key=val ...] [--as=ROLE] --format=json
|
|
390
434
|
HELP
|
|
391
435
|
end
|
|
392
436
|
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "json"
|
|
3
|
+
require "timeout"
|
|
3
4
|
|
|
4
5
|
module Textus
|
|
5
6
|
# Health check for a Textus store. Returns a JSON-friendly Hash envelope
|
|
6
7
|
# with an `issues` array and a summary. Each issue is a Hash with
|
|
7
8
|
# `code`, `level`, `subject`, `message`, and optionally `fix`.
|
|
8
|
-
module Doctor
|
|
9
|
+
module Doctor # rubocop:disable Metrics/ModuleLength -- 8 built-in checks + extension dispatch
|
|
9
10
|
LEVELS = %w[error warning info].freeze
|
|
11
|
+
DOCTOR_CHECK_TIMEOUT_SECONDS = 2
|
|
10
12
|
|
|
11
13
|
module_function
|
|
12
14
|
|
|
@@ -20,6 +22,7 @@ module Textus
|
|
|
20
22
|
issues.concat(check_sentinels(store))
|
|
21
23
|
issues.concat(check_audit_log(store))
|
|
22
24
|
issues.concat(check_unowned_schema_fields(store))
|
|
25
|
+
issues.concat(run_registered_checks(store))
|
|
23
26
|
|
|
24
27
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
25
28
|
{
|
|
@@ -255,6 +258,43 @@ module Textus
|
|
|
255
258
|
out
|
|
256
259
|
end
|
|
257
260
|
|
|
261
|
+
def run_registered_checks(store)
|
|
262
|
+
out = []
|
|
263
|
+
view = StoreView.new(store)
|
|
264
|
+
store.registry.doctor_check_names.each do |name|
|
|
265
|
+
callable = store.registry.doctor_check(name)
|
|
266
|
+
begin
|
|
267
|
+
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
|
|
268
|
+
if result.is_a?(Array)
|
|
269
|
+
out.concat(result.map { |h| h.transform_keys(&:to_s) })
|
|
270
|
+
else
|
|
271
|
+
out << fail_issue(name, code: "doctor_check.bad_return",
|
|
272
|
+
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
273
|
+
fix: "return an array of issue hashes from the doctor_check block")
|
|
274
|
+
end
|
|
275
|
+
rescue Timeout::Error
|
|
276
|
+
out << fail_issue(name, code: "doctor_check.timeout",
|
|
277
|
+
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
278
|
+
fix: "shorten the check or split it into smaller checks")
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
out << fail_issue(name, code: "doctor_check.failed",
|
|
281
|
+
message: "#{e.class}: #{e.message}",
|
|
282
|
+
fix: "fix the doctor_check block in .textus/extensions/")
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
out
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def fail_issue(name, code:, message:, fix:)
|
|
289
|
+
{
|
|
290
|
+
"code" => code,
|
|
291
|
+
"level" => "error",
|
|
292
|
+
"subject" => name.to_s,
|
|
293
|
+
"message" => message,
|
|
294
|
+
"fix" => fix,
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
|
|
258
298
|
# --- Helpers ----------------------------------------------------------
|
|
259
299
|
|
|
260
300
|
def leaf_path_for(store, entry)
|
|
@@ -3,16 +3,17 @@ module Textus
|
|
|
3
3
|
EVENTS = %i[put delete refresh build accept].freeze
|
|
4
4
|
|
|
5
5
|
def initialize
|
|
6
|
-
@
|
|
6
|
+
@actions = {}
|
|
7
7
|
@reducers = {}
|
|
8
8
|
@hooks = {}
|
|
9
|
+
@doctor_checks = {}
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
def
|
|
12
|
+
def register_action(name, &blk)
|
|
12
13
|
name = name.to_sym
|
|
13
|
-
raise UsageError.new("
|
|
14
|
+
raise UsageError.new("action '#{name}' already registered") if @actions.key?(name)
|
|
14
15
|
|
|
15
|
-
@
|
|
16
|
+
@actions[name] = blk
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def register_reducer(name, &blk)
|
|
@@ -29,8 +30,15 @@ module Textus
|
|
|
29
30
|
(@hooks[event] ||= []) << { name: name.to_sym, callable: blk }
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
def
|
|
33
|
-
|
|
33
|
+
def register_doctor_check(name, &blk)
|
|
34
|
+
name = name.to_sym
|
|
35
|
+
raise UsageError.new("doctor_check '#{name}' already registered") if @doctor_checks.key?(name)
|
|
36
|
+
|
|
37
|
+
@doctor_checks[name] = blk
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def action(name)
|
|
41
|
+
@actions[name.to_sym] or raise UsageError.new("unknown action: #{name}")
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
def reducer(name)
|
|
@@ -41,8 +49,13 @@ module Textus
|
|
|
41
49
|
@hooks[event.to_sym] || []
|
|
42
50
|
end
|
|
43
51
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
def doctor_check(name)
|
|
53
|
+
@doctor_checks[name.to_sym] or raise UsageError.new("unknown doctor_check: #{name}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def action_names = @actions.keys
|
|
57
|
+
def reducer_names = @reducers.keys
|
|
58
|
+
def hook_events = @hooks.keys
|
|
59
|
+
def doctor_check_names = @doctor_checks.keys
|
|
47
60
|
end
|
|
48
61
|
end
|
data/lib/textus/extensions.rb
CHANGED
|
@@ -15,8 +15,8 @@ module Textus
|
|
|
15
15
|
raise UsageError.new("no active registry; extension code must be loaded by a Store")
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def self.
|
|
19
|
-
current_registry.
|
|
18
|
+
def self.action(name, &)
|
|
19
|
+
current_registry.register_action(name, &)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def self.reducer(name, &)
|
|
@@ -26,4 +26,8 @@ module Textus
|
|
|
26
26
|
def self.hook(event, name, &)
|
|
27
27
|
current_registry.register_hook(event, name, &)
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
def self.doctor_check(name, &)
|
|
31
|
+
current_registry.register_doctor_check(name, &)
|
|
32
|
+
end
|
|
29
33
|
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -31,12 +31,13 @@ module Textus
|
|
|
31
31
|
File.write(File.join(target_root, "extensions", "README.md"), <<~MD)
|
|
32
32
|
# Extensions
|
|
33
33
|
|
|
34
|
-
Drop one Ruby file per extension.
|
|
34
|
+
Drop one Ruby file per extension. Four verbs are available:
|
|
35
35
|
|
|
36
36
|
```ruby
|
|
37
|
-
Textus.
|
|
38
|
-
Textus.reducer(:name) { |rows:, config:|
|
|
39
|
-
Textus.hook(:event, :name) { |key:, envelope:,
|
|
37
|
+
Textus.action(:name) { |config:, store:, args:| ... }
|
|
38
|
+
Textus.reducer(:name) { |rows:, config:| ... }
|
|
39
|
+
Textus.hook(:event, :name) { |key:, envelope:, **kw| ... }
|
|
40
|
+
Textus.doctor_check(:name) { |store:| ... }
|
|
40
41
|
```
|
|
41
42
|
|
|
42
43
|
Events: :put, :delete, :refresh, :build, :accept.
|
data/lib/textus/intro.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Textus
|
|
|
13
13
|
ZONE_PURPOSES = {
|
|
14
14
|
"canon" => "slow-changing identity; human-only writes",
|
|
15
15
|
"working" => "active project state; humans, AI, and scripts share this surface",
|
|
16
|
-
"intake" => "declared external inputs; script-refreshed via
|
|
16
|
+
"intake" => "declared external inputs; script-refreshed via actions",
|
|
17
17
|
"pending" => "AI proposals awaiting human accept",
|
|
18
18
|
"derived" => "build-computed outputs; never hand-edited",
|
|
19
19
|
}.freeze
|
|
@@ -22,7 +22,7 @@ module Textus
|
|
|
22
22
|
"human" => "edit files in canon/working zones, then 'textus put KEY --as=human'",
|
|
23
23
|
"ai" => "propose changes by writing 'pending.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
|
|
24
24
|
"a human runs 'textus accept' to apply",
|
|
25
|
-
"script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared
|
|
25
|
+
"script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
|
|
26
26
|
"build" => "'textus build' computes derived entries from projections; derived files are never hand-edited",
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
@@ -40,11 +40,11 @@ module Textus
|
|
|
40
40
|
{ "name" => "mv", "summary" => "rename a key in place; uid preserved, audit row written" },
|
|
41
41
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
42
|
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
|
|
43
|
-
{ "name" => "refresh", "summary" => "run
|
|
43
|
+
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
44
44
|
{ "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
|
|
45
45
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
46
46
|
{ "name" => "migrate-keys", "summary" => "rename files whose basenames violate the strict key grammar" },
|
|
47
|
-
{ "name" => "extensions", "summary" => "list registered reducers
|
|
47
|
+
{ "name" => "extensions", "summary" => "list registered actions, reducers, doctor_checks, declared hooks" },
|
|
48
48
|
].freeze
|
|
49
49
|
|
|
50
50
|
def self.run(store)
|
|
@@ -80,7 +80,7 @@ module Textus
|
|
|
80
80
|
"owner" => e.owner,
|
|
81
81
|
"format" => e.format,
|
|
82
82
|
"derived" => derived,
|
|
83
|
-
"intake" => !e.
|
|
83
|
+
"intake" => !e.action.nil?,
|
|
84
84
|
"publish_to" => Array(e.publish_to),
|
|
85
85
|
"publish_each" => e.publish_each,
|
|
86
86
|
}
|
|
@@ -90,13 +90,15 @@ module Textus
|
|
|
90
90
|
def self.extensions_for(store)
|
|
91
91
|
reg = store.registry
|
|
92
92
|
reducers = reg.reducer_names.map(&:to_s).sort
|
|
93
|
-
|
|
93
|
+
actions = reg.action_names.map(&:to_s).sort
|
|
94
|
+
doctor_checks = reg.doctor_check_names.map(&:to_s).sort
|
|
94
95
|
hooks = reg.hook_events.flat_map do |evt|
|
|
95
96
|
reg.hooks(evt).map { |h| { "event" => evt.to_s, "name" => h[:name].to_s } }
|
|
96
97
|
end.sort_by { |h| [h["event"], h["name"]] }
|
|
97
98
|
{
|
|
98
99
|
"reducers" => reducers,
|
|
99
|
-
"
|
|
100
|
+
"actions" => actions,
|
|
101
|
+
"doctor_checks" => doctor_checks,
|
|
100
102
|
"hooks" => hooks,
|
|
101
103
|
}
|
|
102
104
|
end
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -199,7 +199,7 @@ module Textus
|
|
|
199
199
|
PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
|
|
200
200
|
|
|
201
201
|
attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
|
|
202
|
-
:projection, :template, :publish_to, :publish_each, :
|
|
202
|
+
:projection, :template, :publish_to, :publish_each, :action, :action_config, :ttl, :events,
|
|
203
203
|
:inject_intro
|
|
204
204
|
|
|
205
205
|
def initialize(manifest, raw)
|
|
@@ -361,8 +361,8 @@ module Textus
|
|
|
361
361
|
|
|
362
362
|
def parse_source!(src)
|
|
363
363
|
src ||= {}
|
|
364
|
-
@
|
|
365
|
-
@
|
|
364
|
+
@action = src["action"]
|
|
365
|
+
@action_config = src["config"] || {}
|
|
366
366
|
@ttl = src["ttl"]
|
|
367
367
|
end
|
|
368
368
|
|
|
@@ -371,7 +371,13 @@ module Textus
|
|
|
371
371
|
if src.key?("parse") || src.key?("from")
|
|
372
372
|
raise UsageError.new(
|
|
373
373
|
"entry '#{@key}': source.parse/source.from removed in 0.2; " \
|
|
374
|
-
"use source.
|
|
374
|
+
"use source.action (+ source.config). See SPEC §5.4.",
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
if src.key?("fetcher")
|
|
378
|
+
raise UsageError.new(
|
|
379
|
+
"entry '#{@key}': source.fetcher renamed to source.action in 0.4; " \
|
|
380
|
+
"rename the key. See SPEC §5.4.",
|
|
375
381
|
)
|
|
376
382
|
end
|
|
377
383
|
if raw.key?("hooks")
|
data/lib/textus/refresh.rb
CHANGED
|
@@ -2,27 +2,29 @@ require "timeout"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Refresh
|
|
5
|
-
|
|
5
|
+
ACTION_TIMEOUT_SECONDS = 2
|
|
6
6
|
|
|
7
7
|
def self.call(store, key, as:)
|
|
8
8
|
mentry, path, = store.manifest.resolve(key)
|
|
9
|
-
raise UsageError.new("no
|
|
9
|
+
raise UsageError.new("no action declared for '#{key}'") unless mentry.action
|
|
10
10
|
|
|
11
11
|
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
12
|
-
callable = store.registry.
|
|
13
|
-
view = StoreView.new(store)
|
|
12
|
+
callable = store.registry.action(mentry.action)
|
|
13
|
+
view = StoreView.new(store, writable: true, as: as)
|
|
14
14
|
result =
|
|
15
15
|
begin
|
|
16
|
-
Timeout.timeout(
|
|
16
|
+
Timeout.timeout(ACTION_TIMEOUT_SECONDS) do
|
|
17
|
+
callable.call(config: mentry.action_config, store: view, args: {})
|
|
18
|
+
end
|
|
17
19
|
rescue Timeout::Error
|
|
18
|
-
raise UsageError.new("
|
|
20
|
+
raise UsageError.new("action '#{mentry.action}' exceeded #{ACTION_TIMEOUT_SECONDS}s timeout")
|
|
19
21
|
rescue Textus::Error
|
|
20
22
|
raise
|
|
21
23
|
rescue StandardError => e
|
|
22
|
-
raise UsageError.new("
|
|
24
|
+
raise UsageError.new("action '#{mentry.action}' raised: #{e.class}: #{e.message}")
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
normalized =
|
|
27
|
+
normalized = normalize_action_result(result, format: mentry.format)
|
|
26
28
|
envelope = store.put(
|
|
27
29
|
key,
|
|
28
30
|
frontmatter: normalized[:frontmatter],
|
|
@@ -43,9 +45,9 @@ module Textus
|
|
|
43
45
|
envelope
|
|
44
46
|
end
|
|
45
47
|
|
|
46
|
-
# Normalize the three accepted
|
|
47
|
-
# internal {frontmatter, body, content} representation.
|
|
48
|
-
def self.
|
|
48
|
+
# Normalize the three accepted action return shapes into the store's
|
|
49
|
+
# internal {frontmatter, body, content} representation.
|
|
50
|
+
def self.normalize_action_result(res, format:)
|
|
49
51
|
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
50
52
|
res ||= {}
|
|
51
53
|
fm = res["frontmatter"]
|
|
@@ -62,10 +64,9 @@ module Textus
|
|
|
62
64
|
meta = content.is_a?(Hash) && content["_meta"].is_a?(Hash) ? content["_meta"] : {}
|
|
63
65
|
{ frontmatter: meta, body: nil, content: content }
|
|
64
66
|
elsif !body.nil?
|
|
65
|
-
# Store#put will re-parse and validate the bytes.
|
|
66
67
|
{ frontmatter: {}, body: body.to_s, content: nil }
|
|
67
68
|
else
|
|
68
|
-
raise UsageError.new("
|
|
69
|
+
raise UsageError.new("action for #{format} returned neither content nor body")
|
|
69
70
|
end
|
|
70
71
|
else
|
|
71
72
|
raise UsageError.new("unknown format #{format.inspect}")
|
data/lib/textus/store.rb
CHANGED
|
@@ -17,7 +17,10 @@ module Textus
|
|
|
17
17
|
SecureRandom.hex(8)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def self.discover(start_dir = Dir.pwd)
|
|
20
|
+
def self.discover(start_dir = Dir.pwd, root: nil)
|
|
21
|
+
explicit = root || ENV.fetch("TEXTUS_ROOT", nil)
|
|
22
|
+
return discover_explicit(explicit) if explicit
|
|
23
|
+
|
|
21
24
|
dir = File.expand_path(start_dir)
|
|
22
25
|
loop do
|
|
23
26
|
candidate = File.join(dir, ".textus")
|
|
@@ -31,6 +34,13 @@ module Textus
|
|
|
31
34
|
raise IoError.new("no .textus directory found from #{start_dir}")
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
private_class_method def self.discover_explicit(root_arg)
|
|
38
|
+
abs = File.expand_path(root_arg)
|
|
39
|
+
raise IoError.new("no textus store at #{abs}") unless File.directory?(abs) && File.exist?(File.join(abs, "manifest.yaml"))
|
|
40
|
+
|
|
41
|
+
new(abs)
|
|
42
|
+
end
|
|
43
|
+
|
|
34
44
|
def initialize(root)
|
|
35
45
|
@root = File.expand_path(root)
|
|
36
46
|
@manifest = Manifest.load(@root)
|
|
@@ -41,7 +51,7 @@ module Textus
|
|
|
41
51
|
|
|
42
52
|
def load_extensions
|
|
43
53
|
Textus.with_registry(@registry) do
|
|
44
|
-
|
|
54
|
+
BuiltinActions.register_all
|
|
45
55
|
dir = File.join(@root, "extensions")
|
|
46
56
|
return unless File.directory?(dir)
|
|
47
57
|
|
|
@@ -286,7 +296,7 @@ module Textus
|
|
|
286
296
|
end
|
|
287
297
|
|
|
288
298
|
@manifest.entries.each do |mentry|
|
|
289
|
-
next unless mentry.
|
|
299
|
+
next unless mentry.action
|
|
290
300
|
next if zone && mentry.zone != zone
|
|
291
301
|
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
292
302
|
|
|
@@ -519,7 +529,7 @@ module Textus
|
|
|
519
529
|
end
|
|
520
530
|
|
|
521
531
|
def intake_stale_row(mentry, path, reason)
|
|
522
|
-
{ "key" => mentry.key, "path" => path, "
|
|
532
|
+
{ "key" => mentry.key, "path" => path, "action" => mentry.action, "reason" => reason }
|
|
523
533
|
end
|
|
524
534
|
|
|
525
535
|
def stale_row(mentry, path, reason)
|
data/lib/textus/store_view.rb
CHANGED
|
@@ -3,8 +3,12 @@ module Textus
|
|
|
3
3
|
READ_METHODS = %i[get list where schema_envelope deps rdeps published stale validate_all].freeze
|
|
4
4
|
WRITE_METHODS = %i[put delete accept].freeze
|
|
5
5
|
|
|
6
|
-
def initialize(store)
|
|
6
|
+
def initialize(store, writable: false, as: nil)
|
|
7
|
+
raise UsageError.new("writable StoreView requires an as: role") if writable && (as.nil? || as.to_s.empty?)
|
|
8
|
+
|
|
7
9
|
@store = store
|
|
10
|
+
@writable = writable
|
|
11
|
+
@as = as
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
READ_METHODS.each do |m|
|
|
@@ -12,7 +16,12 @@ module Textus
|
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
WRITE_METHODS.each do |m|
|
|
15
|
-
define_method(m)
|
|
19
|
+
define_method(m) do |*args, **kw|
|
|
20
|
+
raise UsageError.new("StoreView is read-only") unless @writable
|
|
21
|
+
|
|
22
|
+
kw[:as] = @as unless kw.key?(:as)
|
|
23
|
+
@store.public_send(m, *args, **kw)
|
|
24
|
+
end
|
|
16
25
|
end
|
|
17
26
|
end
|
|
18
27
|
end
|
data/lib/textus/version.rb
CHANGED
data/lib/textus.rb
CHANGED
|
@@ -19,7 +19,7 @@ require_relative "textus/store_view"
|
|
|
19
19
|
require_relative "textus/refresh"
|
|
20
20
|
require_relative "textus/mustache"
|
|
21
21
|
require_relative "textus/projection"
|
|
22
|
-
require_relative "textus/
|
|
22
|
+
require_relative "textus/builtin_actions"
|
|
23
23
|
require_relative "textus/publisher"
|
|
24
24
|
require_relative "textus/builder"
|
|
25
25
|
require_relative "textus/proposal"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -97,7 +97,7 @@ files:
|
|
|
97
97
|
- lib/textus.rb
|
|
98
98
|
- lib/textus/audit_log.rb
|
|
99
99
|
- lib/textus/builder.rb
|
|
100
|
-
- lib/textus/
|
|
100
|
+
- lib/textus/builtin_actions.rb
|
|
101
101
|
- lib/textus/cli.rb
|
|
102
102
|
- lib/textus/dependencies.rb
|
|
103
103
|
- lib/textus/doctor.rb
|