← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/workbench-state.ts

15.5 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import { SEQUENCE_VIEWER_LIMITS, utf8ByteLength } from "./runtime-contract";
import type {
  MsaDocument,
  MsaMetricTrackKey,
  MsaRowSortDirection,
  MsaRowSortKey,
} from "./msa/types";
import type { SequenceInterfaceSettings } from "./sequence/interface-state";
import type { SequenceDocument, SequenceSelection } from "./sequence/types";
import type { SequenceTrack } from "./sequence/tracks";
import { parseAndValidateWorkbenchSession } from "./workbench-session-validation";

export type WorkbenchMode = "alignment" | "sequence";

export type WorkbenchJobKind =
  | "align"
  | "distance-matrix"
  | "guide-tree"
  | "orfs"
  | "primers"
  | "quality-report"
  | "restriction-analysis"
  | "statistics"
  | "translation";

export type WorkbenchJob = {
  completedAt?: number;
  error?: string;
  id: string;
  kind: WorkbenchJobKind;
  message: string;
  parameters: Record<string, unknown>;
  progress: number;
  result?: Record<string, unknown>;
  startedAt: number;
  status: "cancelled" | "completed" | "failed" | "running";
};

export type WorkbenchArtifact = {
  content: string;
  createdAt: number;
  format: string;
  id: string;
  mediaType: string;
  name: string;
  provenance: {
    engine: string;
    parameters: Record<string, unknown>;
    sourceRevision: number;
  };
};

export type WorkbenchArtifactSummary = Omit<WorkbenchArtifact, "content">;

export function summarizeWorkbenchArtifact({
  content: _content,
  ...artifact
}: WorkbenchArtifact): WorkbenchArtifactSummary {
  return artifact;
}

export type WorkbenchViewState = {
  alignment?: {
    analysisScope: string;
    cellWidth: number;
    colorMode: string;
    enabledMetricTracks?: Array<MsaMetricTrackKey>;
    referenceMode: string;
    residuePalette: string | null;
    rowFilter: string;
    rowSortDirection?: MsaRowSortDirection;
    rowSortKey?: MsaRowSortKey;
    searchScope: string;
    selectedColumns: { end: number; start: number } | null;
    selectedRows: Array<string>;
    showAnnotationTracks: boolean;
    showIdenticalAsDots: boolean;
    showRnaStructureOverlays: boolean;
    showSequenceLogoHelp?: boolean;
  };
  mode: WorkbenchMode;
  sequence?: {
    geneticCodeId: number;
    interface?: SequenceInterfaceSettings;
    layout: "circular" | "linear" | "split";
    orientation: "forward" | "reverse-complement";
    paletteId: string;
    selectedFeatureId: string | null;
    selectedRecordId: string;
    selection: SequenceSelection | null;
    showFeatures: boolean;
    showQuality: boolean;
    showTranslation: boolean;
    synchronizedViews: boolean;
    viewport: { end: number; start: number } | null;
    wrapWidth: number;
  };
};

export type WorkbenchSession = {
  artifacts: Array<WorkbenchArtifact>;
  createdAt: number;
  dirty: boolean;
  jobs: Array<WorkbenchJob>;
  revision: number;
  schemaVersion: 1;
  snapshot?: {
    alignmentDocument?: MsaDocument;
    sequenceDocument?: SequenceDocument;
  };
  source: {
    fileName: string | null;
    format: string;
    stateKey?: string | null;
  };
  tracks: Array<SequenceTrack>;
  view: WorkbenchViewState;
};

export type WorkbenchSharedState = {
  artifacts: Array<WorkbenchArtifact>;
  dirty: boolean;
  jobs: Array<WorkbenchJob>;
  revision: number;
  tracks: Array<SequenceTrack>;
};

