harnex 0.7.13 → 0.7.14
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 +38 -0
- data/README.md +53 -11
- data/TECHNICAL.md +20 -1
- data/guides/01_dispatch.md +29 -9
- data/guides/04_monitoring.md +11 -5
- data/lib/harnex/artifact_report.rb +507 -35
- data/lib/harnex/cli.rb +17 -0
- data/lib/harnex/commands/artifact_report.rb +94 -0
- data/lib/harnex/commands/orchestration.rb +170 -0
- data/lib/harnex/commands/run.rb +49 -5
- data/lib/harnex/commands/wait.rb +6 -1
- data/lib/harnex/commands/watch.rb +2 -0
- data/lib/harnex/orchestration.rb +458 -0
- data/lib/harnex/runtime/session.rb +278 -13
- data/lib/harnex/terminal_status.rb +7 -0
- data/lib/harnex/version.rb +1 -1
- data/lib/harnex.rb +3 -0
- metadata +4 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "digest"
|
|
2
|
+
require "fileutils"
|
|
2
3
|
require "json"
|
|
3
4
|
|
|
4
5
|
module Harnex
|
|
@@ -10,64 +11,496 @@ module Harnex
|
|
|
10
11
|
MAX_CANONICAL_ARTIFACTS = 50
|
|
11
12
|
MAX_EVIDENCE_ITEMS = 20
|
|
12
13
|
MAX_STRING_LENGTH = 2_000
|
|
14
|
+
MAX_DIAGNOSTICS = 100
|
|
15
|
+
|
|
16
|
+
REPORT_STATUSES = %w[in_progress pass fail blocked unknown].freeze
|
|
17
|
+
VALIDATION_STATUSES = %w[in_progress pass fail not_run unknown].freeze
|
|
18
|
+
OUTCOME_STATUSES = %w[accepted rejected no_change unknown].freeze
|
|
19
|
+
ACCEPTED_OUTCOME_STATUSES = %w[accepted no_change].freeze
|
|
20
|
+
|
|
21
|
+
ValidationResult = Struct.new(
|
|
22
|
+
:ok, :status, :path, :bytes, :sha256, :schema, :diagnostics, :report,
|
|
23
|
+
keyword_init: true
|
|
24
|
+
) do
|
|
25
|
+
def public_payload(final: false)
|
|
26
|
+
{
|
|
27
|
+
"ok" => !!ok,
|
|
28
|
+
"status" => status,
|
|
29
|
+
"path" => path,
|
|
30
|
+
"bytes" => bytes,
|
|
31
|
+
"sha256" => sha256,
|
|
32
|
+
"schema" => schema,
|
|
33
|
+
"final" => !!final,
|
|
34
|
+
"diagnostics" => diagnostics || []
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
13
38
|
|
|
14
39
|
module_function
|
|
15
40
|
|
|
16
|
-
def
|
|
41
|
+
def validate(path, final: false)
|
|
17
42
|
report_path = File.expand_path(path.to_s)
|
|
18
|
-
return
|
|
43
|
+
return validation_failure(
|
|
44
|
+
report_path,
|
|
45
|
+
status: "missing",
|
|
46
|
+
diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
|
|
47
|
+
) unless File.file?(report_path)
|
|
19
48
|
|
|
20
49
|
bytes = File.size(report_path)
|
|
21
50
|
sha256 = file_sha256(report_path)
|
|
22
51
|
if bytes > MAX_BYTES
|
|
23
|
-
return
|
|
52
|
+
return validation_failure(
|
|
24
53
|
report_path,
|
|
54
|
+
status: "oversized",
|
|
25
55
|
bytes: bytes,
|
|
26
56
|
sha256: sha256,
|
|
27
|
-
|
|
28
|
-
|
|
57
|
+
diagnostics: [
|
|
58
|
+
diagnostic("report_oversized", "$", "artifact report exceeds the #{MAX_BYTES}-byte limit")
|
|
59
|
+
]
|
|
29
60
|
)
|
|
30
61
|
end
|
|
31
62
|
|
|
32
63
|
parsed = JSON.parse(File.read(report_path, mode: "rb"))
|
|
33
64
|
unless parsed.is_a?(Hash)
|
|
34
|
-
return
|
|
65
|
+
return validation_failure(
|
|
35
66
|
report_path,
|
|
67
|
+
status: "malformed",
|
|
36
68
|
bytes: bytes,
|
|
37
69
|
sha256: sha256,
|
|
38
|
-
|
|
39
|
-
warning: "artifact report must be a JSON object"
|
|
70
|
+
diagnostics: [diagnostic("object_required", "$", "artifact report must be a JSON object")]
|
|
40
71
|
)
|
|
41
72
|
end
|
|
42
73
|
|
|
43
|
-
schema = parsed["schema"].
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
schema = parsed["schema"].is_a?(String) ? bounded_string(parsed["schema"]) : nil
|
|
75
|
+
diagnostics = validate_document(parsed, final: final)
|
|
76
|
+
status = if parsed["schema"] != SCHEMA
|
|
77
|
+
"unsupported_schema"
|
|
78
|
+
elsif diagnostics.empty?
|
|
79
|
+
"valid"
|
|
80
|
+
else
|
|
81
|
+
"invalid"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
ValidationResult.new(
|
|
85
|
+
ok: diagnostics.empty?,
|
|
86
|
+
status: status,
|
|
87
|
+
path: report_path,
|
|
88
|
+
bytes: bytes,
|
|
89
|
+
sha256: sha256,
|
|
90
|
+
schema: schema,
|
|
91
|
+
diagnostics: diagnostics,
|
|
92
|
+
report: parsed
|
|
93
|
+
)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
validation_failure(
|
|
58
96
|
report_path,
|
|
97
|
+
status: "malformed",
|
|
59
98
|
bytes: safe_file_size(report_path),
|
|
60
99
|
sha256: safe_file_sha256(report_path),
|
|
61
|
-
|
|
62
|
-
warning: "malformed artifact report JSON: #{bounded_string(e.message)}"
|
|
100
|
+
diagnostics: [diagnostic("json_invalid", "$", "artifact report is not valid JSON")]
|
|
63
101
|
)
|
|
64
|
-
rescue StandardError
|
|
65
|
-
|
|
102
|
+
rescue StandardError
|
|
103
|
+
validation_failure(
|
|
66
104
|
report_path,
|
|
105
|
+
status: "error",
|
|
67
106
|
bytes: safe_file_size(report_path),
|
|
68
107
|
sha256: safe_file_sha256(report_path),
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
diagnostics: [diagnostic("read_error", "$", "artifact report could not be read")]
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ingest(path)
|
|
113
|
+
result = validate(path)
|
|
114
|
+
case result.status
|
|
115
|
+
when "missing"
|
|
116
|
+
missing(result.path)
|
|
117
|
+
when "oversized"
|
|
118
|
+
warning(
|
|
119
|
+
result.path,
|
|
120
|
+
bytes: result.bytes,
|
|
121
|
+
sha256: result.sha256,
|
|
122
|
+
ingest_status: "oversized",
|
|
123
|
+
warning: "artifact report is #{result.bytes} bytes; max is #{MAX_BYTES} bytes",
|
|
124
|
+
diagnostics: result.diagnostics
|
|
125
|
+
)
|
|
126
|
+
when "malformed"
|
|
127
|
+
warning(
|
|
128
|
+
result.path,
|
|
129
|
+
bytes: result.bytes,
|
|
130
|
+
sha256: result.sha256,
|
|
131
|
+
ingest_status: "malformed",
|
|
132
|
+
warning: "malformed artifact report JSON",
|
|
133
|
+
diagnostics: result.diagnostics
|
|
134
|
+
)
|
|
135
|
+
when "unsupported_schema"
|
|
136
|
+
warning(
|
|
137
|
+
result.path,
|
|
138
|
+
bytes: result.bytes,
|
|
139
|
+
sha256: result.sha256,
|
|
140
|
+
ingest_status: "unsupported_schema",
|
|
141
|
+
schema: result.schema,
|
|
142
|
+
warning: "unsupported artifact report schema; expected #{SCHEMA}",
|
|
143
|
+
diagnostics: result.diagnostics
|
|
144
|
+
)
|
|
145
|
+
when "error"
|
|
146
|
+
warning(
|
|
147
|
+
result.path,
|
|
148
|
+
bytes: result.bytes,
|
|
149
|
+
sha256: result.sha256,
|
|
150
|
+
ingest_status: "error",
|
|
151
|
+
warning: "artifact report ingest failed",
|
|
152
|
+
diagnostics: result.diagnostics
|
|
153
|
+
)
|
|
154
|
+
else
|
|
155
|
+
payload = build_payload(
|
|
156
|
+
result.path,
|
|
157
|
+
bytes: result.bytes,
|
|
158
|
+
sha256: result.sha256,
|
|
159
|
+
report: result.report
|
|
160
|
+
)
|
|
161
|
+
unless result.ok
|
|
162
|
+
metadata = payload.fetch("artifact_report")
|
|
163
|
+
metadata["ingest_status"] = "invalid"
|
|
164
|
+
metadata["warning"] = "artifact report does not conform to #{SCHEMA}"
|
|
165
|
+
metadata["diagnostics"] = result.diagnostics
|
|
166
|
+
end
|
|
167
|
+
payload
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def initialize_file(path, force: false)
|
|
172
|
+
report_path = File.expand_path(path.to_s)
|
|
173
|
+
raise ArgumentError, "artifact report path is required" if path.to_s.strip.empty?
|
|
174
|
+
if File.exist?(report_path) && !force
|
|
175
|
+
raise ArgumentError, "artifact report already exists: #{report_path} (use --force to replace it)"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
FileUtils.mkdir_p(File.dirname(report_path))
|
|
179
|
+
File.write(report_path, JSON.pretty_generate(template) + "\n")
|
|
180
|
+
report_path
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def template
|
|
184
|
+
{
|
|
185
|
+
"schema" => SCHEMA,
|
|
186
|
+
"status" => "in_progress",
|
|
187
|
+
"canonical_artifacts" => [],
|
|
188
|
+
"outcome" => {
|
|
189
|
+
"status" => "unknown",
|
|
190
|
+
"summary" => ""
|
|
191
|
+
},
|
|
192
|
+
"validation" => {
|
|
193
|
+
"status" => "not_run",
|
|
194
|
+
"commands" => [],
|
|
195
|
+
"final_reported" => false
|
|
196
|
+
},
|
|
197
|
+
"artifacts" => []
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def accepted_final?(result)
|
|
202
|
+
result.ok && ACCEPTED_OUTCOME_STATUSES.include?(outcome_status(result))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def fingerprint(path)
|
|
206
|
+
report_path = File.expand_path(path.to_s)
|
|
207
|
+
return nil unless File.file?(report_path)
|
|
208
|
+
|
|
209
|
+
stat = File.stat(report_path)
|
|
210
|
+
{
|
|
211
|
+
"bytes" => stat.size,
|
|
212
|
+
"sha256" => file_sha256(report_path),
|
|
213
|
+
"mtime_ns" => (stat.mtime.to_r * 1_000_000_000).to_i,
|
|
214
|
+
"ctime_ns" => (stat.ctime.to_r * 1_000_000_000).to_i,
|
|
215
|
+
"inode" => stat.ino
|
|
216
|
+
}
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def outcome_status(result)
|
|
222
|
+
report = result.respond_to?(:report) ? result.report : result
|
|
223
|
+
return nil unless report.is_a?(Hash)
|
|
224
|
+
|
|
225
|
+
outcome = report["outcome"]
|
|
226
|
+
outcome.is_a?(Hash) ? outcome["status"] : nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_document(report, final: false)
|
|
230
|
+
diagnostics = []
|
|
231
|
+
validate_schema(report, diagnostics)
|
|
232
|
+
validate_optional_enum(report, "status", REPORT_STATUSES, diagnostics)
|
|
233
|
+
validate_string_array_field(
|
|
234
|
+
report,
|
|
235
|
+
"canonical_artifacts",
|
|
236
|
+
max_items: MAX_CANONICAL_ARTIFACTS,
|
|
237
|
+
diagnostics: diagnostics
|
|
238
|
+
)
|
|
239
|
+
validate_outcome(report["outcome"], diagnostics) if report.key?("outcome")
|
|
240
|
+
validate_validation(report["validation"], diagnostics) if report.key?("validation")
|
|
241
|
+
validate_artifacts(report["artifacts"], diagnostics) if report.key?("artifacts")
|
|
242
|
+
validate_final_contract(report, diagnostics) if final
|
|
243
|
+
diagnostics.first(MAX_DIAGNOSTICS)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_schema(report, diagnostics)
|
|
247
|
+
value = report["schema"]
|
|
248
|
+
if !value.is_a?(String) || value.empty?
|
|
249
|
+
diagnostics << diagnostic("schema_required", "$.schema", "schema must be a non-empty string")
|
|
250
|
+
elsif value != SCHEMA
|
|
251
|
+
diagnostics << diagnostic("schema_unsupported", "$.schema", "schema must equal #{SCHEMA}")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_optional_enum(report, key, values, diagnostics)
|
|
256
|
+
return unless report.key?(key)
|
|
257
|
+
|
|
258
|
+
value = report[key]
|
|
259
|
+
unless value.is_a?(String) && values.include?(value)
|
|
260
|
+
diagnostics << diagnostic("enum", "$.#{key}", "#{key} must be one of #{values.join(', ')}")
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def validate_outcome(value, diagnostics)
|
|
265
|
+
unless value.is_a?(Hash)
|
|
266
|
+
diagnostics << diagnostic("object_required", "$.outcome", "outcome must be an object")
|
|
267
|
+
return
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
status = value["status"]
|
|
271
|
+
unless status.is_a?(String) && OUTCOME_STATUSES.include?(status)
|
|
272
|
+
diagnostics << diagnostic(
|
|
273
|
+
"enum",
|
|
274
|
+
"$.outcome.status",
|
|
275
|
+
"outcome.status must be one of #{OUTCOME_STATUSES.join(', ')}"
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
validate_optional_string(value, "summary", "$.outcome.summary", diagnostics)
|
|
279
|
+
validate_optional_string(value, "commit_sha", "$.outcome.commit_sha", diagnostics)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def validate_validation(value, diagnostics)
|
|
283
|
+
unless value.is_a?(Hash)
|
|
284
|
+
diagnostics << diagnostic("object_required", "$.validation", "validation must be an object")
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
if value.key?("status")
|
|
289
|
+
status = value["status"]
|
|
290
|
+
unless status.is_a?(String) && VALIDATION_STATUSES.include?(status)
|
|
291
|
+
diagnostics << diagnostic(
|
|
292
|
+
"enum",
|
|
293
|
+
"$.validation.status",
|
|
294
|
+
"validation.status must be one of #{VALIDATION_STATUSES.join(', ')}"
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
validate_commands(value["commands"], diagnostics) if value.key?("commands")
|
|
300
|
+
if value.key?("final_reported") && !boolean?(value["final_reported"])
|
|
301
|
+
diagnostics << diagnostic(
|
|
302
|
+
"boolean_required",
|
|
303
|
+
"$.validation.final_reported",
|
|
304
|
+
"validation.final_reported must be true or false"
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_commands(value, diagnostics)
|
|
310
|
+
unless value.is_a?(Array)
|
|
311
|
+
diagnostics << diagnostic("array_required", "$.validation.commands", "validation.commands must be an array")
|
|
312
|
+
return
|
|
313
|
+
end
|
|
314
|
+
if value.length > MAX_COMMANDS
|
|
315
|
+
diagnostics << diagnostic(
|
|
316
|
+
"too_many_items",
|
|
317
|
+
"$.validation.commands",
|
|
318
|
+
"validation.commands may contain at most #{MAX_COMMANDS} entries"
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
value.first(MAX_COMMANDS).each_with_index do |entry, index|
|
|
323
|
+
path = "$.validation.commands[#{index}]"
|
|
324
|
+
unless entry.is_a?(Hash)
|
|
325
|
+
diagnostics << diagnostic("object_required", path, "validation command must be an object")
|
|
326
|
+
next
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
validate_required_string(entry, "cmd", "#{path}.cmd", diagnostics)
|
|
330
|
+
unless entry["exit_code"].is_a?(Integer)
|
|
331
|
+
diagnostics << diagnostic(
|
|
332
|
+
"integer_required",
|
|
333
|
+
"#{path}.exit_code",
|
|
334
|
+
"validation command exit_code must be an integer"
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
validate_optional_string(entry, "status", "#{path}.status", diagnostics)
|
|
338
|
+
if entry.key?("duration_s") && !finite_non_negative_number?(entry["duration_s"])
|
|
339
|
+
diagnostics << diagnostic(
|
|
340
|
+
"number_required",
|
|
341
|
+
"#{path}.duration_s",
|
|
342
|
+
"validation command duration_s must be a finite non-negative number"
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def validate_artifacts(value, diagnostics)
|
|
349
|
+
unless value.is_a?(Array)
|
|
350
|
+
diagnostics << diagnostic("array_required", "$.artifacts", "artifacts must be an array")
|
|
351
|
+
return
|
|
352
|
+
end
|
|
353
|
+
if value.length > MAX_ARTIFACTS
|
|
354
|
+
diagnostics << diagnostic(
|
|
355
|
+
"too_many_items",
|
|
356
|
+
"$.artifacts",
|
|
357
|
+
"artifacts may contain at most #{MAX_ARTIFACTS} entries"
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
value.first(MAX_ARTIFACTS).each_with_index do |entry, index|
|
|
362
|
+
path = "$.artifacts[#{index}]"
|
|
363
|
+
unless entry.is_a?(Hash)
|
|
364
|
+
diagnostics << diagnostic("object_required", path, "artifact must be an object")
|
|
365
|
+
next
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
validate_required_string(entry, "type", "#{path}.type", diagnostics)
|
|
369
|
+
validate_required_string(entry, "summary", "#{path}.summary", diagnostics)
|
|
370
|
+
validate_string_array_value(
|
|
371
|
+
entry["evidence"],
|
|
372
|
+
"#{path}.evidence",
|
|
373
|
+
max_items: MAX_EVIDENCE_ITEMS,
|
|
374
|
+
diagnostics: diagnostics,
|
|
375
|
+
required: true
|
|
376
|
+
)
|
|
377
|
+
confidence = entry["confidence"]
|
|
378
|
+
unless finite_number?(confidence) && confidence >= 0.0 && confidence <= 1.0
|
|
379
|
+
diagnostics << diagnostic(
|
|
380
|
+
"range",
|
|
381
|
+
"#{path}.confidence",
|
|
382
|
+
"artifact confidence must be a finite number from 0 through 1"
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
validate_optional_string(entry, "canonical_ref", "#{path}.canonical_ref", diagnostics)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def validate_final_contract(report, diagnostics)
|
|
390
|
+
unless report["status"] == "pass"
|
|
391
|
+
diagnostics << diagnostic("final_status", "$.status", "final report status must be pass")
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
outcome = report["outcome"]
|
|
395
|
+
unless outcome.is_a?(Hash)
|
|
396
|
+
diagnostics << diagnostic("required", "$.outcome", "final report requires an outcome object")
|
|
397
|
+
else
|
|
398
|
+
unless ACCEPTED_OUTCOME_STATUSES.include?(outcome["status"])
|
|
399
|
+
diagnostics << diagnostic(
|
|
400
|
+
"outcome_not_accepted",
|
|
401
|
+
"$.outcome.status",
|
|
402
|
+
"final outcome must be accepted or no_change"
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
unless non_empty_string?(outcome["summary"])
|
|
406
|
+
diagnostics << diagnostic(
|
|
407
|
+
"required",
|
|
408
|
+
"$.outcome.summary",
|
|
409
|
+
"final outcome requires a non-empty summary"
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
validation = report["validation"]
|
|
415
|
+
unless validation.is_a?(Hash)
|
|
416
|
+
diagnostics << diagnostic("required", "$.validation", "final report requires a validation object")
|
|
417
|
+
return
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
expected_statuses = outcome.is_a?(Hash) && outcome["status"] == "no_change" ? %w[pass not_run] : %w[pass]
|
|
421
|
+
unless expected_statuses.include?(validation["status"])
|
|
422
|
+
diagnostics << diagnostic(
|
|
423
|
+
"final_validation_status",
|
|
424
|
+
"$.validation.status",
|
|
425
|
+
"final validation.status must be #{expected_statuses.join(' or ')}"
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
unless validation["commands"].is_a?(Array)
|
|
429
|
+
diagnostics << diagnostic(
|
|
430
|
+
"required",
|
|
431
|
+
"$.validation.commands",
|
|
432
|
+
"final report requires a validation.commands array"
|
|
433
|
+
)
|
|
434
|
+
end
|
|
435
|
+
unless validation["final_reported"] == true
|
|
436
|
+
diagnostics << diagnostic(
|
|
437
|
+
"final_reported",
|
|
438
|
+
"$.validation.final_reported",
|
|
439
|
+
"final report requires validation.final_reported=true"
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
Array(validation["commands"]).first(MAX_COMMANDS).each_with_index do |entry, index|
|
|
444
|
+
next unless entry.is_a?(Hash) && entry["exit_code"].is_a?(Integer)
|
|
445
|
+
next if entry["exit_code"].zero?
|
|
446
|
+
|
|
447
|
+
diagnostics << diagnostic(
|
|
448
|
+
"command_failed",
|
|
449
|
+
"$.validation.commands[#{index}].exit_code",
|
|
450
|
+
"accepted final reports require validation command exit_code=0"
|
|
451
|
+
)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def validate_string_array_field(report, key, max_items:, diagnostics:)
|
|
456
|
+
return unless report.key?(key)
|
|
457
|
+
|
|
458
|
+
validate_string_array_value(
|
|
459
|
+
report[key],
|
|
460
|
+
"$.#{key}",
|
|
461
|
+
max_items: max_items,
|
|
462
|
+
diagnostics: diagnostics,
|
|
463
|
+
required: true
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def validate_string_array_value(value, path, max_items:, diagnostics:, required:)
|
|
468
|
+
if value.nil? && !required
|
|
469
|
+
return
|
|
470
|
+
end
|
|
471
|
+
unless value.is_a?(Array)
|
|
472
|
+
diagnostics << diagnostic("array_required", path, "#{path.delete_prefix('$.')} must be an array")
|
|
473
|
+
return
|
|
474
|
+
end
|
|
475
|
+
if value.length > max_items
|
|
476
|
+
diagnostics << diagnostic("too_many_items", path, "#{path.delete_prefix('$.')} may contain at most #{max_items} entries")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
value.first(max_items).each_with_index do |item, index|
|
|
480
|
+
next if non_empty_string?(item)
|
|
481
|
+
|
|
482
|
+
diagnostics << diagnostic(
|
|
483
|
+
"string_required",
|
|
484
|
+
"#{path}[#{index}]",
|
|
485
|
+
"#{path.delete_prefix('$.')} entries must be non-empty strings"
|
|
486
|
+
)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def validate_required_string(hash, key, path, diagnostics)
|
|
491
|
+
return if non_empty_string?(hash[key])
|
|
492
|
+
|
|
493
|
+
diagnostics << diagnostic("string_required", path, "#{path.delete_prefix('$.')} must be a non-empty string")
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def validate_optional_string(hash, key, path, diagnostics)
|
|
497
|
+
return unless hash.key?(key)
|
|
498
|
+
return if hash[key].is_a?(String) && hash[key].length <= MAX_STRING_LENGTH
|
|
499
|
+
|
|
500
|
+
diagnostics << diagnostic(
|
|
501
|
+
"string_required",
|
|
502
|
+
path,
|
|
503
|
+
"#{path.delete_prefix('$.')} must be a string no longer than #{MAX_STRING_LENGTH} characters"
|
|
71
504
|
)
|
|
72
505
|
end
|
|
73
506
|
|
|
@@ -100,13 +533,15 @@ module Harnex
|
|
|
100
533
|
bytes: nil,
|
|
101
534
|
sha256: nil,
|
|
102
535
|
ingest_status: "missing",
|
|
103
|
-
warning: "artifact report not found"
|
|
536
|
+
warning: "artifact report not found",
|
|
537
|
+
diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
|
|
104
538
|
)
|
|
105
539
|
end
|
|
106
540
|
|
|
107
|
-
def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil)
|
|
541
|
+
def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil, diagnostics: nil)
|
|
108
542
|
report = metadata(path, bytes: bytes, sha256: sha256, ingest_status: ingest_status, schema: schema)
|
|
109
543
|
report["warning"] = bounded_string(warning)
|
|
544
|
+
report["diagnostics"] = Array(diagnostics).first(MAX_DIAGNOSTICS) unless Array(diagnostics).empty?
|
|
110
545
|
{ "artifact_report" => report }
|
|
111
546
|
end
|
|
112
547
|
|
|
@@ -127,7 +562,7 @@ module Harnex
|
|
|
127
562
|
payload["status"] = bounded_string_or_nil(value["status"])
|
|
128
563
|
payload["commands"] = compact_commands(value["commands"])
|
|
129
564
|
payload["final_reported"] = !!value["final_reported"] if value.key?("final_reported")
|
|
130
|
-
payload.delete_if { |_key,
|
|
565
|
+
payload.delete_if { |_key, item| item.nil? || item == [] }
|
|
131
566
|
payload.empty? ? nil : payload
|
|
132
567
|
end
|
|
133
568
|
|
|
@@ -141,7 +576,7 @@ module Harnex
|
|
|
141
576
|
"status" => bounded_string_or_nil(entry["status"]),
|
|
142
577
|
"duration_s" => finite_float_or_nil(entry["duration_s"])
|
|
143
578
|
}
|
|
144
|
-
compact.delete_if { |_key,
|
|
579
|
+
compact.delete_if { |_key, item| item.nil? }
|
|
145
580
|
compact.empty? ? nil : compact
|
|
146
581
|
end
|
|
147
582
|
end
|
|
@@ -150,7 +585,7 @@ module Harnex
|
|
|
150
585
|
return nil unless value.is_a?(Hash)
|
|
151
586
|
|
|
152
587
|
status = value["status"].to_s
|
|
153
|
-
return nil unless
|
|
588
|
+
return nil unless OUTCOME_STATUSES.include?(status)
|
|
154
589
|
|
|
155
590
|
payload = {
|
|
156
591
|
"status" => status,
|
|
@@ -172,11 +607,32 @@ module Harnex
|
|
|
172
607
|
"confidence" => finite_float_or_nil(entry["confidence"]),
|
|
173
608
|
"canonical_ref" => bounded_string_or_nil(entry["canonical_ref"])
|
|
174
609
|
}
|
|
175
|
-
compact.delete_if { |_key,
|
|
610
|
+
compact.delete_if { |_key, item| item.nil? || item == [] }
|
|
176
611
|
compact.empty? ? nil : compact
|
|
177
612
|
end
|
|
178
613
|
end
|
|
179
614
|
|
|
615
|
+
def validation_failure(path, status:, diagnostics:, bytes: nil, sha256: nil)
|
|
616
|
+
ValidationResult.new(
|
|
617
|
+
ok: false,
|
|
618
|
+
status: status,
|
|
619
|
+
path: path,
|
|
620
|
+
bytes: bytes,
|
|
621
|
+
sha256: sha256,
|
|
622
|
+
schema: nil,
|
|
623
|
+
diagnostics: diagnostics.first(MAX_DIAGNOSTICS),
|
|
624
|
+
report: nil
|
|
625
|
+
)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def diagnostic(code, path, message)
|
|
629
|
+
{
|
|
630
|
+
"code" => code,
|
|
631
|
+
"path" => path,
|
|
632
|
+
"message" => bounded_string(message)
|
|
633
|
+
}
|
|
634
|
+
end
|
|
635
|
+
|
|
180
636
|
def string_array(value, max_items:)
|
|
181
637
|
Array(value).first(max_items).filter_map do |item|
|
|
182
638
|
text = bounded_string_or_nil(item)
|
|
@@ -214,6 +670,22 @@ module Harnex
|
|
|
214
670
|
nil
|
|
215
671
|
end
|
|
216
672
|
|
|
673
|
+
def finite_number?(value)
|
|
674
|
+
value.is_a?(Numeric) && value.finite?
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def finite_non_negative_number?(value)
|
|
678
|
+
finite_number?(value) && value >= 0
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def non_empty_string?(value)
|
|
682
|
+
value.is_a?(String) && !value.empty? && value.length <= MAX_STRING_LENGTH
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def boolean?(value)
|
|
686
|
+
value == true || value == false
|
|
687
|
+
end
|
|
688
|
+
|
|
217
689
|
def file_sha256(path)
|
|
218
690
|
digest = Digest::SHA256.new
|
|
219
691
|
File.open(path, "rb") do |file|
|
data/lib/harnex/cli.rb
CHANGED
|
@@ -37,6 +37,10 @@ module Harnex
|
|
|
37
37
|
AgentsGuide.new(@argv.drop(1)).run
|
|
38
38
|
when "doctor"
|
|
39
39
|
Doctor.new(@argv.drop(1)).run
|
|
40
|
+
when "orchestration"
|
|
41
|
+
OrchestrationCommand.new(@argv.drop(1)).run
|
|
42
|
+
when "artifact-report"
|
|
43
|
+
ArtifactReportCommand.new(@argv.drop(1)).run
|
|
40
44
|
when "help"
|
|
41
45
|
puts help(@argv[1])
|
|
42
46
|
0
|
|
@@ -83,6 +87,10 @@ module Harnex
|
|
|
83
87
|
AgentsGuide.usage
|
|
84
88
|
when "doctor"
|
|
85
89
|
Doctor.usage
|
|
90
|
+
when "orchestration"
|
|
91
|
+
OrchestrationCommand.usage
|
|
92
|
+
when "artifact-report"
|
|
93
|
+
ArtifactReportCommand.usage
|
|
86
94
|
else
|
|
87
95
|
usage
|
|
88
96
|
end
|
|
@@ -103,6 +111,8 @@ module Harnex
|
|
|
103
111
|
harnex pane --id ID [options]
|
|
104
112
|
harnex agents-guide [topic]
|
|
105
113
|
harnex doctor
|
|
114
|
+
harnex orchestration sample|report [options]
|
|
115
|
+
harnex artifact-report init|validate PATH [options]
|
|
106
116
|
harnex help [command]
|
|
107
117
|
|
|
108
118
|
Commands:
|
|
@@ -121,6 +131,10 @@ module Harnex
|
|
|
121
131
|
agents-guide
|
|
122
132
|
Show agent dispatch, chain, buddy, monitoring, and naming guides
|
|
123
133
|
doctor Run preflight checks and optional read-only session sweep
|
|
134
|
+
orchestration
|
|
135
|
+
Emit external primary samples and report orchestration tax
|
|
136
|
+
artifact-report
|
|
137
|
+
Initialize or validate harnex.artifact_report.v1 sidecars
|
|
124
138
|
help Show command help
|
|
125
139
|
|
|
126
140
|
New to harnex? Start with: harnex guide
|
|
@@ -142,6 +156,9 @@ module Harnex
|
|
|
142
156
|
harnex pane --id main --lines 40
|
|
143
157
|
harnex agents-guide dispatch
|
|
144
158
|
harnex doctor
|
|
159
|
+
harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
|
|
160
|
+
harnex artifact-report init .harnex/reports/cx-i-60.json
|
|
161
|
+
harnex artifact-report validate .harnex/reports/cx-i-60.json --final
|
|
145
162
|
harnex send --id main --message "Summarize current progress."
|
|
146
163
|
TEXT
|
|
147
164
|
end
|