textus 0.54.1 → 0.54.2
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 +14 -1
- data/README.md +53 -26
- data/SPEC.md +141 -297
- data/docs/reference/conventions.md +39 -55
- data/lib/textus/action/ingest.rb +103 -35
- data/lib/textus/action/pulse.rb +1 -1
- data/lib/textus/boot.rb +3 -3
- data/lib/textus/cursor_store.rb +1 -1
- data/lib/textus/init.rb +1 -1
- data/lib/textus/layout.rb +15 -7
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -4
- data/lib/textus/ports/raw_index.rb +61 -0
- data/lib/textus/version.rb +1 -1
- metadata +2 -1
|
@@ -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(
|
|
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)`.
|
|
145
129
|
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
|
|
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/lib/textus/action/ingest.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "date"
|
|
5
|
+
require "digest"
|
|
5
6
|
|
|
6
7
|
module Textus
|
|
7
8
|
module Action
|
|
@@ -22,6 +23,8 @@ module Textus
|
|
|
22
23
|
view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
|
|
23
24
|
|
|
24
25
|
SOURCE_KINDS = %w[url file asset].freeze
|
|
26
|
+
CONTENT_HASH_ALGO = "sha256"
|
|
27
|
+
TOMBSTONE_RETAIN = %w[ingested_at].freeze
|
|
25
28
|
|
|
26
29
|
def initialize(kind:, slug:, url: nil, path: nil, zone: nil, label: nil)
|
|
27
30
|
super()
|
|
@@ -43,7 +46,22 @@ module Textus
|
|
|
43
46
|
action: :ingest, actor: call.role, key: key,
|
|
44
47
|
)
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
content_hash = compute_content_hash
|
|
50
|
+
writer = Textus::Envelope::Writer.from(container: container, call: call)
|
|
51
|
+
mentry = container.manifest.resolver.resolve(key).entry
|
|
52
|
+
ts = now.iso8601
|
|
53
|
+
structured = build_structured(ts, container, now, content_hash)
|
|
54
|
+
|
|
55
|
+
index = Textus::Ports::RawIndex.new(root: container.root)
|
|
56
|
+
duplicate_key = find_duplicate(index, content_hash)
|
|
57
|
+
|
|
58
|
+
if duplicate_key && duplicate_key != key
|
|
59
|
+
supersede_entry(duplicate_key, key, structured, container, call, index)
|
|
60
|
+
else
|
|
61
|
+
env = write_raw_entry(key, structured, mentry, writer)
|
|
62
|
+
index.upsert(content_hash: content_hash, url: @url, key: key)
|
|
63
|
+
env
|
|
64
|
+
end
|
|
47
65
|
end
|
|
48
66
|
|
|
49
67
|
private
|
|
@@ -70,50 +88,100 @@ module Textus
|
|
|
70
88
|
"raw.#{date}.#{@kind}-#{@slug}"
|
|
71
89
|
end
|
|
72
90
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
def compute_content_hash
|
|
92
|
+
digest = Digest::SHA256.new
|
|
93
|
+
case @kind
|
|
94
|
+
when "url"
|
|
95
|
+
digest.update(@url)
|
|
96
|
+
when "file", "asset"
|
|
97
|
+
digest.file(@path)
|
|
98
|
+
end
|
|
99
|
+
"#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
|
|
100
|
+
end
|
|
77
101
|
|
|
102
|
+
def build_structured(timestamp, container, now, content_hash)
|
|
103
|
+
base = { "ingested_at" => timestamp, "content_hash" => content_hash }
|
|
78
104
|
case @kind
|
|
79
105
|
when "url"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
|
|
83
|
-
"body" => nil,
|
|
84
|
-
}
|
|
85
|
-
writer.put(key, mentry: mentry,
|
|
86
|
-
payload: Textus::Envelope::Writer::Payload.new(
|
|
87
|
-
meta: nil, body: nil, content: structured,
|
|
88
|
-
))
|
|
106
|
+
base.merge("source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
|
|
107
|
+
"body" => nil)
|
|
89
108
|
when "file"
|
|
90
109
|
body_content = File.read(@path)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"label" => @label || File.basename(@path) },
|
|
95
|
-
"body" => body_content,
|
|
96
|
-
}
|
|
97
|
-
writer.put(key, mentry: mentry,
|
|
98
|
-
payload: Textus::Envelope::Writer::Payload.new(
|
|
99
|
-
meta: nil, body: nil, content: structured,
|
|
100
|
-
))
|
|
110
|
+
base.merge("source" => { "kind" => "file", "path" => @path,
|
|
111
|
+
"label" => @label || File.basename(@path) },
|
|
112
|
+
"body" => body_content)
|
|
101
113
|
when "asset"
|
|
102
114
|
asset_rel = copy_asset_file(container, now)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"asset" => asset_rel,
|
|
108
|
-
"body" => nil,
|
|
109
|
-
}
|
|
110
|
-
writer.put(key, mentry: mentry,
|
|
111
|
-
payload: Textus::Envelope::Writer::Payload.new(
|
|
112
|
-
meta: nil, body: nil, content: structured,
|
|
113
|
-
))
|
|
115
|
+
base.merge("source" => { "kind" => "asset",
|
|
116
|
+
"label" => @label || File.basename(@path) },
|
|
117
|
+
"asset" => asset_rel,
|
|
118
|
+
"body" => nil)
|
|
114
119
|
end
|
|
115
120
|
end
|
|
116
121
|
|
|
122
|
+
def write_raw_entry(key, structured, mentry, writer)
|
|
123
|
+
writer.put(key, mentry: mentry,
|
|
124
|
+
payload: Textus::Envelope::Writer::Payload.new(
|
|
125
|
+
meta: nil, body: nil, content: structured,
|
|
126
|
+
))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def find_duplicate(index, content_hash)
|
|
130
|
+
dup = index.find_by_hash(content_hash)
|
|
131
|
+
return dup if dup
|
|
132
|
+
|
|
133
|
+
return unless @kind == "url"
|
|
134
|
+
|
|
135
|
+
index.find_by_url(@url)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def supersede_entry(old_key, new_key, structured, container, call, index)
|
|
139
|
+
old_mentry = container.manifest.resolver.resolve(old_key).entry
|
|
140
|
+
writer = Textus::Envelope::Writer.from(container: container, call: call)
|
|
141
|
+
|
|
142
|
+
reader = Textus::Envelope::Reader.from(container: container)
|
|
143
|
+
old_env = reader.read(old_key)
|
|
144
|
+
old_content = old_env&.content || {}
|
|
145
|
+
tombstone = {}
|
|
146
|
+
TOMBSTONE_RETAIN.each do |k|
|
|
147
|
+
tombstone[k] = old_content[k] if old_content.key?(k)
|
|
148
|
+
end
|
|
149
|
+
source_kind = old_content.dig("source", "kind")
|
|
150
|
+
tombstone["source"] = { "kind" => source_kind } if source_kind
|
|
151
|
+
tombstone["superseded_by"] = new_key
|
|
152
|
+
|
|
153
|
+
writer.put(old_key, mentry: old_mentry,
|
|
154
|
+
payload: Textus::Envelope::Writer::Payload.new(
|
|
155
|
+
meta: nil, body: nil, content: tombstone,
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
structured["supersedes"] = old_key
|
|
159
|
+
env = write_raw_entry(new_key, structured, container.manifest.resolver.resolve(new_key).entry, writer)
|
|
160
|
+
|
|
161
|
+
move_asset_file(container, old_content["asset"]) if @kind == "asset" && old_content["asset"]
|
|
162
|
+
|
|
163
|
+
index.upsert(content_hash: structured["content_hash"], url: @url, key: new_key)
|
|
164
|
+
env
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def move_asset_file(container, old_asset_rel)
|
|
168
|
+
old_path = File.join(container.root, "assets", old_asset_rel)
|
|
169
|
+
return unless File.exist?(old_path)
|
|
170
|
+
|
|
171
|
+
now = Time.now.utc
|
|
172
|
+
date_path = now.strftime("%Y/%m/%d")
|
|
173
|
+
filename = File.basename(old_path)
|
|
174
|
+
new_dir = File.join(container.root, "assets", "raw", date_path, @zone)
|
|
175
|
+
new_path = File.join(new_dir, filename)
|
|
176
|
+
|
|
177
|
+
return if old_path == new_path
|
|
178
|
+
|
|
179
|
+
FileUtils.mkdir_p(new_dir)
|
|
180
|
+
FileUtils.mv(old_path, new_path)
|
|
181
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
182
|
+
warn "[textus ingest] could not move asset #{old_asset_rel}: #{e.message}"
|
|
183
|
+
end
|
|
184
|
+
|
|
117
185
|
def copy_asset_file(container, now)
|
|
118
186
|
date_path = now.strftime("%Y/%m/%d")
|
|
119
187
|
filename = File.basename(@path)
|
data/lib/textus/action/pulse.rb
CHANGED
|
@@ -46,7 +46,7 @@ module Textus
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def index_etag(container)
|
|
49
|
-
path = container.manifest.resolver.resolve("artifacts.index").path
|
|
49
|
+
path = container.manifest.resolver.resolve("artifacts.system.index").path
|
|
50
50
|
File.exist?(path) ? container.file_store.etag(path) : nil
|
|
51
51
|
rescue Textus::Error
|
|
52
52
|
nil
|
data/lib/textus/boot.rb
CHANGED
|
@@ -158,7 +158,7 @@ module Textus
|
|
|
158
158
|
"contract_etag" => etag,
|
|
159
159
|
"lanes" => lanes_for(manifest),
|
|
160
160
|
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
161
|
-
"orientation" => read_artifact_content(container, "artifacts.orientation"),
|
|
161
|
+
"orientation" => read_artifact_content(container, "artifacts.config.orientation"),
|
|
162
162
|
"context" => read_boot_context(container),
|
|
163
163
|
"index_key" => index_key_if_present(container),
|
|
164
164
|
"agent_protocol" => agent_protocol(manifest),
|
|
@@ -189,8 +189,8 @@ module Textus
|
|
|
189
189
|
end
|
|
190
190
|
|
|
191
191
|
def self.index_key_if_present(container)
|
|
192
|
-
res = container.manifest.resolver.resolve("artifacts.index")
|
|
193
|
-
res.path && File.exist?(res.path) ? "artifacts.index" : nil
|
|
192
|
+
res = container.manifest.resolver.resolve("artifacts.system.index")
|
|
193
|
+
res.path && File.exist?(res.path) ? "artifacts.system.index" : nil
|
|
194
194
|
rescue Textus::Error
|
|
195
195
|
nil
|
|
196
196
|
end
|
data/lib/textus/cursor_store.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
# Per-role cursor cache under <root>/.
|
|
4
|
+
# Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
|
|
5
5
|
# `textus pulse` (no --since) means "since I last looked". Gitignored;
|
|
6
6
|
# losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
|
|
7
7
|
class CursorStore
|
data/lib/textus/init.rb
CHANGED
|
@@ -93,7 +93,7 @@ module Textus
|
|
|
93
93
|
|
|
94
94
|
def self.setup_state_dirs(target_root)
|
|
95
95
|
FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
|
|
96
|
-
FileUtils.mkdir_p(Textus::Layout.
|
|
96
|
+
FileUtils.mkdir_p(Textus::Layout.cursors(target_root))
|
|
97
97
|
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
98
98
|
end
|
|
99
99
|
|
data/lib/textus/layout.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
# Single source of truth for every path textus owns under a store root.
|
|
3
|
-
# All disposable runtime state nests under <root>/.
|
|
3
|
+
# All disposable runtime state nests under <root>/.state/ so the
|
|
4
4
|
# tracked/disposable boundary is a directory boundary. ADR 0038.
|
|
5
5
|
module Layout
|
|
6
|
-
RUN = ".
|
|
6
|
+
RUN = ".state"
|
|
7
7
|
DATA = "data"
|
|
8
8
|
|
|
9
9
|
def self.data(root)
|
|
@@ -18,12 +18,12 @@ module Textus
|
|
|
18
18
|
File.join(root, RUN)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def self.
|
|
22
|
-
File.join(run(root), "
|
|
21
|
+
def self.cursors(root)
|
|
22
|
+
File.join(run(root), "cursors")
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def self.cursor(root, role)
|
|
26
|
-
File.join(
|
|
26
|
+
File.join(cursors(root), role.to_s)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def self.locks(root)
|
|
@@ -51,19 +51,27 @@ module Textus
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Sentinels are machine-generated (the published target's sha), not authored
|
|
54
|
-
# source, so they live on the runtime side under `.
|
|
54
|
+
# source, so they live on the runtime side under `.state/` — git-ignored,
|
|
55
55
|
# regenerated by the next build via content-identical adoption (ADR 0070,
|
|
56
56
|
# superseding ADR 0038's `:config` classification).
|
|
57
57
|
def self.sentinels(root)
|
|
58
58
|
File.join(run(root), "sentinels")
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
def self.indexes(root)
|
|
62
|
+
File.join(run(root), "indexes")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.raw_index(root)
|
|
66
|
+
File.join(indexes(root), "raw.yaml")
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def self.audit_log(root)
|
|
62
70
|
File.join(audit_dir(root), "audit.log")
|
|
63
71
|
end
|
|
64
72
|
|
|
65
73
|
# The store's `.gitignore` body. Always ignores the runtime subtree
|
|
66
|
-
# (`.
|
|
74
|
+
# (`.state/`, ADR 0038); when given untracked entry paths (entries marked
|
|
67
75
|
# `tracked: false`), it also lists those so they stay protocol-readable but
|
|
68
76
|
# uncommitted (ADR 0043, refining 0038). Generated, never hand-kept — no
|
|
69
77
|
# drift between the manifest and the ignore file.
|
|
@@ -45,11 +45,8 @@ module Textus
|
|
|
45
45
|
|
|
46
46
|
private
|
|
47
47
|
|
|
48
|
-
# A structured-data entry that textus owns: its `_meta` stays in the
|
|
49
|
-
# store, so the published file is the re-serialized meta-free content.
|
|
50
|
-
# An external (command) entry is opaque — never parse/re-serialize it.
|
|
51
48
|
def strip_meta?(entry)
|
|
52
|
-
|
|
49
|
+
%w[json yaml].include?(entry.format.to_s)
|
|
53
50
|
end
|
|
54
51
|
|
|
55
52
|
def render_bytes(target, content, renderer, pctx)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Textus
|
|
7
|
+
module Ports
|
|
8
|
+
# Content-addressable index for the raw lane. Maps content hashes and URLs
|
|
9
|
+
# to their current canonical key. Lives under <root>/.state/indexes/raw.yaml
|
|
10
|
+
# (gitignored, regenerable — truth is in the on-disk raw entries).
|
|
11
|
+
class RawIndex
|
|
12
|
+
def initialize(root:)
|
|
13
|
+
@root = root
|
|
14
|
+
@path = Layout.raw_index(root)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :path
|
|
18
|
+
|
|
19
|
+
def load
|
|
20
|
+
return empty_index unless File.exist?(@path)
|
|
21
|
+
|
|
22
|
+
YAML.safe_load_file(@path) || empty_index
|
|
23
|
+
rescue StandardError
|
|
24
|
+
empty_index
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def save(index)
|
|
28
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
29
|
+
File.write(@path, YAML.dump(index))
|
|
30
|
+
index
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def find_by_hash(content_hash)
|
|
34
|
+
index = load
|
|
35
|
+
index["hashes"]&.fetch(content_hash, nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_by_url(url)
|
|
39
|
+
return nil unless url
|
|
40
|
+
|
|
41
|
+
index = load
|
|
42
|
+
index["urls"]&.fetch(url, nil)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def upsert(content_hash:, url:, key:)
|
|
46
|
+
index = load
|
|
47
|
+
index["hashes"] ||= {}
|
|
48
|
+
index["urls"] ||= {}
|
|
49
|
+
index["hashes"][content_hash] = key
|
|
50
|
+
index["urls"][url] = key if url
|
|
51
|
+
save(index)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def empty_index
|
|
57
|
+
{ "hashes" => {}, "urls" => {} }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/textus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.54.
|
|
4
|
+
version: 0.54.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -291,6 +291,7 @@ files:
|
|
|
291
291
|
- lib/textus/ports/job_store.rb
|
|
292
292
|
- lib/textus/ports/job_store/job.rb
|
|
293
293
|
- lib/textus/ports/publisher.rb
|
|
294
|
+
- lib/textus/ports/raw_index.rb
|
|
294
295
|
- lib/textus/ports/sentinel_store.rb
|
|
295
296
|
- lib/textus/ports/storage/file_stat.rb
|
|
296
297
|
- lib/textus/ports/storage/file_store.rb
|