textus 0.10.4 → 0.10.5
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 +25 -1
- data/README.md +8 -62
- data/SPEC.md +50 -0
- data/docs/conventions.md +47 -15
- data/lib/textus/application/writes/delete.rb +1 -2
- data/lib/textus/application/writes/put.rb +2 -5
- data/lib/textus/manifest/entry.rb +34 -1
- data/lib/textus/manifest.rb +35 -31
- data/lib/textus/store/mover.rb +91 -50
- data/lib/textus/store/staleness/generator_check.rb +88 -0
- data/lib/textus/store/staleness/intake_check.rb +46 -0
- data/lib/textus/store/staleness.rb +9 -104
- data/lib/textus/store/writer.rb +13 -11
- data/lib/textus/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79987029ce43500b025495ef26cefc285f7a748cdcd43388aff2fefafdf4c0ca
|
|
4
|
+
data.tar.gz: 44a9d40720a84e4e942a1c4fde9e0ed9e7773126f443b247f6d8cb27d630c204
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a539030b1d406226bbe3d3fad03b3daee59094d3d8db351d97c9c5adc445ca8c188a7d13a16cdfaaae0c933091e6a8858c14a91362f461f25a1dce52aa04a60f
|
|
7
|
+
data.tar.gz: 83667dab7c91d4c2e9def3c2a2d605cf45d84f3fdf89bf0e6d8d25ff2b8fe668d6b71ceae0488e7ffe24970bf80e0f5eeeb13c308b2d16c9d11fc41633160dd6
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
|
|
|
8
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
|
+
## [Unreleased]
|
|
12
|
+
|
|
13
|
+
## 0.10.5 — tech-debt cleanup + `index_filename:` + docs polish (2026-05-25)
|
|
14
|
+
|
|
15
|
+
Patch release. One user-facing feature (`index_filename:` on nested manifest entries) plus internal refactors that remove 7 of 19 `rubocop:disable` suppressions. No protocol bump; existing manifests parse unchanged.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Per-entry `index_filename:` on nested manifest entries.** A nested entry MAY declare `index_filename: SKILL.md` (or any other bare basename) to surface that single file per directory as the row; the row's key segments come from the directory path, and siblings are not enumerated. Lets entries project spec-mandated filenames (e.g. agentskills.io's `SKILL.md`) whose uppercase casing would otherwise be rejected by the `[a-z0-9][a-z0-9-]*` key-segment grammar. `resolve(key)` returns the index-filename path for sub-directories. Validation: requires `nested: true`, basename only (no slashes), extension must match the entry's `format:`. New spec `spec/manifest_index_filename_spec.rb`. Documented in SPEC §4.
|
|
20
|
+
|
|
21
|
+
### Internal
|
|
22
|
+
|
|
23
|
+
- **`Store::Mover#call` refactor.** Replaces an 81-line method (suppressed `Metrics/AbcSize, Metrics/MethodLength`) with an 8-line orchestrator sequenced over four named private phases — `prepare_plan`, `ensure_uid!`, `perform_move`, `record_move` — coordinated through a `MovePlan` value object. The pre-read envelope is threaded separately so `MovePlan` describes only the planned operation.
|
|
24
|
+
- **`Store::Staleness#call` split.** Replaces a 70-line dual-loop method (the most aggressive suppression in the gem — `AbcSize, CyclomaticComplexity, MethodLength, PerceivedComplexity, BlockLength`) with a composer + two single-purpose checks (`GeneratorCheck`, `IntakeCheck`) and a private filter method on the composer. Each new unit fits default rubocop thresholds.
|
|
25
|
+
- **`Store::Writer` payload + ctx grouping.** Collapses `write_envelope_to_disk`'s 8 keyword args to 5 by introducing `Store::Writer::Payload = Data.define(:meta, :body, :content)` and reusing `Application::Context` for `role` + `correlation_id`. Applies the same `ctx:` pattern to the sibling `delete_envelope_from_disk`. The class-wide `Metrics/ParameterLists` disable is narrowed to a method-level disable on the `put` back-compat shim (which mirrors the user-facing `Store#put` 7-kwarg signature and cannot be changed).
|
|
26
|
+
- **Net suppression change:** 19 `rubocop:disable` lines → 17; 7 metric-cop suppressions removed; one `ParameterLists` disable narrowed in scope. Full suite unchanged.
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
- **SPEC.md §5.2.1 added.** Documents the `generator:` field — the externally-generated-derived-entry shape (build runner produces the file; textus tracks `sources:` for staleness via `_meta.generated.at`). The field was always parsed and tested but had no spec coverage. Clarifies that textus never executes `command:` — consistent with §2 "Not an executor."
|
|
31
|
+
- **README.md trimmed.** Removed the duplicated "CLI verbs" and "Zones and roles" tables; readers are pointed at SPEC §5 / §9 and `docs/zones.md` for the canonical surfaces. README narrative kept.
|
|
32
|
+
- **docs/conventions.md** now covers both derived-entry shapes (`projection:` for declarative compute inside textus; `generator:` for external build tools) and the current intake / freshness model (top-level `policies:` + `textus refresh-stale`). Replaces a stale section that described a pre-0.4 build-runner pattern.
|
|
33
|
+
- **CONTRIBUTING.md** sources-of-truth pointer updated. Per-release implementation plans are kept locally by maintainers and no longer signposted in public docs.
|
|
34
|
+
|
|
11
35
|
## 0.10.4 — GitHub folder intake recipe + skill-bundle deferral ADR (2026-05-24)
|
|
12
36
|
|
|
13
37
|
Patch release. Ships a working "pull a GitHub folder as a skill bundle, fan it out to derived entries" pattern as opt-in example hooks under `examples/claude-plugin/recipes/`, with hermetic specs and user-facing docs. Captures the design decision to defer first-class skill-bundle support to a future release. No `lib/` changes; CLI, wire protocol, event surface, manifest schema, and doctor checks are all unchanged.
|
|
@@ -42,7 +66,7 @@ Patch release. Two pieces of work: (1) docs describe current state only — ever
|
|
|
42
66
|
|
|
43
67
|
### Documentation
|
|
44
68
|
|
|
45
|
-
- **`docs/
|
|
69
|
+
- **`CONTRIBUTING.md`** now points readers at SPEC / ARCHITECTURE / `docs/` / CHANGELOG as the sources of truth. Per-release implementation plans are kept locally by maintainers and are no longer signposted in public docs.
|
|
46
70
|
- **README, SPEC, ARCHITECTURE, docs/zones, docs/events, docs/conventions, examples/claude-plugin/README** — stripped `(0.8.2+)`, `(0.9.0+)`, `(0.9.2)`, `(v1.0)`, `(v1.1)`, `(v1.2)`, `(v0.3)` annotations from headings, parentheticals, and inline notes. Removed "Renamed in 0.9.2" / "Pre-0.9.2 stores" / "New in 0.9.0" / "Backward compatibility (v0.5)" callouts. Example code that used pre-0.9.2 zone names (`canon`, `intake`, `pending`, `derived`) now uses current names (`identity`, `inbox`, `review`, `output`).
|
|
47
71
|
- **`docs/events.md`** — header count corrected to "15 events: 3 RPC and 12 pub-sub" (previously read "12 events", with refresh\_\* mentioned in subtext); stale Linear manifest example updated to use top-level `policies:` block.
|
|
48
72
|
- **SPEC.md §10.2** — removed `legacy_intake_fields` from the builtin doctor-check list.
|
data/README.md
CHANGED
|
@@ -85,68 +85,14 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
|
|
|
85
85
|
|
|
86
86
|
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.
|
|
87
87
|
|
|
88
|
-
## CLI
|
|
89
|
-
|
|
90
|
-
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`).
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
| `intro` | Store orientation: zones, entries, hooks, write flows, CLI map |
|
|
97
|
-
| `list [--prefix=K] [--zone=Z]` | Enumerate keys |
|
|
98
|
-
| `where K` | Resolve a key to its filesystem path |
|
|
99
|
-
| `get K` | Full envelope (frontmatter, body, uid, etag, format) |
|
|
100
|
-
| `schema show K` | Schema bound to an entry |
|
|
101
|
-
| `freshness [--prefix=K] [--zone=Z]` | Per-entry status (fresh / stale / never_refreshed / no_policy) against `policies:` |
|
|
102
|
-
| `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | Query `.textus/audit.log` |
|
|
103
|
-
| `blame KEY` | Audit rows joined with git commit metadata |
|
|
104
|
-
| `policy list` / `policy explain KEY` | Dump effective policies / per-slot winners for one key |
|
|
105
|
-
| `deps K` / `rdeps K` | Forward / reverse projection dependencies |
|
|
106
|
-
| `published` | List `publish_to:` targets and their backing keys |
|
|
107
|
-
| `doctor --check=schema_violations` | Validate every entry against its schema |
|
|
108
|
-
| `hook list [--event=E]` | Registered hooks grouped by event (intake, reduce, check, put, deleted, refreshed, built, accepted, published, mv, reject, loaded, refresh_began, refresh_failed, refresh_detached) |
|
|
109
|
-
|
|
110
|
-
**Write:**
|
|
111
|
-
|
|
112
|
-
| Verb | Role |
|
|
113
|
-
|---|---|
|
|
114
|
-
| `put K --stdin --as=R [--action=NAME]` | per zone |
|
|
115
|
-
| `hook run NAME [--key=val] [--as=R]` | per zone written (invoke a registered intake hook) |
|
|
116
|
-
| `delete K --if-etag=E --as=R` | per zone |
|
|
117
|
-
| `refresh K --as=script` | per zone (typically `script`) |
|
|
118
|
-
| `key mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
|
|
119
|
-
| `build [--prefix=K] [--dry-run]` | `build` |
|
|
120
|
-
| `accept K --as=human` | `human` only |
|
|
121
|
-
| `reject K --as=human` | `human` only (discards a pending proposal; fires `:reject`) |
|
|
122
|
-
|
|
123
|
-
**Health & maintenance:**
|
|
124
|
-
|
|
125
|
-
| Verb | Purpose |
|
|
126
|
-
|---|---|
|
|
127
|
-
| `doctor` | Health checks (manifest, schemas, templates, hooks, illegal keys, sentinels, audit log, policy ambiguity, handler allowlist, legacy intake fields); `ok: true` when clean |
|
|
128
|
-
| `key migrate [--dry-run]` | Rename files whose basenames violate the strict key grammar |
|
|
129
|
-
|
|
130
|
-
**Scaffolding (human-only):**
|
|
131
|
-
|
|
132
|
-
| Verb | Purpose |
|
|
133
|
-
|---|---|
|
|
134
|
-
| `init` | Scaffold a fresh `.textus/` |
|
|
135
|
-
| `schema init NAME` | Stub a schema |
|
|
136
|
-
| `schema diff NAME` | Compare a schema against entries that claim it |
|
|
137
|
-
| `schema migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
|
|
138
|
-
|
|
139
|
-
## Zones and roles
|
|
140
|
-
|
|
141
|
-
| Zone | `writable_by` | Purpose |
|
|
142
|
-
|---|---|---|
|
|
143
|
-
| `identity` | `[human]` | Identity, voice, decisions — slow-changing |
|
|
144
|
-
| `working` | `[human, ai, script]` | Active project state |
|
|
145
|
-
| `inbox` | `[script]` | Declared external inputs (actions) |
|
|
146
|
-
| `review` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
|
|
147
|
-
| `output` | `[build]` | Computed outputs from `textus build` |
|
|
148
|
-
|
|
149
|
-
Mismatches return `write_forbidden` with a hint naming the role that *would* be allowed. Every write records the resolved role in `.textus/audit.log`.
|
|
88
|
+
## CLI and zones
|
|
89
|
+
|
|
90
|
+
All verbs accept `--format=json` and return the envelope defined in [SPEC §8](SPEC.md). Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`).
|
|
91
|
+
|
|
92
|
+
- Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
|
|
93
|
+
- Zone semantics and the role/`writable_by` mapping live in [SPEC §5](SPEC.md), with a tutorial expansion in [`docs/zones.md`](docs/zones.md).
|
|
94
|
+
|
|
95
|
+
`textus intro` prints the same information for the current store: zones, entry families with schemas, registered hooks, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
|
|
150
96
|
|
|
151
97
|
## Compute and publish
|
|
152
98
|
|
data/SPEC.md
CHANGED
|
@@ -144,6 +144,18 @@ Zone names are conventional — the manifest is the source of truth for write pe
|
|
|
144
144
|
|
|
145
145
|
For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema.
|
|
146
146
|
|
|
147
|
+
**Per-entry `index_filename:`.** A nested entry MAY declare `index_filename:` to surface a single fixed basename (e.g. `SKILL.md`) per directory as the row, with the row's key segments derived from the directory path. Sibling files are not enumerated. The basename's extension MUST match the entry's `format:`. This lets entries project spec-mandated filenames whose casing would otherwise be rejected by the key-segment grammar. Example:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- key: skills
|
|
151
|
+
path: skills
|
|
152
|
+
zone: skills
|
|
153
|
+
nested: true
|
|
154
|
+
index_filename: SKILL.md
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A file at `.textus/zones/skills/ask/SKILL.md` enumerates as `skills.ask`; `.textus/zones/skills/ask/references/algorithm.md` is not enumerated. Resolving `skills.ask` returns the `SKILL.md` path. `index_filename:` requires `nested: true`; the value must be a bare basename (no slashes).
|
|
158
|
+
|
|
147
159
|
**Per-leaf publishing (`publish_each:`).** A nested manifest entry MAY declare `publish_each:` to byte-copy every leaf to a templated repo-relative path. `publish_each:` and `publish_to:` are mutually exclusive on the same entry, and `publish_each:` requires `nested: true`. The template substitutes these variables (using `{name}` syntax):
|
|
148
160
|
|
|
149
161
|
| Variable | Value |
|
|
@@ -226,6 +238,44 @@ If `template` is given, it names a Mustache template under `.textus/templates/`.
|
|
|
226
238
|
|
|
227
239
|
No partials. No lambdas. No HTML escaping (output is raw text, intended for Markdown). Template recursion depth is bounded at 8; exceeding the limit is an error.
|
|
228
240
|
|
|
241
|
+
### 5.2.1 Externally-generated derived entries (`generator:`)
|
|
242
|
+
|
|
243
|
+
A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `generator:` instead of `projection:`. textus does **not** execute the command (consistent with §2); the external runner is responsible for writing the file. textus records `sources:` so `textus freshness` can compare source mtimes against the derived file's `_meta.generated.at` and report staleness.
|
|
244
|
+
|
|
245
|
+
```yaml
|
|
246
|
+
- key: output.catalogs.skills
|
|
247
|
+
path: output/catalogs/skills.md
|
|
248
|
+
zone: output
|
|
249
|
+
owner: build:catalog-skills
|
|
250
|
+
generator:
|
|
251
|
+
command: "rake catalog:skills" # informational; the runner invokes it
|
|
252
|
+
sources: # dotted keys OR repo-relative paths
|
|
253
|
+
- working.projects
|
|
254
|
+
- working.network
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**`sources:`** is a list. Each element is either a dotted key prefix (matched against manifest entries) or a filesystem path (relative to the repo root, or absolute). For each key prefix, every matching entry's file mtime is checked. For each path, file or directory mtime is checked.
|
|
258
|
+
|
|
259
|
+
**`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `textus freshness` output can carry a hint about how to refresh.
|
|
260
|
+
|
|
261
|
+
**Freshness contract.** An entry with `generator:` is reported by `textus freshness` as `stale` when:
|
|
262
|
+
- The derived file does not exist, OR
|
|
263
|
+
- `_meta.generated.at` is missing or unparseable, OR
|
|
264
|
+
- Any `sources:` element has been modified after `_meta.generated.at`.
|
|
265
|
+
|
|
266
|
+
**Frontmatter contract.** The external runner is responsible for writing the `generated:` frontmatter block when it produces the file:
|
|
267
|
+
|
|
268
|
+
```yaml
|
|
269
|
+
generated:
|
|
270
|
+
by: "rake catalog:skills"
|
|
271
|
+
at: "2026-05-25T12:00:00Z"
|
|
272
|
+
from: [working.projects, working.network]
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`generated.from` SHOULD match `generator.sources` — they're the same list, recorded twice so a diff proves what was actually consumed.
|
|
276
|
+
|
|
277
|
+
`generator:` and `projection:` are alternatives — typically only one is set per entry. Templates are not required when `generator:` is declared: the runner produces the bytes directly.
|
|
278
|
+
|
|
229
279
|
### 5.3 Publish layer (`publish_to:`)
|
|
230
280
|
|
|
231
281
|
A derived entry MAY declare `publish_to:` in its frontmatter, listing one or more destination paths relative to the project root:
|
data/docs/conventions.md
CHANGED
|
@@ -44,34 +44,66 @@ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it
|
|
|
44
44
|
|
|
45
45
|
Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
|
|
46
46
|
|
|
47
|
-
## Derived entries
|
|
47
|
+
## Derived entries
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
textus supports two shapes for derived entries:
|
|
50
|
+
|
|
51
|
+
**`projection:`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
|
|
50
52
|
|
|
51
53
|
```yaml
|
|
52
|
-
- key: output.catalogs.
|
|
53
|
-
path: output/catalogs/
|
|
54
|
+
- key: output.catalogs.people
|
|
55
|
+
path: output/catalogs/people.md
|
|
54
56
|
zone: output
|
|
55
57
|
schema: null
|
|
58
|
+
owner: build:catalog-people
|
|
59
|
+
projection:
|
|
60
|
+
select: working.network.org # prefix or list of prefixes
|
|
61
|
+
pluck: [name, relationship, org]
|
|
62
|
+
sort_by: name
|
|
63
|
+
template: people.mustache # under .textus/templates/
|
|
64
|
+
publish_to: [docs/people.md] # optional repo-relative byte-copy targets
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**`generator:`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`.
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
- key: output.catalogs.skills
|
|
71
|
+
path: output/catalogs/skills.md
|
|
72
|
+
zone: output
|
|
56
73
|
owner: build:catalog-skills
|
|
57
74
|
generator:
|
|
58
|
-
command: "rake catalog:skills"
|
|
59
|
-
sources:
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
command: "rake catalog:skills" # informational; the runner invokes it
|
|
76
|
+
sources: [working.projects, working.network]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The build runner is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `generator.sources` — same list, recorded twice so a diff proves what was consumed.
|
|
80
|
+
|
|
81
|
+
Full contract for both shapes is in [`../SPEC.md` §5.2 and §5.2.1](../SPEC.md). Reducers (`projection.reduce:`) and per-leaf publishing (`publish_each:`) are also covered there.
|
|
82
|
+
|
|
83
|
+
## Intake and freshness
|
|
84
|
+
|
|
85
|
+
External inputs land via `:intake` hooks, not shell commands. Each inbox entry names a registered handler; refresh is on demand:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
textus refresh inbox.notion.roadmap --as=script
|
|
89
|
+
textus refresh-stale --zone=inbox --as=script # everything past its TTL
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Freshness budgets live in the top-level `policies:` block, matched by glob:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
policies:
|
|
96
|
+
- match: inbox.notion.**
|
|
97
|
+
refresh: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
|
|
62
98
|
```
|
|
63
99
|
|
|
64
|
-
|
|
100
|
+
A typical scheduled-refresh integration shells the `refresh-stale` sweep itself:
|
|
65
101
|
|
|
66
102
|
```sh
|
|
67
|
-
textus
|
|
68
|
-
| jq -r '.rows[] | select(.status == "stale") | .key' \
|
|
69
|
-
| while read key; do
|
|
70
|
-
textus refresh "$key" --as=script
|
|
71
|
-
done
|
|
103
|
+
textus refresh-stale --zone=inbox --as=script # in cron / CI
|
|
72
104
|
```
|
|
73
105
|
|
|
74
|
-
|
|
106
|
+
See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
|
|
75
107
|
|
|
76
108
|
## Body content
|
|
77
109
|
|
|
@@ -19,12 +19,9 @@ module Textus
|
|
|
19
19
|
envelope = @ctx.store.writer.write_envelope_to_disk(
|
|
20
20
|
key,
|
|
21
21
|
mentry: mentry,
|
|
22
|
-
meta: meta,
|
|
23
|
-
|
|
24
|
-
content: content,
|
|
22
|
+
payload: Textus::Store::Writer::Payload.new(meta: meta, body: body, content: content),
|
|
23
|
+
ctx: @ctx,
|
|
25
24
|
if_etag: if_etag,
|
|
26
|
-
as: @ctx.role,
|
|
27
|
-
correlation_id: @ctx.correlation_id,
|
|
28
25
|
)
|
|
29
26
|
|
|
30
27
|
unless suppress_events
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
|
|
8
8
|
:projection, :template, :publish_to, :publish_each,
|
|
9
9
|
:intake_handler, :intake_config,
|
|
10
|
-
:events, :inject_intro
|
|
10
|
+
:events, :inject_intro, :index_filename
|
|
11
11
|
|
|
12
12
|
def initialize(manifest, raw)
|
|
13
13
|
@manifest = manifest
|
|
@@ -25,6 +25,7 @@ module Textus
|
|
|
25
25
|
@publish_each = raw["publish_each"]
|
|
26
26
|
@events = raw["events"] || {}
|
|
27
27
|
@inject_intro = raw["inject_intro"] == true
|
|
28
|
+
@index_filename = raw["index_filename"]
|
|
28
29
|
@format = resolve_format!(raw["format"])
|
|
29
30
|
|
|
30
31
|
validate_events!
|
|
@@ -32,6 +33,7 @@ module Textus
|
|
|
32
33
|
validate_format_matrix!
|
|
33
34
|
validate_publish_each!
|
|
34
35
|
validate_inject_intro!
|
|
36
|
+
validate_index_filename!
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
# Resolves the per-leaf target path (relative to repo root) for a full
|
|
@@ -66,6 +68,37 @@ module Textus
|
|
|
66
68
|
|
|
67
69
|
private
|
|
68
70
|
|
|
71
|
+
# `index_filename:` makes a nested entry treat a fixed basename (e.g.
|
|
72
|
+
# `SKILL.md`) as the per-directory row. The directory path becomes the
|
|
73
|
+
# key suffix; sibling files are not enumerated. Allows projecting
|
|
74
|
+
# spec-mandated filenames that would otherwise be rejected by the
|
|
75
|
+
# lowercase-only key segment grammar.
|
|
76
|
+
def validate_index_filename!
|
|
77
|
+
return if @index_filename.nil?
|
|
78
|
+
|
|
79
|
+
raise UsageError.new("entry '#{@key}': index_filename requires nested: true") unless @nested
|
|
80
|
+
unless @index_filename.is_a?(String) && !@index_filename.empty?
|
|
81
|
+
raise UsageError.new("entry '#{@key}': index_filename must be a non-empty string")
|
|
82
|
+
end
|
|
83
|
+
if @index_filename.include?("/") || File.basename(@index_filename) != @index_filename
|
|
84
|
+
raise UsageError.new("entry '#{@key}': index_filename must be a bare basename (no slashes)")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
ext = File.extname(@index_filename)
|
|
88
|
+
inferred = Manifest::EXT_TO_FORMAT[ext]
|
|
89
|
+
if inferred.nil?
|
|
90
|
+
raise UsageError.new(
|
|
91
|
+
"entry '#{@key}': index_filename #{@index_filename.inspect} has unknown extension #{ext.inspect}",
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
return if inferred == @format
|
|
95
|
+
|
|
96
|
+
raise UsageError.new(
|
|
97
|
+
"entry '#{@key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
|
|
98
|
+
"but entry format is #{@format.inspect}",
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
69
102
|
def zone_writers
|
|
70
103
|
@manifest.zone_writers(@zone)
|
|
71
104
|
rescue UsageError => e
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -76,8 +76,12 @@ module Textus
|
|
|
76
76
|
else
|
|
77
77
|
raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
path = if entry.index_filename
|
|
80
|
+
File.join(@root, "zones", entry.path, *remaining, entry.index_filename)
|
|
81
|
+
else
|
|
82
|
+
primary_ext = Textus::Entry.for_format(entry.format).extensions.first
|
|
83
|
+
File.join(@root, "zones", entry.path, *remaining) + primary_ext
|
|
84
|
+
end
|
|
81
85
|
[entry, path, remaining]
|
|
82
86
|
end
|
|
83
87
|
end
|
|
@@ -96,39 +100,11 @@ module Textus
|
|
|
96
100
|
|
|
97
101
|
# Enumerate all entry files reachable through the manifest. Returns
|
|
98
102
|
# [{ key:, path:, manifest_entry: }, ...]
|
|
99
|
-
# rubocop:disable Metrics/AbcSize
|
|
100
103
|
def enumerate(prefix: nil)
|
|
101
|
-
out =
|
|
102
|
-
@entries.each do |entry|
|
|
103
|
-
if entry.nested
|
|
104
|
-
base = File.join(@root, "zones", entry.path)
|
|
105
|
-
next unless File.directory?(base)
|
|
106
|
-
|
|
107
|
-
glob_pattern = nested_glob(entry.format)
|
|
108
|
-
Dir.glob(File.join(base, glob_pattern)).each do |fp|
|
|
109
|
-
rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
110
|
-
stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
111
|
-
segs = stripped.split("/").reject(&:empty?)
|
|
112
|
-
next if segs.empty?
|
|
113
|
-
|
|
114
|
-
illegal = segs.find { |s| !valid_segment?(s) }
|
|
115
|
-
if illegal
|
|
116
|
-
warn("textus: skipping illegal key segment '#{illegal}' at #{fp} — run 'textus key migrate --dry-run'")
|
|
117
|
-
next
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
full_key = (entry.key.split(".") + segs).join(".")
|
|
121
|
-
out << { key: full_key, path: fp, manifest_entry: entry }
|
|
122
|
-
end
|
|
123
|
-
else
|
|
124
|
-
fp = resolve_leaf_path(entry)
|
|
125
|
-
out << { key: entry.key, path: fp, manifest_entry: entry } if File.exist?(fp)
|
|
126
|
-
end
|
|
127
|
-
end
|
|
104
|
+
out = @entries.flat_map { |entry| entry.nested ? enumerate_nested(entry) : enumerate_leaf(entry) }
|
|
128
105
|
out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
|
|
129
106
|
out.sort_by { |row| row[:key] }
|
|
130
107
|
end
|
|
131
|
-
# rubocop:enable Metrics/AbcSize
|
|
132
108
|
|
|
133
109
|
def validate_key!(key)
|
|
134
110
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
@@ -138,6 +114,34 @@ module Textus
|
|
|
138
114
|
|
|
139
115
|
private
|
|
140
116
|
|
|
117
|
+
def enumerate_leaf(entry)
|
|
118
|
+
fp = resolve_leaf_path(entry)
|
|
119
|
+
File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def enumerate_nested(entry)
|
|
123
|
+
base = File.join(@root, "zones", entry.path)
|
|
124
|
+
return [] unless File.directory?(base)
|
|
125
|
+
|
|
126
|
+
glob_pattern = entry.index_filename ? "**/#{entry.index_filename}" : nested_glob(entry.format)
|
|
127
|
+
Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def nested_row_for(entry, base, path)
|
|
131
|
+
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
132
|
+
stripped = entry.index_filename ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
133
|
+
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
134
|
+
return nil if segs.empty?
|
|
135
|
+
|
|
136
|
+
illegal = segs.find { |s| !valid_segment?(s) }
|
|
137
|
+
if illegal
|
|
138
|
+
warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key migrate --dry-run'")
|
|
139
|
+
return nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
{ key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
|
|
143
|
+
end
|
|
144
|
+
|
|
141
145
|
def valid_segment?(seg)
|
|
142
146
|
return false if seg.nil? || seg.empty?
|
|
143
147
|
return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
|
data/lib/textus/store/mover.rb
CHANGED
|
@@ -2,8 +2,12 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
class Store
|
|
5
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
6
5
|
class Mover
|
|
6
|
+
MovePlan = Data.define(
|
|
7
|
+
:old_key, :new_key, :old_path, :new_path,
|
|
8
|
+
:new_mentry, :uid, :etag_before, :as
|
|
9
|
+
)
|
|
10
|
+
|
|
7
11
|
def initialize(store:, reader:, writer:, manifest:, audit_log:)
|
|
8
12
|
@store = store
|
|
9
13
|
@reader = reader
|
|
@@ -13,6 +17,22 @@ module Textus
|
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def call(old_key, new_key, as: Role::DEFAULT, dry_run: false, correlation_id: nil)
|
|
20
|
+
plan, pre_env = prepare_plan(old_key, new_key, as: as)
|
|
21
|
+
return dry_run_result(plan) if dry_run
|
|
22
|
+
|
|
23
|
+
plan = ensure_uid!(plan, pre_env: pre_env)
|
|
24
|
+
etag_after = perform_move!(plan)
|
|
25
|
+
new_envelope = record_move(plan, etag_after: etag_after, correlation_id: correlation_id)
|
|
26
|
+
success_result(plan, new_envelope: new_envelope)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Validates inputs, resolves manifest entries, and reads the source
|
|
32
|
+
# envelope. Returns [MovePlan, pre_envelope]; the pre_envelope is only
|
|
33
|
+
# needed by ensure_uid! and is threaded separately to keep MovePlan
|
|
34
|
+
# focused on the planned operation.
|
|
35
|
+
def prepare_plan(old_key, new_key, as:)
|
|
16
36
|
@manifest.validate_key!(old_key)
|
|
17
37
|
@manifest.validate_key!(new_key)
|
|
18
38
|
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
@@ -21,81 +41,103 @@ module Textus
|
|
|
21
41
|
raise UnknownKey.new(old_key) unless File.exist?(old_path)
|
|
22
42
|
|
|
23
43
|
new_mentry, new_path, = @manifest.resolve(new_key)
|
|
44
|
+
validate_zone_and_format!(old_mentry, new_mentry)
|
|
45
|
+
validate_writer!(old_mentry, old_key, as)
|
|
46
|
+
raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
|
|
24
47
|
|
|
48
|
+
pre_env = @reader.get(old_key)
|
|
49
|
+
plan = MovePlan.new(
|
|
50
|
+
old_key: old_key, new_key: new_key,
|
|
51
|
+
old_path: old_path, new_path: new_path,
|
|
52
|
+
new_mentry: new_mentry,
|
|
53
|
+
uid: pre_env["uid"], etag_before: pre_env["etag"], as: as
|
|
54
|
+
)
|
|
55
|
+
[plan, pre_env]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_zone_and_format!(old_mentry, new_mentry)
|
|
25
59
|
if old_mentry.zone != new_mentry.zone
|
|
26
60
|
raise UsageError.new(
|
|
27
61
|
"mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
|
|
28
62
|
"Use put+delete for cross-zone moves.",
|
|
29
63
|
)
|
|
30
64
|
end
|
|
31
|
-
if old_mentry.format
|
|
32
|
-
raise UsageError.new(
|
|
33
|
-
"mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
|
|
34
|
-
)
|
|
35
|
-
end
|
|
65
|
+
return if old_mentry.format == new_mentry.format
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
|
|
67
|
+
raise UsageError.new(
|
|
68
|
+
"mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
|
|
69
|
+
)
|
|
70
|
+
end
|
|
39
71
|
|
|
40
|
-
|
|
72
|
+
def validate_writer!(mentry, key, as)
|
|
73
|
+
writers = @manifest.zone_writers(mentry.zone)
|
|
74
|
+
return if writers.include?(as)
|
|
41
75
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
current_uid = pre_env["uid"]
|
|
45
|
-
etag_before = pre_env["etag"]
|
|
46
|
-
|
|
47
|
-
if dry_run
|
|
48
|
-
return {
|
|
49
|
-
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
50
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
51
|
-
"from_path" => old_path, "to_path" => new_path,
|
|
52
|
-
"uid" => current_uid
|
|
53
|
-
}
|
|
54
|
-
end
|
|
76
|
+
raise WriteForbidden.new(key, mentry.zone, writers: writers)
|
|
77
|
+
end
|
|
55
78
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
pre_env = @writer.put(old_key,
|
|
59
|
-
meta: pre_env["_meta"],
|
|
60
|
-
body: pre_env["body"],
|
|
61
|
-
content: pre_env["content"],
|
|
62
|
-
as: as,
|
|
63
|
-
suppress_events: true)
|
|
64
|
-
current_uid = pre_env["uid"]
|
|
65
|
-
etag_before = pre_env["etag"]
|
|
66
|
-
end
|
|
79
|
+
def ensure_uid!(plan, pre_env:)
|
|
80
|
+
return plan if plan.uid
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
env = @writer.put(
|
|
83
|
+
plan.old_key,
|
|
84
|
+
meta: pre_env["_meta"],
|
|
85
|
+
body: pre_env["body"],
|
|
86
|
+
content: pre_env["content"],
|
|
87
|
+
as: plan.as,
|
|
88
|
+
suppress_events: true,
|
|
89
|
+
)
|
|
90
|
+
plan.with(uid: env["uid"], etag_before: env["etag"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def perform_move!(plan)
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(plan.new_path))
|
|
95
|
+
FileUtils.mv(plan.old_path, plan.new_path)
|
|
96
|
+
rewrite_name_for_mv!(plan.new_mentry, plan.new_path, plan.new_key)
|
|
97
|
+
Etag.for_file(plan.new_path)
|
|
98
|
+
end
|
|
72
99
|
|
|
100
|
+
def record_move(plan, etag_after:, correlation_id:)
|
|
73
101
|
extras = {
|
|
74
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
75
|
-
"from_path" => old_path, "to_path" => new_path,
|
|
76
|
-
"uid" =>
|
|
102
|
+
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
103
|
+
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
104
|
+
"uid" => plan.uid
|
|
77
105
|
}
|
|
78
106
|
extras["correlation_id"] = correlation_id if correlation_id
|
|
79
107
|
|
|
80
108
|
@audit_log.append(
|
|
81
|
-
role: as, verb: "mv", key: new_key,
|
|
82
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
109
|
+
role: plan.as, verb: "mv", key: plan.new_key,
|
|
110
|
+
etag_before: plan.etag_before, etag_after: etag_after,
|
|
83
111
|
extras: extras
|
|
84
112
|
)
|
|
113
|
+
new_envelope = @reader.get(plan.new_key)
|
|
114
|
+
@store.fire_event(
|
|
115
|
+
:mv,
|
|
116
|
+
key: plan.new_key, from_key: plan.old_key, to_key: plan.new_key,
|
|
117
|
+
envelope: new_envelope
|
|
118
|
+
)
|
|
119
|
+
new_envelope
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def dry_run_result(plan)
|
|
123
|
+
{
|
|
124
|
+
"protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
|
|
125
|
+
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
126
|
+
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
127
|
+
"uid" => plan.uid
|
|
128
|
+
}
|
|
129
|
+
end
|
|
85
130
|
|
|
86
|
-
|
|
87
|
-
@store.fire_event(:mv, key: new_key, from_key: old_key, to_key: new_key, envelope: new_envelope)
|
|
131
|
+
def success_result(plan, new_envelope:)
|
|
88
132
|
{
|
|
89
133
|
"protocol" => PROTOCOL, "ok" => true,
|
|
90
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
91
|
-
"from_path" => old_path, "to_path" => new_path,
|
|
92
|
-
"uid" =>
|
|
134
|
+
"from_key" => plan.old_key, "to_key" => plan.new_key,
|
|
135
|
+
"from_path" => plan.old_path, "to_path" => plan.new_path,
|
|
136
|
+
"uid" => plan.uid,
|
|
93
137
|
"envelope" => new_envelope
|
|
94
138
|
}
|
|
95
139
|
end
|
|
96
140
|
|
|
97
|
-
private
|
|
98
|
-
|
|
99
141
|
# If the moved file carries a `name:` field (markdown) or `_meta.name`
|
|
100
142
|
# (json/yaml), rewrite it to the new basename so enforce_name_match! stays
|
|
101
143
|
# happy on the next read. Only touches the bytes when name actually changes.
|
|
@@ -121,6 +163,5 @@ module Textus
|
|
|
121
163
|
end
|
|
122
164
|
end
|
|
123
165
|
end
|
|
124
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
125
166
|
end
|
|
126
167
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
class Staleness
|
|
6
|
+
# Reports staleness for generator-zone entries — derived files whose
|
|
7
|
+
# generator's listed sources have been modified more recently than the
|
|
8
|
+
# entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
|
|
9
|
+
# (possibly empty) per entry.
|
|
10
|
+
class GeneratorCheck
|
|
11
|
+
def initialize(manifest:)
|
|
12
|
+
@manifest = manifest
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def rows_for(mentry)
|
|
16
|
+
return [] unless mentry.in_generator_zone?
|
|
17
|
+
|
|
18
|
+
gen = mentry.generator
|
|
19
|
+
return [] unless gen
|
|
20
|
+
|
|
21
|
+
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
|
+
return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
|
|
23
|
+
|
|
24
|
+
parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
|
|
25
|
+
generated_at = parsed["_meta"].dig("generated", "at")
|
|
26
|
+
return [stale_row(mentry, path, "missing generated.at frontmatter")] unless generated_at
|
|
27
|
+
|
|
28
|
+
gen_time = parse_time(generated_at)
|
|
29
|
+
return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
|
|
30
|
+
|
|
31
|
+
offender = newest_source_after(gen, gen_time)
|
|
32
|
+
return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
|
|
33
|
+
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse_time(str)
|
|
40
|
+
Time.parse(str.to_s)
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def newest_source_after(gen, gen_time)
|
|
46
|
+
Array(gen["sources"]).each do |src|
|
|
47
|
+
offender = check_source(src, gen_time)
|
|
48
|
+
return offender if offender
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check_source(src, gen_time)
|
|
54
|
+
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
55
|
+
@manifest.enumerate(prefix: src).each do |row|
|
|
56
|
+
return src if File.mtime(row[:path]) > gen_time
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
else
|
|
60
|
+
check_filesystem_source(src, gen_time)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def check_filesystem_source(src, gen_time)
|
|
65
|
+
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
|
|
66
|
+
if File.directory?(abs)
|
|
67
|
+
Dir.glob(File.join(abs, "**", "*")).each do |fp|
|
|
68
|
+
next unless File.file?(fp)
|
|
69
|
+
return src if File.mtime(fp) > gen_time
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
elsif File.exist?(abs) && File.mtime(abs) > gen_time
|
|
73
|
+
src
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stale_row(mentry, path, reason)
|
|
78
|
+
{
|
|
79
|
+
"key" => mentry.key,
|
|
80
|
+
"path" => path,
|
|
81
|
+
"generator" => mentry.generator,
|
|
82
|
+
"reason" => reason,
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
class Staleness
|
|
6
|
+
# Reports TTL-exceeded staleness for intake-handler entries. Returns an
|
|
7
|
+
# Array of row hashes (possibly empty) per entry.
|
|
8
|
+
class IntakeCheck
|
|
9
|
+
def initialize(manifest:)
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def rows_for(mentry)
|
|
14
|
+
return [] unless mentry.intake_handler
|
|
15
|
+
|
|
16
|
+
ttl = @manifest.policies_for(mentry.key).refresh&.ttl_seconds
|
|
17
|
+
return [] unless ttl
|
|
18
|
+
|
|
19
|
+
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
20
|
+
return [row(mentry, path, "never refreshed")] unless File.exist?(path)
|
|
21
|
+
|
|
22
|
+
meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
|
|
23
|
+
last_str = meta["last_refreshed_at"]
|
|
24
|
+
return [row(mentry, path, "never refreshed (no last_refreshed_at)")] if last_str.nil?
|
|
25
|
+
|
|
26
|
+
last = parse_time(last_str)
|
|
27
|
+
return [row(mentry, path, "ttl exceeded (#{ttl}s)")] if last.nil? || (Time.now - last) > ttl
|
|
28
|
+
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def parse_time(str)
|
|
35
|
+
Time.parse(str.to_s)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def row(mentry, path, reason)
|
|
41
|
+
{ "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -1,121 +1,26 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
class Store
|
|
5
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
6
3
|
class Staleness
|
|
7
4
|
def initialize(manifest:)
|
|
8
5
|
@manifest = manifest
|
|
6
|
+
@generator_check = GeneratorCheck.new(manifest: manifest)
|
|
7
|
+
@intake_check = IntakeCheck.new(manifest: manifest)
|
|
9
8
|
end
|
|
10
9
|
|
|
11
10
|
def call(prefix: nil, zone: nil)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
next if zone && mentry.zone != zone
|
|
16
|
-
|
|
17
|
-
gen = mentry.generator
|
|
18
|
-
next unless gen
|
|
19
|
-
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
20
|
-
|
|
21
|
-
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
|
-
|
|
23
|
-
unless File.exist?(path)
|
|
24
|
-
out << stale_row(mentry, path, "derived entry has never been generated")
|
|
25
|
-
next
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
raw = File.binread(path)
|
|
29
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
30
|
-
generated_at = parsed["_meta"].dig("generated", "at")
|
|
31
|
-
unless generated_at
|
|
32
|
-
out << stale_row(mentry, path, "missing generated.at frontmatter")
|
|
33
|
-
next
|
|
34
|
-
end
|
|
35
|
-
gen_time = begin
|
|
36
|
-
Time.parse(generated_at.to_s)
|
|
37
|
-
rescue StandardError
|
|
38
|
-
nil
|
|
39
|
-
end
|
|
40
|
-
unless gen_time
|
|
41
|
-
out << stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")
|
|
42
|
-
next
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
offender = newest_source_after(gen, gen_time)
|
|
46
|
-
out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
@manifest.entries.each do |mentry|
|
|
50
|
-
next unless mentry.intake_handler
|
|
51
|
-
next if zone && mentry.zone != zone
|
|
52
|
-
next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
53
|
-
|
|
54
|
-
policy_set = @manifest.policies_for(mentry.key)
|
|
55
|
-
ttl = policy_set.refresh&.ttl_seconds
|
|
56
|
-
next unless ttl
|
|
57
|
-
|
|
58
|
-
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
59
|
-
|
|
60
|
-
unless File.exist?(path)
|
|
61
|
-
out << intake_stale_row(mentry, path, "never refreshed")
|
|
62
|
-
next
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
|
|
66
|
-
last_str = meta["last_refreshed_at"]
|
|
67
|
-
if last_str.nil?
|
|
68
|
-
out << intake_stale_row(mentry, path, "never refreshed (no last_refreshed_at)")
|
|
69
|
-
next
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
last = begin
|
|
73
|
-
Time.parse(last_str.to_s)
|
|
74
|
-
rescue StandardError
|
|
75
|
-
nil
|
|
76
|
-
end
|
|
77
|
-
out << intake_stale_row(mentry, path, "ttl exceeded (#{ttl}s)") if last.nil? || (Time.now - last) > ttl
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
out
|
|
11
|
+
@manifest.entries
|
|
12
|
+
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
13
|
+
.flat_map { |m| @generator_check.rows_for(m) + @intake_check.rows_for(m) }
|
|
81
14
|
end
|
|
82
15
|
|
|
83
16
|
private
|
|
84
17
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@manifest.enumerate(prefix: src).each do |row|
|
|
89
|
-
return src if File.mtime(row[:path]) > gen_time
|
|
90
|
-
end
|
|
91
|
-
else
|
|
92
|
-
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
|
|
93
|
-
if File.directory?(abs)
|
|
94
|
-
Dir.glob(File.join(abs, "**", "*")).each do |fp|
|
|
95
|
-
next unless File.file?(fp)
|
|
96
|
-
return src if File.mtime(fp) > gen_time
|
|
97
|
-
end
|
|
98
|
-
elsif File.exist?(abs)
|
|
99
|
-
return src if File.mtime(abs) > gen_time
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
nil
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def intake_stale_row(mentry, path, reason)
|
|
107
|
-
{ "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
|
|
108
|
-
end
|
|
18
|
+
def entry_matches?(mentry, prefix:, zone:)
|
|
19
|
+
return false if zone && mentry.zone != zone
|
|
20
|
+
return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
109
21
|
|
|
110
|
-
|
|
111
|
-
{
|
|
112
|
-
"key" => mentry.key,
|
|
113
|
-
"path" => path,
|
|
114
|
-
"generator" => mentry.generator,
|
|
115
|
-
"reason" => reason,
|
|
116
|
-
}
|
|
22
|
+
true
|
|
117
23
|
end
|
|
118
24
|
end
|
|
119
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
120
25
|
end
|
|
121
26
|
end
|
data/lib/textus/store/writer.rb
CHANGED
|
@@ -2,8 +2,9 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
class Store
|
|
5
|
-
# rubocop:disable Metrics/ParameterLists
|
|
6
5
|
class Writer
|
|
6
|
+
Payload = Data.define(:meta, :body, :content)
|
|
7
|
+
|
|
7
8
|
def initialize(store)
|
|
8
9
|
@store = store
|
|
9
10
|
@manifest = store.manifest
|
|
@@ -11,28 +12,30 @@ module Textus
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
# Backward-compat shim — orchestration now lives in Application::Writes::Put.
|
|
15
|
+
# rubocop:disable Metrics/ParameterLists
|
|
14
16
|
def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
|
|
15
17
|
ctx = Textus::Application::Context.new(store: @store, role: as)
|
|
16
18
|
Textus::Application::Writes::Put.new(ctx: ctx, bus: @store.bus).call(
|
|
17
19
|
key, meta: meta, body: body, content: content, if_etag: if_etag, suppress_events: suppress_events
|
|
18
20
|
)
|
|
19
21
|
end
|
|
22
|
+
# rubocop:enable Metrics/ParameterLists
|
|
20
23
|
|
|
21
24
|
# Pure I/O: validate, serialize, etag-check, write to disk, audit. No
|
|
22
25
|
# permission check and no event firing — those are handled by the caller
|
|
23
26
|
# (Application::Writes::Put).
|
|
24
|
-
def write_envelope_to_disk(key, mentry:,
|
|
27
|
+
def write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag: nil)
|
|
25
28
|
_, path, = @manifest.resolve(key)
|
|
26
29
|
|
|
27
|
-
meta
|
|
30
|
+
meta = payload.meta || {}
|
|
28
31
|
strategy = Entry.for_format(mentry.format)
|
|
29
32
|
|
|
30
33
|
existing_uid = existing_uid_for(mentry, path)
|
|
31
|
-
meta, content = ensure_uid(mentry.format, meta, content, existing_uid)
|
|
34
|
+
meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
32
35
|
|
|
33
36
|
bytes, eff_meta, eff_body, eff_content = serialize_for_put(
|
|
34
37
|
mentry: mentry, path: path, strategy: strategy,
|
|
35
|
-
meta: meta, body: body, content: content
|
|
38
|
+
meta: meta, body: payload.body, content: content
|
|
36
39
|
)
|
|
37
40
|
|
|
38
41
|
enforce_name_match!(path, eff_meta, mentry.format)
|
|
@@ -52,9 +55,9 @@ module Textus
|
|
|
52
55
|
File.binwrite(path, bytes)
|
|
53
56
|
etag_after = Etag.for_bytes(bytes)
|
|
54
57
|
@store.audit_log.append(
|
|
55
|
-
role:
|
|
58
|
+
role: ctx.role, verb: "put", key: key,
|
|
56
59
|
etag_before: etag_before, etag_after: etag_after,
|
|
57
|
-
extras: correlation_id ? { "correlation_id" => correlation_id } : nil
|
|
60
|
+
extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
|
|
58
61
|
)
|
|
59
62
|
Envelope.build(
|
|
60
63
|
key: key, mentry: mentry, path: path,
|
|
@@ -129,7 +132,7 @@ module Textus
|
|
|
129
132
|
# Pure I/O: resolve path, validate etag, delete from disk, audit. No
|
|
130
133
|
# permission check and no event firing — those are handled by the caller
|
|
131
134
|
# (Application::Writes::Delete).
|
|
132
|
-
def delete_envelope_from_disk(key, if_etag: nil
|
|
135
|
+
def delete_envelope_from_disk(key, ctx:, if_etag: nil)
|
|
133
136
|
_, path, = @manifest.resolve(key)
|
|
134
137
|
raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
|
|
135
138
|
|
|
@@ -138,9 +141,9 @@ module Textus
|
|
|
138
141
|
|
|
139
142
|
File.delete(path)
|
|
140
143
|
@store.audit_log.append(
|
|
141
|
-
role:
|
|
144
|
+
role: ctx.role, verb: "delete", key: key,
|
|
142
145
|
etag_before: etag_before, etag_after: nil,
|
|
143
|
-
extras: correlation_id ? { "correlation_id" => correlation_id } : nil
|
|
146
|
+
extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
|
|
144
147
|
)
|
|
145
148
|
end
|
|
146
149
|
|
|
@@ -164,6 +167,5 @@ module Textus
|
|
|
164
167
|
{ "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
|
|
165
168
|
end
|
|
166
169
|
end
|
|
167
|
-
# rubocop:enable Metrics/ParameterLists
|
|
168
170
|
end
|
|
169
171
|
end
|
data/lib/textus/version.rb
CHANGED
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.10.
|
|
4
|
+
version: 0.10.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -235,6 +235,8 @@ files:
|
|
|
235
235
|
- lib/textus/store/reader.rb
|
|
236
236
|
- lib/textus/store/sentinel.rb
|
|
237
237
|
- lib/textus/store/staleness.rb
|
|
238
|
+
- lib/textus/store/staleness/generator_check.rb
|
|
239
|
+
- lib/textus/store/staleness/intake_check.rb
|
|
238
240
|
- lib/textus/store/validator.rb
|
|
239
241
|
- lib/textus/store/writer.rb
|
|
240
242
|
- lib/textus/version.rb
|