Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/workflows/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Fixed

- Fixed workflow graph rendering for large, wide fan-outs by clipping cards, edges, and composed rows to the visible viewport, retaining cached topology and layout across status-only updates, and making idle animation eligibility constant-time ([#2100](https://github.com/bastani-inc/atomic/issues/2100)).
- Fixed workflow graph rendering for large, wide fan-outs and payload-heavy runs by clipping cards, edges, and composed rows to the visible viewport, retaining cached topology and layout across status-only updates, making idle animation eligibility constant-time, and routing interactive store updates through a cached payload-free graph projection instead of cloning full workflow inputs, results, child outputs, and tool bodies ([#2100](https://github.com/bastani-inc/atomic/issues/2100)).
- Fixed `open-claude-design` converting a failed `user-feedback-*` stage into design approval (the [#1499](https://github.com/bastani-inc/atomic/issues/1499) catch-then-approve path). A rejected feedback stage — provider errors, model-fallback exhaustion, broken session forks — now propagates and fails the run instead of silently setting `approved_for_export` and exporting an unreviewed design. Resolved results are unchanged: a stage that runs and reports no meaningful notes, including degraded manual/headless display paths, still legitimately reads as approval, and browser/tooling trouble continues to resolve gracefully rather than reject (playwright-cli auto-install, the early browser exit, and the stage's degradation contract are unaffected) ([#2123](https://github.com/bastani-inc/atomic/issues/2123)).
- Fixed `open-claude-design` embedding unbounded research context inline in single stage prompts, the same single-message overflow class as the [#1499](https://github.com/bastani-inc/atomic/issues/1499) `413 request_too_large`. The composed project design context now persists to `<artifact_dir>/design-context.md` and the curated references brief always persists to `<artifact_dir>/references.md` (with the no-references fallback when discovery is skipped); `reference-discovery` reads the design context, and the generate and exporter stages read both files via `reads` with explicit read-the-file instructions instead of inline `design_system`/`reference_inspiration` embeds. The joined research results are no longer injected as `previous` payloads into `generate-1` or `reference-discovery`. Because the files are required stage inputs, a failed write no longer passes silently: the run exits `blocked` through `ctx.exit` with the artifact paths surfaced (or propagates the error when `ctx.exit` is unavailable) instead of dispatching stages against nonexistent context. Deliberately small inline payloads are unchanged: verbatim-threaded user annotations (the #1464 guardrail) and the word-capped prior design summary ([#2121](https://github.com/bastani-inc/atomic/issues/2121)).
- Fixed `open-claude-design` blocking on browser live review while reporting `running`, leaving the user with no input affordance. Each refinement round now raises a deterministic run-level `ctx.ui` prompt before its `user-feedback-*` stage starts, so the needs-attention badge fires and the prompt names the preview path and `file://` URL; choosing the skip option accepts the current design and proceeds to export. Only the executor's unavailable-UI rejection (headless mode or a missing UI adapter) degrades to running the review directly; any other gate failure — interruption, a failed durable checkpoint — propagates and stops the run. The feedback-stage prompt additionally instructs the agent to print the live `http://` review URL before starting any long-poll wait, so the URL is visible at the top of the stage output when attaching mid-run ([#2060](https://github.com/bastani-inc/atomic/issues/2060)).
Expand Down
6 changes: 5 additions & 1 deletion packages/workflows/src/extension/extension-runtime-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StageAdapters } from "../runs/foreground/stage-runner.js";
import type { SessionManager } from "../shared/persistence-restore.js";
import { stageUiBroker } from "../shared/stage-ui-broker.js";
import { store } from "../shared/store.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";
import type { RunSnapshot } from "../shared/store-types.js";
import type {
WorkflowExecutionPolicy,
Expand Down Expand Up @@ -87,7 +88,10 @@ export function createWorkflowExtensionRuntimeState(
const lifecycleNotificationState = createWorkflowLifecycleNotificationState();
const hilAnswerNotificationState = createWorkflowHilAnswerNotificationState();
const beforeRestoreCompleted = (snapshots: readonly RunSnapshot[]): void => {
seedWorkflowLifecycleNotificationState(lifecycleNotificationState, { ...store.snapshot(), runs: snapshots });
seedWorkflowLifecycleNotificationState(lifecycleNotificationState, {
...readGraphStoreSnapshot(store),
runs: snapshots,
});
};
const lifecycleNotificationConfigRef: { current: WorkflowLifecycleNotificationConfig } = {
current: WORKFLOW_CONFIG_DEFAULTS.workflowNotifications,
Expand Down
7 changes: 5 additions & 2 deletions packages/workflows/src/extension/hil-answer-notifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { StageUiBroker } from "../shared/stage-ui-broker.js";
import type { Store } from "../shared/store.js";
import { readGraphStoreSnapshot, subscribeStoreInvalidation } from "../shared/store-observation.js";
import type {
PendingPrompt,
PromptKind,
Expand Down Expand Up @@ -99,10 +100,12 @@ export function installWorkflowHilAnswerNotifications(options: WorkflowHilAnswer
}
};

const unsubscribeStore = options.store.subscribe(inspectWorkflowPromptAnswers);
const unsubscribeStore = subscribeStoreInvalidation(options.store, () =>
inspectWorkflowPromptAnswers(readGraphStoreSnapshot(options.store)),
);
const unsubscribeBroker = options.stageUiBroker?.onStagePromptResolved((event) => {
if (event.answerSource === "workflow_tool") return;
const answeredStage = findStageSnapshot(options.store.snapshot(), event.runId, event.stageId);
const answeredStage = findStageSnapshot(readGraphStoreSnapshot(options.store), event.runId, event.stageId);
if (answeredStage === undefined) return;

emitOnce(
Expand Down
9 changes: 6 additions & 3 deletions packages/workflows/src/extension/lifecycle-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../shared/returned-run-status.js";
import { isTopLevelWorkflowRun } from "../shared/run-visibility.js";
import type { Store } from "../shared/store.js";
import { readGraphStoreSnapshot, subscribeStoreInvalidation } from "../shared/store-observation.js";
import type {
PendingPrompt,
PromptKind,
Expand Down Expand Up @@ -175,7 +176,9 @@ export function installWorkflowLifecycleNotifications(options: WorkflowLifecycle
const notifyOn = new Set<WorkflowLifecycleNoticeKind>(options.config.notifyOn);
const state = options.state ?? createWorkflowLifecycleNotificationState();
let delivery!: ReturnType<typeof createLifecycleNoticeDelivery>;
if (options.seedExisting !== false) seedWorkflowLifecycleNotificationState(state, options.store.snapshot());
if (options.seedExisting !== false) {
seedWorkflowLifecycleNotificationState(state, readGraphStoreSnapshot(options.store));
}

const emit = (details: WorkflowLifecycleNoticeDetails): boolean | Promise<boolean> => {
try {
Expand Down Expand Up @@ -266,8 +269,8 @@ export function installWorkflowLifecycleNotifications(options: WorkflowLifecycle
if (notifyOn.has(details.kind)) delivery.deliver(key, details);
}

const unsubscribe = options.store.subscribe(inspect);
inspect(options.store.snapshot());
const unsubscribe = subscribeStoreInvalidation(options.store, () => inspect(readGraphStoreSnapshot(options.store)));
inspect(readGraphStoreSnapshot(options.store));
return () => {
unsubscribe();
delivery.dispose();
Expand Down
5 changes: 3 additions & 2 deletions packages/workflows/src/extension/postmortem-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { stageControlRegistry } from "../runs/foreground/stage-control-registry.js";
import type { StageAdapters } from "../runs/foreground/stage-runner.js";
import { store } from "../shared/store.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";

export interface PostMortemResolverDeps {
readonly adapters: StageAdapters;
Expand All @@ -33,7 +34,7 @@ function resolveStageCwd(runId: string): string | undefined {
try {
const backend = getDurableBackend();
const owningHandle = backend.getWorkflow(runId);
const run = store.snapshot().runs.find((candidate) => candidate.id === runId);
const run = readGraphStoreSnapshot(store).runs.find((candidate) => candidate.id === runId);
const rootRunId = run?.rootRunId ?? owningHandle?.rootWorkflowId;
const cwdHandle = rootRunId === undefined ? owningHandle : (backend.getWorkflow(rootRunId) ?? owningHandle);
return cwdHandle?.workflowCwd ?? cwdHandle?.invocationCwd ?? undefined;
Expand Down Expand Up @@ -61,7 +62,7 @@ export function createPostMortemHandleResolver(
deps: PostMortemResolverDeps,
): (runId: string, stageId: string) => EnsurePostMortemStageHandleResult | undefined {
return (runId, stageId) => {
const run = store.snapshot().runs.find((candidate) => candidate.id === runId);
const run = readGraphStoreSnapshot(store).runs.find((candidate) => candidate.id === runId);
const stage = run?.stages.find((candidate) => candidate.id === stageId);
if (stage === undefined) return undefined;
return ensurePostMortemStageHandle(runId, stage, postMortemDepsForRun(runId, deps));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getDurableBackend } from "../durable/factory.js";
import type { ResumableWorkflowEntry } from "../durable/types.js";
import { topLevelWorkflowRuns } from "../shared/run-visibility.js";
import type { Store } from "../shared/store.js";
import { subscribeStoreInvalidation } from "../shared/store-observation.js";
import type { RunSnapshot } from "../shared/store-types.js";
import type { WorkflowResumeRefresh } from "../tui/workflow-resume-selector.js";
import type { ExtensionRuntime } from "./runtime.js";
Expand Down Expand Up @@ -66,7 +67,7 @@ export function resumePickerLiveUpdateOptions(
runtime: ExtensionRuntime,
): ResumePickerLiveUpdateOptions {
return {
watch: (onChange) => runStore.subscribe(() => onChange()),
watch: (onChange) => subscribeStoreInvalidation(runStore, onChange),
refresh: async () => {
const current = collectResumePickerLiveRuns(runStore);
const catalog = await prepareWorkflowResumeCatalog(runtime, current.activeLiveIds);
Expand Down
3 changes: 2 additions & 1 deletion packages/workflows/src/extension/workflow-resume-shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getLoadableDurableWorkflow } from "../durable/workflow-status-transitio
import { type JobTracker, jobTracker } from "../runs/background/job-tracker.js";
import { type StageControlRegistry, stageControlRegistry } from "../runs/foreground/stage-control-registry.js";
import { expandWorkflowGraph } from "../shared/expanded-workflow-graph.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";
import type { Store } from "../shared/store-public-types.js";
import type { RunSnapshot } from "../shared/store-types.js";

Expand All @@ -28,7 +29,7 @@ export function classifyDurableResumeShadow(
const jobs = deps.jobs ?? jobTracker;
if (jobs.has(run.id)) return "not_shadow";
const controls = deps.stageControls ?? stageControlRegistry;
const graph = expandWorkflowGraph(store.snapshot(), run.id);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), run.id);
const controlRunIds = new Set<string>([run.id]);
for (const stage of graph.stages) controlRunIds.add(stage.workflowGraphTarget.runId);
if ([...controlRunIds].some((runId) => controls.run(runId).stages().length > 0)) return "not_shadow";
Expand Down
5 changes: 3 additions & 2 deletions packages/workflows/src/extension/workflow-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../shared/expanded-workflow-graph.js";
import { topLevelWorkflowRuns } from "../shared/run-visibility.js";
import { store } from "../shared/store.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";
import type { RunStatus } from "../shared/store-types.js";
import type { OverlayPiSurface } from "../tui/overlay-adapter.js";
import type { PiExecuteContext, WorkflowToolArgs } from "./public-types.js";
Expand Down Expand Up @@ -115,7 +116,7 @@ export type ToolStageTarget = { ok: true; runId?: string; stageId?: string } | {
export function resolveStageTarget(runId: string, stageTarget?: string): ToolStageTarget {
const target = stageTarget?.trim();
if (!target) return { ok: true, runId };
const graph = expandWorkflowGraph(store.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), runId);
const exactVirtualIds = graph.stages.filter((stage) => stage.id === target);
if (exactVirtualIds.length === 1) return resolvedStageTarget(exactVirtualIds[0]!);
if (exactVirtualIds.length > 1) return ambiguousStageTarget(target, exactVirtualIds);
Expand Down Expand Up @@ -166,7 +167,7 @@ export type ControlNodeTarget =
export function resolveControlNodeTarget(runId: string, stageTarget?: string): ControlNodeTarget {
const target = stageTarget?.trim();
if (!target) return { ok: true, kind: "run" };
const graph = expandWorkflowGraph(store.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), runId);
const nodes = graph.renderStages;
const candidates: Array<readonly ExpandedWorkflowStage[]> = [
nodes.filter((node) => node.id === target),
Expand Down
3 changes: 2 additions & 1 deletion packages/workflows/src/extension/workflow-tool-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { coerceStageInputAnswer, hasStageInputAnswerContent, type StageInputAnsw
import { stageUiBroker } from "../shared/stage-ui-broker.js";
import { store } from "../shared/store.js";
import { isTerminalRunStatus } from "../shared/store-internal.js";
import { subscribeStoreInvalidation } from "../shared/store-observation.js";
import { reciprocalWorkflowRootRunId } from "../shared/workflow-run-ownership.js";
import type { WorkflowToolArgs } from "./public-types.js";
import type { WorkflowToolResult } from "./render-result.js";
Expand Down Expand Up @@ -280,7 +281,7 @@ export async function workflowSendAction(
admitted = true;
};
const terminalPending = Promise.withResolvers<never>();
const unsubscribeTerminal = store.subscribe(() => {
const unsubscribeTerminal = subscribeStoreInvalidation(store, () => {
if (admitted) return;
const terminal = terminalWorkflowSendResultForRoot(rootRunId, args.stageId?.trim() ?? resolvedStageId);
if (terminal !== undefined) terminalPending.reject(new WorkflowSendAdmissionError(terminal));
Expand Down
5 changes: 3 additions & 2 deletions packages/workflows/src/runs/background/quit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { WorkflowGracefulQuitError } from "../../engine/workflow-tool-abort.js";
import { expandWorkflowGraph } from "../../shared/expanded-workflow-graph.js";
import { topLevelWorkflowRuns } from "../../shared/run-visibility.js";
import { store as defaultStore } from "../../shared/store.js";
import { readGraphStoreSnapshot } from "../../shared/store-observation.js";
import type { Store } from "../../shared/store-public-types.js";
import type { RunSnapshot, StageSnapshot } from "../../shared/store-types.js";
import type { WorkflowCancelledToolNode, WorkflowToolNodeIdentity } from "../../shared/types.js";
Expand Down Expand Up @@ -100,7 +101,7 @@ export async function quitRun(
if (!run) return { ok: false, runId, reason: "not_found" };
if (run.endedAt !== undefined) return { ok: false, runId, reason: "already_ended" };

const graph = expandWorkflowGraph(activeStore.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(activeStore), runId);
const handles = controllableHandles(activeStore, registry, runId);
const admissionBoundaries = controllableAdmissionBoundaries(activeStore, toolControls, runId);
const promptStages = graph.stages.filter(
Expand Down Expand Up @@ -303,7 +304,7 @@ function controllableHandles(
registry: StageControlRegistry,
runId: string,
): Array<{ controlRunId: string; handle: StageControlHandle }> {
const graph = expandWorkflowGraph(activeStore.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(activeStore), runId);
const controlRunIds = new Set<string>([runId]);
for (const stage of graph.stages) controlRunIds.add(stage.workflowGraphTarget.runId);
return [...controlRunIds].flatMap((controlRunId) =>
Expand Down
3 changes: 2 additions & 1 deletion packages/workflows/src/runs/background/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { effectiveRunStatus } from "../../shared/returned-run-status.js";
import { topLevelWorkflowRuns } from "../../shared/run-visibility.js";
import type { Store } from "../../shared/store.js";
import { store as defaultStore } from "../../shared/store.js";
import { readGraphStoreSnapshot } from "../../shared/store-observation.js";
import type { RunSnapshot, RunStatus, StageSnapshot } from "../../shared/store-types.js";
import type { WorkflowPersistencePort } from "../../shared/types.js";
import type { StageControlRegistry } from "../foreground/stage-control-registry.js";
Expand Down Expand Up @@ -89,7 +90,7 @@ export { type InspectRunResult, inspectRun, type RunDetail } from "./run-inspect
export function statusRuns(opts?: { all?: boolean; store?: Store }): RunStatusEntry[] {
const activeStore = opts?.store ?? defaultStore;

const snapshot = activeStore.snapshot();
const snapshot = readGraphStoreSnapshot(activeStore);
return topLevelWorkflowRuns(snapshot.runs).map((run) => {
const graph = expandWorkflowGraph(snapshot, run.id);
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { expandWorkflowGraph } from "../../shared/expanded-workflow-graph.js";
import { readGraphStoreSnapshot } from "../../shared/store-observation.js";
import type { Store } from "../../shared/store-public-types.js";
import { reciprocalWorkflowRootRunId } from "../../shared/workflow-run-ownership.js";

/** Control-run ids visible below one workflow boundary, in graph order. */
export function expandedControlRunIds(store: Store, runId: string): string[] {
const graph = expandWorkflowGraph(store.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), runId);
const ids = new Set<string>([runId]);
for (const stage of graph.stages) ids.add(stage.workflowGraphTarget.runId);
// Tool-only nested runs own no stage, yet their in-flight ctx.tool nodes are
Expand Down
Loading