textus 0.3.0 → 0.5.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 +79 -1
- data/README.md +22 -18
- data/SPEC.md +49 -35
- data/docs/architecture.md +63 -28
- data/lib/textus/audit_log.rb +46 -11
- data/lib/textus/builder.rb +3 -3
- data/lib/textus/{builtin_fetchers.rb → builtin_actions.rb} +16 -11
- data/lib/textus/cli/accept.rb +13 -0
- data/lib/textus/cli/action.rb +51 -0
- data/lib/textus/cli/build.rb +11 -0
- data/lib/textus/cli/delete.rb +14 -0
- data/lib/textus/cli/deprecated_alias.rb +31 -0
- data/lib/textus/cli/deps.rb +10 -0
- data/lib/textus/cli/doctor.rb +13 -0
- data/lib/textus/cli/extension_group.rb +9 -0
- data/lib/textus/cli/extensions.rb +49 -0
- data/lib/textus/cli/get.rb +10 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/init.rb +12 -0
- data/lib/textus/cli/intro.rb +9 -0
- data/lib/textus/cli/key_group.rb +10 -0
- data/lib/textus/cli/list.rb +12 -0
- data/lib/textus/cli/migrate.rb +41 -0
- data/lib/textus/cli/migrate_keys.rb +19 -0
- data/lib/textus/cli/mv.rb +20 -0
- data/lib/textus/cli/published.rb +9 -0
- data/lib/textus/cli/put.rb +48 -0
- data/lib/textus/cli/rdeps.rb +10 -0
- data/lib/textus/cli/refresh.rb +13 -0
- data/lib/textus/cli/schema.rb +10 -0
- data/lib/textus/cli/schema_diff.rb +15 -0
- data/lib/textus/cli/schema_group.rb +33 -0
- data/lib/textus/cli/schema_init.rb +19 -0
- data/lib/textus/cli/schema_migrate.rb +19 -0
- data/lib/textus/cli/stale.rb +12 -0
- data/lib/textus/cli/uid.rb +15 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli/where.rb +10 -0
- data/lib/textus/cli.rb +65 -347
- data/lib/textus/doctor.rb +103 -32
- data/lib/textus/entry/json.rb +6 -4
- data/lib/textus/entry/markdown.rb +4 -4
- data/lib/textus/entry/text.rb +3 -3
- data/lib/textus/entry/yaml.rb +6 -4
- data/lib/textus/entry.rb +2 -2
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/extension_registry.rb +22 -9
- data/lib/textus/extensions.rb +6 -2
- data/lib/textus/init.rb +6 -5
- data/lib/textus/intro.rb +11 -9
- data/lib/textus/manifest.rb +11 -215
- data/lib/textus/manifest_entry.rb +185 -0
- data/lib/textus/migrate_v2.rb +27 -0
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/proposal.rb +3 -3
- data/lib/textus/refresh.rb +21 -20
- data/lib/textus/schema_tools.rb +8 -8
- data/lib/textus/store/events.rb +31 -0
- data/lib/textus/store/mover.rb +118 -0
- data/lib/textus/store/staleness.rb +142 -0
- data/lib/textus/store/validator.rb +53 -0
- data/lib/textus/store.rb +50 -355
- data/lib/textus/store_view.rb +11 -2
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +39 -1
- metadata +39 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 997772ea1cfaafc9f28bd57eda51cde19911ca527893c658aa6edb1329b1e2e6
|
|
4
|
+
data.tar.gz: a134c101fedfb2cd84c6cc21ab0522597ee1252cdaeaa84bc1b25bf977676538
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 666ac21f47159341408b2399f9051d0dad0fab406d08f73a83aa468ea245aa211f16b9749ccf80668dbdd304c5125fbd87644c7e16301c890538749c4a1c0b5e
|
|
7
|
+
data.tar.gz: f15b77854967509b1c4918dd8452557949f5f42ce6e1ebcd928b3225596d36dff043bf47eca632c8f46b8ba29873cd26792a09109430903402a6b52e3ac51d66
|
data/CHANGELOG.md
CHANGED
|
@@ -5,11 +5,89 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
7
|
The **gem version** (`0.x.y`) is distinct from the **protocol version**
|
|
8
|
-
(currently `textus/
|
|
8
|
+
(currently `textus/2`, embedded in every envelope as `protocol`). The protocol
|
|
9
9
|
is additive within a major; a new major would change the wire string.
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## 0.5.0 — Wire protocol `textus/2`; CLI restructure; Store split (breaking)
|
|
14
|
+
|
|
15
|
+
This release reshapes the public surface ahead of 1.0. The wire protocol bumps to `textus/2`; the CLI grows nested subcommand groups; `Store` is decomposed into a thin facade plus four focused helpers; the audit log finally matches its documented NDJSON shape; and a pile of pre-0.4 cruft gets cut.
|
|
16
|
+
|
|
17
|
+
### Wire protocol — `textus/1` → `textus/2`
|
|
18
|
+
|
|
19
|
+
- **Breaking:** every envelope now carries `"_meta"` instead of `"frontmatter"`. For json/yaml entries, envelope `content` no longer carries a duplicate `_meta` — the metadata lives only at the envelope's top level.
|
|
20
|
+
- **Breaking:** `Manifest.load` refuses `textus/1` manifests with a pointer at the new migration command. On-disk file shapes are unchanged — only the manifest version string changes.
|
|
21
|
+
- **New:** `textus migrate v2` flips `version: textus/1` to `version: textus/2` in `.textus/manifest.yaml`. One command, no file edits.
|
|
22
|
+
- **Internal cleanup:** `Store#extract_uid`, `enforce_name_match!`, `serialize_for_put`, `validate_all`, and `build_envelope` no longer format-switch — metadata access is uniform. Role-authority validation now works for json/yaml entries (was markdown-only).
|
|
23
|
+
- **API:** `Store#put` keyword renamed from `frontmatter:` to `meta:`. Action callbacks return `_meta:` (formerly `frontmatter:`).
|
|
24
|
+
|
|
25
|
+
### CLI — nested subcommand groups
|
|
26
|
+
|
|
27
|
+
- **New:** `textus key {mv, uid, migrate}`, `textus schema {show, init, diff, migrate}`, `textus extension {list, run}`. Discoverable, groupable, scales.
|
|
28
|
+
- **Deprecated (removed in 0.6):** the flat verbs `mv`, `uid`, `migrate-keys`, `schema-init`, `schema-diff`, `schema-migrate`, `extensions`, `action` still work but emit a stderr deprecation warning. `textus schema KEY` (positional dotted-key form) keeps working via a back-compat fallback in `SchemaGroup`.
|
|
29
|
+
- **New:** `textus list`, `textus get`, etc. default to JSON output. `--format=json` is still accepted; non-json values still raise.
|
|
30
|
+
- **CLI refactor:** `lib/textus/cli.rb` shrank from 434 LOC to ~100 LOC. Every verb is now a small command-object file under `lib/textus/cli/`. Dispatch is a frozen `VERBS` hash.
|
|
31
|
+
|
|
32
|
+
### Audit log — true NDJSON
|
|
33
|
+
|
|
34
|
+
- **Breaking:** `.textus/audit.log` rows are now one JSON object per line (`{"ts":..., "role":..., "verb":..., "key":..., "etag_before":..., "etag_after":...}`). Missing etags are `null`, not the string `"NULL"`.
|
|
35
|
+
- **Structural shape:** `from_key`, `to_key`, `uid` (mv rows) live at the top level; arbitrary contextual data goes into an `extras` sub-object that is omitted when empty.
|
|
36
|
+
- **Back-compat:** legacy TSV rows still parse during 0.5 — `AuditLog#last_writer_for` and `Doctor#check_audit_log` accept both formats. Legacy support removed in 0.6.
|
|
37
|
+
|
|
38
|
+
### Doctor
|
|
39
|
+
|
|
40
|
+
- **New:** `textus doctor --check=schema_violations[,name,…]` runs only the named built-in checks. The 9 built-ins are `manifest_files`, `schemas`, `templates`, `extensions`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`. Extension checks always run.
|
|
41
|
+
- **Breaking:** the standalone `textus validate-all` verb is gone. Use `textus doctor --check=schema_violations` instead. The internal `Store#validate_all` Ruby method is unchanged.
|
|
42
|
+
|
|
43
|
+
### Manifest / store cleanup
|
|
44
|
+
|
|
45
|
+
- **Breaking:** `LEGACY_ZONES` fallback removed. Manifests must declare a `zones:` block explicitly (init scaffold does this).
|
|
46
|
+
- **Breaking:** legacy syntax errors removed for `source.parse` / `source.from` / `source.fetcher` / top-level `hooks:`. Those names were rejected with helpful errors in 0.4; in 0.5 they get the generic "unknown key" error from YAML parsing.
|
|
47
|
+
- **Internal:** `ManifestEntry` moved to its own file (`lib/textus/manifest_entry.rb`).
|
|
48
|
+
|
|
49
|
+
### Store split
|
|
50
|
+
|
|
51
|
+
- **Internal:** `lib/textus/store.rb` shrank from 617 LOC to ~312 LOC. Four focused helpers live under `lib/textus/store/`:
|
|
52
|
+
- `events.rb` (31 LOC) — `fire_event` hook plumbing
|
|
53
|
+
- `validator.rb` (53 LOC) — `validate_all` body
|
|
54
|
+
- `staleness.rb` (142 LOC) — `stale` body (was 5 rubocop disables)
|
|
55
|
+
- `mover.rb` (118 LOC) — `mv` body
|
|
56
|
+
- No public-API change. `Store` facade delegates to each helper one-line.
|
|
57
|
+
|
|
58
|
+
### Migration cheat-sheet
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
# 1. Upgrade the gem
|
|
62
|
+
gem update textus # ≥ 0.5.0
|
|
63
|
+
|
|
64
|
+
# 2. Upgrade the store
|
|
65
|
+
cd /path/to/your/store
|
|
66
|
+
textus migrate v2 # flips manifest version
|
|
67
|
+
|
|
68
|
+
# 3. Anything else?
|
|
69
|
+
# - Audit log: existing TSV rows still readable; new rows are NDJSON.
|
|
70
|
+
# - CLI scripts: replace `textus mv ...` with `textus key mv ...`
|
|
71
|
+
# (and 7 similar aliases). Old forms work through 0.5 with a stderr warning.
|
|
72
|
+
# - Ruby callers of Store#put: pass `meta:` instead of `frontmatter:`.
|
|
73
|
+
# - Anything reading envelope["frontmatter"]: read envelope["_meta"] instead.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 0.4.0 — Extension API redesign (breaking)
|
|
77
|
+
|
|
78
|
+
- **Breaking:** `Textus.fetcher` removed. Use `Textus.action` instead. The block signature changes from `|config:, store:|` to `|config:, store:, args:|`.
|
|
79
|
+
- **Breaking:** Manifest field `source.fetcher` renamed to `source.action`. Legacy field is rejected with a migration error.
|
|
80
|
+
- **Breaking:** CLI flag `textus put --fetcher=NAME` renamed to `textus put --action=NAME`.
|
|
81
|
+
- **Breaking:** `BuiltinFetchers` module renamed to `BuiltinActions`.
|
|
82
|
+
- **Breaking:** Synthesized frontmatter key `fetched_with` renamed to `actioned_with` on `put --action`.
|
|
83
|
+
- **New:** `Textus.action` works in three invocation modes — intake refresh, the new `textus action NAME` verb, and `put --action`. See SPEC §5.11.
|
|
84
|
+
- **New:** `Textus.doctor_check(:name) { |store:| ... }` primitive; contributed checks merge into the doctor report.
|
|
85
|
+
- **New:** `textus action NAME [--key=val ...] [--as=ROLE]` CLI verb for invoking actions in verb mode.
|
|
86
|
+
- **New:** `StoreView` gains a writable mode (`writable: true, as: ROLE`); intake and verb-mode actions receive a writable view bound to the calling role.
|
|
87
|
+
- **New:** `extensions list` enumerates actions and doctor_checks.
|
|
88
|
+
|
|
89
|
+
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=`.
|
|
90
|
+
|
|
13
91
|
## [0.3.0] — 2026-05-20 — Configurable store root
|
|
14
92
|
|
|
15
93
|
### Added
|
data/README.md
CHANGED
|
@@ -45,12 +45,12 @@ You get `.textus/` with all five zone directories, baseline schemas, an empty au
|
|
|
45
45
|
audit.log # append-only NDJSON, every write
|
|
46
46
|
schemas/ # YAML field shapes per entry family
|
|
47
47
|
templates/ # mustache templates for derived entries
|
|
48
|
-
extensions/ # one .rb per
|
|
48
|
+
extensions/ # one .rb per action / reducer / hook / doctor_check
|
|
49
49
|
sentinels/ # publish bookkeeping
|
|
50
50
|
zones/
|
|
51
51
|
canon/ # human-only — identity, voice, decisions
|
|
52
52
|
working/ # human / ai / script — day-to-day catalog
|
|
53
|
-
intake/ # script — declared external inputs (
|
|
53
|
+
intake/ # script — declared external inputs (actions)
|
|
54
54
|
pending/ # ai + human — proposals awaiting accept
|
|
55
55
|
derived/ # build only — computed outputs
|
|
56
56
|
```
|
|
@@ -67,14 +67,14 @@ echo '{"frontmatter":{"name":"bob","org":"acme"},"body":"hi\n"}' \
|
|
|
67
67
|
textus stale --zone=derived --format=json
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
For the full shape — Claude plugin with agents, skills, commands, pending walkthrough, intake
|
|
70
|
+
For the full shape — Claude plugin with agents, skills, commands, pending walkthrough, intake action — see [`examples/claude-plugin/`](examples/claude-plugin/).
|
|
71
71
|
|
|
72
72
|
## What 0.2 ships
|
|
73
73
|
|
|
74
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
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
|
|
76
|
+
- **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key 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 key migrate --dry-run|--write` rewrites existing stores with illegal segments deterministically.
|
|
78
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
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
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.
|
|
@@ -93,21 +93,22 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
|
|
|
93
93
|
| `list [--prefix=K] [--zone=Z]` | Enumerate keys |
|
|
94
94
|
| `where K` | Resolve a key to its filesystem path |
|
|
95
95
|
| `get K` | Full envelope (frontmatter, body, uid, etag, format) |
|
|
96
|
-
| `schema K` | Schema bound to an entry |
|
|
96
|
+
| `schema show K` | Schema bound to an entry |
|
|
97
97
|
| `stale [--prefix=K] [--zone=Z]` | List stale derived/intake entries |
|
|
98
98
|
| `deps K` / `rdeps K` | Forward / reverse projection dependencies |
|
|
99
99
|
| `published` | List `publish_to:` targets and their backing keys |
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
100
|
+
| `doctor --check=schema_violations` | Validate every entry against its schema |
|
|
101
|
+
| `extension list [--kind=K]` | Registered actions, reducers, hooks, doctor_checks |
|
|
102
102
|
|
|
103
103
|
**Write:**
|
|
104
104
|
|
|
105
105
|
| Verb | Role |
|
|
106
106
|
|---|---|
|
|
107
|
-
| `put K --stdin --as=R [--
|
|
107
|
+
| `put K --stdin --as=R [--action=NAME]` | per zone |
|
|
108
|
+
| `extension run NAME [--key=val] [--as=R]` | per zone written (invoke a registered action) |
|
|
108
109
|
| `delete K --if-etag=E --as=R` | per zone |
|
|
109
110
|
| `refresh K --as=script` | per zone (typically `script`) |
|
|
110
|
-
| `mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
|
|
111
|
+
| `key mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
|
|
111
112
|
| `build [--prefix=K] [--dry-run]` | `build` |
|
|
112
113
|
| `accept K --as=human` | `human` only |
|
|
113
114
|
|
|
@@ -116,16 +117,18 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
|
|
|
116
117
|
| Verb | Purpose |
|
|
117
118
|
|---|---|
|
|
118
119
|
| `doctor` | 8 health checks; `ok: true` when clean |
|
|
119
|
-
| `migrate
|
|
120
|
+
| `key migrate [--dry-run]` | Rename files whose basenames violate the strict key grammar |
|
|
120
121
|
|
|
121
122
|
**Scaffolding (human-only):**
|
|
122
123
|
|
|
123
124
|
| Verb | Purpose |
|
|
124
125
|
|---|---|
|
|
125
126
|
| `init` | Scaffold a fresh `.textus/` |
|
|
126
|
-
| `schema
|
|
127
|
-
| `schema
|
|
128
|
-
| `schema
|
|
127
|
+
| `schema init NAME` | Stub a schema |
|
|
128
|
+
| `schema diff NAME` | Compare a schema against entries that claim it |
|
|
129
|
+
| `schema migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
|
|
130
|
+
|
|
131
|
+
**Deprecated (removed in 0.6):** `mv`, `uid`, `migrate-keys`, `schema-init`, `schema-diff`, `schema-migrate`, `extensions`, `action`.
|
|
129
132
|
|
|
130
133
|
## Zones and roles
|
|
131
134
|
|
|
@@ -133,7 +136,7 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
|
|
|
133
136
|
|---|---|---|
|
|
134
137
|
| `canon` | `[human]` | Identity, voice, decisions — slow-changing |
|
|
135
138
|
| `working` | `[human, ai, script]` | Active project state |
|
|
136
|
-
| `intake` | `[script]` | Declared external inputs (
|
|
139
|
+
| `intake` | `[script]` | Declared external inputs (actions) |
|
|
137
140
|
| `pending` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
|
|
138
141
|
| `derived` | `[build]` | Computed outputs from `textus build` |
|
|
139
142
|
|
|
@@ -147,17 +150,18 @@ Derived entries declare a `projection:` (`select`, `pluck`, `sort_by`, `limit`,
|
|
|
147
150
|
|
|
148
151
|
## Extensions
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
Four DSL verbs, registered in `.textus/extensions/*.rb`. Each `Store` gets its own registry — no global state.
|
|
151
154
|
|
|
152
|
-
- **`Textus.
|
|
155
|
+
- **`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`.
|
|
153
156
|
- **`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).
|
|
154
157
|
- **`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.
|
|
158
|
+
- **`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.
|
|
155
159
|
|
|
156
160
|
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.
|
|
157
161
|
|
|
158
162
|
## Examples
|
|
159
163
|
|
|
160
|
-
[`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
|
|
164
|
+
[`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`.
|
|
161
165
|
|
|
162
166
|
## Tests
|
|
163
167
|
|
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
|
|
@@ -250,38 +250,38 @@ A sentinel is written for each published file at `<store_root>/sentinels/<target
|
|
|
250
250
|
|
|
251
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.
|
|
252
252
|
|
|
253
|
-
### 5.4 Intake (declared, refreshed via registered
|
|
253
|
+
### 5.4 Intake (declared, refreshed via registered action)
|
|
254
254
|
|
|
255
|
-
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:
|
|
256
256
|
|
|
257
257
|
```yaml
|
|
258
258
|
- key: intake.calendar.events
|
|
259
259
|
zone: intake
|
|
260
260
|
source:
|
|
261
|
-
|
|
261
|
+
action: ical-events
|
|
262
262
|
config:
|
|
263
263
|
url: "https://calendar.google.com/.../basic.ics"
|
|
264
264
|
ttl: 6h
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
`
|
|
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.
|
|
268
268
|
|
|
269
|
-
**
|
|
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):
|
|
270
270
|
|
|
271
|
-
- `{ frontmatter:, body: }` — markdown-friendly
|
|
271
|
+
- `{ frontmatter:, body: }` — markdown-friendly.
|
|
272
272
|
- `{ content: }` — for `format: json|yaml` entries; the parsed object becomes the entry's content.
|
|
273
273
|
- `{ body: }` — raw bytes for `text` or for any format that prefers verbatim writes; the store re-parses and validates per `format:`.
|
|
274
274
|
|
|
275
|
-
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.
|
|
276
276
|
|
|
277
|
-
**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.
|
|
278
278
|
|
|
279
279
|
**Refresh paths.** Two are supported:
|
|
280
280
|
|
|
281
|
-
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`.
|
|
282
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`.
|
|
283
283
|
|
|
284
|
-
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).
|
|
285
285
|
|
|
286
286
|
### 5.5 Pending / accept workflow
|
|
287
287
|
|
|
@@ -306,15 +306,19 @@ Proposed body content.
|
|
|
306
306
|
|
|
307
307
|
### 5.6 Audit log
|
|
308
308
|
|
|
309
|
-
Every successful write appends one
|
|
309
|
+
Every successful write appends one compact JSON object (NDJSON) to `.textus/audit.log`. The file is opened with `flock(LOCK_EX)` for the duration of each append so concurrent writers serialize cleanly.
|
|
310
310
|
|
|
311
|
-
Schema (
|
|
311
|
+
Schema (one JSON object per line, no interior whitespace):
|
|
312
312
|
|
|
313
|
+
```json
|
|
314
|
+
{"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
313
315
|
```
|
|
314
|
-
<iso8601-utc>\t<role>\t<verb>\t<key>\t<etag-before-or-NULL>\t<etag-after-or-NULL>
|
|
315
|
-
```
|
|
316
316
|
|
|
317
|
-
|
|
317
|
+
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the CLI verb (`put`, `delete`, `accept`, `compute`, `migrate-keys`, `mv`, ...). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag). `migrate-keys --write` emits one line per renamed file using the new key as `key` and the file's pre- and post-rename etags.
|
|
318
|
+
|
|
319
|
+
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.
|
|
320
|
+
|
|
321
|
+
**Backward compatibility (v0.5):** files written by v0.4 and earlier contain TSV rows. Readers MUST accept mixed-format files: lines starting with `{` are parsed as JSON; other lines are treated as legacy TSV (`ts\trole\tverb\tkey\tetag_before\tetag_after[\tjson_extras]`). TSV write support is removed in v0.6.
|
|
318
322
|
|
|
319
323
|
### 5.7 Security bounds
|
|
320
324
|
|
|
@@ -355,7 +359,7 @@ evolution:
|
|
|
355
359
|
|
|
356
360
|
**Backwards compat:** v1.0 schemas (no `fields:`, no `evolution:`) continue to parse and behave identically. `schema.maintained_by(field)` returns `nil` for every field; `schema.evolution` returns `{}`.
|
|
357
361
|
|
|
358
|
-
**Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. This preserves human authority over AI/script-managed data — humans curating canon over AI-written embeddings is a feature, not a bug. All other role mismatches are reported by `
|
|
362
|
+
**Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. This preserves human authority over AI/script-managed data — humans curating canon over AI-written embeddings is a feature, not a bug. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
|
|
359
363
|
|
|
360
364
|
### 5.9 Reducers (v1.2)
|
|
361
365
|
|
|
@@ -403,7 +407,7 @@ Lifecycle events fire in-process. Subscribers register via `Textus.hook(:event,
|
|
|
403
407
|
|
|
404
408
|
`:refresh` with `change: :unchanged` does NOT fire — only `:created` and `:updated` are emitted. The `store:` kwarg is always a `Textus::StoreView` (§5.11).
|
|
405
409
|
|
|
406
|
-
**Timeout and isolation.** Each hook runs under `Timeout.timeout(2)`. Hook errors and timeouts are recorded as `event_error` rows in `.textus/audit.log` (
|
|
410
|
+
**Timeout and isolation.** Each hook runs under `Timeout.timeout(2)`. Hook errors and timeouts are recorded as `event_error` rows in `.textus/audit.log` (NDJSON with an `extras` object carrying `event`, `hook`, `error`) but do NOT abort the triggering operation. The store write that fired the event is already committed by the time hooks run.
|
|
407
411
|
|
|
408
412
|
**Manifest declarations.** A manifest entry MAY declare external-runner hooks under an `events:` block, keyed by event name:
|
|
409
413
|
|
|
@@ -419,29 +423,39 @@ Textus does NOT invoke these — they surface only through `textus extensions li
|
|
|
419
423
|
|
|
420
424
|
**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.
|
|
421
425
|
|
|
422
|
-
### 5.11 Extension surface (v1.
|
|
426
|
+
### 5.11 Extension surface (v1.3)
|
|
423
427
|
|
|
424
|
-
|
|
428
|
+
Four DSL verbs cover all user-supplied code:
|
|
425
429
|
|
|
426
430
|
```
|
|
427
|
-
Textus.
|
|
428
|
-
Textus.reducer(:name)
|
|
429
|
-
Textus.hook(:event, :name)
|
|
431
|
+
Textus.action(:name) do |config:, store:, args:| ... end # intake mode: returns content; verb mode: writes via store.put
|
|
432
|
+
Textus.reducer(:name) do |rows:, config:| ... end # returns rows
|
|
433
|
+
Textus.hook(:event, :name) do |**kwargs| ... end # side effects; return ignored
|
|
434
|
+
Textus.doctor_check(:name) do |store:| ... end # returns array of issue hashes
|
|
430
435
|
```
|
|
431
436
|
|
|
432
437
|
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.
|
|
433
438
|
|
|
434
|
-
|
|
439
|
+
**Action invocation modes.**
|
|
440
|
+
|
|
441
|
+
| Mode | Invoked by | `config:` | `store:` | `args:` | Return |
|
|
442
|
+
|---------|---------------------------|------------------------|------------------------|----------------------|------------------------------------------|
|
|
443
|
+
| intake | `textus refresh KEY` | manifest `source.config` | writable view (role from `--as`) | `{}` | required; normalized into entry write |
|
|
444
|
+
| verb | `textus action NAME ...` | `{}` | writable view (role from `--as`) | parsed CLI kv hash | ignored |
|
|
445
|
+
| put-fetch | `textus put K --action=N --stdin` | `{ "bytes" => stdin }` | read-only view | `{}` | required; merged into the put payload |
|
|
446
|
+
|
|
447
|
+
**Failure modes:**
|
|
435
448
|
|
|
436
|
-
| Surface
|
|
437
|
-
|
|
438
|
-
|
|
|
439
|
-
| reducer
|
|
440
|
-
| hook
|
|
449
|
+
| Surface | Timeout | Exception | Bad return |
|
|
450
|
+
|-----------------|------------|---------------------------------------------|------------|
|
|
451
|
+
| action | aborts op | aborts op (wrapped as `UsageError`) | aborts op |
|
|
452
|
+
| reducer | aborts op | aborts op | aborts op |
|
|
453
|
+
| hook | logged | logged (audit `event_error` row) | n/a |
|
|
454
|
+
| doctor_check | reported as `doctor_check.timeout` issue | reported as `doctor_check.failed` issue | reported as `doctor_check.bad_return` |
|
|
441
455
|
|
|
442
|
-
|
|
456
|
+
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.
|
|
443
457
|
|
|
444
|
-
The `store:` argument is always a `Textus::StoreView
|
|
458
|
+
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`.
|
|
445
459
|
|
|
446
460
|
### 5.12 Storage formats (v1.2)
|
|
447
461
|
|
|
@@ -584,8 +598,8 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
|
|
|
584
598
|
| `stale [--prefix=K] [--strict]` | read | any |
|
|
585
599
|
| `deps K` / `rdeps K` | read | any |
|
|
586
600
|
| `published` | read | any |
|
|
587
|
-
| `
|
|
588
|
-
| `put K --stdin --as=R [--
|
|
601
|
+
| `doctor --check=schema_violations` | read | any |
|
|
602
|
+
| `put K --stdin --as=R [--action=NAME]` | write | per zone |
|
|
589
603
|
| `delete K --if-etag=E --as=R` | write | per zone |
|
|
590
604
|
| `refresh K --as=script` | write | per zone (typically `script`) |
|
|
591
605
|
| `build [--prefix=K] [--dry-run]` | write | `build` (default) |
|
|
@@ -595,8 +609,8 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
|
|
|
595
609
|
| `migrate-keys [--dry-run\|--write]` | write (with `--write`) | `human` |
|
|
596
610
|
| `mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
|
|
597
611
|
| `uid K` | read | any |
|
|
598
|
-
| `extensions list [--kind=
|
|
599
|
-
| `doctor [--format=json]` | read | any |
|
|
612
|
+
| `extensions list [--kind=action\|reducer\|hook]` | read | any |
|
|
613
|
+
| `doctor [--check=NAME[,NAME]] [--format=json]` | read | any |
|
|
600
614
|
| `intro [--format=json]` | read | any |
|
|
601
615
|
|
|
602
616
|
**`put` input** (read from stdin when `--stdin` is given):
|
data/docs/architecture.md
CHANGED
|
@@ -2,56 +2,91 @@
|
|
|
2
2
|
|
|
3
3
|
How the reference Ruby implementation is organized. The wire protocol itself lives in [`../SPEC.md`](../SPEC.md); this document covers *how* the gem implements that spec.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The codebase is a flat graph of small modules under one CLI dispatcher, not a strict pyramid. The clusters below describe what each module exists for and which other modules it talks to.
|
|
6
|
+
|
|
7
|
+
## At a glance
|
|
6
8
|
|
|
7
9
|
```
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
└──────────────────────────────────────────────┘
|
|
10
|
+
exe/textus → Textus::CLI ──┬──► Store (verb impl: get/put/list/stale/refresh/accept/…)
|
|
11
|
+
├──► Builder (build verb)
|
|
12
|
+
├──► Refresh (refresh verb)
|
|
13
|
+
├──► Doctor (doctor verb)
|
|
14
|
+
├──► Init (init verb)
|
|
15
|
+
├──► Intro (intro verb)
|
|
16
|
+
├──► MigrateKeys (migrate-keys, mv verbs)
|
|
17
|
+
├──► SchemaTools (schema-init/diff/migrate verbs)
|
|
18
|
+
├──► StoreView (read-only projection over Store)
|
|
19
|
+
└──► Role (role gate)
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
CLI is the single entry point. It parses argv and dispatches each verb to whichever module owns that capability — there is no single mediator below CLI.
|
|
23
|
+
|
|
24
|
+
## Module clusters
|
|
25
|
+
|
|
26
|
+
### 1. Request path — core read/write verbs
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
`Store` (617 LOC) owns the `get`, `put`, `list`, `delete`, `stale`, and proposal-acceptance verbs. It is the largest module and the only one that touches the working-store filesystem for primary read/write. It uses:
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
- **`Manifest`** — parses `.textus/manifest.yaml`; resolves a dotted key to a path via longest-prefix match. `nested: true` entries treat unmatched suffix segments as `/`-joined subdirs, with `.md` appended. Resolution is path-only; existence is the verb's concern.
|
|
31
|
+
- **`Schema`** — loads YAML schema files; validates frontmatter shape and surfaces unknown-key warnings (the §6 forward-compat rule).
|
|
32
|
+
- **`Entry`** + format adapters (`entry/markdown.rb`, `entry/text.rb`, `entry/json.rb`, `entry/yaml.rb`) — splits raw bytes on `---\n`, feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). The frontmatter `name:` field is enforced against the file basename inside `Store` (on read and on write) — mismatch raises `bad_frontmatter`.
|
|
33
|
+
- **`Etag`** — `sha256:<hex>` over raw file bytes. `put` accepts optional `if_etag:`; mismatch raises `etag_mismatch`. No locking, no temp-file-and-rename — v1 leaves stronger guarantees to v1.x.
|
|
34
|
+
- **`Role`** — agent-vs-human gate. `Store#put` checks `ManifestEntry#agent_writable?` (true only for `state`) before doing anything else; otherwise raises `write_forbidden`.
|
|
35
|
+
- **`AuditLog`** — append-only NDJSON; every successful write emits one line.
|
|
36
|
+
- **`Proposal`** — `accept` verb flow for promoting a pending entry into its target zone.
|
|
37
|
+
- **`Dependencies`** — `deps`/`rdeps`/`published` verb backing; walks manifest declarations.
|
|
26
38
|
|
|
27
|
-
|
|
39
|
+
### 2. Build / publish pipeline
|
|
28
40
|
|
|
29
|
-
|
|
41
|
+
Separate from the request path. Owns derived-entry materialization and byte-copy publish.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Builder ──► Projection ──► Mustache ──► Entry ──► Publisher ──► (sentinel)
|
|
45
|
+
```
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
- **`Builder`** — iterates `zone: derived` entries, materializes each by running its declared template + projection, parses the rendered output back through `Entry`, and hands the bytes to `Publisher`.
|
|
48
|
+
- **`Projection`** — collects rows from manifest-declared source keys, applies optional reducer, sorts and positions. Pure data shaping.
|
|
49
|
+
- **`Mustache`** — minimal mustache renderer for templates in `.textus/templates/`.
|
|
50
|
+
- **`Publisher`** — byte-copy from store path to external target path. Refuses to overwrite unmanaged targets; writes a sentinel in `.textus/sentinels/` to track managed targets.
|
|
32
51
|
|
|
33
|
-
|
|
52
|
+
### 3. Extension surface
|
|
34
53
|
|
|
35
|
-
|
|
54
|
+
Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
|
|
36
55
|
|
|
37
|
-
|
|
56
|
+
- **`Extensions`** — declarative manifest schema for action/reducer/hook/doctor_check extensions.
|
|
57
|
+
- **`ExtensionRegistry`** — loads one `.rb` per extension from `.textus/extensions/`, registers callables under their declared names.
|
|
58
|
+
- **`BuiltinActions`** — ships built-in actions (e.g. json, csv, ical-events, rss) available without user extensions.
|
|
59
|
+
- **`Refresh`** — `refresh` verb: looks up the action for a key, invokes it, normalizes the result by declared format, writes through `Store` with an etag check.
|
|
38
60
|
|
|
39
|
-
|
|
61
|
+
### 4. Operational tooling
|
|
40
62
|
|
|
41
|
-
|
|
63
|
+
First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; side modules off CLI.
|
|
42
64
|
|
|
43
|
-
|
|
65
|
+
- **`Doctor`** — `doctor` verb: validates manifest, schemas, extensions, and (via `MigrateKeys`) suggests key migrations. Talks to Manifest/Schema/Entry/ExtensionRegistry directly.
|
|
66
|
+
- **`MigrateKeys`** — `migrate-keys` and `mv` verbs; computes renames against the manifest.
|
|
67
|
+
- **`SchemaTools`** — `schema-init`, `schema-diff`, `schema-migrate` verbs.
|
|
68
|
+
- **`Init`** — `init` verb: scaffolds `.textus/` with the five zone directories, baseline schemas, empty audit log, starter manifest.
|
|
69
|
+
- **`Intro`** — `intro` verb: emits the human/agent-facing onboarding payload.
|
|
70
|
+
- **`StoreView`** — read-only projection over `Store` for code that should not mutate.
|
|
71
|
+
- **`KeyDistance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
|
|
44
72
|
|
|
45
|
-
|
|
73
|
+
### 5. Primitives
|
|
46
74
|
|
|
47
|
-
|
|
75
|
+
- **`Errors`** — `Textus::Error` subclasses, each carrying a stable `code`, a `details` hash, and an `exit_code`. `CLI` catches them at the top level and emits the §8 error envelope on stdout. In `--format=json` mode, errors are **never** written to stderr — agents read stdout.
|
|
76
|
+
- **`version`** — gem semver string (independent of the wire protocol `textus/1`).
|
|
48
77
|
|
|
49
|
-
##
|
|
78
|
+
## Invariants
|
|
50
79
|
|
|
51
|
-
|
|
80
|
+
- **CLI is the only entry point.** No public API surface guarantees outside the verbs CLI exposes.
|
|
81
|
+
- **Manifest is pure.** Reads at load, no mutation.
|
|
82
|
+
- **Store is the only module that writes to working-store entry files.** Init, MigrateKeys, Publisher, Builder, AuditLog write to **other** parts of `.textus/` (scaffolding, sentinels, audit log, derived targets) — they do not edit existing entry files behind Store's back.
|
|
83
|
+
- **`name:` frontmatter matches file basename.** Enforced on read and write.
|
|
84
|
+
- **Zone semantics live in the manifest, not in directory names.** A project may rename `state/` to anything; the manifest declares which zone each entry belongs to.
|
|
85
|
+
- **`stale` does not execute anything.** It walks `zone: derived` entries with a `generator:` block, compares `generated.at` against source mtimes, and returns offenders **plus their declared `command`**. Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
|
|
52
86
|
|
|
53
87
|
## What this implementation deliberately leaves out
|
|
54
88
|
|
|
55
89
|
- **No process spawning.** Even `stale` does not execute. Build runners do that.
|
|
56
90
|
- **No transport.** No HTTP server, no socket, no MCP server in this gem. Those are downstream wrappers (see [`./conventions.md`](./conventions.md)).
|
|
57
91
|
- **No indexes.** Listing walks the filesystem each time. Premature optimisation for v1.
|
|
92
|
+
- **No locking.** Etag is advisory; concurrent writers can still race. Left to v1.x (§14 open question).
|
data/lib/textus/audit_log.rb
CHANGED
|
@@ -10,23 +10,58 @@ module Textus
|
|
|
10
10
|
def last_writer_for(key)
|
|
11
11
|
return nil unless File.exist?(@path)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
last_role = nil
|
|
14
|
+
File.foreach(@path) do |line|
|
|
15
|
+
parsed = parse_row(line.chomp)
|
|
16
|
+
next unless parsed
|
|
17
|
+
next unless parsed["key"] == key
|
|
18
|
+
next unless %w[put delete].include?(parsed["verb"])
|
|
19
|
+
|
|
20
|
+
last_role = parsed["role"]
|
|
21
|
+
end
|
|
22
|
+
last_role
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
19
|
-
|
|
20
|
-
Time.now.utc.iso8601,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
row = {
|
|
27
|
+
"ts" => Time.now.utc.iso8601,
|
|
28
|
+
"role" => role,
|
|
29
|
+
"verb" => verb,
|
|
30
|
+
"key" => key,
|
|
31
|
+
"etag_before" => etag_before,
|
|
32
|
+
"etag_after" => etag_after,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if extras.is_a?(Hash) && !extras.empty?
|
|
36
|
+
extras = extras.dup
|
|
37
|
+
%w[from_key to_key uid].each do |k|
|
|
38
|
+
row[k] = extras.delete(k) if extras.key?(k)
|
|
39
|
+
end
|
|
40
|
+
row["extras"] = extras unless extras.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
26
43
|
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
27
44
|
f.flock(File::LOCK_EX)
|
|
28
|
-
f.write(
|
|
45
|
+
f.write(JSON.generate(row) + "\n")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_row(line)
|
|
52
|
+
return nil if line.empty?
|
|
53
|
+
|
|
54
|
+
if line.start_with?("{")
|
|
55
|
+
JSON.parse(line)
|
|
56
|
+
else
|
|
57
|
+
# Legacy TSV: ts, role, verb, key, etag_before, etag_after [, json_extras]
|
|
58
|
+
fields = line.split("\t")
|
|
59
|
+
return nil if fields.length < 4
|
|
60
|
+
|
|
61
|
+
{ "ts" => fields[0], "role" => fields[1], "verb" => fields[2], "key" => fields[3] }
|
|
29
62
|
end
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
nil
|
|
30
65
|
end
|
|
31
66
|
end
|
|
32
67
|
end
|