textus 0.52.0 → 0.54.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 +59 -0
- data/README.md +62 -54
- data/SPEC.md +64 -192
- data/docs/architecture/README.md +157 -156
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +44 -0
- data/lib/textus/action/audit.rb +131 -0
- data/lib/textus/action/base.rb +42 -0
- data/lib/textus/{read → action}/blame.rb +28 -22
- data/lib/textus/action/boot.rb +17 -0
- data/lib/textus/action/data_mv.rb +69 -0
- data/lib/textus/action/deps.rb +34 -0
- data/lib/textus/action/doctor.rb +24 -0
- data/lib/textus/action/drain.rb +39 -0
- data/lib/textus/action/enqueue.rb +53 -0
- data/lib/textus/action/get.rb +78 -0
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +36 -0
- data/lib/textus/action/key_delete.rb +36 -0
- data/lib/textus/action/key_delete_prefix.rb +44 -0
- data/lib/textus/action/key_mv.rb +129 -0
- data/lib/textus/action/key_mv_prefix.rb +57 -0
- data/lib/textus/action/list.rb +42 -0
- data/lib/textus/action/propose.rb +52 -0
- data/lib/textus/action/published.rb +24 -0
- data/lib/textus/action/pulse.rb +56 -0
- data/lib/textus/action/put.rb +52 -0
- data/lib/textus/action/rdeps.rb +39 -0
- data/lib/textus/action/reject.rb +37 -0
- data/lib/textus/action/rule_explain.rb +91 -0
- data/lib/textus/action/rule_lint.rb +68 -0
- data/lib/textus/action/rule_list.rb +42 -0
- data/lib/textus/action/schema_envelope.rb +29 -0
- data/lib/textus/action/uid.rb +33 -0
- data/lib/textus/action/where.rb +36 -0
- data/lib/textus/action/write_verb.rb +44 -0
- data/lib/textus/boot.rb +64 -132
- data/lib/textus/command.rb +41 -0
- data/lib/textus/container.rb +1 -1
- data/lib/textus/{domain → core}/duration.rb +1 -1
- data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
- data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
- data/lib/textus/{domain → core}/freshness.rb +2 -2
- data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
- data/lib/textus/{domain → core}/retention.rb +2 -2
- data/lib/textus/{domain → core}/sentinel.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/proposal_targets.rb +3 -3
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +8 -2
- data/lib/textus/doctor/check.rb +11 -9
- data/lib/textus/{read → doctor}/validator.rb +22 -13
- data/lib/textus/doctor.rb +4 -44
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +5 -3
- data/lib/textus/errors.rb +25 -13
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +227 -0
- data/lib/textus/gate.rb +116 -0
- data/lib/textus/init.rb +59 -105
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/jobs/plan.rb +9 -0
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/jobs/worker.rb +67 -0
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +10 -3
- data/lib/textus/layout.rb +13 -0
- data/lib/textus/manifest/data.rb +14 -14
- data/lib/textus/manifest/entry/base.rb +14 -25
- data/lib/textus/manifest/entry/parser.rb +31 -14
- data/lib/textus/manifest/entry/produced.rb +13 -33
- data/lib/textus/manifest/entry/publish/mode.rb +1 -1
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
- data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
- data/lib/textus/manifest/policy/react.rb +30 -0
- data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
- data/lib/textus/manifest/policy/source.rb +30 -0
- data/lib/textus/manifest/policy.rb +38 -53
- data/lib/textus/manifest/resolver.rb +5 -4
- data/lib/textus/manifest/rules.rb +4 -4
- data/lib/textus/manifest/schema/keys.rb +20 -28
- data/lib/textus/manifest/schema/validator.rb +27 -33
- data/lib/textus/manifest/schema/vocabulary.rb +2 -1
- data/lib/textus/manifest/schema.rb +2 -2
- data/lib/textus/manifest.rb +2 -2
- data/lib/textus/ports/audit_log.rb +31 -5
- data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
- data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
- data/lib/textus/ports/sentinel_store.rb +2 -2
- data/lib/textus/ports/watcher_lock.rb +48 -0
- data/lib/textus/produce/engine.rb +25 -70
- data/lib/textus/produce/render.rb +2 -7
- data/lib/textus/schema/tools.rb +4 -3
- data/lib/textus/session.rb +6 -3
- data/lib/textus/store.rb +12 -19
- data/lib/textus/surfaces/cli/group/data.rb +11 -0
- data/lib/textus/surfaces/cli/group/key.rb +11 -0
- data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
- data/lib/textus/surfaces/cli/group/rule.rb +11 -0
- data/lib/textus/surfaces/cli/group/schema.rb +11 -0
- data/lib/textus/surfaces/cli/group.rb +50 -0
- data/lib/textus/surfaces/cli/runner.rb +236 -0
- data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
- data/lib/textus/surfaces/cli/verb/get.rb +21 -0
- data/lib/textus/surfaces/cli/verb/init.rb +20 -0
- data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
- data/lib/textus/surfaces/cli/verb/put.rb +30 -0
- data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
- data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
- data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
- data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
- data/lib/textus/surfaces/cli/verb.rb +111 -0
- data/lib/textus/surfaces/cli.rb +148 -0
- data/lib/textus/surfaces/mcp/catalog.rb +99 -0
- data/lib/textus/surfaces/mcp/errors.rb +25 -0
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +184 -0
- data/lib/textus/surfaces/mcp/session.rb +9 -0
- data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
- data/lib/textus/surfaces/mcp.rb +8 -0
- data/lib/textus/surfaces/role_scope.rb +34 -0
- data/lib/textus/surfaces/watcher.rb +38 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +75 -22
- metadata +118 -132
- data/lib/textus/cli/group/hook.rb +0 -9
- data/lib/textus/cli/group/key.rb +0 -9
- data/lib/textus/cli/group/mcp.rb +0 -9
- data/lib/textus/cli/group/rule.rb +0 -9
- data/lib/textus/cli/group/schema.rb +0 -9
- data/lib/textus/cli/group/zone.rb +0 -9
- data/lib/textus/cli/group.rb +0 -48
- data/lib/textus/cli/runner.rb +0 -193
- data/lib/textus/cli/verb/doctor.rb +0 -17
- data/lib/textus/cli/verb/get.rb +0 -18
- data/lib/textus/cli/verb/hook_run.rb +0 -48
- data/lib/textus/cli/verb/hooks.rb +0 -50
- data/lib/textus/cli/verb/init.rb +0 -18
- data/lib/textus/cli/verb/mcp_serve.rb +0 -22
- data/lib/textus/cli/verb/put.rb +0 -30
- data/lib/textus/cli/verb/schema_diff.rb +0 -15
- data/lib/textus/cli/verb/schema_init.rb +0 -19
- data/lib/textus/cli/verb/schema_migrate.rb +0 -19
- data/lib/textus/cli/verb/serve.rb +0 -19
- data/lib/textus/cli/verb.rb +0 -116
- data/lib/textus/cli.rb +0 -138
- data/lib/textus/dispatcher.rb +0 -54
- data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -32
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/domain/action.rb +0 -9
- data/lib/textus/domain/jobs/registry.rb +0 -37
- data/lib/textus/domain/permission.rb +0 -7
- data/lib/textus/domain/policy/base_guards.rb +0 -25
- data/lib/textus/domain/policy/evaluation.rb +0 -15
- data/lib/textus/domain/policy/guard.rb +0 -35
- data/lib/textus/domain/policy/guard_factory.rb +0 -40
- data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
- data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
- data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
- data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
- data/lib/textus/domain/policy/predicates/registry.rb +0 -39
- data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
- data/lib/textus/domain/policy/source.rb +0 -73
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -182
- data/lib/textus/hooks/builtin.rb +0 -70
- data/lib/textus/hooks/catalog.rb +0 -37
- data/lib/textus/hooks/context.rb +0 -72
- data/lib/textus/hooks/error_log.rb +0 -32
- data/lib/textus/hooks/event_bus.rb +0 -114
- data/lib/textus/hooks/fire_report.rb +0 -23
- data/lib/textus/hooks/loader.rb +0 -54
- data/lib/textus/hooks/rpc_registry.rb +0 -43
- data/lib/textus/hooks/signature.rb +0 -31
- data/lib/textus/init/templates/machine_intake.rb +0 -45
- data/lib/textus/init/templates/orientation_reducer.rb +0 -17
- data/lib/textus/jobs/handlers.rb +0 -62
- data/lib/textus/jobs/scheduler.rb +0 -36
- data/lib/textus/jobs/seeder.rb +0 -57
- data/lib/textus/maintenance/drain.rb +0 -42
- data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
- data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
- data/lib/textus/maintenance/retention/apply.rb +0 -52
- data/lib/textus/maintenance/rule_lint.rb +0 -66
- data/lib/textus/maintenance/serve.rb +0 -30
- data/lib/textus/maintenance/worker.rb +0 -74
- data/lib/textus/maintenance/zone_mv.rb +0 -64
- data/lib/textus/maintenance.rb +0 -15
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/mcp/catalog.rb +0 -70
- data/lib/textus/mcp/errors.rb +0 -32
- data/lib/textus/mcp/server.rb +0 -138
- data/lib/textus/mcp/session.rb +0 -7
- data/lib/textus/mcp/tool_schemas.rb +0 -15
- data/lib/textus/mcp.rb +0 -6
- data/lib/textus/mustache.rb +0 -117
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
- data/lib/textus/produce/acquire/handler.rb +0 -29
- data/lib/textus/produce/acquire/intake.rb +0 -130
- data/lib/textus/produce/acquire/projection.rb +0 -127
- data/lib/textus/produce/acquire/serializer/json.rb +0 -31
- data/lib/textus/produce/acquire/serializer/text.rb +0 -16
- data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/produce/acquire/serializer.rb +0 -17
- data/lib/textus/produce/events.rb +0 -36
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/read/audit.rb +0 -130
- data/lib/textus/read/boot.rb +0 -26
- data/lib/textus/read/capabilities.rb +0 -70
- data/lib/textus/read/deps.rb +0 -38
- data/lib/textus/read/doctor.rb +0 -27
- data/lib/textus/read/freshness.rb +0 -152
- data/lib/textus/read/get.rb +0 -73
- data/lib/textus/read/jobs.rb +0 -31
- data/lib/textus/read/list.rb +0 -24
- data/lib/textus/read/published.rb +0 -22
- data/lib/textus/read/pulse.rb +0 -98
- data/lib/textus/read/rdeps.rb +0 -39
- data/lib/textus/read/rule_explain.rb +0 -96
- data/lib/textus/read/rule_list.rb +0 -54
- data/lib/textus/read/schema_envelope.rb +0 -25
- data/lib/textus/read/uid.rb +0 -29
- data/lib/textus/read/validate_all.rb +0 -36
- data/lib/textus/read/where.rb +0 -24
- data/lib/textus/role_scope.rb +0 -78
- data/lib/textus/write/accept.rb +0 -58
- data/lib/textus/write/enqueue.rb +0 -50
- data/lib/textus/write/key_delete.rb +0 -65
- data/lib/textus/write/key_mv.rb +0 -141
- data/lib/textus/write/propose.rb +0 -54
- data/lib/textus/write/put.rb +0 -74
- data/lib/textus/write/reject.rb +0 -68
data/docs/architecture/README.md
CHANGED
|
@@ -1,110 +1,146 @@
|
|
|
1
1
|
# Textus architecture
|
|
2
2
|
|
|
3
3
|
> **Explanation** · for contributors · **read this first** for orientation before SPEC
|
|
4
|
-
> **SSoT for** the Ruby implementation layout (layers, container, ports,
|
|
4
|
+
> **SSoT for** the Ruby implementation layout (layers, container, ports, dispatch paths) · **reviewed** 2026-06 (v0.54)
|
|
5
5
|
|
|
6
6
|
```mermaid
|
|
7
7
|
flowchart TD
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
surfaces["Surfaces — CLI · MCP · RoleScope"]
|
|
9
|
+
contract["Contract — per-verb DSL (source of truth for public interfaces)"]
|
|
10
|
+
gate["Gate — routing (VERB_COMMAND) · Auth"]
|
|
11
|
+
action["Actions — per-verb use cases (action/)"]
|
|
12
|
+
jobs["Jobs — Worker · Planner · Materialize · Retention · Sweep"]
|
|
13
|
+
produce["Produce — Engine · Render"]
|
|
14
|
+
workflow["Workflow — Runner · Registry · Loader · DSL"]
|
|
15
|
+
manifest["Manifest — declarative config, no IO (policy/, schema/, entry/)"]
|
|
16
|
+
core["Core — pure value types (Freshness, Retention, Duration, Sentinel)"]
|
|
17
|
+
ports["Ports — IO adapters (FileStore, AuditLog, JobStore, Publisher…)"]
|
|
18
|
+
surfaces --> contract
|
|
19
|
+
contract --> gate
|
|
20
|
+
gate --> action
|
|
21
|
+
action --> jobs
|
|
22
|
+
action --> produce
|
|
23
|
+
produce --> workflow
|
|
24
|
+
action --> manifest
|
|
25
|
+
action --> core
|
|
26
|
+
action --> ports
|
|
27
|
+
jobs --> produce
|
|
16
28
|
```
|
|
17
29
|
|
|
18
|
-
*Dependency rule:
|
|
30
|
+
*Dependency rule: inward only.* Surfaces → Contract → Gate → Actions → inner layers. Actions never reference surfaces.
|
|
19
31
|
|
|
20
32
|
### What lives in each layer
|
|
21
33
|
|
|
22
|
-
**
|
|
34
|
+
**Surfaces**
|
|
23
35
|
|
|
24
36
|
```
|
|
25
|
-
CLI
|
|
26
|
-
|
|
37
|
+
surfaces/cli/ CLI command generation from contracts
|
|
38
|
+
surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
|
|
39
|
+
surfaces/role_scope.rb
|
|
40
|
+
(Store#as(role)) — holds (container, role, dry_run, correlation_id);
|
|
41
|
+
all verb methods injected via define_method in textus.rb
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Contract**
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
contract/ Per-verb DSL — verb, summary, surfaces, arg, view.
|
|
48
|
+
Contract::Binder.inputs_from_ordered splits the uniform inputs hash
|
|
49
|
+
into positional/keyword args for every surface.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Gate + Auth**
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
gate.rb VERB_COMMAND (verb symbol → Command class)
|
|
56
|
+
Gate#dispatch(cmd) — Auth → action call
|
|
57
|
+
gate/auth.rb Authorization engine — FLOOR predicates + rule guards
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Actions**
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
action/{get,list,put,key_delete,key_mv,accept,reject,propose,
|
|
64
|
+
drain,enqueue,audit,blame,deps,rdeps,published,boot,doctor,
|
|
65
|
+
rule_explain,rule_list,rule_lint,pulse,
|
|
66
|
+
data_mv,key_mv_prefix,key_delete_prefix,
|
|
67
|
+
schema_envelope,where,uid,jobs}.rb
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Jobs**
|
|
27
71
|
|
|
28
|
-
|
|
72
|
+
```
|
|
73
|
+
jobs/planner.rb Rules-driven job planning — seeds materialize/refresh/sweep jobs
|
|
74
|
+
jobs/worker.rb Queue drain — leases and runs jobs from JobStore
|
|
75
|
+
jobs/materialize.rb Per-key produce job → Produce::Engine.converge
|
|
76
|
+
jobs/refresh.rb Per-key refresh job (retention-rule-triggered)
|
|
77
|
+
jobs/retention.rb Retention policy check
|
|
78
|
+
jobs/sweep.rb Lane sweep job
|
|
29
79
|
```
|
|
30
80
|
|
|
31
|
-
|
|
32
|
-
mirror of `MCP::Catalog`. The contract now owns the whole request lifecycle —
|
|
33
|
-
`acquire → bind → invoke → render` (ADRs 0066–0068): one `Contract::Binder.bind`
|
|
34
|
-
splits the uniform by-name `inputs` hash into the use-case's positional/keyword
|
|
35
|
-
args for every surface; per-surface `view`s shape the output (`view` for
|
|
36
|
-
MCP/Ruby, `view(:cli)` for the operator envelope); declarative `source:`/
|
|
37
|
-
`coerce:`/`cli_stdin` populate inputs from files and stdin; `around:` resources
|
|
38
|
-
wrap the single dispatch site (`RoleScope#dispatch_bound`) for stateful verbs;
|
|
39
|
-
and `cli_default:` declares a CLI default that diverges from the agent default.
|
|
40
|
-
`CLI::Runner` generates a command per `:cli` contract, dispatching `contract.verb`
|
|
41
|
-
by construction. Only verbs with genuine *behavior* — `put` (entry persistence),
|
|
42
|
-
`get` (UnknownKey + resolver suggestions, CLI-only), and `doctor` (not yet
|
|
43
|
-
generatable) — stay hand-authored, plus commands with no dispatcher verb (`init`,
|
|
44
|
-
`hook`, `mcp serve`, `schema diff/init`). `boot` is auto-generated from its
|
|
45
|
-
contract. Total reconciliation specs make name/dispatch/facet drift unrepresentable.
|
|
46
|
-
|
|
47
|
-
**Application**
|
|
81
|
+
**Produce**
|
|
48
82
|
|
|
49
83
|
```
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
RoleScope (Store#as(role) — forwards verb calls)
|
|
55
|
-
|
|
56
|
-
read/{get,list,where,uid,schema_envelope,
|
|
57
|
-
deps,rdeps,published,validate_all,boot,doctor,
|
|
58
|
-
freshness,audit,blame,rule_explain,rule_list,pulse}.rb
|
|
59
|
-
write/{put,key_delete,key_mv,accept,reject,propose}.rb
|
|
60
|
-
maintenance/{drain,serve,worker,key_mv_prefix,key_delete_prefix,
|
|
61
|
-
zone_mv,rule_lint}.rb
|
|
62
|
-
produce/{engine,events,render,
|
|
63
|
-
acquire/{intake,handler,projection,serializer}}.rb
|
|
64
|
-
envelope/io/{reader,writer}.rb (split: parse vs persist)
|
|
65
|
-
projection.rb
|
|
84
|
+
produce/engine.rb Engine.converge(container:, call:, keys:)
|
|
85
|
+
For each key: looks up a registered workflow and runs it,
|
|
86
|
+
or publishes existing bytes for entries with publish targets.
|
|
87
|
+
produce/render.rb Mustache template expansion for publish targets
|
|
66
88
|
```
|
|
67
89
|
|
|
68
|
-
**
|
|
90
|
+
**Workflow**
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
workflow/runner.rb Runs a Definition: build_context → execute_steps → publish
|
|
94
|
+
workflow/registry.rb In-memory list of registered Definitions; Registry#for(key)
|
|
95
|
+
workflow/loader.rb Loads .textus/workflows/*.rb files via Collector DSL
|
|
96
|
+
workflow/dsl.rb Textus.workflow "name" { match "...", step :fetch { ... }, publish }
|
|
97
|
+
workflow/context.rb Context passed to each step (key, entry, config, container, call)
|
|
98
|
+
workflow/pattern.rb Key-matching pattern (glob via File.fnmatch?)
|
|
99
|
+
workflow/errors.rb NotFound, StepFailed
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Core (pure value types)**
|
|
69
103
|
|
|
70
104
|
```
|
|
71
|
-
Permission (write predicate per zone)
|
|
72
105
|
Freshness::{Verdict,Evaluator}
|
|
73
|
-
|
|
74
|
-
Policy::{Guard,GuardFactory,BaseGuards,Evaluation,Fetch,Matcher,HandlerAllowlist,
|
|
75
|
-
Predicates::{ZoneWritableBy,SchemaValid,AuthorHeld,TargetIsCanon,EtagMatch,FreshWithin}}
|
|
106
|
+
Core::Duration Core::Sentinel
|
|
76
107
|
```
|
|
77
108
|
|
|
78
109
|
**Infrastructure**
|
|
79
110
|
|
|
80
111
|
```
|
|
81
|
-
Store (composition root — wires ports,
|
|
82
|
-
|
|
83
|
-
Storage::FileStore (bytes-only port: read/write/delete/
|
|
84
|
-
exists?/etag)
|
|
112
|
+
Store (composition root — wires ports, vends Container)
|
|
113
|
+
Storage::FileStore (bytes-only port: read/write/delete/exists?/etag)
|
|
85
114
|
Manifest (Data, Resolver, Policy, Rules)
|
|
86
115
|
Schemas (eager-load cache)
|
|
87
|
-
Ports::{AuditLog,
|
|
88
|
-
BuildLock,
|
|
89
|
-
Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
|
|
90
|
-
Signature,Builtin,ErrorLog}
|
|
116
|
+
Ports::{AuditLog,JobStore,Publisher,Clock,
|
|
117
|
+
BuildLock,SentinelStore}
|
|
91
118
|
Entry::{Markdown,Json,Yaml,Text} (format strategies)
|
|
92
119
|
```
|
|
93
120
|
|
|
94
121
|
## How a verb becomes a method
|
|
95
122
|
|
|
96
|
-
|
|
123
|
+
All actions live under `lib/textus/action/`. The shape is uniform:
|
|
97
124
|
|
|
98
125
|
```ruby
|
|
99
126
|
module Textus
|
|
100
|
-
module
|
|
101
|
-
class Get
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
127
|
+
module Action
|
|
128
|
+
class Get < Base
|
|
129
|
+
extend Textus::Contract::DSL
|
|
130
|
+
|
|
131
|
+
verb :get
|
|
132
|
+
summary "Read an entry by key."
|
|
133
|
+
surfaces :cli, :mcp
|
|
134
|
+
arg :key, String, required: true, positional: true
|
|
135
|
+
|
|
136
|
+
BURN = :sync
|
|
137
|
+
|
|
138
|
+
def initialize(key:)
|
|
139
|
+
super()
|
|
140
|
+
@key = key
|
|
105
141
|
end
|
|
106
142
|
|
|
107
|
-
def call(
|
|
143
|
+
def call(container:, call:)
|
|
108
144
|
...
|
|
109
145
|
end
|
|
110
146
|
end
|
|
@@ -112,19 +148,7 @@ module Textus
|
|
|
112
148
|
end
|
|
113
149
|
```
|
|
114
150
|
|
|
115
|
-
Verbs are looked up in a static frozen table (`Textus::
|
|
116
|
-
|
|
117
|
-
The instantiate-and-call step itself has one home: `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` (ADR 0026). `RoleScope` builds the `Call` (request state) and delegates the dispatch to `Dispatcher.invoke`; the convention for invoking a uniform-shape use case lives next to the table that maps the verbs, not re-spelled in the caller. `Store`'s own verb loop is separate — it extracts the `role:` keyword and forwards to `as(role)`, a role-selection job distinct from invocation.
|
|
118
|
-
|
|
119
|
-
`boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
|
|
120
|
-
are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
|
|
121
|
-
`Textus::Doctor` report-building libraries (`build(container:, ...)`). They are
|
|
122
|
-
reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
|
|
123
|
-
|
|
124
|
-
Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
|
|
125
|
-
|
|
126
|
-
- `Produce::Engine` — runs the produce pipeline that `drain`/`serve` invoke via the `materialize` job handler; composes `Acquire::Intake` (external pull via handler) with `Produce::Render` (template-driven publish) per entry. Reactive re-produce is enqueued as `materialize` jobs by `Ports::ProduceOnWriteSubscriber` and run by a worker (no in-process thread runner).
|
|
127
|
-
- `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
|
|
151
|
+
Verbs are looked up in a static frozen table (`Textus::Action::VERBS`) that maps `:get → Action::Get`, `:put → Action::Put`, etc. Adding a new verb is one entry in `VERBS` plus the class — no metaprogramming.
|
|
128
152
|
|
|
129
153
|
## Container
|
|
130
154
|
|
|
@@ -133,128 +157,105 @@ Use cases never see the raw `Store`. `Textus::Container` is a single record hold
|
|
|
133
157
|
```ruby
|
|
134
158
|
Container = Data.define(
|
|
135
159
|
:manifest, :file_store, :schemas, :root,
|
|
136
|
-
:audit_log, :
|
|
160
|
+
:audit_log, :workflows, :gate
|
|
137
161
|
)
|
|
138
162
|
```
|
|
139
163
|
|
|
140
|
-
The `Store` builds one `Container` at boot; every
|
|
164
|
+
The `Store` builds one `Container` at boot; every action receives it via `(container:, call:)`. Workflow steps receive a `Workflow::Context` (key, entry, config, container, call).
|
|
141
165
|
|
|
142
166
|
## Ports
|
|
143
167
|
|
|
144
|
-
Ports are infrastructure adapters with an interface defined by the domain. Each port is independently replaceable — swap the implementation for tests or alternative runtimes without touching application or domain code.
|
|
145
|
-
|
|
146
168
|
| Class | Role |
|
|
147
169
|
|---|---|
|
|
148
|
-
| `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`.
|
|
149
|
-
| `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking,
|
|
150
|
-
| `Ports::Clock` | Supplies `Time.now` —
|
|
151
|
-
| `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel
|
|
152
|
-
| `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline.
|
|
153
|
-
| `Ports::
|
|
154
|
-
| `Ports::SentinelStore` | Reads and writes
|
|
170
|
+
| `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. |
|
|
171
|
+
| `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, rotation. |
|
|
172
|
+
| `Ports::Clock` | Supplies `Time.now` — module-function so tests can swap without DI boilerplate. |
|
|
173
|
+
| `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel. |
|
|
174
|
+
| `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline. |
|
|
175
|
+
| `Ports::JobStore` | Persistent job queue used by `drain` workers; tracks ready/leased/done/failed jobs. |
|
|
176
|
+
| `Ports::SentinelStore` | Reads and writes per-target sentinel files for managed-file detection. |
|
|
155
177
|
|
|
156
178
|
Application use cases access ports only through `Container` fields — never through the raw `Store`.
|
|
157
179
|
|
|
158
|
-
###
|
|
159
|
-
|
|
160
|
-
`Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
|
|
180
|
+
### Envelope
|
|
161
181
|
|
|
162
|
-
|
|
182
|
+
`Envelope::Reader` and `Envelope::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
|
|
163
183
|
|
|
164
|
-
**
|
|
184
|
+
**Reader** (`lib/textus/envelope/reader.rb`) — resolves a key through `manifest.resolver`, reads bytes via `FileStore`, parses via the format strategy, returns an `Envelope`. No audit, no permissions.
|
|
165
185
|
|
|
166
|
-
|
|
186
|
+
**Writer** (`lib/textus/envelope/writer.rb`) — owns the full write pipeline: serialize → schema-validate → etag-check → `FileStore#write` → `AuditLog#append`. The audit append is the commit step.
|
|
167
187
|
|
|
168
|
-
Both are built from a `Container` via named constructors — `Writer.from(container:, call:)`
|
|
188
|
+
Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` and `Reader.from(container:)`.
|
|
169
189
|
|
|
170
190
|
## Manifest carving
|
|
171
191
|
|
|
172
|
-
Manifest
|
|
173
|
-
|
|
174
|
-
`Manifest` itself is a `Data.define` struct — a composition record with four named members:
|
|
192
|
+
`Manifest` is a `Data.define` struct with four named members:
|
|
175
193
|
|
|
176
194
|
| Member | Class | Responsibility |
|
|
177
195
|
|---|---|---|
|
|
178
|
-
| `data` | `Manifest::Data` | Frozen value:
|
|
179
|
-
| `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`.
|
|
180
|
-
| `policy` | `Manifest::Policy` | Zone/capability authority — `
|
|
181
|
-
| `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)`
|
|
182
|
-
|
|
183
|
-
Rationale: cleaner test seams — a use case that only needs key resolution constructs a `Manifest::Resolver` from a stub `Data`; one that only needs rule lookup constructs a `Manifest::Rules` directly. No consumer is forced to build the full manifest to exercise one sub-view.
|
|
184
|
-
|
|
185
|
-
The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Manifest::Data` constructs `Policy` internally during `initialize`; the others are assembled by the loader and handed in as named arguments.
|
|
196
|
+
| `data` | `Manifest::Data` | Frozen value: raw, root, lanes, entries, audit_config, role_caps. |
|
|
197
|
+
| `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Nested entry enumeration and fuzzy-match suggestions. |
|
|
198
|
+
| `policy` | `Manifest::Policy` | Zone/capability authority — `verb_for_lane`, `roles_with_capability`, `propose_lane_for(role)`. |
|
|
199
|
+
| `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` → `RuleSet(guard, retention, react)`. |
|
|
186
200
|
|
|
187
201
|
## Read path (`store.get(key)`)
|
|
188
202
|
|
|
189
|
-
`
|
|
203
|
+
`Action::Get` is a **pure read** — it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict. It never ingests and never mutates.
|
|
190
204
|
|
|
191
|
-
1. CLI
|
|
192
|
-
2. `
|
|
193
|
-
3. `
|
|
205
|
+
1. CLI/MCP surface calls `store.as(role).get(key)`.
|
|
206
|
+
2. `Gate#dispatch` runs Auth → `Action::Get#call`.
|
|
207
|
+
3. `Get` resolves path via `manifest.resolver`, reads bytes via `file_store`, parses the envelope, annotates `freshness` based on retention-rule TTL (if any).
|
|
194
208
|
|
|
195
|
-
|
|
209
|
+
Staleness is age-based (retention-rule TTL vs file mtime). A stale entry is returned stale — the read does not refresh it; `drain` does.
|
|
196
210
|
|
|
197
211
|
## Write path (`store.put(key, ...)`)
|
|
198
212
|
|
|
199
|
-
1. CLI
|
|
200
|
-
2. `
|
|
201
|
-
3.
|
|
202
|
-
4.
|
|
203
|
-
|
|
204
|
-
`Write::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
|
|
205
|
-
|
|
206
|
-
`Write::KeyMv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
|
|
213
|
+
1. CLI/MCP surface calls `store.as(role).put(key, meta:, body:)`.
|
|
214
|
+
2. `Gate#dispatch` runs Auth → `Action::Put#call`.
|
|
215
|
+
3. `Put` validates, resolves manifest entry, delegates to `Envelope::Writer#put` (serialize → schema-validate → etag-check → FileStore#write → AuditLog#append).
|
|
216
|
+
4. `WriteVerb#cascade_to_rdeps` enqueues `materialize` jobs for any entries with publish_tree that depend on the written key.
|
|
207
217
|
|
|
208
|
-
|
|
218
|
+
`Action::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape.
|
|
209
219
|
|
|
210
|
-
|
|
220
|
+
## Workflow path (`drain` + materialize jobs)
|
|
211
221
|
|
|
212
|
-
`Produce::Engine.converge(container:, call:, keys:)` is the entry point
|
|
222
|
+
The workflow system owns the produce pipeline. `Produce::Engine.converge(container:, call:, keys:)` is the entry point that `Jobs::Materialize` calls.
|
|
213
223
|
|
|
214
224
|
For each key, `Engine#produce_one`:
|
|
215
225
|
|
|
216
|
-
1.
|
|
217
|
-
|
|
218
|
-
-
|
|
219
|
-
-
|
|
220
|
-
|
|
221
|
-
- On success: normalises the handler result via its own `normalize_action_result` (keyed on the entry's format), checks guard, persists via `Envelope::IO::Writer`, publishes `:entry_fetched` unless the etag is unchanged.
|
|
222
|
-
- `Acquire::Handler` resolves and invokes the RPC callable under the timeout deadline. (The sibling **projection** sub-path — `from: project` entries — instead runs `Acquire::Projection`, which renders data files through `Acquire::Serializer::{Json,Yaml,Text}` before persisting.)
|
|
223
|
-
2. **Render phase** — `entry.publish_via(context)` calls `Produce::Render#bytes_for(target:, data:, boot:)` to expand the Mustache template and copy the result to the publish target via `Ports::Publisher`. Returns `nil` if no publish is configured (skipped).
|
|
226
|
+
1. Looks up a registered workflow via `container.workflows.for(key)` (Pattern matching against the key).
|
|
227
|
+
2. If a workflow matches: `Workflow::Runner.new(definition, container:, call:).run(key)`.
|
|
228
|
+
- `Runner` builds a `Workflow::Context`, executes each step in sequence, then publishes.
|
|
229
|
+
- **Built-in publish**: writes the step output to the entry via `Envelope::Writer`, then calls `entry.publish_via(pctx)` for any `publish_to` or `publish_tree` targets.
|
|
230
|
+
3. If no workflow matches but the entry has publish targets: runs `publish_only` — publishes existing store bytes via `entry.publish_via` without re-fetching.
|
|
224
231
|
|
|
225
|
-
|
|
232
|
+
**Workflow files** live in `.textus/workflows/*.rb`. The DSL:
|
|
226
233
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
```ruby
|
|
235
|
+
Textus.workflow "my-producer" do
|
|
236
|
+
match "artifacts.derived.*"
|
|
237
|
+
step :fetch do |data, ctx|
|
|
238
|
+
{ content: { "key" => ctx.key } }
|
|
239
|
+
end
|
|
240
|
+
publish # uses built-in publish (writes to entry, then to publish targets)
|
|
241
|
+
end
|
|
242
|
+
```
|
|
232
243
|
|
|
233
244
|
## Agent surface (boot + pulse + MCP)
|
|
234
245
|
|
|
235
|
-
Agents and plugins talk to a textus store through three layers:
|
|
236
|
-
|
|
237
246
|
```
|
|
238
|
-
|
|
247
|
+
agent/plugin ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
|
|
239
248
|
```
|
|
240
249
|
|
|
241
250
|
Two transports, one façade:
|
|
242
251
|
|
|
243
252
|
- **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
|
|
244
|
-
- **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server
|
|
245
|
-
|
|
246
|
-
Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
|
|
247
|
-
|
|
248
|
-
The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
|
|
249
|
-
|
|
250
|
-
1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
|
|
251
|
-
2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
|
|
252
|
-
3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
|
|
253
|
-
|
|
254
|
-
Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change to the manifest, hooks, or schemas; ADR 0074); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
|
|
253
|
+
- **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server. Tools are auto-derived from contracts. Session state (cursor, role, contract_etag) is server-side.
|
|
255
254
|
|
|
256
|
-
|
|
255
|
+
The agent loop:
|
|
257
256
|
|
|
258
|
-
|
|
257
|
+
1. **Session start:** `boot()` → contract envelope (lanes, entries, workflows, write_flows, agent_quickstart).
|
|
258
|
+
2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor, next_due_at}`.
|
|
259
|
+
3. **On demand:** `get`, `put`, `propose`, `schema_show`, `rule_explain`, `drain`.
|
|
259
260
|
|
|
260
|
-
|
|
261
|
+
Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change to manifest/schemas); audit cursor expiry as `CursorExpired`. Both signal "call `boot` again."
|
data/exe/textus
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class Accept < WriteVerb
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :accept
|
|
9
|
+
summary "apply a queued proposal to its target zone; requires the author capability"
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
cli "accept"
|
|
12
|
+
arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
|
|
13
|
+
|
|
14
|
+
def initialize(pending_key:)
|
|
15
|
+
super()
|
|
16
|
+
@pending_key = pending_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(container:, call:)
|
|
20
|
+
env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
|
|
21
|
+
proposal = env.meta["proposal"] or raise Textus::ProposalError.new("entry has no proposal block: #{@pending_key}")
|
|
22
|
+
target = proposal["target_key"] or raise Textus::ProposalError.new("proposal missing target_key")
|
|
23
|
+
action = proposal["action"] || "put"
|
|
24
|
+
|
|
25
|
+
case action
|
|
26
|
+
when "put"
|
|
27
|
+
Textus::Action::Put.new(
|
|
28
|
+
key: target,
|
|
29
|
+
meta: env.meta["_meta"] || {},
|
|
30
|
+
body: env.body,
|
|
31
|
+
).call(container: container, call: call)
|
|
32
|
+
when "delete"
|
|
33
|
+
Textus::Action::KeyDelete.new(key: target).call(container: container, call: call)
|
|
34
|
+
else
|
|
35
|
+
raise Textus::ProposalError.new("unknown action: #{action}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Textus::Action::KeyDelete.new(key: @pending_key).call(container: container, call: call)
|
|
39
|
+
|
|
40
|
+
{ "protocol" => Textus::PROTOCOL, "accepted" => @pending_key, "target_key" => target, "action" => action }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Textus
|
|
7
|
+
module Action
|
|
8
|
+
class Audit < Base
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :audit
|
|
12
|
+
summary "Query the audit log with optional filters."
|
|
13
|
+
surfaces :cli
|
|
14
|
+
cli "audit"
|
|
15
|
+
arg :key, String, required: false, description: "filter to rows for this key"
|
|
16
|
+
arg :lane, String, required: false, description: "filter to keys in this lane"
|
|
17
|
+
arg :role, String, required: false, description: "filter to rows written under this role"
|
|
18
|
+
arg :verb, String, required: false, description: "filter to rows for this verb"
|
|
19
|
+
arg :since, String, required: false,
|
|
20
|
+
coerce: ->(s) { Textus::Action::Audit.parse_since(s, now: Time.now) },
|
|
21
|
+
description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
|
|
22
|
+
arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
|
|
23
|
+
arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
|
|
24
|
+
arg :limit, Integer, required: false, description: "maximum number of rows to return"
|
|
25
|
+
view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
|
|
26
|
+
|
|
27
|
+
def initialize(**kwargs)
|
|
28
|
+
super()
|
|
29
|
+
@query = Query.build(**kwargs.slice(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def args
|
|
33
|
+
@query.to_h.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(container:, **)
|
|
37
|
+
@manifest = container.manifest
|
|
38
|
+
@root = container.root
|
|
39
|
+
@log_path = Textus::Layout.audit_log(container.root)
|
|
40
|
+
@audit_log = container.audit_log
|
|
41
|
+
|
|
42
|
+
query = @query
|
|
43
|
+
check_cursor_expiry!(query.seq_since)
|
|
44
|
+
|
|
45
|
+
files = all_log_files
|
|
46
|
+
return [] if files.empty?
|
|
47
|
+
|
|
48
|
+
rows = []
|
|
49
|
+
files.each do |file|
|
|
50
|
+
File.foreach(file) do |line|
|
|
51
|
+
parsed = parse_row(line.chomp)
|
|
52
|
+
next unless parsed
|
|
53
|
+
next unless query.matches?(parsed)
|
|
54
|
+
next if query.lane && !key_in_lane?(parsed["key"], query.lane)
|
|
55
|
+
|
|
56
|
+
rows << parsed
|
|
57
|
+
break if limit_reached?(rows, query)
|
|
58
|
+
end
|
|
59
|
+
break if limit_reached?(rows, query)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
rows
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.parse_since(str, now: Time.now.utc)
|
|
66
|
+
return nil if str.nil? || str.empty?
|
|
67
|
+
return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
|
|
68
|
+
|
|
69
|
+
match = str.match(/\A(\d+)([smhd])\z/) or return nil
|
|
70
|
+
mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[match[2]]
|
|
71
|
+
now - (match[1].to_i * mult)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Query = Data.define(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
|
|
75
|
+
# rubocop:disable Metrics/ParameterLists
|
|
76
|
+
def self.build(key: nil, lane: nil, role: nil, verb: nil,
|
|
77
|
+
since: nil, seq_since: nil, correlation_id: nil, limit: nil)
|
|
78
|
+
new(key:, lane:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
|
|
79
|
+
end
|
|
80
|
+
# rubocop:enable Metrics/ParameterLists
|
|
81
|
+
|
|
82
|
+
def matches?(row)
|
|
83
|
+
return false if key && row["key"] != key
|
|
84
|
+
return false if role && row["role"] != role
|
|
85
|
+
return false if verb && row["verb"] != verb
|
|
86
|
+
return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
|
|
87
|
+
return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
|
|
88
|
+
return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
|
|
97
|
+
|
|
98
|
+
def check_cursor_expiry!(seq_since)
|
|
99
|
+
return unless seq_since
|
|
100
|
+
|
|
101
|
+
log = @audit_log || Textus::Ports::AuditLog.new(@root)
|
|
102
|
+
min = log.min_available_seq
|
|
103
|
+
raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def all_log_files
|
|
107
|
+
rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
|
|
108
|
+
.reject { |path| path.end_with?(".meta.json") }
|
|
109
|
+
.sort_by { |path| -path.scan(/\d+$/).first.to_i }
|
|
110
|
+
active = File.exist?(@log_path) ? [@log_path] : []
|
|
111
|
+
rotated + active
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_row(line)
|
|
115
|
+
return nil if line.empty?
|
|
116
|
+
return nil unless line.start_with?("{")
|
|
117
|
+
|
|
118
|
+
JSON.parse(line)
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def key_in_lane?(key, lane)
|
|
124
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
125
|
+
mentry && mentry.lane == lane
|
|
126
|
+
rescue Textus::Error
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|