- 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 | if (activity.kind === "task.started") continue; | 281 | if (activity.kind === "task.started") continue; |
| 282 | // Workflow snapshot/meta activities back the desktop workflow card; on | 282 | // Workflow snapshot/meta activities back the desktop workflow card; on |
| 283 | // mobile they would render as ever-mutating raw rows, so skip them — | 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 | if (activity.kind === "task.workflow-updated") continue; | 287 | if (activity.kind === "task.workflow-updated") continue; |
| 286 | if (activity.kind === "task.workflow-meta") continue; | 288 | if (activity.kind === "task.workflow-meta") continue; |
| 287 | if ( | 289 | if ( |
| 288 | (activity.kind === "task.progress" || activity.kind === "task.completed") && | 290 | activity.kind === "task.progress" && |
| 289 | workflowTaskIds.size > 0 && | 291 | workflowTaskIds.size > 0 && |
| 290 | activityBelongsToWorkflow(activity, workflowTaskIds) | 292 | activityBelongsToWorkflow(activity, workflowTaskIds) |
| 291 | ) { | 293 | ) { |
| @@ -3122,7 +3122,8 @@ describe("ProviderRuntimeIngestion", () => { · 3121 unmodified lines | |||
| 3122 | 3122 | ||
| 3123 | const thread = await waitForThread(harness.readModel, (entry) => | 3123 | const thread = await waitForThread(harness.readModel, (entry) => |
| 3124 | entry.activities.some( | 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 | expect(perTick?.kind).toBe("task.progress"); | 3133 | expect(perTick?.kind).toBe("task.progress"); |
| 3133 | 3134 | ||
| 3134 | const snapshot = thread.activities.find( | 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 | expect(snapshot?.kind).toBe("task.workflow-updated"); | 3139 | expect(snapshot?.kind).toBe("task.workflow-updated"); |
| 3138 | const payload = | 3140 | const payload = |
| @@ -3165,11 +3167,13 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines | |||
| 3165 | 3167 | ||
| 3166 | const thread = await waitForThread(harness.readModel, (entry) => | 3168 | const thread = await waitForThread(harness.readModel, (entry) => |
| 3167 | entry.activities.some( | 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 | const meta = thread.activities.find( | 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 | expect(meta?.kind).toBe("task.workflow-meta"); | 3178 | expect(meta?.kind).toBe("task.workflow-meta"); |
| 3175 | const payload = | 3179 | const payload = |
| @@ -3226,7 +3230,8 @@ describe("ProviderRuntimeIngestion", () => { · 50 unmodified lines | |||
| 3226 | const drainedThread = drained.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | 3230 | const drainedThread = drained.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| 3227 | 3231 | ||
| 3228 | const snapshots = drainedThread?.activities.filter( | 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 | expect(snapshots).toHaveLength(1); | 3236 | expect(snapshots).toHaveLength(1); |
| 3232 | const payload = | 3237 | const payload = |
| @@ -167,12 +167,14 @@ function maxCheckpointTurnCount( · 166 unmodified lines | |||
| 167 | * Stable per-task activity ids: workflow snapshot/meta activities are | 167 | * Stable per-task activity ids: workflow snapshot/meta activities are |
| 168 | * upserted (one projection row per run), not appended per progress tick. | 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 | function workflowActivityId(threadId: ThreadId, taskId: string): EventId { | 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 | function workflowMetaActivityId(threadId: ThreadId, taskId: string): EventId { | 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 | function truncateDetail(value: string, limit = 180): string { | 180 | function truncateDetail(value: string, limit = 180): string { |
| @@ -831,8 +831,11 @@ function normalizeWorkflowProgress( · 830 unmodified lines | |||
| 831 | if (!Array.isArray(value) || value.length === 0) { | 831 | if (!Array.isArray(value) || value.length === 0) { |
| 832 | return undefined; | 832 | return undefined; |
| 833 | } | 833 | } |
| 834 | const agents: Array<WorkflowAgentProgressEntry> = []; | 834 | // Last write wins per index: the snapshot may re-emit an agent/phase slot |
| 835 | const phases: Array<WorkflowPhaseProgressEntry> = []; | 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 | const logs: Array<WorkflowLogProgressEntry> = []; | 839 | const logs: Array<WorkflowLogProgressEntry> = []; |
| 837 | for (const raw of value) { | 840 | for (const raw of value) { |
| 838 | if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { | 841 | if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { |
| @@ -842,8 +845,11 @@ function normalizeWorkflowProgress( · 3 unmodified lines | |||
| 842 | switch (entry.type) { | 845 | switch (entry.type) { |
| 843 | case "workflow_agent": { | 846 | case "workflow_agent": { |
| 844 | const agent = normalizeWorkflowAgentEntry(entry); | 847 | const agent = normalizeWorkflowAgentEntry(entry); |
| 845 | if (agent && agents.length < MAX_WORKFLOW_AGENT_ENTRIES) { | 848 | if ( |
| 846 | agents.push(agent); | 849 | agent && |
| 850 | (agentsByIndex.has(agent.index) || agentsByIndex.size < MAX_WORKFLOW_AGENT_ENTRIES) | ||
| 851 | ) { | ||
| 852 | agentsByIndex.set(agent.index, agent); | ||
| 847 | } | 853 | } |
| 848 | break; | 854 | break; |
| 849 | } | 855 | } |
| @@ -853,9 +859,9 @@ function normalizeWorkflowProgress( · 3 unmodified lines | |||
| 853 | if ( | 859 | if ( |
| 854 | index !== undefined && | 860 | index !== undefined && |
| 855 | title !== undefined && | 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 | type: "workflow_phase", | 865 | type: "workflow_phase", |
| 860 | index, | 866 | index, |
| 861 | title, | 867 | title, |
| @@ -880,7 +886,7 @@ function normalizeWorkflowProgress( · 18 unmodified lines | |||
| 880 | // Narration is append-only upstream; keep the newest lines when clipping. | 886 | // Narration is append-only upstream; keep the newest lines when clipping. |
| 881 | const clippedLogs = | 887 | const clippedLogs = |
| 882 | logs.length > MAX_WORKFLOW_LOG_ENTRIES ? logs.slice(-MAX_WORKFLOW_LOG_ENTRIES) : logs; | 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 | return entries.length > 0 ? entries : undefined; | 890 | return entries.length > 0 ? entries : undefined; |
| 885 | } | 891 | } |
| 886 | 892 | ||
| @@ -893,6 +899,21 @@ function readClaudeWorkflowProgress( · 6 unmodified lines | |||
| 893 | return normalizeWorkflowProgress(raw); | 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 | function normalizeWorkflowRunHandles( | 917 | function normalizeWorkflowRunHandles( |
| 897 | toolUseResult: Record<string, unknown>, | 918 | toolUseResult: Record<string, unknown>, |
| 898 | ): WorkflowRunHandles | undefined { | 919 | ): WorkflowRunHandles | undefined { |
| @@ -917,8 +938,8 @@ function normalizeWorkflowRunHandles( · 18 unmodified lines | |||
| 917 | ...(workflowString(toolUseResult.transcriptDir) !== undefined | 938 | ...(workflowString(toolUseResult.transcriptDir) !== undefined |
| 918 | ? { transcriptDir: workflowString(toolUseResult.transcriptDir) } | 939 | ? { transcriptDir: workflowString(toolUseResult.transcriptDir) } |
| 919 | : {}), | 940 | : {}), |
| 920 | ...(workflowString(toolUseResult.sessionUrl) !== undefined | 941 | ...(workflowHttpUrl(toolUseResult.sessionUrl) !== undefined |
| 921 | ? { sessionUrl: workflowString(toolUseResult.sessionUrl) } | 942 | ? { sessionUrl: workflowHttpUrl(toolUseResult.sessionUrl) } |
| 922 | : {}), | 943 | : {}), |
| 923 | ...(workflowString(toolUseResult.warning) !== undefined | 944 | ...(workflowString(toolUseResult.warning) !== undefined |
| 924 | ? { warning: workflowString(toolUseResult.warning) } | 945 | ? { warning: workflowString(toolUseResult.warning) } |
| @@ -30,7 +30,12 @@ import { · 29 unmodified lines | |||
| 30 | workflowRunTitle, | 30 | workflowRunTitle, |
| 31 | } from "~/workflow-logic"; | 31 | } from "~/workflow-logic"; |
| 32 | import { Button } from "../ui/button"; | 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 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 39 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 35 | 40 | ||
| 36 | type WorkflowTabId = "run" | "script" | "logs"; | 41 | type WorkflowTabId = "run" | "script" | "logs"; |
| @@ -199,7 +204,7 @@ function RunTab({ · 162 unmodified lines | |||
| 199 | remote: boolean; | 204 | remote: boolean; |
| 200 | }): ReactElement { | 205 | }): ReactElement { |
| 201 | if (remote) { | 206 | if (remote) { |
| 202 | const sessionUrl = run.handles?.sessionUrl; | 207 | const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); |
| 203 | if (sessionUrl === undefined) { | 208 | if (sessionUrl === undefined) { |
| 204 | return <MutedBody text="Running in the cloud" />; | 209 | return <MutedBody text="Running in the cloud" />; |
| 205 | } | 210 | } |
| @@ -223,11 +228,11 @@ function RunTab({ · 17 unmodified lines | |||
| 223 | return ( | 228 | return ( |
| 224 | <div className="space-y-px"> | 229 | <div className="space-y-px"> |
| 225 | {run.phases.map((phase) => ( | 230 | {run.phases.map((phase) => ( |
| 226 | <div key={phase.index}> | 231 | <div key={`${run.taskId}:${phase.index}`}> |
| 227 | <PhaseHeader phase={phase} /> | 232 | <PhaseHeader phase={phase} /> |
| 228 | {phase.agents.map((agent) => ( | 233 | {phase.agents.map((agent) => ( |
| 229 | <ExpandableAgentRow | 234 | <ExpandableAgentRow |
| 230 | key={agent.index} | 235 | key={`${run.taskId}:${agent.index}`} |
| 231 | agent={agent} | 236 | agent={agent} |
| 232 | environmentId={environmentId} | 237 | environmentId={environmentId} |
| 233 | transcriptDir={transcriptDir} | 238 | transcriptDir={transcriptDir} |
| @@ -11,7 +11,13 @@ import { · 10 unmodified lines | |||
| 11 | workflowRunTitle, | 11 | workflowRunTitle, |
| 12 | } from "~/workflow-logic"; | 12 | } from "~/workflow-logic"; |
| 13 | import { Button } from "../ui/button"; | 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 | const MAX_CARD_AGENT_ROWS = 8; | 22 | const MAX_CARD_AGENT_ROWS = 8; |
| 17 | 23 | ||
| @@ -89,10 +95,10 @@ export function WorkflowRunCard(props: { · 71 unmodified lines | |||
| 89 | return null; | 95 | return null; |
| 90 | } | 96 | } |
| 91 | return ( | 97 | return ( |
| 92 | <div key={phase.index}> | 98 | <div key={`${run.taskId}:${phase.index}`}> |
| 93 | <PhaseHeader phase={phase} /> | 99 | <PhaseHeader phase={phase} /> |
| 94 | {rows.map((agent) => ( | 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 | <AgentRowContent agent={agent} /> | 102 | <AgentRowContent agent={agent} /> |
| 97 | </div> | 103 | </div> |
| 98 | ))} | 104 | ))} |
| @@ -124,7 +130,7 @@ export function WorkflowRunCard(props: { · 25 unmodified lines | |||
| 124 | } | 130 | } |
| 125 | 131 | ||
| 126 | function RemoteRunBody({ run }: { run: WorkflowRun }): ReactElement { | 132 | function RemoteRunBody({ run }: { run: WorkflowRun }): ReactElement { |
| 127 | const sessionUrl = run.handles?.sessionUrl; | 133 | const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); |
| 128 | if (sessionUrl === undefined) { | 134 | if (sessionUrl === undefined) { |
| 129 | return ( | 135 | return ( |
| 130 | <p className="mt-1.5 px-0.5 text-[12px] text-muted-foreground/70 leading-5"> | 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 | // Phase header + rollup helpers | 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 | export function phaseDoneCount(phase: WorkflowRunPhase): number { | 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 | export function PhaseHeader({ phase }: { phase: WorkflowRunPhase }): ReactElement { | 183 | export function PhaseHeader({ phase }: { phase: WorkflowRunPhase }): ReactElement { |
| @@ -300,13 +300,41 @@ export function collectWorkflowTaskIds( · 299 unmodified lines | |||
| 300 | return taskIds; | 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 | export function deriveWorkflowRuns( | 333 | export function deriveWorkflowRuns( |
| 304 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 334 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 305 | options?: { readonly sessionActive?: boolean | undefined }, | 335 | options?: { readonly sessionActive?: boolean | undefined }, |
| 306 | ): WorkflowRun[] { | 336 | ): WorkflowRun[] { |
| 307 | const ordered = [...activities].toSorted( | 337 | const ordered = [...activities].toSorted(compareWorkflowActivityOrder); |
| 308 | (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), | ||
| 309 | ); | ||
| 310 | const runs = new Map<string, MutableWorkflowRun>(); | 338 | const runs = new Map<string, MutableWorkflowRun>(); |
| 311 | 339 | ||
| 312 | const ensureRun = (taskId: string, activity: OrchestrationThreadActivity): MutableWorkflowRun => { | 340 | const ensureRun = (taskId: string, activity: OrchestrationThreadActivity): MutableWorkflowRun => { |
| @@ -25,6 +25,10 @@ export function createWorkflowEnvironmentAtoms<R, E>( · 24 unmodified lines | |||
| 25 | label: "environment-data:workflow:read-journal", | 25 | label: "environment-data:workflow:read-journal", |
| 26 | tag: WS_METHODS.workflowReadJournal, | 26 | tag: WS_METHODS.workflowReadJournal, |
| 27 | staleTimeMs: 5_000, | 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 | readAgentTranscript: createEnvironmentRpcCommand(runtime, { | 33 | readAgentTranscript: createEnvironmentRpcCommand(runtime, { |
| 30 | label: "environment-data:workflow:read-agent-transcript", | 34 | label: "environment-data:workflow:read-agent-transcript", |