session-orchestrator 3.16.0 → 3.19.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consent.mjs — anonymous-usage-telemetry consent layer (Epic #841, S1 / GL #842).
|
|
3
|
+
*
|
|
4
|
+
* Owns the persisted consent record (`~/.config/session-orchestrator/telemetry.json`)
|
|
5
|
+
* and the pure `resolveConsent()` precedence machine that decides — from env vars,
|
|
6
|
+
* the host-local owner.yaml fleet flag, and the stored per-user decision — whether
|
|
7
|
+
* telemetry may be SENT at all.
|
|
8
|
+
*
|
|
9
|
+
* ── Fail-closed by design (Learning conf 0.9) ────────────────────────────────
|
|
10
|
+
* The `send` bit is true ONLY when an explicitly affirmative signal is present
|
|
11
|
+
* (`SO_TELEMETRY=1`, `owner.yaml telemetry.enabled === true`, or a stored
|
|
12
|
+
* `consent: 'granted'`). Every ambiguous, missing, or corrupt state resolves to
|
|
13
|
+
* `send: false`. "Not explicitly disabled" is NEVER treated as consent.
|
|
14
|
+
*
|
|
15
|
+
* ── Precedence (highest wins) ────────────────────────────────────────────────
|
|
16
|
+
* 1. DO_NOT_TRACK (set, non-empty, not '0'/'false') → disabled-env
|
|
17
|
+
* 2. SO_TELEMETRY_DISABLED === '1' → disabled-env
|
|
18
|
+
* 3. SO_TELEMETRY === '1' → enabled-env
|
|
19
|
+
* 4. ownerConfig.telemetry.enabled === true (strict) → enabled-fleet
|
|
20
|
+
* 5. state.consent === 'granted' → enabled-consent
|
|
21
|
+
* 6. state.consent === 'denied' → disabled-consent
|
|
22
|
+
* 7. (otherwise) → no-consent
|
|
23
|
+
*
|
|
24
|
+
* The env pair (1/2) is the per-shell escape hatch that outranks the fleet flag
|
|
25
|
+
* (PRD AC FA5): a fleet-opted host still honours `SO_TELEMETRY_DISABLED=1` /
|
|
26
|
+
* `DO_NOT_TRACK` for a single shell. The fleet flag (4) intentionally outranks a
|
|
27
|
+
* stored `denied` (6) — owner.yaml is the operator's host-level decision.
|
|
28
|
+
*
|
|
29
|
+
* No `anon_id` is ever minted here — id generation is lazy and lives in a sibling
|
|
30
|
+
* module; this layer only preserves the field across read/modify/write.
|
|
31
|
+
*
|
|
32
|
+
* Node ESM, no external deps beyond `scripts/lib/io.mjs` (atomic write).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
36
|
+
import { dirname } from 'node:path';
|
|
37
|
+
|
|
38
|
+
import { writeJsonAtomicSync } from '../io.mjs';
|
|
39
|
+
import { TELEMETRY_DIR, TELEMETRY_JSON_PATH, TELEMETRY_QUEUE_PATH } from './paths.mjs';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Constants
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Schema version stamped into every telemetry.json record. */
|
|
46
|
+
export const CONSENT_SCHEMA_VERSION = 1;
|
|
47
|
+
|
|
48
|
+
// Path constants are single-sourced in ./paths.mjs — a constants-only leaf
|
|
49
|
+
// module that both consent.mjs (policy) and queue.mjs (storage) import, so
|
|
50
|
+
// neither depends on the other. Re-exported here for backward-compat: these
|
|
51
|
+
// were consent.mjs exports before the extraction.
|
|
52
|
+
export { TELEMETRY_DIR, TELEMETRY_JSON_PATH, TELEMETRY_QUEUE_PATH };
|
|
53
|
+
|
|
54
|
+
/** Consent enum stored in the record's `consent` field. */
|
|
55
|
+
const CONSENT_GRANTED = 'granted';
|
|
56
|
+
const CONSENT_DENIED = 'denied';
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Helpers
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function isPlainObject(v) {
|
|
63
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A fresh default record: everything null, schema_version pinned. Returned when
|
|
68
|
+
* the file is missing or corrupt so callers never see partial/garbage state.
|
|
69
|
+
* @returns {{schema_version: number, consent: null, decided_at: null, anon_id: null, anon_id_created_at: null, last_flush_at: null}}
|
|
70
|
+
*/
|
|
71
|
+
function defaultRecord() {
|
|
72
|
+
return {
|
|
73
|
+
schema_version: CONSENT_SCHEMA_VERSION,
|
|
74
|
+
consent: null,
|
|
75
|
+
decided_at: null,
|
|
76
|
+
anon_id: null,
|
|
77
|
+
anon_id_created_at: null,
|
|
78
|
+
last_flush_at: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when an env var carries a truthy "on" signal: present, trims to a
|
|
84
|
+
* non-empty string that is neither '0' nor (case-insensitively) 'false'.
|
|
85
|
+
* @param {unknown} raw
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function isTruthyEnvFlag(raw) {
|
|
89
|
+
if (raw === undefined || raw === null) return false;
|
|
90
|
+
const t = String(raw).trim();
|
|
91
|
+
if (t === '' || t === '0') return false;
|
|
92
|
+
if (t.toLowerCase() === 'false') return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Persistence
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read and normalise the persisted consent record. NEVER throws.
|
|
102
|
+
*
|
|
103
|
+
* - Missing file → `{ source: 'default' }` + a fresh default record.
|
|
104
|
+
* - Unparseable / non-object → `{ source: 'corrupt' }` + default record + a
|
|
105
|
+
* stderr WARN pointing at `telemetry status`; the errors array names the fault.
|
|
106
|
+
* - Valid object → `{ source: 'file' }`; missing known fields are
|
|
107
|
+
* filled from defaults and UNKNOWN fields are preserved (additive tolerance).
|
|
108
|
+
*
|
|
109
|
+
* @param {object} [opts]
|
|
110
|
+
* @param {string} [opts.path] Override the read path (test injection).
|
|
111
|
+
* @returns {{ record: object, source: 'file'|'default'|'corrupt', errors: string[] }}
|
|
112
|
+
*/
|
|
113
|
+
export function readTelemetryState({ path } = {}) {
|
|
114
|
+
const target = path || TELEMETRY_JSON_PATH;
|
|
115
|
+
|
|
116
|
+
if (!existsSync(target)) {
|
|
117
|
+
return { record: defaultRecord(), source: 'default', errors: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = readFileSync(target, 'utf8');
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg = `telemetry.json unreadable: ${err?.message ?? String(err)}`;
|
|
125
|
+
console.error(`⚠ telemetry: ${msg}. Using defaults — run 'telemetry status' to inspect.`);
|
|
126
|
+
return { record: defaultRecord(), source: 'corrupt', errors: [msg] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
const msg = `telemetry.json is not valid JSON: ${err?.message ?? String(err)}`;
|
|
134
|
+
console.error(`⚠ telemetry: ${msg}. Using defaults — run 'telemetry status' to inspect.`);
|
|
135
|
+
return { record: defaultRecord(), source: 'corrupt', errors: [msg] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isPlainObject(parsed)) {
|
|
139
|
+
const msg = `telemetry.json is not an object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`;
|
|
140
|
+
console.error(`⚠ telemetry: ${msg}. Using defaults — run 'telemetry status' to inspect.`);
|
|
141
|
+
return { record: defaultRecord(), source: 'corrupt', errors: [msg] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Additive tolerance: defaults fill missing known fields, `parsed` overrides
|
|
145
|
+
// and carries any unknown fields through untouched.
|
|
146
|
+
const record = { ...defaultRecord(), ...parsed };
|
|
147
|
+
return { record, source: 'file', errors: [] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Atomically persist a consent record via {@link writeJsonAtomicSync}. Creates
|
|
152
|
+
* the parent directory first. NEVER throws — filesystem failures are returned.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} record The full record to persist.
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {string} [opts.path] Override the write path (test injection).
|
|
157
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
158
|
+
*/
|
|
159
|
+
export function writeTelemetryState(record, { path } = {}) {
|
|
160
|
+
const target = path || TELEMETRY_JSON_PATH;
|
|
161
|
+
try {
|
|
162
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
165
|
+
}
|
|
166
|
+
const res = writeJsonAtomicSync(target, record);
|
|
167
|
+
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Consent decision
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @typedef {'disabled-env'|'disabled-consent'|'enabled-env'|'enabled-fleet'|'enabled-consent'|'no-consent'} ConsentState
|
|
176
|
+
*/
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve the effective telemetry posture from all signals. Pure — no I/O.
|
|
180
|
+
* Fail-closed: `send` is true ONLY for the three `enabled-*` states.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} [opts]
|
|
183
|
+
* @param {Record<string, string|undefined>} [opts.env] Env source (default process.env).
|
|
184
|
+
* @param {object} [opts.ownerConfig] Parsed owner.yaml object (fleet flag lives at `.telemetry.enabled`).
|
|
185
|
+
* @param {object|null} [opts.state] A persisted record (from {@link readTelemetryState}).
|
|
186
|
+
* @param {boolean} [opts.interactive] Whether a TTY prompt is possible right now.
|
|
187
|
+
* @returns {{ state: ConsentState, send: boolean, prompt: boolean, reason: string }}
|
|
188
|
+
*/
|
|
189
|
+
export function resolveConsent({ env = process.env, ownerConfig = {}, state = null, interactive = false } = {}) {
|
|
190
|
+
// 1. DO_NOT_TRACK — universal opt-out, per-shell escape above everything.
|
|
191
|
+
if (isTruthyEnvFlag(env?.DO_NOT_TRACK)) {
|
|
192
|
+
return { state: 'disabled-env', send: false, prompt: false, reason: 'DO_NOT_TRACK is set' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 2. SO_TELEMETRY_DISABLED=1 — per-shell escape, outranks SO_TELEMETRY and fleet.
|
|
196
|
+
if (env?.SO_TELEMETRY_DISABLED === '1') {
|
|
197
|
+
return { state: 'disabled-env', send: false, prompt: false, reason: 'SO_TELEMETRY_DISABLED=1' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 3. SO_TELEMETRY=1 — explicit per-shell opt-in.
|
|
201
|
+
if (env?.SO_TELEMETRY === '1') {
|
|
202
|
+
return { state: 'enabled-env', send: true, prompt: false, reason: 'SO_TELEMETRY=1' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 4. Fleet flag — owner.yaml telemetry.enabled must be STRICTLY boolean true.
|
|
206
|
+
if (ownerConfig?.telemetry?.enabled === true) {
|
|
207
|
+
return { state: 'enabled-fleet', send: true, prompt: false, reason: 'owner.yaml telemetry.enabled=true' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 5/6. Stored per-user decision.
|
|
211
|
+
if (state?.consent === CONSENT_GRANTED) {
|
|
212
|
+
return { state: 'enabled-consent', send: true, prompt: false, reason: 'stored consent: granted' };
|
|
213
|
+
}
|
|
214
|
+
if (state?.consent === CONSENT_DENIED) {
|
|
215
|
+
return { state: 'disabled-consent', send: false, prompt: false, reason: 'stored consent: denied' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 7. No decision on record — only prompt when a TTY is available; never send.
|
|
219
|
+
return {
|
|
220
|
+
state: 'no-consent',
|
|
221
|
+
send: false,
|
|
222
|
+
prompt: interactive === true,
|
|
223
|
+
reason: 'no consent decision recorded',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read-modify-atomic-write helper shared by grant/deny. Preserves the anon_id
|
|
229
|
+
* fields and any unknown fields already on the record; starts from a clean
|
|
230
|
+
* default record when the on-disk file is missing or corrupt (no garbage merge).
|
|
231
|
+
*
|
|
232
|
+
* @param {'granted'|'denied'} decision
|
|
233
|
+
* @param {{ path?: string, now: string }} args
|
|
234
|
+
* @returns {{ ok: boolean, record: object }}
|
|
235
|
+
*/
|
|
236
|
+
function setConsentDecision(decision, { path, now }) {
|
|
237
|
+
const { record } = readTelemetryState({ path });
|
|
238
|
+
const next = { ...record, consent: decision, decided_at: now };
|
|
239
|
+
const res = writeTelemetryState(next, { path });
|
|
240
|
+
return { ok: res.ok, record: next };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Record an affirmative consent decision. anon_id fields are left untouched.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} [opts]
|
|
247
|
+
* @param {string} [opts.path] Override the state path (test injection).
|
|
248
|
+
* @param {string} [opts.now] ISO timestamp for `decided_at` (defaults to now).
|
|
249
|
+
* @returns {{ ok: boolean, record: object }}
|
|
250
|
+
*/
|
|
251
|
+
export function grantConsent({ path, now = new Date().toISOString() } = {}) {
|
|
252
|
+
return setConsentDecision(CONSENT_GRANTED, { path, now });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Record a refusal decision. anon_id fields are left untouched.
|
|
257
|
+
*
|
|
258
|
+
* @param {object} [opts]
|
|
259
|
+
* @param {string} [opts.path] Override the state path (test injection).
|
|
260
|
+
* @param {string} [opts.now] ISO timestamp for `decided_at` (defaults to now).
|
|
261
|
+
* @returns {{ ok: boolean, record: object }}
|
|
262
|
+
*/
|
|
263
|
+
export function denyConsent({ path, now = new Date().toISOString() } = {}) {
|
|
264
|
+
return setConsentDecision(CONSENT_DENIED, { path, now });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Environment probes
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* True when running in a recognised CI environment: `CI` set to a truthy value,
|
|
273
|
+
* or any of the well-known CI marker vars present and non-empty.
|
|
274
|
+
*
|
|
275
|
+
* @param {Record<string, string|undefined>} [env] Env source (default process.env).
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
export function isCiEnv(env = process.env) {
|
|
279
|
+
if (isTruthyEnvFlag(env?.CI)) return true;
|
|
280
|
+
for (const key of ['GITHUB_ACTIONS', 'GITLAB_CI', 'CONTINUOUS_INTEGRATION']) {
|
|
281
|
+
const v = env?.[key];
|
|
282
|
+
if (v !== undefined && v !== null && String(v).trim() !== '') return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* True when there is no interactive TTY to prompt on: any CI environment, or a
|
|
289
|
+
* stdout that is not a TTY. Fail-closed toward headless — anything that is not a
|
|
290
|
+
* confirmed interactive TTY counts as headless.
|
|
291
|
+
*
|
|
292
|
+
* @param {Record<string, string|undefined>} [env] Env source (default process.env).
|
|
293
|
+
* @param {{ stdout?: { isTTY?: boolean } }} [streams] Stream source (default process).
|
|
294
|
+
* @returns {boolean}
|
|
295
|
+
*/
|
|
296
|
+
export function isHeadless(env = process.env, streams = process) {
|
|
297
|
+
if (isCiEnv(env)) return true;
|
|
298
|
+
return streams?.stdout?.isTTY !== true;
|
|
299
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/paths.mjs — host-local telemetry paths (Epic #841).
|
|
3
|
+
*
|
|
4
|
+
* Host-local telemetry paths — constants only, imported by both consent.mjs
|
|
5
|
+
* (policy) and queue.mjs (storage) to avoid a wrong-direction dependency.
|
|
6
|
+
*
|
|
7
|
+
* This module holds NO logic and performs NO I/O beyond computing three path
|
|
8
|
+
* constants under `~/.config/session-orchestrator/` at import time. Because it
|
|
9
|
+
* depends on nothing else in the telemetry tree, both the policy layer
|
|
10
|
+
* (consent.mjs) and the storage layer (queue.mjs) single-source their paths
|
|
11
|
+
* here without either importing the other. The earlier queue.mjs → consent.mjs
|
|
12
|
+
* edge was a wrong-direction dependency — the generic offline queue must not
|
|
13
|
+
* hang off the telemetry consent policy; routing both through this leaf module
|
|
14
|
+
* removes that coupling while keeping a single source of truth for the paths.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
/** Host-local config directory holding all telemetry state. */
|
|
21
|
+
export const TELEMETRY_DIR = join(homedir(), '.config', 'session-orchestrator');
|
|
22
|
+
|
|
23
|
+
/** Default path for the persisted consent record. */
|
|
24
|
+
export const TELEMETRY_JSON_PATH = join(TELEMETRY_DIR, 'telemetry.json');
|
|
25
|
+
|
|
26
|
+
/** Default path for the pending-events send queue (owned by queue.mjs). */
|
|
27
|
+
export const TELEMETRY_QUEUE_PATH = join(TELEMETRY_DIR, 'telemetry-queue.ndjson');
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/queue.mjs — host-local bounded NDJSON offline queue for anonymous
|
|
3
|
+
* usage telemetry (Epic #841, Issue #844 FA3).
|
|
4
|
+
*
|
|
5
|
+
* Persists telemetry batches to a single NDJSON file at
|
|
6
|
+
* `~/.config/session-orchestrator/telemetry-queue.ndjson` (one JSON object per
|
|
7
|
+
* line: `{ queued_at, batch }`) so that a batch collected while the sender is
|
|
8
|
+
* offline (network down, endpoint unreachable) is not lost — it waits in the
|
|
9
|
+
* queue until the next successful `drain()`.
|
|
10
|
+
*
|
|
11
|
+
* The queue is BOUNDED on two independent axes — batch count (`MAX_BATCHES`)
|
|
12
|
+
* and serialized byte size (`MAX_QUEUE_BYTES`) — dropping the OLDEST entries
|
|
13
|
+
* first (FIFO) whenever either cap is exceeded. This prevents an unbounded
|
|
14
|
+
* queue from growing across an extended offline period.
|
|
15
|
+
*
|
|
16
|
+
* Every public function accepts a `{ path }` override (defaulting to
|
|
17
|
+
* `TELEMETRY_QUEUE_PATH`) for test injection, and NEVER throws — filesystem
|
|
18
|
+
* or serialization failures are swallowed and reported via the function's
|
|
19
|
+
* own result shape, mirroring the `scripts/lib/eval/sink.mjs` /
|
|
20
|
+
* `scripts/lib/events-rotation.mjs` "never throw a caller can't route around"
|
|
21
|
+
* convention used elsewhere in this repo.
|
|
22
|
+
*
|
|
23
|
+
* This storage layer imports ONLY the constants-only leaf module ./paths.mjs
|
|
24
|
+
* (for TELEMETRY_QUEUE_PATH) and the generic ../io.mjs helpers — never a policy
|
|
25
|
+
* sibling (consent.mjs) or the schema. Keeping the queue off the consent policy
|
|
26
|
+
* preserves the correct dependency direction: the generic offline queue must not
|
|
27
|
+
* hang off the telemetry consent layer.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
import { mkdirSync, writeFileSync, renameSync, existsSync, statSync } from 'node:fs';
|
|
32
|
+
import { randomBytes } from 'node:crypto';
|
|
33
|
+
import { readJsonlFile } from '../io.mjs';
|
|
34
|
+
import { TELEMETRY_QUEUE_PATH } from './paths.mjs';
|
|
35
|
+
|
|
36
|
+
/** Host-local NDJSON offline queue path — single-sourced from ./paths.mjs (a constants-only leaf module; two independently computed copies can silently drift). */
|
|
37
|
+
export { TELEMETRY_QUEUE_PATH };
|
|
38
|
+
|
|
39
|
+
/** Maximum number of queued batch entries before oldest-first eviction kicks in. */
|
|
40
|
+
export const MAX_BATCHES = 50;
|
|
41
|
+
|
|
42
|
+
/** Maximum serialized queue size in bytes before oldest-first eviction kicks in. */
|
|
43
|
+
export const MAX_QUEUE_BYTES = 256 * 1024;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Internal helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read all queue entries from disk. Never throws: a missing file yields `[]`
|
|
51
|
+
* and any corrupted/malformed line is silently skipped (readJsonlFile with
|
|
52
|
+
* `skipInvalid: true`) — a corrupted line is dropped for good the next time
|
|
53
|
+
* the queue is rewritten (enqueue/drain/dropOldest/clear all rewrite in full).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} filePath
|
|
56
|
+
* @returns {Array<{queued_at: string, batch: object}>}
|
|
57
|
+
*/
|
|
58
|
+
function _readEntries(filePath) {
|
|
59
|
+
try {
|
|
60
|
+
return readJsonlFile(filePath, { skipInvalid: true });
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Serialize entries as NDJSON text (one JSON object per line, trailing
|
|
68
|
+
* newline when non-empty; empty string when entries is empty).
|
|
69
|
+
*
|
|
70
|
+
* @param {Array<object>} entries
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function _toNdjson(entries) {
|
|
74
|
+
if (entries.length === 0) return '';
|
|
75
|
+
return `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Byte length of the entries once serialized as NDJSON — used to enforce
|
|
80
|
+
* `MAX_QUEUE_BYTES`.
|
|
81
|
+
*
|
|
82
|
+
* @param {Array<object>} entries
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
function _serializedByteLength(entries) {
|
|
86
|
+
return Buffer.byteLength(_toNdjson(entries), 'utf8');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Atomically replace `filePath` with the NDJSON serialization of `entries`
|
|
91
|
+
* via tmp-file + `renameSync` in the SAME directory (same-filesystem rename
|
|
92
|
+
* is atomic on POSIX — mirrors `writeJsonAtomicSync` in `scripts/lib/io.mjs`,
|
|
93
|
+
* which has no NDJSON-array variant). Creates the parent directory with
|
|
94
|
+
* `mkdirSync(dir, { recursive: true })` first.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} filePath
|
|
97
|
+
* @param {Array<object>} entries
|
|
98
|
+
* @throws {Error} on filesystem failure — callers MUST catch (this helper is
|
|
99
|
+
* intentionally throw-on-failure; the never-throws contract lives at
|
|
100
|
+
* the public-API layer).
|
|
101
|
+
*/
|
|
102
|
+
function _writeEntriesAtomicSync(filePath, entries) {
|
|
103
|
+
const dir = dirname(filePath);
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
const tmpSuffix = randomBytes(6).toString('hex');
|
|
106
|
+
const tmpFile = join(dir, `.tmp.${tmpSuffix}`);
|
|
107
|
+
writeFileSync(tmpFile, _toNdjson(entries), 'utf8');
|
|
108
|
+
renameSync(tmpFile, filePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Evict oldest entries (FIFO) until both the batch-count cap and the
|
|
113
|
+
* serialized-byte cap are satisfied.
|
|
114
|
+
*
|
|
115
|
+
* @param {Array<object>} entries
|
|
116
|
+
* @param {number} maxBatches
|
|
117
|
+
* @param {number} maxBytes
|
|
118
|
+
* @returns {{entries: Array<object>, dropped: number}}
|
|
119
|
+
*/
|
|
120
|
+
function _enforceCaps(entries, maxBatches, maxBytes) {
|
|
121
|
+
let out = entries;
|
|
122
|
+
let dropped = 0;
|
|
123
|
+
|
|
124
|
+
while (out.length > maxBatches) {
|
|
125
|
+
out = out.slice(1);
|
|
126
|
+
dropped++;
|
|
127
|
+
}
|
|
128
|
+
while (out.length > 0 && _serializedByteLength(out) > maxBytes) {
|
|
129
|
+
out = out.slice(1);
|
|
130
|
+
dropped++;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { entries: out, dropped };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Public API
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Append a telemetry batch to the offline queue, then evict the oldest
|
|
142
|
+
* entries (FIFO) until both `maxBatches` and `maxBytes` caps are satisfied.
|
|
143
|
+
* Rewrites the queue file atomically. Never throws.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} batch — arbitrary JSON-serializable telemetry batch payload.
|
|
146
|
+
* @param {object} [opts]
|
|
147
|
+
* @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
|
|
148
|
+
* @param {string} [opts.now] — ISO8601 timestamp stamped as `queued_at` (defaults to `new Date().toISOString()`).
|
|
149
|
+
* @param {number} [opts.maxBatches] — batch-count cap (defaults to `MAX_BATCHES`).
|
|
150
|
+
* @param {number} [opts.maxBytes] — serialized-byte cap (defaults to `MAX_QUEUE_BYTES`).
|
|
151
|
+
* @returns {{ok: true, dropped: number, total: number} | {ok: false, dropped: 0, total: 0, error: string}}
|
|
152
|
+
*/
|
|
153
|
+
export function enqueue(batch, opts = {}) {
|
|
154
|
+
const {
|
|
155
|
+
path: filePath = TELEMETRY_QUEUE_PATH,
|
|
156
|
+
now = new Date().toISOString(),
|
|
157
|
+
maxBatches = MAX_BATCHES,
|
|
158
|
+
maxBytes = MAX_QUEUE_BYTES,
|
|
159
|
+
} = opts;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const existing = _readEntries(filePath);
|
|
163
|
+
const entry = { queued_at: now, batch };
|
|
164
|
+
const combined = [...existing, entry];
|
|
165
|
+
|
|
166
|
+
const { entries, dropped } = _enforceCaps(combined, maxBatches, maxBytes);
|
|
167
|
+
|
|
168
|
+
_writeEntriesAtomicSync(filePath, entries);
|
|
169
|
+
return { ok: true, dropped, total: entries.length };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { ok: false, dropped: 0, total: 0, error: err?.message ?? String(err) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Drain the queue by handing all queued batches to `sender` as a single call.
|
|
177
|
+
* On success (sender's returned promise resolves), the queue is atomically
|
|
178
|
+
* emptied. On failure (sender's promise rejects or throws synchronously), the
|
|
179
|
+
* queue is left byte-identical to before the call. When `sender` is omitted,
|
|
180
|
+
* this is a no-op. Never throws.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} [opts]
|
|
183
|
+
* @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
|
|
184
|
+
* @param {(batches: object[]) => Promise<void>} [opts.sender] — async callback invoked with all queued batch payloads.
|
|
185
|
+
* @returns {Promise<{sent: number, remaining: number, dropped: number}>}
|
|
186
|
+
*/
|
|
187
|
+
export async function drain(opts = {}) {
|
|
188
|
+
const { path: filePath = TELEMETRY_QUEUE_PATH, sender } = opts;
|
|
189
|
+
|
|
190
|
+
const entries = _readEntries(filePath);
|
|
191
|
+
|
|
192
|
+
if (typeof sender !== 'function') {
|
|
193
|
+
return { sent: 0, remaining: entries.length, dropped: 0 };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await sender(entries.map((entry) => entry.batch));
|
|
198
|
+
} catch {
|
|
199
|
+
return { sent: 0, remaining: entries.length, dropped: 0 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
_writeEntriesAtomicSync(filePath, []);
|
|
204
|
+
return { sent: entries.length, remaining: 0, dropped: 0 };
|
|
205
|
+
} catch {
|
|
206
|
+
// Sender succeeded but the queue could not be cleared — report the
|
|
207
|
+
// batches as unsent-safe (still on disk) rather than losing them.
|
|
208
|
+
return { sent: 0, remaining: entries.length, dropped: 0 };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read all queue entries without mutating the queue. Never throws.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} [opts]
|
|
216
|
+
* @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
|
|
217
|
+
* @returns {Array<{queued_at: string, batch: object}>} — `[]` when the queue file does not exist.
|
|
218
|
+
*/
|
|
219
|
+
export function peekAll(opts = {}) {
|
|
220
|
+
const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
|
|
221
|
+
return _readEntries(filePath);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Drop the `n` oldest entries (FIFO) from the queue and atomically rewrite
|
|
226
|
+
* it. Never throws.
|
|
227
|
+
*
|
|
228
|
+
* @param {number} n — number of oldest entries to drop (clamped to `[0, queue length]`).
|
|
229
|
+
* @param {object} [opts]
|
|
230
|
+
* @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
|
|
231
|
+
* @returns {{dropped: number, remaining: number}}
|
|
232
|
+
*/
|
|
233
|
+
export function dropOldest(n, opts = {}) {
|
|
234
|
+
const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const entries = _readEntries(filePath);
|
|
238
|
+
const count = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
239
|
+
const dropCount = Math.min(count, entries.length);
|
|
240
|
+
const remaining = entries.slice(dropCount);
|
|
241
|
+
|
|
242
|
+
_writeEntriesAtomicSync(filePath, remaining);
|
|
243
|
+
return { dropped: dropCount, remaining: remaining.length };
|
|
244
|
+
} catch {
|
|
245
|
+
return { dropped: 0, remaining: 0 };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Empty the queue (atomic truncate to zero entries). Never throws.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} [opts]
|
|
253
|
+
* @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
|
|
254
|
+
* @returns {{ok: boolean}}
|
|
255
|
+
*/
|
|
256
|
+
export function clear(opts = {}) {
|
|
257
|
+
const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
|
|
258
|
+
try {
|
|
259
|
+
_writeEntriesAtomicSync(filePath, []);
|
|
260
|
+
return { ok: true };
|
|
261
|
+
} catch {
|
|
262
|
+
return { ok: false };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Report current queue occupancy. Never throws.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} [opts]
|
|
270
|
+
* @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
|
|
271
|
+
* @returns {{count: number, bytes: number}} — `bytes` is the on-disk file size (`0` when the file does not exist).
|
|
272
|
+
*/
|
|
273
|
+
export function queueStats(opts = {}) {
|
|
274
|
+
const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
|
|
275
|
+
try {
|
|
276
|
+
const entries = _readEntries(filePath);
|
|
277
|
+
const bytes = existsSync(filePath) ? statSync(filePath).size : 0;
|
|
278
|
+
return { count: entries.length, bytes };
|
|
279
|
+
} catch {
|
|
280
|
+
return { count: 0, bytes: 0 };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Exported for atomicity-smoke tests that want to confirm no stray tmp file
|
|
285
|
+
// is left behind after a write (avoids re-deriving the tmp-name pattern
|
|
286
|
+
// baked into `_writeEntriesAtomicSync` above).
|
|
287
|
+
export const _TMP_FILE_PATTERN = /^\.tmp\./;
|