export type WorkbenchSharedAction =
  | { artifact: WorkbenchArtifact; type: "add-artifact" }
  | { id: string; type: "cancel-job" }
  | { job: WorkbenchJob; type: "start-job" }
  | {
      completedAt: number;
      id: string;
      message: string;
      result: Record<string, unknown>;
      type: "complete-job";
    }
  | {
      completedAt: number;
      error: string;
      id: string;
      type: "fail-job";
    }
  | { track: SequenceTrack; type: "add-track" }
  | { id: string; type: "remove-track" }
  | { state: WorkbenchSharedState; type: "restore-shared" }
  | { dirty: boolean; type: "set-dirty" };

export type SequenceWorkbenchState = WorkbenchSharedState & {
  document: SequenceDocument;
  /** Immutable opened source; histories and saved edit copies do not replace it. */
  sourceDocument: SequenceDocument;
  future: Array<{ document: SequenceDocument; description: string }>;
  history: Array<{ document: SequenceDocument; description: string }>;
};

export type SequenceWorkbenchAction =
  | WorkbenchSharedAction
  | {
      document: SequenceDocument;
      sourceDocument?: SequenceDocument;
      type: "reset-sequence-document";
    }
  | {
      description: string;
      document: SequenceDocument;
      type: "apply-sequence-document";
    }
  | {
      description: string;
      document: SequenceDocument;
      type: "restore-sequence-document";
    }
  | { type: "redo-sequence-document" }
  | { type: "undo-sequence-document" };

export type AlignmentWorkbenchState = WorkbenchSharedState & {
  document: MsaDocument;
  future: Array<{ document: MsaDocument; description: string }>;
  history: Array<{ document: MsaDocument; description: string }>;
  selectedRows: Array<string>;
};

export type AlignmentWorkbenchAction =
  | WorkbenchSharedAction
  | { document: MsaDocument; type: "reset-alignment-document" }
  | { document: MsaDocument; type: "replace-alignment-view-document" }
  | {
      description: string;
      document: MsaDocument;
      type: "apply-alignment-document";
    }
  | { rowIds: Array<string>; type: "select-alignment-rows" }
  | { type: "redo-alignment-document" }
  | { type: "undo-alignment-document" };

export function createSequenceWorkbenchState(
  document: SequenceDocument,
  sourceDocument: SequenceDocument = document,
): SequenceWorkbenchState {
  return {
    ...createSharedState(),
    document: markChangedEvidenceCoordinates(document, sourceDocument, false),
    sourceDocument,
    future: [],
    history: [],
  };
}

export function createAlignmentWorkbenchState(
  document: MsaDocument,
): AlignmentWorkbenchState {
  return {
    ...createSharedState(),
    document,
    future: [],
    history: [],
    selectedRows: [],
  };
}

export function sequenceWorkbenchReducer(
  state: SequenceWorkbenchState,
  action: SequenceWorkbenchAction,
): SequenceWorkbenchState {
  if (action.type === "reset-sequence-document") {
    return createSequenceWorkbenchState(
      action.document,
      action.sourceDocument ?? action.document,
    );
  }
  if (
    action.type === "apply-sequence-document" ||
    action.type === "restore-sequence-document"
  ) {
    const restoring = action.type === "restore-sequence-document";
    const document = markChangedEvidenceCoordinates(
      action.document,
      restoring ? state.sourceDocument : state.document,
      !restoring,
    );
    return {
      ...state,
      dirty: true,
      document,
      future: [],
      history: pushBoundedHistory(state.history, {
        description: action.description,
        document: state.document,
      }),
      revision: state.revision + 1,
    };
  }
  if (action.type === "undo-sequence-document") {
    const previous = state.history.at(-1);
    if (previous == null) return state;
    return {
      ...state,
      document: previous.document,
      future: pushBoundedHistory(state.future, {
        description: previous.description,
        document: state.document,
      }),
      history: state.history.slice(0, -1),
      revision: state.revision + 1,
    };
  }
  if (action.type === "redo-sequence-document") {
    const next = state.future.at(-1);
    if (next == null) return state;
    return {
      ...state,
      document: next.document,
      future: state.future.slice(0, -1),
      history: pushBoundedHistory(state.history, {
        description: next.description,
        document: state.document,
      }),
      revision: state.revision + 1,
    };
  }
  return applySharedAction(state, action);
}

