@surlymochan/evalid 0.5.19 → 0.5.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/src/server.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from './core/mission-state.js';
24
24
  import { mutateStore, readStore, resolveDataFile } from './core/state-store.js';
25
25
  import { readExecutionLog, resolveExecutionLogPath } from './core/execution-log.js';
26
+ import { readEntriesFromOffset, readExecutionLogSize, resolveEntryOffset } from './core/log-tail.js';
26
27
  import { ackPendingGate, listPendingGates } from './core/pending-gates.js';
27
28
  import { prepareWorkflowRerun } from './core/workflow-rerun.js';
28
29
  import { readBinding } from './core/bindings.js';
@@ -140,7 +141,11 @@ function sendHtml(response, statusCode, html) {
140
141
  response.end(html);
141
142
  }
142
143
 
143
- function sendEvent(response, event, payload) {
144
+ function sendEvent(response, event, payload, id) {
145
+ if (response.writableEnded || response.destroyed) return;
146
+ // `id` is optional: entries written by older builds carry no entry_hash, and an empty id line
147
+ // would make the browser replay from a meaningless position.
148
+ if (id) response.write(`id: ${id}\n`);
144
149
  response.write(`event: ${event}\n`);
145
150
  response.write(`data: ${JSON.stringify(payload)}\n\n`);
146
151
  }
@@ -526,10 +531,22 @@ function renderDashboardHtml() {
526
531
  '</div>'
527
532
  )).join('') || '<div class="meta">No pending gates</div>';
528
533
  }
