Skip to content

Commit 2e19d39

Browse files
authored
Merge pull request #170 from flashcatcloud/fix/alert-end-time-type
fix: render unset timestamps as null in --json output
2 parents d0c1262 + 5385f89 commit 2e19d39

44 files changed

Lines changed: 1105 additions & 663 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/cli/root.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,14 @@ func currentOutputFormat() output.Format {
258258
}
259259

260260
// marshalStructured serializes v for machine-readable output: indented JSON for
261-
// FormatJSON (byte-compatible with the legacy --json path) and TOON via the
262-
// toon-format encoder for FormatTOON.
261+
// FormatJSON (byte-compatible with the legacy --json path, except that unset
262+
// SDK timestamps now render as null instead of the bare integer 0 — see
263+
// output.NullUnsetInstants) and TOON via the toon-format encoder for FormatTOON.
263264
func marshalStructured(v any) ([]byte, error) {
264265
if currentOutputFormat() == output.FormatTOON {
265266
return toon.Marshal(v)
266267
}
267-
return json.MarshalIndent(v, "", " ")
268+
return json.MarshalIndent(output.NullUnsetInstants(v), "", " ")
268269
}
269270

270271
// newPrinter creates a Printer based on global flags.

internal/cli/session.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/spf13/cobra"
1212
toon "github.com/toon-format/toon-go"
1313

14+
"github.com/flashcatcloud/flashduty-cli/internal/output"
1415
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
1516
)
1617

@@ -229,13 +230,16 @@ func filterSessionsSince(sessions []flashduty.SessionItem, sinceUnix int64) []fl
229230

230231
// writeSessionList renders the session rows in the requested format. jsonl emits
231232
// one SessionItem per line; json emits the whole SessionListResponse envelope;
232-
// toon emits the compact encoding of that envelope.
233+
// toon emits the compact encoding of that envelope. The json/jsonl paths route
234+
// through output.NullUnsetInstants so unset SDK timestamps (e.g. archived_at
235+
// on a live session) render as null instead of the bare integer 0, matching
236+
// every other --json surface.
233237
func writeSessionList(w io.Writer, format string, sessions []flashduty.SessionItem, total int64) error {
234238
switch format {
235239
case sessionFormatJSONL:
236240
enc := json.NewEncoder(w)
237241
for i := range sessions {
238-
if err := enc.Encode(sessions[i]); err != nil {
242+
if err := enc.Encode(output.NullUnsetInstants(sessions[i])); err != nil {
239243
return fmt.Errorf("failed to encode session: %w", err)
240244
}
241245
}
@@ -249,7 +253,7 @@ func writeSessionList(w io.Writer, format string, sessions []flashduty.SessionIt
249253
if format == sessionFormatTOON {
250254
out, err = toon.Marshal(envelope)
251255
} else {
252-
out, err = json.MarshalIndent(envelope, "", " ")
256+
out, err = json.MarshalIndent(output.NullUnsetInstants(envelope), "", " ")
253257
}
254258
if err != nil {
255259
return fmt.Errorf("failed to marshal sessions: %w", err)

internal/cli/session_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"bufio"
5+
"bytes"
56
"encoding/json"
67
"fmt"
78
"net/http"
@@ -511,6 +512,61 @@ func TestCommandSessionExportMapsErrorEnvelope(t *testing.T) {
511512
}
512513
}
513514

515+
// TestWriteSessionListNullsUnsetInstants is the regression guard for the
516+
// session-list bypass: writeSessionList marshals SDK structs directly, so
517+
// without output.NullUnsetInstants an unset archived_at (0 = not archived)
518+
// would leak as the bare integer 0 while a set one renders as an RFC3339
519+
// string — the mixed-type defect, on both the json envelope and jsonl paths.
520+
func TestWriteSessionListNullsUnsetInstants(t *testing.T) {
521+
const archivedMs = 1779432894000
522+
sessions := []flashduty.SessionItem{
523+
{SessionID: "sess-live"}, // archived_at unset
524+
{SessionID: "sess-arch", ArchivedAt: flashduty.TimestampMilli(archivedMs)}, // archived
525+
}
526+
527+
t.Run("json envelope", func(t *testing.T) {
528+
var buf bytes.Buffer
529+
if err := writeSessionList(&buf, sessionFormatJSON, sessions, 2); err != nil {
530+
t.Fatalf("writeSessionList(json): %v", err)
531+
}
532+
var envelope struct {
533+
Sessions []map[string]any `json:"sessions"`
534+
}
535+
if err := json.Unmarshal(buf.Bytes(), &envelope); err != nil {
536+
t.Fatalf("json output is not valid JSON: %v\n%s", err, buf.String())
537+
}
538+
if v := envelope.Sessions[0]["archived_at"]; v != nil {
539+
t.Errorf("live session archived_at = %#v, want nil (JSON null)", v)
540+
}
541+
if v, ok := envelope.Sessions[1]["archived_at"].(string); !ok {
542+
t.Errorf("archived session archived_at = %#v, want RFC3339 string", envelope.Sessions[1]["archived_at"])
543+
} else if _, err := time.Parse(time.RFC3339, v); err != nil {
544+
t.Errorf("archived_at = %q, not RFC3339: %v", v, err)
545+
}
546+
})
547+
548+
t.Run("jsonl", func(t *testing.T) {
549+
var buf bytes.Buffer
550+
if err := writeSessionList(&buf, sessionFormatJSONL, sessions, 2); err != nil {
551+
t.Fatalf("writeSessionList(jsonl): %v", err)
552+
}
553+
lines := nonEmptyLines(buf.String())
554+
if len(lines) != 2 {
555+
t.Fatalf("expected 2 jsonl lines, got %d:\n%s", len(lines), buf.String())
556+
}
557+
var live map[string]any
558+
if err := json.Unmarshal([]byte(lines[0]), &live); err != nil {
559+
t.Fatalf("line 0 is not valid JSON: %v", err)
560+
}
561+
if v := live["archived_at"]; v != nil {
562+
t.Errorf("live session archived_at = %#v, want nil (JSON null)", v)
563+
}
564+
if strings.Contains(lines[0], `"archived_at":0`) {
565+
t.Errorf("jsonl line leaked the bare integer 0: %s", lines[0])
566+
}
567+
})
568+
}
569+
514570
func nonEmptyLines(s string) []string {
515571
var out []string
516572
for _, l := range strings.Split(s, "\n") {

internal/cli/zz_generated_a2a_agents.go

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/cli/zz_generated_account.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)