function markChangedEvidenceCoordinates(
  document: SequenceDocument,
  comparison: SequenceDocument,
  preserveComparisonStale: boolean,
): SequenceDocument {
  const previousRecords = new Map(
    comparison.records.map((record) => [record.id, record]),
  );
  const records = document.records.map((record) => {
    const previous = previousRecords.get(record.id);
    const sourceChanged =
      previous == null ||
      previous.sequence !== record.sequence ||
      previous.length !== record.length ||
      previous.sourceLabel !== record.sourceLabel;
    return record.evidenceCoordinatesStale !== true &&
      (sourceChanged ||
        (preserveComparisonStale &&
          previous?.evidenceCoordinatesStale === true))
      ? { ...record, evidenceCoordinatesStale: true }
      : record;
  });
  return records.some((record, index) => record !== document.records[index])
    ? { ...document, records }
    : document;
}

export function alignmentWorkbenchReducer(
  state: AlignmentWorkbenchState,
  action: AlignmentWorkbenchAction,
): AlignmentWorkbenchState {
  if (action.type === "reset-alignment-document") {
    return createAlignmentWorkbenchState(action.document);
  }
  if (action.type === "replace-alignment-view-document") {
    return { ...state, document: action.document };
  }
  if (action.type === "apply-alignment-document") {
    const available = new Set(action.document.rows.map(({ id }) => id));
    return {
      ...state,
      dirty: true,
      document: action.document,
      future: [],
      history: pushBoundedHistory(state.history, {
        description: action.description,
        document: state.document,
      }),
      revision: state.revision + 1,
      selectedRows: state.selectedRows.filter((id) => available.has(id)),
    };
  }
  if (action.type === "select-alignment-rows") {
    const available = new Set(state.document.rows.map(({ id }) => id));
    return {
      ...state,
      selectedRows: [...new Set(action.rowIds)].filter((id) =>
        available.has(id),
      ),
    };
  }
  if (action.type === "undo-alignment-document") {
    const previous = state.history.at(-1);
    if (previous == null) return state;
    return {
      ...state,
      document: previous.document,
      future: pushBoundedHistory(state.future, {
        description: previous.description,
        document: state.document,
      }),
      history: state.history.slice(0, -1),
      revision: state.revision + 1,
    };
  }
  if (action.type === "redo-alignment-document") {
    const next = state.future.at(-1);
    if (next == null) return state;
    return {
      ...state,
      document: next.document,
      future: state.future.slice(0, -1),
      history: pushBoundedHistory(state.history, {
        description: next.description,
        document: state.document,
      }),
      revision: state.revision + 1,
    };
  }
  return applySharedAction(state, action);
}

export function serializeWorkbenchSession(session: WorkbenchSession): string {
  const serialized = JSON.stringify(session, null, 2);
  if (
    utf8ByteLength(serialized) > SEQUENCE_VIEWER_LIMITS.session.maxSessionBytes
  ) {
    throw new Error(
      `Workbench session exceeds the ${SEQUENCE_VIEWER_LIMITS.session.maxSessionBytes.toLocaleString()}-byte session budget. Remove large tracks or results before saving.`,
    );
  }
  return serialized;
}

export function parseWorkbenchSession(value: string): WorkbenchSession {
  try {
    return parseAndValidateWorkbenchSession(value);
  } catch (error) {
    throw new Error(
      `This is not a compatible Biological Sequence Viewer session: ${error instanceof Error ? error.message : "validation failed"}`,
    );
  }
}

export function settleRestoredJobs(
  jobs: Array<WorkbenchJob>,
  restoredAt = Date.now(),
): Array<WorkbenchJob> {
  return jobs.map((job) =>
    job.status === "running"
      ? {
          ...job,
          completedAt: restoredAt,
          message:
            "Cancelled when the saved session was restored; run the analysis again on the restored copy.",
          status: "cancelled" as const,
        }
      : job,
  );
}

function createSharedState(): WorkbenchSharedState {
  return { artifacts: [], dirty: false, jobs: [], revision: 0, tracks: [] };
}

