textus 0.54.1 → 0.55.1
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 +51 -1
- data/README.md +61 -27
- data/SPEC.md +167 -296
- data/docs/architecture/README.md +20 -8
- data/docs/reference/conventions.md +40 -56
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +23 -21
- data/lib/textus/action/audit.rb +24 -61
- data/lib/textus/action/base.rb +9 -9
- data/lib/textus/action/blame.rb +18 -36
- data/lib/textus/action/boot.rb +2 -4
- data/lib/textus/action/data_mv.rb +20 -31
- data/lib/textus/action/deps.rb +3 -18
- data/lib/textus/action/doctor.rb +2 -9
- data/lib/textus/action/drain.rb +11 -19
- data/lib/textus/action/enqueue.rb +14 -30
- data/lib/textus/action/get.rb +12 -56
- data/lib/textus/action/ingest.rb +141 -77
- data/lib/textus/action/jobs.rb +6 -15
- data/lib/textus/action/key_delete.rb +6 -16
- data/lib/textus/action/key_delete_prefix.rb +8 -17
- data/lib/textus/action/key_mv.rb +54 -61
- data/lib/textus/action/key_mv_prefix.rb +13 -22
- data/lib/textus/action/list.rb +7 -21
- data/lib/textus/action/propose.rb +16 -26
- data/lib/textus/action/published.rb +3 -5
- data/lib/textus/action/pulse.rb +20 -27
- data/lib/textus/action/put.rb +15 -29
- data/lib/textus/action/rdeps.rb +3 -18
- data/lib/textus/action/reject.rb +12 -21
- data/lib/textus/action/rule_explain.rb +12 -22
- data/lib/textus/action/rule_lint.rb +10 -16
- data/lib/textus/action/rule_list.rb +5 -9
- data/lib/textus/action/schema_envelope.rb +3 -10
- data/lib/textus/action/uid.rb +3 -17
- data/lib/textus/action/where.rb +3 -18
- data/lib/textus/boot.rb +8 -16
- data/lib/textus/contract/arg.rb +10 -0
- data/lib/textus/contract/dsl.rb +88 -0
- data/lib/textus/contract/spec.rb +25 -0
- data/lib/textus/contract.rb +0 -162
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +4 -7
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +6 -0
- data/lib/textus/format/base.rb +0 -4
- data/lib/textus/format/json.rb +5 -6
- data/lib/textus/format/markdown.rb +5 -6
- data/lib/textus/format/shared.rb +17 -0
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -6
- data/lib/textus/format.rb +6 -0
- data/lib/textus/gate/auth.rb +2 -17
- data/lib/textus/gate/binder.rb +50 -0
- data/lib/textus/gate.rb +64 -88
- data/lib/textus/init.rb +2 -4
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -5
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/meta.rb +54 -0
- data/lib/textus/{ports → port}/audit_log.rb +44 -4
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
- data/lib/textus/port/store.rb +93 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -1
- data/lib/textus/schema/tools.rb +11 -7
- data/lib/textus/store/compositor.rb +34 -0
- data/lib/textus/store/container.rb +43 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/envelope/reader.rb +43 -0
- data/lib/textus/store/envelope/writer.rb +195 -0
- data/lib/textus/store/geometry.rb +81 -0
- data/lib/textus/store/index/builder.rb +74 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +104 -0
- data/lib/textus/store/jobs/queue.rb +154 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention.rb +50 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/session.rb +37 -0
- data/lib/textus/store.rb +21 -13
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/surface/projector.rb +27 -0
- data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/value/envelope.rb +89 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/result.rb +26 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +4 -18
- data/lib/textus.rb +9 -10
- metadata +100 -62
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -83
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/role.rb +0 -36
- data/lib/textus/session.rb +0 -35
- data/lib/textus/types.rb +0 -15
data/docs/architecture/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
```mermaid
|
|
7
7
|
flowchart TD
|
|
8
8
|
surfaces["Surfaces — CLI · MCP · RoleScope"]
|
|
9
|
-
contract["Contract — per-verb DSL (
|
|
9
|
+
contract["Contract — per-verb DSL (Arg, Spec, DSL)"]
|
|
10
|
+
dispatch["Dispatch — Binder · Around · CommandBuilder · Dispatcher"]
|
|
10
11
|
gate["Gate — routing (VERB_COMMAND) · Auth"]
|
|
11
12
|
action["Actions — per-verb use cases (action/)"]
|
|
12
13
|
jobs["Jobs — Worker · Planner · Materialize · Retention · Sweep"]
|
|
@@ -15,8 +16,10 @@ flowchart TD
|
|
|
15
16
|
manifest["Manifest — declarative config, no IO (policy/, schema/, entry/)"]
|
|
16
17
|
core["Core — pure value types (Freshness, Retention, Duration, Sentinel)"]
|
|
17
18
|
ports["Ports — IO adapters (FileStore, AuditLog, JobStore, Publisher…)"]
|
|
18
|
-
surfaces -->
|
|
19
|
-
|
|
19
|
+
surfaces --> dispatch
|
|
20
|
+
surfaces -.->|declare| contract
|
|
21
|
+
contract --> dispatch
|
|
22
|
+
dispatch --> gate
|
|
20
23
|
gate --> action
|
|
21
24
|
action --> jobs
|
|
22
25
|
action --> produce
|
|
@@ -27,7 +30,7 @@ flowchart TD
|
|
|
27
30
|
jobs --> produce
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
*Dependency rule: inward only.* Surfaces →
|
|
33
|
+
*Dependency rule: inward only.* Surfaces → Dispatch → Gate → Actions → inner layers. Actions never reference surfaces.
|
|
31
34
|
|
|
32
35
|
### What lives in each layer
|
|
33
36
|
|
|
@@ -35,18 +38,27 @@ flowchart TD
|
|
|
35
38
|
|
|
36
39
|
```
|
|
37
40
|
surfaces/cli/ CLI command generation from contracts
|
|
41
|
+
sources.rb CLI-only input acquisition (stdin parsing, file sourcing, coercion)
|
|
38
42
|
surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
|
|
39
43
|
surfaces/role_scope.rb
|
|
40
44
|
(Store#as(role)) — holds (container, role, dry_run, correlation_id);
|
|
41
45
|
all verb methods injected via define_method in textus.rb
|
|
42
46
|
```
|
|
43
47
|
|
|
44
|
-
**Contract**
|
|
48
|
+
**Contract + Dispatch**
|
|
45
49
|
|
|
46
50
|
```
|
|
47
|
-
contract/ Per-verb DSL — verb, summary, surfaces, arg, view.
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
contract/ Per-verb DSL — verb, summary, surfaces, arg, view, around.
|
|
52
|
+
Pure declaration: Spec and DSL only.
|
|
53
|
+
|
|
54
|
+
dispatch/ Runtime machinery consumed by every surface.
|
|
55
|
+
binder.rb Binder — validates required args, resolves session/literal defaults.
|
|
56
|
+
around.rb Around — registry of stateful wrappers (cursor, build_lock).
|
|
57
|
+
view.rb View.render(spec, surface, result, inputs) — output shaping.
|
|
58
|
+
command_builder.rb
|
|
59
|
+
CommandBuilder — command construction + role injection.
|
|
60
|
+
dispatcher.rb Dispatcher — unified pipeline: Around → Binder → Command → Gate.
|
|
61
|
+
resources/ Around resource implementations (Cursor, BuildLock).
|
|
50
62
|
```
|
|
51
63
|
|
|
52
64
|
**Gate + Auth**
|
|
@@ -8,11 +8,11 @@ Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and i
|
|
|
8
8
|
## Key naming
|
|
9
9
|
|
|
10
10
|
- **Segments are lowercase, kebab- or snake-case.** The grammar `^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$` is the hard limit. Prefer `acme-dashboard` over `acmedashboard` when there's a natural word break.
|
|
11
|
-
- **Lead with the
|
|
11
|
+
- **Lead with the lane in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The lane prefix makes it obvious from the key alone whether a write will be accepted.
|
|
12
12
|
- **Mirror the directory structure.** If `working.projects.acme.dashboard` resolves to `working/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
|
|
13
13
|
- **Don't pluralise the leaf.** `working.network.org.jane`, not `working.network.org.janes`. Pluralise the container, not the entry.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Lane layout
|
|
16
16
|
|
|
17
17
|
Recommended top-level layout — the spec allows alternatives, but this is what tooling will default to:
|
|
18
18
|
|
|
@@ -20,12 +20,14 @@ Recommended top-level layout — the spec allows alternatives, but this is what
|
|
|
20
20
|
.textus/
|
|
21
21
|
manifest.yaml
|
|
22
22
|
schemas/ # YAML schema definitions
|
|
23
|
-
|
|
23
|
+
workflows/ # Textus.workflow DSL files for produced entry acquisition
|
|
24
|
+
templates/ # ERB templates for publish rendering
|
|
25
|
+
data/
|
|
24
26
|
knowledge/ # authored truth: identity, voice, decisions — author-holders write
|
|
25
27
|
notebook/ # agent's own durable lane (workspace) — keep-holders write
|
|
26
|
-
feeds/ # automation-fed external inputs (fetch)
|
|
27
28
|
proposals/ # AI proposals awaiting accept (propose)
|
|
28
|
-
artifacts/ #
|
|
29
|
+
artifacts/ # computed outputs produced by drain — never edit by hand
|
|
30
|
+
raw/ # write-once external source material (ingest)
|
|
29
31
|
```
|
|
30
32
|
|
|
31
33
|
Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
|
|
@@ -47,75 +49,57 @@ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it
|
|
|
47
49
|
|
|
48
50
|
Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
|
|
49
51
|
|
|
50
|
-
##
|
|
52
|
+
## Produced entries
|
|
51
53
|
|
|
52
|
-
A
|
|
53
|
-
|
|
54
|
-
**`source: { from: project }`** — textus computes the entry's data on `textus drain` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
|
|
54
|
+
A produced entry declares `source: { from: external, command: "true", sources: [] }` and a matching `Textus.workflow` block in `.textus/workflows/**/*.rb`. The `source:` acquires **data** — it never renders; rendering is a publish concern (below).
|
|
55
55
|
|
|
56
56
|
```yaml
|
|
57
|
-
- key: artifacts.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
source:
|
|
63
|
-
from: project
|
|
64
|
-
select: knowledge.network.org # prefix or list of prefixes
|
|
65
|
-
pluck: [name, relationship, org]
|
|
66
|
-
sort_by: name
|
|
67
|
-
format: json # the stored form is data
|
|
57
|
+
- key: artifacts.feeds.skills
|
|
58
|
+
lane: artifacts
|
|
59
|
+
kind: produced
|
|
60
|
+
format: json
|
|
61
|
+
source: { from: external, command: "true", sources: [] }
|
|
68
62
|
publish:
|
|
69
|
-
- { to: docs/
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
**`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `converge` (default: `automation`).
|
|
73
|
-
|
|
74
|
-
```yaml
|
|
75
|
-
- key: artifacts.derived.skills
|
|
76
|
-
path: artifacts/derived/skills.md
|
|
77
|
-
zone: artifacts
|
|
78
|
-
owner: automation:catalog-skills
|
|
79
|
-
source:
|
|
80
|
-
from: command
|
|
81
|
-
command: "rake catalog:skills" # informational; the automation invokes it
|
|
82
|
-
sources: [knowledge.projects, knowledge.network]
|
|
63
|
+
- { to: docs/reference/skills.md, template: feeds/skills.erb }
|
|
83
64
|
```
|
|
84
65
|
|
|
85
|
-
The
|
|
86
|
-
|
|
87
|
-
Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Data transforms (`source.transform:`) and publishing (a `publish:` list of `{ to, template? }` / `{ tree }` targets — render or copy the data) are covered in §5.2–§5.3.
|
|
66
|
+
The matching workflow block:
|
|
88
67
|
|
|
89
|
-
|
|
68
|
+
```ruby
|
|
69
|
+
# .textus/workflows/feeds/agentskills.rb
|
|
70
|
+
Textus.workflow "agentskills" do
|
|
71
|
+
match "artifacts.feeds.skills"
|
|
90
72
|
|
|
91
|
-
|
|
73
|
+
step :fetch do |_, _ctx|
|
|
74
|
+
{ "content" => { "skills" => [...], "count" => 1 } }
|
|
75
|
+
end
|
|
92
76
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
textus drain --as=automation # re-pulls every intake entry past its source.ttl
|
|
77
|
+
publish
|
|
78
|
+
end
|
|
96
79
|
```
|
|
97
80
|
|
|
98
|
-
|
|
81
|
+
`drain` discovers workflows, matches entries, runs the steps, and writes the result back to the entry's data path. Publishing then copies or renders each `publish:` target.
|
|
99
82
|
|
|
100
|
-
|
|
101
|
-
- key: artifacts.feeds.notion.events
|
|
102
|
-
kind: produced # produce-method (intake) read from source.from: handler
|
|
103
|
-
zone: artifacts
|
|
104
|
-
source: { from: handler, handler: notion, config: { ... }, ttl: 6h }
|
|
83
|
+
Age-based GC uses a `retention:` rule in the top-level `rules:` block:
|
|
105
84
|
|
|
85
|
+
```yaml
|
|
106
86
|
rules:
|
|
107
|
-
- match: artifacts.feeds
|
|
87
|
+
- match: artifacts.feeds.**
|
|
108
88
|
retention: { ttl: 90d, action: archive }
|
|
109
89
|
```
|
|
110
90
|
|
|
111
|
-
|
|
112
|
-
|
|
91
|
+
Full contract in [`../../SPEC.md` §5.2](../../SPEC.md).
|
|
92
|
+
|
|
93
|
+
## Freshness
|
|
94
|
+
|
|
95
|
+
A `get` is always a pure read (ADR 0089) — it never triggers a re-produce:
|
|
113
96
|
|
|
114
97
|
```sh
|
|
115
|
-
textus
|
|
98
|
+
textus pulse --output=json # `stale` lists entries past their ttl
|
|
99
|
+
textus drain --as=automation # re-produces stale entries
|
|
116
100
|
```
|
|
117
101
|
|
|
118
|
-
See [`./
|
|
102
|
+
See [`./lanes.md`](lanes.md) for lane semantics and [`../how-to/configuring-lanes.md`](../how-to/configuring-lanes.md) for setting up produced entries and workflows.
|
|
119
103
|
|
|
120
104
|
### Read vs. refresh
|
|
121
105
|
|
|
@@ -141,11 +125,11 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
|
|
|
141
125
|
|
|
142
126
|
The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](../architecture/decisions/0018-manifest-carving.md), [ADR 0017](../architecture/decisions/0017-envelope-io-split.md), [ADR 0022](../architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](../architecture/decisions/0023-uniform-use-case-shape.md).
|
|
143
127
|
|
|
144
|
-
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(
|
|
145
|
-
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/
|
|
128
|
+
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(lane)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
|
|
129
|
+
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/action/` with `def initialize(...)` (args from contract) and a `#call(container:, call:)` method; verbs are looked up in the static `Textus::Action::VERBS` table. `Container` is a `Dry::Struct` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `workflows`, `gate`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). All dispatch routes through `Gate#dispatch(spec:, inputs:, role:, correlation_id:, session:)` — the single seam that binds inputs via `Gate::Binder`, checks auth, and invokes the action. `store.as(role)` returns a `RoleScope` that forwards verbs to `Gate::dispatch`.
|
|
146
130
|
- **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
|
|
147
131
|
|
|
148
|
-
The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/
|
|
132
|
+
The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/4`) are unchanged.
|
|
149
133
|
|
|
150
134
|
## Pairing with other tools
|
|
151
135
|
|
data/exe/textus
CHANGED
data/lib/textus/action/accept.rb
CHANGED
|
@@ -2,42 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Action
|
|
5
|
-
class Accept <
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
5
|
+
class Accept < Base
|
|
8
6
|
verb :accept
|
|
9
7
|
summary "apply a queued proposal to its target zone; requires the author capability"
|
|
10
8
|
surfaces :cli, :mcp
|
|
11
9
|
cli "accept"
|
|
12
10
|
arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
|
|
13
11
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
def self.call(container:, call:, pending_key:)
|
|
13
|
+
env = container.compositor.read(pending_key)
|
|
14
|
+
parsed = proposal_from(env, key: pending_key)
|
|
15
|
+
return parsed if parsed.is_a?(Dry::Monads::Result::Failure)
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
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"
|
|
17
|
+
target = parsed[:target_key]
|
|
18
|
+
action = parsed[:proposal]["action"] || "put"
|
|
24
19
|
|
|
25
20
|
case action
|
|
26
21
|
when "put"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
mentry = container.manifest.resolver.resolve(target).entry
|
|
23
|
+
container.compositor.write(
|
|
24
|
+
target,
|
|
25
|
+
mentry: mentry,
|
|
26
|
+
payload: Textus::Store::Envelope::Writer::Payload.new(
|
|
27
|
+
meta: env.meta["_meta"] || {},
|
|
28
|
+
body: env.body,
|
|
29
|
+
content: nil,
|
|
30
|
+
),
|
|
31
|
+
call: call,
|
|
32
|
+
)
|
|
32
33
|
when "delete"
|
|
33
|
-
|
|
34
|
+
container.compositor.delete(target, call: call)
|
|
34
35
|
else
|
|
35
|
-
|
|
36
|
+
return Failure(code: :proposal_error, message: "unknown action: #{action}")
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
container.compositor.delete(pending_key, call: call)
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
Success("protocol" => Textus::PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action,
|
|
42
|
+
"cascade_key" => target)
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
end
|
data/lib/textus/action/audit.rb
CHANGED
|
@@ -6,8 +6,6 @@ require "time"
|
|
|
6
6
|
module Textus
|
|
7
7
|
module Action
|
|
8
8
|
class Audit < Base
|
|
9
|
-
extend Textus::Contract::DSL
|
|
10
|
-
|
|
11
9
|
verb :audit
|
|
12
10
|
summary "Query the audit log with optional filters."
|
|
13
11
|
surfaces :cli
|
|
@@ -24,42 +22,26 @@ module Textus
|
|
|
24
22
|
arg :limit, Integer, required: false, description: "maximum number of rows to return"
|
|
25
23
|
view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
|
|
26
24
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
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)
|
|
25
|
+
def self.call(container:, key: nil, lane: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil, **) # rubocop:disable Metrics/ParameterLists
|
|
26
|
+
audit_log = container.audit_log
|
|
27
|
+
manifest = container.manifest
|
|
44
28
|
|
|
45
|
-
|
|
46
|
-
return
|
|
29
|
+
cursor_check = check_cursor_expiry(seq_since, audit_log)
|
|
30
|
+
return cursor_check if cursor_check.is_a?(Dry::Monads::Result::Failure)
|
|
47
31
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
end
|
|
59
|
-
break if limit_reached?(rows, query)
|
|
60
|
-
end
|
|
32
|
+
Success(audit_log.scan(
|
|
33
|
+
seq_since: seq_since,
|
|
34
|
+
key: key,
|
|
35
|
+
role: role,
|
|
36
|
+
verb: verb,
|
|
37
|
+
correlation_id: correlation_id,
|
|
38
|
+
limit: limit,
|
|
39
|
+
).select do |row|
|
|
40
|
+
next false if lane && !key_in_lane?(row["key"], lane, manifest)
|
|
41
|
+
next false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
|
|
61
42
|
|
|
62
|
-
|
|
43
|
+
true
|
|
44
|
+
end)
|
|
63
45
|
end
|
|
64
46
|
|
|
65
47
|
def self.parse_since(str, now: Time.now.utc)
|
|
@@ -91,37 +73,18 @@ module Textus
|
|
|
91
73
|
end
|
|
92
74
|
end
|
|
93
75
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
|
|
97
|
-
|
|
98
|
-
def check_cursor_expiry!(seq_since)
|
|
76
|
+
def self.check_cursor_expiry(seq_since, audit_log)
|
|
99
77
|
return unless seq_since
|
|
100
78
|
|
|
101
|
-
|
|
102
|
-
min
|
|
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?("{")
|
|
79
|
+
min = audit_log.min_available_seq
|
|
80
|
+
return unless min && seq_since < min - 1
|
|
117
81
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
nil
|
|
82
|
+
Failure(code: :cursor_expired, message: "requested seq #{seq_since} is below minimum available #{min}",
|
|
83
|
+
details: { requested: seq_since, min_available: min })
|
|
121
84
|
end
|
|
122
85
|
|
|
123
|
-
def key_in_lane?(key, lane)
|
|
124
|
-
mentry =
|
|
86
|
+
def self.key_in_lane?(key, lane, manifest)
|
|
87
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
125
88
|
mentry && mentry.lane == lane
|
|
126
89
|
rescue Textus::Error
|
|
127
90
|
false
|
data/lib/textus/action/base.rb
CHANGED
|
@@ -20,22 +20,22 @@ module Textus
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
class Base
|
|
23
|
+
extend Contract::DSL
|
|
24
|
+
extend Dry::Monads[:result]
|
|
25
|
+
|
|
23
26
|
def self.inherited(subclass)
|
|
24
27
|
super
|
|
25
28
|
Textus::Action.register(subclass) if subclass.name
|
|
26
29
|
end
|
|
27
30
|
|
|
28
|
-
def call(**)
|
|
29
|
-
raise NotImplementedError.new("#{
|
|
31
|
+
def self.call(**)
|
|
32
|
+
raise NotImplementedError.new("#{name}.call")
|
|
30
33
|
end
|
|
31
34
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
val = instance_variable_get(:"@#{name}")
|
|
37
|
-
h[name] = val unless val.nil?
|
|
38
|
-
end
|
|
35
|
+
def self.proposal_from(env, key:)
|
|
36
|
+
proposal = env.meta&.dig("proposal") or return Failure(code: :proposal_error, message: "entry has no proposal block: #{key}")
|
|
37
|
+
target = proposal["target_key"] or return Failure(code: :proposal_error, message: "proposal missing target_key")
|
|
38
|
+
{ proposal:, target_key: target }
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
end
|
data/lib/textus/action/blame.rb
CHANGED
|
@@ -5,8 +5,6 @@ require "open3"
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Action
|
|
7
7
|
class Blame < Base
|
|
8
|
-
extend Textus::Contract::DSL
|
|
9
|
-
|
|
10
8
|
verb :blame
|
|
11
9
|
summary "Annotate audit rows for a key with the git commit that introduced each file state."
|
|
12
10
|
surfaces :cli
|
|
@@ -15,59 +13,43 @@ module Textus
|
|
|
15
13
|
arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
|
|
16
14
|
view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
|
|
17
15
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@limit = limit
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def call(container:, **)
|
|
25
|
-
@container = container
|
|
26
|
-
@manifest = container.manifest
|
|
27
|
-
@root = container.root
|
|
16
|
+
def self.call(container:, key:, limit: nil, **)
|
|
17
|
+
manifest = container.manifest
|
|
18
|
+
root = container.root
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
audit_result = Textus::Action::Audit.call(container: container, key: key, limit: limit)
|
|
21
|
+
audit_rows = Value::Result.unwrap(audit_result)
|
|
22
|
+
path = resolve_path(key, manifest: manifest)
|
|
23
|
+
return Success(audit_rows.map { |row| row.merge("git" => nil) }) unless git_tracked?(path, root: root)
|
|
32
24
|
|
|
33
|
-
audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"])) }
|
|
25
|
+
Success(audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"], root: root)) })
|
|
34
26
|
end
|
|
35
27
|
|
|
36
|
-
def self.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
|
|
40
|
-
mapped = positional.zip(args).to_h
|
|
41
|
-
super(**mapped.merge(kwargs))
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
def resolve_path(key)
|
|
47
|
-
res = @manifest.resolver.resolve(key)
|
|
28
|
+
def self.resolve_path(key, manifest:)
|
|
29
|
+
res = manifest.resolver.resolve(key)
|
|
48
30
|
mentry = res.entry
|
|
49
31
|
path = res.path
|
|
50
|
-
path || Textus::Key::Path.resolve(
|
|
32
|
+
path || Textus::Key::Path.resolve(manifest.data, mentry)
|
|
51
33
|
rescue Textus::Error
|
|
52
34
|
nil
|
|
53
35
|
end
|
|
54
36
|
|
|
55
|
-
def git_tracked?(path)
|
|
37
|
+
def self.git_tracked?(path, root:)
|
|
56
38
|
return false if path.nil?
|
|
57
39
|
return false unless File.exist?(path)
|
|
58
|
-
return false unless git_repo?
|
|
40
|
+
return false unless git_repo?(root)
|
|
59
41
|
|
|
60
42
|
_out, _err, status = Open3.capture3(
|
|
61
43
|
"git", "ls-files", "--error-unmatch", path,
|
|
62
|
-
chdir:
|
|
44
|
+
chdir: root
|
|
63
45
|
)
|
|
64
46
|
status.success?
|
|
65
47
|
rescue Errno::ENOENT
|
|
66
48
|
false
|
|
67
49
|
end
|
|
68
50
|
|
|
69
|
-
def git_repo?
|
|
70
|
-
dir =
|
|
51
|
+
def self.git_repo?(root)
|
|
52
|
+
dir = root
|
|
71
53
|
loop do
|
|
72
54
|
return true if File.directory?(File.join(dir, ".git"))
|
|
73
55
|
|
|
@@ -78,11 +60,11 @@ module Textus
|
|
|
78
60
|
end
|
|
79
61
|
end
|
|
80
62
|
|
|
81
|
-
def git_commit_at(path, timestamp:)
|
|
63
|
+
def self.git_commit_at(path, timestamp:, root:)
|
|
82
64
|
args = ["git", "log", "-1"]
|
|
83
65
|
args << "--before=#{timestamp}" if timestamp
|
|
84
66
|
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
85
|
-
out, _err, status = Open3.capture3(*args, chdir:
|
|
67
|
+
out, _err, status = Open3.capture3(*args, chdir: root)
|
|
86
68
|
return nil unless status.success?
|
|
87
69
|
|
|
88
70
|
sha, author, date, subject = out.strip.split("\t", 4)
|
data/lib/textus/action/boot.rb
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Action
|
|
5
5
|
class Boot < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
6
|
verb :boot
|
|
9
7
|
summary "Return the orientation contract: lanes, agent_quickstart, agent_protocol, and pre-computed artifacts."
|
|
10
8
|
surfaces :cli, :mcp
|
|
11
9
|
|
|
12
|
-
def call(container:, **)
|
|
13
|
-
Textus::Boot.build(container: container)
|
|
10
|
+
def self.call(container:, **)
|
|
11
|
+
Success(Textus::Boot.build(container: container))
|
|
14
12
|
end
|
|
15
13
|
end
|
|
16
14
|
end
|
|
@@ -5,8 +5,6 @@ require "yaml"
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Action
|
|
7
7
|
class DataMv < Base
|
|
8
|
-
extend Textus::Contract::DSL
|
|
9
|
-
|
|
10
8
|
verb :data_mv
|
|
11
9
|
summary "Rename a data lane — manifest + files. Refuses if destination exists."
|
|
12
10
|
surfaces :cli, :mcp
|
|
@@ -19,48 +17,39 @@ module Textus
|
|
|
19
17
|
"defaults to false, so omitting it applies the move immediately"
|
|
20
18
|
view { |v, _i| v.to_h }
|
|
21
19
|
|
|
22
|
-
def
|
|
23
|
-
super()
|
|
24
|
-
@from = from
|
|
25
|
-
@to = to
|
|
26
|
-
@dry_run = dry_run
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def call(container:, **)
|
|
20
|
+
def self.call(container:, call:, from:, to:, dry_run: false, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
30
21
|
manifest = container.manifest
|
|
31
|
-
|
|
22
|
+
geom = container.geometry
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
return Failure(code: :usage_error, message: "from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
25
|
+
return Failure(code: :usage_error, message: "data lane '#{from}' not declared") unless manifest.data.declared_lane_kinds.key?(from)
|
|
35
26
|
|
|
36
|
-
dest_dir =
|
|
37
|
-
|
|
27
|
+
dest_dir = geom.lane_path(to)
|
|
28
|
+
return Failure(code: :usage_error, message: "destination 'data/#{to}' already exists") if File.exist?(dest_dir)
|
|
38
29
|
|
|
39
|
-
affected_keys = manifest.data.entries.select { |entry| entry.lane ==
|
|
30
|
+
affected_keys = manifest.data.entries.select { |entry| entry.lane == from }.map(&:key)
|
|
40
31
|
|
|
41
|
-
steps = [{ "op" => "rename_zone", "from" =>
|
|
32
|
+
steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
|
|
42
33
|
steps += affected_keys.map do |key|
|
|
43
|
-
{ "op" => "mv", "from" => key, "to" => "#{
|
|
34
|
+
{ "op" => "mv", "from" => key, "to" => "#{to}#{key[from.length..]}" }
|
|
44
35
|
end
|
|
45
36
|
|
|
46
|
-
plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
|
|
47
|
-
return plan if
|
|
37
|
+
plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: [])
|
|
38
|
+
return Success(plan) if dry_run
|
|
48
39
|
|
|
49
|
-
rewrite_manifest!(
|
|
50
|
-
FileUtils.mv(
|
|
51
|
-
plan
|
|
40
|
+
rewrite_manifest!(geom, from:, to:)
|
|
41
|
+
FileUtils.mv(geom.lane_path(from), dest_dir)
|
|
42
|
+
Success(plan)
|
|
52
43
|
end
|
|
53
44
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def rewrite_manifest!(root)
|
|
57
|
-
path = File.join(root, "manifest.yaml")
|
|
45
|
+
def self.rewrite_manifest!(geom, from:, to:)
|
|
46
|
+
path = geom.manifest_path
|
|
58
47
|
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
|
|
59
|
-
raw["lanes"].each { |lane| lane["name"] =
|
|
48
|
+
raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
|
|
60
49
|
raw["entries"].each do |entry|
|
|
61
|
-
entry["lane"] =
|
|
62
|
-
entry["key"] = entry["key"].sub(/\A#{Regexp.escape(
|
|
63
|
-
entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(
|
|
50
|
+
entry["lane"] = to if entry["lane"] == from
|
|
51
|
+
entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
|
|
52
|
+
entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
|
|
64
53
|
end
|
|
65
54
|
File.write(path, YAML.dump(raw))
|
|
66
55
|
end
|