diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8e574c8..705bcbd3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Global live update pause: disable automatic query refreshes on browser focus and reconnect, preventing paused workflow detail pages from re-fetching wait data outside the configured refresh interval. [PR #584](https://github.com/riverqueue/riverui/pull/584).
+- Job args: preserve large numeric JSON values exactly when displaying and copying args, while keeping object keys sorted. [Fixes #593](https://github.com/riverqueue/riverui/issues/593).
## [v0.16.0] - 2026-05-19
diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go
index 22cd326f..e15f1a09 100644
--- a/handler_api_endpoint.go
+++ b/handler_api_endpoint.go
@@ -943,20 +943,20 @@ type PartitionConfig struct {
}
type RiverJobMinimal struct {
- ID int64 `json:"id"`
- Args json.RawMessage `json:"args"`
- Attempt int `json:"attempt"`
- AttemptedAt *time.Time `json:"attempted_at"`
- AttemptedBy []string `json:"attempted_by"`
- CreatedAt time.Time `json:"created_at"`
- FinalizedAt *time.Time `json:"finalized_at"`
- Kind string `json:"kind"`
- MaxAttempts int `json:"max_attempts"`
- Priority int `json:"priority"`
- Queue string `json:"queue"`
- ScheduledAt time.Time `json:"scheduled_at"`
- State string `json:"state"`
- Tags []string `json:"tags"`
+ ID int64 `json:"id"`
+ Args string `json:"args"`
+ Attempt int `json:"attempt"`
+ AttemptedAt *time.Time `json:"attempted_at"`
+ AttemptedBy []string `json:"attempted_by"`
+ CreatedAt time.Time `json:"created_at"`
+ FinalizedAt *time.Time `json:"finalized_at"`
+ Kind string `json:"kind"`
+ MaxAttempts int `json:"max_attempts"`
+ Priority int `json:"priority"`
+ Queue string `json:"queue"`
+ ScheduledAt time.Time `json:"scheduled_at"`
+ State string `json:"state"`
+ Tags []string `json:"tags"`
}
type RiverJob struct {
@@ -988,7 +988,7 @@ func riverJobToSerializableJobMinimal(riverJob *rivertype.JobRow) *RiverJobMinim
return &RiverJobMinimal{
ID: riverJob.ID,
- Args: riverJob.EncodedArgs,
+ Args: string(riverJob.EncodedArgs),
Attempt: riverJob.Attempt,
AttemptedAt: riverJob.AttemptedAt,
AttemptedBy: attemptedBy,
diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go
index b555f54d..dd0a8622 100644
--- a/handler_api_endpoint_test.go
+++ b/handler_api_endpoint_test.go
@@ -2,6 +2,7 @@ package riverui
import (
"context"
+ "encoding/json"
"log/slog"
"testing"
"time"
@@ -438,11 +439,25 @@ func TestAPIHandlerJobGet(t *testing.T) {
endpoint, bundle := setupEndpoint(ctx, t, newJobGetEndpoint)
- job := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{})
+ encodedArgs := []byte(`{"id":1970670598291982290,"max":9223372036854775807}`)
+ job := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
+ EncodedArgs: encodedArgs,
+ })
resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobGetRequest{JobID: job.ID})
require.NoError(t, err)
require.Equal(t, job.ID, resp.ID)
+ expectedArgs := string(job.EncodedArgs)
+ require.Equal(t, expectedArgs, resp.Args)
+ require.Contains(t, resp.Args, "1970670598291982290")
+ require.Contains(t, resp.Args, "9223372036854775807")
+
+ var wireResp struct {
+ Args string `json:"args"`
+ }
+ require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp))
+ require.Equal(t, expectedArgs, wireResp.Args)
+ require.True(t, json.Valid([]byte(wireResp.Args)))
})
t.Run("NotFound", func(t *testing.T) {
@@ -466,9 +481,10 @@ func TestAPIHandlerJobList(t *testing.T) {
endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint)
job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
- Kind: ptrutil.Ptr("kind1"),
- Queue: ptrutil.Ptr("queue1"),
- State: ptrutil.Ptr(rivertype.JobStateRunning),
+ EncodedArgs: []byte(`{"id":1970670598291982290}`),
+ Kind: ptrutil.Ptr("kind1"),
+ Queue: ptrutil.Ptr("queue1"),
+ State: ptrutil.Ptr(rivertype.JobStateRunning),
})
job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
Kind: ptrutil.Ptr("kind2"),
@@ -480,7 +496,18 @@ func TestAPIHandlerJobList(t *testing.T) {
require.NoError(t, err)
require.Len(t, resp.Data, 2)
require.Equal(t, job1.ID, resp.Data[0].ID)
+ expectedArgs := string(job1.EncodedArgs)
+ require.Equal(t, expectedArgs, resp.Data[0].Args)
+ require.Contains(t, resp.Data[0].Args, "1970670598291982290")
require.Equal(t, job2.ID, resp.Data[1].ID)
+
+ var wireResp struct {
+ Data []struct {
+ Args string `json:"args"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp))
+ require.Equal(t, expectedArgs, wireResp.Data[0].Args)
})
t.Run("FilterByIDs", func(t *testing.T) {
diff --git a/riverproui/internal/prohandler/pro_handler_api_endpoints.go b/riverproui/internal/prohandler/pro_handler_api_endpoints.go
index b4794122..375e1412 100644
--- a/riverproui/internal/prohandler/pro_handler_api_endpoints.go
+++ b/riverproui/internal/prohandler/pro_handler_api_endpoints.go
@@ -825,20 +825,20 @@ func (a *workflowRetryEndpoint[TTx]) Execute(ctx context.Context, req *workflowR
}
type riverJobMinimal struct {
- ID int64 `json:"id"`
- Args json.RawMessage `json:"args"`
- Attempt int `json:"attempt"`
- AttemptedAt *time.Time `json:"attempted_at"`
- AttemptedBy []string `json:"attempted_by"`
- CreatedAt time.Time `json:"created_at"`
- FinalizedAt *time.Time `json:"finalized_at"`
- Kind string `json:"kind"`
- MaxAttempts int `json:"max_attempts"`
- Priority int `json:"priority"`
- Queue string `json:"queue"`
- ScheduledAt time.Time `json:"scheduled_at"`
- State string `json:"state"`
- Tags []string `json:"tags"`
+ ID int64 `json:"id"`
+ Args string `json:"args"`
+ Attempt int `json:"attempt"`
+ AttemptedAt *time.Time `json:"attempted_at"`
+ AttemptedBy []string `json:"attempted_by"`
+ CreatedAt time.Time `json:"created_at"`
+ FinalizedAt *time.Time `json:"finalized_at"`
+ Kind string `json:"kind"`
+ MaxAttempts int `json:"max_attempts"`
+ Priority int `json:"priority"`
+ Queue string `json:"queue"`
+ ScheduledAt time.Time `json:"scheduled_at"`
+ State string `json:"state"`
+ Tags []string `json:"tags"`
}
func internalJobToJobMinimal(internal *rivertype.JobRow) *riverJobMinimal {
@@ -849,7 +849,7 @@ func internalJobToJobMinimal(internal *rivertype.JobRow) *riverJobMinimal {
return &riverJobMinimal{
ID: internal.ID,
- Args: internal.EncodedArgs,
+ Args: string(internal.EncodedArgs),
Attempt: internal.Attempt,
AttemptedAt: internal.AttemptedAt,
AttemptedBy: attemptedBy,
diff --git a/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go b/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go
index f2a85511..1c4c8ab8 100644
--- a/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go
+++ b/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go
@@ -177,6 +177,7 @@ func TestProAPIHandlerWorkflowGet(t *testing.T) {
}
dependencyJob := jobWithSchema(ctx, t, bundle.exec, bundle.schema, &testfactory.JobOpts{
+ EncodedArgs: []byte(`{"id":1970670598291982290,"max":9223372036854775807}`),
FinalizedAt: ptrutil.Ptr(now.Add(-2 * time.Minute)),
Metadata: workflowMetadata("wf_get", "collect_inputs", nil),
State: ptrutil.Ptr(rivertype.JobStateCompleted),
@@ -207,6 +208,27 @@ func TestProAPIHandlerWorkflowGet(t *testing.T) {
require.Equal(t, workflowTaskWaitReasonNone, taskByID[dependencyJob.ID].WaitReason)
require.Nil(t, taskByID[dependencyJob.ID].Wait)
+ expectedArgs := string(dependencyJob.EncodedArgs)
+ require.Equal(t, expectedArgs, taskByID[dependencyJob.ID].Args)
+ require.Contains(t, taskByID[dependencyJob.ID].Args, "1970670598291982290")
+ require.Contains(t, taskByID[dependencyJob.ID].Args, "9223372036854775807")
+
+ var wireResp struct {
+ Tasks []struct {
+ Args string `json:"args"`
+ ID int64 `json:"id"`
+ } `json:"tasks"`
+ }
+ require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp))
+ var dependencyArgs string
+ for _, task := range wireResp.Tasks {
+ if task.ID == dependencyJob.ID {
+ dependencyArgs = task.Args
+ break
+ }
+ }
+ require.Equal(t, expectedArgs, dependencyArgs)
+ require.True(t, json.Valid([]byte(dependencyArgs)))
waitingTask := taskByID[waitingJob.ID]
require.NotNil(t, waitingTask)
diff --git a/src/components/JSONTextView.test.tsx b/src/components/JSONTextView.test.tsx
new file mode 100644
index 00000000..42eb2d5c
--- /dev/null
+++ b/src/components/JSONTextView.test.tsx
@@ -0,0 +1,52 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import toast from "react-hot-toast";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import JSONTextView from "./JSONTextView";
+
+Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockImplementation(() => Promise.resolve()),
+ },
+});
+
+vi.mock("react-hot-toast", () => ({
+ default: {
+ custom: vi.fn(),
+ },
+}));
+
+describe("JSONTextView", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders and copies sorted JSON without rounding large numbers", async () => {
+ const rawJSON = '{"z":2,"id":1970670598291982290,"a":1}';
+ const formattedJSON = `{
+ "a": 1,
+ "id": 1970670598291982290,
+ "z": 2
+}`;
+
+ render();
+
+ expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("text-copy-button"));
+ });
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(formattedJSON);
+
+ await waitFor(() => {
+ expect(toast.custom).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/JSONTextView.tsx b/src/components/JSONTextView.tsx
new file mode 100644
index 00000000..17894556
--- /dev/null
+++ b/src/components/JSONTextView.tsx
@@ -0,0 +1,28 @@
+import { useMemo } from "react";
+
+import PlaintextPanel from "@/components/PlaintextPanel";
+import { formatJSONText } from "@/utils/jsonText";
+
+type JSONTextViewProps = {
+ className?: string;
+ copyTitle?: string;
+ text: string;
+};
+
+export default function JSONTextView({
+ className,
+ copyTitle = "JSON",
+ text,
+}: JSONTextViewProps) {
+ const formattedText = useMemo(() => formatJSONText(text), [text]);
+
+ return (
+
+ );
+}
diff --git a/src/components/JobDetail.test.tsx b/src/components/JobDetail.test.tsx
index 9e1aa95b..af1c14b5 100644
--- a/src/components/JobDetail.test.tsx
+++ b/src/components/JobDetail.test.tsx
@@ -73,3 +73,14 @@ test("cancels job delete confirmation", async () => {
).not.toBeInTheDocument();
});
});
+
+test("renders raw job args without rounding large numbers", () => {
+ const argsRaw = '{"id":1970670598291982290}';
+ const job = jobFactory.completed().build({ argsRaw });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument();
+});
diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx
index 693abc4f..3040348e 100644
--- a/src/components/JobDetail.tsx
+++ b/src/components/JobDetail.tsx
@@ -3,6 +3,7 @@ import ButtonForGroup from "@components/ButtonForGroup";
import ConfirmationDialog from "@components/ConfirmationDialog";
import JobAttempts from "@components/JobAttempts";
import JobTimeline from "@components/JobTimeline";
+import JSONTextView from "@components/JSONTextView";
import JSONView from "@components/JSONView";
import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
import TopNavTitleOnly from "@components/TopNavTitleOnly";
@@ -164,7 +165,7 @@ export default function JobDetail({
Args
-
+
diff --git a/src/components/JobList.test.tsx b/src/components/JobList.test.tsx
index d8e202ce..57a02745 100644
--- a/src/components/JobList.test.tsx
+++ b/src/components/JobList.test.tsx
@@ -61,7 +61,9 @@ describe("JobList", () => {
});
it("shows job args by default", () => {
- const job = jobMinimalFactory.build();
+ const job = jobMinimalFactory.build({
+ argsRaw: '{"z":2,"id":1970670598291982290,"a":1}',
+ });
const features = createFeatures({
jobListHideArgsByDefault: false,
});
@@ -87,7 +89,9 @@ describe("JobList", () => {
,
);
- expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument();
+ expect(
+ screen.getByText('{"a":1,"id":1970670598291982290,"z":2}'),
+ ).toBeInTheDocument();
});
it("hides job args when jobListHideArgsByDefault is true", () => {
@@ -117,9 +121,7 @@ describe("JobList", () => {
,
);
- expect(
- screen.queryByText(JSON.stringify(job.args)),
- ).not.toBeInTheDocument();
+ expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument();
});
it("shows job args when user overrides default hide setting", () => {
@@ -150,7 +152,7 @@ describe("JobList", () => {
);
// Even though server default is to hide, user setting should make them visible
- expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument();
+ expect(screen.getByText(job.argsRaw)).toBeInTheDocument();
});
it("hides job args when user overrides default show setting", () => {
@@ -181,9 +183,7 @@ describe("JobList", () => {
);
// Even though server default is to show, user setting should hide them
- expect(
- screen.queryByText(JSON.stringify(job.args)),
- ).not.toBeInTheDocument();
+ expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument();
});
it("requires confirmation before deleting selected jobs", async () => {
diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx
index 820004fd..411e4353 100644
--- a/src/components/JobList.tsx
+++ b/src/components/JobList.tsx
@@ -29,6 +29,7 @@ import {
JobStateFilterItem,
jobStateFilterItems,
} from "@utils/jobStateFilterItems";
+import { compactJSONText } from "@utils/jsonText";
import { classNames } from "@utils/style";
import React, {
FormEvent,
@@ -89,6 +90,10 @@ const JobListItem = ({
onChangeSelect,
}: JobListItemProps) => {
const showArgs = !hideArgs;
+ const argsPreview = useMemo(
+ () => compactJSONText(job.argsRaw),
+ [job.argsRaw],
+ );
return (
@@ -134,7 +139,7 @@ const JobListItem = ({
{showArgs && (
- {JSON.stringify(job.args)}
+ {argsPreview}
)}
diff --git a/src/components/WorkflowDetail.test.tsx b/src/components/WorkflowDetail.test.tsx
index 444cbc83..def686b7 100644
--- a/src/components/WorkflowDetail.test.tsx
+++ b/src/components/WorkflowDetail.test.tsx
@@ -219,6 +219,24 @@ describe("WorkflowDetail wait inspector", () => {
expect(screen.getByText("Not waiting")).toBeInTheDocument();
});
+ it("renders selected task args without rounding large numbers", async () => {
+ const argsRaw = '{"id":1970670598291982290}';
+ const task = workflowJobFactory.build({
+ argsRaw,
+ id: 1,
+ state: JobState.Completed,
+ task: "send_response",
+ waitReason: "none",
+ });
+
+ await renderWorkflowDetail(
+ { id: "wf-test-args", name: "Workflow Test", tasks: [task] },
+ task.id,
+ );
+
+ expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument();
+ });
+
it("updates the lower inspector when the selected task changes", async () => {
const firstTask = workflowJobFactory.build({
id: 1,
diff --git a/src/components/WorkflowDetail.tsx b/src/components/WorkflowDetail.tsx
index 210b185e..3dfeaf72 100644
--- a/src/components/WorkflowDetail.tsx
+++ b/src/components/WorkflowDetail.tsx
@@ -2,6 +2,7 @@ import ButtonForGroup from "@components/ButtonForGroup";
import { DurationCompact } from "@components/DurationCompact";
import { Subheading } from "@components/Heading";
import { RunningSpinnerIcon } from "@components/icons/jobStateIcons";
+import JSONTextView from "@components/JSONTextView";
import JSONView from "@components/JSONView";
import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
import RetryWorkflowDialog from "@components/RetryWorkflowDialog";
@@ -335,7 +336,7 @@ const SelectedJobDetails = ({
Args
-
+
diff --git a/src/services/jobs.test.ts b/src/services/jobs.test.ts
new file mode 100644
index 00000000..e57f0887
--- /dev/null
+++ b/src/services/jobs.test.ts
@@ -0,0 +1,96 @@
+import { getJob, getJobKey, listJobs, listJobsKey } from "@services/jobs";
+import { JobState } from "@services/types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+type APIAttemptErrorForTest = {
+ at: string;
+ attempt: number;
+ error: string;
+ trace: string;
+};
+
+type APIJobForTest = {
+ args: string;
+ attempt: number;
+ attempted_by: string[];
+ created_at: string;
+ errors: APIAttemptErrorForTest[];
+ finalized_at: undefined;
+ id: number;
+ kind: string;
+ max_attempts: number;
+ metadata: object;
+ priority: number;
+ queue: string;
+ scheduled_at: string;
+ state: JobState;
+ tags: string[];
+};
+
+const apiJob = (overrides: Partial
= {}): APIJobForTest => ({
+ args: '{"id":1970670598291982290}',
+ attempt: 0,
+ attempted_by: [],
+ created_at: "2026-04-21T17:57:00Z",
+ errors: [],
+ finalized_at: undefined,
+ id: 123,
+ kind: "RowOperation",
+ max_attempts: 25,
+ metadata: {},
+ priority: 1,
+ queue: "default",
+ scheduled_at: "2026-04-21T17:57:00Z",
+ state: JobState.Available,
+ tags: [],
+ ...overrides,
+});
+
+describe("jobs service", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ });
+
+ it("preserves list job args as raw JSON text", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ data: [apiJob()] }), {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ }),
+ );
+
+ const jobs = await listJobs({
+ client: undefined as never,
+ meta: undefined,
+ queryKey: listJobsKey({ limit: 10 }),
+ signal: new AbortController().signal,
+ });
+
+ expect(jobs[0]?.argsRaw).toBe('{"id":1970670598291982290}');
+ });
+
+ it("preserves job detail args as raw JSON text", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify(apiJob()), {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ }),
+ );
+
+ const job = await getJob({
+ client: undefined as never,
+ meta: undefined,
+ queryKey: getJobKey(123n),
+ signal: new AbortController().signal,
+ });
+
+ expect(job.argsRaw).toBe('{"id":1970670598291982290}');
+ });
+});
diff --git a/src/services/jobs.ts b/src/services/jobs.ts
index caddc780..09796c78 100644
--- a/src/services/jobs.ts
+++ b/src/services/jobs.ts
@@ -17,18 +17,13 @@ export type AttemptError = {
};
export type Job = {
- [Key in keyof JobFromAPI as SnakeToCamelCase]: Key extends
- | StringEndingWithUnderscoreAt
- | undefined
- ? Date
- : JobFromAPI[Key] extends AttemptErrorFromAPI[]
- ? AttemptError[]
- : JobFromAPI[Key];
-};
+ errors: AttemptError[];
+ logs: JobLogs;
+ metadata: KnownMetadata | object;
+} & JobMinimal;
export type JobFromAPI = {
errors: AttemptErrorFromAPI[];
- logs: JobLogs;
metadata: KnownMetadata | object;
} & JobMinimalFromAPI;
@@ -43,18 +38,25 @@ export type JobLogs = {
};
export type JobMinimal = {
- [Key in keyof JobMinimalFromAPI as SnakeToCamelCase]: Key extends
+ [Key in keyof Omit<
+ JobMinimalFromAPI,
+ "args"
+ > as SnakeToCamelCase]: Key extends
| StringEndingWithUnderscoreAt
| undefined
? Date
: JobMinimalFromAPI[Key];
+} & {
+ argsRaw: string;
};
// Represents a Job as received from the API. This just like Job, except with
// string dates instead of Date objects and keys as snake_case instead of
// camelCase.
export type JobMinimalFromAPI = {
- args: object;
+ // JSON text as returned by River. Keep this unparsed to preserve large
+ // integer values exactly for display and copy.
+ args: string;
attempt: number;
attempted_at?: string;
attempted_by: string[];
@@ -100,7 +102,7 @@ type RiverJobLogEntry = {
export const apiJobMinimalToJobMinimal = (
job: JobMinimalFromAPI,
): JobMinimal => ({
- args: job.args,
+ argsRaw: job.args,
attempt: job.attempt,
attemptedAt: job.attempted_at ? new Date(job.attempted_at) : undefined,
attemptedBy: job.attempted_by,
diff --git a/src/services/workflows.test.ts b/src/services/workflows.test.ts
index 9844c582..4dd1d3bf 100644
--- a/src/services/workflows.test.ts
+++ b/src/services/workflows.test.ts
@@ -1,4 +1,7 @@
+import { JobState } from "@services/types";
import {
+ getWorkflow,
+ getWorkflowKey,
getWorkflowTaskSignals,
getWorkflowTaskWaitDiagnostics,
} from "@services/workflows";
@@ -10,6 +13,65 @@ describe("workflows service", () => {
document.body.innerHTML = "";
});
+ it("preserves workflow task args as raw JSON text", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: "wf-args",
+ name: "Workflow Args",
+ tasks: [
+ {
+ args: '{"id":1970670598291982290}',
+ attempt: 0,
+ attempted_by: [],
+ created_at: "2026-04-21T17:57:00Z",
+ deps: [],
+ errors: [],
+ id: 123,
+ ignore_cancelled_deps: false,
+ ignore_deleted_deps: false,
+ ignore_discarded_deps: false,
+ kind: "RowOperation",
+ max_attempts: 25,
+ metadata: {
+ deps: [],
+ task: "row_operation",
+ workflow_id: "wf-args",
+ workflow_staged_at: "2026-04-21T17:57:00Z",
+ },
+ name: "row_operation",
+ priority: 1,
+ queue: "default",
+ scheduled_at: "2026-04-21T17:57:00Z",
+ state: JobState.Available,
+ tags: [],
+ wait_reason: "none",
+ workflow_id: "wf-args",
+ },
+ ],
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ },
+ ),
+ );
+
+ const workflow = await getWorkflow({
+ client: undefined as never,
+ direction: "forward",
+ meta: undefined,
+ pageParam: undefined,
+ queryKey: getWorkflowKey("wf-args"),
+ signal: new AbortController().signal,
+ });
+
+ expect(workflow.tasks[0]?.argsRaw).toBe('{"id":1970670598291982290}');
+ });
+
it("parses task signal dates, ids, cursor ids, evidence, and scope", async () => {
document.body.innerHTML =
'';
diff --git a/src/test/factories/job.ts b/src/test/factories/job.ts
index efd797a6..c40e51b5 100644
--- a/src/test/factories/job.ts
+++ b/src/test/factories/job.ts
@@ -266,7 +266,7 @@ export const jobFactory = JobFactory.define(({ sequence }) => {
const createdAt = faker.date.recent({ days: 0.001 });
return {
- args: { baz: 1, foo: "bar" },
+ argsRaw: '{"baz":1,"foo":"bar"}',
attempt: 0,
attemptedAt: undefined,
attemptedBy: [],
diff --git a/src/test/factories/workflowJob.ts b/src/test/factories/workflowJob.ts
index 0f8ce16e..d5b59bb1 100644
--- a/src/test/factories/workflowJob.ts
+++ b/src/test/factories/workflowJob.ts
@@ -13,6 +13,7 @@ const defaultWorkflowStagedAt = new Date("2025-01-01T00:00:00.000Z");
const defaultWorkflowID = "wf-1";
type WorkflowJobFactoryParams = {
+ argsRaw?: string;
attemptedAt?: Date;
createdAt?: Date;
deps?: string[];
@@ -81,6 +82,7 @@ export const workflowJobFactory = Factory.define<
const baseJob = jobFactory.build({
...(attemptedAt ? { attemptedAt } : {}),
+ ...(params.argsRaw ? { argsRaw: params.argsRaw } : {}),
createdAt,
...(finalizedAt ? { finalizedAt } : {}),
id,
diff --git a/src/utils/jsonText.test.ts b/src/utils/jsonText.test.ts
new file mode 100644
index 00000000..c9b15ff1
--- /dev/null
+++ b/src/utils/jsonText.test.ts
@@ -0,0 +1,27 @@
+import { compactJSONText, formatJSONText } from "@utils/jsonText";
+import { describe, expect, it } from "vitest";
+
+describe("jsonText", () => {
+ it("sorts object keys without parsing large number tokens", () => {
+ const rawJSON =
+ '{"z":2,"id":1970670598291982290,"nested":{"b":9223372036854775807,"a":1}}';
+
+ expect(compactJSONText(rawJSON)).toBe(
+ '{"id":1970670598291982290,"nested":{"a":1,"b":9223372036854775807},"z":2}',
+ );
+ });
+
+ it("pretty-prints sorted JSON without rounding numbers", () => {
+ const rawJSON = '{"z":2,"id":1970670598291982290,"a":1}';
+
+ expect(formatJSONText(rawJSON)).toBe(`{
+ "a": 1,
+ "id": 1970670598291982290,
+ "z": 2
+}`);
+ });
+
+ it("falls back to original text when args are not valid JSON", () => {
+ expect(formatJSONText("{not valid")).toBe("{not valid");
+ });
+});
diff --git a/src/utils/jsonText.ts b/src/utils/jsonText.ts
new file mode 100644
index 00000000..b343f426
--- /dev/null
+++ b/src/utils/jsonText.ts
@@ -0,0 +1,268 @@
+type JSONTextNode =
+ | { entries: JSONTextObjectEntry[]; kind: "object" }
+ | { kind: "array"; values: JSONTextNode[] }
+ | { kind: "literal"; value: "false" | "null" | "true" }
+ | { kind: "number"; raw: string }
+ | { kind: "string"; value: string };
+
+type JSONTextObjectEntry = {
+ key: string;
+ value: JSONTextNode;
+};
+
+class JSONTextParser {
+ private position = 0;
+
+ constructor(private readonly text: string) {}
+
+ parse(): JSONTextNode | undefined {
+ try {
+ const value = this.parseValue();
+ this.skipWhitespace();
+ return this.position === this.text.length ? value : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private expect(char: string) {
+ if (this.text[this.position] !== char) {
+ throw new Error(`expected ${char}`);
+ }
+ this.position += 1;
+ }
+
+ private parseArray(): JSONTextNode {
+ this.expect("[");
+ this.skipWhitespace();
+
+ const values: JSONTextNode[] = [];
+ if (this.text[this.position] === "]") {
+ this.position += 1;
+ return { kind: "array", values };
+ }
+
+ while (true) {
+ values.push(this.parseValue());
+ this.skipWhitespace();
+
+ if (this.text[this.position] === "]") {
+ this.position += 1;
+ return { kind: "array", values };
+ }
+
+ this.expect(",");
+ this.skipWhitespace();
+ }
+ }
+
+ private parseLiteral(literal: "false" | "null" | "true"): JSONTextNode {
+ if (!this.text.startsWith(literal, this.position)) {
+ throw new Error(`expected ${literal}`);
+ }
+
+ this.position += literal.length;
+ return { kind: "literal", value: literal };
+ }
+
+ private parseNumber(): JSONTextNode {
+ const start = this.position;
+
+ if (this.text[this.position] === "-") {
+ this.position += 1;
+ }
+
+ if (this.text[this.position] === "0") {
+ this.position += 1;
+ } else if (isDigitOneToNine(this.text[this.position])) {
+ this.position += 1;
+ while (isDigit(this.text[this.position])) {
+ this.position += 1;
+ }
+ } else {
+ throw new Error("expected number");
+ }
+
+ if (this.text[this.position] === ".") {
+ this.position += 1;
+ if (!isDigit(this.text[this.position])) {
+ throw new Error("expected fractional digit");
+ }
+ while (isDigit(this.text[this.position])) {
+ this.position += 1;
+ }
+ }
+
+ if (this.text[this.position] === "e" || this.text[this.position] === "E") {
+ this.position += 1;
+ if (
+ this.text[this.position] === "+" ||
+ this.text[this.position] === "-"
+ ) {
+ this.position += 1;
+ }
+ if (!isDigit(this.text[this.position])) {
+ throw new Error("expected exponent digit");
+ }
+ while (isDigit(this.text[this.position])) {
+ this.position += 1;
+ }
+ }
+
+ return { kind: "number", raw: this.text.slice(start, this.position) };
+ }
+
+ private parseObject(): JSONTextNode {
+ this.expect("{");
+ this.skipWhitespace();
+
+ const entries: JSONTextObjectEntry[] = [];
+ if (this.text[this.position] === "}") {
+ this.position += 1;
+ return { entries, kind: "object" };
+ }
+
+ while (true) {
+ const key = this.parseStringValue();
+ this.skipWhitespace();
+ this.expect(":");
+ const value = this.parseValue();
+ entries.push({ key, value });
+ this.skipWhitespace();
+
+ if (this.text[this.position] === "}") {
+ this.position += 1;
+ return { entries, kind: "object" };
+ }
+
+ this.expect(",");
+ this.skipWhitespace();
+ }
+ }
+
+ private parseString(): JSONTextNode {
+ return { kind: "string", value: this.parseStringValue() };
+ }
+
+ private parseStringValue(): string {
+ const start = this.position;
+ this.expect('"');
+
+ while (this.position < this.text.length) {
+ const char = this.text[this.position];
+ if (char === '"') {
+ this.position += 1;
+ const parsed = JSON.parse(this.text.slice(start, this.position));
+ if (typeof parsed !== "string") {
+ throw new Error("expected string");
+ }
+ return parsed;
+ }
+
+ if (char === "\\") {
+ this.position += 2;
+ } else {
+ this.position += 1;
+ }
+ }
+
+ throw new Error("unterminated string");
+ }
+
+ private parseValue(): JSONTextNode {
+ this.skipWhitespace();
+
+ const char = this.text[this.position];
+ if (char === "{") return this.parseObject();
+ if (char === "[") return this.parseArray();
+ if (char === '"') return this.parseString();
+ if (char === "t") return this.parseLiteral("true");
+ if (char === "f") return this.parseLiteral("false");
+ if (char === "n") return this.parseLiteral("null");
+ return this.parseNumber();
+ }
+
+ private skipWhitespace() {
+ while (/[\t\n\r ]/.test(this.text[this.position] ?? "")) {
+ this.position += 1;
+ }
+ }
+}
+
+export function compactJSONText(text: string): string {
+ const parsed = parseJSONText(text);
+ return parsed ? stringifyCompact(parsed) : text;
+}
+
+export function formatJSONText(text: string): string {
+ const parsed = parseJSONText(text);
+ return parsed ? stringifyPretty(parsed, 0) : text;
+}
+
+function isDigit(char: string | undefined): boolean {
+ return char !== undefined && char >= "0" && char <= "9";
+}
+
+function isDigitOneToNine(char: string | undefined): boolean {
+ return char !== undefined && char >= "1" && char <= "9";
+}
+
+function parseJSONText(text: string): JSONTextNode | undefined {
+ const parser = new JSONTextParser(text);
+ return parser.parse();
+}
+
+function sortedEntries(entries: JSONTextObjectEntry[]): JSONTextObjectEntry[] {
+ return [...entries].sort((left, right) => left.key.localeCompare(right.key));
+}
+
+function stringifyCompact(node: JSONTextNode): string {
+ switch (node.kind) {
+ case "array":
+ return `[${node.values.map(stringifyCompact).join(",")}]`;
+ case "literal":
+ return node.value;
+ case "number":
+ return node.raw;
+ case "object":
+ return `{${sortedEntries(node.entries)
+ .map(
+ (entry) =>
+ `${JSON.stringify(entry.key)}:${stringifyCompact(entry.value)}`,
+ )
+ .join(",")}}`;
+ case "string":
+ return JSON.stringify(node.value);
+ }
+}
+
+function stringifyPretty(node: JSONTextNode, depth: number): string {
+ const indent = " ";
+ const currentIndent = indent.repeat(depth);
+ const childIndent = indent.repeat(depth + 1);
+
+ switch (node.kind) {
+ case "array":
+ if (node.values.length === 0) return "[]";
+ return `[\n${node.values
+ .map((value) => `${childIndent}${stringifyPretty(value, depth + 1)}`)
+ .join(",\n")}\n${currentIndent}]`;
+ case "literal":
+ return node.value;
+ case "number":
+ return node.raw;
+ case "object":
+ if (node.entries.length === 0) return "{}";
+ return `{\n${sortedEntries(node.entries)
+ .map(
+ (entry) =>
+ `${childIndent}${JSON.stringify(entry.key)}: ${stringifyPretty(
+ entry.value,
+ depth + 1,
+ )}`,
+ )
+ .join(",\n")}\n${currentIndent}}`;
+ case "string":
+ return JSON.stringify(node.value);
+ }
+}