session-orchestrator 3.24.0 → 4.0.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/architecture/SKILL.md +18 -0
- package/.agents/skills/autopilot/SKILL.md +17 -0
- package/.agents/skills/bootstrap/SKILL.md +20 -0
- package/.agents/skills/brainstorm/SKILL.md +22 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
- package/.agents/skills/debug/SKILL.md +22 -0
- package/.agents/skills/discovery/SKILL.md +20 -0
- package/.agents/skills/dispatcher/SKILL.md +15 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
- package/.agents/skills/ecosystem-health/SKILL.md +20 -0
- package/.agents/skills/eli5/SKILL.md +20 -0
- package/.agents/skills/eval/SKILL.md +21 -0
- package/.agents/skills/evolve/SKILL.md +21 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
- package/.agents/skills/gitlab-ops/SKILL.md +20 -0
- package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
- package/.agents/skills/grill/SKILL.md +22 -0
- package/.agents/skills/hook-development/SKILL.md +15 -0
- package/.agents/skills/mcp-builder/SKILL.md +15 -0
- package/.agents/skills/memory-cleanup/SKILL.md +21 -0
- package/.agents/skills/mode-selector/SKILL.md +17 -0
- package/.agents/skills/npm-publish/SKILL.md +16 -0
- package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
- package/.agents/skills/persona-panel/SKILL.md +17 -0
- package/.agents/skills/plan/SKILL.md +20 -0
- package/.agents/skills/playwright-driver/SKILL.md +20 -0
- package/.agents/skills/quality-gates/SKILL.md +20 -0
- package/.agents/skills/reconcile/SKILL.md +21 -0
- package/.agents/skills/remote-offload/SKILL.md +20 -0
- package/.agents/skills/repo-audit/SKILL.md +16 -0
- package/.agents/skills/session-end/SKILL.md +20 -0
- package/.agents/skills/session-plan/SKILL.md +20 -0
- package/.agents/skills/session-start/SKILL.md +20 -0
- package/.agents/skills/spinout/SKILL.md +16 -0
- package/.agents/skills/sunset-review/SKILL.md +16 -0
- package/.agents/skills/test-runner/SKILL.md +20 -0
- package/.agents/skills/tmux-layout/SKILL.md +21 -0
- package/.agents/skills/using-orchestrator/SKILL.md +17 -0
- package/.agents/skills/vault-mirror/SKILL.md +15 -0
- package/.agents/skills/vault-sync/SKILL.md +15 -0
- package/.agents/skills/wave-executor/SKILL.md +20 -0
- package/.agents/skills/write-executable-plan/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot.md +2 -2
- package/.cursor/commands/bootstrap.md +1 -1
- package/.cursor/commands/brainstorm.md +1 -1
- package/.cursor/commands/debug.md +1 -1
- package/.cursor/commands/discovery.md +1 -1
- package/.cursor/commands/dispatcher.md +2 -2
- package/.cursor/commands/eli5.md +2 -2
- package/.cursor/commands/eval.md +2 -2
- package/.cursor/commands/evolve.md +1 -1
- package/.cursor/commands/go.md +1 -1
- package/.cursor/commands/grill.md +2 -2
- package/.cursor/commands/memory-cleanup.md +2 -2
- package/.cursor/commands/persona-panel.md +1 -1
- package/.cursor/commands/plan.md +1 -1
- package/.cursor/commands/portfolio.md +1 -1
- package/.cursor/commands/reconcile.md +2 -2
- package/.cursor/commands/release.md +2 -2
- package/.cursor/commands/session.md +2 -2
- package/.cursor/commands/spinout.md +2 -2
- package/.cursor/commands/sunset-review.md +2 -2
- package/.cursor/commands/templates-ack.md +2 -2
- package/.cursor/commands/test.md +2 -2
- package/.cursor/skills/brainstorm/SKILL.md +1 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/quality-gates/SKILL.md +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +1 -1
- package/.orchestrator/policy/blocked-commands.json +121 -0
- package/.orchestrator/policy/ecosystem.schema.json +66 -0
- package/.orchestrator/policy/quality-gates.example.json +16 -0
- package/.orchestrator/policy/quality-gates.schema.json +38 -0
- package/.orchestrator/policy/templates-policy.json +27 -0
- package/.orchestrator/policy/test-profiles.json +47 -0
- package/AGENTS.md +225 -0
- package/CHANGELOG.md +1125 -2
- package/NOTICE +11 -6
- package/README.md +127 -94
- package/agents/eval-judge.md +1 -1
- package/agents/skill-applied-judge.md +1 -1
- package/assets/wave-lifecycle.svg +98 -0
- package/commands/release.md +6 -3
- package/commands/session.md +18 -3
- package/docs/README.md +4 -0
- package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
- package/docs/baseline.md +67 -0
- package/docs/ci-setup.md +108 -62
- package/docs/codex-setup.md +65 -21
- package/docs/components.md +36 -15
- package/docs/cursor-setup.md +6 -2
- package/docs/events-schema.md +9 -6
- package/docs/instruction-delivery.md +62 -0
- package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
- package/docs/migration-v4.md +341 -0
- package/docs/pi-setup.md +6 -1
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +85 -19
- package/docs/scope-collision-guard.md +5 -5
- package/docs/session-config-reference.md +57 -56
- package/docs/session-config-template.md +6 -29
- package/docs/telemetry.md +157 -3
- package/docs/vault-docs-architecture.md +50 -11
- package/hooks/_lib/hook-import-set.json +1487 -0
- package/hooks/_lib/subagent-transcript.mjs +562 -0
- package/hooks/config-protection.mjs +2 -2
- package/hooks/cwd-change-restore.mjs +2 -2
- package/hooks/enforce-commands.mjs +69 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/loop-guard.mjs +3 -3
- package/hooks/on-session-end.mjs +2 -2
- package/hooks/on-session-start.mjs +103 -2
- package/hooks/on-stop.mjs +36 -11
- package/hooks/operator-steer.mjs +2 -2
- package/hooks/post-bash-write-verify.mjs +85 -0
- package/hooks/post-edit-import-probe.mjs +344 -0
- package/hooks/post-subagent-discovery-validator.mjs +187 -431
- package/hooks/post-tool-batch-wave-signal.mjs +118 -4
- package/hooks/post-tool-failure-corrective-context.mjs +2 -2
- package/hooks/post-tooluse-frontend-slop.mjs +3 -3
- package/hooks/pre-bash-destructive-guard.mjs +39 -13
- package/hooks/skill-invocation-telemetry.mjs +17 -5
- package/hooks/subagent-telemetry.mjs +13 -4
- package/monitors/monitors.json +3 -3
- package/package.json +9 -1
- package/pi/prompts/session.md +2 -2
- package/plugin.json +27 -0
- package/scripts/backfill-abandoned-sessions.mjs +50 -4
- package/scripts/backfill-learnings-from-vault.mjs +9 -3
- package/scripts/dialectic-deriver.mjs +73 -8
- package/scripts/export-hw-learnings.mjs +113 -1
- package/scripts/generate-agents-skills.mjs +378 -0
- package/scripts/generate-cursor-adapter.mjs +45 -8
- package/scripts/generate-hook-import-set.mjs +249 -0
- package/scripts/lib/agent-status.mjs +13 -2
- package/scripts/lib/auto-dream.mjs +38 -36
- package/scripts/lib/autonomy/suitability.mjs +6 -0
- package/scripts/lib/autopilot/loop.mjs +2 -2
- package/scripts/lib/ci-status-banner.mjs +220 -75
- package/scripts/lib/codex/plugin-contract.mjs +82 -6
- package/scripts/lib/config/auto-dream.mjs +2 -1
- package/scripts/lib/config/block-header.mjs +8 -0
- package/scripts/lib/config/block-preprocess.mjs +177 -0
- package/scripts/lib/config/broken-window.mjs +2 -1
- package/scripts/lib/config/cold-start.mjs +2 -1
- package/scripts/lib/config/config-protection.mjs +22 -2
- package/scripts/lib/config/context-coverage.mjs +2 -1
- package/scripts/lib/config/cross-repo.mjs +2 -1
- package/scripts/lib/config/custom-phases.mjs +2 -1
- package/scripts/lib/config/dialectic.mjs +2 -1
- package/scripts/lib/config/discovery-validator.mjs +2 -1
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
- package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
- package/scripts/lib/config/docs-orchestrator.mjs +2 -1
- package/scripts/lib/config/docs-staleness.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +2 -1
- package/scripts/lib/config/eval.mjs +2 -1
- package/scripts/lib/config/events-rotation.mjs +2 -1
- package/scripts/lib/config/evolve.mjs +8 -2
- package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
- package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
- package/scripts/lib/config/handover-gate.mjs +2 -1
- package/scripts/lib/config/health-endpoints.mjs +7 -2
- package/scripts/lib/config/issue-budget.mjs +2 -1
- package/scripts/lib/config/loop-guard.mjs +2 -1
- package/scripts/lib/config/memory.mjs +2 -1
- package/scripts/lib/config/moc-staleness.mjs +2 -1
- package/scripts/lib/config/persona-gate-wave.mjs +2 -1
- package/scripts/lib/config/private-config-dir.mjs +67 -0
- package/scripts/lib/config/reconcile.mjs +2 -1
- package/scripts/lib/config/remote-hosts.mjs +2 -1
- package/scripts/lib/config/section-extractor.mjs +7 -1
- package/scripts/lib/config/skill-evolution.mjs +2 -1
- package/scripts/lib/config/slopcheck.mjs +2 -1
- package/scripts/lib/config/state-md-lock.mjs +2 -1
- package/scripts/lib/config/templates-first.mjs +2 -1
- package/scripts/lib/config/test.mjs +2 -1
- package/scripts/lib/config/vault-integration.mjs +7 -1
- package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
- package/scripts/lib/config/vault-staleness.mjs +2 -1
- package/scripts/lib/config/vault-sync.mjs +2 -1
- package/scripts/lib/config/verification-auto-fix.mjs +2 -1
- package/scripts/lib/config/wave-reviewers.mjs +2 -1
- package/scripts/lib/config/worktree-orphans.mjs +2 -1
- package/scripts/lib/convergence-monitor.mjs +82 -16
- package/scripts/lib/dispatcher/rank.mjs +124 -48
- package/scripts/lib/ecosystem-health.mjs +16 -2
- package/scripts/lib/eval/engine.mjs +9 -1
- package/scripts/lib/eval/session-resolve.mjs +23 -4
- package/scripts/lib/events.mjs +22 -6
- package/scripts/lib/frontmatter-guard.mjs +131 -13
- package/scripts/lib/gates/gate-full.mjs +26 -0
- package/scripts/lib/gates/gate-helpers.mjs +76 -0
- package/scripts/lib/hardware-pattern-detector.mjs +18 -1
- package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
- package/scripts/lib/host-identity.mjs +50 -11
- package/scripts/lib/instruction-budget-guard.mjs +171 -5
- package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
- package/scripts/lib/learnings/io.mjs +60 -6
- package/scripts/lib/memory-proposals/store.mjs +30 -22
- package/scripts/lib/owner-config-banner.mjs +43 -6
- package/scripts/lib/owner-config-loader.mjs +21 -10
- package/scripts/lib/owner-interview.mjs +3 -3
- package/scripts/lib/owner-yaml.mjs +207 -14
- package/scripts/lib/platform.mjs +108 -15
- package/scripts/lib/plugin-update-banner.mjs +406 -0
- package/scripts/lib/project-hygiene.mjs +38 -2
- package/scripts/lib/qg-command-drift-banner.mjs +50 -12
- package/scripts/lib/quality-gate.mjs +133 -44
- package/scripts/lib/reconcile/emitter.mjs +68 -6
- package/scripts/lib/reconcile/engine.mjs +13 -4
- package/scripts/lib/reconcile/idempotency.mjs +37 -4
- package/scripts/lib/reconcile/writer.mjs +40 -18
- package/scripts/lib/session-close-backfill.mjs +67 -9
- package/scripts/lib/session-id.mjs +12 -23
- package/scripts/lib/session-identity/own-session.mjs +125 -10
- package/scripts/lib/session-lock-shape.mjs +43 -0
- package/scripts/lib/session-lock.mjs +5 -10
- package/scripts/lib/session-registry.mjs +25 -9
- package/scripts/lib/session-schema/constants.mjs +36 -2
- package/scripts/lib/session-schema/validator.mjs +38 -4
- package/scripts/lib/session-start-probes.mjs +18 -1
- package/scripts/lib/sessions-staleness-banner.mjs +18 -11
- package/scripts/lib/skill-health/join.mjs +17 -4
- package/scripts/lib/state-md.mjs +78 -0
- package/scripts/lib/sunset/walker.mjs +6 -0
- package/scripts/lib/telemetry/schema.mjs +181 -9
- package/scripts/lib/telemetry/sync.mjs +368 -12
- package/scripts/lib/validate/check-agents-skills.mjs +327 -0
- package/scripts/lib/validate/check-agents.mjs +3 -3
- package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
- package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
- package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
- package/scripts/lib/validate/check-skill-links.mjs +163 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
- package/scripts/lib/validate/check-unwired-features.mjs +0 -2
- package/scripts/lib/validate/check-validator-registration.mjs +10 -4
- package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
- package/scripts/lib/vault-backfill/template.mjs +63 -6
- package/scripts/lib/vault-mirror/process.mjs +165 -42
- package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
- package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
- package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
- package/scripts/lib/wave-resource-gate.mjs +8 -2
- package/scripts/lib/wave-sizing.mjs +4 -1
- package/scripts/lib/wave-transcript-tail.mjs +118 -4
- package/scripts/materialize-wave-scope.mjs +12 -5
- package/scripts/memory-propose.mjs +19 -5
- package/scripts/migrate-cold-start-seed.mjs +4 -1
- package/scripts/parse-config.mjs +60 -3
- package/scripts/release.mjs +337 -29
- package/scripts/repair-invalid-sessions.mjs +3 -3
- package/scripts/run-quality-gate.mjs +128 -11
- package/scripts/sweep-expired-learnings.mjs +90 -0
- package/scripts/sync-vault-schema.mjs +3 -1
- package/scripts/telemetry.mjs +2 -2
- package/scripts/validate-plugin.mjs +161 -0
- package/scripts/validate-wave-scope.mjs +28 -8
- package/scripts/wave-scope-binding.mjs +215 -0
- package/skills/_shared/instruction-file-resolution.md +10 -0
- package/skills/_shared/parallel-aware-preamble.md +1 -0
- package/skills/_shared/platform-tools.md +1 -1
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/architecture/SKILL.md +7 -5
- package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
- package/skills/autopilot/SKILL.md +4 -18
- package/skills/claude-md-drift-check/SKILL.md +5 -1
- package/skills/claude-md-drift-check/checker.mjs +62 -2
- package/skills/convergence-monitoring/SIGNALS.md +55 -0
- package/skills/discovery/probes/vault-staleness.mjs +37 -13
- package/skills/discovery/probes-arch.md +20 -18
- package/skills/dispatcher/SKILL.md +3 -2
- package/skills/evolve/SKILL.md +65 -26
- package/skills/frontmatter-guard/SKILL.md +11 -5
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +33 -0
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +18 -905
- package/skills/session-end/phase-3-6-tail.md +10 -3
- package/skills/session-end/plan-verification.md +221 -155
- package/skills/session-end/references/phase-2-quality-gate.md +93 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
- package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
- package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
- package/skills/session-end/references/session-summary-template.md +62 -0
- package/skills/session-plan/SKILL.md +49 -0
- package/skills/session-start/SKILL.md +22 -904
- package/skills/session-start/phase-8-5-express-path.md +1 -1
- package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
- package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
- package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
- package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
- package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
- package/skills/vault-sync/validator.mjs +21 -27
- package/skills/wave-executor/SKILL.md +15 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
- package/skills/wave-executor/references/wave-loop-review.md +570 -0
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
- package/skills/wave-executor/wave-loop.md +14 -1309
- package/templates/_shared/journey-manifest.md +10 -6
- package/.cursor/commands/autopilot-multi.md +0 -14
- package/.cursor/commands/contract-version-bump.md +0 -14
- package/.cursor/commands/journey-audit.md +0 -14
- package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
- package/.cursor/skills/daily/SKILL.md +0 -12
- package/.cursor/skills/domain-model/SKILL.md +0 -13
- package/.cursor/skills/journey-audit/SKILL.md +0 -13
- package/.cursor/skills/skill-creator/SKILL.md +0 -13
- package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
- package/commands/autopilot-multi.md +0 -74
- package/commands/contract-version-bump.md +0 -28
- package/commands/journey-audit.md +0 -43
- package/pi/prompts/autopilot-multi.md +0 -12
- package/pi/prompts/contract-version-bump.md +0 -12
- package/pi/prompts/journey-audit.md +0 -12
- package/scripts/autopilot-multi.mjs +0 -885
- package/scripts/backfill-learnings-expires.mjs +0 -196
- package/scripts/backfill-learnings.mjs +0 -203
- package/scripts/fleet-instruction-scan.mjs +0 -141
- package/scripts/lib/autopilot/dep-graph.mjs +0 -417
- package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
- package/scripts/lib/webhook-url.mjs +0 -105
- package/scripts/lifecycle-sim-v6.mjs +0 -347
- package/scripts/migrate-learnings-jsonl.mjs +0 -189
- package/scripts/migrate-subagents-jsonl.mjs +0 -196
- package/scripts/upload-social-preview.mjs +0 -316
- package/skills/_shared/model-selection.md +0 -64
- package/skills/contract-version-bump/SKILL.md +0 -219
- package/skills/daily/SKILL.md +0 -222
- package/skills/daily/generate.sh +0 -92
- package/skills/daily/templates/daily.md.tpl +0 -36
- package/skills/journey-audit/SKILL.md +0 -270
- package/skills/skill-creator/SKILL.md +0 -168
- package/skills/ubiquitous-language/SKILL.md +0 -97
- package/skills/vault-sync/package-lock.json +0 -40
- /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
- /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import path from 'node:path';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import fs from 'node:fs';
|
|
30
32
|
|
|
31
33
|
import {
|
|
32
34
|
resolveConsent,
|
|
@@ -34,12 +36,15 @@ import {
|
|
|
34
36
|
writeTelemetryState,
|
|
35
37
|
TELEMETRY_JSON_PATH,
|
|
36
38
|
} from './consent.mjs';
|
|
37
|
-
import { buildUsagePing, projectUsagePing } from './schema.mjs';
|
|
39
|
+
import { buildUsagePing, projectUsagePing, normalizeSessionProfile } from './schema.mjs';
|
|
38
40
|
import { ensureAnonId } from './anon-id.mjs';
|
|
39
41
|
import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
|
|
40
42
|
import { loadOwnerConfig } from '../owner-yaml.mjs';
|
|
41
43
|
import { readJsonlFile } from '../io.mjs';
|
|
42
44
|
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
45
|
+
import { resolvePrivateConfigDir } from '../config/private-config-dir.mjs';
|
|
46
|
+
import { readSessionProfile } from '../state-md.mjs';
|
|
47
|
+
import { resolveStateMdPath } from '../state-md/frontmatter-mutators.mjs';
|
|
43
48
|
|
|
44
49
|
// ---------------------------------------------------------------------------
|
|
45
50
|
// Constants
|
|
@@ -85,11 +90,158 @@ function defaultSender({ env, timeoutMs }) {
|
|
|
85
90
|
signal: AbortSignal.timeout(timeoutMs),
|
|
86
91
|
});
|
|
87
92
|
if (!res.ok) {
|
|
88
|
-
|
|
93
|
+
// The status travels ON the error: `flush` needs it to tell a TRANSPORT
|
|
94
|
+
// failure (re-queue) from a SCHEMA rejection (evict — see
|
|
95
|
+
// `isSchemaRejection`). A bare Error carries no such distinction, and
|
|
96
|
+
// parsing the message string would be a second, drift-prone encoding.
|
|
97
|
+
const err = new Error(`telemetry endpoint returned HTTP ${res.status}`);
|
|
98
|
+
err.status = res.status;
|
|
99
|
+
throw err;
|
|
89
100
|
}
|
|
90
101
|
};
|
|
91
102
|
}
|
|
92
103
|
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Sandbox guard (GitLab #1234)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* THE BUG THIS EXISTS FOR: Wave-1 sandbox runs sent 6 production pings with a
|
|
110
|
+
* wrong session_type on 2026-09-06.
|
|
111
|
+
*
|
|
112
|
+
* Six agent sandboxes executed `hooks/on-session-end.mjs` from the repo checkout
|
|
113
|
+
* at 11:02:50–11:03:36Z. Each one resolved the OPERATOR's real `anon_id` and real
|
|
114
|
+
* consent from `~/.config/session-orchestrator/telemetry.json` — because
|
|
115
|
+
* `paths.mjs` computes that path from `homedir()` and does NOT honour
|
|
116
|
+
* `SO_CONFIG_HOME` — while `owner.yaml` was unreachable inside the sandbox and
|
|
117
|
+
* `sessions.jsonl` did not exist. Result: six real records on the ingest server,
|
|
118
|
+
* attributed to a real person, carrying `session_type: "other"` and `fleet: 0`.
|
|
119
|
+
* Same failure class as the d7-F2 registry leak: a bench harness whose SOURCE was
|
|
120
|
+
* faked but whose DESTINATION was not.
|
|
121
|
+
*
|
|
122
|
+
* The guard refuses the send. It runs AFTER the consent gate (so the documented
|
|
123
|
+
* outermost-seam invariant is untouched — see the module docblock) and BEFORE
|
|
124
|
+
* `buildBatch()`, so a refused send performs no network call, no queue write and
|
|
125
|
+
* NO anon-ID mint.
|
|
126
|
+
*
|
|
127
|
+
* Detection, three independent conditions — any ONE refuses:
|
|
128
|
+
*
|
|
129
|
+
* (a) `SO_TELEMETRY_DISABLED` / `DO_NOT_TRACK` — already refused one layer up by
|
|
130
|
+
* `resolveConsent()`; re-asserted here so the guard is complete on its own
|
|
131
|
+
* and a future reordering cannot silently drop it.
|
|
132
|
+
* (b) CONFIG-HOME SPLIT — `SO_CONFIG_HOME` / `XDG_CONFIG_HOME` declares a config
|
|
133
|
+
* home, but the telemetry state is NOT read from inside it. That split IS
|
|
134
|
+
* the leak: the caller believes it redirected the identity, and it did not.
|
|
135
|
+
* A caller that redirects CONSISTENTLY (declared home + a `statePath`
|
|
136
|
+
* inside it) has actually isolated itself and is permitted.
|
|
137
|
+
* (c) TEMP-ROOT — `CLAUDE_PROJECT_DIR` (or the cwd) sits under the OS temp
|
|
138
|
+
* directory or `/tmp`, WHILE the identity is a real one. A real operator
|
|
139
|
+
* session runs from a real checkout.
|
|
140
|
+
*
|
|
141
|
+
* A FOURTH outcome is the guard's own failure: if any probe throws, the answer is
|
|
142
|
+
* `sandbox: true` with `reason: 'sandbox:probe-failed'`. An environment the guard
|
|
143
|
+
* cannot classify is treated as one it would have refused.
|
|
144
|
+
*
|
|
145
|
+
* (b) and (c) share one principle, and it is the whole design: **the guard
|
|
146
|
+
* protects the DEFAULT host identity.** When the effective telemetry state path
|
|
147
|
+
* is itself throwaway — inside the declared config home, or under a temp root —
|
|
148
|
+
* there is no operator identity to leak and the send is permitted. That is what
|
|
149
|
+
* keeps a properly-isolated harness (this repo's convention: a tmp `HOME`, see
|
|
150
|
+
* `tests/_helpers/telemetry-isolation.mjs`) sendable, while the Wave-1 shape —
|
|
151
|
+
* a redirect that the state reader ignored, so the REAL anon_id was used —
|
|
152
|
+
* is refused.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} [opts]
|
|
155
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
156
|
+
* @param {string} [opts.statePath] — an explicit telemetry.json override, if any.
|
|
157
|
+
* @param {string} [opts.cwd]
|
|
158
|
+
* @returns {{ sandbox: boolean, reason: string|null }}
|
|
159
|
+
*/
|
|
160
|
+
export function detectSandbox({ env = process.env, statePath, cwd } = {}) {
|
|
161
|
+
try {
|
|
162
|
+
// (a) explicit opt-out env — belt to resolveConsent's braces.
|
|
163
|
+
if (env?.SO_TELEMETRY_DISABLED === '1') return { sandbox: true, reason: 'sandbox:telemetry-disabled' };
|
|
164
|
+
const dnt = (env?.DO_NOT_TRACK || '').trim();
|
|
165
|
+
if (dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') {
|
|
166
|
+
return { sandbox: true, reason: 'sandbox:do-not-track' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The state path that will ACTUALLY be read — the identity at stake.
|
|
170
|
+
const effectiveStatePath = realOrSelf(statePath || TELEMETRY_JSON_PATH);
|
|
171
|
+
|
|
172
|
+
// (b) config-home split: a declared config home that the state path is not
|
|
173
|
+
// inside. `resolvePrivateConfigDir` returns the homedir default when
|
|
174
|
+
// nothing is declared, which is why the raw env vars are checked here —
|
|
175
|
+
// an undeclared default is not a split, it is the normal case.
|
|
176
|
+
const declaredHome = (env?.SO_CONFIG_HOME || '').trim() || (env?.XDG_CONFIG_HOME || '').trim();
|
|
177
|
+
if (declaredHome !== '') {
|
|
178
|
+
const declaredDir = realOrSelf(resolvePrivateConfigDir({ env }));
|
|
179
|
+
if (!isUnder(effectiveStatePath, declaredDir)) {
|
|
180
|
+
return { sandbox: true, reason: 'sandbox:config-home-split' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// (c) temp-root project WHILE the identity is real. Compare REAL paths:
|
|
185
|
+
// macOS $TMPDIR is /var/folders/… symlinked to /private/var/folders/…,
|
|
186
|
+
// so a string prefix on the raw values misses every macOS sandbox.
|
|
187
|
+
const tempRoots = [os.tmpdir(), '/tmp'].filter(Boolean).map(realOrSelf);
|
|
188
|
+
const identityIsThrowaway = tempRoots.some((root) => isUnder(effectiveStatePath, root));
|
|
189
|
+
if (!identityIsThrowaway) {
|
|
190
|
+
const project = realOrSelf((env?.CLAUDE_PROJECT_DIR || '').trim() || cwd || process.cwd());
|
|
191
|
+
if (tempRoots.some((root) => isUnder(project, root))) {
|
|
192
|
+
return { sandbox: true, reason: 'sandbox:temp-root' };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { sandbox: false, reason: null };
|
|
197
|
+
} catch {
|
|
198
|
+
// A guard that throws must never become a guard that permits — and until
|
|
199
|
+
// 2026-09-06 this catch said exactly that while doing the opposite
|
|
200
|
+
// (`{ sandbox: false }`, i.e. PERMIT on probe failure). It now fails CLOSED.
|
|
201
|
+
//
|
|
202
|
+
// What is refused is one ping, and the batch is not lost: `flush` returns
|
|
203
|
+
// `reason: 'sandbox:probe-failed'`, writes no queue mutation, and the next
|
|
204
|
+
// session's flush re-probes from scratch. What the old branch risked is the
|
|
205
|
+
// thing this guard exists to prevent — a real `anon_id` leaving an
|
|
206
|
+
// environment the guard could not classify.
|
|
207
|
+
return { sandbox: true, reason: 'sandbox:probe-failed' };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** True when `candidate` IS `root` or lies beneath it (both already realpath'd). */
|
|
212
|
+
function isUnder(candidate, root) {
|
|
213
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `fs.realpathSync` for a path that may not exist yet.
|
|
218
|
+
*
|
|
219
|
+
* Resolving the NEAREST EXISTING ANCESTOR and re-appending the remainder is the
|
|
220
|
+
* load-bearing part, not a nicety: on macOS `$TMPDIR` is `/var/folders/…`, a
|
|
221
|
+
* symlink to `/private/var/folders/…`. A plain `realpathSync` on a not-yet-created
|
|
222
|
+
* `<tmp>/telemetry.json` throws, the raw `/var/folders/…` string is returned, and
|
|
223
|
+
* it then fails to match the realpath'd `/private/var/folders/…` temp root — so
|
|
224
|
+
* every comparison against a path that does not exist yet silently comes out
|
|
225
|
+
* "not under the temp root". Measured while writing this guard's own tests.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} p
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
function realOrSelf(p) {
|
|
231
|
+
let abs = path.resolve(p);
|
|
232
|
+
const tail = [];
|
|
233
|
+
for (;;) {
|
|
234
|
+
try {
|
|
235
|
+
return path.join(fs.realpathSync(abs), ...tail);
|
|
236
|
+
} catch {
|
|
237
|
+
const parent = path.dirname(abs);
|
|
238
|
+
if (parent === abs) return path.resolve(p); // reached the root, nothing exists
|
|
239
|
+
tail.unshift(path.basename(abs));
|
|
240
|
+
abs = parent;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
93
245
|
// ---------------------------------------------------------------------------
|
|
94
246
|
// Batch build
|
|
95
247
|
// ---------------------------------------------------------------------------
|
|
@@ -129,6 +281,104 @@ function mostRecentSession(records) {
|
|
|
129
281
|
return best;
|
|
130
282
|
}
|
|
131
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Read the session PROFILE (STATE.md frontmatter `session-profile`) for the repo
|
|
286
|
+
* that owns `metricsDir`.
|
|
287
|
+
*
|
|
288
|
+
* The profile is a SECOND axis beside `session_type`: an ultradeep session is
|
|
289
|
+
* `session_type: "deep"` PLUS `session_profile: "ultradeep"`. It deliberately
|
|
290
|
+
* does NOT go through `normalizeSessionType`, which would flatten any unknown
|
|
291
|
+
* value to `'other'` and destroy the only signal that distinguishes the 7-wave
|
|
292
|
+
* form from an ordinary deep session.
|
|
293
|
+
*
|
|
294
|
+
* ABSENT IS NOT EMPTY. No STATE.md, no frontmatter, or no `session-profile` key
|
|
295
|
+
* ⇒ `null` ⇒ the ping OMITS the field. A derived (ledger-less) ping therefore
|
|
296
|
+
* carries no profile rather than an invented one.
|
|
297
|
+
*
|
|
298
|
+
* Never throws.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} metricsDir — `<repoRoot>/.orchestrator/metrics`.
|
|
301
|
+
* @returns {string|null}
|
|
302
|
+
*/
|
|
303
|
+
export function readSessionProfileForMetricsDir(metricsDir) {
|
|
304
|
+
try {
|
|
305
|
+
// metricsDir is `<repoRoot>/.orchestrator/metrics` by construction (every
|
|
306
|
+
// caller builds it that way); two levels up is the repo root.
|
|
307
|
+
const repoRoot = path.resolve(metricsDir, '..', '..');
|
|
308
|
+
return readSessionProfile(fs.readFileSync(resolveStateMdPath(repoRoot), 'utf8'));
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Reconstruct the session facts a ping needs (`session_type`, `started_at`,
|
|
316
|
+
* `completed_at`) from `<metricsDir>/events.jsonl` when `sessions.jsonl` has no
|
|
317
|
+
* usable record.
|
|
318
|
+
*
|
|
319
|
+
* WHY THIS EXISTS: `buildBatch` keyed exclusively on `sessions.jsonl`, which is
|
|
320
|
+
* written by `/close`. Measured 2026-09-06 (d8): the fleet's real close rate is
|
|
321
|
+
* 21,3 % (429 clean closes / 2.016 distinct `session.started` ids over 90 days),
|
|
322
|
+
* and THIS repo has no `sessions.jsonl` at all — so `sessionForPing` was `{}` and
|
|
323
|
+
* every ping reported `session_type: "other"` / `duration_bucket: "<15m"` as if
|
|
324
|
+
* measured. `events.jsonl` is written on every SessionStart, independent of
|
|
325
|
+
* `/close`, so it is the source that survives a killed session.
|
|
326
|
+
*
|
|
327
|
+
* NEVER FABRICATES: when no `orchestrator.session.started` record carries a mode,
|
|
328
|
+
* `session_type` is returned absent, and the caller emits `'unknown'`.
|
|
329
|
+
*
|
|
330
|
+
* Deliberate simplification (named ceiling): the LAST `session.started` record
|
|
331
|
+
* wins and the LAST record of any kind supplies `completed_at`. That conflates a
|
|
332
|
+
* session with the tail of a peer's events in a shared working copy. It is the
|
|
333
|
+
* same precision the ledger path already offers (`mostRecentSession`), it costs
|
|
334
|
+
* one linear pass, and the `session_record: 'derived'` marker tells the reader it
|
|
335
|
+
* is a reconstruction. Revisit if events.jsonl ever carries interleaved sessions
|
|
336
|
+
* that must be told apart — the `session_id` field is already there for it.
|
|
337
|
+
*
|
|
338
|
+
* Never throws.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} metricsDir
|
|
341
|
+
* @returns {{ session: object, source: 'derived'|'absent' }}
|
|
342
|
+
*/
|
|
343
|
+
export function deriveSessionFromEvents(metricsDir) {
|
|
344
|
+
try {
|
|
345
|
+
const events = readJsonlFile(path.join(metricsDir, 'events.jsonl'), { skipInvalid: true });
|
|
346
|
+
if (!Array.isArray(events) || events.length === 0) return { session: {}, source: 'absent' };
|
|
347
|
+
|
|
348
|
+
let startedAt = null;
|
|
349
|
+
let sessionType = null;
|
|
350
|
+
let lastTs = null;
|
|
351
|
+
|
|
352
|
+
for (const ev of events) {
|
|
353
|
+
if (!ev || typeof ev !== 'object') continue;
|
|
354
|
+
const ts = typeof ev.timestamp === 'string' && !Number.isNaN(Date.parse(ev.timestamp)) ? ev.timestamp : null;
|
|
355
|
+
if (ts && (lastTs === null || ts > lastTs)) lastTs = ts;
|
|
356
|
+
if (ev.event !== 'orchestrator.session.started') continue;
|
|
357
|
+
// `mode` is what session-start writes; `session_type` is the ledger's own
|
|
358
|
+
// name for the same fact. Read both — neither is guaranteed present.
|
|
359
|
+
const mode = typeof ev.session_type === 'string' ? ev.session_type : ev.mode;
|
|
360
|
+
const started = typeof ev.started_at === 'string' ? ev.started_at : ts;
|
|
361
|
+
if (started && (startedAt === null || started >= startedAt)) {
|
|
362
|
+
startedAt = started;
|
|
363
|
+
sessionType = typeof mode === 'string' && mode.trim() !== '' ? mode.trim() : null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (startedAt === null && sessionType === null) return { session: {}, source: 'absent' };
|
|
368
|
+
|
|
369
|
+
const session = {};
|
|
370
|
+
if (sessionType !== null) session.session_type = sessionType;
|
|
371
|
+
if (startedAt !== null) session.started_at = startedAt;
|
|
372
|
+
// completed_at is the last life-sign, never the wall clock — the same
|
|
373
|
+
// omit-never-fabricate contract session-close-backfill.mjs uses (#914 R1).
|
|
374
|
+
if (lastTs !== null && startedAt !== null && lastTs >= startedAt) session.completed_at = lastTs;
|
|
375
|
+
|
|
376
|
+
return { session, source: 'derived' };
|
|
377
|
+
} catch {
|
|
378
|
+
return { session: {}, source: 'absent' };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
132
382
|
/**
|
|
133
383
|
* Build ONE whitelist-projected usage-ping record from the local JSONL streams.
|
|
134
384
|
*
|
|
@@ -169,6 +419,7 @@ export function buildBatch({
|
|
|
169
419
|
now,
|
|
170
420
|
statePath,
|
|
171
421
|
persist = true,
|
|
422
|
+
consentState,
|
|
172
423
|
} = {}) {
|
|
173
424
|
try {
|
|
174
425
|
const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
|
|
@@ -181,6 +432,8 @@ export function buildBatch({
|
|
|
181
432
|
|
|
182
433
|
let windowInvocations;
|
|
183
434
|
let sessionForPing;
|
|
435
|
+
/** @type {'ledger'|'derived'|'absent'} */
|
|
436
|
+
let sessionRecordSource = 'ledger';
|
|
184
437
|
if (sessionRecord && typeof sessionRecord.started_at === 'string' && !Number.isNaN(Date.parse(sessionRecord.started_at))) {
|
|
185
438
|
const startMs = Date.parse(sessionRecord.started_at);
|
|
186
439
|
windowInvocations = invocations.filter((rec) => {
|
|
@@ -188,15 +441,24 @@ export function buildBatch({
|
|
|
188
441
|
return !Number.isNaN(t) && t >= startMs;
|
|
189
442
|
});
|
|
190
443
|
sessionForPing = sessionRecord;
|
|
444
|
+
sessionRecordSource = 'ledger';
|
|
191
445
|
} else {
|
|
192
|
-
// No usable
|
|
193
|
-
//
|
|
446
|
+
// No usable LEDGER record → reconstruct from events.jsonl, which is
|
|
447
|
+
// written on every SessionStart and therefore survives a killed session
|
|
448
|
+
// (see deriveSessionFromEvents). Only when THAT also yields nothing does
|
|
449
|
+
// the ping fall back to `session_type: 'unknown'` — never to a
|
|
450
|
+
// measured-looking 'other'.
|
|
451
|
+
const derived = deriveSessionFromEvents(dir);
|
|
452
|
+
sessionForPing = derived.session;
|
|
453
|
+
sessionRecordSource = derived.source;
|
|
454
|
+
|
|
455
|
+
const derivedStartMs = Date.parse(sessionForPing.started_at);
|
|
194
456
|
const cutoff = (Number.isNaN(Date.parse(nowIso)) ? Date.now() : Date.parse(nowIso)) - DAILY_FLUSH_MS;
|
|
457
|
+
const windowStart = Number.isNaN(derivedStartMs) ? cutoff : derivedStartMs;
|
|
195
458
|
windowInvocations = invocations.filter((rec) => {
|
|
196
459
|
const t = Date.parse(rec?.timestamp);
|
|
197
|
-
return !Number.isNaN(t) && t >=
|
|
460
|
+
return !Number.isNaN(t) && t >= windowStart;
|
|
198
461
|
});
|
|
199
|
-
sessionForPing = {};
|
|
200
462
|
}
|
|
201
463
|
|
|
202
464
|
const cfg = ownerConfig ?? loadOwnerConfig().config;
|
|
@@ -208,6 +470,9 @@ export function buildBatch({
|
|
|
208
470
|
env,
|
|
209
471
|
now: nowIso,
|
|
210
472
|
roster,
|
|
473
|
+
consentState,
|
|
474
|
+
sessionRecordSource,
|
|
475
|
+
sessionProfile: readSessionProfileForMetricsDir(dir),
|
|
211
476
|
});
|
|
212
477
|
|
|
213
478
|
const target = statePath || TELEMETRY_JSON_PATH;
|
|
@@ -232,6 +497,61 @@ export function buildBatch({
|
|
|
232
497
|
}
|
|
233
498
|
}
|
|
234
499
|
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// Transport-boundary normalisation (the queue is not a trusted producer)
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* THE BUG THIS EXISTS FOR: a record written to the offline queue by an OLDER
|
|
506
|
+
* client — one built before `session_profile` was whitelisted — carries whatever
|
|
507
|
+
* `session-profile` that host's STATE.md held, e.g. a private repo name. `flush`
|
|
508
|
+
* forwarded queued batches to the sender VERBATIM, so the builder-side whitelist
|
|
509
|
+
* (`normalizeSessionProfile`, applied in `buildUsagePing`) was bypassed for every
|
|
510
|
+
* record that had ever been queued. Two consequences, both live:
|
|
511
|
+
*
|
|
512
|
+
* (a) PRIVACY — the private string reaches the wire on every later flush.
|
|
513
|
+
* (b) POISON QUEUE — the ingest server validates a batch ALL-OR-NOTHING, so
|
|
514
|
+
* the unknown profile 400s the whole batch; the new record is queued and
|
|
515
|
+
* the queue grows 1 → 2 → 3 … and never drains again.
|
|
516
|
+
*
|
|
517
|
+
* The fix is a boundary invariant, not a one-off patch: **a queued record can
|
|
518
|
+
* never carry what a freshly built one cannot.** Every queued entry passes the
|
|
519
|
+
* SAME two steps the builder applies — `projectUsagePing` field projection, then
|
|
520
|
+
* the `normalizeSessionProfile` whitelist (unlisted ⇒ the key is DROPPED, per
|
|
521
|
+
* that function's omit-don't-degrade contract).
|
|
522
|
+
*
|
|
523
|
+
* @param {unknown} batch A record as read back from the offline queue.
|
|
524
|
+
* @returns {object} The projected + normalised record safe to hand to the sender.
|
|
525
|
+
*/
|
|
526
|
+
export function sanitizeQueuedRecord(batch) {
|
|
527
|
+
const record = projectUsagePing(batch);
|
|
528
|
+
if ('session_profile' in record) {
|
|
529
|
+
const profile = normalizeSessionProfile(record.session_profile);
|
|
530
|
+
if (profile === null) delete record.session_profile;
|
|
531
|
+
else record.session_profile = profile;
|
|
532
|
+
}
|
|
533
|
+
return record;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Does this send failure mean "the server refused this PAYLOAD" (evict) rather
|
|
538
|
+
* than "the send did not get through" (re-queue)?
|
|
539
|
+
*
|
|
540
|
+
* `defaultSender` attaches `err.status`; an injected sender that throws a bare
|
|
541
|
+
* Error carries no status and therefore always routes to the re-queue branch —
|
|
542
|
+
* the pre-existing behaviour, unchanged.
|
|
543
|
+
*
|
|
544
|
+
* Only 400 (schema) and 422 (semantic) count. 408/429 and every 5xx are
|
|
545
|
+
* transport-class and MUST re-queue: a rate-limited batch is not a bad batch.
|
|
546
|
+
*
|
|
547
|
+
* @param {unknown} err
|
|
548
|
+
* @returns {boolean}
|
|
549
|
+
*/
|
|
550
|
+
function isSchemaRejection(err) {
|
|
551
|
+
const status = Number(err?.status);
|
|
552
|
+
return status === 400 || status === 422;
|
|
553
|
+
}
|
|
554
|
+
|
|
235
555
|
// ---------------------------------------------------------------------------
|
|
236
556
|
// Flush
|
|
237
557
|
// ---------------------------------------------------------------------------
|
|
@@ -251,6 +571,11 @@ export function buildBatch({
|
|
|
251
571
|
* @param {string} [opts.now] ISO timestamp (sent_at, last_flush_at, rotation clock).
|
|
252
572
|
* @param {object} [opts.ownerConfig] Parsed owner.yaml (default: loaded here). Inject to
|
|
253
573
|
* isolate a test from the host's real owner.yaml fleet flag.
|
|
574
|
+
* `reason` values: `gated` (consent), `sandbox:*` (the sandbox guard refused —
|
|
575
|
+
* no network, no queue mutation, no anon-ID mint), `debug`, `queued`, `sent`,
|
|
576
|
+
* `rejected-evicted` (the server refused the payload with 400/422 — the batch is
|
|
577
|
+
* dropped instead of re-queued forever), `no-record`, `build-error: …`.
|
|
578
|
+
*
|
|
254
579
|
* @returns {Promise<{ sent: boolean, queued: boolean, state: string, reason: string }>}
|
|
255
580
|
*/
|
|
256
581
|
export async function flush({
|
|
@@ -280,11 +605,28 @@ export async function flush({
|
|
|
280
605
|
return { sent: false, queued: false, state: consent.state, reason: 'gated' };
|
|
281
606
|
}
|
|
282
607
|
|
|
608
|
+
// SANDBOX GUARD — runs strictly between the consent gate and buildBatch, so a
|
|
609
|
+
// refused send performs no network call, no queue write and no anon-ID mint.
|
|
610
|
+
// See detectSandbox for the six production pings this exists to prevent.
|
|
611
|
+
const sandbox = detectSandbox({ env, statePath });
|
|
612
|
+
if (sandbox.sandbox) {
|
|
613
|
+
return { sent: false, queued: false, state: consent.state, reason: sandbox.reason };
|
|
614
|
+
}
|
|
615
|
+
|
|
283
616
|
const nowIso = now || new Date().toISOString();
|
|
284
617
|
|
|
285
618
|
// Build the batch (this lazily mints + persists the anon-ID — only reachable
|
|
286
619
|
// here, i.e. strictly after the gate).
|
|
287
|
-
const { record, reason } = buildBatch({
|
|
620
|
+
const { record, reason } = buildBatch({
|
|
621
|
+
metricsDir,
|
|
622
|
+
env,
|
|
623
|
+
ownerConfig: cfg,
|
|
624
|
+
statePath,
|
|
625
|
+
now: nowIso,
|
|
626
|
+
// The RESOLVED consent state is what `fleet` is derived from now — not a raw
|
|
627
|
+
// owner.yaml read. See buildUsagePing's fleet block (d8 root cause a).
|
|
628
|
+
consentState: consent.state,
|
|
629
|
+
});
|
|
288
630
|
if (!record) {
|
|
289
631
|
return { sent: false, queued: false, state: consent.state, reason: reason || 'no-record' };
|
|
290
632
|
}
|
|
@@ -295,17 +637,31 @@ export async function flush({
|
|
|
295
637
|
return { sent: false, queued: false, state: consent.state, reason: 'debug' };
|
|
296
638
|
}
|
|
297
639
|
|
|
298
|
-
// Drain the existing queue together with the new record in ONE send.
|
|
299
|
-
|
|
640
|
+
// Drain the existing queue together with the new record in ONE send. Every
|
|
641
|
+
// queued record is re-normalised at this boundary — see sanitizeQueuedRecord
|
|
642
|
+
// for the privacy + poison-queue defect that made this necessary.
|
|
643
|
+
const queuedBatches = peekAll({ path: queuePath }).map((entry) => sanitizeQueuedRecord(entry.batch));
|
|
300
644
|
const batches = [...queuedBatches, record];
|
|
301
645
|
|
|
302
646
|
const send = typeof sender === 'function' ? sender : defaultSender({ env, timeoutMs });
|
|
303
647
|
|
|
304
648
|
try {
|
|
305
649
|
await send(batches);
|
|
306
|
-
} catch {
|
|
307
|
-
|
|
308
|
-
|
|
650
|
+
} catch (err) {
|
|
651
|
+
if (isSchemaRejection(err)) {
|
|
652
|
+
// The server refused the PAYLOAD. Re-queueing would replay the identical
|
|
653
|
+
// batch forever, which is the poison-queue class itself. Drop it.
|
|
654
|
+
//
|
|
655
|
+
// Named ceiling (BV-004): the ingest API validates a batch all-or-nothing
|
|
656
|
+
// and returns no per-record index, so the rejected record cannot be
|
|
657
|
+
// identified — the only bounded choice is to evict the WHOLE batch (the
|
|
658
|
+
// queued records AND the new one). Revisit if the server ever reports
|
|
659
|
+
// which entries failed; then evict only those.
|
|
660
|
+
clear({ path: queuePath });
|
|
661
|
+
return { sent: false, queued: false, state: consent.state, reason: 'rejected-evicted' };
|
|
662
|
+
}
|
|
663
|
+
// Transport failure → only the NEW record joins the queue (queued batches
|
|
664
|
+
// remain in place since the queue was not cleared).
|
|
309
665
|
enqueue(record, { path: queuePath, now: nowIso });
|
|
310
666
|
return { sent: false, queued: true, state: consent.state, reason: 'queued' };
|
|
311
667
|
}
|