From 8747b2419a96d0f448cd0919ec9f4e57cb7daa69 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 23:37:02 -0700 Subject: [PATCH] refactor(daemon): move session types + tracking to sessionState.ts Verbatim move of SessionState, the tracker types (PendingToolCall, SubagentTracker, TeamMember, LoadedInstruction), SubagentTracking, newSessionState, and resolvePermissionIfPending. Also dedupes the sha256-hex idiom into utils.sha256Hex now that it has two consumers (hashPrompt + the config fingerprint). No behavior change. Co-Authored-By: Claude Fable 5 --- src/config.ts | 6 +- src/daemon.ts | 290 ++------------------------------------------ src/sessionState.ts | 247 ++++++++++++++++++++++++++++++++++++- src/utils.ts | 14 ++- 4 files changed, 267 insertions(+), 290 deletions(-) diff --git a/src/config.ts b/src/config.ts index 09314f8..572e43c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,7 +7,7 @@ // import cycle (cli.ts imports the daemon entry point). import { DEFAULT_AGENT_NAME } from './genaiSpans.js'; -import { createHash } from 'crypto'; +import { sha256Hex } from './utils.js'; import type { Settings } from './setup.js'; /** Where a resolved value came from, for user-facing "source" reporting. */ @@ -131,8 +131,6 @@ const CONFIG_FINGERPRINT_LENGTH = 16; /** Short, stable hash of a daemon config. The API key is hashed, not exposed, * so the fingerprint is safe to send over the socket. */ export function daemonConfigFingerprint(c: DaemonConfig): string { - return createHash('sha256') - .update(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) - .digest('hex') + return sha256Hex(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) .slice(0, CONFIG_FINGERPRINT_LENGTH); } diff --git a/src/daemon.ts b/src/daemon.ts index b80a677..914d4db 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -39,11 +39,21 @@ import type { import { loadSettings, VERSION } from './setup.js'; import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; import { + resolvePermissionIfPending, hashPrompt, computeSubagentTranscriptPath, extractUserMessageContent, lastAssistantTextEndsWith, readSubagentFirstLineWithRetry, + newSessionState, + upsertInstruction, +} from './sessionState.js'; +import type { + PendingToolCall, + SubagentTracker, + TeamMember, + SessionState, + LoadedInstruction, } from './sessionState.js'; import { appendToLog, deepEqual } from './utils.js'; import { @@ -98,37 +108,6 @@ function isControlMessage(payload: unknown): payload is ControlMessage { return cmd === 'shutdown' || cmd === 'config-hash'; } -/** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ -type PendingToolCall = { - span: Span; - toolName: string; - toolInput: Record; - /** True once a PermissionRequest event has been emitted for this tool. */ - permissionRequested?: boolean; -} - -/** Tracks the chat span currently open for a single assistant API response. - * Tool spans for that response parent here so the trace tree shows the - * model's interleaved text → tool_use → text order. The response's - * text/thinking children are emitted when the span is finalized (at the next - * response transition or at Stop), once all its split transcript lines are - * present. */ -type ActiveChatSpan = { - /** Response key (Anthropic `message.id`, or index fallback) this chat span - * represents; see `chatMessageKey`. */ - responseKey: string; - span: Span; -} - -/** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ -function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { - if (!pending.permissionRequested) return; - addPermissionResolvedEvent(pending.span, { - approved, - timestamp: new Date(), - }); -} - /** Stable identity for an assistant API call within a turn. Anthropic returns * a `message.id` on every response; that's the primary key. When it's * missing (legacy transcripts), fall back to the index, which is stable @@ -174,136 +153,6 @@ function parseIsoOrNow(ts: string | undefined): Date { return parseTimestamp(ts) ?? new Date(); } -/** - * Tracks a subagent across hook events. Two shapes: - * (a) Matched — created at PreToolUse when an Agent tool with subagent_type - * is detected; carries `toolUseId`, `promptHash`, and a reference to - * the subagent's `invoke_agent` span. `agentId` is filled in at - * SubagentStart via content-based correlation: sha256(firing prompt) + - * subagent_type. - * (b) Orphan — created at SubagentStart when no tracker matches the firing - * prompt (the parent's Agent PreToolUse never fired, or its prompt - * differs from the subagent transcript's line 1). The `invoke_agent` - * span is created at SubagentStart with the current turn span as - * parent and no input messages (the firing prompt is unavailable). - * - * The subagent is its own `invoke_agent ` span, child of the - * parent turn's `invoke_agent claude-code` span. Per the Weave Agents chat - * view (`weave/trace_server/agents/chat_view.py`), nested `invoke_agent` - * spans render as an `agent_start` lifecycle marker with the inner agent's - * own assistant text — distinct from an `execute_tool` tool-call event. - * The Agent tool call does NOT emit an `execute_tool` span; it emits this - * `invoke_agent` span directly. - */ -type SubagentTracker = { - subagentType: string; - detectedAt: Date; - toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) - invokeAgentSpan?: Span; // subagent's `invoke_agent` span; subagent chat/tool spans parent here - agentId?: string; - /** sha256 of the prompt passed to the Agent tool; matched against the - * subagent's transcript line-1 user message at SubagentStart. */ - promptHash?: string; - /** True once the invoke_agent span has been ended. Guards against - * double-end when PostToolUse and SubagentStop both try to close it. */ - ended?: boolean; - /** Subagent transcript path — stored at SubagentStart so TeammateIdle can - * read all turns without relying on the payload's transcript_path (which - * CC sets to the coordinator's path, not the subagent's). */ - transcriptPath?: string; - /** Set on orphan trackers when SubagentStop fires before TeammateIdle. - * Suppresses span closure at SubagentStop so TeammateIdle can close it - * with full all-turns content. */ - pendingTeammateIdle?: boolean; - /** Set when this Agent tool spawn carried a `team_name` (agent-teams model). - * The teammate runs in its OWN session, so its TeammateIdle fires under a - * different session_id and the per-session lookup misses. The invoke_agent - * span is registered in GlobalDaemon.teamMembers and closed there (at the - * teammate's TeammateIdle), NOT at the coordinator's PostToolUse(Agent). */ - teamName?: string; -} - -/** Cross-session team correlation. In agent-teams (TeamCreate) a teammate is an - * independent Claude session whose TeammateIdle fires under the teammate's own - * session_id, not the coordinator's — so the per-session SubagentTracking - * lookup misses. The coordinator's PreToolUse(Agent, team_name) is the one - * reliable anchor; we record its invoke_agent span here keyed by - * `${team_name}::${name}`. - * - * Entries are stored as a FIFO queue per key (not a single value) because the - * SAME `${team}::${name}` can be spawned more than once in a run — e.g. the - * TARS triage flow re-spawns a specialist (Sonnet→Opus) for deeper work. Each - * spawn pushes its own TeamMember; each teammate's TeammateIdle consumes the - * oldest not-yet-emitted entry (FIFO), so re-spawns never overwrite a live span - * (which would leak it and mis-attribute the first teammate's transcript). This - * mirrors SubagentTracking.findPendingTeammateIdle for the per-session path. */ -type TeamMember = { - invokeAgentSpan: Span; - conversationId: string; - coordinatorTranscriptPath: string; - emitted: boolean; -} - -/** One instruction file surfaced by the `InstructionsLoaded` hook. Accumulated - * per session (deduped by path) and stamped as `gen_ai.system_instructions` on - * each turn root. */ -type LoadedInstruction = { filePath: string; content: string }; - -/** Append `item` to `list` in place, replacing any existing entry with the same - * filePath so a reloaded file (e.g. `load_reason=compact`) updates rather than - * duplicates. Preserves each file's first-seen position. */ -function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { - const idx = list.findIndex((i) => i.filePath === item.filePath); - if (idx >= 0) list[idx] = item; - else list.push(item); -} - -type SessionState = { - sessionId: string; - /** Root ancestor's session id — used as `gen_ai.conversation.id` so resumed - * turns stitch with their pre-resume turns server-side. Equals `sessionId` - * for fresh (non-forked) sessions. Resolved once at SessionStart by - * walking `forkedFrom.sessionId` pointers across transcript files. */ - conversationId: string; - transcript: TranscriptFile; - cwd: string; - source: string; - initialRequestModel?: string; - /** Integration identity (name, version, meta.*) as OTel Baggage, built once - * at SessionStart. Activated for every event in `routeEvent` so each span - * inherits it via `IntegrationBaggageSpanProcessor`. */ - integrationBaggage: Baggage; - - currentTurnSpan?: Span; - - turnNumber: number; - totalToolCalls: number; - turnToolCalls: number; - toolCounts: Record; - - pendingToolCalls: Map; - subagents: SubagentTracking; - - /** Chat span currently open for an in-progress assistant API call. Tool - * spans from PreToolUse parent here; finalized at Stop or on transition - * to the next API call. Cleared at Stop. */ - activeChatSpan?: ActiveChatSpan; - /** Response keys (see `chatMessageKey`) in the current turn for which a chat - * span has been opened (open or already finalized). Stop uses this to - * identify responses that need a chat span emitted from scratch (responses - * with no tool_use blocks never triggered PreToolUse). Reset per turn. */ - emittedChatSpanResponseKeys: Set; - - /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ - pendingCompaction?: CompactionAttrs; - - /** Instruction files (global/project CLAUDE.md, .claude/rules, @-imports) - * captured from InstructionsLoaded, in load order, deduped by path. Stamped - * on every turn root as `gen_ai.system_instructions`. */ - systemInstructions: LoadedInstruction[]; - -} - // ───────────────────────────────────────────────────────────────────────────── // GlobalDaemon // ───────────────────────────────────────────────────────────────────────────── @@ -328,125 +177,6 @@ const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection const MAX_SOCKET_PAYLOAD_BYTES = 4 * 1024 * 1024; // 4 MiB per message -/** - * Per-session container that tracks subagents from PreToolUse (when an Agent - * tool with subagent_type is detected) through SubagentStop. Single source of - * truth for the tracker list, with intent-revealing lookup methods. - */ -class SubagentTracking { - private trackers: SubagentTracker[] = []; - - /** Add a pending tracker at PreToolUse, before SubagentStart correlates an agent_id. */ - add(tracker: SubagentTracker): void { - this.trackers.push(tracker); - } - - /** - * Find the unmatched tracker (no agent_id yet) matching `(promptHash, - * subagentType)`. FIFO across ties: the oldest pending tracker wins, so two - * back-to-back identical Agent calls still correlate in dispatch order. - * Returns undefined if no candidate qualifies. - */ - findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (t.agentId) continue; - if (t.promptHash !== promptHash) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - byAgentId(agentId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.agentId === agentId); - } - - /** Find a tracker awaiting TeammateIdle by its subagentType. Used to - * correlate TeammateIdle(teammate_name) with the orphan tracker created - * at SubagentStart. Returns the oldest pending match (FIFO). */ - findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (!t.pendingTeammateIdle) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - /** Lookup by spawning Agent tool's tool_use_id. Used at PostToolUse to find - * the subagent's `invoke_agent` span when the matching toolUseId is not - * in `pendingToolCalls` (because the Agent tool emits an invoke_agent - * span instead of an execute_tool span). */ - byToolUseId(toolUseId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.toolUseId === toolUseId); - } - - remove(tracker: SubagentTracker): void { - const idx = this.trackers.indexOf(tracker); - if (idx >= 0) this.trackers.splice(idx, 1); - } - - size(): number { - return this.trackers.length; - } - - all(): SubagentTracker[] { - return [...this.trackers]; - } -} - -/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 - * for a brand-new session, or the number of turns already on disk when - * reconstructing a session lost across a daemon restart (so the resumed turn - * keeps counting up instead of resetting to 1). */ -type NewSessionStateOptions = { - sessionId: string; - conversationId: string; - transcript: TranscriptFile; - cwd: string; - source: string; - initialRequestModel: string | undefined; - turnNumber: number; -}; - -/** Build a fresh SessionState. */ -function newSessionState(options: NewSessionStateOptions): SessionState { - const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = - options; - // Claude Code stamps its CLI version on each transcript line; capture it - // best-effort from the head line for the integration metadata. Absent when - // the writer hasn't flushed yet, the meta key is simply omitted. Built - // here (not at the SessionStart call site) so a session reconstructed after - // a daemon restart carries the same integration identity on its spans. - const headLine = readFirstTranscriptLine(transcript.resolvedPath); - const version = headLine?.['version']; - const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; - const integrationBaggage = createIntegrationBaggage({ - version: VERSION, - meta: { claude_code_app_version: claudeCodeAppVersion }, - }); - - return { - sessionId, - conversationId, - transcript, - cwd, - source, - initialRequestModel, - integrationBaggage, - turnNumber, - totalToolCalls: 0, - turnToolCalls: 0, - toolCounts: {}, - pendingToolCalls: new Map(), - subagents: new SubagentTracking(), - emittedChatSpanResponseKeys: new Set(), - systemInstructions: [], - }; -} - /** Absolute real path of the daemon's own entry script, resolving the npm bin * symlink to the actual dist/cli.js (or src/cli.ts under tsx). Lets `status` * report which build the running daemon is executing. Falls back to the raw diff --git a/src/sessionState.ts b/src/sessionState.ts index 220b7db..eb31d89 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -6,14 +6,255 @@ // from daemon.ts; no behavior change. import * as path from 'path'; -import { createHash } from 'crypto'; +import type { Baggage } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; +import { VERSION } from './setup.js'; import { parseSessionFd, extractAssistantTextBlocks } from './parser.js'; -import { readFirstTranscriptLine } from './transcriptFile.js'; +import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; +import { addPermissionResolvedEvent, createIntegrationBaggage } from './genaiSpans.js'; +import type { CompactionAttrs } from './genaiSpans.js'; +import { sha256Hex } from './utils.js'; + +/** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ +export type PendingToolCall = { + span: Span; + toolName: string; + toolInput: Record; + /** True once a PermissionRequest event has been emitted for this tool. */ + permissionRequested?: boolean; +} + +/** The chat span open for the in-flight assistant response; its tool spans + * parent here. Content lands when it is finalized (next response transition, + * or Stop), once all its transcript lines are flushed. */ +type ActiveChatSpan = { + /** Response key (Anthropic `message.id`, or index fallback) this chat span + * represents; see `chatMessageKey`. */ + responseKey: string; + span: Span; +} + +/** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ +export function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { + if (!pending.permissionRequested) return; + addPermissionResolvedEvent(pending.span, { + approved, + timestamp: new Date(), + }); +} + +/** + * Tracks a subagent across hook events. Matched trackers are created at + * PreToolUse(Agent) and correlated to an agent_id at SubagentStart by + * sha256(firing prompt) + type; orphans are created at SubagentStart when + * nothing matches. Either way the subagent is its own `invoke_agent` span + * under the turn (why a marker and not `execute_tool`: see the daemon's + * Agent-dispatch branch). + */ +export type SubagentTracker = { + subagentType: string; + detectedAt: Date; + toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) + invokeAgentSpan?: Span; // subagent's `invoke_agent` span; subagent chat/tool spans parent here + agentId?: string; + /** sha256 of the prompt passed to the Agent tool; matched against the + * subagent's transcript line-1 user message at SubagentStart. */ + promptHash?: string; + /** True once the invoke_agent span has been ended. Guards against + * double-end when PostToolUse and SubagentStop both try to close it. */ + ended?: boolean; + /** Stored at SubagentStart; TeammateIdle's own transcript_path is the + * coordinator's, so this is the reliable copy. */ + transcriptPath?: string; + /** Orphan awaiting TeammateIdle: SubagentStop leaves the span open so + * TeammateIdle can close it with full all-turns content. */ + pendingTeammateIdle?: boolean; + /** Set for `team_name` spawns: the span is owned by + * GlobalDaemon.teamMembers and closed at the teammate's TeammateIdle, + * NOT at the coordinator's PostToolUse(Agent). */ + teamName?: string; +} + +/** One queued team-member spawn. A teammate is an independent session whose + * TeammateIdle fires under its OWN session_id, so the coordinator's + * PreToolUse(Agent, team_name) is the only reliable anchor: it queues the + * span in GlobalDaemon.teamMembers (FIFO per `${team}::${name}`; the same + * name can be re-spawned, and overwriting would leak the first, still-open + * span). */ +export type TeamMember = { + invokeAgentSpan: Span; + conversationId: string; + coordinatorTranscriptPath: string; + emitted: boolean; +} + +/** One instruction file surfaced by the `InstructionsLoaded` hook. Accumulated + * per session (deduped by path) and stamped as `gen_ai.system_instructions` on + * each turn root. */ +export type LoadedInstruction = { filePath: string; content: string }; + +/** Append `item` to `list` in place, replacing any existing entry with the same + * filePath so a reloaded file (e.g. `load_reason=compact`) updates rather than + * duplicates. Preserves each file's first-seen position. */ +export function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { + const idx = list.findIndex((i) => i.filePath === item.filePath); + if (idx >= 0) list[idx] = item; + else list.push(item); +} + +export type SessionState = { + sessionId: string; + /** Root ancestor's session id — used as `gen_ai.conversation.id` so resumed + * turns stitch with their pre-resume turns server-side. Equals `sessionId` + * for fresh (non-forked) sessions. Resolved once at SessionStart by + * walking `forkedFrom.sessionId` pointers across transcript files. */ + conversationId: string; + transcript: TranscriptFile; + cwd: string; + source: string; + initialRequestModel?: string; + /** Integration identity (name, version, meta.*) as OTel Baggage, built once + * at SessionStart. Activated for every event in `routeEvent` so each span + * inherits it via `IntegrationBaggageSpanProcessor`. */ + integrationBaggage: Baggage; + + currentTurnSpan?: Span; + + turnNumber: number; + totalToolCalls: number; + turnToolCalls: number; + toolCounts: Record; + + pendingToolCalls: Map; + subagents: SubagentTracking; + + /** Chat span currently open for an in-progress assistant API call. Tool + * spans from PreToolUse parent here; finalized at Stop or on transition + * to the next API call. Cleared at Stop. */ + activeChatSpan?: ActiveChatSpan; + /** Response keys with a chat span already opened this turn; Stop emits + * fresh spans for the rest (tool-less responses never hit PreToolUse). */ + emittedChatSpanResponseKeys: Set; + + /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ + pendingCompaction?: CompactionAttrs; + + /** Instruction files from InstructionsLoaded, in load order, deduped by + * path; stamped on every turn root as `gen_ai.system_instructions`. */ + systemInstructions: LoadedInstruction[]; +} + +/** + * Per-session container that tracks subagents from PreToolUse (when an Agent + * tool with subagent_type is detected) through SubagentStop. Single source of + * truth for the tracker list, with intent-revealing lookup methods. + */ +export class SubagentTracking { + private trackers: SubagentTracker[] = []; + + /** Add a pending tracker at PreToolUse, before SubagentStart correlates an agent_id. */ + add(tracker: SubagentTracker): void { + this.trackers.push(tracker); + } + + /** Oldest unmatched tracker (no agent_id yet) for `(promptHash, type)`; + * FIFO so back-to-back identical Agent calls correlate in dispatch order. */ + findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { + let best: SubagentTracker | undefined; + for (const t of this.trackers) { + if (t.agentId) continue; + if (t.promptHash !== promptHash) continue; + if (t.subagentType !== subagentType) continue; + if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; + } + return best; + } + + byAgentId(agentId: string): SubagentTracker | undefined { + return this.trackers.find(t => t.agentId === agentId); + } + + /** Oldest tracker awaiting TeammateIdle for this subagentType (FIFO). */ + findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { + let best: SubagentTracker | undefined; + for (const t of this.trackers) { + if (!t.pendingTeammateIdle) continue; + if (t.subagentType !== subagentType) continue; + if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; + } + return best; + } + + /** Lookup by the spawning Agent tool's tool_use_id (PostToolUse settle). */ + byToolUseId(toolUseId: string): SubagentTracker | undefined { + return this.trackers.find(t => t.toolUseId === toolUseId); + } + + remove(tracker: SubagentTracker): void { + const idx = this.trackers.indexOf(tracker); + if (idx >= 0) this.trackers.splice(idx, 1); + } + + size(): number { + return this.trackers.length; + } + + all(): SubagentTracker[] { + return [...this.trackers]; + } +} + +/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 + * for a brand-new session, or the number of turns already on disk when + * reconstructing a session lost across a daemon restart (so the resumed turn + * keeps counting up instead of resetting to 1). */ +type NewSessionStateOptions = { + sessionId: string; + conversationId: string; + transcript: TranscriptFile; + cwd: string; + source: string; + initialRequestModel: string | undefined; + turnNumber: number; +}; + +/** Build a fresh SessionState. */ +export function newSessionState(options: NewSessionStateOptions): SessionState { + const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = + options; + // Best-effort CC CLI version from the transcript head line; built here so a + // reconstructed session carries the same integration identity. + const headLine = readFirstTranscriptLine(transcript.resolvedPath); + const version = headLine?.['version']; + const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; + const integrationBaggage = createIntegrationBaggage({ + version: VERSION, + meta: { claude_code_app_version: claudeCodeAppVersion }, + }); + + return { + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + integrationBaggage, + turnNumber, + totalToolCalls: 0, + turnToolCalls: 0, + toolCounts: {}, + pendingToolCalls: new Map(), + subagents: new SubagentTracking(), + emittedChatSpanResponseKeys: new Set(), + systemInstructions: [], + }; +} /** sha256 of the firing prompt — used to correlate an `Agent` PreToolUse with * the subagent's SubagentStart by matching transcript content. */ export function hashPrompt(prompt: string): string { - return createHash('sha256').update(prompt, 'utf8').digest('hex'); + return sha256Hex(prompt); } /** diff --git a/src/utils.ts b/src/utils.ts index e2fa616..2765fea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,12 @@ import * as net from 'net'; import * as path from 'path'; import * as readline from 'readline'; import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; + +/** Hex-encoded sha256 of `input`. */ +export function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex'); +} export function prompt(question: string): Promise { return new Promise((resolve) => { @@ -95,10 +101,12 @@ export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; if (Array.isArray(a) !== Array.isArray(b)) return false; - const keysA = Object.keys(a as object); - const keysB = Object.keys(b as object); + const ao = a as Record; + const bo = b as Record; + const keysA = Object.keys(ao); + const keysB = Object.keys(bo); if (keysA.length !== keysB.length) return false; - return keysA.every(k => deepEqual((a as Record)[k], (b as Record)[k])); + return keysA.every(k => deepEqual(ao[k], bo[k])); } /**