function applySharedAction<T extends WorkbenchSharedState>(
  state: T,
  action: WorkbenchSharedAction,
): T {
  switch (action.type) {
    case "add-artifact":
      if (
        utf8ByteLength(action.artifact.content) >
        SEQUENCE_VIEWER_LIMITS.session.maxArtifactBytes
      ) {
        throw new Error(
          `Artifact ${action.artifact.name} exceeds the ${SEQUENCE_VIEWER_LIMITS.session.maxArtifactBytes.toLocaleString()}-byte artifact budget.`,
        );
      }
      return {
        ...state,
        artifacts: pruneArtifactCache([
          ...state.artifacts.filter(({ id }) => id !== action.artifact.id),
          action.artifact,
        ]),
        revision: state.revision + 1,
      };
    case "add-track":
      return {
        ...state,
        dirty: true,
        revision: state.revision + 1,
        tracks: [
          ...state.tracks.filter(({ id }) => id !== action.track.id),
          action.track,
        ],
      };
    case "remove-track":
      return {
        ...state,
        dirty: true,
        revision: state.revision + 1,
        tracks: state.tracks.filter(({ id }) => id !== action.id),
      };
    case "start-job":
      return {
        ...state,
        jobs: [
          ...state.jobs.filter(({ id }) => id !== action.job.id),
          action.job,
        ].slice(-100),
        revision: state.revision + 1,
      };
    case "complete-job":
      if (
        utf8ByteLength(JSON.stringify(action.result)) >
        SEQUENCE_VIEWER_LIMITS.analysis.maxResultBytes
      ) {
        return updateJob(state, action.id, (job) => ({
          ...job,
          completedAt: action.completedAt,
          error:
            "The result exceeded the bounded live-result budget. Narrow the row set, coordinate range, or requested result count and retry.",
          message:
            "Result was too large for the bounded live workbench. Refine the request and retry.",
          status: "failed",
        }));
      }
      return updateJob(state, action.id, (job) => ({
        ...job,
        completedAt: action.completedAt,
        message: action.message,
        progress: 1,
        result: action.result,
        status: "completed",
      }));
    case "fail-job":
      return updateJob(state, action.id, (job) => ({
        ...job,
        completedAt: action.completedAt,
        error: action.error,
        message: action.error,
        status: "failed",
      }));
    case "cancel-job":
      return updateJob(state, action.id, (job) => ({
        ...job,
        completedAt: Date.now(),
        message: "Cancelled",
        status: "cancelled",
      }));
    case "restore-shared":
      return { ...state, ...action.state, revision: state.revision + 1 };
    case "set-dirty":
      return { ...state, dirty: action.dirty, revision: state.revision + 1 };
  }
}

function pruneArtifactCache(
  artifacts: Array<WorkbenchArtifact>,
): Array<WorkbenchArtifact> {
  const retained = artifacts.slice(
    -SEQUENCE_VIEWER_LIMITS.session.maxArtifacts,
  );
  let totalBytes = retained.reduce(
    (sum, artifact) => sum + utf8ByteLength(artifact.content),
    0,
  );
  while (
    retained.length > 1 &&
    totalBytes > SEQUENCE_VIEWER_LIMITS.session.maxArtifactCacheBytes
  ) {
    const removed = retained.shift();
    totalBytes -= removed == null ? 0 : utf8ByteLength(removed.content);
  }
  return retained;
}

function updateJob<T extends WorkbenchSharedState>(
  state: T,
  id: string,
  update: (job: WorkbenchJob) => WorkbenchJob,
): T {
  if (!state.jobs.some((job) => job.id === id)) return state;
  return {
    ...state,
    jobs: state.jobs.map((job) => (job.id === id ? update(job) : job)),
    revision: state.revision + 1,
  };
}

function pushBoundedHistory<T>(items: Array<T>, item: T): Array<T> {
  return [...items, item].slice(-50);
}

SHA-256: d59fe97c2349361f79806413e422eb47c16f004647d92d789d794a135b9314bc