@giamme/forge 0.10.0 → 0.11.1
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/CHANGELOG.md +43 -0
- package/SKILL.md +4 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +13 -1
- package/docs/commands.md +11 -5
- package/docs/dataset.md +13 -0
- package/docs/decomposed-runs.md +13 -3
- package/docs/forge-ui.md +8 -3
- package/docs/memory.md +4 -2
- package/docs/reviews.md +73 -2
- package/forge +16 -1
- package/package.json +1 -1
- package/references/dataset.md +86 -4
- package/references/decompose.md +24 -6
- package/references/harnesses.md +43 -1
- package/references/ui.md +4 -1
- package/scripts/forge-config.py +5 -2
- package/scripts/forge-control.py +4 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +345 -17
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +132 -14
- package/scripts/forge-parallel.sh +96 -11
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +72 -12
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-ui.py +8 -4
- package/scripts/forge_dataset/__init__.py +7 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +23 -2
- package/scripts/forge_dataset/importer.py +20 -8
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +15 -10
- package/ui/README.md +5 -4
- package/ui/docs/api.md +88 -23
- package/ui/forge_ui/control.py +26 -5
- package/ui/forge_ui/reader.py +184 -11
- package/ui/forge_ui/static/app.js +24 -10
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/findingsview.js +65 -14
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +52 -10
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +48 -12
- package/ui/forge_ui/static/style.css +7 -3
package/ui/forge_ui/reader.py
CHANGED
|
@@ -42,6 +42,21 @@ def _kind(path):
|
|
|
42
42
|
return None
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _default_registry_dir():
|
|
46
|
+
"""forge's run registry, resolved the way forge writes it (scripts/forge_registry.registry_dir).
|
|
47
|
+
|
|
48
|
+
``XDG_CONFIG_HOME`` (``~`` expanded) when it is an absolute path, otherwise ``~/.config``: an
|
|
49
|
+
empty or relative value counts as unset (XDG spec), as it does when forge registers a run.
|
|
50
|
+
None only when even that is not absolute (a relative HOME): forge never registers a run
|
|
51
|
+
there, so reading it would only pick up whatever the current directory happens to hold.
|
|
52
|
+
"""
|
|
53
|
+
value = os.environ.get('XDG_CONFIG_HOME') or ''
|
|
54
|
+
base = Path(value).expanduser() if value else None
|
|
55
|
+
if base is None or not base.is_absolute():
|
|
56
|
+
base = Path.home() / '.config'
|
|
57
|
+
return base / 'forge' / 'runs' if base.is_absolute() else None
|
|
58
|
+
|
|
59
|
+
|
|
45
60
|
def discover(plans=(), roots=(), registry=True, registry_dir=None):
|
|
46
61
|
found, seen = [], set()
|
|
47
62
|
|
|
@@ -76,10 +91,9 @@ def discover(plans=(), roots=(), registry=True, registry_dir=None):
|
|
|
76
91
|
for path in roots:
|
|
77
92
|
scan(Path(path))
|
|
78
93
|
if registry:
|
|
79
|
-
base = Path(registry_dir) if registry_dir is not None else
|
|
80
|
-
os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'forge' / 'runs'
|
|
94
|
+
base = Path(registry_dir) if registry_dir is not None else _default_registry_dir()
|
|
81
95
|
try:
|
|
82
|
-
entries = sorted(base.glob('*.txt'))
|
|
96
|
+
entries = sorted(base.glob('*.txt')) if base is not None else []
|
|
83
97
|
except OSError:
|
|
84
98
|
entries = []
|
|
85
99
|
for entry in entries:
|
|
@@ -310,10 +324,14 @@ def _tasks(reader):
|
|
|
310
324
|
reader.warn(label, 'invalid task id')
|
|
311
325
|
continue
|
|
312
326
|
prefix = f'tasks/{ident}'
|
|
313
|
-
|
|
327
|
+
# One stat: the run list needs the mtime, and detail reads the same marker again.
|
|
328
|
+
merged = reader.stat(prefix + '/merged')
|
|
329
|
+
status = 'MERGED' if merged is not None else reader.word(prefix + '/status') or 'PENDING'
|
|
314
330
|
tasks.append(dict(id=ident, title='\t'.join(row[6:]), deps=_list(deps),
|
|
315
331
|
difficulty=difficulty, files=_list(files), dwarf=_nullable(dwarf),
|
|
316
|
-
qa=_nullable(qa), status=status
|
|
332
|
+
qa=_nullable(qa), status=status,
|
|
333
|
+
merged_at=float(merged.st_mtime) if merged is not None else None,
|
|
334
|
+
accepted=_nonempty(reader, prefix + '/accepted.by')))
|
|
317
335
|
return tasks
|
|
318
336
|
|
|
319
337
|
|
|
@@ -353,12 +371,15 @@ def _summary(reader, source):
|
|
|
353
371
|
final_review = reader.word('final-review.verdict')
|
|
354
372
|
dwarf_models = sorted({task['dwarf'] for task in tasks if task['dwarf']})
|
|
355
373
|
qa_models = sorted({task['qa'] for task in tasks if task['qa']})
|
|
374
|
+
# Before snapshotting `updated`: these reads are files the summary read, so a newer
|
|
375
|
+
# accepted.integration (or the published verdict / review.meta) moves the activity time.
|
|
376
|
+
final_review_outdated = _published_outdated(reader, tasks)
|
|
356
377
|
result.update(updated=reader.updated, created=_created(reader.path, _PLAN_START_FILES),
|
|
357
378
|
task_count=len(tasks), counts=counts,
|
|
358
379
|
task_statuses=[dict(id=task['id'], status=task['status'], difficulty=task['difficulty'] or None,
|
|
359
|
-
title=_brief(task['title'])) for task in tasks],
|
|
380
|
+
title=_brief(task['title']), accepted=task['accepted']) for task in tasks],
|
|
360
381
|
models=sorted(set(dwarf_models) | set(qa_models)), dwarf_models=dwarf_models, qa_models=qa_models,
|
|
361
|
-
final_review=final_review)
|
|
382
|
+
final_review=final_review, final_review_outdated=final_review_outdated)
|
|
362
383
|
return result, tasks
|
|
363
384
|
|
|
364
385
|
|
|
@@ -389,9 +410,10 @@ def _solo_summary(reader, source):
|
|
|
389
410
|
decompose_level=None, verification_status=None, updated=reader.updated,
|
|
390
411
|
created=_created(reader.path, _SOLO_START_FILES),
|
|
391
412
|
task_count=1, counts={status: 1},
|
|
392
|
-
task_statuses=[dict(id='solo', status=status, difficulty=None, title=_brief(goal or 'Solo run')
|
|
413
|
+
task_statuses=[dict(id='solo', status=status, difficulty=None, title=_brief(goal or 'Solo run'),
|
|
414
|
+
accepted=False)],
|
|
393
415
|
models=sorted({dwarf, qa} - {None}), dwarf_models=[dwarf] if dwarf else [],
|
|
394
|
-
qa_models=[qa] if qa else [], final_review=None)
|
|
416
|
+
qa_models=[qa] if qa else [], final_review=None, final_review_outdated=None)
|
|
395
417
|
|
|
396
418
|
|
|
397
419
|
def summarize(path, source='plan') -> dict:
|
|
@@ -502,6 +524,26 @@ def _shadows(reader, ident, task_execs):
|
|
|
502
524
|
return result
|
|
503
525
|
|
|
504
526
|
|
|
527
|
+
def _accepted_fields(reader, name, text):
|
|
528
|
+
"""(attempt, over) from an accepted.by body. A non-integer attempt= is null and warned.
|
|
529
|
+
|
|
530
|
+
status= FAIL or UNKNOWN is what was accepted over; any other or missing value is null
|
|
531
|
+
(a PASS record is an acceptance with nothing to say beyond "accepted").
|
|
532
|
+
"""
|
|
533
|
+
if not text:
|
|
534
|
+
return None, None
|
|
535
|
+
fields = executions.parse_resolved(text)
|
|
536
|
+
attempt = None
|
|
537
|
+
raw = fields.get('attempt')
|
|
538
|
+
if raw:
|
|
539
|
+
try:
|
|
540
|
+
attempt = int(raw)
|
|
541
|
+
except (ValueError, OverflowError):
|
|
542
|
+
reader.warn(name, 'attempt is not an integer')
|
|
543
|
+
status = fields.get('status')
|
|
544
|
+
return attempt, status if status in ('FAIL', 'UNKNOWN') else None
|
|
545
|
+
|
|
546
|
+
|
|
505
547
|
def _task_detail(reader, task, run_id, waves, branches):
|
|
506
548
|
"""Fill in a plan task; returns every execution it read (its own and its shadows')."""
|
|
507
549
|
ident = task['id']
|
|
@@ -520,6 +562,8 @@ def _task_detail(reader, task, run_id, waves, branches):
|
|
|
520
562
|
task['qa_tail'] = reader.cap(qa, prefix + '/qa_tail', 4000, True)
|
|
521
563
|
task['dwarf_tail'] = reader.cap(reader.text(prefix + '/dwarf.last'), prefix + '/dwarf_tail', 4000, True)
|
|
522
564
|
task['accepted_by'] = reader.text(prefix + '/accepted.by')
|
|
565
|
+
task['accepted_attempt'], task['accepted_over'] = _accepted_fields(
|
|
566
|
+
reader, prefix + '/accepted.by', task['accepted_by'])
|
|
523
567
|
for field, filename in [('prompt', 'prompt.md'), ('approach', 'approach.md'), ('retry_findings', 'retry_findings.md')]:
|
|
524
568
|
task[field] = reader.cap(reader.text(prefix + '/' + filename), prefix + '/' + field)
|
|
525
569
|
task['route'] = []
|
|
@@ -606,6 +650,120 @@ def _published(reader):
|
|
|
606
650
|
return real if executions.inside(reader.path, real) else None
|
|
607
651
|
|
|
608
652
|
|
|
653
|
+
def _review_clock(reader, name):
|
|
654
|
+
"""(source, at) of a `final-review-<e>-<p>` or `integrate-<e>-<p>` dir name, else None.
|
|
655
|
+
|
|
656
|
+
`at` is the whole-second epoch in the name. The same clock `executions.review_dirs` uses,
|
|
657
|
+
so a summary does not have to scan the plan to know when the published review started.
|
|
658
|
+
"""
|
|
659
|
+
match = executions._KIND.fullmatch(name)
|
|
660
|
+
if not match:
|
|
661
|
+
return None
|
|
662
|
+
kind, epoch, _pid = match.groups()
|
|
663
|
+
if len(epoch) > 15:
|
|
664
|
+
reader.warn(name, 'invalid epoch')
|
|
665
|
+
return None
|
|
666
|
+
try:
|
|
667
|
+
at = float(epoch)
|
|
668
|
+
if not math.isfinite(at):
|
|
669
|
+
raise ValueError()
|
|
670
|
+
except (ValueError, OverflowError):
|
|
671
|
+
reader.warn(name, 'invalid epoch')
|
|
672
|
+
return None
|
|
673
|
+
return ('review' if kind == 'final-review' else 'integrate'), at
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _published_identity(reader):
|
|
677
|
+
"""(source, at) of the dir `final-review` links to, when the link stays inside the plan."""
|
|
678
|
+
real = _published(reader)
|
|
679
|
+
if real is None:
|
|
680
|
+
return None
|
|
681
|
+
path = Path(real)
|
|
682
|
+
# An integrate review is `<integrate-dir>/final-review`; a final-review dir is named itself.
|
|
683
|
+
if path.name == 'final-review':
|
|
684
|
+
found = _review_clock(reader, path.parent.name)
|
|
685
|
+
return found if found is not None and found[0] == 'integrate' else None
|
|
686
|
+
found = _review_clock(reader, path.name)
|
|
687
|
+
return found if found is not None and found[0] == 'review' else None
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _merge_pairs(tasks):
|
|
691
|
+
"""(id, merged_at) of every merged task, oldest merge first, then id."""
|
|
692
|
+
pairs = [(task['id'], task['merged_at']) for task in tasks if task.get('merged_at') is not None]
|
|
693
|
+
pairs.sort(key=lambda item: (item[1], item[0]))
|
|
694
|
+
return pairs
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# What `git rev-parse` writes for a commit: sha1 (40) or sha256 (64), lowercase hex.
|
|
698
|
+
_COMMIT = re.compile(r'(?:[0-9a-f]{40}|[0-9a-f]{64})')
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _meta_integration(reader, name, meta):
|
|
702
|
+
"""The `integration=` commit of a review.meta, or None.
|
|
703
|
+
|
|
704
|
+
Missing or empty is absent (older runs record no integration commit). Any other value
|
|
705
|
+
that is not a commit object name is malformed: null, and a warning, so freshness uses
|
|
706
|
+
the next rule instead of treating the text as the commit the review judged.
|
|
707
|
+
"""
|
|
708
|
+
value = meta.get('integration') or None
|
|
709
|
+
if not value:
|
|
710
|
+
return None
|
|
711
|
+
if _COMMIT.fullmatch(value):
|
|
712
|
+
return value
|
|
713
|
+
reader.warn(name, 'integration is not a commit')
|
|
714
|
+
return None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _review_freshness(accepted, integration, tip, source, at, merges, verdict):
|
|
718
|
+
"""(outdated, merged_since) for one final review.
|
|
719
|
+
|
|
720
|
+
Null when the review has no verdict or there is no `accepted.integration` to compare with.
|
|
721
|
+
Otherwise the first rule that applies: a known `integration` commit, else `tip` when
|
|
722
|
+
`source` is `review`, else (an older integrate review) any task merged after `at`.
|
|
723
|
+
`integration` is a commit object name or None (a malformed value is None). A merge counts
|
|
724
|
+
when its marker mtime is `>= at + 1`. `merged_since` is those ids, oldest first, and is
|
|
725
|
+
empty unless the review is outdated.
|
|
726
|
+
"""
|
|
727
|
+
later = []
|
|
728
|
+
if at is not None:
|
|
729
|
+
later = [item for item in merges if item[1] is not None and item[1] >= at + 1]
|
|
730
|
+
later.sort(key=lambda item: (item[1], item[0]))
|
|
731
|
+
ids = [task_id for task_id, _merged_at in later]
|
|
732
|
+
if not verdict or not accepted:
|
|
733
|
+
return None, []
|
|
734
|
+
if integration:
|
|
735
|
+
moved = integration != accepted
|
|
736
|
+
elif source == 'review':
|
|
737
|
+
moved = tip != accepted
|
|
738
|
+
else:
|
|
739
|
+
moved = bool(ids)
|
|
740
|
+
return (True, ids) if moved else (False, [])
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _stamp_review(summary, accepted, merges):
|
|
744
|
+
summary['outdated'], summary['merged_since'] = _review_freshness(
|
|
745
|
+
accepted, summary['integration'], summary['tip'], summary['source'], summary['at'], merges,
|
|
746
|
+
summary['verdict'])
|
|
747
|
+
return summary
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _published_outdated(reader, tasks):
|
|
751
|
+
"""final_review_outdated for the published review only.
|
|
752
|
+
|
|
753
|
+
Reads `accepted.integration` and, through the `final-review` link, that dir's `verdict`
|
|
754
|
+
and `review.meta`. Does not scan other review dirs or open a review's attempts.
|
|
755
|
+
"""
|
|
756
|
+
found = _published_identity(reader)
|
|
757
|
+
if found is None:
|
|
758
|
+
return None
|
|
759
|
+
source, at = found
|
|
760
|
+
verdict = reader.word('final-review/verdict')
|
|
761
|
+
meta = executions.parse_resolved(reader.text('final-review/review.meta'))
|
|
762
|
+
return _review_freshness(reader.line('accepted.integration'),
|
|
763
|
+
_meta_integration(reader, 'final-review/review.meta', meta),
|
|
764
|
+
meta.get('tip') or None, source, at, _merge_pairs(tasks), verdict)[0]
|
|
765
|
+
|
|
766
|
+
|
|
609
767
|
def _final_review(reader, item, newest, published):
|
|
610
768
|
"""(FinalReviewSummary, its executions, review.meta) of one review dir."""
|
|
611
769
|
name, source, at, rel = item
|
|
@@ -630,6 +788,8 @@ def _final_review(reader, item, newest, published):
|
|
|
630
788
|
verdict=verdict, state=state, spec=meta.get('spec') or None,
|
|
631
789
|
threshold=None if threshold == 'none' else threshold,
|
|
632
790
|
base=meta.get('base') or None, tip=meta.get('tip') or None,
|
|
791
|
+
integration=_meta_integration(reader, rel + '/review.meta', meta),
|
|
792
|
+
outdated=None, merged_since=[],
|
|
633
793
|
gate=_gate(reader, rel + '/qa.gate'), findings=executions.qa_result(reader, rel + '/qa.last')[1],
|
|
634
794
|
execution=run['id'] if run else None, started=run['started'] if run else None,
|
|
635
795
|
ended=run['ended'] if run else None, duration_s=run['duration_s'] if run else None)
|
|
@@ -701,6 +861,7 @@ def _solo_detail(reader, source):
|
|
|
701
861
|
dwarf=_role_spec(reader, 'dwarf'), qa=_role_spec(reader, 'qa'), wave=1, status=status,
|
|
702
862
|
branch=None, attempt=max((item['attempt'] for item in attempts), default=None),
|
|
703
863
|
attempts=attempts, escalations=[], verdict=_verdict(qa), accepted_by=None,
|
|
864
|
+
accepted=False, accepted_attempt=None, accepted_over=None,
|
|
704
865
|
explored=False, route=[], clef={'first_pass': None, 'retry_pass': {}}, shadow=[],
|
|
705
866
|
prompt=reader.cap(reader.text('prompt.md'), 'tasks/solo/prompt'), approach=None,
|
|
706
867
|
retry_findings=None, qa_tail=reader.cap(qa, 'tasks/solo/qa_tail', 4000, True),
|
|
@@ -767,6 +928,10 @@ def detail(path, source='plan') -> dict:
|
|
|
767
928
|
result['timeline'] = _timeline(pool + run_execs, [item for item in run_execs if item['role'] == 'parallel'],
|
|
768
929
|
[item for item in run_execs if item['role'] == 'verification'])
|
|
769
930
|
result['integration'] = _integration(reader, tasks, integrations, reviews, published)
|
|
931
|
+
accepted = result['integration']['accepted'] if result['integration'] is not None else None
|
|
932
|
+
merges = [(item['id'], item['at']) for item in result['integration']['merged']] if result['integration'] else []
|
|
933
|
+
for summary in reviews:
|
|
934
|
+
_stamp_review(summary, accepted, merges)
|
|
770
935
|
result['final_review'] = reviews[0] if reviews else None
|
|
771
936
|
result['final_reviews'] = reviews
|
|
772
937
|
result['deferrals'] = [line.strip() for line in (reader.text('deferrals.txt') or '').splitlines() if line.strip()]
|
|
@@ -943,6 +1108,7 @@ def final_review_detail(path, name, source='plan'):
|
|
|
943
1108
|
if index is None:
|
|
944
1109
|
return None
|
|
945
1110
|
summary, _, meta = _final_review(reader, items[index], index == 0, _published(reader))
|
|
1111
|
+
_stamp_review(summary, reader.line('accepted.integration'), _merge_pairs(_tasks(reader)))
|
|
946
1112
|
rel = items[index][3]
|
|
947
1113
|
qa_last = reader.text(rel + '/qa.last')
|
|
948
1114
|
result = dict(summary)
|
|
@@ -1215,8 +1381,13 @@ def run_findings(path, source='plan'):
|
|
|
1215
1381
|
if kind is None:
|
|
1216
1382
|
return None
|
|
1217
1383
|
keys = ('kind', 'execution', 'attempt', 'spec', 'verdict')
|
|
1384
|
+
if kind == 'solo':
|
|
1385
|
+
task_ids, merges, accepted = ['solo'], [], None
|
|
1386
|
+
else:
|
|
1387
|
+
rows = _tasks(reader)
|
|
1388
|
+
task_ids, merges, accepted = [task['id'] for task in rows], _merge_pairs(rows), reader.line('accepted.integration')
|
|
1218
1389
|
tasks = []
|
|
1219
|
-
for task_id in
|
|
1390
|
+
for task_id in task_ids:
|
|
1220
1391
|
ctx = _diff_task(reader, task_id)
|
|
1221
1392
|
reviews = [dict({key: review[key] for key in keys}, findings=found)
|
|
1222
1393
|
for review in (ctx['reviews'] if ctx else []) for found in [_findings_of(reader, review)] if found]
|
|
@@ -1227,13 +1398,15 @@ def run_findings(path, source='plan'):
|
|
|
1227
1398
|
items, published = executions.review_dirs(reader)[0], _published(reader)
|
|
1228
1399
|
for index, item in enumerate(items[:_MAX_REVIEWS]):
|
|
1229
1400
|
summary = _final_review(reader, item, index == 0, published)[0]
|
|
1401
|
+
_stamp_review(summary, accepted, merges)
|
|
1230
1402
|
# the same review final_review_diff pins, so a Finding's id opens it there
|
|
1231
1403
|
review = _diff_review('final', None, None, summary['spec'], summary['verdict'], item[3] + '/qa.last', [],
|
|
1232
1404
|
summary['execution'])
|
|
1233
1405
|
found = _findings_of(reader, review)
|
|
1234
1406
|
if found:
|
|
1235
1407
|
finals.append(dict(name=item[0], execution=summary['execution'], spec=summary['spec'],
|
|
1236
|
-
verdict=summary['verdict'],
|
|
1408
|
+
verdict=summary['verdict'], outdated=summary['outdated'],
|
|
1409
|
+
merged_since=summary['merged_since'], findings=found))
|
|
1237
1410
|
return dict(run=run_key(reader.path), available=findings.available(), tasks=tasks, final_reviews=finals,
|
|
1238
1411
|
truncated=reader.truncated, warnings=reader.warnings)
|
|
1239
1412
|
|
|
@@ -19,7 +19,7 @@ window.ForgeApp = (() => {
|
|
|
19
19
|
replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
|
|
20
20
|
lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
|
|
21
21
|
settings: false, lastKey: null, settingsRun: null, settingsPickedAt: null, settingsView: null,
|
|
22
|
-
models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
|
|
22
|
+
models: false, modelsData: null, modelsText: null, modelsError: null, reviewerOutcomes: null, modelsPrefs: store.get('models', {}),
|
|
23
23
|
files: new Map(), findingsCount: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
|
|
24
24
|
findings: new Map() };
|
|
25
25
|
state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
|
|
@@ -106,9 +106,9 @@ window.ForgeApp = (() => {
|
|
|
106
106
|
function link(text, key, sel) { const a = el('a', text); a.href = hashFor(key, sel); return a; }
|
|
107
107
|
|
|
108
108
|
const statuses = new Set(['merged', 'pass', 'running', 'pending', 'fail', 'error', 'infra',
|
|
109
|
-
'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict']);
|
|
110
|
-
function chip(value) {
|
|
111
|
-
const status = String(value || 'unknown').toLowerCase();
|
|
109
|
+
'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict', 'accepted', 'outdated']);
|
|
110
|
+
function chip(value, cls) {
|
|
111
|
+
const status = String(cls || value || 'unknown').toLowerCase();
|
|
112
112
|
return el('span', value || '—', 'chip st-' + (statuses.has(status) ? status : 'unknown'));
|
|
113
113
|
}
|
|
114
114
|
function time(value) {
|
|
@@ -251,7 +251,8 @@ window.ForgeApp = (() => {
|
|
|
251
251
|
if (!solo) {
|
|
252
252
|
const verification = el('span', undefined, 'verification');
|
|
253
253
|
verification.append(document.createTextNode('Verification '), chip(run.verification_status));
|
|
254
|
-
|
|
254
|
+
const reviewStatus = RM.runReviewStatus(run);
|
|
255
|
+
if (reviewStatus) verification.append(document.createTextNode(' Review '), chip(reviewStatus));
|
|
255
256
|
item.append(verification);
|
|
256
257
|
}
|
|
257
258
|
$('run-list').append(item);
|
|
@@ -338,7 +339,7 @@ window.ForgeApp = (() => {
|
|
|
338
339
|
add('Kind', sel('kind', [['', 'any'], ['plan', 'plan'], ['solo', 'solo']]));
|
|
339
340
|
add('Model', sel('model', [['', 'any'], ...opts.models.map(m => [m, m])]));
|
|
340
341
|
add('Age', sel('ageDays', [[0, 'any'], [1, 'last day'], [7, 'last week'], [30, 'last month']]));
|
|
341
|
-
add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']]));
|
|
342
|
+
add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['OUTDATED', 'outdated'], ['none', 'no verdict']]));
|
|
342
343
|
const all = el('input'); all.type = 'checkbox'; all.checked = state.showAll; all.dataset.f = 'showAll';
|
|
343
344
|
const allL = el('label', undefined, 'filter check'); allL.append(all, document.createTextNode(' older runs too')); bar.append(allL);
|
|
344
345
|
const reset = el('button', 'Reset'); reset.type = 'button'; reset.addEventListener('click', () => { Object.assign(state.mapFilter, { text: '', state: '', repo: '', kind: '', model: '', ageDays: 0, verdict: '' }); state.showAll = false; filterChanged(true); });
|
|
@@ -368,7 +369,12 @@ window.ForgeApp = (() => {
|
|
|
368
369
|
card.className = `map-card st-${({ live: 'running', failed: 'fail', done: 'merged', pending: 'pending' })[m.state]}${m.match ? '' : ' dim'}`;
|
|
369
370
|
card.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
|
|
370
371
|
const leds = el('span', undefined, 'leds');
|
|
371
|
-
for (const t of m.tasks.slice(0, 60)) {
|
|
372
|
+
for (const t of m.tasks.slice(0, 60)) {
|
|
373
|
+
const status = RM.taskStatus(t);
|
|
374
|
+
const l = el('i', undefined, 'led st-' + String(status || 'pending').toLowerCase());
|
|
375
|
+
l.title = `${t.id}: ${status}`;
|
|
376
|
+
leds.append(l);
|
|
377
|
+
}
|
|
372
378
|
card.append(leds, el('span', relative(r.updated), 'muted'));
|
|
373
379
|
grid.append(card);
|
|
374
380
|
}
|
|
@@ -474,15 +480,23 @@ window.ForgeApp = (() => {
|
|
|
474
480
|
runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
|
|
475
481
|
savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
|
|
476
482
|
tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
|
|
477
|
-
modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
|
|
483
|
+
modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias), reviewerOutcomes: state.reviewerOutcomes,
|
|
478
484
|
});
|
|
479
485
|
}
|
|
486
|
+
// How each reviewer's PASSed work held up (forge dataset reviewers). Advisory and optional: read once per
|
|
487
|
+
// change of the models data, and a failure (an export, no dataset) only leaves the column empty.
|
|
488
|
+
function loadReviewerOutcomes() {
|
|
489
|
+
if (SNAP) return;
|
|
490
|
+
fetchJSON('/api/models/reviewers').then(result => { state.reviewerOutcomes = result.data; },
|
|
491
|
+
() => { state.reviewerOutcomes = null; }).then(() => { if (state.models) renderModels(); });
|
|
492
|
+
}
|
|
480
493
|
function loadModels() {
|
|
481
494
|
return request('/api/models', result => {
|
|
482
495
|
state.modelsError = null;
|
|
483
496
|
if (result.text === state.modelsText) return;
|
|
484
497
|
state.modelsText = result.text; state.modelsData = result.data;
|
|
485
498
|
if (state.models) renderModels();
|
|
499
|
+
loadReviewerOutcomes();
|
|
486
500
|
}, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
|
|
487
501
|
}
|
|
488
502
|
|
|
@@ -741,11 +755,11 @@ window.ForgeApp = (() => {
|
|
|
741
755
|
{ id: 'title', label: 'Title', value: t => t.title },
|
|
742
756
|
{ id: 'difficulty', label: 'Difficulty', value: t => DIFF_RANK[t.difficulty] ?? null, cell: t => display(t.difficulty) },
|
|
743
757
|
{ id: 'wave', label: 'Wave', value: t => Number.isFinite(Number(t.wave)) && t.wave !== null && t.wave !== '' ? Number(t.wave) : null, cell: t => display(t.wave) },
|
|
744
|
-
{ id: 'status', label: 'Status', value: t => t
|
|
758
|
+
{ id: 'status', label: 'Status', value: t => RM.taskStatus(t), cell: t => chip(RM.taskStatus(t)) },
|
|
745
759
|
{ id: 'dwarf', label: 'Dwarf', value: t => t.dwarf, cell: t => window.ForgeIcons.modelPlate(t.dwarf) },
|
|
746
760
|
{ id: 'qa', label: 'QA', value: t => t.qa, cell: t => window.ForgeIcons.modelPlate(t.qa) },
|
|
747
761
|
{ id: 'attempt', label: 'Attempt', value: t => Number(t.attempt) || null, cell: t => display(t.attempt) },
|
|
748
|
-
{ id: 'verdict', label: 'Verdict', value: t => t.
|
|
762
|
+
{ id: 'verdict', label: 'Verdict', value: t => RM.taskVerdict(t).text, cell: t => { const v = RM.taskVerdict(t); return chip(v.text, v.status); } },
|
|
749
763
|
{ id: 'findings', label: 'Findings', title: 'The worst confirmed severity and how many; ~n plausible only',
|
|
750
764
|
value: t => t.findings && t.findings.worst ? SEV_RANK[t.findings.worst] * 1000 - (t.findings[t.findings.worst] || 0) : t.findings && t.findings.plausible ? 4000 : null,
|
|
751
765
|
cell: t => { const f = t.findings, v = sev(f); if (!f || !f.worst) return v; const n = el('span', v, 'sev-chip sev-' + f.worst); return n; } },
|
|
@@ -427,9 +427,12 @@ export function createCloud(E, host, cb = {}) {
|
|
|
427
427
|
function info(el) {
|
|
428
428
|
const f = el.f, r = f.run, counts = Object.entries(r.counts || {}).map(([s, n]) => `${s.toLowerCase()} ${n}`).join(' · ');
|
|
429
429
|
const proj = st.L && st.L.projects.find(p => p.id === f.project);
|
|
430
|
+
const verdict = r.final_review && typeof r.final_review === 'object' ? r.final_review.verdict : r.final_review;
|
|
431
|
+
const isOutdated = verdict && RM.runReviewStatus(r) === 'OUTDATED';
|
|
432
|
+
const reviewText = verdict ? (isOutdated ? `${verdict} (outdated)` : verdict) : null;
|
|
430
433
|
return [['meta', `${proj ? proj.name : RM.basename(r.repo)} · ${r.run_id || ''} · ${r.kind}`], ['title', String(r.goal || 'Untitled run').split('\n')[0]],
|
|
431
434
|
['body', `${f.n} task${f.n > 1 ? 's' : ''} · ${counts}`], ['body', `${[...f.dwarf].join(', ') || '—'} → ${[...f.qa].join(', ') || '—'}`],
|
|
432
|
-
['foot', `started ${new Date(f.t * 1000).toLocaleString()}${
|
|
435
|
+
['foot', `started ${new Date(f.t * 1000).toLocaleString()}${reviewText ? ' · review ' + reviewText : ''}${r.verification_status ? ' · verify ' + r.verification_status : ''}`],
|
|
433
436
|
['foot', (store.layouts[st.layout] || {})[el.key] ? 'moved by you · drag to move, Shift = depth, Alt = height' : 'drag to move · double-click to open']];
|
|
434
437
|
}
|
|
435
438
|
function showTip(el, cx, cy) {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
const aliases = specs => new Set(list(specs).map(RM.alias).filter(Boolean));
|
|
64
64
|
return { key: run.key, run, project: hashId(run.repo || '?'), n, bucket: bucket(n), diff, dominant, state, t: startOf(run),
|
|
65
65
|
dwarf: aliases(run.dwarf_models || run.models), qa: aliases(run.qa_models || []), all: aliases(run.models),
|
|
66
|
-
review: run
|
|
66
|
+
review: RM.runReviewStatus(run) || null, verification: run.verification_status || null, decompose: run.decompose_level || null, kind: run.kind };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/* ---------- filters: the cloud shows exactly the runs that match ---------- */
|
|
@@ -254,7 +254,7 @@ window.ForgeCloudUI = (() => {
|
|
|
254
254
|
}
|
|
255
255
|
const more = el('fieldset', undefined, 'cloud-field cloud-more'); more.append(el('legend', 'More'));
|
|
256
256
|
more.append(select('Active', 'active', [[0, 'any time'], [1, 'in the last day'], [7, 'in the last week'], [30, 'in the last month'], [90, 'in the last 3 months']], () => f.activeDays, v => { f.activeDays = Number(v) || 0; }));
|
|
257
|
-
more.append(select('Final review', 'review', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']], () => f.review, v => { f.review = v; }));
|
|
257
|
+
more.append(select('Final review', 'review', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['OUTDATED', 'outdated'], ['none', 'no verdict']], () => f.review, v => { f.review = v; }));
|
|
258
258
|
more.append(select('Verification', 'verification', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'not run']], () => f.verification, v => { f.verification = v; }));
|
|
259
259
|
const dec = Object.keys(O.decompose);
|
|
260
260
|
if (dec.length > 1) more.append(chips('Decompose', dec.map(k => [k, k === 'none' ? 'off' : k, O.decompose[k]]), () => f.decompose, v => { f.decompose = v; }));
|
|
@@ -290,13 +290,22 @@ window.ForgeCloudUI = (() => {
|
|
|
290
290
|
head.append(shut);
|
|
291
291
|
card.append(head, el('h2', String(r.goal || 'Untitled run').split('\n')[0]), el('p', r.run_id || '', 'mono muted'));
|
|
292
292
|
const leds = el('span', undefined, 'leds');
|
|
293
|
-
for (const t of list(r.task_statuses).slice(0, 120)) {
|
|
293
|
+
for (const t of list(r.task_statuses).slice(0, 120)) {
|
|
294
|
+
const st = RM.taskStatus(t);
|
|
295
|
+
const l = el('i', undefined, 'led st-' + String(st || 'pending').toLowerCase());
|
|
296
|
+
l.title = `${t.id}: ${st}${t.title ? ' · ' + t.title : ''}`;
|
|
297
|
+
leds.append(l);
|
|
298
|
+
}
|
|
294
299
|
card.append(leds);
|
|
295
300
|
const dl = el('dl', undefined, 'fields');
|
|
296
301
|
const mix = ['high', 'medium', 'low', 'none'].filter(k => f.diff[k]).map(k => `${f.diff[k]} ${k === 'none' ? 'unset' : k}`).join(' · ');
|
|
297
302
|
const plates = specs => { if (!specs.length || !window.ForgeIcons) return specs.join(', ') || '—'; const s = el('span', undefined, 'plates'); for (const x of specs) s.append(window.ForgeIcons.modelPlate(x)); return s; };
|
|
303
|
+
const verdict = r.final_review && typeof r.final_review === 'object' ? r.final_review.verdict : r.final_review;
|
|
304
|
+
const isOutdated = verdict && RM.runReviewStatus(r) === 'OUTDATED';
|
|
305
|
+
const reviewText = verdict ? (isOutdated ? `${verdict} (outdated)` : verdict) : '—';
|
|
306
|
+
const reviewVal = isOutdated ? el('span', reviewText, 'chip st-outdated') : reviewText;
|
|
298
307
|
for (const [k, v, mono] of [['Tasks', `${f.n}${mix ? ' · ' + mix : ''}`], ['Implementers', plates([...f.dwarf])], ['Reviewers', plates([...f.qa])],
|
|
299
|
-
['Started', when(r.created)], ['Updated', when(r.updated)], ['Final review',
|
|
308
|
+
['Started', when(r.created)], ['Updated', when(r.updated)], ['Final review', reviewVal], ['Verification', r.verification_status || '—'], ['Decompose', r.decompose_level || 'off']]) {
|
|
300
309
|
const dd = el('dd', undefined, mono ? 'mono' : ''); dd.append(v instanceof Node ? v : document.createTextNode(String(v))); dl.append(el('dt', k), dd);
|
|
301
310
|
}
|
|
302
311
|
card.append(dl);
|
|
@@ -345,7 +354,7 @@ window.ForgeCloudUI = (() => {
|
|
|
345
354
|
a.href = '#/runs/' + encodeURIComponent(r.key);
|
|
346
355
|
a.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''} · ${f.n} task${f.n === 1 ? '' : 's'}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
|
|
347
356
|
const leds = el('span', undefined, 'leds');
|
|
348
|
-
for (const t of list(r.task_statuses).slice(0, 60)) leds.append(el('i', undefined, 'led st-' + String(t
|
|
357
|
+
for (const t of list(r.task_statuses).slice(0, 60)) leds.append(el('i', undefined, 'led st-' + String(RM.taskStatus(t) || 'pending').toLowerCase()));
|
|
349
358
|
a.append(leds); grid.append(a);
|
|
350
359
|
}
|
|
351
360
|
g.append(grid); listHost.append(g);
|