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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/schema.mjs — usage-ping v1 schema, whitelist projection, roster
|
|
3
|
+
* filter, and payload builder for anonymous usage telemetry (Epic #841, S2 /
|
|
4
|
+
* GitLab #843; PRD docs/prd/2026-07-20-anonymous-usage-telemetry.md §3-FA2).
|
|
5
|
+
*
|
|
6
|
+
* Privacy is the raison d'être of this module. It mirrors the data-minimization
|
|
7
|
+
* pattern of scripts/lib/eval/schema.mjs (SUBMISSION_FIELDS + projectSubmission):
|
|
8
|
+
* a FROZEN field whitelist plus a data-driven projection so nothing outside the
|
|
9
|
+
* whitelist can ever reach the wire — no paths, repo names, prompts, args, git
|
|
10
|
+
* remotes, or hostnames. Skill/command names are additionally projected against
|
|
11
|
+
* the shipped plugin roster: any name not in the roster becomes the opaque token
|
|
12
|
+
* "other", so custom/third-party names never leave the machine.
|
|
13
|
+
*
|
|
14
|
+
* ── USAGE-PING RECORD (schema_version: 1, record_kind: "usage-ping") ─────────
|
|
15
|
+
* record_kind 'usage-ping'
|
|
16
|
+
* schema_version 1
|
|
17
|
+
* anon_id set downstream by ensureAnonId (anon-id.mjs) — NOT here
|
|
18
|
+
* sent_at ISO 8601 string (passed in as `now`)
|
|
19
|
+
* plugin_version package.json version at SO_PLUGIN_ROOT, or 'unknown'
|
|
20
|
+
* platform claude|codex|cursor|pi (+ 'other' fallback)
|
|
21
|
+
* os normalizeOs(process.platform) — closed set, else 'other'
|
|
22
|
+
* arch normalizeArch(process.arch) — closed set, else 'other'
|
|
23
|
+
* node_major integer major version of the running Node
|
|
24
|
+
* ci boolean — running under CI
|
|
25
|
+
* fleet boolean — operator fleet mode (owner.yaml telemetry.enabled)
|
|
26
|
+
* session_type housekeeping|feature|deep (+ 'other' fallback)
|
|
27
|
+
* duration_bucket '<15m'|'15-60m'|'1-3h'|'>3h'
|
|
28
|
+
* skills roster-filtered, deduped, sorted string[] (≤100)
|
|
29
|
+
* commands roster-filtered, deduped, sorted string[] (≤100)
|
|
30
|
+
*
|
|
31
|
+
* The builder returns the record WITHOUT anon_id — the caller sets it via
|
|
32
|
+
* ensureAnonId so the ID-rotation concern stays isolated in anon-id.mjs.
|
|
33
|
+
*
|
|
34
|
+
* This module reads files (package.json, the roster surface dirs) but writes
|
|
35
|
+
* none and holds no mutable runtime state.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { SO_PLATFORM, SO_PLUGIN_ROOT } from '../platform.mjs';
|
|
39
|
+
import { enumerateSurface } from '../sunset/walker.mjs';
|
|
40
|
+
import { readPluginVersionFromPackageJson } from '../bootstrap-lock-freshness.mjs';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Constants
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Current usage-ping schema version. Additive-only within a version. */
|
|
47
|
+
export const USAGE_PING_SCHEMA_VERSION = 1;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* FROZEN whitelist of the ONLY fields a usage-ping may carry. projectUsagePing is
|
|
51
|
+
* fully data-driven from this list: a field absent here is dropped. Widening this
|
|
52
|
+
* list is the intended fake-regression tripwire — adding a leaky field (e.g.
|
|
53
|
+
* 'repo') turns the data-minimization projection test RED.
|
|
54
|
+
*/
|
|
55
|
+
export const USAGE_PING_FIELDS = Object.freeze([
|
|
56
|
+
'record_kind',
|
|
57
|
+
'schema_version',
|
|
58
|
+
'anon_id',
|
|
59
|
+
'sent_at',
|
|
60
|
+
'plugin_version',
|
|
61
|
+
'platform',
|
|
62
|
+
'os',
|
|
63
|
+
'arch',
|
|
64
|
+
'node_major',
|
|
65
|
+
'ci',
|
|
66
|
+
'fleet',
|
|
67
|
+
'session_type',
|
|
68
|
+
'duration_bucket',
|
|
69
|
+
'skills',
|
|
70
|
+
'commands',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/** Exact duration-bucket tokens (ASCII, stable wire values). */
|
|
74
|
+
export const DURATION_BUCKETS = Object.freeze(['<15m', '15-60m', '1-3h', '>3h']);
|
|
75
|
+
|
|
76
|
+
/** Roster-projection guardrails. */
|
|
77
|
+
const ROSTER_OTHER = 'other';
|
|
78
|
+
const MAX_NAME_LENGTH = 64;
|
|
79
|
+
const MAX_NAMES = 100;
|
|
80
|
+
|
|
81
|
+
/** Enum fallbacks. */
|
|
82
|
+
const VALID_PLATFORMS = Object.freeze(['claude', 'codex', 'cursor', 'pi']);
|
|
83
|
+
const VALID_SESSION_TYPES = Object.freeze(['housekeeping', 'feature', 'deep']);
|
|
84
|
+
const PLATFORM_OTHER = 'other';
|
|
85
|
+
const SESSION_TYPE_OTHER = 'other';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Closed sets for os/arch client-side normalization. A value outside the set —
|
|
89
|
+
* including a future Node os/arch string — degrades to 'other', so the client
|
|
90
|
+
* never sends a value the server's enum would reject with a 400. These MUST
|
|
91
|
+
* mirror the server's OS/ARCH enums (server/validate.mjs); a loong64 client
|
|
92
|
+
* therefore sends 'loong64' (in-set → accepted with a signal), while a
|
|
93
|
+
* hypothetical unknown value degrades safely.
|
|
94
|
+
*/
|
|
95
|
+
const OS_VALUES = Object.freeze(['aix', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32', 'android']);
|
|
96
|
+
const ARCH_VALUES = Object.freeze([
|
|
97
|
+
'arm', 'arm64', 'ia32', 'loong64', 'mips', 'mipsel', 'ppc', 'ppc64', 'riscv64', 's390', 's390x', 'x64',
|
|
98
|
+
]);
|
|
99
|
+
const OS_OTHER = 'other';
|
|
100
|
+
const ARCH_OTHER = 'other';
|
|
101
|
+
|
|
102
|
+
/** Prefix under which skills are recorded in skill-invocations.jsonl. */
|
|
103
|
+
const SKILL_PREFIX = 'session-orchestrator:';
|
|
104
|
+
|
|
105
|
+
const MS_PER_SECOND = 1000;
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Internal helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function isPlainObject(v) {
|
|
112
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isNonEmptyString(v) {
|
|
116
|
+
return typeof v === 'string' && v.trim().length > 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Whitelist projection (Data-Minimization)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Project an arbitrary object onto the usage-ping whitelist (USAGE_PING_FIELDS).
|
|
125
|
+
* Fully data-driven: any key not on the whitelist — paths, repo names, prompts,
|
|
126
|
+
* args, hostnames, rogue extras — is dropped. Array fields (skills, commands) are
|
|
127
|
+
* copied as NEW arrays so no caller reference leaks into the projection.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} input
|
|
130
|
+
* @returns {object} whitelist-safe projection
|
|
131
|
+
*/
|
|
132
|
+
export function projectUsagePing(input) {
|
|
133
|
+
if (!isPlainObject(input)) return {};
|
|
134
|
+
const out = {};
|
|
135
|
+
for (const key of USAGE_PING_FIELDS) {
|
|
136
|
+
if (key in input) {
|
|
137
|
+
const v = input[key];
|
|
138
|
+
out[key] = Array.isArray(v) ? [...v] : v;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Roster loader
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Load the shipped plugin roster (skills + commands) from the on-disk surface.
|
|
150
|
+
* Uses enumerateSurface() against SO_PLUGIN_ROOT (never process.cwd()) so the
|
|
151
|
+
* roster reflects the INSTALLED plugin, not the operator's working directory.
|
|
152
|
+
*
|
|
153
|
+
* skills are returned prefixed with 'session-orchestrator:' (matching the names
|
|
154
|
+
* recorded in skill-invocations.jsonl); commands are bare.
|
|
155
|
+
*
|
|
156
|
+
* Fail-closed: if the surface directory is missing (e.g. a partial npm install)
|
|
157
|
+
* or enumeration throws, this returns EMPTY sets and a stderr WARN — an empty
|
|
158
|
+
* roster means every name projects to "other", never a leak.
|
|
159
|
+
*
|
|
160
|
+
* @param {{pluginRoot?: string}} [opts]
|
|
161
|
+
* @returns {{skills: Set<string>, commands: Set<string>}}
|
|
162
|
+
*/
|
|
163
|
+
export function loadRoster({ pluginRoot } = {}) {
|
|
164
|
+
const root = (typeof pluginRoot === 'string' && pluginRoot.trim() !== '')
|
|
165
|
+
? pluginRoot
|
|
166
|
+
: SO_PLUGIN_ROOT;
|
|
167
|
+
|
|
168
|
+
if (!isNonEmptyString(root)) {
|
|
169
|
+
process.stderr.write(
|
|
170
|
+
"[telemetry] WARN: plugin root unresolved — roster empty, all skill/command names project to 'other'\n",
|
|
171
|
+
);
|
|
172
|
+
return { skills: new Set(), commands: new Set() };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const surface = enumerateSurface(root);
|
|
177
|
+
const skills = new Set((surface.skills ?? []).map((name) => `${SKILL_PREFIX}${name}`));
|
|
178
|
+
const commands = new Set(surface.commands ?? []);
|
|
179
|
+
if (skills.size === 0 && commands.size === 0) {
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`[telemetry] WARN: roster surface empty at ${root} — all skill/command names project to 'other'\n`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return { skills, commands };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
process.stderr.write(
|
|
187
|
+
`[telemetry] WARN: roster enumeration failed (${err?.message ?? err}) — all names project to 'other'\n`,
|
|
188
|
+
);
|
|
189
|
+
return { skills: new Set(), commands: new Set() };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Roster name filter
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Project a list of raw names onto the roster: a name present in `rosterSet` is
|
|
199
|
+
* kept verbatim; anything else (off-roster, non-string, or longer than
|
|
200
|
+
* MAX_NAME_LENGTH chars) becomes the opaque token "other". The result is
|
|
201
|
+
* deduplicated and sorted, "other" appears at most once, and the list is capped
|
|
202
|
+
* at MAX_NAMES entries.
|
|
203
|
+
*
|
|
204
|
+
* @param {string[]} names
|
|
205
|
+
* @param {Set<string>} rosterSet
|
|
206
|
+
* @returns {string[]} deduped, sorted, roster-safe names (≤ MAX_NAMES)
|
|
207
|
+
*/
|
|
208
|
+
export function filterRosterNames(names, rosterSet) {
|
|
209
|
+
const roster = rosterSet instanceof Set ? rosterSet : new Set();
|
|
210
|
+
const raw = Array.isArray(names) ? names : [];
|
|
211
|
+
const mapped = [];
|
|
212
|
+
for (const name of raw) {
|
|
213
|
+
if (typeof name !== 'string' || name.length > MAX_NAME_LENGTH) {
|
|
214
|
+
mapped.push(ROSTER_OTHER);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
mapped.push(roster.has(name) ? name : ROSTER_OTHER);
|
|
218
|
+
}
|
|
219
|
+
const deduped = [...new Set(mapped)].sort();
|
|
220
|
+
return deduped.slice(0, MAX_NAMES);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Duration bucketing
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Map a session duration to a coarse bucket. Boundaries (seconds):
|
|
229
|
+
* <900 → '<15m'
|
|
230
|
+
* [900, 3600) → '15-60m'
|
|
231
|
+
* [3600, 10800] → '1-3h' (exactly 1h and exactly 3h both land here)
|
|
232
|
+
* >10800 → '>3h'
|
|
233
|
+
*
|
|
234
|
+
* Fail-safe: an unparsable, missing, or negative duration returns the most
|
|
235
|
+
* conservative bucket '<15m' (never throws).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} startedAtISO
|
|
238
|
+
* @param {string} completedAtISO
|
|
239
|
+
* @returns {'<15m'|'15-60m'|'1-3h'|'>3h'}
|
|
240
|
+
*/
|
|
241
|
+
export function deriveDurationBucket(startedAtISO, completedAtISO) {
|
|
242
|
+
const start = typeof startedAtISO === 'string' ? Date.parse(startedAtISO) : NaN;
|
|
243
|
+
const end = typeof completedAtISO === 'string' ? Date.parse(completedAtISO) : NaN;
|
|
244
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return '<15m';
|
|
245
|
+
|
|
246
|
+
const seconds = (end - start) / MS_PER_SECOND;
|
|
247
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '<15m';
|
|
248
|
+
if (seconds < 900) return '<15m';
|
|
249
|
+
if (seconds < 3600) return '15-60m';
|
|
250
|
+
if (seconds <= 10800) return '1-3h';
|
|
251
|
+
return '>3h';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Field derivation helpers
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Plugin version from package.json at SO_PLUGIN_ROOT; 'unknown' on any failure.
|
|
260
|
+
* Single-sourced through readPluginVersionFromPackageJson (bootstrap-lock-freshness.mjs),
|
|
261
|
+
* whose null return (missing/unparseable package.json or non-string version) maps to 'unknown'.
|
|
262
|
+
*/
|
|
263
|
+
function resolvePluginVersion() {
|
|
264
|
+
if (!isNonEmptyString(SO_PLUGIN_ROOT)) return 'unknown';
|
|
265
|
+
return readPluginVersionFromPackageJson(SO_PLUGIN_ROOT) ?? 'unknown';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Normalize the detected platform to the closed enum (+ 'other' fallback). */
|
|
269
|
+
function normalizePlatform(platform) {
|
|
270
|
+
return VALID_PLATFORMS.includes(platform) ? platform : PLATFORM_OTHER;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Normalize the session type to the closed enum (+ 'other' fallback). */
|
|
274
|
+
function normalizeSessionType(sessionType) {
|
|
275
|
+
return VALID_SESSION_TYPES.includes(sessionType) ? sessionType : SESSION_TYPE_OTHER;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Normalize an OS identifier (process.platform) against the closed OS_VALUES set.
|
|
280
|
+
* An in-set value is kept verbatim; anything else degrades to 'other' — mirrors
|
|
281
|
+
* normalizePlatform / normalizeSessionType so the client never emits a raw value
|
|
282
|
+
* the server enum would 400 on.
|
|
283
|
+
* @param {unknown} os
|
|
284
|
+
* @returns {string}
|
|
285
|
+
*/
|
|
286
|
+
export function normalizeOs(os) {
|
|
287
|
+
return OS_VALUES.includes(os) ? os : OS_OTHER;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Normalize a CPU arch identifier (process.arch) against the closed ARCH_VALUES
|
|
292
|
+
* set. In-set kept verbatim (e.g. 'loong64' survives); anything else → 'other'.
|
|
293
|
+
* @param {unknown} arch
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
export function normalizeArch(arch) {
|
|
297
|
+
return ARCH_VALUES.includes(arch) ? arch : ARCH_OTHER;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* CI detection from the env: a non-empty CI value that is not '0' and not (case-
|
|
302
|
+
* insensitively) 'false' counts as running under CI.
|
|
303
|
+
* @param {NodeJS.ProcessEnv} env
|
|
304
|
+
* @returns {boolean}
|
|
305
|
+
*/
|
|
306
|
+
function deriveCi(env) {
|
|
307
|
+
const raw = env?.CI;
|
|
308
|
+
if (typeof raw !== 'string') return false;
|
|
309
|
+
const v = raw.trim();
|
|
310
|
+
if (v === '' || v === '0' || v.toLowerCase() === 'false') return false;
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Distinct non-empty string values of `field` across a list of records. */
|
|
315
|
+
function distinctField(records, field) {
|
|
316
|
+
const seen = new Set();
|
|
317
|
+
for (const rec of records) {
|
|
318
|
+
if (!isPlainObject(rec)) continue;
|
|
319
|
+
const value = rec[field];
|
|
320
|
+
if (isNonEmptyString(value)) seen.add(value);
|
|
321
|
+
}
|
|
322
|
+
return [...seen];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Payload builder
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build a usage-ping record from local telemetry inputs. The returned record is
|
|
331
|
+
* whitelist-clean but does NOT carry `anon_id` — the caller sets it via
|
|
332
|
+
* ensureAnonId (anon-id.mjs), keeping ID rotation isolated there.
|
|
333
|
+
*
|
|
334
|
+
* skills are the distinct `.skill` values of `skillInvocations`; commands are the
|
|
335
|
+
* distinct `.command` values of the same records (none in v1 skill-invocations
|
|
336
|
+
* telemetry ⇒ []; the field is honored so a future command-telemetry stream feeds
|
|
337
|
+
* in without a signature change). Both are roster-filtered — off-roster names
|
|
338
|
+
* become "other" — deduped, sorted, and capped. No frequencies are recorded (v1
|
|
339
|
+
* decision).
|
|
340
|
+
*
|
|
341
|
+
* @param {{
|
|
342
|
+
* sessionRecord: object,
|
|
343
|
+
* skillInvocations: object[],
|
|
344
|
+
* ownerConfig?: object,
|
|
345
|
+
* env?: NodeJS.ProcessEnv,
|
|
346
|
+
* now?: string,
|
|
347
|
+
* roster?: {skills: Set<string>, commands: Set<string>}
|
|
348
|
+
* }} args
|
|
349
|
+
* @returns {object} usage-ping record (without anon_id)
|
|
350
|
+
*/
|
|
351
|
+
export function buildUsagePing({
|
|
352
|
+
sessionRecord,
|
|
353
|
+
skillInvocations,
|
|
354
|
+
ownerConfig,
|
|
355
|
+
env = process.env,
|
|
356
|
+
now = new Date().toISOString(),
|
|
357
|
+
roster,
|
|
358
|
+
} = {}) {
|
|
359
|
+
const session = isPlainObject(sessionRecord) ? sessionRecord : {};
|
|
360
|
+
const invocations = Array.isArray(skillInvocations) ? skillInvocations : [];
|
|
361
|
+
const rst = roster ?? loadRoster();
|
|
362
|
+
const rosterSkills = rst?.skills instanceof Set ? rst.skills : new Set();
|
|
363
|
+
const rosterCommands = rst?.commands instanceof Set ? rst.commands : new Set();
|
|
364
|
+
|
|
365
|
+
const skillNames = distinctField(invocations, 'skill');
|
|
366
|
+
const commandNames = distinctField(invocations, 'command');
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
record_kind: 'usage-ping',
|
|
370
|
+
schema_version: USAGE_PING_SCHEMA_VERSION,
|
|
371
|
+
sent_at: now,
|
|
372
|
+
plugin_version: resolvePluginVersion(),
|
|
373
|
+
platform: normalizePlatform(SO_PLATFORM),
|
|
374
|
+
os: normalizeOs(process.platform),
|
|
375
|
+
arch: normalizeArch(process.arch),
|
|
376
|
+
node_major: parseInt(process.versions.node, 10),
|
|
377
|
+
ci: deriveCi(env),
|
|
378
|
+
fleet: ownerConfig?.telemetry?.enabled === true,
|
|
379
|
+
session_type: normalizeSessionType(session.session_type),
|
|
380
|
+
duration_bucket: deriveDurationBucket(session.started_at, session.completed_at),
|
|
381
|
+
skills: filterRosterNames(skillNames, rosterSkills),
|
|
382
|
+
commands: filterRosterNames(commandNames, rosterCommands),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/sync.mjs — batch build + offline-tolerant sync for anonymous usage
|
|
3
|
+
* telemetry (Epic #841, Issue #844 / S3 FA3; PRD
|
|
4
|
+
* docs/prd/2026-07-20-anonymous-usage-telemetry.md §3-FA3).
|
|
5
|
+
*
|
|
6
|
+
* This module is the SEND path. It ties together the W2 primitives:
|
|
7
|
+
* - consent.mjs — resolveConsent (the outermost gate), telemetry.json read/write
|
|
8
|
+
* - schema.mjs — buildUsagePing + projectUsagePing (whitelist projection)
|
|
9
|
+
* - anon-id.mjs — ensureAnonId (lazy mint + 90-day rotation)
|
|
10
|
+
* - queue.mjs — the bounded NDJSON offline queue
|
|
11
|
+
*
|
|
12
|
+
* ── Outermost-seam gating (load-bearing privacy invariant) ───────────────────
|
|
13
|
+
* `resolveConsent()` is the FIRST statement of `flush()`. When it returns
|
|
14
|
+
* `send !== true` the function returns immediately — nothing below the gate is
|
|
15
|
+
* reachable: no fetch, no queue write, and NO anon-ID minting. The anon-ID is
|
|
16
|
+
* minted lazily inside `buildBatch()`, which `flush()` calls ONLY after the gate
|
|
17
|
+
* has passed. This makes "no ID exists until an affirmative-consent send is
|
|
18
|
+
* actually attempted" a structural guarantee, not a discipline.
|
|
19
|
+
*
|
|
20
|
+
* ── Fire-and-forget, never-throw ─────────────────────────────────────────────
|
|
21
|
+
* A flush never throws and never blocks a session beyond the POST timeout. On
|
|
22
|
+
* any send failure (network, timeout, non-2xx) the batch lands in the host-local
|
|
23
|
+
* queue (bounded, oldest-dropped) and the session closes with zero user-facing
|
|
24
|
+
* error.
|
|
25
|
+
*
|
|
26
|
+
* Node ESM. The only network dependency is the global `fetch`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
resolveConsent,
|
|
33
|
+
readTelemetryState,
|
|
34
|
+
writeTelemetryState,
|
|
35
|
+
TELEMETRY_JSON_PATH,
|
|
36
|
+
} from './consent.mjs';
|
|
37
|
+
import { buildUsagePing, projectUsagePing } from './schema.mjs';
|
|
38
|
+
import { ensureAnonId } from './anon-id.mjs';
|
|
39
|
+
import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
|
|
40
|
+
import { loadOwnerConfig } from '../owner-yaml.mjs';
|
|
41
|
+
import { readJsonlFile } from '../io.mjs';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Constants
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Public ingest endpoint. Overridable per-process via SO_TELEMETRY_ENDPOINT (tests/staging). */
|
|
48
|
+
export const TELEMETRY_ENDPOINT = 'https://telemetry.session-orchestrator.com/v1/records';
|
|
49
|
+
|
|
50
|
+
/** Fire-and-forget POST timeout (ms). */
|
|
51
|
+
export const POST_TIMEOUT_MS = 3000;
|
|
52
|
+
|
|
53
|
+
/** Daily-fallback horizon: only flush a backlog older than this. */
|
|
54
|
+
const DAILY_FLUSH_MS = 24 * 60 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Internal helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Resolve the effective ingest endpoint (env override wins). */
|
|
61
|
+
function resolveEndpoint(env) {
|
|
62
|
+
const override = (env?.SO_TELEMETRY_ENDPOINT || '').trim();
|
|
63
|
+
return override || TELEMETRY_ENDPOINT;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The default network sender: one POST carrying the whole batch array as the
|
|
68
|
+
* JSON body (the server accepts arrays), with an AbortSignal timeout. Resolves
|
|
69
|
+
* on a 2xx status, rejects on anything else (which routes the caller into the
|
|
70
|
+
* offline queue).
|
|
71
|
+
*
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {NodeJS.ProcessEnv} opts.env
|
|
74
|
+
* @param {number} opts.timeoutMs
|
|
75
|
+
* @returns {(batches: object[]) => Promise<void>}
|
|
76
|
+
*/
|
|
77
|
+
function defaultSender({ env, timeoutMs }) {
|
|
78
|
+
const endpoint = resolveEndpoint(env);
|
|
79
|
+
return async (batches) => {
|
|
80
|
+
const res = await fetch(endpoint, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify(batches),
|
|
84
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
throw new Error(`telemetry endpoint returned HTTP ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Batch build
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build ONE whitelist-projected usage-ping record from the local JSONL streams.
|
|
98
|
+
*
|
|
99
|
+
* Reads `<metricsDir>/sessions.jsonl` + `<metricsDir>/skill-invocations.jsonl`
|
|
100
|
+
* (metricsDir defaults to `<cwd>/.orchestrator/metrics`). The LAST sessions.jsonl
|
|
101
|
+
* record defines the session window: skill-invocations whose `timestamp >=` its
|
|
102
|
+
* `started_at` are included. When no session record exists, the ping falls back
|
|
103
|
+
* to `session_type: 'other'`, `duration_bucket: '<15m'`, and the invocations of
|
|
104
|
+
* the last 24 hours.
|
|
105
|
+
*
|
|
106
|
+
* anon-ID handling (persist=true, the send path): `ensureAnonId` runs on the
|
|
107
|
+
* telemetry.json record; a created/rotated ID is persisted via
|
|
108
|
+
* `writeTelemetryState`. With `persist=false` (the CLI `show` preview) NOTHING is
|
|
109
|
+
* minted or written — an existing ID is echoed, otherwise a placeholder string is
|
|
110
|
+
* shown. This preserves the lazy-ID invariant even for `show`.
|
|
111
|
+
*
|
|
112
|
+
* INVARIANT: `flush()` calls this ONLY after the consent gate has passed, so the
|
|
113
|
+
* (persisting) anon-ID mint is never reachable under `send !== true`.
|
|
114
|
+
*
|
|
115
|
+
* Never throws — an internal failure returns `{ record: null, reason }`.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} [opts]
|
|
118
|
+
* @param {string} [opts.metricsDir] Metrics dir (default `<cwd>/.orchestrator/metrics`).
|
|
119
|
+
* @param {NodeJS.ProcessEnv} [opts.env] Env source (default process.env).
|
|
120
|
+
* @param {object} [opts.ownerConfig] Parsed owner.yaml (default: loaded here).
|
|
121
|
+
* @param {{skills: Set<string>, commands: Set<string>}} [opts.roster] Roster (default: loaded by schema).
|
|
122
|
+
* @param {string} [opts.now] ISO timestamp for sent_at + rotation clock.
|
|
123
|
+
* @param {string} [opts.statePath] telemetry.json path override (test injection).
|
|
124
|
+
* @param {boolean} [opts.persist=true] Mint+persist the anon-ID (false ⇒ preview only).
|
|
125
|
+
* @returns {{ record: object|null, reason?: string }}
|
|
126
|
+
*/
|
|
127
|
+
export function buildBatch({
|
|
128
|
+
metricsDir,
|
|
129
|
+
env = process.env,
|
|
130
|
+
ownerConfig,
|
|
131
|
+
roster,
|
|
132
|
+
now,
|
|
133
|
+
statePath,
|
|
134
|
+
persist = true,
|
|
135
|
+
} = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
|
|
138
|
+
const nowIso = now || new Date().toISOString();
|
|
139
|
+
|
|
140
|
+
const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
|
|
141
|
+
const invocations = readJsonlFile(path.join(dir, 'skill-invocations.jsonl'), { skipInvalid: true });
|
|
142
|
+
|
|
143
|
+
const sessionRecord = sessions.length > 0 ? sessions[sessions.length - 1] : null;
|
|
144
|
+
|
|
145
|
+
let windowInvocations;
|
|
146
|
+
let sessionForPing;
|
|
147
|
+
if (sessionRecord && typeof sessionRecord.started_at === 'string' && !Number.isNaN(Date.parse(sessionRecord.started_at))) {
|
|
148
|
+
const startMs = Date.parse(sessionRecord.started_at);
|
|
149
|
+
windowInvocations = invocations.filter((rec) => {
|
|
150
|
+
const t = Date.parse(rec?.timestamp);
|
|
151
|
+
return !Number.isNaN(t) && t >= startMs;
|
|
152
|
+
});
|
|
153
|
+
sessionForPing = sessionRecord;
|
|
154
|
+
} else {
|
|
155
|
+
// No usable session record → 24h window + synthetic session (schema
|
|
156
|
+
// fallbacks yield session_type 'other' / duration_bucket '<15m').
|
|
157
|
+
const cutoff = (Number.isNaN(Date.parse(nowIso)) ? Date.now() : Date.parse(nowIso)) - DAILY_FLUSH_MS;
|
|
158
|
+
windowInvocations = invocations.filter((rec) => {
|
|
159
|
+
const t = Date.parse(rec?.timestamp);
|
|
160
|
+
return !Number.isNaN(t) && t >= cutoff;
|
|
161
|
+
});
|
|
162
|
+
sessionForPing = {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cfg = ownerConfig ?? loadOwnerConfig().config;
|
|
166
|
+
|
|
167
|
+
const ping = buildUsagePing({
|
|
168
|
+
sessionRecord: sessionForPing,
|
|
169
|
+
skillInvocations: windowInvocations,
|
|
170
|
+
ownerConfig: cfg,
|
|
171
|
+
env,
|
|
172
|
+
now: nowIso,
|
|
173
|
+
roster,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const target = statePath || TELEMETRY_JSON_PATH;
|
|
177
|
+
const { record: stateRecord } = readTelemetryState({ path: target });
|
|
178
|
+
|
|
179
|
+
if (persist) {
|
|
180
|
+
const { record: nextState, anon_id, created, rotated } = ensureAnonId(stateRecord, { now: nowIso });
|
|
181
|
+
if (created || rotated) {
|
|
182
|
+
writeTelemetryState(nextState, { path: target });
|
|
183
|
+
}
|
|
184
|
+
ping.anon_id = anon_id;
|
|
185
|
+
} else {
|
|
186
|
+
ping.anon_id =
|
|
187
|
+
typeof stateRecord.anon_id === 'string' && stateRecord.anon_id.trim() !== ''
|
|
188
|
+
? stateRecord.anon_id
|
|
189
|
+
: '(generated on first send)';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { record: projectUsagePing(ping) };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return { record: null, reason: `build-error: ${err?.message ?? String(err)}` };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Flush
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Attempt to flush telemetry: gate on consent, build the batch, drain the
|
|
204
|
+
* offline queue together with the new record in ONE send, and empty the queue on
|
|
205
|
+
* success. Never throws, never blocks beyond `timeoutMs`.
|
|
206
|
+
*
|
|
207
|
+
* @param {object} [opts]
|
|
208
|
+
* @param {NodeJS.ProcessEnv} [opts.env] Env source (default process.env).
|
|
209
|
+
* @param {number} [opts.timeoutMs] POST timeout (default POST_TIMEOUT_MS).
|
|
210
|
+
* @param {(batches: object[]) => Promise<void>} [opts.sender] Injected sender (default: network POST).
|
|
211
|
+
* @param {string} [opts.metricsDir] Metrics dir override.
|
|
212
|
+
* @param {string} [opts.statePath] telemetry.json path override.
|
|
213
|
+
* @param {string} [opts.queuePath] queue path override.
|
|
214
|
+
* @param {string} [opts.now] ISO timestamp (sent_at, last_flush_at, rotation clock).
|
|
215
|
+
* @param {object} [opts.ownerConfig] Parsed owner.yaml (default: loaded here). Inject to
|
|
216
|
+
* isolate a test from the host's real owner.yaml fleet flag.
|
|
217
|
+
* @returns {Promise<{ sent: boolean, queued: boolean, state: string, reason: string }>}
|
|
218
|
+
*/
|
|
219
|
+
export async function flush({
|
|
220
|
+
env = process.env,
|
|
221
|
+
timeoutMs = POST_TIMEOUT_MS,
|
|
222
|
+
sender,
|
|
223
|
+
metricsDir,
|
|
224
|
+
statePath,
|
|
225
|
+
queuePath,
|
|
226
|
+
now,
|
|
227
|
+
ownerConfig,
|
|
228
|
+
} = {}) {
|
|
229
|
+
// Resolve owner.yaml once (injectable for hermetic tests). loadOwnerConfig reads the host's
|
|
230
|
+
// real owner.yaml — a test asserting "consent absent" MUST inject {} or a real fleet flag
|
|
231
|
+
// (telemetry.enabled: true) legitimately flips send=true.
|
|
232
|
+
const cfg = ownerConfig ?? loadOwnerConfig().config;
|
|
233
|
+
|
|
234
|
+
// OUTERMOST SEAM — the consent gate is the FIRST statement. When send !== true
|
|
235
|
+
// nothing below (no fetch, no queue write, no anon-ID mint) is reachable.
|
|
236
|
+
const consent = resolveConsent({
|
|
237
|
+
env,
|
|
238
|
+
ownerConfig: cfg,
|
|
239
|
+
state: readTelemetryState({ path: statePath }).record,
|
|
240
|
+
interactive: false,
|
|
241
|
+
});
|
|
242
|
+
if (consent.send !== true) {
|
|
243
|
+
return { sent: false, queued: false, state: consent.state, reason: 'gated' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const nowIso = now || new Date().toISOString();
|
|
247
|
+
|
|
248
|
+
// Build the batch (this lazily mints + persists the anon-ID — only reachable
|
|
249
|
+
// here, i.e. strictly after the gate).
|
|
250
|
+
const { record, reason } = buildBatch({ metricsDir, env, ownerConfig: cfg, statePath, now: nowIso });
|
|
251
|
+
if (!record) {
|
|
252
|
+
return { sent: false, queued: false, state: consent.state, reason: reason || 'no-record' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Debug seam: print the exact payload, send nothing.
|
|
256
|
+
if (env?.SO_TELEMETRY_DEBUG === '1') {
|
|
257
|
+
process.stderr.write(`${JSON.stringify(record)}\n`);
|
|
258
|
+
return { sent: false, queued: false, state: consent.state, reason: 'debug' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Drain the existing queue together with the new record in ONE send.
|
|
262
|
+
const queuedBatches = peekAll({ path: queuePath }).map((entry) => entry.batch);
|
|
263
|
+
const batches = [...queuedBatches, record];
|
|
264
|
+
|
|
265
|
+
const send = typeof sender === 'function' ? sender : defaultSender({ env, timeoutMs });
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
await send(batches);
|
|
269
|
+
} catch {
|
|
270
|
+
// Send failed → only the NEW record joins the queue (queued batches remain
|
|
271
|
+
// in place since the queue was not cleared).
|
|
272
|
+
enqueue(record, { path: queuePath, now: nowIso });
|
|
273
|
+
return { sent: false, queued: true, state: consent.state, reason: 'queued' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 2xx → empty the queue and stamp last_flush_at (preserving the anon-ID that
|
|
277
|
+
// buildBatch may have just persisted).
|
|
278
|
+
clear({ path: queuePath });
|
|
279
|
+
const { record: freshState } = readTelemetryState({ path: statePath });
|
|
280
|
+
writeTelemetryState({ ...freshState, last_flush_at: nowIso }, { path: statePath });
|
|
281
|
+
|
|
282
|
+
return { sent: true, queued: false, state: consent.state, reason: 'sent' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Daily-fallback predicate
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Whether a daily-fallback flush is due: the queue is non-empty AND more than 24h
|
|
291
|
+
* have passed since the last successful flush (a never-flushed queue with items
|
|
292
|
+
* counts as due). Cheap — a single telemetry.json read + a queue stat. Never throws.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} [opts]
|
|
295
|
+
* @param {string} [opts.statePath] telemetry.json path override.
|
|
296
|
+
* @param {string} [opts.queuePath] queue path override.
|
|
297
|
+
* @param {number} [opts.now] Reference time in epoch-ms (default Date.now()).
|
|
298
|
+
* @returns {boolean}
|
|
299
|
+
*/
|
|
300
|
+
export function shouldDailyFlush({ statePath, queuePath, now = Date.now() } = {}) {
|
|
301
|
+
try {
|
|
302
|
+
const { count } = queueStats({ path: queuePath });
|
|
303
|
+
if (count <= 0) return false;
|
|
304
|
+
|
|
305
|
+
const { record } = readTelemetryState({ path: statePath });
|
|
306
|
+
const raw = record?.last_flush_at;
|
|
307
|
+
const lastMs = typeof raw === 'string' && !Number.isNaN(Date.parse(raw)) ? Date.parse(raw) : 0;
|
|
308
|
+
return now - lastMs > DAILY_FLUSH_MS;
|
|
309
|
+
} catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|