textus 0.55.2 → 0.56.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 +175 -2768
- data/README.md +8 -9
- data/lib/textus/artifact.rb +8 -0
- data/lib/textus/errors.rb +0 -39
- data/lib/textus/human.rb +7 -0
- data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
- data/lib/textus/infra/clock.rb +7 -0
- data/lib/textus/infra/database.rb +127 -0
- data/lib/textus/infra/file_system.rb +54 -0
- data/lib/textus/infra/locks.rb +50 -0
- data/lib/textus/infra/port/clock.rb +18 -0
- data/lib/textus/infra/port/publisher.rb +71 -0
- data/lib/textus/infra/port/sentinel.rb +25 -0
- data/lib/textus/infra/port/sentinel_store.rb +93 -0
- data/lib/textus/infra/store/audit_log.rb +42 -0
- data/lib/textus/infra/store/base.rb +25 -0
- data/lib/textus/infra/store/cursor_store.rb +27 -0
- data/lib/textus/infra/store/entry_index.rb +31 -0
- data/lib/textus/infra/store/envelope/meta.rb +63 -0
- data/lib/textus/infra/store/file_store.rb +23 -0
- data/lib/textus/infra/store/index/builder.rb +28 -0
- data/lib/textus/infra/store/index/lookup.rb +23 -0
- data/lib/textus/infra/store/link_store.rb +43 -0
- data/lib/textus/infra/store/workflow_queue.rb +54 -0
- data/lib/textus/infra/store.rb +29 -0
- data/lib/textus/ingest.rb +8 -0
- data/lib/textus/lanes/artifact/cache.rb +35 -0
- data/lib/textus/lanes/artifact/handlers.rb +29 -0
- data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
- data/lib/textus/lanes/artifact/verbs.rb +18 -0
- data/lib/textus/lanes/ingest/dedup.rb +17 -0
- data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
- data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
- data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
- data/lib/textus/lanes/ingest/entry_types.rb +25 -0
- data/lib/textus/lanes/ingest/handlers.rb +54 -0
- data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
- data/lib/textus/lanes/ingest/key_builder.rb +33 -0
- data/lib/textus/lanes/ingest/resolver.rb +21 -0
- data/lib/textus/lanes/knowledge/freeform.rb +15 -0
- data/lib/textus/lanes/knowledge/handlers.rb +53 -0
- data/lib/textus/lanes/knowledge/runner.rb +45 -0
- data/lib/textus/lanes/knowledge/verbs.rb +16 -0
- data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
- data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
- data/lib/textus/{dispatch → protocol}/binder.rb +1 -7
- data/lib/textus/protocol/boot/catalog.rb +34 -0
- data/lib/textus/protocol/boot/context.rb +124 -0
- data/lib/textus/protocol/boot/protocol.rb +33 -0
- data/lib/textus/protocol/boot.rb +42 -0
- data/lib/textus/protocol/builder/domain.rb +17 -0
- data/lib/textus/protocol/builder/infrastructure.rb +17 -0
- data/lib/textus/protocol/builder/runtime.rb +46 -0
- data/lib/textus/protocol/builder.rb +11 -0
- data/lib/textus/protocol/container.rb +22 -0
- data/lib/textus/protocol/diff.rb +170 -0
- data/lib/textus/protocol/entry_constraint.rb +54 -0
- data/lib/textus/protocol/envelope.rb +70 -0
- data/lib/textus/protocol/format/base.rb +80 -0
- data/lib/textus/protocol/format/bash.rb +10 -0
- data/lib/textus/protocol/format/javascript.rb +10 -0
- data/lib/textus/protocol/format/json.rb +82 -0
- data/lib/textus/protocol/format/markdown.rb +53 -0
- data/lib/textus/protocol/format/python.rb +10 -0
- data/lib/textus/protocol/format/ruby.rb +10 -0
- data/lib/textus/protocol/format/script.rb +55 -0
- data/lib/textus/protocol/format/text.rb +48 -0
- data/lib/textus/protocol/format/yaml.rb +105 -0
- data/lib/textus/protocol/format.rb +73 -0
- data/lib/textus/{store → protocol}/freshness/ttl_evaluator.rb +7 -3
- data/lib/textus/protocol/gate.rb +61 -0
- data/lib/textus/protocol/handlers/command_context.rb +19 -0
- data/lib/textus/protocol/handlers/concern.rb +15 -0
- data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
- data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
- data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
- data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
- data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
- data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
- data/lib/textus/protocol/handlers/query_context.rb +41 -0
- data/lib/textus/protocol/handlers/read.rb +67 -0
- data/lib/textus/protocol/handlers/system.rb +93 -0
- data/lib/textus/protocol/handlers/write.rb +57 -0
- data/lib/textus/protocol/init.rb +143 -0
- data/lib/textus/protocol/key/grammar.rb +35 -0
- data/lib/textus/protocol/key/matching.rb +23 -0
- data/lib/textus/protocol/key/path.rb +23 -0
- data/lib/textus/protocol/key.rb +25 -0
- data/lib/textus/protocol/lane.rb +12 -0
- data/lib/textus/{store → protocol}/layout.rb +16 -22
- data/lib/textus/protocol/links/resolver.rb +33 -0
- data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
- data/lib/textus/protocol/links.rb +10 -0
- data/lib/textus/protocol/manifest/data.rb +90 -0
- data/lib/textus/protocol/manifest/entry/base.rb +95 -0
- data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
- data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
- data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
- data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
- data/lib/textus/protocol/manifest/entry.rb +10 -0
- data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
- data/lib/textus/protocol/manifest/policy.rb +69 -0
- data/lib/textus/protocol/manifest/resolver.rb +156 -0
- data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
- data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
- data/lib/textus/protocol/manifest/schema.rb +40 -0
- data/lib/textus/protocol/manifest.rb +62 -0
- data/lib/textus/protocol/produce/render/context.rb +43 -0
- data/lib/textus/protocol/produce/render.rb +44 -0
- data/lib/textus/protocol/schema/registry.rb +44 -0
- data/lib/textus/protocol/schema/tools.rb +104 -0
- data/lib/textus/protocol/schema.rb +117 -0
- data/lib/textus/protocol/session/bridge.rb +11 -0
- data/lib/textus/protocol/session/context.rb +5 -0
- data/lib/textus/protocol/session.rb +87 -0
- data/lib/textus/protocol/store_engine/delete.rb +37 -0
- data/lib/textus/protocol/store_engine/move.rb +109 -0
- data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
- data/lib/textus/protocol/store_engine/put.rb +104 -0
- data/lib/textus/protocol/store_engine.rb +86 -0
- data/lib/textus/protocol/v4.rb +25 -0
- data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
- data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
- data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
- data/lib/textus/protocol/verb_registry.rb +42 -0
- data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
- data/lib/textus/surface/cli/runner.rb +22 -21
- data/lib/textus/surface/cli/sources.rb +1 -3
- data/lib/textus/surface/cli/verb/get.rb +2 -7
- data/lib/textus/surface/cli/verb/init.rb +1 -1
- data/lib/textus/surface/cli/verb/put.rb +7 -7
- data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
- data/lib/textus/surface/cli.rb +25 -6
- data/lib/textus/surface/dispatch.rb +17 -0
- data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
- data/lib/textus/surface/mcp/catalog.rb +12 -12
- data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
- data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
- data/lib/textus/surface/mcp/server.rb +35 -24
- data/lib/textus/surface/watcher.rb +18 -12
- data/lib/textus/surface/web.rb +50 -0
- data/lib/textus/value/call.rb +1 -1
- data/lib/textus/value/etag.rb +7 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/consumer.rb +39 -0
- data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
- data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
- data/lib/textus/workflow/dsl/step_builder.rb +56 -0
- data/lib/textus/workflow/dsl.rb +111 -21
- data/lib/textus/workflow/event_emitter.rb +21 -0
- data/lib/textus/workflow/publisher.rb +59 -0
- data/lib/textus/workflow/registry.rb +31 -8
- data/lib/textus/workflow/runner.rb +25 -53
- data/lib/textus/workflow/scheduler.rb +45 -0
- data/lib/textus/workflow/step_executor.rb +78 -0
- data/lib/textus/workflow/step_helpers.rb +102 -0
- data/lib/textus/workflow.rb +2 -5
- data/lib/textus.rb +16 -16
- metadata +169 -185
- data/SPEC.md +0 -909
- data/docs/architecture/README.md +0 -273
- data/docs/reference/conventions.md +0 -141
- data/lib/textus/boot.rb +0 -181
- data/lib/textus/dispatch/contracts.rb +0 -63
- data/lib/textus/dispatch/handler_registry.rb +0 -21
- data/lib/textus/dispatch/middleware/audit_index.rb +0 -51
- data/lib/textus/dispatch/middleware/auth.rb +0 -40
- data/lib/textus/dispatch/middleware/base.rb +0 -26
- data/lib/textus/dispatch/middleware/binder.rb +0 -20
- data/lib/textus/dispatch/middleware/cascade.rb +0 -53
- data/lib/textus/dispatch/pipeline.rb +0 -35
- data/lib/textus/doctor/check/audit_log.rb +0 -34
- data/lib/textus/doctor/check/generator_drift.rb +0 -29
- data/lib/textus/doctor/check/illegal_keys.rb +0 -60
- data/lib/textus/doctor/check/manifest_files.rb +0 -25
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
- data/lib/textus/doctor/check/proposal_targets.rb +0 -45
- data/lib/textus/doctor/check/protocol_version.rb +0 -47
- data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
- data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
- data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
- data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
- data/lib/textus/doctor/check/schema_violations.rb +0 -28
- data/lib/textus/doctor/check/schemas.rb +0 -26
- data/lib/textus/doctor/check/scratchpad_sources.rb +0 -55
- data/lib/textus/doctor/check/sentinels.rb +0 -60
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
- data/lib/textus/doctor/check/templates.rb +0 -29
- data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
- data/lib/textus/doctor/check.rb +0 -43
- data/lib/textus/doctor/validator.rb +0 -93
- data/lib/textus/doctor.rb +0 -54
- data/lib/textus/format/base.rb +0 -77
- data/lib/textus/format/json.rb +0 -81
- data/lib/textus/format/markdown.rb +0 -59
- data/lib/textus/format/text.rb +0 -55
- data/lib/textus/format/yaml.rb +0 -104
- data/lib/textus/format.rb +0 -61
- data/lib/textus/handlers/maintenance/boot_store.rb +0 -15
- data/lib/textus/handlers/maintenance/doctor_store.rb +0 -15
- data/lib/textus/handlers/maintenance/drain_store.rb +0 -21
- data/lib/textus/handlers/maintenance/ingest_entry.rb +0 -159
- data/lib/textus/handlers/maintenance/jobs_action.rb +0 -21
- data/lib/textus/handlers/maintenance/published_entries.rb +0 -17
- data/lib/textus/handlers/maintenance/rule_explain.rb +0 -77
- data/lib/textus/handlers/maintenance/rule_lint.rb +0 -54
- data/lib/textus/handlers/maintenance/rule_list.rb +0 -32
- data/lib/textus/handlers/maintenance/schema_envelope.rb +0 -19
- data/lib/textus/handlers/read/audit_entries.rb +0 -48
- data/lib/textus/handlers/read/blame_entry.rb +0 -71
- data/lib/textus/handlers/read/deps_entry.rb +0 -17
- data/lib/textus/handlers/read/get_entry.rb +0 -68
- data/lib/textus/handlers/read/list_keys.rb +0 -36
- data/lib/textus/handlers/read/pulse_entries.rb +0 -66
- data/lib/textus/handlers/read/rdeps_entry.rb +0 -21
- data/lib/textus/handlers/read/uid_entry.rb +0 -18
- data/lib/textus/handlers/read/where_entry.rb +0 -18
- data/lib/textus/handlers/write/accept_proposal.rb +0 -39
- data/lib/textus/handlers/write/data_mv.rb +0 -55
- data/lib/textus/handlers/write/delete_key.rb +0 -17
- data/lib/textus/handlers/write/enqueue_job.rb +0 -27
- data/lib/textus/handlers/write/key_delete_prefix.rb +0 -32
- data/lib/textus/handlers/write/key_mv_prefix.rb +0 -45
- data/lib/textus/handlers/write/move_key.rb +0 -80
- data/lib/textus/handlers/write/propose_entry.rb +0 -29
- data/lib/textus/handlers/write/put_entry.rb +0 -29
- data/lib/textus/handlers/write/reject_proposal.rb +0 -29
- data/lib/textus/init.rb +0 -157
- data/lib/textus/jobs.rb +0 -9
- data/lib/textus/key/distance.rb +0 -55
- data/lib/textus/key/grammar.rb +0 -33
- data/lib/textus/key/matching.rb +0 -24
- data/lib/textus/key/path.rb +0 -28
- data/lib/textus/manifest/capabilities.rb +0 -29
- data/lib/textus/manifest/data.rb +0 -102
- data/lib/textus/manifest/entry/base.rb +0 -105
- data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
- data/lib/textus/manifest/entry/leaf.rb +0 -17
- data/lib/textus/manifest/entry/nested.rb +0 -37
- data/lib/textus/manifest/entry/parser.rb +0 -99
- data/lib/textus/manifest/entry/produced.rb +0 -36
- data/lib/textus/manifest/entry/publish/mode.rb +0 -45
- data/lib/textus/manifest/entry/publish/none.rb +0 -14
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
- data/lib/textus/manifest/entry/publish/template.rb +0 -16
- data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
- data/lib/textus/manifest/entry/publish/tree.rb +0 -50
- data/lib/textus/manifest/entry/publish.rb +0 -45
- data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
- data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
- data/lib/textus/manifest/entry/validators/publish.rb +0 -30
- data/lib/textus/manifest/entry/validators.rb +0 -18
- data/lib/textus/manifest/entry.rb +0 -8
- data/lib/textus/manifest/policy/matcher.rb +0 -51
- data/lib/textus/manifest/policy/predicates/author_held.rb +0 -22
- data/lib/textus/manifest/policy/predicates/etag_match.rb +0 -18
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +0 -13
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +0 -31
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +0 -23
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +0 -25
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +0 -24
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +0 -41
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +0 -20
- data/lib/textus/manifest/policy/predicates.rb +0 -54
- data/lib/textus/manifest/policy/publish_target.rb +0 -34
- data/lib/textus/manifest/policy/react.rb +0 -30
- data/lib/textus/manifest/policy/retention.rb +0 -29
- data/lib/textus/manifest/policy/source.rb +0 -30
- data/lib/textus/manifest/policy.rb +0 -95
- data/lib/textus/manifest/resolver.rb +0 -118
- data/lib/textus/manifest/rules.rb +0 -86
- data/lib/textus/manifest/schema/contract.rb +0 -61
- data/lib/textus/manifest/schema/keys.rb +0 -90
- data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
- data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
- data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
- data/lib/textus/manifest/schema/semantics.rb +0 -27
- data/lib/textus/manifest/schema/validator.rb +0 -48
- data/lib/textus/manifest/schema/vocabulary.rb +0 -25
- data/lib/textus/manifest/schema.rb +0 -30
- data/lib/textus/manifest.rb +0 -65
- data/lib/textus/orchestration.rb +0 -55
- data/lib/textus/port/audit_log.rb +0 -268
- data/lib/textus/port/build_lock.rb +0 -64
- data/lib/textus/port/clock.rb +0 -10
- data/lib/textus/port/publisher.rb +0 -60
- data/lib/textus/port/sentinel.rb +0 -25
- data/lib/textus/port/sentinel_store.rb +0 -83
- data/lib/textus/port/storage/file_stat.rb +0 -19
- data/lib/textus/port/storage/file_store.rb +0 -49
- data/lib/textus/port/storage/interface.rb +0 -17
- data/lib/textus/port/store.rb +0 -149
- data/lib/textus/port/watcher_lock.rb +0 -48
- data/lib/textus/produce/engine.rb +0 -40
- data/lib/textus/produce/publisher.rb +0 -21
- data/lib/textus/produce/render.rb +0 -20
- data/lib/textus/schema/registry.rb +0 -42
- data/lib/textus/schema/tools.rb +0 -108
- data/lib/textus/schema.rb +0 -100
- data/lib/textus/store/container.rb +0 -173
- data/lib/textus/store/cursor.rb +0 -26
- data/lib/textus/store/entry/reader.rb +0 -47
- data/lib/textus/store/entry/writer.rb +0 -219
- data/lib/textus/store/envelope/meta.rb +0 -61
- data/lib/textus/store/freshness/drift_detector.rb +0 -93
- data/lib/textus/store/freshness/evaluator.rb +0 -20
- data/lib/textus/store/freshness/verdict.rb +0 -24
- data/lib/textus/store/freshness.rb +0 -8
- data/lib/textus/store/index/builder.rb +0 -76
- data/lib/textus/store/index/lookup.rb +0 -60
- data/lib/textus/store/jobs/base.rb +0 -13
- data/lib/textus/store/jobs/index.rb +0 -15
- data/lib/textus/store/jobs/materialize.rb +0 -15
- data/lib/textus/store/jobs/plan.rb +0 -11
- data/lib/textus/store/jobs/planner.rb +0 -124
- data/lib/textus/store/jobs/queue.rb +0 -162
- data/lib/textus/store/jobs/registry.rb +0 -19
- data/lib/textus/store/jobs/retention/base.rb +0 -52
- data/lib/textus/store/jobs/retention/sweep.rb +0 -55
- data/lib/textus/store/jobs/retention.rb +0 -8
- data/lib/textus/store/jobs/sweep.rb +0 -21
- data/lib/textus/store/jobs/worker.rb +0 -64
- data/lib/textus/store.rb +0 -122
- data/lib/textus/surface/cli/verb/doctor.rb +0 -20
- data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
- data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
- data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
- data/lib/textus/surface/mcp/projector.rb +0 -20
- data/lib/textus/surface/mcp.rb +0 -8
- data/lib/textus/value/command.rb +0 -16
- data/lib/textus/value/envelope.rb +0 -90
- data/lib/textus/value/result.rb +0 -46
- data/lib/textus/verb_registry.rb +0 -417
- data/lib/textus/workflow/collector.rb +0 -27
- data/lib/textus/workflow/context.rb +0 -5
- data/lib/textus/workflow/loader.rb +0 -17
- data/lib/textus/workflow/pattern.rb +0 -18
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- Generated from knowledge.readme
|
|
1
|
+
<!-- Generated from knowledge.intent.readme-* fragments by the artifacts.docs.readme workflow. Edit the fragments under .textus/data/knowledge/intent/ and run `textus drain`. -->
|
|
2
2
|
<!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
|
|
3
3
|
<p align="center">
|
|
4
4
|
<picture>
|
|
@@ -125,7 +125,7 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
|
|
|
125
125
|
|
|
126
126
|
This Ruby gem is the reference implementation of **`textus/4`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/lane gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/4`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
|
|
127
127
|
|
|
128
|
-
- Specification:
|
|
128
|
+
- Specification: the wire protocol spec (`textus get knowledge.specs.*`)
|
|
129
129
|
- Architecture: [`docs/architecture/README.md`](docs/architecture/README.md)
|
|
130
130
|
- Per-release notes: [`CHANGELOG.md`](CHANGELOG.md)
|
|
131
131
|
|
|
@@ -201,9 +201,9 @@ For a worked store — knowledge entries, a staged proposal, schemas, ERB templa
|
|
|
201
201
|
|
|
202
202
|
## What's shipped
|
|
203
203
|
|
|
204
|
-
- **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. (
|
|
204
|
+
- **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. (the wire protocol spec §5.2–5.3)
|
|
205
205
|
- **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
|
|
206
|
-
- **Capability × lane-kind gate.** Writes carry `--as=<role>`; a role may write a lane iff it holds the capability the lane's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. (
|
|
206
|
+
- **Capability × lane-kind gate.** Writes carry `--as=<role>`; a role may write a lane iff it holds the capability the lane's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. (the wire protocol spec §5)
|
|
207
207
|
- **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, pending proposals, index etag for catalog drift detection). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
|
|
208
208
|
- **MCP surface.** The official `mcp` Ruby SDK drives the stdio JSON-RPC server; protocol version auto-negotiated up to `2025-11-25`. Wire textus into Claude Code, Cursor, or any MCP host in one config block.
|
|
209
209
|
- **`textus doctor`.** Health checks across schemas, workflow registrations, keys, sentinels, and the audit log.
|
|
@@ -211,10 +211,10 @@ For a worked store — knowledge entries, a staged proposal, schemas, ERB templa
|
|
|
211
211
|
|
|
212
212
|
## CLI and lanes
|
|
213
213
|
|
|
214
|
-
Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` (
|
|
214
|
+
Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` (the wire protocol spec §3.1). Write verbs require `--as=<role>`, resolved as: `--as` flag → **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` (the wire protocol spec §5.1). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in the wire protocol spec §8.
|
|
215
215
|
|
|
216
|
-
- Full verb table — read, write, health, scaffolding — is in
|
|
217
|
-
- Lane semantics and the capability × lane-kind mapping live in
|
|
216
|
+
- Full verb table — read, write, health, scaffolding — is in the wire protocol spec §9.
|
|
217
|
+
- Lane semantics and the capability × lane-kind mapping live in the wire protocol spec §5, with the reference in [`docs/reference/lanes.md`](docs/reference/lanes.md).
|
|
218
218
|
|
|
219
219
|
`textus boot` prints the same information for the current store: lanes, entry families with schemas, registered workflows, 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.
|
|
220
220
|
|
|
@@ -225,7 +225,7 @@ Produced entries (`kind: produced`) declare how they're acquired in one `source:
|
|
|
225
225
|
- **`source: { from: external, command: "...", sources: [...] }`** — *externally managed*: an out-of-band command or workflow writes the file; textus tracks staleness via declared `sources`.
|
|
226
226
|
- **`source: { from: external, command: "true", sources: [] }` + a workflow** — *workflow-driven*: a `Textus.workflow` block (in `.textus/workflows/`) acquires and shapes the data on `drain`.
|
|
227
227
|
|
|
228
|
-
Publishing is one typed `publish:` block (ADR 0052/0094). Each target is either `{ to: path, template?: name }` for a single file (optionally rendered through an ERB template) or `{ tree: "dir" }` to mirror a whole stored subtree. Sentinels for every published file live under `.textus
|
|
228
|
+
Publishing is one typed `publish:` block (ADR 0052/0094). Each target is either `{ to: path, template?: name }` for a single file (optionally rendered through an ERB template) or `{ tree: "dir" }` to mirror a whole stored subtree. Sentinels for every published file live under `.textus/track/sentinels/` (git-ignored, regenerated on drain). See SPEC §5.2, §5.3, §5.12.
|
|
229
229
|
|
|
230
230
|
Templates live in `.textus/templates/` as ERB files (`.erb`). The template receives the entry's `content` hash as local variables via `ERB#result_with_hash`. If `inject_boot: true`, a `boot` variable is also available with the live orientation context.
|
|
231
231
|
|
|
@@ -322,4 +322,3 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
|
|
|
322
322
|
|
|
323
323
|
[MIT](LICENSE)
|
|
324
324
|
|
|
325
|
-
|
data/lib/textus/errors.rb
CHANGED
|
@@ -131,18 +131,6 @@ module Textus
|
|
|
131
131
|
def initialize(m) = super("io_error", m, exit_code: 64)
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
-
class BuildInProgress < Error
|
|
135
|
-
def initialize(holder)
|
|
136
|
-
super(
|
|
137
|
-
"build_in_progress",
|
|
138
|
-
"a textus maintenance pass is already running (#{holder})",
|
|
139
|
-
details: { "holder" => holder },
|
|
140
|
-
exit_code: 75,
|
|
141
|
-
hint: "wait for the running maintenance pass to finish, or check for a recursive hook trigger"
|
|
142
|
-
)
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
|
|
146
134
|
class UsageError < Error
|
|
147
135
|
def initialize(m, hint: nil) = super("usage", m, exit_code: 2, hint: hint)
|
|
148
136
|
end
|
|
@@ -166,18 +154,6 @@ module Textus
|
|
|
166
154
|
end
|
|
167
155
|
end
|
|
168
156
|
|
|
169
|
-
class BadRender < Error
|
|
170
|
-
def initialize(m, format: nil)
|
|
171
|
-
hint =
|
|
172
|
-
if format
|
|
173
|
-
"the template rendered invalid #{format}; try rendering with mock data and parsing the output before re-running drain"
|
|
174
|
-
else
|
|
175
|
-
"the template rendered invalid content; try rendering with mock data and parsing the output before re-running drain"
|
|
176
|
-
end
|
|
177
|
-
super("bad_render", m, hint: hint)
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
157
|
class PublishError < Error
|
|
182
158
|
def initialize(m, target: nil)
|
|
183
159
|
hint =
|
|
@@ -190,21 +166,6 @@ module Textus
|
|
|
190
166
|
def initialize(m) = super("proposal_error", m)
|
|
191
167
|
end
|
|
192
168
|
|
|
193
|
-
class GuardFailed < Error
|
|
194
|
-
def initialize(failed)
|
|
195
|
-
# failed: [[predicate_name, reason], ...]
|
|
196
|
-
rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
|
|
197
|
-
names = failed.map(&:first)
|
|
198
|
-
super(
|
|
199
|
-
"guard_failed",
|
|
200
|
-
"guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
|
|
201
|
-
details: { "failed" => rows },
|
|
202
|
-
hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
|
|
203
|
-
"unmet: #{names.join(", ")}",
|
|
204
|
-
)
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
169
|
class ContractDrift < Error
|
|
209
170
|
JSONRPC_CODE = -32_001
|
|
210
171
|
|
data/lib/textus/human.rb
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "sequel"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Infra
|
|
6
|
+
class Database
|
|
7
|
+
def initialize(layout:)
|
|
8
|
+
@layout = layout
|
|
9
|
+
@db = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def connection = @db
|
|
13
|
+
|
|
14
|
+
def setup!
|
|
15
|
+
path = @layout.db_path
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
17
|
+
@db = Sequel.sqlite(path)
|
|
18
|
+
@db.run("PRAGMA journal_mode=WAL")
|
|
19
|
+
@db.run("PRAGMA foreign_keys=ON")
|
|
20
|
+
create_tables
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def transaction(&) = @db.transaction(&)
|
|
25
|
+
def close = @db.disconnect
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def create_tables
|
|
30
|
+
@db.create_table?(:workflow_events) do
|
|
31
|
+
primary_key :id
|
|
32
|
+
String :event_type, null: false
|
|
33
|
+
String :workflow_type, null: false
|
|
34
|
+
String :key
|
|
35
|
+
String :lane
|
|
36
|
+
String :payload, text: true
|
|
37
|
+
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
|
|
38
|
+
DateTime :scheduled_at
|
|
39
|
+
String :state, null: false, default: "pending"
|
|
40
|
+
Integer :retry_count, null: false, default: 0
|
|
41
|
+
Integer :max_attempts, null: false, default: 3
|
|
42
|
+
String :error, text: true
|
|
43
|
+
index %i[state scheduled_at], where: "state = 'pending'"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@db.create_table?(:entries) do
|
|
47
|
+
String :key, primary_key: true
|
|
48
|
+
String :lane, null: false
|
|
49
|
+
String :format, null: false
|
|
50
|
+
String :schema
|
|
51
|
+
String :content, text: true
|
|
52
|
+
DateTime :indexed_at, null: false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@db.create_table?(:link_edges) do
|
|
56
|
+
String :from_key, null: false
|
|
57
|
+
String :to_key, null: false
|
|
58
|
+
primary_key %i[from_key to_key]
|
|
59
|
+
index :to_key
|
|
60
|
+
index :from_key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@db.create_table?(:audit_events) do
|
|
64
|
+
primary_key :seq
|
|
65
|
+
String :ts, null: false
|
|
66
|
+
String :role, null: false
|
|
67
|
+
String :verb, null: false
|
|
68
|
+
String :key, null: false
|
|
69
|
+
String :etag_before
|
|
70
|
+
String :etag_after
|
|
71
|
+
String :correlation_id
|
|
72
|
+
index :key
|
|
73
|
+
index %i[role verb]
|
|
74
|
+
index :correlation_id
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
@db.create_table?(:cursors) do
|
|
78
|
+
String :role, primary_key: true
|
|
79
|
+
Integer :seq, null: false, default: 0
|
|
80
|
+
DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@db.create_table?(:loop_positions) do
|
|
84
|
+
String :role, primary_key: true
|
|
85
|
+
Integer :index, null: false, default: 0
|
|
86
|
+
DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
create_entries_fts
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def create_entries_fts
|
|
93
|
+
@db.run(<<~SQL)
|
|
94
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
|
|
95
|
+
key, lane, schema, content,
|
|
96
|
+
content=entries, content_rowid=rowid
|
|
97
|
+
)
|
|
98
|
+
SQL
|
|
99
|
+
|
|
100
|
+
@db.run(<<~SQL)
|
|
101
|
+
CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
|
|
102
|
+
INSERT INTO entries_fts(rowid, key, lane, schema, content)
|
|
103
|
+
VALUES (new.rowid, new.key, new.lane, new.schema, new.content);
|
|
104
|
+
END
|
|
105
|
+
SQL
|
|
106
|
+
|
|
107
|
+
@db.run(<<~SQL)
|
|
108
|
+
CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
|
|
109
|
+
INSERT INTO entries_fts(entries_fts, rowid, key, lane, schema, content)
|
|
110
|
+
VALUES ('delete', old.rowid, old.key, old.lane, old.schema, old.content);
|
|
111
|
+
END
|
|
112
|
+
SQL
|
|
113
|
+
|
|
114
|
+
@db.run(<<~SQL)
|
|
115
|
+
CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
|
|
116
|
+
INSERT INTO entries_fts(entries_fts, rowid, key, lane, schema, content)
|
|
117
|
+
VALUES ('delete', old.rowid, old.key, old.lane, old.schema, old.content);
|
|
118
|
+
INSERT INTO entries_fts(rowid, key, lane, schema, content)
|
|
119
|
+
VALUES (new.rowid, new.key, new.lane, new.schema, new.content);
|
|
120
|
+
END
|
|
121
|
+
SQL
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Infra
|
|
5
|
+
class FileSystem
|
|
6
|
+
module Interface
|
|
7
|
+
def read(path) = raise NotImplementedError
|
|
8
|
+
def write(path, bytes) = raise NotImplementedError
|
|
9
|
+
def delete(path) = raise NotImplementedError
|
|
10
|
+
def exists?(path) = raise NotImplementedError
|
|
11
|
+
def mkdir_p(path) = raise NotImplementedError
|
|
12
|
+
def mv(from, to) = raise NotImplementedError
|
|
13
|
+
def copy(source, target) = raise NotImplementedError
|
|
14
|
+
def glob(pattern) = raise NotImplementedError
|
|
15
|
+
def etag(path) = raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
include Interface
|
|
19
|
+
|
|
20
|
+
def read(path) = File.binread(path)
|
|
21
|
+
|
|
22
|
+
def write(path, bytes)
|
|
23
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
24
|
+
File.binwrite(path, bytes)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def delete(path) = File.delete(path)
|
|
28
|
+
def exists?(path) = File.exist?(path)
|
|
29
|
+
def size(path) = File.size(path)
|
|
30
|
+
def mkdir_p(path) = FileUtils.mkdir_p(path)
|
|
31
|
+
def rmdir(path) = Dir.rmdir(path)
|
|
32
|
+
def dir_empty?(dir) = Dir.empty?(dir)
|
|
33
|
+
def glob(pattern) = Dir.glob(pattern)
|
|
34
|
+
def mv(from, to) = File.rename(from, to)
|
|
35
|
+
def copy(source, target) = FileUtils.cp(source, target)
|
|
36
|
+
def symlink?(path) = File.symlink?(path)
|
|
37
|
+
def identical?(left, right) = FileUtils.identical?(left, right)
|
|
38
|
+
def mtime(path) = File.mtime(path)
|
|
39
|
+
def directory?(path) = File.directory?(path)
|
|
40
|
+
|
|
41
|
+
def etag(path)
|
|
42
|
+
Value::Etag.for_file(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def flock_exclusive(path, nonblock: false)
|
|
46
|
+
File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
|
|
47
|
+
f.close_on_exec = true
|
|
48
|
+
f.flock(nonblock ? File::LOCK_EX | File::LOCK_NB : File::LOCK_EX)
|
|
49
|
+
yield f
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "socket"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Infra
|
|
7
|
+
module Locks
|
|
8
|
+
class WatcherLock
|
|
9
|
+
def initialize(root, file_system: Infra::FileSystem.new)
|
|
10
|
+
@root = File.expand_path(root)
|
|
11
|
+
@file_system = file_system
|
|
12
|
+
@path = Textus::Protocol::Layout.new(@root).lock_path("watcher")
|
|
13
|
+
@file = nil
|
|
14
|
+
@file_system.mkdir_p(File.dirname(@path))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.running?(root, file_system: Infra::FileSystem.new)
|
|
18
|
+
path = Textus::Protocol::Layout.new(root).lock_path("watcher")
|
|
19
|
+
return false unless file_system.exists?(path)
|
|
20
|
+
|
|
21
|
+
File.open(path, "r+") do |file|
|
|
22
|
+
got = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
23
|
+
file.flock(File::LOCK_UN) if got
|
|
24
|
+
return !got
|
|
25
|
+
end
|
|
26
|
+
rescue Errno::ENOENT
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def acquire
|
|
31
|
+
@file = File.open(@path, File::RDWR | File::CREAT, 0o644)
|
|
32
|
+
raise "watcher already running" unless @file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
33
|
+
|
|
34
|
+
@file.write("pid=#{Process.pid} host=#{Socket.gethostname}\n")
|
|
35
|
+
@file.flush
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def release
|
|
40
|
+
return unless @file
|
|
41
|
+
|
|
42
|
+
@file.flock(File::LOCK_UN)
|
|
43
|
+
@file.close
|
|
44
|
+
@file_system.delete(@path) if @file_system.exists?(@path)
|
|
45
|
+
@file = nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
module Port
|
|
4
|
+
# The wall clock. An instantiable class (ADR 0109) — uniform with the other
|
|
5
|
+
# ports; `now` reads the system time. Callers that need a fixed time still
|
|
6
|
+
# pass it as data via `Call#now`.
|
|
7
|
+
class Clock
|
|
8
|
+
module Interface
|
|
9
|
+
def now = raise NotImplementedError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
include Interface
|
|
13
|
+
|
|
14
|
+
def now = Time.now
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
module Port
|
|
4
|
+
# Publishes built artifacts from the store to repo-relative consumer paths.
|
|
5
|
+
# Publish = copy + sentinel. The in-store file is already the consumer-shaped
|
|
6
|
+
# artifact; no parsing or stripping.
|
|
7
|
+
#
|
|
8
|
+
# Sentinel I/O is delegated to Textus::Infra::Port::SentinelStore. Sentinels live
|
|
9
|
+
# under `<store_root>/track/sentinels/` (runtime, git-ignored — ADR 0070) and
|
|
10
|
+
# mirror the target's repo-relative layout so consumer directories aren't
|
|
11
|
+
# polluted with `.textus-managed.json` siblings.
|
|
12
|
+
#
|
|
13
|
+
# An instantiable class (ADR 0109).
|
|
14
|
+
class Publisher
|
|
15
|
+
module Interface
|
|
16
|
+
def publish(source:, target:, store_root:, provenance_source: nil) = raise NotImplementedError
|
|
17
|
+
def unpublish(target:, store_root:) = raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
include Interface
|
|
20
|
+
|
|
21
|
+
def initialize(file_system: Infra::FileSystem.new)
|
|
22
|
+
@file_system = file_system
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def publish(source:, target:, store_root:, provenance_source: source)
|
|
26
|
+
@file_system.mkdir_p(File.dirname(target))
|
|
27
|
+
guard_clobber(source, target, store_root)
|
|
28
|
+
@file_system.delete(target) if @file_system.symlink?(target)
|
|
29
|
+
@file_system.copy(source, target)
|
|
30
|
+
Textus::Infra::Port::SentinelStore.new(file_system: @file_system).write!(target: target, source: provenance_source,
|
|
31
|
+
store_root: store_root)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Removes a previously-published file and its sentinel. No-op unless the
|
|
35
|
+
# target is textus-managed — never deletes an unmanaged file.
|
|
36
|
+
def unpublish(target:, store_root:)
|
|
37
|
+
return unless managed?(target, store_root)
|
|
38
|
+
|
|
39
|
+
@file_system.delete(target)
|
|
40
|
+
sentinel = Textus::Infra::Port::SentinelStore.new(file_system: @file_system).sentinel_path(target, store_root)
|
|
41
|
+
@file_system.delete(sentinel)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
|
|
47
|
+
# already equal the source (ADR 0050: a migration copies files into the
|
|
48
|
+
# store and publishes them back to where they already live, so the target
|
|
49
|
+
# is byte-identical — nothing is at risk). Adoption writes no sentinel
|
|
50
|
+
# here; the normal publish path below does, and the cp is a content no-op.
|
|
51
|
+
# An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
|
|
52
|
+
# still refused — that is the guard's real job.
|
|
53
|
+
def guard_clobber(source, target, store_root)
|
|
54
|
+
return unless @file_system.exists?(target) || @file_system.symlink?(target)
|
|
55
|
+
return if managed?(target, store_root)
|
|
56
|
+
return if adoptable?(source, target)
|
|
57
|
+
|
|
58
|
+
raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def adoptable?(source, target)
|
|
62
|
+
!@file_system.symlink?(target) && File.file?(target) && @file_system.identical?(source, target)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def managed?(target, store_root)
|
|
66
|
+
@file_system.exists?(Textus::Infra::Port::SentinelStore.new(file_system: @file_system).sentinel_path(target, store_root))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
module Port
|
|
4
|
+
class Sentinel
|
|
5
|
+
attr_reader :target, :source, :sha256, :mode
|
|
6
|
+
|
|
7
|
+
def initialize(target:, source:, sha256:, mode:)
|
|
8
|
+
@target = target
|
|
9
|
+
@source = source
|
|
10
|
+
@sha256 = sha256
|
|
11
|
+
@mode = mode
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def orphan?(file_stat) = @target.nil? || !file_stat.exists?(@target)
|
|
15
|
+
|
|
16
|
+
def drift?(file_stat)
|
|
17
|
+
return false if orphan?(file_stat)
|
|
18
|
+
return false if @sha256.nil?
|
|
19
|
+
|
|
20
|
+
Digest::SHA256.hexdigest(file_stat.read(@target)) != @sha256
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
module Port
|
|
4
|
+
# Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
|
|
5
|
+
# path layout (<store_root>/track/sentinels/<target-rel-to-repo>.textus-managed.json
|
|
6
|
+
# — runtime, git-ignored, ADR 0070), and all File/FileUtils I/O.
|
|
7
|
+
# Core::Sentinel is a pure value object that depends on this port for
|
|
8
|
+
# reads and writes.
|
|
9
|
+
class SentinelStore
|
|
10
|
+
module Interface
|
|
11
|
+
def write!(target:, source:, store_root:) = raise NotImplementedError
|
|
12
|
+
def load(path, repo_root) = raise NotImplementedError
|
|
13
|
+
def sentinel_path(target, store_root) = raise NotImplementedError
|
|
14
|
+
def targets_under(target_dir, store_root) = raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
include Interface
|
|
17
|
+
|
|
18
|
+
SUFFIX = ".textus-managed.json".freeze
|
|
19
|
+
|
|
20
|
+
def initialize(file_system: Infra::FileSystem.new)
|
|
21
|
+
@file_system = file_system
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write!(target:, source:, store_root:)
|
|
25
|
+
path = sentinel_path(target, store_root)
|
|
26
|
+
@file_system.mkdir_p(File.dirname(path))
|
|
27
|
+
repo_root = File.dirname(store_root)
|
|
28
|
+
@file_system.write(path, JSON.generate(
|
|
29
|
+
"source" => rel_or_abs(source, repo_root),
|
|
30
|
+
"target" => rel_or_abs(target, repo_root),
|
|
31
|
+
"sha256" => Digest::SHA256.hexdigest(@file_system.read(target)),
|
|
32
|
+
"mode" => "copy",
|
|
33
|
+
))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load(path, repo_root)
|
|
37
|
+
raw = JSON.parse(@file_system.read(path))
|
|
38
|
+
Textus::Infra::Port::Sentinel.new(
|
|
39
|
+
target: absolutize(raw["target"], repo_root),
|
|
40
|
+
source: absolutize(raw["source"], repo_root),
|
|
41
|
+
sha256: raw["sha256"],
|
|
42
|
+
mode: raw["mode"],
|
|
43
|
+
)
|
|
44
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sentinel_path(target, store_root)
|
|
49
|
+
repo_root = File.dirname(store_root)
|
|
50
|
+
rel = relative_to(target, repo_root) || File.basename(target)
|
|
51
|
+
File.join(Textus::Protocol::Layout.new(store_root).sentinels_root, rel + SUFFIX)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Absolute target paths of every sentinel recorded under `target_dir`.
|
|
55
|
+
def targets_under(target_dir, store_root)
|
|
56
|
+
repo_root = File.dirname(store_root)
|
|
57
|
+
rel = relative_to(target_dir, repo_root) or return []
|
|
58
|
+
root = Textus::Protocol::Layout.new(store_root).sentinels_root
|
|
59
|
+
sdir = File.join(root, rel)
|
|
60
|
+
return [] unless @file_system.directory?(sdir)
|
|
61
|
+
|
|
62
|
+
prefix = root + "/"
|
|
63
|
+
@file_system.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
|
|
64
|
+
# strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
|
|
65
|
+
trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
|
|
66
|
+
File.join(repo_root, trel)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def rel_or_abs(path, repo_root)
|
|
73
|
+
relative_to(path, repo_root) || File.expand_path(path)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def relative_to(path, repo_root)
|
|
77
|
+
path = File.expand_path(path)
|
|
78
|
+
base = File.expand_path(repo_root)
|
|
79
|
+
return nil unless path.start_with?(base + File::SEPARATOR)
|
|
80
|
+
|
|
81
|
+
path[(base.length + 1)..]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def absolutize(path, repo_root)
|
|
85
|
+
return path if path.nil?
|
|
86
|
+
return path if File.absolute_path?(path)
|
|
87
|
+
|
|
88
|
+
File.expand_path(path, repo_root)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
class Store
|
|
4
|
+
class AuditLog < Base
|
|
5
|
+
def append(role:, verb:, key:, etag_before: nil, etag_after: nil, correlation_id: nil)
|
|
6
|
+
table(:audit_events).insert(
|
|
7
|
+
ts: Time.now.utc.iso8601,
|
|
8
|
+
role:, verb:, key:,
|
|
9
|
+
etag_before:, etag_after:, correlation_id:
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def list(seq_since: nil, key: nil, role: nil, verb: nil, correlation_id: nil, limit: nil)
|
|
14
|
+
dataset = table(:audit_events)
|
|
15
|
+
dataset = dataset.where { seq > seq_since } if seq_since
|
|
16
|
+
dataset = dataset.where(key:) if key
|
|
17
|
+
dataset = dataset.where(role:) if role
|
|
18
|
+
dataset = dataset.where(verb:) if verb
|
|
19
|
+
dataset = dataset.where(correlation_id:) if correlation_id
|
|
20
|
+
dataset = dataset.limit(limit) if limit
|
|
21
|
+
results(dataset.order(:seq).all)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def latest
|
|
25
|
+
result(table(:audit_events).order(Sequel.desc(:seq)).first) || { seq: 0 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def min_available
|
|
29
|
+
result(table(:audit_events).order(:seq).first)&.dig(:seq)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def last_writer(key)
|
|
33
|
+
row = table(:audit_events)
|
|
34
|
+
.where(key:, verb: %w[put delete key_delete])
|
|
35
|
+
.order(Sequel.desc(:seq))
|
|
36
|
+
.first
|
|
37
|
+
result(row)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Infra
|
|
3
|
+
class Store
|
|
4
|
+
class Base
|
|
5
|
+
def initialize(database)
|
|
6
|
+
@db = database
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def table(name)
|
|
12
|
+
@db[name]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def result(row)
|
|
16
|
+
row&.transform_keys(&:to_sym)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def results(rows)
|
|
20
|
+
rows.map { |r| result(r) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|