session-orchestrator 5.3.0 → 5.5.0
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/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* events-retention-banner.mjs — session-start probe: does the rotated events
|
|
3
|
+
* ledger still hold the TIME WINDOW its readers need? (#1401 part 3, variant a)
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* `.orchestrator/metrics/events.jsonl` rotates into
|
|
8
|
+
* `_archive/events-<firstTs>_<lastTs>.jsonl`, and `events-rotation.max-backups`
|
|
9
|
+
* bounds the NUMBER of archives kept (`events-rotation.mjs` `pruneArchives`).
|
|
10
|
+
* The readers of the rotated ledger need a TIME window instead. Count and time
|
|
11
|
+
* are different units (`.claude/rules/host-resources.md` § HR-103), so nothing
|
|
12
|
+
* noticed when a high write rate made "five archives" mean "less history than
|
|
13
|
+
* a reader reads": the pruned records simply stopped existing.
|
|
14
|
+
*
|
|
15
|
+
* Each reader declares its need as `REQUIRED_EVENTS_WINDOW_DAYS` beside its call
|
|
16
|
+
* to `readEventsWithRotations(`, `scanEventsBackwards(` or
|
|
17
|
+
* `listEventSourcesNewestFirst(` — a finite number of days, or `null` when no
|
|
18
|
+
* day count bounds its question (it then cannot set a retention floor, and its
|
|
19
|
+
* declaration comment states what a cut history does to its answer). This probe
|
|
20
|
+
* takes the MAX of the finite declarations and compares it against the span
|
|
21
|
+
* the retained ledger covers. No new Session Config key: it reads the existing
|
|
22
|
+
* `events-rotation.max-backups` from the config the runner passes.
|
|
23
|
+
*
|
|
24
|
+
* ## When it warns (HR-101 — a signal may only warn if it is rare)
|
|
25
|
+
*
|
|
26
|
+
* Only when the archive ring is FULL (`archives >= max-backups`). Below that
|
|
27
|
+
* the rotator prunes nothing under the current config, so the ledger only
|
|
28
|
+
* grows — a two-hour-old repo covering two hours is not "short retention", and
|
|
29
|
+
* judging it so would fire on every young repo's start.
|
|
30
|
+
*
|
|
31
|
+
* "Not full" does not mean "never pruned": after `max-backups` is RAISED, the
|
|
32
|
+
* archives pruned under the old, smaller ring stay gone. The oldest retained
|
|
33
|
+
* archive then opens with a rotation record whose `archived_as` names a
|
|
34
|
+
* predecessor that no longer exists — that is the hole, and the silent
|
|
35
|
+
* coverage figure then starts at the oldest archive exactly as on a full ring,
|
|
36
|
+
* never at a legacy `events.jsonl.N` older than the hole.
|
|
37
|
+
*
|
|
38
|
+
* ## Rare vs. broken (HR-105, #1489 item 2)
|
|
39
|
+
*
|
|
40
|
+
* Measured 2026-10-02: 4 of 35 fleet ledgers hold any archive, at most 3 at
|
|
41
|
+
* `max-backups: 5`, so the warning has never fired — and a warning that never
|
|
42
|
+
* fires looks the same whether it is rare or dead. Every MEASURED answer
|
|
43
|
+
* therefore carries the measurement, warn or not: `coverageDays`,
|
|
44
|
+
* `oldestEventAt`, `requiredDays`/`requiredBy`, `archives`, `maxBackups`.
|
|
45
|
+
* `coverageDays` is now minus the oldest archive's start on a FULL ring — the
|
|
46
|
+
* span the verdict judges — and below it when that archive's predecessor is
|
|
47
|
+
* gone (see above); otherwise now minus the oldest stamp of any source
|
|
48
|
+
* (archives, legacy ring, active file), where nothing was pruned.
|
|
49
|
+
* The registry entry's `telemetry` (`session-start-probes.mjs`) persists
|
|
50
|
+
* `coverage_days`, `archives`, `max_backups` and `required_days` as `measure`
|
|
51
|
+
* on this probe's element of `orchestrator.probes.completed`; `oldestEventAt`
|
|
52
|
+
* and `requiredBy` reach the caller only.
|
|
53
|
+
*
|
|
54
|
+
* ## Three states, never two
|
|
55
|
+
*
|
|
56
|
+
* - **not measured** — no live ledger and no `ARCHIVE_NAME_RE` archive in
|
|
57
|
+
* `_archive/` (absent, empty, or holding only foreign files):
|
|
58
|
+
* `{severity:'ok', kind:'not-measured', reason:'no-events-ledger'}`. The
|
|
59
|
+
* registry's precondition (`hasRotationArchive` in
|
|
60
|
+
* `session-start-probes.mjs`) lists `_archive/` the same way and skips
|
|
61
|
+
* exactly these inputs, so the runner records them as `skipped`, never as
|
|
62
|
+
* `ran-clean` — which is what severity ok would otherwise become.
|
|
63
|
+
* - **unmeasurable** — `_archive/` exists but cannot be listed, a reader's
|
|
64
|
+
* declaration cannot be read, the oldest retained stamp lies after now
|
|
65
|
+
* (a future-dated archive or a clock set back — the span would be
|
|
66
|
+
* negative), or no source carries a stamp at all (no archive, and no
|
|
67
|
+
* parseable `timestamp` in the head of the active file or a legacy-ring
|
|
68
|
+
* file — #1498: it answered `ring-not-full` with `coverageDays: null` and
|
|
69
|
+
* recorded as `ran-clean`): `{severity:'warn', degraded:true}`. An
|
|
70
|
+
* unreadable source must never read as "retention is fine".
|
|
71
|
+
* - **measured** — `{severity:'ok', kind:'ring-not-full'|'no-finite-window'|'covered', …}`
|
|
72
|
+
* or `{severity:'warn', kind:'retention-short', message, …}`.
|
|
73
|
+
*
|
|
74
|
+
* Cost: two `readdirSync` of `_archive/` per session start (the registry
|
|
75
|
+
* precondition, then this probe), below a full ring one ≤64 KiB head read of
|
|
76
|
+
* the oldest archive (for its rotation record) and — unless that shows a
|
|
77
|
+
* pruned predecessor — of the active file and of each legacy-ring file, and — on EVERY measured start,
|
|
78
|
+
* before the ring check, because `requiredDays` is part of the silent
|
|
79
|
+
* measurement — the import of every reader module to read its declaration:
|
|
80
|
+
* 20-28 ms cold for all seven in a fresh process (measured 2026-10-02, load
|
|
81
|
+
* average ~8); two of them are session-start probes and two the SessionStart
|
|
82
|
+
* backfill, already loaded in that process. So a reader module that fails to import degrades this probe on
|
|
83
|
+
* every start, not only on a full ring.
|
|
84
|
+
*
|
|
85
|
+
* @module scripts/lib/events-retention-banner
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync } from 'node:fs';
|
|
89
|
+
import path from 'node:path';
|
|
90
|
+
|
|
91
|
+
import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE, LEGACY_RING_MAX, ROTATION_EVENT } from './events-schema.mjs';
|
|
92
|
+
|
|
93
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
94
|
+
|
|
95
|
+
/** `_parseEventsRotation`'s own default (`scripts/lib/config/events-rotation.mjs`). */
|
|
96
|
+
const DEFAULT_MAX_BACKUPS = 5;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How much of a ledger file's head is read for its first timestamp. NAMED
|
|
100
|
+
* CEILING (BV-004): one record is ~300 B in this repo's ledger, so 64 KiB holds
|
|
101
|
+
* hundreds; a file whose first 64 KiB carry no parseable `timestamp` reports
|
|
102
|
+
* no stamp rather than a guessed one.
|
|
103
|
+
*/
|
|
104
|
+
const HEAD_BYTES = 64 * 1024;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every module that reads the rotated ledger. Census 2026-10-02 @ `84c107a1`:
|
|
108
|
+
* `rg -n "readEventsWithRotations\(|scanEventsBackwards\(|listEventSourcesNewestFirst\(" --glob '!tests/**' scripts hooks skills`
|
|
109
|
+
* → 6 call sites beside the definitions in `events.mjs` (and its internal
|
|
110
|
+
* `listEventSourcesNewestFirst` call); the seventh, `session-close-backfill`,
|
|
111
|
+
* joined with #1498. A test re-runs that census, so an eighth reader cannot
|
|
112
|
+
* join unlisted.
|
|
113
|
+
*/
|
|
114
|
+
export const EVENTS_WINDOW_READERS = Object.freeze([
|
|
115
|
+
{ label: 'eval/engine', spec: './eval/engine.mjs' },
|
|
116
|
+
{ label: 'telemetry/sync', spec: './telemetry/sync.mjs' },
|
|
117
|
+
{ label: 'tmux-layout/telemetry-stats', spec: './tmux-layout/telemetry-stats.mjs' },
|
|
118
|
+
{ label: 'maintenance-due-banner', spec: './maintenance-due-banner.mjs' },
|
|
119
|
+
{ label: 'instruction-budget-guard', spec: './instruction-budget-guard.mjs' },
|
|
120
|
+
{ label: 'backfill-abandoned-sessions', spec: '../backfill-abandoned-sessions.mjs' },
|
|
121
|
+
{ label: 'session-close-backfill', spec: './session-close-backfill.mjs' },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Epoch ms of the START of an archive's range, from its name alone. An
|
|
126
|
+
* `unknown` first stamp falls back to the LAST stamp — a later instant, so the
|
|
127
|
+
* covered span is understated and the probe errs toward reporting.
|
|
128
|
+
*
|
|
129
|
+
* Total over `ARCHIVE_NAME_RE` matches, the only names it is given: the regex
|
|
130
|
+
* guarantees two digit stamps, and `Date.UTC` normalizes an out-of-range field
|
|
131
|
+
* (month 13 → January of the next year) instead of returning NaN. The NaN
|
|
132
|
+
* return exists only to type the regex miss; no caller can reach it.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} name — matches `ARCHIVE_NAME_RE`
|
|
135
|
+
* @returns {number} epoch ms; NaN only for a name outside `ARCHIVE_NAME_RE`
|
|
136
|
+
*/
|
|
137
|
+
function archiveStartMs(name) {
|
|
138
|
+
const m = /^events-(\d{8}T\d{6}Z|unknown)_(\d{8}T\d{6}Z)/.exec(name);
|
|
139
|
+
if (!m) return Number.NaN;
|
|
140
|
+
const stamp = m[1] === 'unknown' ? m[2] : m[1];
|
|
141
|
+
const p = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
|
142
|
+
return p ? Date.UTC(+p[1], +p[2] - 1, +p[3], +p[4], +p[5], +p[6]) : Number.NaN;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The parseable JSON records within the first {@link HEAD_BYTES} of `file`, in
|
|
147
|
+
* file order; `[]` when absent or unreadable. Malformed lines — and the partial
|
|
148
|
+
* last line of the head — are skipped, never guessed at.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} file
|
|
151
|
+
* @returns {Array<*>}
|
|
152
|
+
*/
|
|
153
|
+
function headRecords(file) {
|
|
154
|
+
let fd;
|
|
155
|
+
let text = '';
|
|
156
|
+
try {
|
|
157
|
+
fd = openSync(file, 'r');
|
|
158
|
+
const buf = Buffer.alloc(HEAD_BYTES);
|
|
159
|
+
const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
|
|
160
|
+
text = buf.subarray(0, n).toString('utf8');
|
|
161
|
+
} catch {
|
|
162
|
+
/* absent or unreadable — no records from this file */
|
|
163
|
+
} finally {
|
|
164
|
+
if (fd !== undefined) {
|
|
165
|
+
try {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
} catch {
|
|
168
|
+
/* best-effort */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const records = [];
|
|
173
|
+
for (const line of text.split('\n')) {
|
|
174
|
+
try {
|
|
175
|
+
records.push(JSON.parse(line));
|
|
176
|
+
} catch {
|
|
177
|
+
/* malformed, or the partial last line of the head */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return records;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Epoch ms of the first parseable `timestamp` within the head of `file` — the
|
|
185
|
+
* file's oldest event, since every ledger file is append-ordered. NaN when
|
|
186
|
+
* absent, unreadable, or stampless: the caller then has no stamp from this
|
|
187
|
+
* file, never a guessed one.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} file
|
|
190
|
+
* @returns {number}
|
|
191
|
+
*/
|
|
192
|
+
function firstTimestampMs(file) {
|
|
193
|
+
for (const rec of headRecords(file)) {
|
|
194
|
+
const ms = Date.parse(rec?.timestamp);
|
|
195
|
+
if (Number.isFinite(ms)) return ms;
|
|
196
|
+
}
|
|
197
|
+
return Number.NaN;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Was history pruned before `oldest`, the oldest retained archive? Its head
|
|
202
|
+
* carries the rotation record written when its predecessor was archived, and
|
|
203
|
+
* that record's `archived_as` names the predecessor: an archive name absent
|
|
204
|
+
* from `names` was pruned — under a smaller ring, before `max-backups` was
|
|
205
|
+
* raised — or deleted. `false` when the head holds no rotation record: the
|
|
206
|
+
* hole then goes unseen, it is never invented.
|
|
207
|
+
*
|
|
208
|
+
* Named ceiling (BV-004, #1498). A head without a rotation record is the FIRST
|
|
209
|
+
* archive a ledger ever wrote (its file predates any rotation, and the legacy
|
|
210
|
+
* ring wrote none) — no archive can have been pruned before it, so nothing is
|
|
211
|
+
* missed there. The tombstone is appended right after the rename, so it sits on
|
|
212
|
+
* the first lines, far inside {@link HEAD_BYTES}. The blind case is a later
|
|
213
|
+
* archive whose tombstone was never written (`maybeRotate` → `recordWritten:
|
|
214
|
+
* false`) AND whose predecessor is gone. Measured 2026-10-02 over the 5 fleet
|
|
215
|
+
* `_archive/` dirs: 4 of 4 first archives carry no tombstone, 2 of 2 later
|
|
216
|
+
* archives carry it on line 0. Revisit if a census finds a later archive
|
|
217
|
+
* without one.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} archiveDir
|
|
220
|
+
* @param {string} oldest — a member of `names`
|
|
221
|
+
* @param {string[]} names — the retained `ARCHIVE_NAME_RE` archives
|
|
222
|
+
* @returns {boolean}
|
|
223
|
+
*/
|
|
224
|
+
function predecessorPruned(archiveDir, oldest, names) {
|
|
225
|
+
const rotation = headRecords(path.join(archiveDir, oldest)).find((r) => r?.event === ROTATION_EVENT);
|
|
226
|
+
if (typeof rotation?.archived_as !== 'string') return false;
|
|
227
|
+
const predecessor = path.basename(rotation.archived_as);
|
|
228
|
+
return ARCHIVE_NAME_RE.test(predecessor) && !names.includes(predecessor);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The largest finite window any reader declares, with the reader naming it.
|
|
233
|
+
*
|
|
234
|
+
* @param {ReadonlyArray<{label: string, spec: string}>} readers
|
|
235
|
+
* @returns {Promise<{days: number|null, by: string|null}>}
|
|
236
|
+
* @throws when a reader cannot be imported or declares a non-window value
|
|
237
|
+
*/
|
|
238
|
+
async function maxRequiredWindow(readers) {
|
|
239
|
+
const values = await Promise.all(
|
|
240
|
+
readers.map(async (r) => {
|
|
241
|
+
const mod = await import(new URL(r.spec, import.meta.url).href);
|
|
242
|
+
const v = mod.REQUIRED_EVENTS_WINDOW_DAYS;
|
|
243
|
+
if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
|
|
244
|
+
throw new Error(`${r.label} declares no REQUIRED_EVENTS_WINDOW_DAYS`);
|
|
245
|
+
}
|
|
246
|
+
return { days: v, by: r.label };
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
let best = { days: null, by: null };
|
|
250
|
+
for (const v of values) {
|
|
251
|
+
if (v.days !== null && (best.days === null || v.days > best.days)) best = v;
|
|
252
|
+
}
|
|
253
|
+
return best;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Compare the retained events ledger's time span against the window its
|
|
258
|
+
* readers need. Never throws; never blocks.
|
|
259
|
+
*
|
|
260
|
+
* @param {object} opts
|
|
261
|
+
* @param {string} opts.repoRoot
|
|
262
|
+
* @param {object} [opts.config] — parsed Session Config (`events-rotation.max-backups`)
|
|
263
|
+
* @param {number} [opts.now=Date.now()] — injectable clock, epoch ms
|
|
264
|
+
* @returns {Promise<null|{severity: 'ok'|'warn', kind: string, message?: string,
|
|
265
|
+
* degraded?: boolean, reason?: string, coverageDays?: number,
|
|
266
|
+
* oldestEventAt?: string, requiredDays?: number|null, requiredBy?: string|null,
|
|
267
|
+
* archives?: number, maxBackups?: number}>} `null` only without a `repoRoot`.
|
|
268
|
+
*/
|
|
269
|
+
export async function checkEventsRetention({ repoRoot, config, now = Date.now() } = {}) {
|
|
270
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
271
|
+
const metricsDir = path.join(repoRoot, '.orchestrator', 'metrics');
|
|
272
|
+
const archiveDir = path.join(metricsDir, ARCHIVE_DIR_NAME);
|
|
273
|
+
const activePath = path.join(metricsDir, 'events.jsonl');
|
|
274
|
+
const nowMs = typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
|
|
275
|
+
|
|
276
|
+
let names;
|
|
277
|
+
try {
|
|
278
|
+
names = readdirSync(archiveDir).filter((n) => ARCHIVE_NAME_RE.test(n));
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (err?.code !== 'ENOENT') {
|
|
281
|
+
return {
|
|
282
|
+
severity: 'warn',
|
|
283
|
+
degraded: true,
|
|
284
|
+
kind: 'unmeasurable',
|
|
285
|
+
message: `⚠ events-retention: could not list ${ARCHIVE_DIR_NAME}/ (${err?.code ?? 'error'}) — retention span not measured, which is not the same as covered.`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
names = [];
|
|
289
|
+
}
|
|
290
|
+
// Mirrored by the registry precondition, which skips these inputs before the
|
|
291
|
+
// runner could record this severity-ok answer as `ran-clean`.
|
|
292
|
+
if (names.length === 0 && !existsSync(activePath)) {
|
|
293
|
+
return { severity: 'ok', kind: 'not-measured', reason: 'no-events-ledger' };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let required;
|
|
297
|
+
try {
|
|
298
|
+
required = await maxRequiredWindow(EVENTS_WINDOW_READERS);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
severity: 'warn',
|
|
302
|
+
degraded: true,
|
|
303
|
+
kind: 'unmeasurable',
|
|
304
|
+
message: `⚠ events-retention: required window not readable (${String(err?.message ?? err).slice(0, 120)}) — retention span not judged, which is not the same as covered.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const raw = config?.['events-rotation']?.['max-backups'];
|
|
309
|
+
const maxBackups = Number.isInteger(raw) && raw >= 1 ? raw : DEFAULT_MAX_BACKUPS;
|
|
310
|
+
const ringFull = names.length >= maxBackups;
|
|
311
|
+
|
|
312
|
+
// A FULL ring is judged on its archives alone. The rotator prunes archives
|
|
313
|
+
// but never the legacy ring, so a legacy `events.jsonl.N` from before the
|
|
314
|
+
// migration outlives the pruned archives and its stamp would span the hole
|
|
315
|
+
// they left (#1489 review MED-1: a `.1` from April turned 20 hours of
|
|
316
|
+
// contiguous history into "173.5d, covered"). Below a full ring the same
|
|
317
|
+
// hole exists when an archive was pruned under an earlier, smaller ring
|
|
318
|
+
// (`max-backups` raised since): the oldest archive's rotation record names a
|
|
319
|
+
// predecessor that is gone. Only without such a hole does every source
|
|
320
|
+
// count toward the silent coverage figure.
|
|
321
|
+
const oldestArchive = names.reduce(
|
|
322
|
+
(a, b) => (a === null || archiveStartMs(b) < archiveStartMs(a) ? b : a),
|
|
323
|
+
null,
|
|
324
|
+
);
|
|
325
|
+
const holeBeforeArchives = ringFull || (oldestArchive !== null && predecessorPruned(archiveDir, oldestArchive, names));
|
|
326
|
+
const starts = [
|
|
327
|
+
...names.map(archiveStartMs),
|
|
328
|
+
...(holeBeforeArchives
|
|
329
|
+
? []
|
|
330
|
+
: [
|
|
331
|
+
...Array.from({ length: LEGACY_RING_MAX }, (_, i) => `${activePath}.${i + 1}`)
|
|
332
|
+
.filter((p) => existsSync(p))
|
|
333
|
+
.map(firstTimestampMs),
|
|
334
|
+
firstTimestampMs(activePath),
|
|
335
|
+
]),
|
|
336
|
+
].filter(Number.isFinite);
|
|
337
|
+
const oldestMs = starts.length > 0 ? Math.min(...starts) : Number.NaN;
|
|
338
|
+
|
|
339
|
+
// Every retained source dated after now — a future-dated archive, or a clock
|
|
340
|
+
// set back — makes the span negative. That is no measurement, and an
|
|
341
|
+
// unmeasured span must never record as clean, so it degrades like an
|
|
342
|
+
// unreadable source instead of persisting a negative `coverage_days`.
|
|
343
|
+
if (oldestMs > nowMs) {
|
|
344
|
+
return {
|
|
345
|
+
severity: 'warn',
|
|
346
|
+
degraded: true,
|
|
347
|
+
kind: 'unmeasurable',
|
|
348
|
+
message: `⚠ events-retention: the oldest retained event is dated ${new Date(oldestMs).toISOString()}, after now — retention span not measured, which is not the same as covered.`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// No stamp at all (#1498) — reachable only without archives (every archive
|
|
353
|
+
// dates itself by name), when the first 64 KiB of the active file and of
|
|
354
|
+
// every legacy-ring file carry no parseable `timestamp`. That span was not
|
|
355
|
+
// measured; answered `ring-not-full` with `coverageDays: null` it recorded
|
|
356
|
+
// as `ran-clean`. Census 2026-10-02: 0 of 37 fleet `events.jsonl` heads are
|
|
357
|
+
// stampless, so this degrades rarely (HR-101) — and visibly (HR-105).
|
|
358
|
+
if (!Number.isFinite(oldestMs)) {
|
|
359
|
+
return {
|
|
360
|
+
severity: 'warn',
|
|
361
|
+
degraded: true,
|
|
362
|
+
kind: 'unmeasurable',
|
|
363
|
+
message: `⚠ events-retention: no parseable timestamp in the first ${HEAD_BYTES / 1024} KiB of events.jsonl (or of a legacy events.jsonl.N) and no ${ARCHIVE_DIR_NAME}/ archive — retention span not measured, which is not the same as covered.`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const measured = {
|
|
368
|
+
coverageDays: (nowMs - oldestMs) / DAY_MS,
|
|
369
|
+
oldestEventAt: new Date(oldestMs).toISOString(),
|
|
370
|
+
requiredDays: required.days,
|
|
371
|
+
requiredBy: required.by,
|
|
372
|
+
archives: names.length,
|
|
373
|
+
maxBackups,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Ring not full ⇒ nothing is pruned under this config, so the retained
|
|
377
|
+
// ledger only grows; any older hole is already excluded from the figure
|
|
378
|
+
// above. Never a warning here — see the module header (HR-101).
|
|
379
|
+
if (!ringFull) return { severity: 'ok', kind: 'ring-not-full', ...measured };
|
|
380
|
+
if (required.days === null) return { severity: 'ok', kind: 'no-finite-window', ...measured };
|
|
381
|
+
// Finite here: the ring is full, so `names` is non-empty and
|
|
382
|
+
// `archiveStartMs` is total over `ARCHIVE_NAME_RE` matches.
|
|
383
|
+
if (measured.coverageDays >= required.days) return { severity: 'ok', kind: 'covered', ...measured };
|
|
384
|
+
const cov = measured.coverageDays.toFixed(1);
|
|
385
|
+
return {
|
|
386
|
+
severity: 'warn',
|
|
387
|
+
kind: 'retention-short',
|
|
388
|
+
// Names what the rule judged (HR-106): the measured span against the
|
|
389
|
+
// reader's DECLARED window — not a claim about how the reader reads.
|
|
390
|
+
message: `⚠ events-retention: the rotated events ledger covers ${cov}d (${names.length} archives at events-rotation.max-backups: ${maxBackups}), less than the ${required.days}d window ${required.by} declares as REQUIRED_EVENTS_WINDOW_DAYS — events older than ${cov}d are not retained; raise events-rotation.max-backups or max-size-mb.`,
|
|
391
|
+
...measured,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* synchronous fs calls (the hook is short-lived; async adds complexity).
|
|
7
7
|
*
|
|
8
8
|
* Issue #251 (CRITICAL). Rotation fires only at session-start, not per-append —
|
|
9
|
-
* per
|
|
9
|
+
* a size check per append buys nothing when one 10 MiB file takes days to
|
|
10
|
+
* weeks to fill (measured growth: see {@link pruneArchives}).
|
|
10
11
|
*
|
|
11
12
|
* Rename safety (POSIX): atomic rename is safe with in-flight writers. Old fds
|
|
12
13
|
* continue writing to the original inode (now the archive); new writers will
|
|
@@ -104,8 +105,9 @@ function uniqueArchivePath(dir, firstTs, lastTs) {
|
|
|
104
105
|
const to = lastTs ? compactStamp(lastTs) : compactStamp(new Date().toISOString());
|
|
105
106
|
const base = `events-${from}_${to}`;
|
|
106
107
|
let candidate = path.join(dir, `${base}.jsonl`);
|
|
107
|
-
// Ceiling (BV-004): 999 same-range archives. Rotation fires
|
|
108
|
-
//
|
|
108
|
+
// Ceiling (BV-004): 999 same-range archives. Rotation fires every ~52 days in
|
|
109
|
+
// this repo and every ~5 days in the busiest fleet repo (measured 2026-10-02,
|
|
110
|
+
// see `pruneArchives`), and the range is content-derived, so a second
|
|
109
111
|
// collision already implies something is re-rotating identical content.
|
|
110
112
|
// Revisit if a `pruned`-less archive dir is ever seen holding a `-3` suffix.
|
|
111
113
|
for (let n = 2; existsSync(candidate); n += 1) {
|
|
@@ -122,8 +124,19 @@ function uniqueArchivePath(dir, firstTs, lastTs) {
|
|
|
122
124
|
*
|
|
123
125
|
* The cap is kept rather than dropped because `events-rotation.max-backups` is
|
|
124
126
|
* a live, documented config key: a key whose reader silently stops enforcing it
|
|
125
|
-
* is the "config key nobody produces" defect in reverse.
|
|
126
|
-
*
|
|
127
|
+
* is the "config key nobody produces" defect in reverse.
|
|
128
|
+
*
|
|
129
|
+
* What `max-backups: 5` retains, measured 2026-10-02 (line bytes summed per
|
|
130
|
+
* calendar day of each record's `timestamp`, via a `node` read of
|
|
131
|
+
* `.orchestrator/metrics/events.jsonl` and `_archive/`): this repo's active
|
|
132
|
+
* ledger grew 2,770,612 B over 13.7 days (2026-09-18 → 2026-10-02), i.e.
|
|
133
|
+
* ~202,570 B/day with a 1,207,177 B peak day; across 35 fleet ledgers the median
|
|
134
|
+
* active-file rate was 59,388 B/day, and the busiest repo averaged ~2.1 MB/day
|
|
135
|
+
* (43,978,706 B over 2026-09-11 → 2026-10-02) with a 10,248,010 B peak day.
|
|
136
|
+
* One archive holds ≥10 MiB, so five archives retain ~8.5 months here and ~3.5
|
|
137
|
+
* weeks in the busiest repo — not the "roughly two years" this comment used to
|
|
138
|
+
* claim from a ~6 KiB/day figure. Whether that suffices is judged per reader
|
|
139
|
+
* window by `events-retention-banner.mjs`.
|
|
127
140
|
*
|
|
128
141
|
* Only names matching {@link ARCHIVE_NAME_RE} are eligible, and the archive
|
|
129
142
|
* just written is never a candidate. A failed unlink is swallowed: leaving one
|
|
@@ -221,7 +234,9 @@ export function maybeRotate({ logPath, maxSizeMb, maxBackups, enabled } = {}) {
|
|
|
221
234
|
|
|
222
235
|
// Read BEFORE the rename: the content is what names the archive. Cost
|
|
223
236
|
// ceiling (BV-004): one full read of a file at the rotation threshold —
|
|
224
|
-
// ~50 ms and ~40 MB transient at the default 10 MB cap, paid
|
|
237
|
+
// ~50 ms and ~40 MB transient at the default 10 MB cap, paid once per
|
|
238
|
+
// rotation (every ~52 days here, ~5 days in the busiest fleet repo —
|
|
239
|
+
// measured 2026-10-02, see `pruneArchives`).
|
|
225
240
|
// Revisit if `max-size-mb` is ever raised past ~100.
|
|
226
241
|
const { records, malformedLines } = parseEventLines(readFileSync(logPath, 'utf8'));
|
|
227
242
|
const { firstTs, lastTs } = summarizeEventRecords(records);
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -388,7 +388,11 @@ function waveForRecord(root, attribution) {
|
|
|
388
388
|
* metrics/events.jsonl` instead of the module-level `SO_PROJECT_DIR` default
|
|
389
389
|
* (#941). Ignored when `opts.filePath` is given (explicit path wins). This is
|
|
390
390
|
* the clean interface replacing the hand-built `join(repoRoot, …)` recipes that
|
|
391
|
-
* used to open-code this destination at each call-site.
|
|
391
|
+
* used to open-code this destination at each call-site. A relative or empty
|
|
392
|
+
* `repoRoot` is refused with one stderr WARN and nothing is written (no line,
|
|
393
|
+
* no directory, no webhook): joining it would land relative to the working
|
|
394
|
+
* directory — the incident class where an unset env var produced
|
|
395
|
+
* `undefined/…/.orchestrator/metrics/events.jsonl` under the cwd.
|
|
392
396
|
* @returns {Promise<void>}
|
|
393
397
|
*/
|
|
394
398
|
export async function emitEvent(type, payload = {}, opts = {}) {
|
|
@@ -434,11 +438,26 @@ export async function emitEvent(type, payload = {}, opts = {}) {
|
|
|
434
438
|
|
|
435
439
|
// Ensure the destination directory exists before appending. Resolution order:
|
|
436
440
|
// 1. explicit opts.filePath (a pre-resolved path — #611)
|
|
437
|
-
// 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941)
|
|
441
|
+
// 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941),
|
|
442
|
+
// or refused when relative/empty (WARN, no write — see below)
|
|
438
443
|
// 3. the SO_PROJECT_DIR default (unchanged for 2-arg callers)
|
|
439
444
|
// eventsFilePath(undefined) falls through to its SO_PROJECT_DIR default, so a
|
|
440
445
|
// caller passing neither behaves EXACTLY as before (additive) — except under
|
|
441
446
|
// the test-only EVENTS_LEDGER_SANDBOX_ENV seam, which only 3. honours.
|
|
447
|
+
// An explicit filePath is never second-guessed. A defined repoRoot that is not
|
|
448
|
+
// absolute would be joined relative to the cwd, so it is refused: WARN and
|
|
449
|
+
// return (hooks must never throw). The value itself is not printed — it may
|
|
450
|
+
// carry a host-local path.
|
|
451
|
+
if (
|
|
452
|
+
opts.filePath === undefined &&
|
|
453
|
+
opts.repoRoot !== undefined &&
|
|
454
|
+
(typeof opts.repoRoot !== 'string' || !path.isAbsolute(opts.repoRoot))
|
|
455
|
+
) {
|
|
456
|
+
process.stderr.write(
|
|
457
|
+
`[events] ${String(type)} not emitted: opts.repoRoot is not an absolute path; refusing to write relative to the working directory.\n`,
|
|
458
|
+
);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
442
461
|
const filePath = opts.filePath ?? eventsFilePath(opts.repoRoot);
|
|
443
462
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
444
463
|
await fs.appendFile(filePath, line, 'utf8');
|
|
@@ -807,7 +826,13 @@ export function listEventSourcesNewestFirst(opts = {}) {
|
|
|
807
826
|
* a stop, the walk ends with `truncated: true` — which is NOT "not found":
|
|
808
827
|
* the caller must report undeterminable, never a clean negative.
|
|
809
828
|
* @returns {{stopped: boolean, truncated: boolean, malformed_lines: number,
|
|
810
|
-
* sources: string[], unreadable: string[]}}
|
|
829
|
+
* sources: string[], unreadable: string[], gaps: object[]}} `gaps`
|
|
830
|
+
* (#1489): every rotation tombstone the walk passed, on any line and
|
|
831
|
+
* regardless of `filter`, whose archive is not one of this walk's sources — pruned
|
|
832
|
+
* (`missing-archive`) or renamed out of `ARCHIVE_NAME_RE`
|
|
833
|
+
* (`unindexed-archive`), the same two kinds and fields
|
|
834
|
+
* {@link readEventsWithRotations} reports. Non-empty ⇒ the history the walk
|
|
835
|
+
* read is CUT, so a walk that found nothing has not proven "never happened".
|
|
811
836
|
*/
|
|
812
837
|
export function scanEventsBackwards(opts = {}) {
|
|
813
838
|
const { onRecord, filter, budgetMs } = opts;
|
|
@@ -821,16 +846,58 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
821
846
|
|
|
822
847
|
const scanned = [];
|
|
823
848
|
const unreadable = [];
|
|
849
|
+
const gaps = [];
|
|
824
850
|
let malformed = 0;
|
|
825
851
|
let stopped = false;
|
|
826
852
|
let truncated = false;
|
|
827
853
|
|
|
854
|
+
const sources = listEventSourcesNewestFirst(opts);
|
|
855
|
+
const listed = new Set(sources.map((s) => s.path));
|
|
856
|
+
// Same construction as `discoverArchives`, so a listed archive matches by string.
|
|
857
|
+
const ownArchiveDir = path.join(
|
|
858
|
+
path.dirname(opts.filePath ?? eventsFilePath(opts.repoRoot)),
|
|
859
|
+
ARCHIVE_DIR_NAME,
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Every rotation tombstone the walk passes is checked, on whatever line it
|
|
864
|
+
* sits — the semantics of {@link readEventsWithRotations}, which checks every
|
|
865
|
+
* rotation record of every source. Not only first lines (#1489 review
|
|
866
|
+
* LOW-2): `maybeRotate` renames, prunes, THEN appends the tombstone, from an
|
|
867
|
+
* async SessionStart hook, so a concurrent append can land first and push it
|
|
868
|
+
* to line 2. Called BEFORE the caller's `filter`, which names another event
|
|
869
|
+
* and would otherwise hide the cut. Resolved by basename against this
|
|
870
|
+
* ledger's own `_archive/`, never by the absolute provenance value (#1411).
|
|
871
|
+
*/
|
|
872
|
+
const noteCut = (line, source) => {
|
|
873
|
+
if (!line.includes(ROTATION_EVENT)) return;
|
|
874
|
+
let record;
|
|
875
|
+
try {
|
|
876
|
+
record = JSON.parse(line);
|
|
877
|
+
} catch {
|
|
878
|
+
return; // counted by `consume` like every other malformed line
|
|
879
|
+
}
|
|
880
|
+
const target = record?.event === ROTATION_EVENT ? record.archived_as : null;
|
|
881
|
+
if (typeof target !== 'string' || target.length === 0) return;
|
|
882
|
+
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
883
|
+
if (listed.has(sibling)) return;
|
|
884
|
+
gaps.push({
|
|
885
|
+
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
886
|
+
path: sibling,
|
|
887
|
+
archived_as: target,
|
|
888
|
+
first_ts: record.first_ts ?? null,
|
|
889
|
+
last_ts: record.last_ts ?? null,
|
|
890
|
+
reported_by: source.path,
|
|
891
|
+
});
|
|
892
|
+
};
|
|
893
|
+
|
|
828
894
|
/** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
|
|
829
895
|
const consume = (block, source) => {
|
|
830
896
|
const lines = block.toString('utf8').split('\n');
|
|
831
897
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
832
898
|
const line = lines[i];
|
|
833
899
|
if (!line) continue;
|
|
900
|
+
noteCut(line, source);
|
|
834
901
|
if (typeof filter === 'string' && !line.includes(filter)) continue;
|
|
835
902
|
let record;
|
|
836
903
|
try {
|
|
@@ -844,7 +911,7 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
844
911
|
return false;
|
|
845
912
|
};
|
|
846
913
|
|
|
847
|
-
for (const source of
|
|
914
|
+
for (const source of sources) {
|
|
848
915
|
if (stopped || truncated) break;
|
|
849
916
|
if (outOfTime()) {
|
|
850
917
|
truncated = true;
|
|
@@ -896,5 +963,5 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
896
963
|
}
|
|
897
964
|
}
|
|
898
965
|
|
|
899
|
-
return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable };
|
|
966
|
+
return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable, gaps };
|
|
900
967
|
}
|