529
- function renderActivity(entries) {
530
- document.getElementById('activity').innerHTML = entries.map((entry) => (
531
- '<li><div><strong>' + (entry.atom_id || entry.kind || 'event') + '</strong></div>' +
532
- '<div class="meta">' + [
534
+ let activityEntries = [];
535
+ // Hashes already on screen. Seeded from the snapshot as well as from log events: an
536
+ // entry can arrive twice, once inside the snapshot and once as a stream event written
537
+ // between the snapshot read and the point where the stream started.
538
+ const seenHashes = new Set();
539
+ function describeEntry(entry) {
540
+ if (entry.kind === 'agent_event') {
541
+ const detail = entry.text || entry.args_preview || entry.reason || '';
542
+ return {
543
+ title: entry.event + (entry.tool ? ' · ' + entry.tool : ''),
544
+ meta: [entry.phase, entry.atom, detail ? String(detail).slice(0, 120) : '', entry.timestamp],
545
+ };
546
+ }
547
+ return {
548
+ title: entry.atom_id || entry.kind || 'event',
549
+ meta: [
533
550
  entry.phase_id,
534
551
  entry.status,
535
552
  entry.adapter,
@@ -537,8 +554,44 @@ function renderDashboardHtml() {
537
554
  entry.artifact_type,
538
555
  entry.parallel_weight ? 'weight ' + entry.parallel_weight : '',
539
556
  entry.timestamp,
540
- ].filter(Boolean).join(' · ') + '</div></li>'
541
- )).join('') || '<li class="meta">No execution activity yet</li>';
557
+ ],
558
+ };
559
+ }
560
+ function paintActivity() {
561
+ document.getElementById('activity').innerHTML = activityEntries.map((entry) => {
562
+ const view = describeEntry(entry);
563
+ return '<li><div><strong>' + view.title + '</strong></div>' +
564
+ '<div class="meta">' + view.meta.filter(Boolean).join(' · ') + '</div></li>';
565
+ }).join('') || '<li class="meta">No execution activity yet</li>';
566
+ }
567
+ // A snapshot carries entries a log event may already have painted. Merging by entry_hash
568
+ // instead of replacing the list keeps anything that arrived while the snapshot was in
569
+ // flight (replacing it made those rows disappear again for up to one snapshot interval).
570
+ // Entries already on screen stay first: they are strictly newer than anything the snapshot
571
+ // adds, so this preserves file order without trusting timestamps.
572
+ function renderActivity(entries, options) {
573
+ const incoming = entries.slice(0, 20);
574
+ for (const entry of incoming) {
575
+ if (entry.entry_hash) seenHashes.add(entry.entry_hash);
576
+ }
577
+ if (options && options.merge) {
578
+ const merged = activityEntries.slice();
579
+ const seen = new Set(merged.map((entry) => entry.entry_hash).filter(Boolean));
580
+ for (const entry of incoming) {
581
+ if (entry.entry_hash && seen.has(entry.entry_hash)) continue;
582
+ if (entry.entry_hash) seen.add(entry.entry_hash);
583
+ merged.push(entry);
584
+ }
585
+ activityEntries = merged.slice(0, 20);
586
+ } else {
587
+ activityEntries = incoming;
588
+ }
589
+ paintActivity();
590
+ }
591
+ function appendActivity(entry) {
592
+ activityEntries.unshift(entry);
593
+ if (activityEntries.length > 20) activityEntries.length = 20;
594
+ paintActivity();
542
595
  }
543
596
  async function refreshFallback() {
544
597
  const [summaryRes, gatesRes, logsRes] = await Promise.all([
@@ -617,7 +670,15 @@ function renderDashboardHtml() {
617
670
  const payload = JSON.parse(event.data);
618
671
  renderSummary(payload.summary);
619
672
  renderGates(payload.pending_gates || []);
620
- renderActivity(payload.execution_log?.entries || []);
673
+ renderActivity(payload.execution_log?.entries || [], { merge: true });
674
+ });
675
+ events.addEventListener('log', (event) => {
676
+ const entry = JSON.parse(event.data);
677
+ if (entry.entry_hash) {
678
+ if (seenHashes.has(entry.entry_hash)) return;
679
+ seenHashes.add(entry.entry_hash);
680
+ }
681
+ appendActivity(entry);
621
682
  });
622
683
  events.onerror = () => {
623
684
  events.close();
@@ -816,22 +877,68 @@ export default async function startServer(options = {}) {
816
877
  });
817
878
  response.write('\n');
818
879
 
880
+ let closed = false;
881
+ let offset = null;
882
+
819
883
  const sendSnapshot = async () => {
820
884
  const nextState = await readStore(dataFile);
821
885
  sendEvent(response, 'snapshot', await buildSnapshot(projectRoot, nextState));
822
886
  };
823
887
 
888
+ // Append-only tail of the execution log. Each entry is one `log` event whose SSE id is the
889
+ // entry hash, so a reconnecting client resumes from Last-Event-ID instead of re-reading.
890
+ const sendNewEntries = async () => {
891
+ const batch = await readEntriesFromOffset(projectRoot, offset, { limit: 100 });
892
+ offset = batch.nextOffset;
893
+ for (const entry of batch.entries) {
894
+ sendEvent(response, 'log', entry, entry.entry_hash);
895
+ }
896
+ };
897
+
898
+ const lastEventId = String(request.headers['last-event-id'] || '').trim();
899
+ if (lastEventId) {
900
+ const resumed = await resolveEntryOffset(projectRoot, lastEventId);
901
+ if (resumed !== null) offset = resumed;
902
+ }
903
+
904
+ if (offset === null) {
905
+ // A fresh client already has history in the snapshot, so follow only entries from here on.
906
+ // The offset is taken before the snapshot is written: anything that lands in between is
907
+ // tailed right after it and deduplicated client-side by entry_hash, so nothing is missed.
908
+ offset = await readExecutionLogSize(projectRoot);
909
+ }
910
+
911
+ // Snapshot stays the first frame: the dashboard relies on it for summary and gates.
912
+ await sendSnapshot();
913
+ await sendNewEntries().catch(() => {});
914
+
915
+ const tail = async () => {
916
+ if (closed) return;
917
+ try {
918
+ await sendNewEntries();
919
+ } catch {
920
+ // A transient read failure must not kill the stream; the next tick retries.
921
+ }
922
+ };
923
+
824
924
  const heartbeat = setInterval(() => {
825
- response.write(': ping\n\n');
925
+ if (!closed) response.write(': ping\n\n');
826
926
  }, 15000);
827
927
  const timer = setInterval(() => {
828
- sendSnapshot().catch(() => {});
928
+ if (!closed) sendSnapshot().catch(() => {});
829
929
  }, 3000);
930
+ // One tailer per connection: the read is a stat plus a short read from the last offset, so a
931
+ // dashboard tab costs a few hundred bytes per tick. If many clients ever watch the same run,
932
+ // the cheaper shape is one shared tailer broadcasting to all responses.
933
+ const tailer = setInterval(() => {
934
+ tail().catch(() => {});
935
+ }, 200);
830
936
 
831
- await sendSnapshot();
832
937
  request.on('close', () => {
938
+ closed = true;
833
939
  clearInterval(timer);
834
940
  clearInterval(heartbeat);
941
+ clearInterval(tailer);
835
942
  });
836
943
  return;
837
944
  }
@@ -21,6 +21,13 @@ Thresholds (initial values, see evalid #97 for the calibration):
21
21
  flips the verdict to ``full`` -- the spec ritual exists precisely to catch
22
22
  drift there, so a small diff touching spec/workflow definitions must not
23
23
  skip it. An empty diff classifies ``lite``.
24
+ * Workflow-generated artifacts under ``specs/changes/<id>/`` do not count
25
+ (slice 4, evalid #103): the workflow itself rewrites ``status.json``
26
+ (+268 lines in the M4 demo) and writes proof artifacts (``tdd/``,
27
+ ``cr/``, ``verify_proof.json``, ``spec_delta_review.*``) during a run,
28
+ so a resume re-measuring the worktree would otherwise flip an auto-lite
29
+ change to full on its own state. Human-authored spec files
30
+ (``proposal.md`` / ``plan.md`` / ``tasks.md``) still force full.
24
31
 
25
32
  CLI (always prints one JSON object; exit 0, exit 2 on git/usage errors)::
26
33
 
@@ -53,6 +60,36 @@ DEFAULT_MAX_LINES = 300
53
60
  FORCE_FULL_PREFIXES = ("specs/", "workflows/")
54
61
  VERSION = 1
55
62
 
63
+ # Files the workflow itself writes under specs/changes/<id>/ -- machine
64
+ # state and proof artifacts, not spec prose (slice 4, evalid #103).
65
+ GENERATED_CHANGE_FILES = (
66
+ "status.json",
67
+ "verify_proof.json",
68
+ "spec_delta_review.json",
69
+ "spec_delta_review.md",
70
+ )
71
+ GENERATED_CHANGE_DIRS = ("tdd/", "cr/")
72
+
73
+
74
+ def _is_generated_change_artifact(path):
75
+ """True when ``path`` is workflow-generated state under a change dir.
76
+
77
+ ``specs/changes/<id>/status.json`` is rewritten by the workflow on
78
+ every run (stage transitions, integrity block); ``tdd/`` / ``cr/``
79
+ hold sealed proofs and review output, ``verify_proof.json`` and
80
+ ``spec_delta_review.*`` likewise. None of these is a human spec edit,
81
+ so none of them may force the full track or inflate the measured
82
+ diff. Anything else under ``specs/changes/<id>/`` -- in particular
83
+ ``proposal.md`` / ``plan.md`` / ``tasks.md`` -- still counts.
84
+ """
85
+ parts = path.split("/")
86
+ if len(parts) < 4 or parts[0] != "specs" or parts[1] != "changes":
87
+ return False
88
+ rest = parts[3:]
89
+ if len(rest) == 1 and rest[0] in GENERATED_CHANGE_FILES:
90
+ return True
91
+ return bool(rest) and (rest[0] + "/") in GENERATED_CHANGE_DIRS
92
+
56
93
 
57
94
  def _git(repo, *argv):
58
95
  proc = subprocess.run(
@@ -172,6 +209,15 @@ def classify_repo(repo, base="main", max_files=DEFAULT_MAX_FILES,
172
209
  stats = _numstat_worktree(repo, base_sha)
173
210
  else:
174
211
  stats = _numstat(repo, base_sha)
212
+ # Workflow-generated artifacts under specs/changes/<id>/ are the
213
+ # workflow's own state, not the change under review (slice 4): drop
214
+ # them before the verdict so a resume cannot flip to full on its own
215
+ # status.json rewrite, and so their lines cannot inflate the budget.
216
+ stats = {
217
+ path: counts
218
+ for path, counts in stats.items()
219
+ if not _is_generated_change_artifact(path)
220
+ }
175
221
  files = sorted(stats)
176
222
  added = sum(a for a, _ in stats.values())
177
223
  removed = sum(r for _, r in stats.values())