textus 0.55.1 → 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 +17 -18
- data/lib/textus/artifact.rb +8 -0
- data/lib/textus/errors.rb +2 -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/{gate → protocol}/binder.rb +2 -10
- 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/protocol/freshness/ttl_evaluator.rb +61 -0
- 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/geometry.rb → protocol/layout.rb} +32 -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 +25 -25
- data/lib/textus/surface/cli/sources.rb +1 -3
- data/lib/textus/surface/cli/verb/get.rb +3 -6
- data/lib/textus/surface/cli/verb/init.rb +1 -1
- data/lib/textus/surface/cli/verb/put.rb +8 -6
- 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 +26 -62
- 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/errors.rb +0 -10
- data/lib/textus/surface/mcp/server.rb +46 -46
- 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/{core → value}/duration.rb +1 -4
- data/lib/textus/value/etag.rb +7 -7
- data/lib/textus/value/payload.rb +7 -0
- 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 -61
- 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 -80
- metadata +167 -183
- data/SPEC.md +0 -908
- data/docs/architecture/README.md +0 -273
- data/docs/reference/conventions.md +0 -138
- data/lib/textus/action/accept.rb +0 -46
- data/lib/textus/action/audit.rb +0 -94
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -79
- data/lib/textus/action/boot.rb +0 -15
- data/lib/textus/action/data_mv.rb +0 -58
- data/lib/textus/action/deps.rb +0 -19
- data/lib/textus/action/doctor.rb +0 -17
- data/lib/textus/action/drain.rb +0 -31
- data/lib/textus/action/enqueue.rb +0 -37
- data/lib/textus/action/get.rb +0 -34
- data/lib/textus/action/ingest.rb +0 -199
- data/lib/textus/action/jobs.rb +0 -27
- data/lib/textus/action/key_delete.rb +0 -26
- data/lib/textus/action/key_delete_prefix.rb +0 -35
- data/lib/textus/action/key_mv.rb +0 -122
- data/lib/textus/action/key_mv_prefix.rb +0 -48
- data/lib/textus/action/list.rb +0 -28
- data/lib/textus/action/propose.rb +0 -42
- data/lib/textus/action/published.rb +0 -22
- data/lib/textus/action/pulse.rb +0 -49
- data/lib/textus/action/put.rb +0 -38
- data/lib/textus/action/rdeps.rb +0 -24
- data/lib/textus/action/reject.rb +0 -28
- data/lib/textus/action/rule_explain.rb +0 -81
- data/lib/textus/action/rule_lint.rb +0 -62
- data/lib/textus/action/rule_list.rb +0 -38
- data/lib/textus/action/schema_envelope.rb +0 -22
- data/lib/textus/action/uid.rb +0 -19
- data/lib/textus/action/where.rb +0 -21
- data/lib/textus/boot.rb +0 -202
- data/lib/textus/contract/arg.rb +0 -10
- data/lib/textus/contract/dsl.rb +0 -88
- data/lib/textus/contract/spec.rb +0 -25
- data/lib/textus/contract.rb +0 -12
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness/verdict.rb +0 -34
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- data/lib/textus/core/sentinel.rb +0 -30
- 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/notebook_sources.rb +0 -50
- 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/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 -41
- data/lib/textus/doctor/validator.rb +0 -93
- data/lib/textus/doctor.rb +0 -54
- data/lib/textus/format/base.rb +0 -49
- data/lib/textus/format/json.rb +0 -102
- data/lib/textus/format/markdown.rb +0 -80
- data/lib/textus/format/shared.rb +0 -17
- data/lib/textus/format/text.rb +0 -55
- data/lib/textus/format/yaml.rb +0 -125
- data/lib/textus/format.rb +0 -46
- data/lib/textus/gate/auth.rb +0 -212
- data/lib/textus/gate.rb +0 -92
- 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/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/meta.rb +0 -54
- 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_store.rb +0 -83
- data/lib/textus/port/storage/file_stat.rb +0 -19
- data/lib/textus/port/storage/file_store.rb +0 -26
- data/lib/textus/port/store.rb +0 -93
- data/lib/textus/port/watcher_lock.rb +0 -48
- data/lib/textus/produce/engine.rb +0 -50
- data/lib/textus/produce/render.rb +0 -20
- data/lib/textus/schema/tools.rb +0 -115
- data/lib/textus/schema.rb +0 -100
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/store/compositor.rb +0 -34
- data/lib/textus/store/container.rb +0 -43
- data/lib/textus/store/cursor.rb +0 -26
- data/lib/textus/store/envelope/reader.rb +0 -43
- data/lib/textus/store/envelope/writer.rb +0 -195
- data/lib/textus/store/index/builder.rb +0 -74
- 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 -104
- data/lib/textus/store/jobs/queue.rb +0 -154
- data/lib/textus/store/jobs/registry.rb +0 -19
- data/lib/textus/store/jobs/retention.rb +0 -50
- data/lib/textus/store/jobs/sweep.rb +0 -21
- data/lib/textus/store/jobs/worker.rb +0 -64
- data/lib/textus/store/session.rb +0 -37
- data/lib/textus/store.rb +0 -99
- data/lib/textus/surface/cli/verb/doctor.rb +0 -19
- 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.rb +0 -8
- data/lib/textus/surface/projector.rb +0 -27
- data/lib/textus/surface/role_scope.rb +0 -34
- data/lib/textus/value/command.rb +0 -16
- data/lib/textus/value/envelope.rb +0 -89
- data/lib/textus/value/result.rb +0 -26
- 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>
|
|
@@ -39,7 +39,7 @@ flowchart LR
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
human -->|author| knowledge["knowledge<br/>(canon)"]
|
|
42
|
-
agent -->|keep|
|
|
42
|
+
agent -->|keep| scratchpad["scratchpad<br/>(workspace)"]
|
|
43
43
|
agent -->|propose| proposals["proposals<br/>(queue)"]
|
|
44
44
|
automation -->|drain| artifacts["artifacts<br/>(machine)"]
|
|
45
45
|
human -->|ingest| raw["raw<br/>(intake)"]
|
|
@@ -68,7 +68,7 @@ The point of those lanes is to **build context you can trust**. Place each lane
|
|
|
68
68
|
LOW TRUST HIGH TRUST
|
|
69
69
|
(unreviewed) (authoritative)
|
|
70
70
|
┌──────────────────────────┬───────────────────────────────┐
|
|
71
|
-
DURABLE │
|
|
71
|
+
DURABLE │ scratchpad │ knowledge ★ the goal │
|
|
72
72
|
(kept) │ agent's working truth │ canon — a human authors │
|
|
73
73
|
│ durable, but low-trust │ here · the context you ship │
|
|
74
74
|
├──────────────────────────┼───────────────────────────────┤
|
|
@@ -84,12 +84,12 @@ Without coordination, they overwrite each other and nothing remembers why. textu
|
|
|
84
84
|
|
|
85
85
|
```
|
|
86
86
|
knowledge/ author only — who you are, what you decide, how you sound
|
|
87
|
-
|
|
87
|
+
scratchpad/ keep only — agent's own durable lane (bytes climb to knowledge only via propose→accept)
|
|
88
88
|
proposals/ propose (agent+human) — proposals waiting on a human accept
|
|
89
89
|
artifacts/ converge only — machine-maintained: computed outputs + external inputs
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `
|
|
92
|
+
An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `scratchpad/` (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
|
|
93
93
|
|
|
94
94
|
That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
|
|
95
95
|
|
|
@@ -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
|
|
|
@@ -156,7 +156,7 @@ roles:
|
|
|
156
156
|
|
|
157
157
|
lanes:
|
|
158
158
|
- { name: knowledge, kind: canon } # author — canonical truth
|
|
159
|
-
- { name:
|
|
159
|
+
- { name: scratchpad, kind: workspace } # keep — agent's own durable lane
|
|
160
160
|
- { name: proposals, kind: queue } # propose — proposals awaiting accept
|
|
161
161
|
- { name: artifacts, kind: machine } # converge — computed outputs + external inputs
|
|
162
162
|
```
|
|
@@ -170,7 +170,7 @@ lanes:
|
|
|
170
170
|
.gitignore # generated — ignores .state/ and any tracked:false entries
|
|
171
171
|
data/ # one dir per lane; kinds + capabilities are in the manifest above
|
|
172
172
|
knowledge/ # e.g. identity (knowledge.identity.*), voice, decisions, notes
|
|
173
|
-
|
|
173
|
+
scratchpad/
|
|
174
174
|
proposals/
|
|
175
175
|
artifacts/ # machine lane: computed outputs + external inputs
|
|
176
176
|
.state/ # disposable runtime state — gitignored, safe to delete (ADR 0038)
|
|
@@ -201,20 +201,20 @@ 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.
|
|
210
|
-
- **`raw` lane and `ingest` verb.** Write-once intake lane for external URL bookmarks, files, and binary assets. Three source kinds (`url`/`file`/`asset`); daily key derivation;
|
|
210
|
+
- **`raw` lane and `ingest` verb.** Write-once intake lane for external URL bookmarks, files, and binary assets. Three source kinds (`url`/`file`/`asset`); daily key derivation; scratchpad stub per ingest. See "Intake and ingest" section below.
|
|
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
|
|
|
@@ -281,8 +281,8 @@ textus ingest url agentskills-io-brainstorming \
|
|
|
281
281
|
# see what landed in the raw lane
|
|
282
282
|
textus list --lane=raw
|
|
283
283
|
|
|
284
|
-
# a
|
|
285
|
-
textus get
|
|
284
|
+
# a scratchpad stub was created alongside — annotate it
|
|
285
|
+
textus get scratchpad.notes.raw
|
|
286
286
|
```
|
|
287
287
|
|
|
288
288
|
Stale produced entries are re-materialised by `drain`, not by reads — `get` is a pure read (ADR 0089).
|
|
@@ -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
|
|
|
@@ -232,6 +193,8 @@ module Textus
|
|
|
232
193
|
end
|
|
233
194
|
|
|
234
195
|
class CursorExpired < Error
|
|
196
|
+
JSONRPC_CODE = -32_002
|
|
197
|
+
|
|
235
198
|
attr_reader :requested, :min_available
|
|
236
199
|
|
|
237
200
|
def initialize(requested:, min_available:)
|
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
|