- ClaudeAdapter: dedupe workflow agents/phases by index (last write wins) before applying entry caps, so repeated slot updates cannot exhaust the cap and freeze later agents stale; restrict sessionUrl to http(s) so a hostile tool result cannot smuggle a javascript: href - workflowUi: guard sessionUrl scheme again at both anchor render sites (defense in depth for payloads persisted before the adapter filter) - Ingestion: JSON-encode the (threadId, taskId) tuple in stable workflow activity ids to remove delimiter-ambiguity collisions - workflow-logic: order same-timestamp activities by provider sequence then lifecycle rank so task.completed can never apply before its task.started - WorkflowPanel/Card: scope phase and agent row keys by taskId so switching runs remounts rows instead of leaking expanded transcript state and cursors across runs - workflowUi: phase progress counter counts settled (done + error) agents instead of reporting 0/1 for an errored terminal phase - client-runtime: readJournal query polls every 4s while mounted so the Logs tab picks up new results during a live run - mobile: keep workflow task.completed rows (only per-tick progress is suppressed) — with no workflow card on mobile it is the only signal a workflow finished or failed
Unified · Split
| @@ -281,11 +281,13 @@ function deriveWorkLogEntries( · 280 unmodified lines | ||
| 281 | 281 | if (activity.kind === "task.started") continue; |
| 282 | 282 | // Workflow snapshot/meta activities back the desktop workflow card; on |
| 283 | 283 | // mobile they would render as ever-mutating raw rows, so skip them — |
| 284 | − // along with the per-tick task rows the workflow owns. | |
| 284 | + // along with the per-tick progress rows the workflow owns. task.completed | |
| 285 | + // stays: with no workflow card here it is mobile's only signal that a | |
| 286 | + // workflow finished, failed, or was stopped. | |
| 285 | 287 | if (activity.kind === "task.workflow-updated") continue; |
| 286 | 288 | if (activity.kind === "task.workflow-meta") continue; |
| 287 | 289 | if ( |
| 288 | − (activity.kind === "task.progress" || activity.kind === "task.completed") && | |
| 290 | + activity.kind === "task.progress" && | |
| 289 | 291 | workflowTaskIds.size > 0 && |
| 290 | 292 | activityBelongsToWorkflow(activity, workflowTaskIds) |
| 291 | 293 | ) { |
| @@ -3122,7 +3122,8 @@ describe("ProviderRuntimeIngestion", () => { · 3121 unmodified lines | ||
| 3122 | 3122 | |
| 3123 | 3123 | const thread = await waitForThread(harness.readModel, (entry) => |
| 3124 | 3124 | entry.activities.some( |
| 3125 | − (activity: ProviderRuntimeTestActivity) => activity.id === "workflow:thread-1:task-wf-1", | |
| 3125 | + (activity: ProviderRuntimeTestActivity) => | |
| 3126 | + activity.id === `workflow:${JSON.stringify(["thread-1", "task-wf-1"])}`, | |
| 3126 | 3127 | ), |
| 3127 | 3128 | ); |
| 3128 | 3129 | |
| @@ -3132,7 +3133,8 @@ describe("ProviderRuntimeIngestion", () => { · 3 unmodified lines | ||
| 3132 | 3133 | expect(perTick?.kind).toBe("task.progress"); |
| 3133 | 3134 | |
| 3134 | 3135 | const snapshot = thread.activities.find( |
| 3135 | − (activity: ProviderRuntimeTestActivity) => activity.id === "workflow:thread-1:task-wf-1", | |
| 3136 | + (activity: ProviderRuntimeTestActivity) => | |
| 3137 | + activity.id === `workflow:${JSON.stringify(["thread-1", "task-wf-1"])}`, | |
| 3136 | 3138 | ); |
| 3137 | 3139 | expect(snapshot?.kind).toBe("task.workflow-updated"); |
| 3138 | 3140 | const payload = |
| @@ -3165,11 +3167,13 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines | ||
| 3165 | 3167 | |
| 3166 | 3168 | const thread = await waitForThread(harness.readModel, (entry) => |
| 3167 | 3169 | entry.activities.some( |
| 3168 | − (activity: ProviderRuntimeTestActivity) => activity.id === "workflow-meta:thread-1:task-wf-meta", | |
| 3170 | + (activity: ProviderRuntimeTestActivity) => | |
| 3171 | + activity.id === `workflow-meta:${JSON.stringify(["thread-1", "task-wf-meta"])}`, | |
| 3169 | 3172 | ), |
| 3170 | 3173 | ); |
| 3171 | 3174 | const meta = thread.activities.find( |
| 3172 | − (activity: ProviderRuntimeTestActivity) => activity.id === "workflow-meta:thread-1:task-wf-meta", | |
| 3175 | + (activity: ProviderRuntimeTestActivity) => | |
| 3176 | + activity.id === `workflow-meta:${JSON.stringify(["thread-1", "task-wf-meta"])}`, | |
| 3173 | 3177 | ); |
| 3174 | 3178 | expect(meta?.kind).toBe("task.workflow-meta"); |
| 3175 | 3179 | const payload = |
| @@ -3226,7 +3230,8 @@ describe("ProviderRuntimeIngestion", () => { · 50 unmodified lines | ||
| 3226 | 3230 | const drainedThread = drained.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| 3227 | 3231 | |
| 3228 | 3232 | const snapshots = drainedThread?.activities.filter( |
| 3229 | − (activity: ProviderRuntimeTestActivity) => activity.id === "workflow:thread-1:task-wf-2", | |
| 3233 | + (activity: ProviderRuntimeTestActivity) => | |
| 3234 | + activity.id === `workflow:${JSON.stringify(["thread-1", "task-wf-2"])}`, | |
| 3230 | 3235 | ); |
| 3231 | 3236 | expect(snapshots).toHaveLength(1); |
| 3232 | 3237 | const payload = |
| @@ -167,12 +167,14 @@ function maxCheckpointTurnCount( · 166 unmodified lines | ||
| 167 | 167 | * Stable per-task activity ids: workflow snapshot/meta activities are |
| 168 | 168 | * upserted (one projection row per run), not appended per progress tick. |
| 169 | 169 | */ |
| 170 | +// JSON-encode the (threadId, taskId) tuple: both are free-form strings, so a | |
| 171 | +// bare `:`-joined key would let distinct pairs collide on the upsert id. | |
| 170 | 172 | function workflowActivityId(threadId: ThreadId, taskId: string): EventId { |
| 171 | − return EventId.make(`workflow:${threadId}:${taskId}`); | |
| 173 | + return EventId.make(`workflow:${JSON.stringify([threadId, taskId])}`); | |
| 172 | 174 | } |
| 173 | 175 | |
| 174 | 176 | function workflowMetaActivityId(threadId: ThreadId, taskId: string): EventId { |
| 175 | − return EventId.make(`workflow-meta:${threadId}:${taskId}`); | |
| 177 | + return EventId.make(`workflow-meta:${JSON.stringify([threadId, taskId])}`); | |
| 176 | 178 | } |
| 177 | 179 | |
| 178 | 180 | function truncateDetail(value: string, limit = 180): string { |
| @@ -831,8 +831,11 @@ function normalizeWorkflowProgress( · 830 unmodified lines | ||
| 831 | 831 | if (!Array.isArray(value) || value.length === 0) { |
| 832 | 832 | return undefined; |
| 833 | 833 | } |
| 834 | − const agents: Array<WorkflowAgentProgressEntry> = []; | |
| 835 | − const phases: Array<WorkflowPhaseProgressEntry> = []; | |
| 834 | + // Last write wins per index: the snapshot may re-emit an agent/phase slot | |
| 835 | + // (retries, later progress), and the cap must count unique slots, not raw | |
| 836 | + // entries, or repeats would freeze later agents in a stale state. | |
| 837 | + const agentsByIndex = new Map<number, WorkflowAgentProgressEntry>(); | |
| 838 | + const phasesByIndex = new Map<number, WorkflowPhaseProgressEntry>(); | |
| 836 | 839 | const logs: Array<WorkflowLogProgressEntry> = []; |
| 837 | 840 | for (const raw of value) { |
| 838 | 841 | if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { |
| @@ -842,8 +845,11 @@ function normalizeWorkflowProgress( · 3 unmodified lines | ||
| 842 | 845 | switch (entry.type) { |
| 843 | 846 | case "workflow_agent": { |
| 844 | 847 | const agent = normalizeWorkflowAgentEntry(entry); |
| 845 | − if (agent && agents.length < MAX_WORKFLOW_AGENT_ENTRIES) { | |
| 846 | − agents.push(agent); | |
| 848 | + if ( | |
| 849 | + agent && | |
| 850 | + (agentsByIndex.has(agent.index) || agentsByIndex.size < MAX_WORKFLOW_AGENT_ENTRIES) | |
| 851 | + ) { | |
| 852 | + agentsByIndex.set(agent.index, agent); | |
| 847 | 853 | } |
| 848 | 854 | break; |
| 849 | 855 | } |
| @@ -853,9 +859,9 @@ function normalizeWorkflowProgress( · 3 unmodified lines | ||
| 853 | 859 | if ( |
| 854 | 860 | index !== undefined && |
| 855 | 861 | title !== undefined && |
| 856 | − phases.length < MAX_WORKFLOW_PHASE_ENTRIES | |
| 862 | + (phasesByIndex.has(index) || phasesByIndex.size < MAX_WORKFLOW_PHASE_ENTRIES) | |
| 857 | 863 | ) { |
| 858 | − phases.push({ | |
| 864 | + phasesByIndex.set(index, { | |
| 859 | 865 | type: "workflow_phase", |
| 860 | 866 | index, |
| 861 | 867 | title, |
| @@ -880,7 +886,7 @@ function normalizeWorkflowProgress( · 18 unmodified lines | ||
| 880 | 886 | // Narration is append-only upstream; keep the newest lines when clipping. |
| 881 | 887 | const clippedLogs = |
| 882 | 888 | logs.length > MAX_WORKFLOW_LOG_ENTRIES ? logs.slice(-MAX_WORKFLOW_LOG_ENTRIES) : logs; |
| 883 | − const entries = [...phases, ...agents, ...clippedLogs]; | |
| 889 | + const entries = [...phasesByIndex.values(), ...agentsByIndex.values(), ...clippedLogs]; | |
| 884 | 890 | return entries.length > 0 ? entries : undefined; |
| 885 | 891 | } |
| 886 | 892 | |
| @@ -893,6 +899,21 @@ function readClaudeWorkflowProgress( · 6 unmodified lines | ||
| 893 | 899 | return normalizeWorkflowProgress(raw); |
| 894 | 900 | } |
| 895 | 901 | |
| 902 | +function workflowHttpUrl(value: unknown): string | undefined { | |
| 903 | + const text = workflowString(value); | |
| 904 | + if (text === undefined) { | |
| 905 | + return undefined; | |
| 906 | + } | |
| 907 | + // Clients render this into an anchor href — restrict to web URLs so a | |
| 908 | + // hostile tool result cannot smuggle a javascript:/file: scheme through. | |
| 909 | + try { | |
| 910 | + const parsed = new URL(text); | |
| 911 | + return parsed.protocol === "https:" || parsed.protocol === "http:" ? text : undefined; | |
| 912 | + } catch { | |
| 913 | + return undefined; | |
| 914 | + } | |
| 915 | +} | |
| 916 | + | |
| 896 | 917 | function normalizeWorkflowRunHandles( |
| 897 | 918 | toolUseResult: Record<string, unknown>, |
| 898 | 919 | ): WorkflowRunHandles | undefined { |
| @@ -917,8 +938,8 @@ function normalizeWorkflowRunHandles( · 18 unmodified lines | ||
| 917 | 938 | ...(workflowString(toolUseResult.transcriptDir) !== undefined |
| 918 | 939 | ? { transcriptDir: workflowString(toolUseResult.transcriptDir) } |
| 919 | 940 | : {}), |
| 920 | − ...(workflowString(toolUseResult.sessionUrl) !== undefined | |
| 921 | − ? { sessionUrl: workflowString(toolUseResult.sessionUrl) } | |
| 941 | + ...(workflowHttpUrl(toolUseResult.sessionUrl) !== undefined | |
| 942 | + ? { sessionUrl: workflowHttpUrl(toolUseResult.sessionUrl) } | |
| 922 | 943 | : {}), |
| 923 | 944 | ...(workflowString(toolUseResult.warning) !== undefined |
| 924 | 945 | ? { warning: workflowString(toolUseResult.warning) } |
| @@ -30,7 +30,12 @@ import { · 29 unmodified lines | ||
| 30 | 30 | workflowRunTitle, |
| 31 | 31 | } from "~/workflow-logic"; |
| 32 | 32 | import { Button } from "../ui/button"; |
| 33 | −import { AgentRowContent, PhaseHeader, WorkflowStatusChip } from "./workflowUi"; | |
| 33 | +import { | |
| 34 | + AgentRowContent, | |
| 35 | + PhaseHeader, | |
| 36 | + WorkflowStatusChip, | |
| 37 | + safeWorkflowSessionUrl, | |
| 38 | +} from "./workflowUi"; | |
| 34 | 39 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 35 | 40 | |
| 36 | 41 | type WorkflowTabId = "run" | "script" | "logs"; |
| @@ -199,7 +204,7 @@ function RunTab({ · 162 unmodified lines | ||
| 199 | 204 | remote: boolean; |
| 200 | 205 | }): ReactElement { |
| 201 | 206 | if (remote) { |
| 202 | − const sessionUrl = run.handles?.sessionUrl; | |
| 207 | + const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); | |
| 203 | 208 | if (sessionUrl === undefined) { |
| 204 | 209 | return <MutedBody text="Running in the cloud" />; |
| 205 | 210 | } |
| @@ -223,11 +228,11 @@ function RunTab({ · 17 unmodified lines | ||
| 223 | 228 | return ( |
| 224 | 229 | <div className="space-y-px"> |
| 225 | 230 | {run.phases.map((phase) => ( |
| 226 | − <div key={phase.index}> | |
| 231 | + <div key={`${run.taskId}:${phase.index}`}> | |
| 227 | 232 | <PhaseHeader phase={phase} /> |
| 228 | 233 | {phase.agents.map((agent) => ( |
| 229 | 234 | <ExpandableAgentRow |
| 230 | − key={agent.index} | |
| 235 | + key={`${run.taskId}:${agent.index}`} | |
| 231 | 236 | agent={agent} |
| 232 | 237 | environmentId={environmentId} |
| 233 | 238 | transcriptDir={transcriptDir} |
| @@ -11,7 +11,13 @@ import { · 10 unmodified lines | ||
| 11 | 11 | workflowRunTitle, |
| 12 | 12 | } from "~/workflow-logic"; |
| 13 | 13 | import { Button } from "../ui/button"; |
| 14 | −import { AgentRowContent, PhaseHeader, WorkflowStatusChip, agentRollupLabel } from "./workflowUi"; | |
| 14 | +import { | |
| 15 | + AgentRowContent, | |
| 16 | + PhaseHeader, | |
| 17 | + WorkflowStatusChip, | |
| 18 | + agentRollupLabel, | |
| 19 | + safeWorkflowSessionUrl, | |
| 20 | +} from "./workflowUi"; | |
| 15 | 21 | |
| 16 | 22 | const MAX_CARD_AGENT_ROWS = 8; |
| 17 | 23 | |
| @@ -89,10 +95,10 @@ export function WorkflowRunCard(props: { · 71 unmodified lines | ||
| 89 | 95 | return null; |
| 90 | 96 | } |
| 91 | 97 | return ( |
| 92 | − <div key={phase.index}> | |
| 98 | + <div key={`${run.taskId}:${phase.index}`}> | |
| 93 | 99 | <PhaseHeader phase={phase} /> |
| 94 | 100 | {rows.map((agent) => ( |
| 95 | − <div key={agent.index} className="px-0.5 py-0.5"> | |
| 101 | + <div key={`${run.taskId}:${agent.index}`} className="px-0.5 py-0.5"> | |
| 96 | 102 | <AgentRowContent agent={agent} /> |
| 97 | 103 | </div> |
| 98 | 104 | ))} |
| @@ -124,7 +130,7 @@ export function WorkflowRunCard(props: { · 25 unmodified lines | ||
| 124 | 130 | } |
| 125 | 131 | |
| 126 | 132 | function RemoteRunBody({ run }: { run: WorkflowRun }): ReactElement { |
| 127 | − const sessionUrl = run.handles?.sessionUrl; | |
| 133 | + const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); | |
| 128 | 134 | if (sessionUrl === undefined) { |
| 129 | 135 | return ( |
| 130 | 136 | <p className="mt-1.5 px-0.5 text-[12px] text-muted-foreground/70 leading-5"> |
| @@ -157,8 +157,27 @@ export function AgentRowContent({ · 156 unmodified lines | ||
| 157 | 157 | // Phase header + rollup helpers |
| 158 | 158 | // --------------------------------------------------------------------------- |
| 159 | 159 | |
| 160 | +/** | |
| 161 | + * Only web URLs may reach an anchor href. The server already filters the | |
| 162 | + * scheme at ingestion; this guards payloads persisted before that filter | |
| 163 | + * (and any other producer) as defense in depth. | |
| 164 | + */ | |
| 165 | +export function safeWorkflowSessionUrl(sessionUrl: string | undefined): string | undefined { | |
| 166 | + if (sessionUrl === undefined) { | |
| 167 | + return undefined; | |
| 168 | + } | |
| 169 | + try { | |
| 170 | + const parsed = new URL(sessionUrl); | |
| 171 | + return parsed.protocol === "https:" || parsed.protocol === "http:" ? sessionUrl : undefined; | |
| 172 | + } catch { | |
| 173 | + return undefined; | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +/** Settled agents (done or error) — the x/y header is a progress counter, | |
| 178 | + * and an errored agent has no work remaining. */ | |
| 160 | 179 | export function phaseDoneCount(phase: WorkflowRunPhase): number { |
| 161 | − return phase.agents.filter((agent) => agent.status === "done").length; | |
| 180 | + return phase.agents.filter((agent) => agent.status === "done" || agent.status === "error").length; | |
| 162 | 181 | } |
| 163 | 182 | |
| 164 | 183 | export function PhaseHeader({ phase }: { phase: WorkflowRunPhase }): ReactElement { |
| @@ -300,13 +300,41 @@ export function collectWorkflowTaskIds( · 299 unmodified lines | ||
| 300 | 300 | return taskIds; |
| 301 | 301 | } |
| 302 | 302 | |
| 303 | +/** | |
| 304 | + * Millisecond timestamps collide, so equal-time activities are ordered by | |
| 305 | + * provider sequence when present, then by lifecycle rank — a task.completed | |
| 306 | + * must never be applied before the task.started that creates its run. | |
| 307 | + */ | |
| 308 | +const WORKFLOW_ACTIVITY_RANK: Record<string, number> = { | |
| 309 | + "task.started": 0, | |
| 310 | + "task.workflow-meta": 1, | |
| 311 | + "task.workflow-updated": 1, | |
| 312 | + "task.completed": 2, | |
| 313 | +}; | |
| 314 | + | |
| 315 | +function compareWorkflowActivityOrder( | |
| 316 | + a: OrchestrationThreadActivity, | |
| 317 | + b: OrchestrationThreadActivity, | |
| 318 | +): number { | |
| 319 | + if (a.sequence !== undefined && b.sequence !== undefined && a.sequence !== b.sequence) { | |
| 320 | + return a.sequence - b.sequence; | |
| 321 | + } | |
| 322 | + const byTime = a.createdAt.localeCompare(b.createdAt); | |
| 323 | + if (byTime !== 0) { | |
| 324 | + return byTime; | |
| 325 | + } | |
| 326 | + const byRank = (WORKFLOW_ACTIVITY_RANK[a.kind] ?? 1) - (WORKFLOW_ACTIVITY_RANK[b.kind] ?? 1); | |
| 327 | + if (byRank !== 0) { | |
| 328 | + return byRank; | |
| 329 | + } | |
| 330 | + return a.id.localeCompare(b.id); | |
| 331 | +} | |
| 332 | + | |
| 303 | 333 | export function deriveWorkflowRuns( |
| 304 | 334 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 305 | 335 | options?: { readonly sessionActive?: boolean | undefined }, |
| 306 | 336 | ): WorkflowRun[] { |
| 307 | − const ordered = [...activities].toSorted( | |
| 308 | − (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), | |
| 309 | − ); | |
| 337 | + const ordered = [...activities].toSorted(compareWorkflowActivityOrder); | |
| 310 | 338 | const runs = new Map<string, MutableWorkflowRun>(); |
| 311 | 339 | |
| 312 | 340 | const ensureRun = (taskId: string, activity: OrchestrationThreadActivity): MutableWorkflowRun => { |
| @@ -25,6 +25,10 @@ export function createWorkflowEnvironmentAtoms<R, E>( · 24 unmodified lines | ||
| 25 | 25 | label: "environment-data:workflow:read-journal", |
| 26 | 26 | tag: WS_METHODS.workflowReadJournal, |
| 27 | 27 | staleTimeMs: 5_000, |
| 28 | + // The journal grows while a run is live and the query only mounts | |
| 29 | + // while the Logs tab is open — poll so new results appear without a | |
| 30 | + // manual refresh. | |
| 31 | + refreshIntervalMs: 4_000, | |
| 28 | 32 | }), |
| 29 | 33 | readAgentTranscript: createEnvironmentRpcCommand(runtime, { |
| 30 | 34 | label: "environment-data:workflow:read-agent-transcript", |