mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-04 15:26:55 +02:00
* feat(worker): add agentic static analysis Add the ten-stage Agentic SAST pipeline, confined repository tools, model runtime, prompt templates, and SARIF export. Make retries, repair sessions, reduced coverage, usage accounting, and model-output drift durable across Temporal replay and resume. Keep retry diagnostics in their actionable closed vocabulary. Package the Mantis-derived license material with the prompts that require it. * feat(worker): deduplicate static and runtime findings before exploitation Parse Agentic SAST SARIF into typed observations, enrich and route those observations, and reconcile them with pentest findings before exploitation. Publish deterministic exploitation queues with stable lineage, exact-path Git commits, retry-safe manifests, named drop reasons, and confined task formation. Reject duplicate producer IDs before commit and adopt either legal provenance shape after a lost acknowledgement. * feat(config)!: replace vuln_classes with agentic_sast Wire Agentic SAST and reconciliation into the main pipeline, persist their durable state, and add the Miscellaneous finding and exploitation lane. Make scan completion, cancellation, partial outcomes, resume identity, and report recovery use the integrated final workflow contract. Introduce the atomic finalization, ordering, renumbering, compaction, and output services that workflow calls. Keep completed Miscellaneous work and report drafts idempotent across resume, preserve public main's default-on exploit SARIF behavior, and describe stage-fallback candidates without claiming they were exported. BREAKING CHANGE: `vuln_classes` has been removed. Configs containing it now fail validation, and all five core pentest classes run on every scan. Workspaces created by Shannon 2.x cannot be resumed. Finish or discard in-flight scans before upgrading, then start a new workspace name. * perf: overlap static analysis and the Miscellaneous lane with the pentest Run Agentic SAST alongside vulnerability analysis and run Miscellaneous exploitation alongside the specialist exploitation lanes. Keep reconciliation dependent on the completed static-analysis result while preserving parallel work everywhere that has no data dependency. * feat(cli)!: default the scan target and add a JSON error contract List local scans, resolve the active or most recent workspace automatically, and make logs, status, and stop use one canonical scan identity. Add stable machine-readable failures, richer status output, explicit help errors, and seven-day Temporal retention. Treat absent Temporal pending-activity failures as absent whether the decoder represents them as `null` or missing. BREAKING CHANGE: `status --json` now returns a fixed `failureMessage`. Read `partialReasons`, `agenticSast`, and `workflow.log` for diagnostic detail. * feat(logging): trace tool calls and write a log per agent Record complete tool-call arguments in the workflow log and project each agent's events into its own durable log. Add agent listing and agent-specific log tailing while preserving byte-exact output and draining log handles before activities return. * feat(worker): standardize severity and reporting guidance in exploit prompts Give every exploit agent the same status, confidence, severity-reasoning, report-writing, credential-handling, and scope contract. Apply the same task-formation and SAST-enrichment procedure to the Miscellaneous lane. * feat(worker): disclose scan coverage and make reporting auditable Build on the retry-safe finalization foundation to preserve correct identities, source locations, scan dates, partial-coverage limitations, and consistent report JSON, Markdown, SARIF, and PDF output. Report Agentic SAST, reconciliation wall-clock time, stage usage, retry spend, and background work without duplicate or hardcoded totals. Keep report findings canonical, drop cross-class restatements, name enrichment losses, and render the executive-summary narrative in the PDF. * chore(license): attribute Mantis and Pi and refresh the docs Add the final Mantis and Pi notices, license copies, acknowledgements, and residual copyright updates. Update the README, maintained documentation, contributor guidance, and hand-maintained mirrors to describe Agentic SAST, reconciliation, the Miscellaneous lane, current CLI behavior, and the final release contract. Correct stale workspace and container guidance and annotate long-standing internals for maintainers. * fix(logging): treat a slash as a word separator in agent labels * feat(cli)!: rebuild scan status around model work - show Capella stages beneath the concurrent Agentic SAST phase - attach reconciliation time to the class row it feeds - hide completed bookkeeping and the duplicate miscellaneous wrapper - carry validated child-workflow progress into durable parent state - derive the terminal tree and status JSON from the same phase shape BREAKING CHANGE: `status --json` replaces phase `parallel` with `children` and `meta`, adds phase summaries and notes plus agent attachment fields, and removes the `analysis-engines` and `operational-work` phases. * fix(report): drop the empty Critical Findings section from the PDF summary * fix(sast): align Capella export with the submit-time code-path contract The export gate required every code_paths entry to be file:line, but submit only requires the primary sink to be file:line and accepts bare trace steps. A single malformed trace step therefore dropped an otherwise-valid finding at export. - add isValidPrimaryCodePath as the one shared primary-sink contract - validate only the primary at export; buildResult already drops unusable steps - route the submit-time validator through the same helper so the two cannot drift * feat(sast): tolerate hygiene-only Capella reductions instead of going partial A reduction only makes a run partial when it loses real coverage or a whole finding. Malformed model output, salvaged turn-limit work, and rejected duplicate verdicts are recorded as evidence but no longer flip the run to partial. - add reductionIsTolerable: partial only when genuine-loss counts are nonzero - drive runCapella's partial reasons and display coverage off non-tolerable ones - keep every reduction in agenticSast.reductions so nothing is lost as evidence * feat(logging): record the provider reason for a failed agent turn A failed provider turn collapsed to AGENT_EXECUTION_FAILED/unknown with the underlying reason discarded, so a model-side rejection or safeguard was indistinguishable from a transport fault in the error log. - add safeProviderTurnDetails: write bounded, non-sensitive fields (provider, model, responseId, stop reason, tool-in-flight, category, retryable) to error.log - gate a sanitized errorMessage snippet behind SHANNON_DEBUG_PROVIDER_ERRORS, off by default - forward SHANNON_DEBUG_PROVIDER_ERRORS from the CLI into the worker container * fix(cli): keep shannon logs tailing through a Temporal blip - End the interactive tail on the log's own terminal marker or Ctrl-C, so a transient Temporal outage no longer aborts the command with exit 1. - Rebuild the memoized Temporal client after a failed poll: a wedged gRPC channel was cached forever, so "retrying…" could never reconnect. - Keep start --follow (CI) bounded — a genuinely dead Temporal still fails the run instead of hanging. * fix(worker): correct PDF finding reporting - Render OWASP category, authentication state, and remediation - Omit the redundant per-finding exploited status - Preserve canonical category and field ordering across report modes - Continue Proof of Impact numbering across embedded code blocks - Wrap long PDF code lines without changing canonical report content * fix: attribute a reconciliation failure to exploitation only - Stop marking a class's vulnerability-analysis agent failed when that agent succeeded and only reconciliation failed; the status tree now renders the analysis row completed and the exploitation row failed - Consume the worker's failedReconciliations signal in the CLI, which the mirrored PipelineState already declared but never read - Correct the class_reconciliation_failed message, which claimed the class's analysis results were still in the report when the class is excluded from it * fix(pi): give each task sub-session its own resource loader to prevent stale extension ctx * fix(prompts): scope exploit agents to in-band proof, mark OOB-only findings blocked * fix(cli): reject a shell credential that shadows a gateway config.toml key * fix(cli): make scan shutdown verifiable - preselect and persist workflow identity before worker launch - cancel first, then verify bounded Temporal termination - reconcile Docker workers with Temporal open workflows - fail closed on stale images and unavailable lifecycle state - mark cancellation only after confirmed shutdown * feat(cli): prompt for setup on a bare npx invocation with no credentials * fix(cli): don't blame anthropic when no credentials are configured at all * chore(release): bump beta base version to 3.0.0 * feat(cli): show a 'start your first scan' box in help on a TTY * docs: refresh README and platform overview for Shannon 3.0 - lead with the 3.0 launch note and rewrite key capabilities around security code analysis, the rebuilt terminal experience, native CI/CD, and PDF/SARIF - recast the editions table as Shannon Open Source against the Keygraph Enterprise Platform, stating open source is not a trial edition - rewrite the platform overview around exhaustive agentic SAST, canonical findings, automated remediation, targeted verification, and governance - add five product screenshots under assets/keygraph-platform/, referenced relative to docs/ * docs: add the Shannon naming section and swap in the 3.0 demo GIF - explain the Claude Shannon information-theory origin under "What is Shannon?" - point "Shannon in Action" at the 3.0 recording in assets/Shannon3GIF.gif Both taken from the README half of #438. * docs: document CI/CD integrations and the reconciled analysis pipeline - add a CI/CD Integrations section covering the official GitHub Action and GitLab component, pipeline artifacts, and exploit-only severity gates - redraw the architecture section as a Mermaid flow: agentic code analysis and recon feed finding reconciliation, then exploitation and reporting - describe open-source code analysis as a multi-stage agentic workflow and reserve parsed-code CPGs and exhaustive verification for Enterprise - sharpen the privacy wording: results stay local, but model requests carry source context to whichever endpoint you configure - drop the "not recommended" framing on local models and add a section on why Shannon complements rather than replaces human pentesters - regenerate llms-full.txt from the updated README and docs * docs: add the Photoview benchmark across three models - Add a "Shannon in Action" table for Photoview 2.4.0 runs on DeepSeek v4 Flash, Grok 4.6, and Claude Opus 5, each linking its PDF report and SARIF output - Store the per-model reports under benchmark/ - Link the (forthcoming) benchmark writeup from the section intro * docs: add the Shannon vs XBOW/Aikido Photoview benchmark writeup - Add docs/shannon-xbow-aikido-benchmark.md with methodology, per-model cost/coverage tables, and links to each model's report and SARIF - Link the writeup from the README "Shannon in Action" section * docs: link the benchmark announcement discussion from the README * fix(readme): restore theme-aware banner, badge, and buttons * feat!: trigger the Shannon 3.0 major release --------- Co-authored-by: ezl-keygraph <ezhil@keygraph.io>
782 lines
34 KiB
TypeScript
782 lines
34 KiB
TypeScript
// Copyright (C) 2026 Keygraph, Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License version 3
|
|
// as published by the Free Software Foundation.
|
|
|
|
/** Closed-field, human-readable scan logging. */
|
|
|
|
import { promises as fsPromises } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { isCapellaSafeFailureMessage, isCapellaTerminalStageLabel } from '../ai/sast/capella/safe-failures.js';
|
|
import { CAPELLA_STAGE_LABELS, type CapellaStage } from '../ai/sast/types.js';
|
|
import { type ErrorCode, isProviderFailureCategory } from '../types/errors.js';
|
|
import { isPartialReason, type PartialReasonView, projectPartialReasons } from '../types/run-state.js';
|
|
import { formatDuration, formatTimestamp } from '../utils/formatting.js';
|
|
import {
|
|
agentLogPath,
|
|
agentsDir,
|
|
projectActor,
|
|
safeAgentFileSlug,
|
|
safeIdentityLabel,
|
|
type TraceActor,
|
|
} from './actor-projection.js';
|
|
import { LogStream, warnAgentLoggingFailure, warnLoggingFailure } from './log-stream.js';
|
|
import { type OperationalStageTiming, summarizeOperationalMetrics } from './operational-summary.js';
|
|
import {
|
|
isLoggableAgentName,
|
|
isWorkflowPhase,
|
|
type LoggableAgentName,
|
|
safeErrorFromCode,
|
|
safeTargetUrl,
|
|
safeWorkflowIdentifier,
|
|
type WorkflowPhase,
|
|
} from './safe-fields.js';
|
|
import type { ToolInvocation, ToolOutcome } from './trace.js';
|
|
import { generateWorkflowLogPath, type SessionMetadata } from './utils.js';
|
|
|
|
export type { TraceActor } from './actor-projection.js';
|
|
|
|
export interface AgentLogDetails {
|
|
readonly attemptNumber?: number;
|
|
readonly duration_ms?: number;
|
|
readonly cost_usd?: number;
|
|
readonly success?: boolean;
|
|
readonly errorCode?: ErrorCode;
|
|
}
|
|
|
|
export interface AgentMetricsSummary {
|
|
readonly durationMs: number;
|
|
readonly costUsd: number | null;
|
|
}
|
|
|
|
export interface OperationalMetricsSummary {
|
|
readonly durationMs: number;
|
|
readonly costUsd: number | null;
|
|
readonly inputTokens: number | null;
|
|
readonly outputTokens: number | null;
|
|
readonly cacheReadTokens: number | null;
|
|
readonly cacheWriteTokens: number | null;
|
|
readonly numTurns: number | null;
|
|
readonly usageComplete: boolean;
|
|
}
|
|
|
|
export interface WorkflowSummary {
|
|
readonly status: 'completed' | 'failed' | 'cancelled' | 'partial';
|
|
readonly startedAtMs: number;
|
|
readonly endedAtMs: number;
|
|
readonly totalDurationMs: number;
|
|
readonly totalCostUsd: number;
|
|
readonly completedAgents: readonly string[];
|
|
readonly skippedAgents?: readonly string[];
|
|
readonly agentMetrics: Readonly<Record<string, AgentMetricsSummary>>;
|
|
readonly operationalMetrics: Readonly<Record<string, OperationalMetricsSummary>>;
|
|
/** Per-stage wall-clock spans, keyed as `operationalStages` is; feeds each group's real duration. */
|
|
readonly operationalStages: Readonly<Record<string, OperationalStageTiming>>;
|
|
readonly partialReasons?: readonly PartialReasonView[];
|
|
readonly usageAccountingComplete?: boolean;
|
|
/** Usage-accounting warnings from the Capella run; empty when the ledger reconciled. */
|
|
readonly usageAccountingWarnings?: readonly string[];
|
|
readonly agenticSastFailedStage?: string;
|
|
readonly agenticSastFailureMessage?: string;
|
|
readonly agenticSastErrorCode?: string;
|
|
/** Terminal disposition of the agentic-SAST child, so a successful run is visible, not just a failed one. */
|
|
readonly agenticSastStatus?: 'disabled' | 'running' | 'succeeded' | 'failed';
|
|
readonly agenticSastCoverage?: 'complete' | 'reduced';
|
|
readonly errorCode?: ErrorCode;
|
|
}
|
|
|
|
export type ChildTaskFailureCode = 'CANCELLED' | 'CHILD_TASK_FAILED';
|
|
|
|
function isSafeCount(value: number): boolean {
|
|
return Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000_000;
|
|
}
|
|
|
|
/**
|
|
* Render a partial reason's message only after rebuilding it as a fresh, narrowly-shaped
|
|
* candidate and revalidating it with {@link isPartialReason}. The view arrives already
|
|
* projected, but re-checking here means a future field added to the view can never reach the
|
|
* log through this path until it is deliberately admitted into the narrowed candidate shape.
|
|
*/
|
|
function safeReasonMessage(reason: PartialReasonView): string | undefined {
|
|
if (reason.code === 'agentic_sast_reduced') return 'Agentic SAST completed with reduced coverage.';
|
|
let candidate: unknown = { code: reason.code };
|
|
if (reason.vulnerabilityClass !== undefined) {
|
|
candidate = { code: reason.code, vulnerabilityClass: reason.vulnerabilityClass };
|
|
} else if (reason.stage !== undefined && reason.code === 'agentic_sast_failed') {
|
|
candidate = { code: reason.code, stage: reason.stage };
|
|
}
|
|
if (!isPartialReason(candidate)) return undefined;
|
|
return projectPartialReasons([candidate])[0]?.message;
|
|
}
|
|
|
|
function safeAgenticSastCode(code: string | undefined): string | undefined {
|
|
if (code !== undefined && (/^[A-Z][A-Z0-9_]{0,63}$/u.test(code) || isProviderFailureCategory(code))) return code;
|
|
return undefined;
|
|
}
|
|
|
|
function safeAgenticSastStageLabel(label: string | undefined): string | undefined {
|
|
return label !== undefined && isCapellaTerminalStageLabel(label) ? label : undefined;
|
|
}
|
|
|
|
/** Render a cost the same way the agent breakdown does: N/A when unknown, else a fixed 4-dp dollar value. */
|
|
function formatCostUsd(costUsd: number | null): string {
|
|
return costUsd === null ? 'N/A' : `$${Math.max(0, costUsd).toFixed(4)}`;
|
|
}
|
|
|
|
/** Keep normal PI names readable and losslessly quote any unexpected name. */
|
|
function formatToolName(tool: string): string {
|
|
return /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u.test(tool) ? tool : JSON.stringify(tool);
|
|
}
|
|
|
|
/** One self-describing first line per per-agent file, appended once when its lease opens. */
|
|
const AGENT_LOG_HEADER_PREFIX = '=== Shannon agent log: ';
|
|
|
|
function agentLogHeader(slug: string): string {
|
|
return `${AGENT_LOG_HEADER_PREFIX}${slug} ===`;
|
|
}
|
|
|
|
/** Manages the one human-readable log for a scan workspace, plus its per-agent projection files. */
|
|
export class WorkflowLogger {
|
|
private readonly logPath: string;
|
|
private readonly sessionMetadata: SessionMetadata;
|
|
private logStream: LogStream | null = null;
|
|
private initializePromise: Promise<void> | null = null;
|
|
private workflowId: string | undefined;
|
|
// A pipeline agent's file lease, held from its start line to its end line so the agent's own
|
|
// trace and lifecycle lines ride the reference count instead of reopening the file each write.
|
|
private readonly agentLeases = new Map<string, LogStream>();
|
|
|
|
private static traceTimestamp(): string {
|
|
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
}
|
|
|
|
/**
|
|
* Append one line to the combined log. A failure here is swallowed and only warned about once
|
|
* process-wide: the scan itself must keep running even if the log file becomes unwritable, so
|
|
* this never throws back into agent or workflow logic.
|
|
*/
|
|
private static async writeLine(workflowLogPath: string, line: string, flush: boolean): Promise<void> {
|
|
let stream: LogStream | undefined;
|
|
try {
|
|
stream = await LogStream.acquire(workflowLogPath);
|
|
await stream.write(`${line}\n`, flush);
|
|
} catch {
|
|
warnLoggingFailure();
|
|
} finally {
|
|
await stream?.release().catch(warnLoggingFailure);
|
|
}
|
|
}
|
|
|
|
// Per-tool trace lines are high-frequency; a per-line fsync would dominate their cost.
|
|
// They rely on the OS write buffer (visible to the tailing CLI) rather than durable flush,
|
|
// while the low-frequency structural lines still fsync.
|
|
private static writeTraceLine(workflowLogPath: string, line: string): Promise<void> {
|
|
return WorkflowLogger.writeLine(workflowLogPath, line, false);
|
|
}
|
|
|
|
/**
|
|
* Fan a formatted line out to an actor's per-agent projection file. Best-effort and isolated: a
|
|
* failure here warns separately and never disturbs the canonical combined log. When a lifecycle
|
|
* owner holds a lease on the file, this acquire/release rides its reference count and the stream
|
|
* stays open; with no owner it opens per line, which is correct, just slower.
|
|
*/
|
|
private static async fanOutLine(workflowLogPath: string, slug: string, line: string, flush: boolean): Promise<void> {
|
|
let stream: LogStream | undefined;
|
|
try {
|
|
stream = await LogStream.acquire(agentLogPath(workflowLogPath, slug));
|
|
await stream.write(`${line}\n`, flush);
|
|
} catch {
|
|
warnAgentLoggingFailure();
|
|
} finally {
|
|
await stream?.release().catch(warnAgentLoggingFailure);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write one trace line to the combined log first, then fan it out to the actor's per-agent file.
|
|
* A structurally unsafe actor drops the line from both, preserving the pre-existing fail-closed
|
|
* behavior; an unnameable owning file skips the fan-out alone.
|
|
*/
|
|
private static async writeProjectedTraceLine(
|
|
workflowLogPath: string,
|
|
actor: TraceActor,
|
|
render: (prefix: string) => string,
|
|
): Promise<void> {
|
|
const { combinedPrefix, agentFileSlug: slug } = projectActor(actor);
|
|
if (combinedPrefix === undefined) return;
|
|
const line = render(combinedPrefix);
|
|
await WorkflowLogger.writeTraceLine(workflowLogPath, line);
|
|
if (slug !== undefined) await WorkflowLogger.fanOutLine(workflowLogPath, slug, line, false);
|
|
}
|
|
|
|
/**
|
|
* A flushed Capella stage lifecycle line: to the combined log first, then to the stage's own
|
|
* per-agent file. The stage id is a closed field, so its slug is always safe.
|
|
*/
|
|
private static async writeStageStructuralLine(
|
|
workflowLogPath: string,
|
|
stage: CapellaStage,
|
|
line: string,
|
|
): Promise<void> {
|
|
await WorkflowLogger.writeLine(workflowLogPath, line, true);
|
|
const slug = safeAgentFileSlug(`agentic-sast-${stage}`);
|
|
if (slug !== undefined) await WorkflowLogger.fanOutLine(workflowLogPath, slug, line, true);
|
|
}
|
|
|
|
/**
|
|
* Acquire a per-agent file's lease and ensure its header is present. `appendIfAbsent` makes the
|
|
* header idempotent, so reopening the same file across a Temporal retry or a resumed run never
|
|
* duplicates it. Returns `null` on any failure rather than throwing, since an agent whose own
|
|
* file cannot be opened must still be able to run and log to the combined file.
|
|
*/
|
|
private static async openAgentLease(workflowLogPath: string, slug: string): Promise<LogStream | null> {
|
|
try {
|
|
const stream = await LogStream.acquire(agentLogPath(workflowLogPath, slug));
|
|
const header = agentLogHeader(slug);
|
|
await stream.appendIfAbsent(`${header}\n`, { marker: header, scope: 'whole-file', match: 'exact-line' });
|
|
return stream;
|
|
} catch {
|
|
warnAgentLoggingFailure();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open and hold a Capella stage's per-agent file lease for the life of the stage activity. The
|
|
* caller passes the handle back to {@link closeStageAgentLog} in its `finally`; while held, the
|
|
* stage's concurrent session trace lines keep the file open through the shared reference count.
|
|
*/
|
|
static openStageAgentLog(workflowLogPath: string, stage: CapellaStage): Promise<LogStream | null> {
|
|
const slug = safeAgentFileSlug(`agentic-sast-${stage}`);
|
|
if (slug === undefined) return Promise.resolve(null);
|
|
return WorkflowLogger.openAgentLease(workflowLogPath, slug);
|
|
}
|
|
|
|
/** Release a stage's per-agent file lease. Best-effort: a failure never fails the stage. */
|
|
static async closeStageAgentLog(lease: LogStream | null): Promise<void> {
|
|
if (lease === null) return;
|
|
await lease.release().catch(warnAgentLoggingFailure);
|
|
}
|
|
|
|
/** `[agent] read: {"path":"/src/routes"}` — one complete invocation per line. */
|
|
static async logToolCall(workflowLogPath: string, actor: TraceActor, invocation: ToolInvocation): Promise<void> {
|
|
const tool = formatToolName(invocation.tool);
|
|
await WorkflowLogger.writeProjectedTraceLine(
|
|
workflowLogPath,
|
|
actor,
|
|
(prefix) => `[${WorkflowLogger.traceTimestamp()}] [${prefix}] ${tool}: ${invocation.argumentsJson}`,
|
|
);
|
|
}
|
|
|
|
/** The conditional second line: a failure, a slow success, or a collector's safe count. */
|
|
static async logToolOutcome(workflowLogPath: string, actor: TraceActor, outcome: ToolOutcome): Promise<void> {
|
|
const tool = formatToolName(outcome.tool);
|
|
let suffix: string;
|
|
if (outcome.kind === 'failed') {
|
|
suffix = `${tool} failed (${formatDuration(Math.max(0, outcome.durationMs))})`;
|
|
} else if (outcome.kind === 'slow') {
|
|
suffix = `${tool} slow (${formatDuration(Math.max(0, outcome.durationMs))})`;
|
|
} else {
|
|
if (!isSafeCount(outcome.count)) return;
|
|
suffix = `${tool}: submitted ${outcome.count} findings`;
|
|
}
|
|
await WorkflowLogger.writeProjectedTraceLine(
|
|
workflowLogPath,
|
|
actor,
|
|
(prefix) => `[${WorkflowLogger.traceTimestamp()}] [${prefix}] ${suffix}`,
|
|
);
|
|
}
|
|
|
|
/** `[parent] task: started subagent "entry point mapper"` — the single delegation line. */
|
|
static async logDelegationStart(workflowLogPath: string, parent: LoggableAgentName, child: string): Promise<void> {
|
|
if (!isLoggableAgentName(parent)) return;
|
|
const identity = safeIdentityLabel(child);
|
|
if (identity === undefined) return;
|
|
await WorkflowLogger.writeProjectedTraceLine(
|
|
workflowLogPath,
|
|
{ kind: 'agent', agent: parent },
|
|
(prefix) => `[${WorkflowLogger.traceTimestamp()}] [${prefix}] task: started subagent "${identity}"`,
|
|
);
|
|
}
|
|
|
|
/** `[actor] completed (1m 14s, 11 turns, 30 operations)` — a subagent or SAST session's terminal line. */
|
|
static async logSessionComplete(
|
|
workflowLogPath: string,
|
|
actor: TraceActor,
|
|
durationMs: number,
|
|
turns: number,
|
|
operations: number,
|
|
): Promise<void> {
|
|
const safeTurns = isSafeCount(turns) ? turns : 0;
|
|
const safeOperations = isSafeCount(operations) ? operations : 0;
|
|
const duration = formatDuration(Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0);
|
|
await WorkflowLogger.writeProjectedTraceLine(
|
|
workflowLogPath,
|
|
actor,
|
|
(prefix) =>
|
|
`[${WorkflowLogger.traceTimestamp()}] [${prefix}] completed (${duration}, ${safeTurns} turns, ${safeOperations} operations)`,
|
|
);
|
|
}
|
|
|
|
/** `[actor] failed (2.1s, CHILD_TASK_FAILED)` — a subagent session's terminal failure line. */
|
|
static async logSessionFailure(
|
|
workflowLogPath: string,
|
|
actor: TraceActor,
|
|
code: ChildTaskFailureCode,
|
|
durationMs: number,
|
|
): Promise<void> {
|
|
const safeCode: ChildTaskFailureCode = code === 'CANCELLED' ? 'CANCELLED' : 'CHILD_TASK_FAILED';
|
|
const status = safeCode === 'CANCELLED' ? 'cancelled' : 'failed';
|
|
const duration = formatDuration(Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0);
|
|
await WorkflowLogger.writeProjectedTraceLine(
|
|
workflowLogPath,
|
|
actor,
|
|
(prefix) => `[${WorkflowLogger.traceTimestamp()}] [${prefix}] ${status} (${duration}, ${safeCode})`,
|
|
);
|
|
}
|
|
|
|
/** A Capella stage's flushed structural start line: to the combined log and its own per-agent file. */
|
|
static async logAgenticSastStart(
|
|
workflowLogPath: string,
|
|
stage: CapellaStage,
|
|
attempt: number,
|
|
maximumAttempts: number,
|
|
): Promise<void> {
|
|
const safeAttempt = isSafeCount(attempt) ? attempt : 1;
|
|
const safeMaximum = isSafeCount(maximumAttempts) ? maximumAttempts : safeAttempt;
|
|
await WorkflowLogger.writeStageStructuralLine(
|
|
workflowLogPath,
|
|
stage,
|
|
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: Starting (attempt ${safeAttempt} of ${safeMaximum})`,
|
|
);
|
|
}
|
|
|
|
/** A Capella stage's flushed structural completion line, including reuse and dispatch counts when known. */
|
|
static async logAgenticSastComplete(
|
|
workflowLogPath: string,
|
|
stage: CapellaStage,
|
|
durationMs: number,
|
|
reused: boolean,
|
|
counts?: { readonly dispatchedCount: number; readonly resumedCount: number },
|
|
): Promise<void> {
|
|
const details: string[] = [];
|
|
if (counts !== undefined && isSafeCount(counts.dispatchedCount) && isSafeCount(counts.resumedCount)) {
|
|
details.push(`${counts.dispatchedCount} dispatched`, `${counts.resumedCount} resumed`);
|
|
}
|
|
details.push(formatDuration(Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0));
|
|
if (reused) details.push('reused');
|
|
await WorkflowLogger.writeStageStructuralLine(
|
|
workflowLogPath,
|
|
stage,
|
|
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: Completed (${details.join(', ')})`,
|
|
);
|
|
}
|
|
|
|
/** A Capella stage's flushed structural failure line, noting whether Temporal will retry the attempt. */
|
|
static async logAgenticSastFailure(
|
|
workflowLogPath: string,
|
|
stage: CapellaStage,
|
|
attempt: number,
|
|
maximumAttempts: number,
|
|
code: string,
|
|
retrying: boolean,
|
|
): Promise<void> {
|
|
const safeAttempt = isSafeCount(attempt) ? attempt : 1;
|
|
const safeMaximum = isSafeCount(maximumAttempts) ? maximumAttempts : safeAttempt;
|
|
const safeCode = safeAgenticSastCode(code) ?? 'ACTIVITY_FAILURE';
|
|
const outcome = retrying ? 'Failed, retrying' : 'Failed';
|
|
await WorkflowLogger.writeStageStructuralLine(
|
|
workflowLogPath,
|
|
stage,
|
|
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: ${outcome} (attempt ${safeAttempt} of ${safeMaximum}, ${safeCode})`,
|
|
);
|
|
}
|
|
|
|
/** A Capella stage's flushed structural cancellation line. */
|
|
static async logAgenticSastCancelled(
|
|
workflowLogPath: string,
|
|
stage: CapellaStage,
|
|
attempt: number,
|
|
maximumAttempts: number,
|
|
): Promise<void> {
|
|
const safeAttempt = isSafeCount(attempt) ? attempt : 1;
|
|
const safeMaximum = isSafeCount(maximumAttempts) ? maximumAttempts : safeAttempt;
|
|
await WorkflowLogger.writeStageStructuralLine(
|
|
workflowLogPath,
|
|
stage,
|
|
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: Cancelled (attempt ${safeAttempt} of ${safeMaximum}, CANCELLED)`,
|
|
);
|
|
}
|
|
|
|
constructor(sessionMetadata: SessionMetadata) {
|
|
this.sessionMetadata = sessionMetadata;
|
|
this.logPath = generateWorkflowLogPath(sessionMetadata);
|
|
}
|
|
|
|
setWorkflowId(workflowId: string): void {
|
|
this.workflowId = safeWorkflowIdentifier(workflowId);
|
|
}
|
|
|
|
/**
|
|
* Open the combined-log stream and write its header, memoizing the in-flight promise so
|
|
* concurrent first calls on this instance share one open attempt instead of racing to open
|
|
* the file and write the header twice.
|
|
*/
|
|
async initialize(workflowId?: string): Promise<void> {
|
|
if (workflowId !== undefined) this.setWorkflowId(workflowId);
|
|
if (this.logStream !== null) return;
|
|
if (this.initializePromise === null) {
|
|
this.initializePromise = this.openAndWriteHeader();
|
|
}
|
|
await this.initializePromise;
|
|
}
|
|
|
|
private async openAndWriteHeader(): Promise<void> {
|
|
try {
|
|
this.logStream = await LogStream.acquire(this.logPath);
|
|
const workflowId = safeWorkflowIdentifier(this.workflowId ?? this.sessionMetadata.id);
|
|
const header = [
|
|
'================================================================================',
|
|
'Shannon Pentest - Scan Log',
|
|
'================================================================================',
|
|
`Workflow ID: ${workflowId}`,
|
|
`Target URL: ${safeTargetUrl(this.sessionMetadata.webUrl)}`,
|
|
`Started: ${formatTimestamp()}`,
|
|
'================================================================================',
|
|
'',
|
|
].join('\n');
|
|
await this.logStream.appendIfAbsent(header, {
|
|
marker: 'Shannon Pentest - Scan Log',
|
|
scope: 'whole-file',
|
|
match: 'exact-line',
|
|
});
|
|
} catch {
|
|
this.logStream = null;
|
|
warnLoggingFailure();
|
|
}
|
|
}
|
|
|
|
private formatLogTime(): string {
|
|
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
}
|
|
|
|
/**
|
|
* Run a structural write against the combined-log stream, opening it first if needed. If the
|
|
* stream never opened, or `operation` throws, this resolves quietly after warning once: a
|
|
* logging fault must never surface as a thrown error to the phase, agent, or resume logic
|
|
* calling in.
|
|
*/
|
|
private async withStream(operation: (stream: LogStream) => Promise<unknown>): Promise<void> {
|
|
await this.initialize();
|
|
if (this.logStream === null) return;
|
|
try {
|
|
await operation(this.logStream);
|
|
} catch {
|
|
warnLoggingFailure();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append one idempotent structural marker to every existing per-agent file. Used for the resume
|
|
* boundary and the terminal marker, which each file needs so a `--agent` tail can tell a resume
|
|
* from a fresh run and can self-terminate. Best-effort: any file that fails is skipped.
|
|
*/
|
|
private async fanOutMarkerToAgents(markerLine: string): Promise<void> {
|
|
const directory = agentsDir(this.logPath);
|
|
let entries: string[];
|
|
try {
|
|
entries = await fsPromises.readdir(directory);
|
|
} catch {
|
|
// No agents directory yet — nothing has been projected, so there is nothing to mark.
|
|
return;
|
|
}
|
|
await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.endsWith('.log'))
|
|
.map(async (entry) => {
|
|
let stream: LogStream | undefined;
|
|
try {
|
|
stream = await LogStream.acquire(path.join(directory, entry));
|
|
await stream.appendIfAbsent(`${markerLine}\n`, {
|
|
marker: markerLine,
|
|
scope: 'whole-file',
|
|
match: 'exact-line',
|
|
flush: true,
|
|
});
|
|
} catch {
|
|
warnAgentLoggingFailure();
|
|
} finally {
|
|
await stream?.release().catch(warnAgentLoggingFailure);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async logResumeBoundary(newWorkflowIdValue: string): Promise<void> {
|
|
const newWorkflowId = safeWorkflowIdentifier(newWorkflowIdValue);
|
|
const marker = `New Workflow ID: ${newWorkflowId}`;
|
|
const header = [
|
|
'',
|
|
'================================================================================',
|
|
'RESUMED',
|
|
'================================================================================',
|
|
marker,
|
|
`Resumed At: ${formatTimestamp()}`,
|
|
'================================================================================',
|
|
'',
|
|
].join('\n');
|
|
await this.withStream((stream) =>
|
|
stream.appendIfAbsent(header, { marker, scope: 'whole-file', match: 'exact-line', flush: true }),
|
|
);
|
|
// A per-resume-distinct, per-file-idempotent boundary; a bare timestamp could not tell a
|
|
// Temporal retry from a new execution.
|
|
await this.fanOutMarkerToAgents(`--- RESUMED (${newWorkflowId}) ---`);
|
|
}
|
|
|
|
/** Append the resume checkpoint and completed-agent count beneath an already-written resume boundary. */
|
|
async logResumeDetails(resumeInfo: {
|
|
readonly previousWorkflowId: string;
|
|
readonly newWorkflowId: string;
|
|
readonly checkpointHash: string;
|
|
readonly completedAgents: readonly string[];
|
|
}): Promise<void> {
|
|
const previousWorkflowId = safeWorkflowIdentifier(resumeInfo.previousWorkflowId);
|
|
const newWorkflowId = safeWorkflowIdentifier(resumeInfo.newWorkflowId);
|
|
const checkpointHash = /^[a-f0-9]{7,64}$/u.test(resumeInfo.checkpointHash) ? resumeInfo.checkpointHash : 'unknown';
|
|
const completedAgents = resumeInfo.completedAgents.filter(isLoggableAgentName);
|
|
const marker = `Resume checkpoint (${newWorkflowId}): ${checkpointHash}`;
|
|
const details = [
|
|
`Previous Workflow ID: ${previousWorkflowId}`,
|
|
marker,
|
|
`Completed: ${completedAgents.length} agents (${completedAgents.join(', ')})`,
|
|
].join('\n');
|
|
await this.withStream((stream) =>
|
|
stream.appendIfAbsent(`${details}\n`, { marker, scope: 'whole-file', match: 'exact-line', flush: true }),
|
|
);
|
|
}
|
|
|
|
async logPhase(phase: WorkflowPhase, event: 'start' | 'complete'): Promise<void> {
|
|
if (!isWorkflowPhase(phase)) return;
|
|
const action = event === 'start' ? 'Starting' : 'Completed';
|
|
const suffix = `[PHASE] ${action}: ${phase}`;
|
|
const line = `${event === 'start' ? '\n' : ''}[${this.formatLogTime()}] ${suffix}\n`;
|
|
await this.withStream((stream) =>
|
|
stream.appendIfAbsent(line, { marker: suffix, scope: 'current-execution', match: 'line-suffix' }),
|
|
);
|
|
}
|
|
|
|
private async ensureAgentLease(agentName: LoggableAgentName): Promise<void> {
|
|
const slug = safeAgentFileSlug(agentName);
|
|
if (slug === undefined || this.agentLeases.has(slug)) return;
|
|
const lease = await WorkflowLogger.openAgentLease(this.logPath, slug);
|
|
if (lease !== null) this.agentLeases.set(slug, lease);
|
|
}
|
|
|
|
private async releaseAgentLease(agentName: LoggableAgentName): Promise<void> {
|
|
const slug = safeAgentFileSlug(agentName);
|
|
if (slug === undefined) return;
|
|
const lease = this.agentLeases.get(slug);
|
|
if (lease === undefined) return;
|
|
this.agentLeases.delete(slug);
|
|
await lease.release().catch(warnAgentLoggingFailure);
|
|
}
|
|
|
|
/**
|
|
* Release a pipeline agent's held per-agent file lease if one is open. Idempotent and
|
|
* best-effort: a backstop for an abnormal abort where the agent's end line never ran, so the
|
|
* file handle never outlives the activity. A normal end has already released it, making this a
|
|
* no-op.
|
|
*/
|
|
async releaseAgentLog(agentName: LoggableAgentName): Promise<void> {
|
|
if (!isLoggableAgentName(agentName)) return;
|
|
await this.releaseAgentLease(agentName);
|
|
}
|
|
|
|
/**
|
|
* Write a pipeline agent's start or end line to the combined log and its per-agent file. The
|
|
* per-agent lease opens on `start`, before the header line, and is released on `end`, after the
|
|
* closing line, so the file stays open for the agent's own duration rather than reopening per
|
|
* trace line.
|
|
*/
|
|
async logAgent(agentName: LoggableAgentName, event: 'start' | 'end', details: AgentLogDetails = {}): Promise<void> {
|
|
if (!isLoggableAgentName(agentName)) return;
|
|
let message: string;
|
|
if (event === 'start') {
|
|
const attempt = isSafeCount(details.attemptNumber ?? 1) ? (details.attemptNumber ?? 1) : 1;
|
|
message = `${agentName}: Starting (attempt ${attempt})`;
|
|
} else {
|
|
const status = details.success === false ? 'Failed' : 'Completed';
|
|
const code = details.success === false && details.errorCode !== undefined ? ` (${details.errorCode})` : '';
|
|
const outcomeDetails: string[] = [];
|
|
if (details.duration_ms !== undefined) outcomeDetails.push(formatDuration(Math.max(0, details.duration_ms)));
|
|
if (details.cost_usd !== undefined && Number.isFinite(details.cost_usd)) {
|
|
outcomeDetails.push(`$${Math.max(0, details.cost_usd).toFixed(4)}`);
|
|
}
|
|
const suffix = outcomeDetails.length === 0 ? '' : ` (${outcomeDetails.join(', ')})`;
|
|
message = `${agentName}: ${status}${code}${suffix}`;
|
|
}
|
|
// Open the lease before the start line so the file's header lands first and the agent's
|
|
// trace lines through the run ride an already-open handle.
|
|
if (event === 'start') await this.ensureAgentLease(agentName);
|
|
const line = `[${this.formatLogTime()}] [AGENT] ${message}`;
|
|
await this.withStream((stream) => stream.write(`${line}\n`));
|
|
const slug = safeAgentFileSlug(agentName);
|
|
if (slug !== undefined) await WorkflowLogger.fanOutLine(this.logPath, slug, line, false);
|
|
// Release after the end line so the closing line still rides the lease.
|
|
if (event === 'end') await this.releaseAgentLease(agentName);
|
|
}
|
|
|
|
/** A one-line, closed-vocabulary error record for an agent attempt, written to both the combined and per-agent logs. */
|
|
async logAgentError(
|
|
agentName: LoggableAgentName,
|
|
code: ErrorCode,
|
|
category: string,
|
|
attempt: number,
|
|
durationMs: number,
|
|
turns: number,
|
|
): Promise<void> {
|
|
if (!isLoggableAgentName(agentName)) return;
|
|
const safe = safeErrorFromCode(code);
|
|
const safeAttempt = isSafeCount(attempt) ? attempt : 0;
|
|
const safeTurns = isSafeCount(turns) ? turns : 0;
|
|
const safeDuration = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0;
|
|
const safeCategory = /^(?:config|network|prompt|filesystem|validation|unknown)$/u.test(category)
|
|
? category
|
|
: 'unknown';
|
|
const line = `[${this.formatLogTime()}] [${agentName}] [ERROR] ${safe.code} (${safeCategory}, attempt ${safeAttempt}, ${formatDuration(safeDuration)}, ${safeTurns} turns)`;
|
|
await this.withStream((stream) => stream.write(`${line}\n`));
|
|
const slug = safeAgentFileSlug(agentName);
|
|
if (slug !== undefined) await WorkflowLogger.fanOutLine(this.logPath, slug, line, false);
|
|
}
|
|
|
|
async logWorkflowComplete(summary: WorkflowSummary): Promise<void> {
|
|
const statusHeaders: Record<WorkflowSummary['status'], string> = {
|
|
completed: 'COMPLETED',
|
|
partial: 'PARTIAL',
|
|
cancelled: 'CANCELLED',
|
|
failed: 'FAILED',
|
|
};
|
|
const status = statusHeaders[summary.status];
|
|
const completedAgents = summary.completedAgents.filter(isLoggableAgentName);
|
|
const skippedAgents = (summary.skippedAgents ?? []).filter(isLoggableAgentName);
|
|
const operationalGroups = summarizeOperationalMetrics(summary.operationalMetrics, summary.operationalStages);
|
|
const sastGroup = operationalGroups.find((group) => group.key === 'agentic-sast');
|
|
const lines = [
|
|
'',
|
|
'================================================================================',
|
|
`Scan ${status}`,
|
|
'────────────────────────────────────────',
|
|
`Workflow ID: ${safeWorkflowIdentifier(this.workflowId ?? this.sessionMetadata.id)}`,
|
|
`Status: ${summary.status}`,
|
|
`Duration: ${formatDuration(Math.max(0, summary.totalDurationMs))}`,
|
|
`Total Cost: $${Math.max(0, summary.totalCostUsd).toFixed(4)}`,
|
|
`Agents: ${completedAgents.length} ran, ${skippedAgents.length} skipped`,
|
|
];
|
|
if (summary.usageAccountingComplete === false) {
|
|
lines.push('Cost Note: Cost is incomplete — some background work is not included in this total.');
|
|
}
|
|
if (summary.errorCode !== undefined) {
|
|
const safeError = safeErrorFromCode(summary.errorCode);
|
|
lines.push(`Error: ${safeError.message}`);
|
|
lines.push(` ${safeError.code}`);
|
|
}
|
|
|
|
const partialMessages = (summary.partialReasons ?? [])
|
|
.map(safeReasonMessage)
|
|
.filter((message): message is string => message !== undefined);
|
|
if (partialMessages.length > 0) {
|
|
lines.push('', 'Why this scan is partial:');
|
|
for (const message of partialMessages) lines.push(` - ${message}`);
|
|
}
|
|
|
|
const failedStage = safeAgenticSastStageLabel(summary.agenticSastFailedStage);
|
|
const failureMessage = summary.agenticSastFailureMessage;
|
|
if (failedStage !== undefined && failureMessage !== undefined && isCapellaSafeFailureMessage(failureMessage)) {
|
|
lines.push('', `Agentic SAST stopped at: ${failedStage}`);
|
|
lines.push(`What happened: ${failureMessage}`);
|
|
const failureCode = safeAgenticSastCode(summary.agenticSastErrorCode);
|
|
if (failureCode !== undefined) lines.push(`Reference code (for a bug report): ${failureCode}`);
|
|
}
|
|
|
|
if (completedAgents.length > 0 || skippedAgents.length > 0) {
|
|
lines.push('', 'Agent Breakdown:');
|
|
for (const agentName of completedAgents) {
|
|
const metrics = summary.agentMetrics[agentName];
|
|
if (metrics === undefined) {
|
|
lines.push(` - ${agentName}`);
|
|
continue;
|
|
}
|
|
lines.push(
|
|
` - ${agentName} (${formatDuration(Math.max(0, metrics.durationMs))}, ${formatCostUsd(metrics.costUsd)})`,
|
|
);
|
|
}
|
|
for (const agentName of skippedAgents) lines.push(` - ${agentName} (skipped — nothing to exploit)`);
|
|
}
|
|
|
|
// Agentic SAST is a pluggable analysis engine, a peer to the pentest rather than background
|
|
// plumbing, so it gets its own section. Its spend sits in Total Cost but has no agent line; the
|
|
// detailed "stopped at" block above narrates a failure, this line accounts for its time and cost.
|
|
if (
|
|
sastGroup !== undefined &&
|
|
summary.agenticSastStatus !== undefined &&
|
|
summary.agenticSastStatus !== 'disabled'
|
|
) {
|
|
const outcome = summary.agenticSastStatus === 'failed' ? 'failed' : 'completed';
|
|
const coverageSuffix = summary.agenticSastCoverage === 'reduced' ? ' — reduced coverage' : '';
|
|
lines.push('', 'Analysis Engines:');
|
|
lines.push(
|
|
` - Agentic SAST — ${outcome} (${formatDuration(sastGroup.durationMs)}, ${formatCostUsd(sastGroup.costUsd)})${coverageSuffix}`,
|
|
);
|
|
}
|
|
|
|
// Concurrent, non-agent plumbing (finding reconciliation, and a rare catch-all) whose spend is
|
|
// inside Total Cost but never itemized above. Grouped so it stays legible as keys evolve.
|
|
const backgroundGroups = operationalGroups.filter((group) => group.key !== 'agentic-sast');
|
|
if (backgroundGroups.length > 0) {
|
|
lines.push('', 'Background Work:');
|
|
for (const group of backgroundGroups) {
|
|
lines.push(` - ${group.label} (${formatDuration(group.durationMs)}, ${formatCostUsd(group.costUsd)})`);
|
|
}
|
|
}
|
|
lines.push('================================================================================');
|
|
|
|
const marker = `Scan ${status}`;
|
|
await this.withStream((stream) =>
|
|
stream.appendIfAbsent(`${lines.join('\n')}\n`, {
|
|
marker,
|
|
scope: 'current-execution',
|
|
match: 'exact-line',
|
|
flush: true,
|
|
}),
|
|
);
|
|
// The same bare heading the combined log carries, so a `--agent` tail terminates on the file
|
|
// alone when Temporal's status is no longer available (e.g. past its retention window).
|
|
await this.fanOutMarkerToAgents(marker);
|
|
}
|
|
|
|
/**
|
|
* Release every lease this instance currently holds, then release the combined-log stream
|
|
* itself. This tears down all of this instance's open per-agent leases unconditionally, not
|
|
* just one caller's, so an instance must never be shared between agents running concurrently:
|
|
* one agent's `close()` would sever another's still-open lease. Callers close after each
|
|
* logical unit of work (an agent's end, a phase boundary) for exactly this reason, and each
|
|
* concurrent agent is given its own `WorkflowLogger`/`AuditSession` instance rather than a
|
|
* shared one.
|
|
*/
|
|
async close(): Promise<void> {
|
|
for (const [slug, lease] of this.agentLeases) {
|
|
this.agentLeases.delete(slug);
|
|
await lease.release().catch(warnAgentLoggingFailure);
|
|
}
|
|
await this.initializePromise;
|
|
if (this.logStream === null) {
|
|
this.initializePromise = null;
|
|
return;
|
|
}
|
|
await this.logStream.release().catch(warnLoggingFailure);
|
|
this.logStream = null;
|
|
this.initializePromise = null;
|
|
}
|
|
}
|