diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 0a1f4dfd0..5f327b5af 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -733,6 +733,7 @@ limits.provider.copilot,ui,LimitsPage,UsageLimitsPanel,provider_copilot,"GitHub limits.provider.antigravity,ui,LimitsPage,UsageLimitsPanel,provider_antigravity,"Antigravity",,active limits.provider.zcode,ui,LimitsPage,UsageLimitsPanel,provider_zcode,"ZCode",,active provider.display.omp,ui,*,ProviderDisplay,omp,"oh-my-pi",,active +provider.display.deepseek_harness,ui,*,ProviderDisplay,deepseek_harness,"DeepSeek Harness",,active limits.label.zcode_glm52,ui,LimitsPage,UsageLimitsPanel,zcode_glm52,"GLM-5.2",,active limits.label.zcode_glm5t,ui,LimitsPage,UsageLimitsPanel,zcode_glm5t,"GLM-5-Turbo",,active limits.label.zcode_5h,ui,LimitsPage,UsageLimitsPanel,zcode_5h,"5h",,active diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json index 656f0a47f..235952538 100644 --- a/dashboard/src/content/i18n/zh-TW/core.json +++ b/dashboard/src/content/i18n/zh-TW/core.json @@ -153,6 +153,7 @@ "limits.provider.grok": "Grok Build", "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", + "provider.display.deepseek_harness": "DeepSeek Harness", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json index 5376ab2fc..2cf698041 100644 --- a/dashboard/src/content/i18n/zh/core.json +++ b/dashboard/src/content/i18n/zh/core.json @@ -153,6 +153,7 @@ "limits.provider.grok": "Grok Build", "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", + "provider.display.deepseek_harness": "DeepSeek Harness", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/lib/model-breakdown.test.ts b/dashboard/src/lib/model-breakdown.test.ts index 4c4207b64..b64acabff 100644 --- a/dashboard/src/lib/model-breakdown.test.ts +++ b/dashboard/src/lib/model-breakdown.test.ts @@ -31,6 +31,45 @@ describe("buildFleetData", () => { expect(fleet[2].totalPercentValue).toBeGreaterThan(0); expect(fleet[2].totalPercentValue).toBeLessThan(0.01); }); + + it("folds the deprecated deepseek source alias into dsh and merges models", () => { + const fleet = buildFleetData({ + sources: [ + { + source: "deepseek", + totals: { billable_total_tokens: 100, total_cost_usd: "1.00" }, + models: [ + { model_id: "deepseek-v4-pro", totals: { billable_total_tokens: 70 } }, + { model_id: "deepseek-v4-flash", totals: { billable_total_tokens: 30 } }, + ], + }, + { + source: "dsh", + totals: { billable_total_tokens: 400, total_cost_usd: "4.00" }, + models: [ + { model_id: "deepseek-v4-pro", totals: { billable_total_tokens: 300 } }, + { model_id: "deepseek-v4-flash", totals: { billable_total_tokens: 100 } }, + ], + }, + ], + }); + + expect(fleet.map(({ source }) => source)).toEqual(["dsh"]); + expect(fleet[0].usage).toBe(500); + expect(fleet[0].usd).toBe(5); + const byId = Object.fromEntries(fleet[0].models.map((m: any) => [m.id, m.usage])); + expect(byId).toEqual({ "deepseek-v4-pro": 370, "deepseek-v4-flash": 130 }); + }); + + it("ignores malformed non-array model collections while preserving source totals", () => { + expect(() => buildFleetData({ + sources: [{ + source: "dsh", + totals: { billable_total_tokens: 42 }, + models: { model_id: "not-an-array" }, + }], + })).not.toThrow(); + }); }); describe("buildAllModels", () => { diff --git a/dashboard/src/lib/model-breakdown.ts b/dashboard/src/lib/model-breakdown.ts index c4d76bce0..37dfaab0c 100644 --- a/dashboard/src/lib/model-breakdown.ts +++ b/dashboard/src/lib/model-breakdown.ts @@ -57,9 +57,89 @@ export function resolveDisplayTokens(totals: any, fallback = 0) { return billableTokens ?? totalTokens ?? fallback; } +// Deprecated source names folded into their canonical spelling at display +// time. "deepseek" was the first dsh-integration source name; it was renamed +// to "dsh", but a cloud upload from before the rename still carries the old +// name. Folding it back keeps one "DeepSeek Harness" provider instead of two +// ("DEEPSEEK" + "DeepSeek Harness"). This only changes presentation; the +// underlying cloud rows are not rewritten. +const SOURCE_ALIASES: Record = { + deepseek: "dsh", +}; + +function canonicalSource(source: any): string { + const raw = typeof source === "string" ? source : ""; + return SOURCE_ALIASES[raw] || raw; +} + +const TOKEN_TOTAL_KEYS = [ + "total_tokens", + "billable_total_tokens", + "input_tokens", + "output_tokens", + "cached_input_tokens", + "cache_creation_input_tokens", + "reasoning_output_tokens", +]; + +function addTotalsInto(target: any, add: any) { + for (const key of TOKEN_TOTAL_KEYS) { + target[key] = (Number(target[key]) || 0) + (Number(add?.[key]) || 0); + } + target.total_cost_usd = String( + Number(target.total_cost_usd || 0) + Number(add?.total_cost_usd || 0), + ); +} + +function emptyTotals() { + return { + total_tokens: 0, + billable_total_tokens: 0, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_cost_usd: "0", + }; +} + +// Merge API sources whose canonical name collides after alias resolution +// (e.g. a stale "deepseek" plus the current "dsh"), summing totals and +// combining per-model rows keyed by model id. +function mergeSourcesByAlias(sources: any[]) { + const bySource = new Map(); + for (const entry of sources || []) { + const source = canonicalSource(entry?.source); + if (!source) continue; + let merged = bySource.get(source); + if (!merged) { + merged = { source, source_scope: entry?.source_scope, totals: emptyTotals(), models: new Map() }; + bySource.set(source, merged); + } + addTotalsInto(merged.totals, entry?.totals); + for (const model of Array.isArray(entry?.models) ? entry.models : []) { + const id = model?.model_id || model?.model || ""; + if (!id) continue; + let modelRow = merged.models.get(id); + if (!modelRow) { + modelRow = { model: model?.model || id, model_id: id, totals: emptyTotals() }; + merged.models.set(id, modelRow); + } + addTotalsInto(modelRow.totals, model?.totals); + } + } + return Array.from(bySource.values()).map((s) => ({ + ...s, + models: Array.from(s.models.values()), + })); +} + export function buildFleetData(modelBreakdown: any, { copyFn }: AnyRecord = {}) { const safeCopy = typeof copyFn === "function" ? copyFn : (key: string) => key; - const sources: any[] = Array.isArray(modelBreakdown?.sources) ? modelBreakdown.sources : []; + const sources: any[] = mergeSourcesByAlias( + Array.isArray(modelBreakdown?.sources) ? modelBreakdown.sources : [], + ); const normalizedSources = sources .map((entry: any) => { const totalTokens = resolveDisplayTokens(entry?.totals); diff --git a/dashboard/src/lib/provider-display.js b/dashboard/src/lib/provider-display.js index a5c5d98c1..c80e75286 100644 --- a/dashboard/src/lib/provider-display.js +++ b/dashboard/src/lib/provider-display.js @@ -9,6 +9,8 @@ const SPECIAL_PROVIDER_NAMES = { }; const SPECIAL_PROVIDER_COPY_KEYS = { + deepseek: "provider.display.deepseek_harness", + dsh: "provider.display.deepseek_harness", omp: "provider.display.omp", }; diff --git a/dashboard/src/lib/provider-display.test.js b/dashboard/src/lib/provider-display.test.js index d2ef10c58..c83ba5d09 100644 --- a/dashboard/src/lib/provider-display.test.js +++ b/dashboard/src/lib/provider-display.test.js @@ -24,4 +24,9 @@ describe("formatProviderDisplayName", () => { expect(formatProviderDisplayName("omp")).toBe("oh-my-pi"); expect(formatProviderDisplayName("OMP")).toBe("oh-my-pi"); }); + + it("uses the registered DeepSeek Harness product name for current and legacy sources", () => { + expect(formatProviderDisplayName("dsh")).toBe("DeepSeek Harness"); + expect(formatProviderDisplayName("deepseek")).toBe("DeepSeek Harness"); + }); }); diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx index 7ca404736..5c8986ae5 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx @@ -263,11 +263,21 @@ function ZedIcon({ size = 16, className = "" }) { ); } -// DeepSeek whale mark, mono path from @lobehub/icons. -function DeepSeekIcon({ size = 16, className = "" }) { +// Official DeepSeek Harness FishLogo geometry from deepseek-ai/deepseek-harness. +// Harness deliberately renders the shared fish silhouette in currentColor so +// it follows the product's wordmark ink in both light and dark themes. +function DeepSeekHarnessIcon({ size = 16, className = "" }) { return ( - - + ); } @@ -282,7 +292,8 @@ const PROVIDER_ICON_MAP = { COPILOT: CopilotIcon, CRAFT: CraftIcon, CURSOR: CursorIcon, - DEEPSEEK: DeepSeekIcon, + DEEPSEEK: DeepSeekHarnessIcon, + DSH: DeepSeekHarnessIcon, DROID: DroidIcon, "EVERY-CODE": CodexIcon, GEMINI: GeminiIcon, @@ -315,7 +326,6 @@ const PROVIDER_LOGO_MAP = { CLAUDE: "/brand-logos/claude-code.svg", "CLAUDE-SCIENCE": "/brand-logos/claude-code.svg", CODEX: "/brand-logos/codex.svg", - DEEPSEEK: "/brand-logos/deepseek.svg", GEMINI: "/brand-logos/gemini.svg", "KILO-CLI": "/brand-logos/kilo.svg", "KILO-CODE": "/brand-logos/kilo.svg", diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx index 72e4db4ac..22adc68ab 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx @@ -64,6 +64,19 @@ describe("ProviderIcon", () => { expect(icon).toHaveAttribute("width", "20"); expect(icon).toHaveAttribute("height", "20"); }); + + it("renders the official theme-aware DeepSeek Harness fish instead of the legacy blue asset", () => { + for (const provider of ["dsh", "deepseek"]) { + const { container } = render(); + const icon = container.querySelector('svg[data-brand="deepseek-harness"]'); + + expect(icon).not.toBeNull(); + expect(icon).toHaveAttribute("width", "20"); + expect(icon).toHaveAttribute("viewBox", "0 0 23.16 17.04"); + expect(container.querySelector('img[src="/brand-logos/deepseek.svg"]')).toBeNull(); + expect(icon?.querySelector('path[fill="currentColor"]')).not.toBeNull(); + } + }); }); it("renders the Qoder CN green-crescent mark from its own asset", () => { diff --git a/dashboard/src/ui/dashboard/components/UsageOverview.jsx b/dashboard/src/ui/dashboard/components/UsageOverview.jsx index 41f0b242d..5cf1847e0 100644 --- a/dashboard/src/ui/dashboard/components/UsageOverview.jsx +++ b/dashboard/src/ui/dashboard/components/UsageOverview.jsx @@ -74,6 +74,7 @@ function parseAnimatedCounterValue(displayValue) { // Provider color mapping for visual distinction const PROVIDER_COLORS = { CODEX: "#3b82f6", // blue-500 + DSH: "var(--community-deepseek)", // DeepSeek Harness brand blue CLAUDE: "#d97757", // Anthropic Japonica orange-red OPENCODE: "#f59e0b", // amber-500 GEMINI: "#2196f3", // Google Gemini bright blue diff --git a/scripts/validate-locale-coverage.cjs b/scripts/validate-locale-coverage.cjs index b5e64855f..f02bc9041 100644 --- a/scripts/validate-locale-coverage.cjs +++ b/scripts/validate-locale-coverage.cjs @@ -30,7 +30,7 @@ const SOURCE_IDENTICAL_KEY_ALLOWLIST = [ /^settings[.]menubar[.]iconStyle[.]clawd$/, /^settings[.]menubar[.]updates[.]footerCore$/, /^limits[.]provider[.]/, - /^provider[.]display[.]omp$/, + /^provider[.]display[.](?:omp|deepseek_harness)$/, /^limits[.]label[.](?:cursor_api|zcode_glm52|zcode_glm5t|claude_opus|codex_spark_[57][hd]|gemini_(?:pro|flash|lite)|antigravity_)/, /^skills[.]mode[.]skillssh$/, /^skills[.]repo[.]placeholder$/, diff --git a/src/commands/status.js b/src/commands/status.js index 95c204b7b..ddeb2821f 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -74,6 +74,8 @@ const { resolveGooseDbPath, listDroidSettingsFiles, resolveDroidSessionsDir, + resolveDshHome, + resolveDshSessionFiles, resolveTraeStoragePath, readTraeEntitlementFromStorage, resolveGrokBuildSessions, @@ -617,6 +619,10 @@ async function cmdStatus(argv = []) { const droidSessionsDir = resolveDroidSessionsDir(process.env); const droidSettingsFiles = listDroidSettingsFiles(process.env); const droidInstalled = droidSettingsFiles.length > 0; + const dshHome = resolveDshHome(process.env); + const dshSessionsDir = path.join(dshHome, "sessions"); + const dshSessionFiles = await resolveDshSessionFiles(process.env); + const dshInstalled = dshSessionFiles.length > 0; // Trae SOLO (ByteDance AI IDE) — passive entitlement snapshot reader. const traeStoragePath = resolveTraeStoragePath(process.env); @@ -907,6 +913,9 @@ async function cmdStatus(argv = []) { droid: droidInstalled ? { installed: true, files: droidSettingsFiles.length, detail: droidSessionsDir } : { installed: false }, + dsh: dshInstalled + ? { installed: true, files: dshSessionFiles.length, detail: dshSessionsDir } + : { installed: false }, trae: traeInstalled ? { installed: true, @@ -1069,6 +1078,9 @@ async function cmdStatus(argv = []) { droidInstalled ? `- Droid (Factory): passive reader (${droidSettingsFiles.length} session${droidSettingsFiles.length !== 1 ? "s" : ""} in ${droidSessionsDir}, cumulative-delta)` : null, + dshInstalled + ? `- DeepSeek Harness: passive reader (${dshSessionFiles.length} session${dshSessionFiles.length !== 1 ? "s" : ""} in ${dshSessionsDir})` + : null, traeInstalled // Deliberately NOT "passive reader": every other line with that wording // means tokens are being counted. Trae encrypts its session transcripts diff --git a/src/commands/sync.js b/src/commands/sync.js index 59a32605b..11c0a6f9e 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -108,6 +108,8 @@ const { parseDroidIncremental, droidSessionIdFromPath, resolveDroidModel, + resolveDshSessionFiles, + parseDshIncremental, bucketKey, toUtcHalfHourStart, totalsKey, @@ -246,8 +248,10 @@ const CODEX_COLD_SCAN_AUDIT_MAX_SYNCS = 288; // one-time repair purges all source=mimo data from the local queues + cursor // state so the next sync (providerID-filtered reader) rebuilds it correctly. const MIMO_PROVIDER_REPAIR_KEY = "mimoClaudeMislabelRepair_2026_06"; +const DSH_LEGACY_SOURCE_MIGRATION_KEY = "deepseekHarnessSourceMigration_2026_08"; const AUTO_SYNC_SOURCE_ALIASES = new Map([ ["code", "every-code"], + ["deepseek", "dsh"], ["everycode", "every-code"], ["kilo", "kilo-cli"], ["kilo-code", "kilocode"], @@ -265,6 +269,7 @@ const AUTO_SYNC_SOURCES = new Set([ "craft", "cursor", "droid", + "dsh", "every-code", "gemini", "goose", @@ -1399,6 +1404,32 @@ async function cmdSync(argv, context = {}) { }; } + // ── DeepSeek Harness — passive read of ~/.dsh/sessions session logs ── + let dshResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; + if (sourceAllowed("dsh")) { + await migrateLegacyDeepseekHarnessSource({ cursors, queuePath, queueStatePath }); + const dshSessionFiles = await resolveDshSessionFiles(process.env); + if (dshSessionFiles.length > 0) { + if (progress?.enabled) { + progress.start( + `Parsing DeepSeek Harness ${renderBar(0)} 0/${formatNumber( + dshSessionFiles.length, + )} sessions | buckets 0`, + ); + } + try { + dshResult = await parseDshIncremental({ + sessionFiles: dshSessionFiles, + cursors, + queuePath, + onProgress: makeProviderProgress("DeepSeek Harness"), + }); + } catch (err) { + warnProviderParseFailure("DeepSeek Harness", err, opts); + } + } + } + // ── AnythingLLM Desktop (workspace_chats.response.metrics) ── let anythingllmResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; if (sourceAllowed("anythingllm")) { @@ -2544,6 +2575,7 @@ async function cmdSync(argv, context = {}) { roocodeResult.recordsProcessed + zedResult.recordsProcessed + gooseResult.recordsProcessed + + dshResult.recordsProcessed + droidResult.recordsProcessed; const totalBuckets = parseResult.bucketsQueued + @@ -2577,6 +2609,7 @@ async function cmdSync(argv, context = {}) { roocodeResult.bucketsQueued + zedResult.bucketsQueued + gooseResult.bucketsQueued + + dshResult.bucketsQueued + droidResult.bucketsQueued; const skipNoOpCursorCommit = opts.auto && @@ -2910,6 +2943,8 @@ module.exports = { repairCodebuddyLogJsonlOverlap, repairWorkbuddyContextUsage, WORKBUDDY_CONTEXT_USAGE_REPAIR_KEY, + migrateLegacyDeepseekHarnessSource, + DSH_LEGACY_SOURCE_MIGRATION_KEY, repairCodexRescanInflation, repairCodexForkReplayInflation, repairCodexInterleavedUsageInflation, @@ -2934,6 +2969,121 @@ module.exports = { CLOUD_CONVERSATIONS_BACKFILL_KEY, }; +// The pre-merge Harness prototype briefly emitted source="deepseek" before +// settling on the official CLI id, source="dsh". Relabel the local history, +// append explicit zero rows for every old cloud key, and replay from offset 0. +// That preserves the latest canonical totals while making remote whole-row +// upserts remove the stale alias instead of displaying both sources or summing +// the same session twice. +async function migrateLegacyDeepseekHarnessSource({ cursors, queuePath, queueStatePath } = {}) { + if (!cursors || typeof cursors !== "object") return false; + const migrations = (cursors.migrations ||= {}); + if (migrations[DSH_LEGACY_SOURCE_MIGRATION_KEY]) return false; + + let raw = ""; + try { + raw = await fs.readFile(queuePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + + const output = []; + const legacyKeys = new Map(); + const latestMigratedRows = new Map(); + const latestExplicitCanonicalRows = new Map(); + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let row; + try { + row = JSON.parse(line); + } catch (_error) { + output.push(line); + continue; + } + + const isLegacy = + row?.source === "deepseek" && + typeof row.model === "string" && + typeof row.hour_start === "string"; + if (isLegacy) { + const key = `${row.model}|${row.hour_start}`; + legacyKeys.set(key, { model: row.model, hour_start: row.hour_start }); + row = { + ...row, + source: "dsh", + billable_total_tokens: row.billable_total_tokens ?? row.total_tokens, + }; + } + const serialized = JSON.stringify(row); + output.push(serialized); + if (row?.source === "dsh" && typeof row.model === "string" && typeof row.hour_start === "string") { + const key = `${row.model}|${row.hour_start}`; + if (isLegacy) latestMigratedRows.set(key, serialized); + else latestExplicitCanonicalRows.set(key, serialized); + } + } + + if (legacyKeys.size === 0) { + migrations[DSH_LEGACY_SOURCE_MIGRATION_KEY] = { + status: "noop", + appliedAt: new Date().toISOString(), + reason: "no-legacy-deepseek-rows", + }; + return false; + } + + // Re-append the last canonical row for each affected key so local readers' + // last-row-wins contract remains correct even when an old alias line was + // physically later than an already-emitted dsh row. + for (const key of legacyKeys.keys()) { + const canonical = latestExplicitCanonicalRows.get(key) || latestMigratedRows.get(key); + if (canonical) output.push(canonical); + } + for (const { model, hour_start } of legacyKeys.values()) { + output.push(JSON.stringify({ + source: "deepseek", + model, + hour_start, + input_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 0, + billable_total_tokens: 0, + conversation_count: 0, + })); + } + + await ensureDir(path.dirname(queuePath)); + const tmpPath = `${queuePath}.tmp.${process.pid}.${Date.now()}`; + await fs.writeFile(tmpPath, `${output.join("\n")}\n`, "utf8"); + await fs.rename(tmpPath, queuePath); + + if (typeof queueStatePath === "string" && queueStatePath) { + let state = {}; + try { + state = JSON.parse(await fs.readFile(queueStatePath, "utf8")); + if (!state || typeof state !== "object") state = {}; + } catch (_error) { + state = {}; + } + state.offset = 0; + state.updatedAt = new Date().toISOString(); + state.note = "reset_after_deepseek_harness_source_migration_2026_08"; + await ensureDir(path.dirname(queueStatePath)); + await fs.writeFile(queueStatePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + } + + migrations[DSH_LEGACY_SOURCE_MIGRATION_KEY] = { + status: "applied", + appliedAt: new Date().toISOString(), + migratedBuckets: legacyKeys.size, + retractedBuckets: legacyKeys.size, + }; + return true; +} + function normalizeString(value) { if (typeof value !== "string") return null; const trimmed = value.trim(); diff --git a/src/lib/rollout.js b/src/lib/rollout.js index 0d15dd678..ad384eb44 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16378,6 +16378,626 @@ function readTraeEntitlementFromStorage(storagePath) { }; } +// ───────────────────────────────────────────────────────────────────────────── +// DeepSeek Harness (dsh) — passive reader of the harness's session logs. +// +// The DeepSeek Harness SDK persists each agent session as an append-only JSONL +// log under `/sessions///session.jsonl[.zstd]` +// (default `~/.dsh/sessions`; `$DSH_HOME` / TOKENTRACKER_DSH_HOME override). The first line is a `type: "session"` header; +// every following line is a `SessionEvent` carrying `type`, `seq`, `time` +// (epoch ms), and `data`. Token accounting rides on `assistant/message` events +// (`data.usage`), and the model on the same event's `data.message.source.model` +// (falling back to the most recent `request/header` config). Usage counts are +// DISJOINT — `inputTokens` is uncached input only, cache reads/writes and +// reasoning travel separately — so the mapping to our queue columns is direct +// (no cache subtraction, unlike Codex). +// +// Zstd artifacts are a concatenated-frame container: Node's +// `zlib.zstdDecompressSync` decodes only the first frame (silently dropping +// every event line), so we identify the frame boundaries and feed each frame +// through `@mongodb-js/zstd`, enforcing a cumulative plaintext bound as we go. +// Dedup is a per-file `lastSeq` watermark — seq +// is monotonic within a session log, so an append-only grow re-reads the file +// and skips everything at or below the watermark; a torn-tail repair or full +// rewrite re-reads the same seqs and is therefore idempotent. +const DSH_SESSION_LOG_MAX_BYTES = 64 * 1024 * 1024; +const DSH_SESSION_TEXT_MAX_BYTES = 128 * 1024 * 1024; +const DSH_SOURCE = "dsh"; + +// Precedence mirrors the harness's own resolveDshHome: an explicit +// TokenTracker override, then the harness's $DSH_HOME, then `~/.dsh`. Tests +// isolate $DSH_HOME via the `withHome` helper, so honoring it here cannot leak +// the real home past test isolation. +function resolveDshHome(env = process.env) { + const explicit = + (typeof env?.TOKENTRACKER_DSH_HOME === "string" && env.TOKENTRACKER_DSH_HOME.trim()) || + (typeof env?.DSH_HOME === "string" && env.DSH_HOME.trim()) || + ""; + if (explicit) return path.resolve(explicit); + return path.join(os.homedir(), ".dsh"); +} + +function isDshSessionLogName(name) { + return name === "session.jsonl" || name === "session.jsonl.zstd"; +} + +// Walk the harness sessions root for per-session log artifacts. The tree is +// `///session.jsonl[.zstd]`; only the +// exact leaf names are collected so unrelated harness files are ignored. +async function resolveDshSessionFiles(env = process.env) { + const sessionsRoot = path.join(resolveDshHome(env), "sessions"); + const out = []; + const projects = await safeReadDir(sessionsRoot); + for (const project of projects) { + if (!project.isDirectory()) continue; + const projectDir = path.join(sessionsRoot, project.name); + const sessions = await safeReadDir(projectDir); + for (const session of sessions) { + if (!session.isDirectory()) continue; + const sessionDir = path.join(projectDir, session.name); + const artifacts = await safeReadDir(sessionDir); + const transcripts = artifacts.filter( + (artifact) => artifact.isFile() && isDshSessionLogName(artifact.name), + ); + if (transcripts.length === 1) { + out.push(path.join(sessionDir, transcripts[0].name)); + } else if (transcripts.length > 1) { + // Harness itself rejects mixed encodings in one root. For a passive + // reader, choose the actively-written artifact instead of counting the + // same session twice; a tie prefers the default zstd encoding. + const ranked = await Promise.all(transcripts.map(async (artifact) => { + const full = path.join(sessionDir, artifact.name); + const handle = await fs.open(full, "r").catch(() => null); + if (!handle) return { full, name: artifact.name, mtimeMs: 0 }; + try { + const stat = await handle.stat().catch(() => null); + return { + full, + name: artifact.name, + mtimeMs: stat?.isFile() ? Number(stat.mtimeMs || 0) : 0, + }; + } finally { + await handle.close().catch(() => {}); + } + })); + ranked.sort((left, right) => + right.mtimeMs - left.mtimeMs || + Number(right.name.endsWith(".zstd")) - Number(left.name.endsWith(".zstd")), + ); + if (ranked[0]) out.push(ranked[0].full); + } + } + } + out.sort((a, b) => a.localeCompare(b)); + return out; +} + +// Inspect concatenated Zstandard frames without decompressing them. Harness +// writes a content size into every independent append frame; summing those +// declarations lets us reject a decompression bomb before the native decoder +// allocates its plaintext buffer. +function inspectDshZstdFrames(data, maxOutputBytes = DSH_SESSION_TEXT_MAX_BYTES) { + const input = Buffer.isBuffer(data) ? data : Buffer.from(data || []); + const limit = Number.isSafeInteger(maxOutputBytes) && maxOutputBytes >= 0 + ? maxOutputBytes + : DSH_SESSION_TEXT_MAX_BYTES; + let offset = 0; + let totalContentBytes = 0; + let allContentSizesDeclared = true; + let frames = 0; + const frameRanges = []; + + const need = (count, label) => { + if (offset + count > input.length) throw new Error(`Invalid DeepSeek Harness zstd ${label}`); + }; + const readUnsignedLE = (count) => { + need(count, "frame header"); + let value = 0n; + for (let i = 0; i < count; i++) value |= BigInt(input[offset + i]) << BigInt(i * 8); + offset += count; + return value; + }; + + while (offset < input.length) { + const frameStart = offset; + need(4, "magic"); + const magic = input.readUInt32LE(offset); + offset += 4; + + if (magic >= 0x184d2a50 && magic <= 0x184d2a5f) { + need(4, "skippable frame size"); + const skipBytes = input.readUInt32LE(offset); + offset += 4; + need(skipBytes, "skippable frame payload"); + offset += skipBytes; + continue; + } + if (magic !== 0xfd2fb528) throw new Error("Invalid DeepSeek Harness zstd frame magic"); + + need(1, "descriptor"); + const descriptor = input[offset++]; + if ((descriptor & 0x08) !== 0) throw new Error("Invalid DeepSeek Harness zstd reserved frame bit"); + const contentSizeFlag = descriptor >>> 6; + const singleSegment = (descriptor & 0x20) !== 0; + const hasChecksum = (descriptor & 0x04) !== 0; + const dictionaryIdBytes = [0, 1, 2, 4][descriptor & 0x03]; + let windowSize = null; + if (!singleSegment) { + need(1, "window descriptor"); + const windowDescriptor = input[offset++]; + const windowBase = 2 ** (10 + (windowDescriptor >>> 3)); + windowSize = windowBase + (windowBase / 8) * (windowDescriptor & 0x07); + } + need(dictionaryIdBytes, "dictionary id"); + offset += dictionaryIdBytes; + + const contentSizeBytes = contentSizeFlag === 0 + ? (singleSegment ? 1 : 0) + : [0, 2, 4, 8][contentSizeFlag]; + let declaredContentBytes = null; + if (contentSizeBytes > 0) { + declaredContentBytes = readUnsignedLE(contentSizeBytes); + if (contentSizeBytes === 2) declaredContentBytes += 256n; + if (declaredContentBytes > BigInt(limit)) { + throw new Error(`DeepSeek Harness decompressed session log exceeds ${limit} bytes`); + } + if (singleSegment) windowSize = Number(declaredContentBytes); + } else { + allContentSizesDeclared = false; + } + + let lastBlock = false; + let frameUpperBound = 0; + while (!lastBlock) { + need(3, "block header"); + const blockHeader = input[offset] | (input[offset + 1] << 8) | (input[offset + 2] << 16); + offset += 3; + lastBlock = (blockHeader & 1) !== 0; + const blockType = (blockHeader >>> 1) & 0x03; + const blockSize = blockHeader >>> 3; + if (blockType === 3) throw new Error("Invalid DeepSeek Harness zstd reserved block type"); + const payloadBytes = blockType === 1 ? 1 : blockSize; + need(payloadBytes, "block payload"); + offset += payloadBytes; + // A raw block emits blockSize bytes; an RLE block expands to blockSize + // repeated bytes; a compressed block cannot emit more than the frame's + // window or Zstandard's 128 KiB maximum block size. This gives frames + // without FCS a safe allocation bound before native decompression. + frameUpperBound += blockType === 2 + ? Math.min(Number(windowSize) || 128 * 1024, 128 * 1024) + : blockSize; + if (declaredContentBytes == null && frameUpperBound > limit) { + throw new Error(`DeepSeek Harness decompressed session log exceeds ${limit} bytes`); + } + } + if (hasChecksum) { + need(4, "checksum"); + offset += 4; + } + const boundedFrameBytes = declaredContentBytes == null + ? frameUpperBound + : Number(declaredContentBytes); + totalContentBytes += boundedFrameBytes; + if (allContentSizesDeclared && totalContentBytes > limit) { + throw new Error(`DeepSeek Harness decompressed session log exceeds ${limit} bytes`); + } + frameRanges.push({ start: frameStart, end: offset, maxContentBytes: boundedFrameBytes }); + frames += 1; + } + + if (frames === 0) throw new Error("DeepSeek Harness zstd log contains no data frames"); + return { + frames, + totalContentBytes: allContentSizesDeclared ? totalContentBytes : null, + maxTotalContentBytes: totalContentBytes, + frameRanges, + }; +} + +// Decode one independent Harness append frame at a time. The MongoDB binding +// understands every Node version we ship; per-frame decoding also lets us +// enforce the aggregate plaintext limit when the frame omits its content size. +async function decodeDshZstd( + data, + { maxOutputBytes = DSH_SESSION_TEXT_MAX_BYTES, inspected = null } = {}, +) { + const input = Buffer.isBuffer(data) ? data : Buffer.from(data || []); + const frameInfo = inspected || inspectDshZstdFrames(input, maxOutputBytes); + const parts = []; + let total = 0; + for (const frame of frameInfo.frameRanges) { + const decoded = Buffer.from( + await require("@mongodb-js/zstd").decompress(input.subarray(frame.start, frame.end)), + ); + total += decoded.length; + if (total > maxOutputBytes) { + throw new Error(`DeepSeek Harness decompressed session log exceeds ${maxOutputBytes} bytes`); + } + parts.push(decoded); + } + return Buffer.concat(parts, total); +} + +function dshSessionMetadata(stat) { + return { + inode: stat?.ino || 0, + size: Number.isFinite(stat?.size) ? stat.size : 0, + mtimeMs: Number.isFinite(stat?.mtimeMs) ? stat.mtimeMs : 0, + }; +} + +function sameDshSessionMetadata(previous, current) { + return Boolean( + previous && + previous.inode === current.inode && + previous.size === current.size && + previous.mtimeMs === current.mtimeMs + ); +} + +// Open, inspect and read through one handle so a path replacement cannot make +// the metadata describe a different file from the bytes we parse. +async function readDshSessionSnapshot( + filePath, + { maxOutputBytes = DSH_SESSION_TEXT_MAX_BYTES, previous = null } = {}, +) { + const handle = await fs.open(filePath, "r").catch(() => null); + if (!handle) return null; + try { + const initialStat = await handle.stat().catch(() => null); + if (!initialStat || !initialStat.isFile()) return null; + const initialMetadata = dshSessionMetadata(initialStat); + if (initialMetadata.size > DSH_SESSION_LOG_MAX_BYTES) { + throw new Error(`DeepSeek Harness session log exceeds ${DSH_SESSION_LOG_MAX_BYTES} bytes`); + } + if (sameDshSessionMetadata(previous, initialMetadata)) { + return { ...initialMetadata, unchanged: true, text: null }; + } + const outputLimit = Number.isSafeInteger(maxOutputBytes) && maxOutputBytes >= 0 + ? maxOutputBytes + : DSH_SESSION_TEXT_MAX_BYTES; + const data = await handle.readFile(); + if (data.length > DSH_SESSION_LOG_MAX_BYTES) { + throw new Error(`DeepSeek Harness session log exceeds ${DSH_SESSION_LOG_MAX_BYTES} bytes`); + } + let text; + if (filePath.endsWith(".zstd")) { + if (data.length === 0) text = ""; + else { + const inspected = inspectDshZstdFrames(data, outputLimit); + const decoded = await decodeDshZstd(data, { maxOutputBytes: outputLimit, inspected }); + if ( + decoded.length > outputLimit || + (inspected.totalContentBytes != null && decoded.length !== inspected.totalContentBytes) + ) { + throw new Error(`DeepSeek Harness decompressed session log exceeds ${outputLimit} bytes`); + } + text = decoded.toString("utf8"); + } + } else { + if (data.length > outputLimit) { + throw new Error(`DeepSeek Harness decompressed session log exceeds ${outputLimit} bytes`); + } + text = data.toString("utf8"); + } + + const finalStat = await handle.stat().catch(() => initialStat); + const metadata = dshSessionMetadata(finalStat); + // If Harness appended while this handle was being read, force a retry on + // the next sync unless the bytes consumed reached the final compressed/raw + // file size. This avoids acknowledging a tail that was never parsed. + if (metadata.size !== data.length) metadata.mtimeMs = -1; + return { ...metadata, unchanged: false, text }; + } finally { + await handle.close().catch(() => {}); + } +} + +// Read one session log to plaintext, decompressing zstd artifacts. Returns +// null for a missing/non-file path so callers treat it as a no-op. +async function readDshSessionText(filePath, options = {}) { + const snapshot = await readDshSessionSnapshot(filePath, options); + return snapshot?.text ?? null; +} + +// Drop a provider-qualified model id prefix ("deepseek/deepseek-v4-pro" → +// "deepseek-v4-pro"). The harness emits a bare id today, but the source +// vocabulary is provider-qualified elsewhere; keep the mapping cheap. +function normalizeDshModelName(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const slash = trimmed.lastIndexOf("/"); + const name = slash >= 0 ? trimmed.slice(slash + 1) : trimmed; + return name ? name : null; +} + +// Convert a harness `TokenUsage` object into our queue `totals` delta. Counts +// are disjoint, so every column maps 1:1; returns null for an all-zero usage. +function dshUsageToTotals(usage) { + if (!usage || typeof usage !== "object") return null; + const input = toNonNegativeInt(usage.inputTokens); + const output = toNonNegativeInt(usage.outputTokens); + const cacheRead = toNonNegativeInt(usage.cacheReadTokens); + const cacheWrite = toNonNegativeInt(usage.cacheWriteTokens); + const reasoning = toNonNegativeInt(usage.reasoningTokens); + if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0 && reasoning === 0) { + return null; + } + return { + input_tokens: input, + cached_input_tokens: cacheRead, + cache_creation_input_tokens: cacheWrite, + output_tokens: output, + reasoning_output_tokens: reasoning, + total_tokens: input + output + cacheRead + cacheWrite + reasoning, + conversation_count: 1, + }; +} + +// Return one top-level JSON object property's raw value slice without parsing +// sibling values. In particular this lets the Harness reader skip message +// content byte-for-byte and materialize only routing metadata plus token +// counters. It is intentionally small and JSON-syntax aware (strings, escapes, +// nested arrays/objects), not a regex over potentially nested content. +function findDshJsonProperty(raw, wantedKey) { + const text = String(raw || ""); + const skipWhitespace = (index) => { + while (index < text.length && /\s/.test(text[index])) index += 1; + return index; + }; + const stringEnd = (start) => { + if (text[start] !== '"') return -1; + for (let index = start + 1; index < text.length; index++) { + if (text[index] === "\\") { + index += 1; + } else if (text[index] === '"') { + return index + 1; + } + } + return -1; + }; + const valueEnd = (start) => { + const first = text[start]; + if (first === '"') return stringEnd(start); + if (first === "{" || first === "[") { + const stack = [first === "{" ? "}" : "]"]; + let inString = false; + for (let index = start + 1; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (char === "\\") index += 1; + else if (char === '"') inString = false; + continue; + } + if (char === '"') { + inString = true; + } else if (char === "{") { + stack.push("}"); + } else if (char === "[") { + stack.push("]"); + } else if (char === stack.at(-1)) { + stack.pop(); + if (stack.length === 0) return index + 1; + } + } + return -1; + } + let index = start; + while (index < text.length && !/[\s,}\]]/.test(text[index])) index += 1; + return index > start ? index : -1; + }; + + let index = skipWhitespace(0); + if (text[index] !== "{") return null; + index += 1; + while (index < text.length) { + index = skipWhitespace(index); + if (text[index] === "}") return null; + const keyStart = index; + const keyEnd = stringEnd(keyStart); + if (keyEnd < 0) return null; + let key; + try { + key = JSON.parse(text.slice(keyStart, keyEnd)); + } catch { + return null; + } + index = skipWhitespace(keyEnd); + if (text[index] !== ":") return null; + index = skipWhitespace(index + 1); + const start = index; + const end = valueEnd(start); + if (end < 0) return null; + if (key === wantedKey) return text.slice(start, end); + index = skipWhitespace(end); + if (text[index] === ",") { + index += 1; + continue; + } + if (text[index] === "}") return null; + return null; + } + return null; +} + +function parseDshJsonString(raw) { + if (typeof raw !== "string" || raw[0] !== '"') return null; + try { + const value = JSON.parse(raw); + return typeof value === "string" ? value : null; + } catch { + return null; + } +} + +function parseDshJsonNumber(raw) { + if (typeof raw !== "string") return NaN; + const value = Number(raw); + return Number.isFinite(value) ? value : NaN; +} + +function parseDshUsageSlice(raw) { + if (typeof raw !== "string") return null; + return { + inputTokens: parseDshJsonNumber(findDshJsonProperty(raw, "inputTokens")), + outputTokens: parseDshJsonNumber(findDshJsonProperty(raw, "outputTokens")), + cacheReadTokens: parseDshJsonNumber(findDshJsonProperty(raw, "cacheReadTokens")), + cacheWriteTokens: parseDshJsonNumber(findDshJsonProperty(raw, "cacheWriteTokens")), + reasoningTokens: parseDshJsonNumber(findDshJsonProperty(raw, "reasoningTokens")), + }; +} + +// Parse one session log's plaintext into usage deltas, skipping events whose +// seq is at or below the watermark. Returns the deltas (each carrying the +// model and epoch-ms timestamp), the highest seq seen, and the session id. +function extractDshSessionUsage(text, lastSeq = -1) { + const deltas = []; + const watermark = Number.isFinite(lastSeq) ? lastSeq : -1; + let maxSeq = watermark; + let sessionId = null; + let headerModel = null; + + const lines = String(text || "").split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line || !line.trim()) continue; + const eventType = parseDshJsonString(findDshJsonProperty(line, "type")); + if (!eventType) continue; + + if (eventType === "session") { + const id = parseDshJsonString(findDshJsonProperty(line, "id")); + if (id) sessionId = id; + continue; + } + + const seq = parseDshJsonNumber(findDshJsonProperty(line, "seq")); + const seqKnown = Number.isFinite(seq) && seq >= 0; + if (seqKnown && seq > maxSeq) maxSeq = seq; + + const data = findDshJsonProperty(line, "data"); + if (!data) continue; + + if (eventType === "request/header") { + const header = findDshJsonProperty(data, "header"); + const config = findDshJsonProperty(header, "config"); + const model = parseDshJsonString(findDshJsonProperty(config, "model")); + const normalized = normalizeDshModelName(model); + if (normalized) headerModel = normalized; + continue; + } + + if (eventType !== "assistant/message") continue; + if (seqKnown && seq <= watermark) continue; + + const message = findDshJsonProperty(data, "message"); + const source = findDshJsonProperty(message, "source"); + const model = normalizeDshModelName( + parseDshJsonString(findDshJsonProperty(source, "model")), + ) || headerModel; + const totals = dshUsageToTotals( + parseDshUsageSlice(findDshJsonProperty(data, "usage")), + ); + if (!model || !totals) continue; + + const timeMs = parseDshJsonNumber(findDshJsonProperty(line, "time")); + if (!Number.isFinite(timeMs) || timeMs <= 0) continue; + + deltas.push({ model, timeMs, totals }); + } + + return { deltas, maxSeq, sessionId }; +} + +// Incremental parser entrypoint. Mirrors the other passive JSONL readers: +// identity-check each file (inode/size/mtime), re-read + re-parse only when it +// changed, dedup via the per-file seq watermark, accumulate into hourly +// buckets, then flush touched buckets to the queue. +async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgress }) { + await ensureDir(path.dirname(queuePath)); + if (!cursors || typeof cursors !== "object") cursors = {}; + if (!cursors.dsh || typeof cursors.dsh !== "object") cursors.dsh = {}; + const dshState = cursors.dsh; + const fileState = + dshState.files && typeof dshState.files === "object" ? dshState.files : {}; + + const hourlyState = normalizeHourlyState(cursors?.hourly); + const touchedBuckets = new Set(); + const cb = typeof onProgress === "function" ? onProgress : null; + + const files = Array.isArray(sessionFiles) ? sessionFiles : []; + const presentFiles = new Set(files.filter((filePath) => typeof filePath === "string")); + const total = files.length; + let recordsProcessed = 0; + let eventsAggregated = 0; + + for (let idx = 0; idx < files.length; idx++) { + const filePath = files[idx]; + const prev = fileState[filePath] || null; + let snapshot; + let parsed; + try { + snapshot = await readDshSessionSnapshot(filePath, { previous: prev }); + if (!snapshot || snapshot.unchanged) { + if (cb) { + cb({ index: idx + 1, total, recordsProcessed, eventsAggregated, bucketsQueued: touchedBuckets.size }); + } + continue; + } + + const lastSeq = Number.isFinite(prev?.lastSeq) ? prev.lastSeq : -1; + parsed = extractDshSessionUsage(snapshot.text, lastSeq); + if (prev && parsed.sessionId && parsed.sessionId !== prev.sessionId) { + parsed = extractDshSessionUsage(snapshot.text, -1); + } + } catch (error) { + if (process.env.TOKENTRACKER_DEBUG) { + process.stderr.write(`[dsh] skipped ${filePath}: ${error?.message || error}\n`); + } + if (cb) { + cb({ index: idx + 1, total, recordsProcessed, eventsAggregated, bucketsQueued: touchedBuckets.size }); + } + continue; + } + + for (const delta of parsed.deltas) { + const bucketStart = toUtcHalfHourStart(delta.timeMs); + if (!bucketStart) continue; + const bucket = getHourlyBucket(hourlyState, DSH_SOURCE, delta.model, bucketStart); + addTotals(bucket.totals, delta.totals); + touchedBuckets.add(bucketKey(DSH_SOURCE, delta.model, bucketStart)); + eventsAggregated += 1; + } + + fileState[filePath] = { + inode: snapshot.inode, + size: snapshot.size, + mtimeMs: snapshot.mtimeMs, + sessionId: parsed.sessionId || null, + lastSeq: parsed.maxSeq, + updatedAt: new Date().toISOString(), + }; + recordsProcessed += 1; + + if (cb) { + cb({ index: idx + 1, total, recordsProcessed, eventsAggregated, bucketsQueued: touchedBuckets.size }); + } + } + + for (const filePath of Object.keys(fileState)) { + if (!presentFiles.has(filePath)) delete fileState[filePath]; + } + + const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }); + hourlyState.updatedAt = new Date().toISOString(); + cursors.hourly = hourlyState; + dshState.files = fileState; + dshState.updatedAt = new Date().toISOString(); + + return { recordsProcessed, eventsAggregated, bucketsQueued }; +} + + module.exports = { listRolloutFiles, listRolloutFilesDeep, @@ -16553,4 +17173,14 @@ module.exports = { resolveTraePath, resolveTraeStoragePath, readTraeEntitlementFromStorage, + // DeepSeek Harness (dsh) — passive session-log reader + resolveDshHome, + resolveDshSessionFiles, + readDshSessionText, + decodeDshZstd, + inspectDshZstdFrames, + normalizeDshModelName, + dshUsageToTotals, + extractDshSessionUsage, + parseDshIncremental, }; diff --git a/test/codex-source-scoped-cache.test.js b/test/codex-source-scoped-cache.test.js index 8c4970c76..ad78b4249 100644 --- a/test/codex-source-scoped-cache.test.js +++ b/test/codex-source-scoped-cache.test.js @@ -96,6 +96,8 @@ async function withTempSyncEnv(fn) { TOKENTRACKER_OPENCLAW_SESSION_KEY: process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY, TOKENTRACKER_OPENCLAW_HOME: process.env.TOKENTRACKER_OPENCLAW_HOME, TOKENTRACKER_INSFORGE_BASE_URL: process.env.TOKENTRACKER_INSFORGE_BASE_URL, + DSH_HOME: process.env.DSH_HOME, + TOKENTRACKER_DSH_HOME: process.env.TOKENTRACKER_DSH_HOME, }; try { process.env.HOME = home; @@ -111,6 +113,8 @@ async function withTempSyncEnv(fn) { delete process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY; delete process.env.TOKENTRACKER_DEVICE_TOKEN; delete process.env.TOKENTRACKER_INSFORGE_BASE_URL; + delete process.env.DSH_HOME; + delete process.env.TOKENTRACKER_DSH_HOME; return await fn(home); } finally { for (const [key, value] of Object.entries(saved)) { diff --git a/test/codex-union-cursor-divergence.test.js b/test/codex-union-cursor-divergence.test.js index 3dbeaa153..8da63aa7c 100644 --- a/test/codex-union-cursor-divergence.test.js +++ b/test/codex-union-cursor-divergence.test.js @@ -40,6 +40,7 @@ const DELETE_KEYS = [ "TOKENTRACKER_OPENCLAW_SESSION_KEY", "KILO_HOME", "MIMO_HOME", "ZCODE_HOME", "KIRO_CLI_DB_PATH", "KIRO_HOME", "TOKENTRACKER_HERMES_HOME", "TOKENTRACKER_COPILOT_SESSION_STORE_DB", + "DSH_HOME", "TOKENTRACKER_DSH_HOME", ]; const ENV_KEYS = ["HOME","USERPROFILE","CODEX_HOME","CODE_HOME","GEMINI_HOME","OPENCODE_HOME","XDG_DATA_HOME","TOKENTRACKER_OPENCLAW_HOME","APPDATA","LOCALAPPDATA","TOKENTRACKER_WSL_MODE",...DELETE_KEYS]; diff --git a/test/codex-wsl-shadow.test.js b/test/codex-wsl-shadow.test.js index 705dc7649..207d880ce 100644 --- a/test/codex-wsl-shadow.test.js +++ b/test/codex-wsl-shadow.test.js @@ -63,6 +63,8 @@ const DELETE_KEYS = [ "KIRO_HOME", "TOKENTRACKER_HERMES_HOME", "TOKENTRACKER_COPILOT_SESSION_STORE_DB", + "DSH_HOME", + "TOKENTRACKER_DSH_HOME", ]; const ENV_KEYS = [ diff --git a/test/deepseek-harness-migration.test.js b/test/deepseek-harness-migration.test.js new file mode 100644 index 000000000..2acbf4a65 --- /dev/null +++ b/test/deepseek-harness-migration.test.js @@ -0,0 +1,57 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { + migrateLegacyDeepseekHarnessSource, + DSH_LEGACY_SOURCE_MIGRATION_KEY, +} = require("../src/commands/sync"); + +test("legacy DeepSeek Harness rows migrate to dsh and retract the old cloud keys", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "tokentracker-dsh-migration-")); + const queuePath = path.join(dir, "queue.jsonl"); + const queueStatePath = path.join(dir, "queue.state.json"); + const hour = "2026-08-14T01:00:00Z"; + const legacy = { + source: "deepseek", + model: "deepseek-v4-flash", + hour_start: hour, + input_tokens: 70, + output_tokens: 30, + total_tokens: 100, + conversation_count: 1, + }; + const current = { ...legacy, source: "dsh", input_tokens: 84, output_tokens: 36, total_tokens: 120 }; + const codex = { ...legacy, source: "codex", model: "gpt-5", total_tokens: 9 }; + // Keep the legacy alias physically after the canonical row. The migration + // must re-append the explicit dsh row so last-row-wins readers retain 120. + await fs.writeFile(queuePath, [JSON.stringify(codex), JSON.stringify(current), JSON.stringify(legacy)].join("\n") + "\n"); + await fs.writeFile(queueStatePath, JSON.stringify({ offset: 999 })); + const cursors = {}; + + assert.equal(await migrateLegacyDeepseekHarnessSource({ cursors, queuePath, queueStatePath }), true); + const rows = (await fs.readFile(queuePath, "utf8")).trim().split("\n").map(JSON.parse); + const legacyRows = rows.filter((row) => row.source === "deepseek"); + assert.ok(legacyRows.length > 0, "old cloud keys receive an explicit zero retraction"); + assert.ok(legacyRows.every((row) => row.total_tokens === 0)); + + const dshRows = rows.filter((row) => row.source === "dsh" && row.model === current.model && row.hour_start === hour); + assert.equal(dshRows.at(-1).total_tokens, 120, "the latest canonical row remains authoritative"); + const migratedLegacy = dshRows.find((row) => row.total_tokens === 100); + assert.equal(migratedLegacy.billable_total_tokens, 100, "legacy billable totals default to total tokens"); + assert.equal(rows.filter((row) => row.source === "codex").length, 1, "unrelated sources stay intact"); + + const state = JSON.parse(await fs.readFile(queueStatePath, "utf8")); + assert.equal(state.offset, 0); + assert.match(state.note, /deepseek_harness_source/); + assert.equal(cursors.migrations[DSH_LEGACY_SOURCE_MIGRATION_KEY].status, "applied"); + + const snapshot = await fs.readFile(queuePath, "utf8"); + assert.equal(await migrateLegacyDeepseekHarnessSource({ cursors, queuePath, queueStatePath }), false); + assert.equal(await fs.readFile(queuePath, "utf8"), snapshot, "migration is idempotent"); + await fs.rm(dir, { recursive: true, force: true }); +}); diff --git a/test/deepseek-harness.test.js b/test/deepseek-harness.test.js new file mode 100644 index 000000000..dae46d7f3 --- /dev/null +++ b/test/deepseek-harness.test.js @@ -0,0 +1,389 @@ +/** + * DeepSeek Harness (dsh) parser test. + * + * The harness persists each session as an append-only JSONL log under + * `$DSH_HOME/sessions///session.jsonl[.zstd]`. This + * suite builds synthetic trees under a tempdir and verifies: + * - `resolveDshHome` precedence (TOKENTRACKER_DSH_HOME > DSH_HOME > ~/.dsh) + * - `resolveDshSessionFiles` finds both plaintext and zstd artifacts + * - `readDshSessionText` reassembles a concatenated-frame zstd container + * frame-by-frame with a bounded aggregate output + * - usage mapping (disjoint input/cache_read/cache_write/reasoning/output) + * - model from `assistant/message.data.message.source.model` with a + * `request/header` fallback + * - per-file `seq` watermark dedup: a second run adds nothing; an appended + * event adds only the delta + * + * No real harness install is required: fixtures are written under a tempdir + * and passed directly to the parser. + */ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const zlib = require("node:zlib"); + +const { + resolveDshHome, + resolveDshSessionFiles, + readDshSessionText, + normalizeDshModelName, + dshUsageToTotals, + extractDshSessionUsage, + parseDshIncremental, +} = require("../src/lib/rollout"); + +async function zstdCompress(data) { + if (typeof zlib.zstdCompressSync === "function") { + return zlib.zstdCompressSync(data); + } + return Buffer.from(await require("@mongodb-js/zstd").compress(data)); +} + +const T0 = new Date("2026-05-01T12:00:00Z").getTime(); + +function headerLine(id = "sess-1") { + return JSON.stringify({ + type: "session", + version: 0, + id, + createdAt: T0, + cwd: "/proj", + }); +} + +function requestHeaderLine(seq, model = "deepseek-v4-pro") { + return JSON.stringify({ + type: "request/header", + seq, + time: T0, + data: { header: { config: { provider: "deepseek-official", model } } }, + }); +} + +function assistantLine(seq, usage, { model, time = T0 } = {}) { + const source = model + ? { kind: "model", provider: "deepseek-official", model } + : undefined; + return JSON.stringify({ + type: "assistant/message", + seq, + time, + data: { + turn: 1, + step: 1, + message: { role: "assistant", content: [], source, id: `m-${seq}` }, + usage, + }, + }); +} + +async function writeSessionLog(dir, name, lines, { zstd = false } = {}) { + const filePath = path.join(dir, name); + const text = lines.join("\n") + "\n"; + let data; + if (zstd) { + // Split across two independent frames so the concatenated-frame decoder is + // exercised; the frame boundary is arbitrary (all fixture bytes are ASCII). + data = Buffer.concat([ + await zstdCompress(Buffer.from(text.slice(0, 60))), + await zstdCompress(Buffer.from(text.slice(60))), + ]); + } else { + data = Buffer.from(text); + } + fs.writeFileSync(filePath, data); + return filePath; +} + +async function makeTree({ compression = "zstd", lines } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-test-")); + const sessionsRoot = path.join(dir, ".dsh", "sessions"); + const projectDir = path.join(sessionsRoot, "--proj--"); + const sessionDir = path.join(projectDir, "sess-1"); + fs.mkdirSync(sessionDir, { recursive: true }); + const name = compression === "zstd" ? "session.jsonl.zstd" : "session.jsonl"; + const logPath = await writeSessionLog(sessionDir, name, lines, { zstd: compression === "zstd" }); + return { dir, dshHome: path.join(dir, ".dsh"), sessionDir, sessionsRoot, logPath }; +} + +test("resolveDshHome honors TOKENTRACKER_DSH_HOME, DSH_HOME, then default", () => { + assert.equal(resolveDshHome({ TOKENTRACKER_DSH_HOME: "/tmp/a" }), path.resolve("/tmp/a")); + assert.equal(resolveDshHome({ TOKENTRACKER_DSH_HOME: " ", DSH_HOME: "/tmp/b" }), path.resolve("/tmp/b")); + assert.equal(resolveDshHome({ DSH_HOME: "/tmp/c" }), path.resolve("/tmp/c")); + assert.equal(resolveDshHome({}), path.join(os.homedir(), ".dsh")); +}); + +test("normalizeDshModelName drops provider-qualified prefixes", () => { + assert.equal(normalizeDshModelName("deepseek-v4-pro"), "deepseek-v4-pro"); + assert.equal(normalizeDshModelName("deepseek/deepseek-v4-pro"), "deepseek-v4-pro"); + assert.equal(normalizeDshModelName(" "), null); + assert.equal(normalizeDshModelName(null), null); + assert.equal(normalizeDshModelName("/"), null); +}); + +test("dshUsageToTotals maps disjoint columns 1:1 and rejects all-zero", () => { + assert.deepEqual( + dshUsageToTotals({ + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + cacheWriteTokens: 5, + reasoningTokens: 7, + }), + { + input_tokens: 100, + cached_input_tokens: 10, + cache_creation_input_tokens: 5, + output_tokens: 50, + reasoning_output_tokens: 7, + total_tokens: 172, + conversation_count: 1, + }, + ); + assert.equal(dshUsageToTotals({ inputTokens: 0, outputTokens: 0 }), null); + assert.equal(dshUsageToTotals(null), null); +}); + +test("extractDshSessionUsage parses header, model source, header fallback, watermark", () => { + const text = [ + headerLine("sess-1"), + requestHeaderLine(0, "deepseek-v4-flash"), + assistantLine(1, { inputTokens: 10, outputTokens: 5 }), + // message.source carries the model and overrides the header model. + assistantLine(2, { inputTokens: 20, outputTokens: 5 }, { model: "deepseek-v4-pro" }), + // all-zero usage is dropped. + assistantLine(3, { inputTokens: 0, outputTokens: 0 }, { model: "deepseek-v4-pro" }), + ].join("\n"); + + const parsed = extractDshSessionUsage(text, -1); + assert.equal(parsed.sessionId, "sess-1"); + assert.equal(parsed.maxSeq, 3); + assert.equal(parsed.deltas.length, 2); + // seq 1 used the request/header fallback model. + assert.equal(parsed.deltas[0].model, "deepseek-v4-flash"); + assert.equal(parsed.deltas[0].totals.total_tokens, 15); + // seq 2 used message.source.model. + assert.equal(parsed.deltas[1].model, "deepseek-v4-pro"); + assert.equal(parsed.deltas[1].totals.total_tokens, 25); + + // A watermark at seq 1 skips seq <= 1 and only returns seq 2. + const tail = extractDshSessionUsage(text, 1); + assert.equal(tail.deltas.length, 1); + assert.equal(tail.deltas[0].totals.total_tokens, 25); +}); + +test("extractDshSessionUsage never materializes assistant content", () => { + const secret = "SECRET_PROMPT_MUST_NOT_BE_PARSED"; + const line = JSON.stringify({ + type: "assistant/message", + seq: 1, + time: T0, + data: { + message: { + role: "assistant", + content: [{ type: "text", text: secret }], + source: { provider: "deepseek-official", model: "deepseek-v4-flash" }, + }, + usage: { inputTokens: 4, outputTokens: 2 }, + }, + }); + const originalParse = JSON.parse; + let leaked = false; + JSON.parse = function privacyGuard(value, ...args) { + if (String(value).includes(secret)) leaked = true; + return originalParse.call(this, value, ...args); + }; + try { + const parsed = extractDshSessionUsage(`${headerLine()}\n${line}\n`, -1); + assert.equal(parsed.deltas.length, 1); + assert.equal(parsed.deltas[0].totals.total_tokens, 6); + } finally { + JSON.parse = originalParse; + } + assert.equal(leaked, false, "message content was passed to JSON.parse"); +}); + +test("readDshSessionText reassembles concatenated-frame zstd", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-zstd-")); + const lines = [headerLine(), assistantLine(1, { inputTokens: 1, outputTokens: 1 })]; + const full = lines.join("\n") + "\n"; + const f1 = await zstdCompress(Buffer.from(full.slice(0, 80))); + const f2 = await zstdCompress(Buffer.from(full.slice(80))); + const p = path.join(dir, "session.jsonl.zstd"); + fs.writeFileSync(p, Buffer.concat([f1, f2])); + + const text = await readDshSessionText(p); + assert.equal(text, full, "both frames must be reassembled"); + + await assert.rejects( + readDshSessionText(p, { maxOutputBytes: Buffer.byteLength(full) - 1 }), + /decompressed session log exceeds/i, + "declared frame sizes must be rejected before unbounded decompression", + ); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("resolveDshSessionFiles only accepts exact project/session transcript leaves", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-files-")); + const root = path.join(dir, ".dsh", "sessions"); + fs.mkdirSync(path.join(root, "--a--", "s1"), { recursive: true }); + fs.mkdirSync(path.join(root, "--a--", "s2"), { recursive: true }); + fs.mkdirSync(path.join(root, "--a--", "s3", "nested"), { recursive: true }); + fs.mkdirSync(path.join(root, "--a--", "s4"), { recursive: true }); + fs.writeFileSync(path.join(root, "--a--", "s1", "session.jsonl"), "x\n"); + fs.writeFileSync(path.join(root, "--a--", "s2", "session.jsonl.zstd"), "y\n"); + fs.writeFileSync(path.join(root, "--a--", "s2", "settings.yaml"), "ignore\n"); + fs.writeFileSync(path.join(root, "session.jsonl"), "wrong depth\n"); + fs.writeFileSync(path.join(root, "--a--", "session.jsonl"), "wrong depth\n"); + fs.writeFileSync(path.join(root, "--a--", "s3", "nested", "session.jsonl"), "wrong depth\n"); + const staleRaw = path.join(root, "--a--", "s4", "session.jsonl"); + const activeZstd = path.join(root, "--a--", "s4", "session.jsonl.zstd"); + fs.writeFileSync(staleRaw, "stale encoding\n"); + fs.writeFileSync(activeZstd, "active encoding\n"); + fs.utimesSync(staleRaw, new Date(T0), new Date(T0)); + fs.utimesSync(activeZstd, new Date(T0 + 1000), new Date(T0 + 1000)); + + const files = await resolveDshSessionFiles({ TOKENTRACKER_DSH_HOME: path.join(dir, ".dsh") }); + assert.deepEqual(files, [ + path.join(root, "--a--", "s1", "session.jsonl"), + path.join(root, "--a--", "s2", "session.jsonl.zstd"), + activeZstd, + ]); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("parseDshIncremental writes queue rows, dedups on rerun, and adds appended delta", async () => { + const { dir, logPath } = await makeTree({ + lines: [ + headerLine("sess-1"), + requestHeaderLine(0), + assistantLine(1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 5, reasoningTokens: 7 }), + assistantLine(2, { inputTokens: 20, outputTokens: 5 }, { time: T0 + 40 * 60 * 1000 }), + ], + }); + const queuePath = path.join(dir, "queue.jsonl"); + const cursors = {}; + + const res1 = await parseDshIncremental({ sessionFiles: [logPath], cursors, queuePath }); + assert.equal(res1.eventsAggregated, 2); + + const rows = fs.readFileSync(queuePath, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse); + const dshRows = rows.filter((r) => r.source === "dsh"); + assert.equal(dshRows.length, 2, "two distinct half-hour buckets"); + assert.ok(dshRows.every((r) => r.model === "deepseek-v4-pro")); + const first = dshRows.find((r) => r.total_tokens === 172); + assert.ok(first); + assert.equal(first.input_tokens, 100); + assert.equal(first.output_tokens, 50); + assert.equal(first.cached_input_tokens, 10); + assert.equal(first.cache_creation_input_tokens, 5); + assert.equal(first.reasoning_output_tokens, 7); + assert.equal(first.conversation_count, 1); + + // Second run: file identity is unchanged, nothing new is parsed or queued. + const res2 = await parseDshIncremental({ sessionFiles: [logPath], cursors, queuePath }); + assert.equal(res2.eventsAggregated, 0, "no delta on unchanged rerun"); + const rowsAfter2 = fs.readFileSync(queuePath, "utf8").trim().split("\n").filter(Boolean).length; + assert.equal(rowsAfter2, rows.length, "no new rows on unchanged rerun"); + + // Append a third event to the same file; only the new seq contributes. + const existing = await readDshSessionText(logPath); + const grown = existing + assistantLine(3, { inputTokens: 300, outputTokens: 30 }, { time: T0 + 80 * 60 * 1000 }) + "\n"; + fs.writeFileSync(logPath, Buffer.concat([ + await zstdCompress(Buffer.from(grown.slice(0, 60))), + await zstdCompress(Buffer.from(grown.slice(60))), + ])); + + const res3 = await parseDshIncremental({ sessionFiles: [logPath], cursors, queuePath }); + assert.equal(res3.eventsAggregated, 1, "only the appended event is counted"); + const rows3 = fs.readFileSync(queuePath, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse); + const dshTokens = rows3.filter((r) => r.source === "dsh").reduce((s, r) => s + r.total_tokens, 0); + assert.equal(dshTokens, 172 + 25 + 330, "no re-count of the seed events"); + + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("parseDshIncremental accepts a replacement session whose seq restarts", async () => { + const { dir, logPath } = await makeTree({ + compression: "none", + lines: [ + headerLine("sess-old"), + requestHeaderLine(0), + assistantLine(1, { inputTokens: 100, outputTokens: 50 }), + assistantLine(2, { inputTokens: 20, outputTokens: 5 }), + ], + }); + const queuePath = path.join(dir, "queue.jsonl"); + const cursors = {}; + + const first = await parseDshIncremental({ sessionFiles: [logPath], cursors, queuePath }); + assert.equal(first.eventsAggregated, 2); + + await writeSessionLog(path.dirname(logPath), path.basename(logPath), [ + headerLine("sess-new"), + requestHeaderLine(0, "deepseek-v4-flash"), + assistantLine(1, { inputTokens: 7, outputTokens: 3 }), + ]); + + const second = await parseDshIncremental({ sessionFiles: [logPath], cursors, queuePath }); + assert.equal(second.eventsAggregated, 1, "new session identity resets the seq watermark"); + assert.equal(cursors.dsh.files[logPath].sessionId, "sess-new"); + + const rows = fs.readFileSync(queuePath, "utf8").trim().split("\n").map(JSON.parse); + const total = rows.filter((row) => row.source === "dsh").reduce((sum, row) => sum + row.total_tokens, 0); + assert.equal(total, 150 + 25 + 10); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("parseDshIncremental isolates corrupt logs and prunes deleted-session cursors", async () => { + const { dir, sessionDir, logPath } = await makeTree({ + compression: "none", + lines: [ + headerLine("sess-good"), + requestHeaderLine(0), + assistantLine(1, { inputTokens: 8, outputTokens: 2 }), + ], + }); + const corruptDir = path.join(path.dirname(sessionDir), "sess-corrupt"); + fs.mkdirSync(corruptDir, { recursive: true }); + const corruptPath = path.join(corruptDir, "session.jsonl.zstd"); + fs.writeFileSync(corruptPath, "not-a-zstd-frame"); + const deletedPath = path.join(path.dirname(sessionDir), "sess-deleted", "session.jsonl"); + const cursors = { + dsh: { + files: { + [deletedPath]: { inode: 1, size: 10, mtimeMs: 1, lastSeq: 9 }, + }, + }, + }; + + const result = await parseDshIncremental({ + sessionFiles: [corruptPath, logPath], + cursors, + queuePath: path.join(dir, "queue.jsonl"), + }); + + assert.equal(result.recordsProcessed, 1); + assert.equal(result.eventsAggregated, 1, "healthy sessions still aggregate"); + assert.ok(cursors.dsh.files[logPath]); + assert.equal(cursors.dsh.files[corruptPath], undefined, "failed logs retry on a later sync"); + assert.equal(cursors.dsh.files[deletedPath], undefined, "deleted sessions do not leak cursor state"); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("parseDshIncremental is a no-op with no files", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-empty-")); + const res = await parseDshIncremental({ + sessionFiles: [], + cursors: {}, + queuePath: path.join(dir, "queue.jsonl"), + }); + assert.equal(res.recordsProcessed, 0); + assert.equal(res.eventsAggregated, 0); + assert.equal(res.bucketsQueued, 0); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/test/helpers/with-home.js b/test/helpers/with-home.js index 28060b3dc..14089a3c0 100644 --- a/test/helpers/with-home.js +++ b/test/helpers/with-home.js @@ -10,11 +10,22 @@ // // Returns a restore() to call from the test's finally block. function withHome(dir) { - const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + const prev = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + DSH_HOME: process.env.DSH_HOME, + TOKENTRACKER_DSH_HOME: process.env.TOKENTRACKER_DSH_HOME, + }; process.env.HOME = dir; process.env.USERPROFILE = dir; + // The DeepSeek Harness exports DSH_HOME to every child process, so running + // the suite inside a dsh session would otherwise leak the real ~/.dsh past + // this isolation. Clear both harness-home overrides so the dsh parser + // resolves to the isolated HOME's `.dsh` instead. + delete process.env.DSH_HOME; + delete process.env.TOKENTRACKER_DSH_HOME; return function restoreHome() { - for (const key of ["HOME", "USERPROFILE"]) { + for (const key of ["HOME", "USERPROFILE", "DSH_HOME", "TOKENTRACKER_DSH_HOME"]) { if (prev[key] === undefined) delete process.env[key]; else process.env[key] = prev[key]; } diff --git a/test/legacy-baseurl-migration.test.js b/test/legacy-baseurl-migration.test.js index 62c64f3af..1790647a9 100644 --- a/test/legacy-baseurl-migration.test.js +++ b/test/legacy-baseurl-migration.test.js @@ -24,6 +24,8 @@ async function withTempHome(fn) { CODEX_HOME: process.env.CODEX_HOME, CODE_HOME: process.env.CODE_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME, + DSH_HOME: process.env.DSH_HOME, + TOKENTRACKER_DSH_HOME: process.env.TOKENTRACKER_DSH_HOME, TOKENTRACKER_DEVICE_TOKEN: process.env.TOKENTRACKER_DEVICE_TOKEN, TOKENTRACKER_INSFORGE_BASE_URL: process.env.TOKENTRACKER_INSFORGE_BASE_URL, TOKENTRACKER_SKIP_FIRST_SYNC: process.env.TOKENTRACKER_SKIP_FIRST_SYNC, @@ -36,6 +38,8 @@ async function withTempHome(fn) { process.env.XDG_DATA_HOME = path.join(home, ".local", "share"); delete process.env.TOKENTRACKER_DEVICE_TOKEN; delete process.env.TOKENTRACKER_INSFORGE_BASE_URL; + delete process.env.DSH_HOME; + delete process.env.TOKENTRACKER_DSH_HOME; return await fn(home); } finally { global.fetch = savedFetch; diff --git a/test/sync-codex-rescan-repair.test.js b/test/sync-codex-rescan-repair.test.js index 8498bce20..38ea2845b 100644 --- a/test/sync-codex-rescan-repair.test.js +++ b/test/sync-codex-rescan-repair.test.js @@ -94,6 +94,8 @@ async function withTempSyncEnv(fn) { CODE_HOME: process.env.CODE_HOME, GEMINI_HOME: process.env.GEMINI_HOME, OPENCODE_HOME: process.env.OPENCODE_HOME, + DSH_HOME: process.env.DSH_HOME, + TOKENTRACKER_DSH_HOME: process.env.TOKENTRACKER_DSH_HOME, TOKENTRACKER_DEVICE_TOKEN: process.env.TOKENTRACKER_DEVICE_TOKEN, TOKENTRACKER_AUTO_RETRY_NO_SPAWN: process.env.TOKENTRACKER_AUTO_RETRY_NO_SPAWN, }; @@ -106,6 +108,8 @@ async function withTempSyncEnv(fn) { process.env.OPENCODE_HOME = path.join(home, ".opencode"); process.env.TOKENTRACKER_DEVICE_TOKEN = "test-device-token"; process.env.TOKENTRACKER_AUTO_RETRY_NO_SPAWN = "1"; + delete process.env.DSH_HOME; + delete process.env.TOKENTRACKER_DSH_HOME; return await fn(home); } finally { for (const [key, value] of Object.entries(saved)) { diff --git a/test/sync-lock-debris.test.js b/test/sync-lock-debris.test.js index 9d07fd9a7..3f9e4c0d6 100644 --- a/test/sync-lock-debris.test.js +++ b/test/sync-lock-debris.test.js @@ -67,6 +67,8 @@ async function withTempHome(fn) { CODEX_HOME: process.env.CODEX_HOME, CODE_HOME: process.env.CODE_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME, + DSH_HOME: process.env.DSH_HOME, + TOKENTRACKER_DSH_HOME: process.env.TOKENTRACKER_DSH_HOME, }; try { process.env.HOME = home; @@ -74,6 +76,8 @@ async function withTempHome(fn) { process.env.CODEX_HOME = path.join(home, ".codex"); process.env.CODE_HOME = path.join(home, ".code"); process.env.XDG_DATA_HOME = path.join(home, ".local", "share"); + delete process.env.DSH_HOME; + delete process.env.TOKENTRACKER_DSH_HOME; return await fn(home); } finally { for (const [key, value] of Object.entries(saved)) {