From 9f6aaef0b0ad82ef282ac91c09d553a3e7b10b3c Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 11 Jun 2026 21:08:24 -0500 Subject: [PATCH] preserve large numeric job args Job args can contain database identifiers that exceed JavaScript's safe integer range. The API previously emitted args as nested JSON, so `response.json()` converted large numeric literals to rounded `number` values before the UI could render or copy them. Return job args as JSON text from both the standard and Pro job serializers, and map that field to `argsRaw` in the frontend model. Job list, job detail, and workflow detail now render args through a lossless JSON text formatter that sorts object keys while preserving number tokens as text. Coverage exercises the wire contract for standard and Pro job responses, the frontend fetch path for job and workflow responses, and the list/detail rendering paths for the reported large integer scenario. --- CHANGELOG.md | 1 + handler_api_endpoint.go | 30 +- handler_api_endpoint_test.go | 35 ++- .../prohandler/pro_handler_api_endpoints.go | 30 +- .../pro_handler_api_endpoints_test.go | 22 ++ src/components/JSONTextView.test.tsx | 52 ++++ src/components/JSONTextView.tsx | 28 ++ src/components/JobDetail.test.tsx | 11 + src/components/JobDetail.tsx | 3 +- src/components/JobList.test.tsx | 18 +- src/components/JobList.tsx | 7 +- src/components/WorkflowDetail.test.tsx | 18 ++ src/components/WorkflowDetail.tsx | 3 +- src/services/jobs.test.ts | 96 +++++++ src/services/jobs.ts | 26 +- src/services/workflows.test.ts | 62 ++++ src/test/factories/job.ts | 2 +- src/test/factories/workflowJob.ts | 2 + src/utils/jsonText.test.ts | 27 ++ src/utils/jsonText.ts | 268 ++++++++++++++++++ 20 files changed, 682 insertions(+), 59 deletions(-) create mode 100644 src/components/JSONTextView.test.tsx create mode 100644 src/components/JSONTextView.tsx create mode 100644 src/services/jobs.test.ts create mode 100644 src/utils/jsonText.test.ts create mode 100644 src/utils/jsonText.ts 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); + } +}