@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "strscan"
|
|
5
|
+
require_relative "savepoint"
|
|
6
|
+
require_relative "guarded_append"
|
|
7
|
+
|
|
8
|
+
# NodeLedger - the node and intent transition line (intent 335, G2). Owns the
|
|
9
|
+
# line's byte-exact format, the closed state vocabulary, the required fields per
|
|
10
|
+
# state, the parser, torn-line and unattributed-line detection, status as the
|
|
11
|
+
# last line per subject in file order, and the guarded transition append that
|
|
12
|
+
# skips dedup entirely (spec D11).
|
|
13
|
+
#
|
|
14
|
+
# Per spec D17 the subject vocabulary (`Savepoint::INTENT_SUBJECT`,
|
|
15
|
+
# `Savepoint::NODE_SUBJECT_RE`, `Savepoint.transition_candidate?`) lives on
|
|
16
|
+
# Savepoint, not here: this file requires savepoint.rb and reuses them, the
|
|
17
|
+
# dependency running one way only (test/savepoint_split_test.rb:57 pins
|
|
18
|
+
# savepoint.rb to loading no other project file).
|
|
19
|
+
#
|
|
20
|
+
# Pure and dependency-injected: every path and clock is an argument, `guard:` is
|
|
21
|
+
# an injectable seam for the transition append, and this module reads no
|
|
22
|
+
# environment variable.
|
|
23
|
+
module NodeLedger
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Ten states, closed (spec D1). "ready" is computed and never written.
|
|
27
|
+
STATES = %w[
|
|
28
|
+
planned running done failed_verification needs_decision blocked deferred
|
|
29
|
+
superseded abandoned reclaimed
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# A written state's RESOLVED state for the ready function 336 builds on top of
|
|
33
|
+
# this ledger (spec D2): every state resolves to itself except `reclaimed`,
|
|
34
|
+
# which resolves to `planned` ("a reclaimed node is planned again").
|
|
35
|
+
RESOLUTION = { "reclaimed" => "planned" }.freeze
|
|
36
|
+
|
|
37
|
+
# Required fields per state (spec D4), refused by #append_transition and read
|
|
38
|
+
# as torn by #torn? when absent. `done`'s evidence requirement (gates= plus at
|
|
39
|
+
# least one of commit=/verdict=) is asymmetric, so it is not representable as
|
|
40
|
+
# a flat list; DONE_EVIDENCE_FIELDS below carries the "at least one of" half.
|
|
41
|
+
REQUIRED_FIELDS = {
|
|
42
|
+
"planned" => [],
|
|
43
|
+
"running" => %w[holder expires packet model],
|
|
44
|
+
"done" => %w[gates],
|
|
45
|
+
"failed_verification" => %w[gates reason],
|
|
46
|
+
"needs_decision" => %w[question],
|
|
47
|
+
"blocked" => %w[reason],
|
|
48
|
+
"deferred" => %w[reason],
|
|
49
|
+
"superseded" => %w[by],
|
|
50
|
+
"abandoned" => %w[reason],
|
|
51
|
+
"reclaimed" => %w[holder expired],
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# `done` requires gates= plus at least one of these (spec D7). `model=` is
|
|
55
|
+
# deliberately absent from every required-fields list except `running` (spec
|
|
56
|
+
# D6) and is accepted, never required, everywhere else - no separate table
|
|
57
|
+
# entry is needed for that: an unlisted field is never required.
|
|
58
|
+
DONE_EVIDENCE_FIELDS = %w[commit verdict].freeze
|
|
59
|
+
|
|
60
|
+
# Canonical field render order (spec D8's "stable declared order", matrix
|
|
61
|
+
# 2.6): every key named in REQUIRED_FIELDS or DONE_EVIDENCE_FIELDS, plus
|
|
62
|
+
# `model` (accepted on any state). A field outside this list still renders,
|
|
63
|
+
# sorted after these, so an unrecognized key is never silently dropped.
|
|
64
|
+
FIELD_ORDER = %w[holder expires packet model gates commit verdict reason question by expired].freeze
|
|
65
|
+
|
|
66
|
+
SEPARATOR = " "
|
|
67
|
+
|
|
68
|
+
# The field key character class, shared by the emitter (#render_fields, below) and
|
|
69
|
+
# the parser (FIELD_TOKEN_RE, further down): a key outside this class is a key the
|
|
70
|
+
# parser can never read back as a pair, so it must never be emitted (post-execution
|
|
71
|
+
# review row 7.7). One source of truth for the char class keeps the two families
|
|
72
|
+
# from drifting the way they did before this row's fix.
|
|
73
|
+
FIELD_KEY_CHARS = "[a-z_]+"
|
|
74
|
+
FIELD_KEY_RE = /\A#{FIELD_KEY_CHARS}\z/.freeze
|
|
75
|
+
|
|
76
|
+
# timestamp<SEP>subject<SEP>state field=value... (comment). Two-space
|
|
77
|
+
# separators keep the line a three-field line under both `split(/\s{2,}/)`
|
|
78
|
+
# and every reader's own `SAVEPOINT_RE` (spec "Line shape"). Raises
|
|
79
|
+
# ArgumentError, writing nothing, for an unknown state or a missing required
|
|
80
|
+
# field (D4/D9): the caller of #append_transition relies on this check
|
|
81
|
+
# running BEFORE the file is ever touched.
|
|
82
|
+
def transition_line(subject:, state:, fields: {}, comment: nil, now: Time.now)
|
|
83
|
+
state = state.to_s
|
|
84
|
+
raise ArgumentError, "unknown transition state: #{state.inspect}" unless STATES.include?(state)
|
|
85
|
+
|
|
86
|
+
missing = missing_fields(state, fields)
|
|
87
|
+
raise ArgumentError, "state #{state} requires #{missing.join(', ')}" if missing.any?
|
|
88
|
+
|
|
89
|
+
timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
90
|
+
rest = ([state] + render_fields(fields)).join(" ")
|
|
91
|
+
rest = "#{rest} (#{normalize_value(comment)})" if comment && !comment.to_s.strip.empty?
|
|
92
|
+
"#{timestamp}#{SEPARATOR}#{subject}#{SEPARATOR}#{rest}\n"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Collapse every run of two-or-more WHITESPACE characters in `text` to one
|
|
96
|
+
# (spec "Line shape": "collapses every whitespace run inside a field value
|
|
97
|
+
# to a single space"), so a caller-supplied value or comment can never
|
|
98
|
+
# reintroduce a two-or-more run the line's own separators rely on being
|
|
99
|
+
# unique to field boundaries. A tab or a newline is refused outright (raises
|
|
100
|
+
# ArgumentError) rather than collapsed: silently eating either would hide a
|
|
101
|
+
# value that could otherwise tear a ledger line. Post-execution review row
|
|
102
|
+
# 7.6: a bare `/ {2,}/` collapsed only literal spaces, so a carriage return,
|
|
103
|
+
# vertical tab or form feed beside a space still reached field 3 as a
|
|
104
|
+
# two-whitespace run that `split(/\s{2,}/)` reads as four fields while
|
|
105
|
+
# SAVEPOINT_RE reads as three - `\s{2,}` is what the acceptance criterion
|
|
106
|
+
# ("no run of two or more whitespace characters ever reaches field 3")
|
|
107
|
+
# actually requires.
|
|
108
|
+
def normalize_value(text)
|
|
109
|
+
value = text.to_s
|
|
110
|
+
raise ArgumentError, "value must not contain a tab or a newline: #{value.inspect}" if value =~ /[\t\n]/
|
|
111
|
+
|
|
112
|
+
value.gsub(/\s{2,}/, " ")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A required-field D4 check independent of vocabulary validity: given a state
|
|
116
|
+
# (assumed valid) and a fields hash, the list of required field names still
|
|
117
|
+
# missing. `done`'s "at least one of commit=/verdict=" half renders as the
|
|
118
|
+
# literal string "commit or verdict" when both are absent.
|
|
119
|
+
def missing_fields(state, fields)
|
|
120
|
+
normalized = stringify_keys(fields)
|
|
121
|
+
required = REQUIRED_FIELDS.fetch(state.to_s, [])
|
|
122
|
+
missing = required.reject { |key| present?(normalized[key]) }
|
|
123
|
+
if state.to_s == "done" && DONE_EVIDENCE_FIELDS.none? { |key| present?(normalized[key]) }
|
|
124
|
+
missing += ["commit or verdict"]
|
|
125
|
+
end
|
|
126
|
+
missing
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def present?(value)
|
|
130
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
131
|
+
end
|
|
132
|
+
private_class_method :present?
|
|
133
|
+
|
|
134
|
+
def stringify_keys(fields)
|
|
135
|
+
(fields || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
136
|
+
end
|
|
137
|
+
private_class_method :stringify_keys
|
|
138
|
+
|
|
139
|
+
def render_fields(fields)
|
|
140
|
+
normalized = stringify_keys(fields)
|
|
141
|
+
unreadable = normalized.keys.reject { |key| key.match?(FIELD_KEY_RE) }
|
|
142
|
+
if unreadable.any?
|
|
143
|
+
raise ArgumentError, "field key(s) the parser cannot read back: #{unreadable.join(', ')}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
known = FIELD_ORDER.select { |key| normalized.key?(key) }
|
|
147
|
+
extra = (normalized.keys - FIELD_ORDER).sort
|
|
148
|
+
(known + extra).map { |key| "#{key}=#{render_value(normalized[key])}" }
|
|
149
|
+
end
|
|
150
|
+
private_class_method :render_fields
|
|
151
|
+
|
|
152
|
+
def render_value(value)
|
|
153
|
+
text = normalize_value(value)
|
|
154
|
+
return text if bare_safe?(text)
|
|
155
|
+
|
|
156
|
+
escaped = text.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }
|
|
157
|
+
"\"#{escaped}\""
|
|
158
|
+
end
|
|
159
|
+
private_class_method :render_value
|
|
160
|
+
|
|
161
|
+
def bare_safe?(text)
|
|
162
|
+
!text.empty? && !text.match?(/[\s"\\]/)
|
|
163
|
+
end
|
|
164
|
+
private_class_method :bare_safe?
|
|
165
|
+
|
|
166
|
+
# --- Parsing ---------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
# timestamp<2sp>subject<2sp>rest, mirroring intent_screen.rb's SAVEPOINT_RE
|
|
169
|
+
# (the same three-field contract every reader in the tree already assumes).
|
|
170
|
+
TRANSITION_LINE_RE = /\A(\S+)#{SEPARATOR}(\S+)#{SEPARATOR}(.+?)\s*\z/.freeze
|
|
171
|
+
private_constant :TRANSITION_LINE_RE
|
|
172
|
+
|
|
173
|
+
FIELD_TOKEN_RE = /(#{FIELD_KEY_CHARS})=("(?:[^"\\]|\\.)*"|[^\s"]+)/.freeze
|
|
174
|
+
private_constant :FIELD_TOKEN_RE
|
|
175
|
+
|
|
176
|
+
# Parse one raw ledger line into {timestamp:, subject:, state:, fields:,
|
|
177
|
+
# comment:, raw:}, or nil when it does not even split into the three fields
|
|
178
|
+
# every transition line has. `#scrub`s the line first (mirrors
|
|
179
|
+
# SessionLedger.parse_checklist_line) so a stray non-UTF-8 byte anywhere in
|
|
180
|
+
# the ledger never raises out of the parser and takes down every reader
|
|
181
|
+
# (matrix 2.44); it only ever affects that one line's parsed text.
|
|
182
|
+
def parse_transition_line(line)
|
|
183
|
+
raw = line.to_s.chomp.scrub
|
|
184
|
+
m = TRANSITION_LINE_RE.match(raw)
|
|
185
|
+
return nil unless m
|
|
186
|
+
|
|
187
|
+
timestamp, subject, rest = m.captures
|
|
188
|
+
state, remainder = rest.split(/\s+/, 2)
|
|
189
|
+
fields, comment = scan_fields(remainder.to_s)
|
|
190
|
+
{ timestamp: timestamp, subject: subject, state: state, fields: fields, comment: comment, raw: raw }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Scan `rest` (everything after the state token) for `key=value` pairs, quote-
|
|
194
|
+
# and-escape aware, greedily from the start; the first token that is not a
|
|
195
|
+
# pair, and everything after it, is the trailing comment (spec D8), with one
|
|
196
|
+
# layer of wrapping parens stripped when present (the shape #transition_line
|
|
197
|
+
# itself always emits). Returns [fields_hash, comment_or_nil].
|
|
198
|
+
def scan_fields(rest)
|
|
199
|
+
scanner = StringScanner.new(rest)
|
|
200
|
+
fields = {}
|
|
201
|
+
loop do
|
|
202
|
+
scanner.skip(/\s+/)
|
|
203
|
+
break if scanner.eos?
|
|
204
|
+
|
|
205
|
+
start_pos = scanner.pos
|
|
206
|
+
if scanner.scan(FIELD_TOKEN_RE)
|
|
207
|
+
fields[scanner[1]] = unquote(scanner[2])
|
|
208
|
+
else
|
|
209
|
+
scanner.pos = start_pos
|
|
210
|
+
break
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
tail = scanner.rest.to_s.strip
|
|
214
|
+
comment = tail.empty? ? nil : tail.sub(/\A\((.*)\)\z/m, '\1')
|
|
215
|
+
[fields, comment]
|
|
216
|
+
end
|
|
217
|
+
private_class_method :scan_fields
|
|
218
|
+
|
|
219
|
+
def unquote(raw)
|
|
220
|
+
return raw unless raw.start_with?('"')
|
|
221
|
+
|
|
222
|
+
raw[1..-2].gsub(/\\(.)/) { Regexp.last_match(1) }
|
|
223
|
+
end
|
|
224
|
+
private_class_method :unquote
|
|
225
|
+
|
|
226
|
+
# A transition line is torn (spec D9) when it does not parse into the three
|
|
227
|
+
# fields at all, when its state token is outside STATES, or when its state is
|
|
228
|
+
# in STATES but a field REQUIRED_FIELDS demands is missing. The second half
|
|
229
|
+
# matters more than the first: a crash-truncated write usually keeps a valid
|
|
230
|
+
# state token and loses the tail (`n1 running holder=auto-ce5`), which is
|
|
231
|
+
# the realistic torn line, not a truncated state token.
|
|
232
|
+
def torn?(line)
|
|
233
|
+
parsed = parse_transition_line(line)
|
|
234
|
+
return true unless parsed
|
|
235
|
+
return true unless parsed[:state] && STATES.include?(parsed[:state])
|
|
236
|
+
|
|
237
|
+
missing_fields(parsed[:state], parsed[:fields]).any?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def torn_reason(parsed)
|
|
241
|
+
return "does not parse as timestamp subject state ..." unless parsed && parsed[:state]
|
|
242
|
+
return "unknown state #{parsed[:state].inspect}" unless STATES.include?(parsed[:state])
|
|
243
|
+
|
|
244
|
+
"missing required field(s): #{missing_fields(parsed[:state], parsed[:fields]).join(', ')}"
|
|
245
|
+
end
|
|
246
|
+
private_class_method :torn_reason
|
|
247
|
+
|
|
248
|
+
# A transition line is attributed (spec D10) iff it carries a non-blank
|
|
249
|
+
# `holder=` field. Unattributed and torn are independent: a line can be
|
|
250
|
+
# unattributed and otherwise well formed at the same time.
|
|
251
|
+
def attributed?(parsed)
|
|
252
|
+
return false unless parsed
|
|
253
|
+
|
|
254
|
+
present?((parsed[:fields] || {})["holder"])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def resolved_state(state)
|
|
258
|
+
RESOLUTION.fetch(state.to_s, state.to_s)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# --- Reading the ledger ------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
# Every transition candidate line in `path`, in file order, each annotated
|
|
264
|
+
# with its parse, torn-ness, and attribution. Non-candidate lines (a stage
|
|
265
|
+
# line, a Lock takeover line) are excluded entirely: they are never a
|
|
266
|
+
# transition and are never reported as torn by this module (spec Acceptance
|
|
267
|
+
# Criteria; Savepoint's own phantom detector covers the stage family).
|
|
268
|
+
# `#scrub`s the whole file up front (matrix 2.44) so one bad byte anywhere
|
|
269
|
+
# never raises out of this reader.
|
|
270
|
+
def entries(path)
|
|
271
|
+
return [] unless path && File.exist?(path)
|
|
272
|
+
|
|
273
|
+
entries_from_content(File.read(path))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Same as #entries, but over an in-memory string rather than a path (post-
|
|
277
|
+
# execution review row 7.1/7.2): the readiness decision `node-transition`
|
|
278
|
+
# makes for `running` must be evaluated against the exact content
|
|
279
|
+
# GuardedAppend read under its lock hold, never a re-read of the path (which
|
|
280
|
+
# could observe a different file than the one the guard is holding closed
|
|
281
|
+
# against other writers, and reintroduces the check-then-append gap this row
|
|
282
|
+
# exists to close).
|
|
283
|
+
def entries_from_content(content)
|
|
284
|
+
content.to_s.scrub.each_line.filter_map do |raw|
|
|
285
|
+
line = raw.chomp
|
|
286
|
+
next nil if line.strip.empty?
|
|
287
|
+
next nil unless Savepoint.transition_candidate?(line)
|
|
288
|
+
|
|
289
|
+
parsed = parse_transition_line(line)
|
|
290
|
+
torn = torn?(line)
|
|
291
|
+
{
|
|
292
|
+
raw: line,
|
|
293
|
+
timestamp: parsed && parsed[:timestamp],
|
|
294
|
+
subject: parsed ? parsed[:subject] : line.split(/\s{2,}/)[1],
|
|
295
|
+
state: parsed && parsed[:state],
|
|
296
|
+
fields: parsed ? parsed[:fields] : {},
|
|
297
|
+
comment: parsed && parsed[:comment],
|
|
298
|
+
torn: torn,
|
|
299
|
+
attributed: parsed ? attributed?(parsed) : false,
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Status per subject: the last NON-TORN line for that subject in FILE order
|
|
305
|
+
# (never by timestamp, spec Acceptance Criteria), resolved (spec D2). An
|
|
306
|
+
# unattributed-but-well-formed line still counts for status (D10: status
|
|
307
|
+
# shows it; only the readiness check ignores it).
|
|
308
|
+
def status(path)
|
|
309
|
+
status_from_content(path && File.exist?(path) ? File.read(path) : "")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Content-based counterpart to #status (see #entries_from_content).
|
|
313
|
+
def status_from_content(content)
|
|
314
|
+
entries_from_content(content).each_with_object({}) do |entry, memo|
|
|
315
|
+
next if entry[:torn]
|
|
316
|
+
|
|
317
|
+
memo[entry[:subject]] = resolved_state(entry[:state])
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# A subject with no line at all reads as "planned" (spec D1's implicit
|
|
322
|
+
# starting state), never raises.
|
|
323
|
+
def status_for(path, subject)
|
|
324
|
+
status(path).fetch(subject.to_s, "planned")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Content-based counterpart to #status_for (see #entries_from_content).
|
|
328
|
+
def status_for_content(content, subject)
|
|
329
|
+
status_from_content(content).fetch(subject.to_s, "planned")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The last (file-order) non-torn `running` entry for `subject`, or nil. Used
|
|
333
|
+
# by the reclaim precondition (S3) to find the `expires=` this subject's live
|
|
334
|
+
# dispatch carries.
|
|
335
|
+
def last_running(path, subject)
|
|
336
|
+
entries(path).select { |e| !e[:torn] && e[:subject] == subject.to_s && e[:state] == "running" }.last
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Torn and unattributed lines, each paired with a reason (spec C2/C5's
|
|
340
|
+
# reader): [{line:, reason:}, ...]. A clean or absent ledger returns []. A
|
|
341
|
+
# torn line is reported once, as torn; a well-formed unattributed line is
|
|
342
|
+
# reported once, as unattributed - the two categories never conflate.
|
|
343
|
+
def anomalies(path)
|
|
344
|
+
entries(path).filter_map do |entry|
|
|
345
|
+
if entry[:torn]
|
|
346
|
+
{ line: entry[:raw], reason: "torn: #{torn_reason(parse_transition_line(entry[:raw]))}" }
|
|
347
|
+
elsif !entry[:attributed]
|
|
348
|
+
{ line: entry[:raw], reason: "unattributed (no holder=)" }
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# --- Writing -----------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
# Refuse an unknown state or a missing required field BEFORE touching the
|
|
356
|
+
# file (spec C20), then append through `guard` (default GuardedAppend,
|
|
357
|
+
# strict), skipping dedup entirely (spec D11: transition lines never consult
|
|
358
|
+
# savepoint_recorded_pairs). Returns whatever `guard.call` returns
|
|
359
|
+
# (:written or :refused); propagates GuardedAppend::Unavailable rather than
|
|
360
|
+
# swallowing it (a caller must never believe a line landed when it did not).
|
|
361
|
+
#
|
|
362
|
+
# `precondition:` (post-execution review rows 7.1-7.3) is an optional
|
|
363
|
+
# callable evaluated INSIDE the guard's lock hold, against the exact
|
|
364
|
+
# `content` the guard just read - never a value captured before the call.
|
|
365
|
+
# When it returns falsy, the block returns nil (GuardedAppend's own
|
|
366
|
+
# refusal contract) and nothing is written; the caller sees :refused,
|
|
367
|
+
# distinguishable from :written, and must not believe the line landed. This
|
|
368
|
+
# is what makes "is the subject still ready" and "append running" atomic
|
|
369
|
+
# against a second writer: a check followed by a separate append never is.
|
|
370
|
+
def append_transition(path, subject:, state:, fields: {}, comment: nil, now: Time.now, guard: GuardedAppend,
|
|
371
|
+
precondition: nil)
|
|
372
|
+
line = transition_line(subject: subject, state: state, fields: fields, comment: comment, now: now)
|
|
373
|
+
guard.call(path, strict: true) do |content|
|
|
374
|
+
precondition && !precondition.call(content) ? nil : line
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|