diff --git a/AGENTS.md b/AGENTS.md index 2c71d4aa..c2625e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,5 +120,5 @@ ## Active Speckit Plan -- Active Speckit implementation plan: `specs/316-delete-cancel-logging/plan.md` +- Active Speckit implementation plan: `specs/321-listener-timestamps/plan.md` diff --git a/README.md b/README.md index 52c7a6ed..18e94c4e 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,14 @@ Generated reference: [get process-instance](docs/cli/c8volt_get_process-instance Use `--with-elements` when the process instance is the main target, and `get element` when element filters should drive the search. +Listener rows use `s:` for the job creation time—not worker execution start—and `e:` for the recorded job end time. An available deadline appears as `d:` only while the job state is exactly `ACTIVATED`; unavailable timestamps are omitted independently. For example, a completed listener can appear as: + +```text +job-1 TASK_LISTENER lsnr:CREATING COMPLETED tp:updateTaskData r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842 +``` + +The same timestamp grammar applies to listener rows from `get element`, `get process-instance`, `walk process-instance`, and `ops analyse slow-process-instances`. + ```bash ./c8volt get process-instance --key --with-elements ./c8volt get process-instance --key --with-elements --with-listeners diff --git a/c8volt/element/client_test.go b/c8volt/element/client_test.go index 390bd4cf..7e077307 100644 --- a/c8volt/element/client_test.go +++ b/c8volt/element/client_test.go @@ -5,10 +5,12 @@ package element import ( "context" + "encoding/json" "errors" "io" "log/slog" "testing" + "time" "github.com/grafvonb/c8volt/c8volt/ferrors" options "github.com/grafvonb/c8volt/c8volt/foptions" @@ -97,6 +99,7 @@ func (f fakeElementService) SearchElementsTotal(ctx context.Context, request d.E return f.total(ctx, request, opts...) } +// TestClient_GetElement_Found verifies plain lookup mapping leaves unrequested listeners nil. func TestClient_GetElement_Found(t *testing.T) { api := New(fakeElementService{ get: func(_ context.Context, key string, _ ...services.CallOption) (d.Element, error) { @@ -123,6 +126,7 @@ func TestClient_GetElement_Found(t *testing.T) { result, err := api.GetElement(context.Background(), "2251799813689002") require.NoError(t, err) + require.Nil(t, result.Listeners) require.Equal(t, Element{ ElementInstanceKey: "2251799813689002", ElementId: "ship-order", @@ -158,6 +162,9 @@ func TestClient_GetElement_NotFound(t *testing.T) { // TestClient_GetElementWithListeners_AttachesMatchingJobs verifies keyed enrichment keeps only element-owned listener jobs. func TestClient_GetElementWithListeners_AttachesMatchingJobs(t *testing.T) { var jobQueries []d.JobSearchQuery + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+2", 2*60*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC+2", 2*60*60)) api := NewWithListeners(fakeElementService{ get: func(_ context.Context, key string, _ ...services.CallOption) (d.Element, error) { require.Equal(t, "2251799813689002", key) @@ -173,7 +180,7 @@ func TestClient_GetElementWithListeners_AttachesMatchingJobs(t *testing.T) { search: func(_ context.Context, query d.JobSearchQuery, _ ...services.CallOption) (d.JobSearchResult, error) { jobQueries = append(jobQueries, query) return d.JobSearchResult{Items: []d.Job{ - {Key: "2251799813689101", Kind: query.Kind, ListenerEventType: "START", Type: "audit", State: "CREATED", Retries: 3, ProcessInstanceKey: "2251799813688001", ElementInstanceKey: "2251799813689002", ElementId: "ship-order"}, + {Key: "2251799813689101", Kind: query.Kind, ListenerEventType: "START", Type: "audit", State: "COMPLETED", Retries: 3, CreationTime: &creation, EndTime: &end, Deadline: &deadline, ProcessInstanceKey: "2251799813688001", ElementInstanceKey: "2251799813689002", ElementId: "ship-order"}, {Key: "2251799813689999", Kind: query.Kind, ProcessInstanceKey: "2251799813688001", ElementInstanceKey: "2251799813689998"}, }}, nil }, @@ -190,6 +197,9 @@ func TestClient_GetElementWithListeners_AttachesMatchingJobs(t *testing.T) { require.Len(t, *result.Listeners, 2) require.Equal(t, "2251799813689101", (*result.Listeners)[0].JobKey) require.Equal(t, d.JobKindExecutionListener, (*result.Listeners)[0].Kind) + require.Equal(t, creation, *(*result.Listeners)[0].CreationTime) + require.Equal(t, end, *(*result.Listeners)[0].EndTime) + require.Equal(t, deadline, *(*result.Listeners)[0].Deadline) } // TestClient_SearchElementsWithListeners_IncludesEmptyArrays verifies requested listener enrichment survives empty matches. @@ -217,6 +227,76 @@ func TestClient_SearchElementsWithListeners_IncludesEmptyArrays(t *testing.T) { require.Empty(t, *result.Items[0].Listeners) } +// TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates verifies +// exact public names, optional omission, retained deadlines, and nil-versus-empty arrays. +func TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates(t *testing.T) { + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+2", 2*60*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC+2", 2*60*60)) + listeners := []RuntimeListenerJob{{JobKey: "job-1", State: "COMPLETED", CreationTime: &creation, EndTime: &end, Deadline: &deadline}} + empty := []RuntimeListenerJob{} + + for _, tc := range []struct { + name string + value Element + wantListeners bool + wantLen int + }{ + {name: "unrequested", value: Element{}}, + {name: "requested empty", value: Element{Listeners: &empty}, wantListeners: true}, + {name: "populated", value: Element{Listeners: &listeners}, wantListeners: true, wantLen: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(tc.value) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(raw, &got)) + rawListeners, present := got["listeners"] + require.Equal(t, tc.wantListeners, present) + if !present { + return + } + gotListeners := rawListeners.([]any) + require.Len(t, gotListeners, tc.wantLen) + if tc.wantLen == 0 { + return + } + listener := gotListeners[0].(map[string]any) + require.Equal(t, creation.Format(time.RFC3339Nano), listener["creationTime"]) + require.Equal(t, end.Format(time.RFC3339Nano), listener["endTime"]) + require.Equal(t, deadline.Format(time.RFC3339Nano), listener["deadline"]) + }) + } + for _, tc := range []struct { + name string + creation, end *time.Time + }{ + {name: "both", creation: &creation, end: &end}, + {name: "creation only", creation: &creation}, + {name: "end only", end: &end}, + {name: "neither"}, + } { + t.Run(tc.name, func(t *testing.T) { + listener := fromDomainRuntimeListenerJob(d.RuntimeListenerJob{ + JobKey: "job-optional", State: "CANCELED", + CreationTime: tc.creation, EndTime: tc.end, Deadline: &deadline, + }) + raw, err := json.Marshal(listener) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(raw, &got)) + for field, want := range map[string]*time.Time{"creationTime": tc.creation, "endTime": tc.end} { + if want == nil { + require.NotContains(t, got, field) + } else { + require.Equal(t, want.Format(time.RFC3339Nano), got[field]) + } + } + require.Equal(t, deadline.Format(time.RFC3339Nano), got["deadline"]) + }) + } +} + func TestClient_SearchElementsWithListeners_MapsProgress(t *testing.T) { api := NewWithListeners(fakeElementService{ search: func(_ context.Context, request d.ElementSearchQuery, _ ...services.CallOption) (d.ElementSearchResult, error) { diff --git a/c8volt/element/convert.go b/c8volt/element/convert.go index 175cd6a7..12850eb1 100644 --- a/c8volt/element/convert.go +++ b/c8volt/element/convert.go @@ -29,6 +29,7 @@ func fromDomainElement(result d.Element) Element { } } +// fromDomainRuntimeListenerJob preserves optional listener lifecycle facts at the facade boundary. func fromDomainRuntimeListenerJob(result d.RuntimeListenerJob) RuntimeListenerJob { return RuntimeListenerJob{ JobKey: result.JobKey, @@ -38,6 +39,8 @@ func fromDomainRuntimeListenerJob(result d.RuntimeListenerJob) RuntimeListenerJo State: result.State, Retries: result.Retries, Worker: result.Worker, + CreationTime: result.CreationTime, + EndTime: result.EndTime, Deadline: result.Deadline, ProcessInstanceKey: result.ProcessInstanceKey, ElementInstanceKey: result.ElementInstanceKey, diff --git a/c8volt/element/model.go b/c8volt/element/model.go index 8459b969..29cb0249 100644 --- a/c8volt/element/model.go +++ b/c8volt/element/model.go @@ -5,6 +5,7 @@ package element import "time" +// RuntimeListenerJob exposes a listener job attached to a runtime element. type RuntimeListenerJob struct { JobKey string `json:"jobKey,omitempty"` Kind string `json:"kind,omitempty"` @@ -13,6 +14,8 @@ type RuntimeListenerJob struct { State string `json:"state,omitempty"` Retries int32 `json:"retries"` Worker string `json:"worker,omitempty"` + CreationTime *time.Time `json:"creationTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` Deadline *time.Time `json:"deadline,omitempty"` ProcessInstanceKey string `json:"processInstanceKey,omitempty"` ElementInstanceKey string `json:"elementInstanceKey,omitempty"` diff --git a/c8volt/job/client.go b/c8volt/job/client.go index 6aff46f7..ee3ae196 100644 --- a/c8volt/job/client.go +++ b/c8volt/job/client.go @@ -98,11 +98,14 @@ func (c *client) SubmitJobWorkerOutcome(ctx context.Context, request WorkerOutco return out, nil } +// fromDomainJob preserves independently optional job facts at the public boundary. func fromDomainJob(result d.Job) Job { return Job{ Key: result.Key, State: result.State, Retries: result.Retries, + CreationTime: result.CreationTime, + EndTime: result.EndTime, Deadline: result.Deadline, Type: result.Type, Worker: result.Worker, diff --git a/c8volt/job/client_test.go b/c8volt/job/client_test.go index 444a0220..b0c7e5f3 100644 --- a/c8volt/job/client_test.go +++ b/c8volt/job/client_test.go @@ -5,6 +5,7 @@ package job import ( "context" + "encoding/json" "errors" "io" "log/slog" @@ -76,7 +77,11 @@ func (f fakeJobService) SubmitJobWorkerOutcome(ctx context.Context, request d.Jo return f.outcome(ctx, request, opts...) } +// TestClient_GetJob_Found verifies all job facts, including timestamp offsets, +// survive the public get conversion. func TestClient_GetJob_Found(t *testing.T) { + creation := time.Date(2026, 5, 8, 10, 12, 0, 123000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 5, 8, 10, 14, 0, 456000000, time.FixedZone("UTC+2", 2*60*60)) deadline := time.Date(2026, 5, 8, 10, 15, 0, 0, time.UTC) api := New(fakeJobService{ get: func(_ context.Context, key string, _ ...services.CallOption) (d.Job, error) { @@ -85,6 +90,8 @@ func TestClient_GetJob_Found(t *testing.T) { Key: key, State: "FAILED", Retries: 2, + CreationTime: &creation, + EndTime: &end, Deadline: &deadline, Type: "payment-worker", Worker: "worker-a", @@ -106,6 +113,8 @@ func TestClient_GetJob_Found(t *testing.T) { require.Equal(t, "2251799813711967", result.Key) require.Equal(t, "FAILED", result.State) require.Equal(t, int32(2), result.Retries) + require.Equal(t, &creation, result.CreationTime) + require.Equal(t, &end, result.EndTime) require.Equal(t, &deadline, result.Deadline) require.Equal(t, "payment-worker", result.Type) require.Equal(t, "worker-a", result.Worker) @@ -117,10 +126,14 @@ func TestClient_GetJob_Found(t *testing.T) { require.Equal(t, "PAYMENT_ERROR", result.ErrorCode) require.Equal(t, "worker failed", result.ErrorMessage) require.Equal(t, "tenant-a", result.TenantId) + requireJobTimestampJSON(t, result, true, true, true) } +// TestClient_SearchJobs_MapsFoundationalQueryAndResults verifies search inputs +// and independently optional timestamp results cross the facade unchanged. func TestClient_SearchJobs_MapsFoundationalQueryAndResults(t *testing.T) { retries := int32(0) + creation := time.Date(2026, 5, 8, 10, 12, 0, 123000000, time.FixedZone("UTC-3", -3*60*60)) api := New(fakeJobService{ search: func(_ context.Context, request d.JobSearchQuery, _ ...services.CallOption) (d.JobSearchResult, error) { require.Equal(t, "FAILED", request.State) @@ -134,7 +147,7 @@ func TestClient_SearchJobs_MapsFoundationalQueryAndResults(t *testing.T) { require.Equal(t, "COMPLETING", request.ListenerEventType) require.Equal(t, int32(50), request.Limit) return d.JobSearchResult{ - Items: []d.Job{{Key: "2251799813711967", State: "FAILED", Type: request.Type}}, + Items: []d.Job{{Key: "2251799813711967", State: "FAILED", Type: request.Type, CreationTime: &creation}}, Limit: request.Limit, }, nil }, @@ -158,6 +171,9 @@ func TestClient_SearchJobs_MapsFoundationalQueryAndResults(t *testing.T) { require.Len(t, result.Items, 1) require.Equal(t, "2251799813711967", result.Items[0].Key) require.Equal(t, "payment-worker", result.Items[0].Type) + require.Equal(t, &creation, result.Items[0].CreationTime) + require.Nil(t, result.Items[0].EndTime) + requireJobTimestampJSON(t, result.Items[0], true, false, false) } // TestClient_SearchJobs_PreservesZeroRetriesAndLimit protects search mapping @@ -221,13 +237,14 @@ func TestClient_SearchJobs_ForwardsPageCollectionControls(t *testing.T) { // TestClient_SearchJobsPages_MapsVisitorStepAndAction verifies the facade keeps // rendering callbacks public while delegating traversal state to the service. func TestClient_SearchJobsPages_MapsVisitorStepAndAction(t *testing.T) { + end := time.Date(2026, 5, 8, 10, 14, 0, 456000000, time.FixedZone("UTC+5:30", 5*60*60+30*60)) api := New(fakeJobService{ pages: func(_ context.Context, request d.JobSearchQuery, visitor d.JobSearchPageVisitor, _ ...services.CallOption) (d.JobSearchPagesResult, error) { require.Equal(t, int32(2), request.BatchSize) require.NotNil(t, visitor) action, err := visitor(d.JobSearchPageStep{ Page: d.JobSearchPage{ - Items: []d.Job{{Key: "2251799813711967", State: "FAILED"}}, + Items: []d.Job{{Key: "2251799813711967", State: "FAILED", EndTime: &end}}, Request: d.JobPageRequest{ From: 0, Size: 2, @@ -239,7 +256,7 @@ func TestClient_SearchJobsPages_MapsVisitorStepAndAction(t *testing.T) { require.NoError(t, err) require.Equal(t, d.JobSearchPageActionStop, action) return d.JobSearchPagesResult{ - Items: []d.Job{{Key: "2251799813711967", State: "FAILED"}}, + Items: []d.Job{{Key: "2251799813711967", State: "FAILED", EndTime: &end}}, Limit: request.Limit, Pages: 1, }, nil @@ -261,9 +278,51 @@ func TestClient_SearchJobsPages_MapsVisitorStepAndAction(t *testing.T) { require.Equal(t, int32(2), seen.Page.Request.Size) require.Equal(t, OverflowStateHasMore, seen.Page.OverflowState) require.Equal(t, "2251799813711967", seen.Page.Items[0].Key) + require.Nil(t, seen.Page.Items[0].CreationTime) + require.Equal(t, &end, seen.Page.Items[0].EndTime) + requireJobTimestampJSON(t, seen.Page.Items[0], false, true, false) require.Equal(t, int32(4), result.Limit) require.Equal(t, int32(1), result.Pages) require.Len(t, result.Items, 1) + require.Equal(t, &end, result.Items[0].EndTime) +} + +// TestClient_SearchJobsPage_OmitsMissingTimestamps verifies page conversion +// leaves independently absent timestamps nil and absent from public JSON. +func TestClient_SearchJobsPage_OmitsMissingTimestamps(t *testing.T) { + api := New(fakeJobService{ + page: func(_ context.Context, _ d.JobSearchQuery, request d.JobPageRequest, _ ...services.CallOption) (d.JobSearchPage, error) { + require.Equal(t, d.JobPageRequest{From: 4, Size: 2}, request) + return d.JobSearchPage{ + Items: []d.Job{{Key: "2251799813711970", State: "CANCELED"}}, + Request: request, + }, nil + }, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + result, err := api.SearchJobsPage(context.Background(), SearchRequest{State: "CANCELED"}, PageRequest{From: 4, Size: 2}) + + require.NoError(t, err) + require.Len(t, result.Items, 1) + require.Nil(t, result.Items[0].CreationTime) + require.Nil(t, result.Items[0].EndTime) + requireJobTimestampJSON(t, result.Items[0], false, false, false) +} + +// requireJobTimestampJSON verifies the public wire names, omission rules, and +// retained deadline independently of the source job state. +func requireJobTimestampJSON(t *testing.T, value Job, wantCreation, wantEnd, wantDeadline bool) { + t.Helper() + raw, err := json.Marshal(value) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(raw, &got)) + _, hasCreation := got["creationTime"] + _, hasEnd := got["endTime"] + _, hasDeadline := got["deadline"] + require.Equal(t, wantCreation, hasCreation) + require.Equal(t, wantEnd, hasEnd) + require.Equal(t, wantDeadline, hasDeadline) } // TestClient_SearchJobsTotal_DelegatesTotalFallback verifies the facade exposes diff --git a/c8volt/job/model.go b/c8volt/job/model.go index 394e8d20..421d5bb6 100644 --- a/c8volt/job/model.go +++ b/c8volt/job/model.go @@ -5,10 +5,13 @@ package job import "time" +// Job exposes one runtime job and its optional lifecycle timestamps. type Job struct { Key string `json:"key,omitempty"` State string `json:"state,omitempty"` Retries int32 `json:"retries"` + CreationTime *time.Time `json:"creationTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` Deadline *time.Time `json:"deadline,omitempty"` Type string `json:"type,omitempty"` Worker string `json:"worker,omitempty"` diff --git a/c8volt/ops/client_test.go b/c8volt/ops/client_test.go index e69b7db1..15c10206 100644 --- a/c8volt/ops/client_test.go +++ b/c8volt/ops/client_test.go @@ -5,6 +5,7 @@ package ops import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -373,6 +374,9 @@ func TestClientExecuteSmokeTestMapsProgressTenantContext(t *testing.T) { // TestClientAnalyseSlowProcessInstancesMapsListenerServiceBoundary verifies the slow-analysis facade stays thin. func TestClientAnalyseSlowProcessInstancesMapsListenerServiceBoundary(t *testing.T) { t.Parallel() + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+2", 2*60*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC+2", 2*60*60)) captured := time.Date(2026, 7, 18, 10, 30, 0, 0, time.UTC) rootDurationLonger := 10 * time.Minute @@ -499,6 +503,9 @@ func TestClientAnalyseSlowProcessInstancesMapsListenerServiceBoundary(t *testing Type: "audit-user-task", State: "CREATED", Retries: 3, + CreationTime: &creation, + EndTime: &end, + Deadline: &deadline, ProcessInstanceKey: "2251799813685249", ElementInstanceKey: "2251799813685250", }}, @@ -575,11 +582,84 @@ func TestClientAnalyseSlowProcessInstancesMapsListenerServiceBoundary(t *testing Type: "audit-user-task", State: "CREATED", Retries: 3, + CreationTime: &creation, + EndTime: &end, + Deadline: &deadline, ProcessInstanceKey: "2251799813685249", ElementInstanceKey: "2251799813685250", }}, *got.Items[0].Timeline[0].Listeners) } +// TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates verifies +// exact public names, optional omission, retained deadlines, and nil-versus-empty arrays. +func TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates(t *testing.T) { + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+5:30", 5*60*60+30*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+5:30", 5*60*60+30*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC+5:30", 5*60*60+30*60)) + listeners := []RuntimeListenerJob{{JobKey: "job-1", State: "COMPLETED", CreationTime: &creation, EndTime: &end, Deadline: &deadline}} + empty := []RuntimeListenerJob{} + + for _, tc := range []struct { + name string + value SlowProcessAnalysisTimelineEntry + wantListeners bool + wantLen int + }{ + {name: "unrequested", value: SlowProcessAnalysisTimelineEntry{}}, + {name: "requested empty", value: SlowProcessAnalysisTimelineEntry{Listeners: &empty}, wantListeners: true}, + {name: "populated", value: SlowProcessAnalysisTimelineEntry{Listeners: &listeners}, wantListeners: true, wantLen: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(tc.value) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(raw, &got)) + rawListeners, present := got["listeners"] + require.Equal(t, tc.wantListeners, present) + if !present { + return + } + gotListeners := rawListeners.([]any) + require.Len(t, gotListeners, tc.wantLen) + if tc.wantLen == 0 { + return + } + listener := gotListeners[0].(map[string]any) + require.Equal(t, creation.Format(time.RFC3339Nano), listener["creationTime"]) + require.Equal(t, end.Format(time.RFC3339Nano), listener["endTime"]) + require.Equal(t, deadline.Format(time.RFC3339Nano), listener["deadline"]) + }) + } + for _, tc := range []struct { + name string + creation, end *time.Time + }{ + {name: "both", creation: &creation, end: &end}, + {name: "creation only", creation: &creation}, + {name: "end only", end: &end}, + {name: "neither"}, + } { + t.Run(tc.name, func(t *testing.T) { + listener := fromDomainRuntimeListenerJob(d.RuntimeListenerJob{ + JobKey: "job-optional", State: "CANCELED", + CreationTime: tc.creation, EndTime: tc.end, Deadline: &deadline, + }) + raw, err := json.Marshal(listener) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(raw, &got)) + for field, want := range map[string]*time.Time{"creationTime": tc.creation, "endTime": tc.end} { + if want == nil { + require.NotContains(t, got, field) + } else { + require.Equal(t, want.Format(time.RFC3339Nano), got[field]) + } + } + require.Equal(t, deadline.Format(time.RFC3339Nano), got["deadline"]) + }) + } +} + // TestClientAnalyseSlowProcessInstancesCopiesKeysAndMapsErrors verifies public slices and domain errors stay boundary-safe. func TestClientAnalyseSlowProcessInstancesCopiesKeysAndMapsErrors(t *testing.T) { t.Parallel() diff --git a/c8volt/ops/convert.go b/c8volt/ops/convert.go index 9a5c3589..004cdfc7 100644 --- a/c8volt/ops/convert.go +++ b/c8volt/ops/convert.go @@ -720,6 +720,8 @@ func fromDomainRuntimeListenerJob(x d.RuntimeListenerJob) RuntimeListenerJob { State: x.State, Retries: x.Retries, Worker: x.Worker, + CreationTime: x.CreationTime, + EndTime: x.EndTime, Deadline: x.Deadline, ProcessInstanceKey: x.ProcessInstanceKey, ElementInstanceKey: x.ElementInstanceKey, diff --git a/c8volt/ops/model.go b/c8volt/ops/model.go index 3fdc8f86..a9231bde 100644 --- a/c8volt/ops/model.go +++ b/c8volt/ops/model.go @@ -160,6 +160,8 @@ type RuntimeListenerJob struct { State string `json:"state,omitempty"` Retries int32 `json:"retries"` Worker string `json:"worker,omitempty"` + CreationTime *time.Time `json:"creationTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` Deadline *time.Time `json:"deadline,omitempty"` ProcessInstanceKey string `json:"processInstanceKey,omitempty"` ElementInstanceKey string `json:"elementInstanceKey,omitempty"` diff --git a/c8volt/process/client_test.go b/c8volt/process/client_test.go index 4428047d..cbbcbbef 100644 --- a/c8volt/process/client_test.go +++ b/c8volt/process/client_test.go @@ -1221,6 +1221,9 @@ func TestClient_EnrichProcessInstancesWithElements_MapsProgress(t *testing.T) { func TestClient_EnrichProcessInstancesWithElementListeners_MapsListenerFields(t *testing.T) { t.Parallel() + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+2", 2*60*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC+2", 2*60*60)) ctx := context.Background() elAPI := stubElementAPI{ @@ -1248,6 +1251,9 @@ func TestClient_EnrichProcessInstancesWithElementListeners_MapsListenerFields(t State: "CREATED", Retries: 3, Worker: "audit-worker", + CreationTime: &creation, + EndTime: &end, + Deadline: &deadline, ProcessInstanceKey: "pi-1", ElementInstanceKey: "el-1", ElementId: "ReviewOrder", @@ -1273,6 +1279,9 @@ func TestClient_EnrichProcessInstancesWithElementListeners_MapsListenerFields(t State: "CREATED", Retries: 3, Worker: "audit-worker", + CreationTime: &creation, + EndTime: &end, + Deadline: &deadline, ProcessInstanceKey: "pi-1", ElementInstanceKey: "el-1", ElementId: "ReviewOrder", @@ -1284,6 +1293,76 @@ func TestClient_EnrichProcessInstancesWithElementListeners_MapsListenerFields(t require.Empty(t, *got.Items[0].Elements[1].Listeners) } +// TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates verifies +// exact public names, optional omission, retained deadlines, and nil-versus-empty arrays. +func TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates(t *testing.T) { + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC-3", -3*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC-3", -3*60*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC-3", -3*60*60)) + listeners := []RuntimeListenerJob{{JobKey: "job-1", State: "CANCELED", CreationTime: &creation, EndTime: &end, Deadline: &deadline}} + empty := []RuntimeListenerJob{} + + for _, tc := range []struct { + name string + value ProcessInstanceElement + wantListeners bool + wantLen int + }{ + {name: "unrequested", value: ProcessInstanceElement{}}, + {name: "requested empty", value: ProcessInstanceElement{Listeners: &empty}, wantListeners: true}, + {name: "populated", value: ProcessInstanceElement{Listeners: &listeners}, wantListeners: true, wantLen: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(tc.value) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(raw, &got)) + rawListeners, present := got["listeners"] + require.Equal(t, tc.wantListeners, present) + if !present { + return + } + gotListeners := rawListeners.([]any) + require.Len(t, gotListeners, tc.wantLen) + if tc.wantLen == 0 { + return + } + listener := gotListeners[0].(map[string]any) + require.Equal(t, creation.Format(time.RFC3339Nano), listener["creationTime"]) + require.Equal(t, end.Format(time.RFC3339Nano), listener["endTime"]) + require.Equal(t, deadline.Format(time.RFC3339Nano), listener["deadline"]) + }) + } + for _, tc := range []struct { + name string + creation, end *time.Time + }{ + {name: "both", creation: &creation, end: &end}, + {name: "creation only", creation: &creation}, + {name: "end only", end: &end}, + {name: "neither"}, + } { + t.Run(tc.name, func(t *testing.T) { + listener := fromDomainRuntimeListenerJob(d.RuntimeListenerJob{ + JobKey: "job-optional", State: "CANCELED", + CreationTime: tc.creation, EndTime: tc.end, Deadline: &deadline, + }) + raw, err := json.Marshal(listener) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(raw, &got)) + for field, want := range map[string]*time.Time{"creationTime": tc.creation, "endTime": tc.end} { + if want == nil { + require.NotContains(t, got, field) + } else { + require.Equal(t, want.Format(time.RFC3339Nano), got[field]) + } + } + require.Equal(t, deadline.Format(time.RFC3339Nano), got["deadline"]) + }) + } +} + func TestUpdateProcessInstanceVariablesMapsConfirmedServiceResponse(t *testing.T) { t.Parallel() diff --git a/c8volt/process/convert.go b/c8volt/process/convert.go index 8d56be26..5e7171d9 100644 --- a/c8volt/process/convert.go +++ b/c8volt/process/convert.go @@ -272,6 +272,8 @@ func fromDomainRuntimeListenerJob(x d.RuntimeListenerJob) RuntimeListenerJob { State: x.State, Retries: x.Retries, Worker: x.Worker, + CreationTime: x.CreationTime, + EndTime: x.EndTime, Deadline: x.Deadline, ProcessInstanceKey: x.ProcessInstanceKey, ElementInstanceKey: x.ElementInstanceKey, diff --git a/c8volt/process/model.go b/c8volt/process/model.go index a4618067..cac89329 100644 --- a/c8volt/process/model.go +++ b/c8volt/process/model.go @@ -230,6 +230,8 @@ type RuntimeListenerJob struct { State string `json:"state,omitempty"` Retries int32 `json:"retries"` Worker string `json:"worker,omitempty"` + CreationTime *time.Time `json:"creationTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` Deadline *time.Time `json:"deadline,omitempty"` ProcessInstanceKey string `json:"processInstanceKey,omitempty"` ElementInstanceKey string `json:"elementInstanceKey,omitempty"` diff --git a/cmd/cmd_views_element.go b/cmd/cmd_views_element.go index 3c597000..e71d1608 100644 --- a/cmd/cmd_views_element.go +++ b/cmd/cmd_views_element.go @@ -145,11 +145,7 @@ func flatRowElementListenerWithTimezone(item element.RuntimeListenerJob, showTim "r:" + strconv.FormatInt(int64(item.Retries), 10), prefixedJobField("worker", item.Worker), } - if item.Deadline != nil { - parts = append(parts, "d:"+toolx.FormatTime(*item.Deadline, showTimezoneOffset)) - } else { - parts = append(parts, "") - } + parts = append(parts, listenerTimestampColumns(item.CreationTime, item.EndTime, item.Deadline, item.State, showTimezoneOffset)...) if item.ErrorCode != "" { parts = append(parts, "ec:"+item.ErrorCode) } else { diff --git a/cmd/cmd_views_element_test.go b/cmd/cmd_views_element_test.go index cb522bda..a0963760 100644 --- a/cmd/cmd_views_element_test.go +++ b/cmd/cmd_views_element_test.go @@ -110,3 +110,26 @@ func TestElementFlatRowsAlignElementIDColumn(t *testing.T) { require.NotContains(t, lines[0], "element:") require.NotContains(t, lines[1], "element:") } + +// TestElementListenerRowsKeepTimestampAndErrorColumnsAligned verifies both listener kinds retain fixed optional columns. +func TestElementListenerRowsKeepTimestampAndErrorColumnsAligned(t *testing.T) { + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.UTC) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.UTC) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.UTC) + listeners := []element.RuntimeListenerJob{ + {JobKey: "job-1", Kind: "EXECUTION_LISTENER", ListenerEventType: "START", State: "COMPLETED", Type: "audit", Retries: 0, CreationTime: &creation, EndTime: &end, Deadline: &deadline}, + {JobKey: "job-2", Kind: "TASK_LISTENER", ListenerEventType: "COMPLETING", State: "ACTIVATED", Type: "notify", Retries: 1, Worker: "worker-a", CreationTime: &creation, Deadline: &deadline, ErrorCode: "E1", ErrorMessage: "failed"}, + } + + lines := formatElementListenerRows(&listeners, false) + + require.Len(t, lines, 2) + require.Contains(t, lines[0], "s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") + require.NotContains(t, lines[0], "d:") + require.Contains(t, lines[1], "worker:worker-a") + require.Contains(t, lines[1], "s:2026-09-16T13:07:16.359") + require.Contains(t, lines[1], "d:2026-09-16T13:08:00.000") + require.Contains(t, lines[1], "ec:E1 err:failed") + require.Less(t, strings.Index(lines[1], "worker:"), strings.Index(lines[1], "s:")) + require.Less(t, strings.Index(lines[1], "d:"), strings.Index(lines[1], "ec:")) +} diff --git a/cmd/cmd_views_listener.go b/cmd/cmd_views_listener.go new file mode 100644 index 00000000..16de6f20 --- /dev/null +++ b/cmd/cmd_views_listener.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 Adam Bogdan Boczek +// SPDX-License-Identifier: GPL-3.0-or-later + +package cmd + +import ( + "time" + + "github.com/grafvonb/c8volt/toolx" +) + +// listenerTimestampColumns keeps optional listener times in stable s:, e:, d: positions for aligned rows. +func listenerTimestampColumns(creationTime *time.Time, endTime *time.Time, deadline *time.Time, state string, showTimezoneOffset bool) flatRow { + columns := flatRow{"", "", ""} + if creationTime != nil { + columns[0] = "s:" + toolx.FormatTime(*creationTime, showTimezoneOffset) + } + if endTime != nil { + columns[1] = "e:" + toolx.FormatTime(*endTime, showTimezoneOffset) + } + if state == "ACTIVATED" && deadline != nil { + columns[2] = "d:" + toolx.FormatTime(*deadline, showTimezoneOffset) + } + return columns +} diff --git a/cmd/cmd_views_listener_test.go b/cmd/cmd_views_listener_test.go new file mode 100644 index 00000000..aadb1f4b --- /dev/null +++ b/cmd/cmd_views_listener_test.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Adam Bogdan Boczek +// SPDX-License-Identifier: GPL-3.0-or-later + +package cmd + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestListenerTimestampColumns enforces independent lifecycle fields and the exact activated deadline rule. +func TestListenerTimestampColumns(t *testing.T) { + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+2", 2*60*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC+2", 2*60*60)) + + tests := []struct { + name string + state string + creation *time.Time + end *time.Time + deadline *time.Time + showOffset bool + want flatRow + }{ + {name: "completed", state: "COMPLETED", creation: &creation, end: &end, deadline: &deadline, want: flatRow{"s:2026-09-16T13:07:16.359", "e:2026-09-16T13:07:16.842", ""}}, + {name: "activated", state: "ACTIVATED", creation: &creation, deadline: &deadline, want: flatRow{"s:2026-09-16T13:07:16.359", "", "d:2026-09-16T13:08:00.000"}}, + {name: "activated with end", state: "ACTIVATED", creation: &creation, end: &end, deadline: &deadline, want: flatRow{"s:2026-09-16T13:07:16.359", "e:2026-09-16T13:07:16.842", "d:2026-09-16T13:08:00.000"}}, + {name: "canceled", state: "CANCELED", creation: &creation, end: &end, deadline: &deadline, want: flatRow{"s:2026-09-16T13:07:16.359", "e:2026-09-16T13:07:16.842", ""}}, + {name: "created", state: "CREATED", creation: &creation, deadline: &deadline, want: flatRow{"s:2026-09-16T13:07:16.359", "", ""}}, + {name: "failed", state: "FAILED", end: &end, deadline: &deadline, want: flatRow{"", "e:2026-09-16T13:07:16.842", ""}}, + {name: "blank", deadline: &deadline, want: flatRow{"", "", ""}}, + {name: "unfamiliar", state: "PAUSED", creation: &creation, end: &end, deadline: &deadline, want: flatRow{"s:2026-09-16T13:07:16.359", "e:2026-09-16T13:07:16.842", ""}}, + {name: "noncanonical lowercase", state: "activated", creation: &creation, deadline: &deadline, want: flatRow{"s:2026-09-16T13:07:16.359", "", ""}}, + {name: "no timestamps", state: "ACTIVATED", want: flatRow{"", "", ""}}, + {name: "numeric offsets", state: "ACTIVATED", creation: &creation, end: &end, deadline: &deadline, showOffset: true, want: flatRow{"s:2026-09-16T13:07:16.359+02:00", "e:2026-09-16T13:07:16.842+02:00", "d:2026-09-16T13:08:00.000+02:00"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, listenerTimestampColumns(tc.creation, tc.end, tc.deadline, tc.state, tc.showOffset)) + }) + } +} diff --git a/cmd/cmd_views_ops_slow_process_analysis.go b/cmd/cmd_views_ops_slow_process_analysis.go index f7375089..16adcb92 100644 --- a/cmd/cmd_views_ops_slow_process_analysis.go +++ b/cmd/cmd_views_ops_slow_process_analysis.go @@ -258,11 +258,7 @@ func flatRowOpsSlowProcessAnalysisListenerWithTimezone(item ops.RuntimeListenerJ "r:" + strconv.FormatInt(int64(item.Retries), 10), prefixedJobField("worker", item.Worker), } - if item.Deadline != nil { - parts = append(parts, "d:"+toolx.FormatTime(*item.Deadline, showTimezoneOffset)) - } else { - parts = append(parts, "") - } + parts = append(parts, listenerTimestampColumns(item.CreationTime, item.EndTime, item.Deadline, item.State, showTimezoneOffset)...) if item.ErrorCode != "" { parts = append(parts, "ec:"+item.ErrorCode) } else { diff --git a/cmd/cmd_views_ops_slow_process_analysis_test.go b/cmd/cmd_views_ops_slow_process_analysis_test.go index 0616eebc..625660ea 100644 --- a/cmd/cmd_views_ops_slow_process_analysis_test.go +++ b/cmd/cmd_views_ops_slow_process_analysis_test.go @@ -319,6 +319,9 @@ func TestRenderOpsSlowProcessAnalysisResultHumanRendersHotspotSummaryDetails(t * // TestRenderOpsSlowProcessAnalysisResultHumanRendersListenerRows verifies listeners stay under element timeline rows. func TestRenderOpsSlowProcessAnalysisResultHumanRendersListenerRows(t *testing.T) { cmd, buf := newOpsSlowProcessAnalysisRenderTestCommand() + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.UTC) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.UTC) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.UTC) listeners := []ops.RuntimeListenerJob{{ JobKey: "job-task", Kind: "TASK_LISTENER", @@ -326,6 +329,9 @@ func TestRenderOpsSlowProcessAnalysisResultHumanRendersListenerRows(t *testing.T Type: "audit-task", State: "FAILED", Retries: 0, + CreationTime: &creation, + EndTime: &end, + Deadline: &deadline, ErrorCode: "LISTENER_FAILED", ErrorMessage: "handler rejected", }} @@ -341,10 +347,27 @@ func TestRenderOpsSlowProcessAnalysisResultHumanRendersListenerRows(t *testing.T require.Contains(t, output, "└─ slowest elements:\n") require.Contains(t, output, " ├─ USER_TASK ReviewOrder COMPLETED") require.Contains(t, output, " │ └─ listeners:\n") - require.Contains(t, output, " │ └─ job-task TASK_LISTENER lsnr:COMPLETING FAILED tp:audit-task r:0 ec:LISTENER_FAILED err:handler rejected") + require.Contains(t, output, "job-task TASK_LISTENER lsnr:COMPLETING FAILED tp:audit-task r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") + require.Contains(t, output, "ec:LISTENER_FAILED err:handler rejected") + require.NotContains(t, output, "d:2026-09-16T13:08:00.000") require.NotContains(t, output, "ReviewOrder -> OrderFinished") } +func TestOpsSlowProcessListenerRowUsesConfiguredTimezoneOffset(t *testing.T) { + offset := time.FixedZone("UTC+2", 2*60*60) + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, offset) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, offset) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, offset) + + row := formatFlatRows([]flatRow{flatRowOpsSlowProcessAnalysisListenerWithTimezone(ops.RuntimeListenerJob{ + JobKey: "job-active", State: "ACTIVATED", CreationTime: &creation, EndTime: &end, Deadline: &deadline, + }, true)})[0] + + require.Contains(t, row, "s:2026-09-16T13:07:16.359+02:00") + require.Contains(t, row, "e:2026-09-16T13:07:16.842+02:00") + require.Contains(t, row, "d:2026-09-16T13:08:00.000+02:00") +} + // TestRenderOpsSlowProcessAnalysisResultKeysOnlyRendersRootKeys verifies keyed output remains pipeline-safe. func TestRenderOpsSlowProcessAnalysisResultKeysOnlyRendersUniqueRootKeys(t *testing.T) { cmd, buf := newOpsSlowProcessAnalysisRenderTestCommand() diff --git a/cmd/cmd_views_processinstance_activity.go b/cmd/cmd_views_processinstance_activity.go index c924f40d..48cd494c 100644 --- a/cmd/cmd_views_processinstance_activity.go +++ b/cmd/cmd_views_processinstance_activity.go @@ -388,11 +388,7 @@ func flatRowProcessInstanceElementListenerWithTimezone(item process.RuntimeListe "r:" + strconv.FormatInt(int64(item.Retries), 10), prefixedJobField("worker", item.Worker), } - if item.Deadline != nil { - parts = append(parts, "d:"+toolx.FormatTime(*item.Deadline, showTimezoneOffset)) - } else { - parts = append(parts, "") - } + parts = append(parts, listenerTimestampColumns(item.CreationTime, item.EndTime, item.Deadline, item.State, showTimezoneOffset)...) if item.ErrorCode != "" { parts = append(parts, "ec:"+item.ErrorCode) } else { diff --git a/cmd/cmd_views_processinstance_activity_test.go b/cmd/cmd_views_processinstance_activity_test.go index 376e0569..d76ba778 100644 --- a/cmd/cmd_views_processinstance_activity_test.go +++ b/cmd/cmd_views_processinstance_activity_test.go @@ -5,6 +5,7 @@ package cmd import ( "encoding/json" + "strings" "testing" "time" @@ -12,6 +13,27 @@ import ( "github.com/stretchr/testify/require" ) +func TestProcessInstanceListenerRowsUseSharedTimestampGrammar(t *testing.T) { + offset := time.FixedZone("UTC+2", 2*60*60) + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, offset) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, offset) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, offset) + listeners := []process.RuntimeListenerJob{ + {JobKey: "job-complete", Kind: "EXECUTION_LISTENER", ListenerEventType: "END", State: "COMPLETED", Type: "audit", CreationTime: &creation, EndTime: &end, Deadline: &deadline}, + {JobKey: "job-active", Kind: "TASK_LISTENER", ListenerEventType: "COMPLETING", State: "ACTIVATED", Type: "notify", Worker: "worker-a", CreationTime: &creation, Deadline: &deadline, ErrorCode: "E1", ErrorMessage: "failed"}, + } + + lines := formatProcessInstanceElementListenerRows(&listeners, true) + + require.Len(t, lines, 2) + require.Contains(t, lines[0], "s:2026-09-16T13:07:16.359+02:00 e:2026-09-16T13:07:16.842+02:00") + require.NotContains(t, lines[0], "d:") + require.Contains(t, lines[1], "s:2026-09-16T13:07:16.359+02:00") + require.Contains(t, lines[1], "d:2026-09-16T13:08:00.000+02:00") + require.Less(t, strings.Index(lines[1], "worker:"), strings.Index(lines[1], "s:")) + require.Less(t, strings.Index(lines[1], "d:"), strings.Index(lines[1], "ec:")) +} + func TestFormatProcessInstanceActivityElementListenersNestUnderOwningElement(t *testing.T) { capturedNow := time.Date(2026, 7, 15, 10, 13, 0, 0, time.UTC) listeners := []process.RuntimeListenerJob{ diff --git a/cmd/command_contract_test.go b/cmd/command_contract_test.go index 2f5b70f2..fc546566 100644 --- a/cmd/command_contract_test.go +++ b/cmd/command_contract_test.go @@ -1521,6 +1521,10 @@ func TestCommandCapabilityForCommand_GetElementContract(t *testing.T) { require.Equal(t, []string{"ei"}, capability.Aliases) require.Contains(t, getElementCmd.Long, "Use --key for a known element instance") require.Contains(t, getElementCmd.Long, "--with-listeners to include runtime listener jobs") + require.Contains(t, getElementCmd.Long, "job creation time as s: (not worker execution start)") + require.Contains(t, getElementCmd.Long, "job end time as e:") + require.Contains(t, getElementCmd.Long, "d: only for an ACTIVATED job with a deadline") + require.Contains(t, getElementCmd.Long, "s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") require.Contains(t, getElementCmd.Example, "./c8volt get element --key --with-listeners") require.Contains(t, getElementCmd.Example, "./c8volt get element --pi-key --limit 10") require.Contains(t, getElementCmd.Example, "./c8volt get element --pi-key --with-listeners") @@ -2604,6 +2608,11 @@ func TestGetElementHelp_DocumentsSearchAndOutputModes(t *testing.T) { "--limit caps returned elements across all pages", "--total to count matching elements", "--with-listeners to include runtime listener jobs", + "job creation time as s: (not worker execution start)", + "job end time as e:", + "d: only for an ACTIVATED job with a deadline", + "omit unavailable timestamps", + "s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842", "Requires Camunda 8.8 or newer", "Aliases:", "ei", diff --git a/cmd/get_element.go b/cmd/get_element.go index 1d2dc416..135c1bb8 100644 --- a/cmd/get_element.go +++ b/cmd/get_element.go @@ -36,6 +36,10 @@ Use --key for a known element instance. Otherwise search by process instance, BP --batch-size controls each discovery request; --limit caps returned elements across all pages. Use --total to count matching elements, or --with-listeners to include runtime listener jobs. +Listener rows label job creation time as s: (not worker execution start) and job end time as e:. They show d: only for an ACTIVATED job with a deadline, and omit unavailable timestamps. A completed listener can appear as: + + job-1 TASK_LISTENER lsnr:CREATING COMPLETED tp:updateTaskData r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842 + Requires Camunda 8.8 or newer.`, Example: ` ./c8volt get element --key ./c8volt get element --key --with-listeners diff --git a/cmd/get_element_test.go b/cmd/get_element_test.go index f83586e4..932a6c21 100644 --- a/cmd/get_element_test.go +++ b/cmd/get_element_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" elementapi "github.com/grafvonb/c8volt/c8volt/element" @@ -31,6 +32,15 @@ func TestGetElementCommand_ValidateDirectLookupKey(t *testing.T) { require.NoError(t, validateGetElementFlags(getElementCmd)) } +// TestGetElementCommand_ListenerTimestampHelp documents lifecycle meanings and missing-value behavior at the command source. +func TestGetElementCommand_ListenerTimestampHelp(t *testing.T) { + require.Contains(t, getElementCmd.Long, "job creation time as s: (not worker execution start)") + require.Contains(t, getElementCmd.Long, "job end time as e:") + require.Contains(t, getElementCmd.Long, "d: only for an ACTIVATED job with a deadline") + require.Contains(t, getElementCmd.Long, "omit unavailable timestamps") + require.Contains(t, getElementCmd.Long, "s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") +} + // TestGetElementCommand_HTTPFallbackActivityUsesCommandContext verifies element search preserves fallback activity. func TestGetElementCommand_HTTPFallbackActivityUsesCommandContext(t *testing.T) { resetGetElementFlagState() @@ -609,13 +619,13 @@ func TestGetElementCommand_KeyedLookupWithListenersHumanOutput(t *testing.T) { "tenantId": "tenant-a", "hasIncident": false }`}, []string{ - `{"items":[{"jobKey":"2251799813689101","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"CREATED","retries":3,"processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002","elementId":"ship-order","tenantId":"tenant-a"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, - `{"items":[{"jobKey":"2251799813689102","kind":"TASK_LISTENER","listenerEventType":"COMPLETING","type":"audit-task","state":"FAILED","retries":0,"processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002","elementId":"ship-order","tenantId":"tenant-a","errorCode":"LISTENER_FAILED","errorMessage":"worker failed"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, + `{"items":[{"jobKey":"2251799813689101","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"COMPLETED","retries":3,"creationTime":"2026-09-16T13:07:16.359Z","endTime":"2026-09-16T13:07:16.842Z","deadline":"2026-09-16T13:08:00Z","processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002","elementId":"ship-order","tenantId":"tenant-a"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, + `{"items":[{"jobKey":"2251799813689102","kind":"TASK_LISTENER","listenerEventType":"COMPLETING","type":"audit-task","state":"ACTIVATED","retries":0,"worker":"worker-a","creationTime":"2026-09-16T13:09:00Z","deadline":"2026-09-16T13:10:00Z","processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002","elementId":"ship-order","tenantId":"tenant-a","errorCode":"LISTENER_FAILED","errorMessage":"worker failed"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, }) t.Cleanup(srv.Close) cfgPath := testx.WriteTestConfigForVersion(t, srv.URL, "8.8") - output := executeRootForElementTest(t, "--config", cfgPath, "get", "element", "--key", "2251799813689002", "--with-listeners") + output, stderr := executeRootForElementTestWithSeparateOutputs(t, "--config", cfgPath, "get", "element", "--key", "2251799813689002", "--with-listeners") require.Equal(t, []string{ "GET /v2/element-instances/2251799813689002", @@ -624,12 +634,37 @@ func TestGetElementCommand_KeyedLookupWithListenersHumanOutput(t *testing.T) { }, requests) require.Contains(t, output, "2251799813689002") require.Contains(t, output, "└─ listeners:") - require.Contains(t, output, "2251799813689101 EXECUTION_LISTENER lsnr:START") + require.Contains(t, output, "2251799813689101 EXECUTION_LISTENER lsnr:START COMPLETED tp:audit-start r:3") + require.Contains(t, output, "s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") + require.NotContains(t, strings.Split(output, "2251799813689102")[0], "d:2026-09-16T13:08:00.000") require.Contains(t, output, "2251799813689102 TASK_LISTENER") - require.Contains(t, output, "lsnr:COMPLETING FAILED") + require.Contains(t, output, "lsnr:COMPLETING ACTIVATED") require.Contains(t, output, "tp:audit-task") require.Contains(t, output, "r:0") + require.Contains(t, output, "worker:worker-a") + require.Contains(t, output, "s:2026-09-16T13:09:00.000") + require.Contains(t, output, "d:2026-09-16T13:10:00.000") require.Contains(t, output, "ec:LISTENER_FAILED") + require.NotContains(t, stderr, "2251799813689101") +} + +// TestGetElementCommand_SearchWithListenersHumanOutput preserves timestamp meaning through the search execution path. +func TestGetElementCommand_SearchWithListenersHumanOutput(t *testing.T) { + var requests []string + srv := newElementWithListenersServer(t, &requests, []string{`{"items":[{"elementInstanceKey":"2251799813689002","elementId":"ship-order","type":"SERVICE_TASK","state":"ACTIVE","processInstanceKey":"2251799813688001","tenantId":"tenant-a","hasIncident":false}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`}, []string{ + `{"items":[{"jobKey":"2251799813689101","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"CANCELED","retries":0,"creationTime":"2026-09-16T13:07:16.359Z","endTime":"2026-09-16T13:07:16.842Z","deadline":"2026-09-16T13:08:00Z","processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, + `{"items":[],"page":{"totalItems":0,"hasMoreTotalItems":false}}`, + }) + t.Cleanup(srv.Close) + cfgPath := testx.WriteTestConfigForVersion(t, srv.URL, "8.8") + + stdout, stderr := executeRootForElementTestWithSeparateOutputs(t, "--config", cfgPath, "get", "element", "--pi-key", "2251799813688001", "--with-listeners") + + require.Equal(t, []string{"POST /v2/element-instances/search", "POST /v2/jobs/search", "POST /v2/jobs/search"}, requests) + require.Contains(t, stdout, "2251799813689101 EXECUTION_LISTENER lsnr:START CANCELED") + require.Contains(t, stdout, "s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") + require.NotContains(t, stdout, "d:2026-09-16T13:08:00.000") + require.NotContains(t, stderr, "2251799813689101") } // TestGetElementCommand_SearchWithListenersJSONOutput preserves requested-empty arrays and omits unmatched jobs. @@ -639,28 +674,36 @@ func TestGetElementCommand_SearchWithListenersJSONOutput(t *testing.T) { "items": [ {"elementInstanceKey":"2251799813689002","elementId":"ship-order","type":"SERVICE_TASK","state":"ACTIVE","processInstanceKey":"2251799813688001","tenantId":"tenant-a","hasIncident":false}, {"elementInstanceKey":"2251799813689003","elementId":"finish-order","type":"END_EVENT","state":"COMPLETED","processInstanceKey":"2251799813688001","tenantId":"tenant-a","hasIncident":false} - ], + ], "page": {"totalItems":2,"hasMoreTotalItems":false} }`}, []string{ - `{"items":[{"jobKey":"2251799813689101","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"CREATED","retries":3,"processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002","elementId":"ship-order","tenantId":"tenant-a"},{"jobKey":"2251799813689999","kind":"EXECUTION_LISTENER","processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689998"}],"page":{"totalItems":2,"hasMoreTotalItems":false}}`, - `{"items":[],"page":{"totalItems":0,"hasMoreTotalItems":false}}`, + `{"items":[{"jobKey":"2251799813689101","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"COMPLETED","retries":3,"creationTime":"2026-09-16T13:07:16.359+02:00","endTime":"2026-09-16T13:07:16.842+02:00","deadline":"2026-09-16T13:08:00+02:00","processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002","elementId":"ship-order","tenantId":"tenant-a"},{"jobKey":"2251799813689999","kind":"EXECUTION_LISTENER","processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689998"}],"page":{"totalItems":2,"hasMoreTotalItems":false}}`, + `{"items":[{"jobKey":"2251799813689102","kind":"TASK_LISTENER","listenerEventType":"COMPLETING","type":"audit-task","state":"CANCELED","retries":0,"endTime":"2026-09-16T13:09:16.842-03:00","deadline":"2026-09-16T13:10:00-03:00","processInstanceKey":"2251799813688001","elementInstanceKey":"2251799813689002","elementId":"ship-order","tenantId":"tenant-a"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, }) t.Cleanup(srv.Close) cfgPath := testx.WriteTestConfigForVersion(t, srv.URL, "8.8") - output := executeRootForElementTest(t, "--config", cfgPath, "--json", "get", "element", "--pi-key", "2251799813688001", "--with-listeners") + output, stderr := executeRootForElementTestWithSeparateOutputs(t, "--config", cfgPath, "--json", "get", "element", "--pi-key", "2251799813688001", "--with-listeners") require.Equal(t, []string{"POST /v2/element-instances/search", "POST /v2/jobs/search", "POST /v2/jobs/search"}, requests) - var envelope map[string]any - require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + require.Empty(t, stderr) + envelope := requireSingleJSONObjectDocument(t, output) payload := requireJSONObject(t, envelope["payload"]) items := payload["items"].([]any) require.Len(t, items, 2) first := requireJSONObject(t, items[0]) require.NotNil(t, first["listeners"], output) firstListeners := first["listeners"].([]any) - require.Len(t, firstListeners, 1) - require.Equal(t, "2251799813689101", requireJSONObject(t, firstListeners[0])["jobKey"]) + require.Len(t, firstListeners, 2) + completed := requireJSONObject(t, firstListeners[0]) + require.Equal(t, "2251799813689101", completed["jobKey"]) + require.Equal(t, "2026-09-16T13:07:16.359+02:00", completed["creationTime"]) + require.Equal(t, "2026-09-16T13:07:16.842+02:00", completed["endTime"]) + require.Equal(t, "2026-09-16T13:08:00+02:00", completed["deadline"]) + canceled := requireJSONObject(t, firstListeners[1]) + require.NotContains(t, canceled, "creationTime") + require.Equal(t, "2026-09-16T13:09:16.842-03:00", canceled["endTime"]) + require.Equal(t, "2026-09-16T13:10:00-03:00", canceled["deadline"]) second := requireJSONObject(t, items[1]) require.Empty(t, second["listeners"].([]any)) } diff --git a/cmd/get_processinstance.go b/cmd/get_processinstance.go index 2fcc0a37..efd5c661 100644 --- a/cmd/get_processinstance.go +++ b/cmd/get_processinstance.go @@ -69,7 +69,7 @@ Use a known key or search by process definition, tenant, state, incidents, varia --tenant limits search and selector discovery. Explicit --key and stdin keys use backend authorization without tenant filtering. A --bpmn-process-id selector must match a visible process definition before discovery. -Use --with-incidents for direct incidents, --with-vars for process-instance-scope variables, or --with-elements for runtime element instances. Add --with-listeners to --with-elements for runtime listener jobs. +Use --with-incidents for direct incidents, --with-vars for process-instance-scope variables, or --with-elements for runtime element instances. Add --with-listeners to --with-elements for runtime listener jobs. Listener rows use s: for job creation (not worker execution start), e: for job end, and d: for an available deadline only while the state is exactly ACTIVATED; missing times are omitted. Use variable-search flags to narrow list/search results natively on Camunda 8.8 or newer; Camunda 8.7 returns an unsupported-version error for those flags. --var-exists requires every listed variable name to exist. --var accepts name=value equality shorthand plus advanced name.$operator=value clauses for $eq, $neq, $exists, $in, $notIn, and $like; $notin is accepted as $notIn. --var-like uses native wildcard patterns: * matches zero or more characters, ? matches one character, and escaped wildcards remain literal. Commas inside quoted values and JSON arrays stay inside the variable clause. Variable scopeKey means the scope where the variable is directly defined. diff --git a/cmd/get_processinstance_test.go b/cmd/get_processinstance_test.go index 21b1a62d..a193ee2d 100644 --- a/cmd/get_processinstance_test.go +++ b/cmd/get_processinstance_test.go @@ -41,6 +41,8 @@ func TestGetProcessInstanceHelp_DocumentsPagingAndAutomationSurface(t *testing.T require.Contains(t, output, "--with-vars for process-instance-scope variables") require.Contains(t, output, "--with-elements for runtime element instances") require.Contains(t, output, "Add --with-listeners to --with-elements for runtime listener jobs") + require.Contains(t, output, "s: for job creation (not worker execution start), e: for job end") + require.Contains(t, output, "d: for an available deadline only while the state is exactly ACTIVATED") require.NotContains(t, output, "Add --incident-message-limit to shorten incident messages") require.Contains(t, output, "./c8volt get process-instance --bpmn-process-id --state active --limit 5") require.Contains(t, output, "./c8volt get process-instance --key ") @@ -2291,8 +2293,8 @@ func TestGetProcessInstanceWithElementsAndListeners_HumanOutputNestsListenerRows {"elementInstanceKey":"element-1","elementId":"task-a","type":"SERVICE_TASK","state":"ACTIVE","startDate":"2026-07-15T10:12:01Z","processInstanceKey":"123","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false}, {"elementInstanceKey":"element-2","elementId":"user-task","type":"USER_TASK","state":"ACTIVE","startDate":"2026-07-15T10:12:02Z","processInstanceKey":"123","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false} ],"page":{"totalItems":2,"hasMoreTotalItems":false}}`}, []string{ - `{"items":[{"jobKey":"job-exec-1","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"CREATED","retries":3,"worker":"worker-a","processInstanceKey":"123","elementInstanceKey":"element-1","elementId":"task-a","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, - `{"items":[{"jobKey":"job-task-1","kind":"TASK_LISTENER","listenerEventType":"COMPLETING","type":"audit-task","state":"FAILED","retries":0,"processInstanceKey":"123","elementInstanceKey":"element-2","elementId":"user-task","tenantId":"tenant","errorCode":"LISTENER_FAILED","errorMessage":"worker failed"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, + `{"items":[{"jobKey":"job-exec-1","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"ACTIVATED","retries":3,"worker":"worker-a","creationTime":"2026-09-16T13:07:16.359+02:00","deadline":"2026-09-16T13:08:00+02:00","processInstanceKey":"123","elementInstanceKey":"element-1","elementId":"task-a","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, + `{"items":[{"jobKey":"job-task-1","kind":"TASK_LISTENER","listenerEventType":"COMPLETING","type":"audit-task","state":"COMPLETED","retries":0,"creationTime":"2026-09-16T13:07:16.359+02:00","endTime":"2026-09-16T13:07:16.842+02:00","deadline":"2026-09-16T13:08:00+02:00","processInstanceKey":"123","elementInstanceKey":"element-2","elementId":"user-task","tenantId":"tenant","errorCode":"LISTENER_FAILED","errorMessage":"worker failed"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, }) t.Cleanup(srv.Close) @@ -2318,14 +2320,48 @@ func TestGetProcessInstanceWithElementsAndListeners_HumanOutputNestsListenerRows require.Contains(t, output, "element-1 SERVICE_TASK task-a") require.Contains(t, output, "ACTIVE") require.Contains(t, output, "│ └─ listeners:") - require.Contains(t, output, "job-exec-1 EXECUTION_LISTENER lsnr:START CREATED tp:audit-start r:3 worker:worker-a") + require.Contains(t, output, "job-exec-1 EXECUTION_LISTENER lsnr:START ACTIVATED tp:audit-start r:3 worker:worker-a s:2026-09-16T13:07:16.359 d:2026-09-16T13:08:00.000") require.Contains(t, output, "element-2 USER_TASK") require.Contains(t, output, "user-task ACTIVE") - require.Contains(t, output, "job-task-1 TASK_LISTENER lsnr:COMPLETING FAILED tp:audit-task r:0") + require.Contains(t, output, "job-task-1 TASK_LISTENER lsnr:COMPLETING COMPLETED tp:audit-task r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") + require.NotRegexp(t, `(?m)^.*job-task-1 TASK_LISTENER .* d:.*$`, output) require.Contains(t, output, "ec:LISTENER_FAILED") require.Contains(t, output, "found: 1") } +func TestGetProcessInstanceListWithElementsAndListeners_HumanOutputRendersLifecycleTimestamps(t *testing.T) { + var requests testx.SafeSlice[string] + srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Append(r.Method + " " + r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/process-instances/search": + _, _ = w.Write([]byte(`{"items":[{"hasIncident":false,"processDefinitionId":"demo","processDefinitionKey":"9001","processDefinitionVersion":3,"processInstanceKey":"2251799813685249","startDate":"2026-07-15T10:12:00Z","state":"ACTIVE","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + case "/v2/element-instances/search": + _, _ = w.Write([]byte(`{"items":[{"elementInstanceKey":"element-1","elementId":"task-a","type":"SERVICE_TASK","state":"ACTIVE","startDate":"2026-07-15T10:12:01Z","processInstanceKey":"2251799813685249","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + case "/v2/jobs/search": + _, _ = w.Write([]byte(`{"items":[{"jobKey":"job-list-1","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"ACTIVATED","retries":3,"creationTime":"2026-09-16T13:07:16.359+02:00","deadline":"2026-09-16T13:08:00+02:00","processInstanceKey":"2251799813685249","elementInstanceKey":"element-1","elementId":"task-a","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(srv.Close) + + stdout, stderr := executeRootForProcessInstanceWithSeparateOutputs(t, + "--config", writeTestConfigForVersion(t, srv.URL, "8.8"), "--no-indicator", + "get", "process-instance", "--state", "active", "--with-elements", "--with-listeners", + ) + + require.Contains(t, stdout, "job-list-1 EXECUTION_LISTENER lsnr:START ACTIVATED tp:audit-start r:3 s:2026-09-16T13:07:16.359 d:2026-09-16T13:08:00.000") + require.Empty(t, stderr) + require.ElementsMatch(t, []string{ + "POST /v2/process-instances/search", + "POST /v2/element-instances/search", + "POST /v2/jobs/search", + "POST /v2/jobs/search", + }, requests.Snapshot()) +} + // TestGetProcessInstanceWithElementsAndListeners_JSONOutputPreservesEmptyArraysAndOmitsUnmatchedJobs verifies requested listener arrays survive JSON rendering. func TestGetProcessInstanceWithElementsAndListeners_JSONOutputPreservesEmptyArraysAndOmitsUnmatchedJobs(t *testing.T) { var requests testx.SafeSlice[string] @@ -2333,14 +2369,14 @@ func TestGetProcessInstanceWithElementsAndListeners_JSONOutputPreservesEmptyArra {"elementInstanceKey":"element-1","elementId":"task-a","type":"SERVICE_TASK","state":"ACTIVE","processInstanceKey":"123","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false}, {"elementInstanceKey":"element-empty","elementId":"empty-task","type":"SERVICE_TASK","state":"ACTIVE","processInstanceKey":"123","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false} ],"page":{"totalItems":2,"hasMoreTotalItems":false}}`}, []string{ - `{"items":[{"jobKey":"job-exec-1","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"CREATED","retries":3,"processInstanceKey":"123","elementInstanceKey":"element-1","elementId":"task-a","tenantId":"tenant"},{"jobKey":"job-unmatched","kind":"EXECUTION_LISTENER","listenerEventType":"END","type":"audit-end","state":"CREATED","retries":3,"processInstanceKey":"123","elementInstanceKey":"element-missing","elementId":"missing","tenantId":"tenant"}],"page":{"totalItems":2,"hasMoreTotalItems":false}}`, + `{"items":[{"jobKey":"job-exec-1","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit-start","state":"CANCELED","retries":3,"creationTime":"2026-09-16T13:07:16.359+02:00","deadline":"2026-09-16T13:08:00+02:00","processInstanceKey":"123","elementInstanceKey":"element-1","elementId":"task-a","tenantId":"tenant"},{"jobKey":"job-unmatched","kind":"EXECUTION_LISTENER","listenerEventType":"END","type":"audit-end","state":"CREATED","retries":3,"processInstanceKey":"123","elementInstanceKey":"element-missing","elementId":"missing","tenantId":"tenant"}],"page":{"totalItems":2,"hasMoreTotalItems":false}}`, `{"items":[],"page":{"totalItems":0,"hasMoreTotalItems":false}}`, }) t.Cleanup(srv.Close) cfgPath := writeTestConfigForVersion(t, srv.URL, "8.8") - output := executeRootForProcessInstanceTest(t, + output, stderr := executeRootForProcessInstanceWithSeparateOutputs(t, "--config", cfgPath, "--tenant", "tenant", "--json", @@ -2356,13 +2392,18 @@ func TestGetProcessInstanceWithElementsAndListeners_JSONOutputPreservesEmptyArra "POST /v2/jobs/search", "POST /v2/jobs/search", }, requests.Snapshot()) + require.Empty(t, stderr) payload := requireProcessInstanceElementJSONPayload(t, output) items := requireJSONItems(t, payload["items"], 1) first := requireJSONObject(t, items[0]) elements := requireJSONItems(t, first["elements"], 2) firstElement := requireJSONObject(t, elements[0]) firstListeners := requireJSONItems(t, firstElement["listeners"], 1) - require.Equal(t, "job-exec-1", requireJSONObject(t, firstListeners[0])["jobKey"]) + listener := requireJSONObject(t, firstListeners[0]) + require.Equal(t, "job-exec-1", listener["jobKey"]) + require.Equal(t, "2026-09-16T13:07:16.359+02:00", listener["creationTime"]) + require.NotContains(t, listener, "endTime") + require.Equal(t, "2026-09-16T13:08:00+02:00", listener["deadline"]) secondElement := requireJSONObject(t, elements[1]) require.Empty(t, requireJSONItems(t, secondElement["listeners"], 0)) require.NotContains(t, output, "job-unmatched") @@ -4324,8 +4365,7 @@ func requireProcessInstanceVariableJSONPayload(t *testing.T, output string) map[ func requireProcessInstanceElementJSONPayload(t *testing.T, output string) map[string]any { t.Helper() - var envelope map[string]any - require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + envelope := requireSingleJSONObjectDocument(t, output) require.Equal(t, string(OutcomeSucceeded), envelope["outcome"]) require.Equal(t, "get process-instance", envelope["command"]) return requireJSONObject(t, envelope["payload"]) diff --git a/cmd/listener_timestamp_execution_test.go b/cmd/listener_timestamp_execution_test.go new file mode 100644 index 00000000..eb01f4ae --- /dev/null +++ b/cmd/listener_timestamp_execution_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Adam Bogdan Boczek +// SPDX-License-Identifier: GPL-3.0-or-later + +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + + "github.com/grafvonb/c8volt/testx" + "github.com/stretchr/testify/require" +) + +// TestListenerTimestampCommandsHonorTimezoneConfig exercises config loading, +// transport conversion, facade mapping, and rendering in all four commands. +func TestListenerTimestampCommandsHonorTimezoneConfig(t *testing.T) { + const key = "2251799813685249" + for _, command := range []struct { + name string + args []string + }{ + {"element", []string{"get", "element", "--pi-key", key, "--with-listeners"}}, + {"process", []string{"get", "process-instance", "--key", key, "--with-elements", "--with-listeners"}}, + {"walk", []string{"walk", "process-instance", "--key", key, "--with-elements", "--with-listeners"}}, + {"analysis", []string{"ops", "analyse", "slow-process-instances", "--key", key, "--with-listeners"}}, + } { + for _, showOffset := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/offset=%t", command.name, showOffset), func(t *testing.T) { + var requests testx.SafeSlice[string] + process := `{"processInstanceKey":"2251799813685249","processDefinitionKey":"9001","processDefinitionId":"demo","processDefinitionVersion":1,"state":"ACTIVE","startDate":"2026-09-16T12:00:00Z","tenantId":"tenant","hasIncident":false}` + srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Append(r.Method + " " + r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/process-instances/" + key: + _, _ = w.Write([]byte(process)) + case "/v2/process-instances/search": + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + filter, _ := body["filter"].(map[string]any) + if _, children := filter["parentProcessInstanceKey"]; children { + _, _ = w.Write([]byte(`{"items":[],"page":{"totalItems":0,"hasMoreTotalItems":false}}`)) + } else { + _, _ = fmt.Fprintf(w, `{"items":[%s],"page":{"totalItems":1,"hasMoreTotalItems":false}}`, process) + } + case "/v2/element-instances/search": + _, _ = w.Write([]byte(`{"items":[{"elementInstanceKey":"element-1","elementId":"task-a","type":"SERVICE_TASK","state":"ACTIVE","startDate":"2026-09-16T12:00:00Z","processInstanceKey":"2251799813685249","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + case "/v2/jobs/search": + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + filter := requireJSONObject(t, body["filter"]) + if filter["kind"] == "EXECUTION_LISTENER" { + _, _ = w.Write([]byte(`{"items":[{"jobKey":"job-offset","kind":"EXECUTION_LISTENER","listenerEventType":"START","type":"audit","state":"ACTIVATED","retries":1,"creationTime":"2026-09-16T13:07:16.359+05:30","endTime":"2026-09-16T13:07:16.842+05:30","deadline":"2026-09-16T13:08:00+05:30","processInstanceKey":"2251799813685249","elementInstanceKey":"element-1"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + } else { + _, _ = w.Write([]byte(`{"items":[],"page":{"totalItems":0,"hasMoreTotalItems":false}}`)) + } + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + cfgPath := testx.WriteTestConfigForVersion(t, srv.URL, "8.8") + cfg, err := os.ReadFile(cfgPath) + require.NoError(t, err) + cfg = []byte(strings.Replace(string(cfg), "app:\n", fmt.Sprintf("app:\n show_timezone_offset: %t\n", showOffset), 1)) + require.NoError(t, os.WriteFile(cfgPath, cfg, 0600)) + args := append([]string{"--config", cfgPath, "--no-indicator"}, command.args...) + stdout, stderr := executeRootForProcessInstanceWithSeparateOutputs(t, args...) + require.Empty(t, stderr) + suffix := "" + if showOffset { + suffix = "+05:30" + } + found := false + for _, line := range strings.Split(stdout, "\n") { + if !strings.Contains(line, "job-offset ") { + continue + } + found = true + fields := strings.Fields(line) + require.Contains(t, fields, "s:2026-09-16T13:07:16.359"+suffix) + require.Contains(t, fields, "e:2026-09-16T13:07:16.842"+suffix) + require.Contains(t, fields, "d:2026-09-16T13:08:00.000"+suffix) + } + require.True(t, found, "listener row missing: %s", stdout) + jobs := 0 + for _, request := range requests.Snapshot() { + if request == "POST /v2/jobs/search" { + jobs++ + } + } + require.Equal(t, 2, jobs) + }) + } + } +} diff --git a/cmd/ops_analyse_slow_process_instances.go b/cmd/ops_analyse_slow_process_instances.go index 6a80245f..57180d9d 100644 --- a/cmd/ops_analyse_slow_process_instances.go +++ b/cmd/ops_analyse_slow_process_instances.go @@ -57,7 +57,7 @@ var opsAnalyseSlowProcessInstancesCmd = &cobra.Command{ Select explicit --key values or exactly one process-definition selector. --batch-size controls each discovery request; --limit caps selected instances across all pages. Explicit keys bypass discovery paging. ---dur-longer selects roots whose total duration exceeds a threshold. --element-id, --type, --element-state, and --dur-element-longer restrict analysis to matching element or transition details. Use --with-full-timeline to inspect the complete chronology, or --with-listeners to include runtime listener jobs. +--dur-longer selects roots whose total duration exceeds a threshold. --element-id, --type, --element-state, and --dur-element-longer restrict analysis to matching element or transition details. Use --with-full-timeline to inspect the complete chronology, or --with-listeners to include runtime listener jobs. Listener rows use s: for job creation (not worker execution start), e: for job end, and d: for an available deadline only while the state is exactly ACTIVATED; missing times are omitted. Durations use Go syntax such as 500ms, 30s, 5m, 1h30m, or 24h. Calendar units such as 1d are not supported.`, Example: ` ./c8volt ops analyse slow-process-instances --key diff --git a/cmd/ops_analyse_slow_process_instances_test.go b/cmd/ops_analyse_slow_process_instances_test.go index fe0aeb83..e32db3f5 100644 --- a/cmd/ops_analyse_slow_process_instances_test.go +++ b/cmd/ops_analyse_slow_process_instances_test.go @@ -5,6 +5,8 @@ package cmd import ( "context" + "encoding/json" + "net/http" "strings" "testing" "time" @@ -12,6 +14,7 @@ import ( "github.com/grafvonb/c8volt/c8volt/ops" "github.com/grafvonb/c8volt/c8volt/process" "github.com/grafvonb/c8volt/consts" + "github.com/grafvonb/c8volt/testx" "github.com/grafvonb/c8volt/typex" "github.com/spf13/cobra" "github.com/stretchr/testify/require" @@ -145,6 +148,112 @@ func TestOpsAnalyseSlowProcessInstancesWithListenersMapsRequest(t *testing.T) { require.Equal(t, typex.Keys{"2251799813685249"}, got.Request.InputKeys) } +func TestOpsAnalyseSlowProcessInstancesHelpDocumentsListenerTimestampGrammar(t *testing.T) { + require.Contains(t, opsAnalyseSlowProcessInstancesCmd.Long, "s: for job creation (not worker execution start), e: for job end") + require.Contains(t, opsAnalyseSlowProcessInstancesCmd.Long, "d: for an available deadline only while the state is exactly ACTIVATED") +} + +func TestOpsAnalyseSlowProcessInstancesWithListenersCommandRendersLifecycleTimestamps(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {name: "normal"}, + {name: "full timeline", args: []string{"--with-full-timeline"}}, + } { + t.Run(tc.name, func(t *testing.T) { + var requests testx.SafeSlice[string] + const key = "2251799813685249" + srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Append(r.Method + " " + r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/process-instances/" + key: + _, _ = w.Write([]byte(`{"hasIncident":false,"processDefinitionId":"demo","processDefinitionKey":"9001","processDefinitionName":"demo","processDefinitionVersion":3,"processInstanceKey":"2251799813685249","startDate":"2026-07-15T10:12:00Z","state":"ACTIVE","tenantId":"tenant"}`)) + case "/v2/process-instances/search": + _, _ = w.Write([]byte(`{"items":[{"hasIncident":false,"processDefinitionId":"demo","processDefinitionKey":"9001","processDefinitionName":"demo","processDefinitionVersion":3,"processInstanceKey":"2251799813685249","startDate":"2026-07-15T10:12:00Z","state":"ACTIVE","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + case "/v2/element-instances/search": + _, _ = w.Write([]byte(`{"items":[{"elementInstanceKey":"element-1","elementId":"task-a","type":"SERVICE_TASK","state":"ACTIVE","startDate":"2026-07-15T10:12:00Z","processInstanceKey":"2251799813685249","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + case "/v2/jobs/search": + _, _ = w.Write([]byte(`{"items":[{"jobKey":"job-exec-1","kind":"EXECUTION_LISTENER","listenerEventType":"END","type":"audit-end","state":"COMPLETED","retries":0,"creationTime":"2026-09-16T13:07:16.359+02:00","endTime":"2026-09-16T13:07:16.842+02:00","deadline":"2026-09-16T13:08:00+02:00","processInstanceKey":"2251799813685249","elementInstanceKey":"element-1","elementId":"task-a","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(srv.Close) + + args := []string{"--config", writeTestConfigForVersion(t, srv.URL, "8.8"), "--no-indicator", "ops", "analyse", "slow-process-instances", "--key", key, "--with-listeners"} + args = append(args, tc.args...) + stdout, stderr := executeRootForProcessInstanceWithSeparateOutputs(t, args...) + + require.Contains(t, stdout, "job-exec-1 EXECUTION_LISTENER lsnr:END COMPLETED tp:audit-end r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") + require.NotContains(t, stdout, "d:2026-09-16T13:08:00.000") + require.Empty(t, stderr) + require.ElementsMatch(t, []string{ + "POST /v2/process-instances/search", + "POST /v2/element-instances/search", + "POST /v2/jobs/search", + "POST /v2/jobs/search", + }, requests.Snapshot()) + }) + } +} + +// TestOpsAnalyseSlowProcessInstancesWithListenersCommandJSONPreservesTimestamps verifies +// the execution path emits one clean envelope with optional times and retained deadlines. +func TestOpsAnalyseSlowProcessInstancesWithListenersCommandJSONPreservesTimestamps(t *testing.T) { + var requests testx.SafeSlice[string] + const key = "2251799813685249" + srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Append(r.Method + " " + r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/process-instances/" + key: + _, _ = w.Write([]byte(`{"hasIncident":false,"processDefinitionId":"demo","processDefinitionKey":"9001","processDefinitionName":"demo","processDefinitionVersion":3,"processInstanceKey":"2251799813685249","startDate":"2026-07-15T10:12:00Z","state":"ACTIVE","tenantId":"tenant"}`)) + case "/v2/process-instances/search": + _, _ = w.Write([]byte(`{"items":[{"hasIncident":false,"processDefinitionId":"demo","processDefinitionKey":"9001","processDefinitionName":"demo","processDefinitionVersion":3,"processInstanceKey":"2251799813685249","startDate":"2026-07-15T10:12:00Z","state":"ACTIVE","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + case "/v2/element-instances/search": + _, _ = w.Write([]byte(`{"items":[{"elementInstanceKey":"element-1","elementId":"task-a","type":"SERVICE_TASK","state":"ACTIVE","startDate":"2026-07-15T10:12:00Z","processInstanceKey":"2251799813685249","processDefinitionKey":"9001","tenantId":"tenant","hasIncident":false}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + case "/v2/jobs/search": + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + filter := requireJSONObject(t, body["filter"]) + if filter["kind"] == "EXECUTION_LISTENER" { + _, _ = w.Write([]byte(`{"items":[{"jobKey":"job-exec-1","kind":"EXECUTION_LISTENER","listenerEventType":"END","type":"audit-end","state":"COMPLETED","retries":0,"creationTime":"2026-09-16T13:07:16.359+05:30","endTime":"2026-09-16T13:07:16.842+05:30","deadline":"2026-09-16T13:08:00+05:30","processInstanceKey":"2251799813685249","elementInstanceKey":"element-1","elementId":"task-a","tenantId":"tenant"}],"page":{"totalItems":1,"hasMoreTotalItems":false}}`)) + break + } + _, _ = w.Write([]byte(`{"items":[],"page":{"totalItems":0,"hasMoreTotalItems":false}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(srv.Close) + + stdout, stderr := executeRootForProcessInstanceWithSeparateOutputs(t, + "--config", writeTestConfigForVersion(t, srv.URL, "8.8"), "--no-indicator", "--json", + "ops", "analyse", "slow-process-instances", "--key", key, "--with-listeners", + ) + + require.Empty(t, stderr) + require.ElementsMatch(t, []string{ + "POST /v2/process-instances/search", + "POST /v2/element-instances/search", + "POST /v2/jobs/search", + "POST /v2/jobs/search", + }, requests.Snapshot()) + envelope := requireSingleJSONObjectDocument(t, stdout) + require.Equal(t, string(OutcomeSucceeded), envelope["outcome"]) + require.Equal(t, "ops analyse slow-process-instances", envelope["command"]) + payload := requireJSONObject(t, envelope["payload"]) + items := requireJSONItems(t, payload["items"], 1) + timeline := requireJSONItems(t, requireJSONObject(t, items[0])["timeline"], 1) + listeners := requireJSONItems(t, requireJSONObject(t, timeline[0])["listeners"], 1) + listener := requireJSONObject(t, listeners[0]) + require.Equal(t, "2026-09-16T13:07:16.359+05:30", listener["creationTime"]) + require.Equal(t, "2026-09-16T13:07:16.842+05:30", listener["endTime"]) + require.Equal(t, "2026-09-16T13:08:00+05:30", listener["deadline"]) +} + // TestOpsAnalyseSlowProcessInstancesBuildsProcessDefinitionSearchRequests verifies selector and discovery flags normalize to search mode. func TestOpsAnalyseSlowProcessInstancesBuildsProcessDefinitionSearchRequests(t *testing.T) { tests := []struct { diff --git a/cmd/walk_processinstance.go b/cmd/walk_processinstance.go index 886af5c3..a4b14576 100644 --- a/cmd/walk_processinstance.go +++ b/cmd/walk_processinstance.go @@ -36,7 +36,7 @@ var walkProcessInstanceCmd = &cobra.Command{ Use --parent for ancestry or --children for descendants; the default scope is the full family. Explicit --key uses backend authorization without tenant filtering. -Add --with-incidents, --with-vars, or --with-elements for incident details, process-instance-scope variables, or runtime elements. Add --with-listeners to --with-elements for runtime listener jobs. +Add --with-incidents, --with-vars, or --with-elements for incident details, process-instance-scope variables, or runtime elements. Add --with-listeners to --with-elements for runtime listener jobs. Listener rows use s: for job creation (not worker execution start), e: for job end, and d: for an available deadline only while the state is exactly ACTIVATED; missing times are omitted. When an ancestor is missing but reachable family data remains, walk returns the available family. Direct single-resource lookups remain strict.`, Example: ` ./c8volt walk process-instance --key diff --git a/cmd/walk_test.go b/cmd/walk_test.go index f8a526dc..72c45c28 100644 --- a/cmd/walk_test.go +++ b/cmd/walk_test.go @@ -96,6 +96,8 @@ func TestWalkHelp_DocumentsTraversalVerificationGuidance(t *testing.T) { require.Contains(t, output, "--flat") require.Contains(t, output, "--with-elements") require.Contains(t, output, "--with-listeners") + require.Contains(t, output, "s: for job creation (not worker execution start), e: for job end") + require.Contains(t, output, "d: for an available deadline only while the state is exactly ACTIVATED") require.Contains(t, output, "--incident-message-limit int") require.Contains(t, output, "--incident-state string") require.Contains(t, output, "incident state scope for --with-incidents: active, pending, resolved, migrated, unknown, all") @@ -301,12 +303,12 @@ func TestWalkProcessInstanceCommand_WithListenersFamilyHumanOutputNestsListenerR ), }, map[string][]string{ "123": { - walkedJobSearchJSON(t, map[string]any{"jobKey": "job-exec-root", "kind": "EXECUTION_LISTENER", "listenerEventType": "START", "type": "audit-start", "state": "CREATED", "retries": 3, "worker": "worker-a", "processInstanceKey": "123", "elementInstanceKey": "element-root", "elementId": "root-task", "tenantId": "tenant"}), + walkedJobSearchJSON(t, map[string]any{"jobKey": "job-exec-root", "kind": "EXECUTION_LISTENER", "listenerEventType": "START", "type": "audit-start", "state": "ACTIVATED", "retries": 3, "worker": "worker-a", "creationTime": "2026-09-16T13:07:16.359+02:00", "deadline": "2026-09-16T13:08:00+02:00", "processInstanceKey": "123", "elementInstanceKey": "element-root", "elementId": "root-task", "tenantId": "tenant"}), walkedJobSearchJSON(t), }, "124": { walkedJobSearchJSON(t), - walkedJobSearchJSON(t, map[string]any{"jobKey": "job-task-child", "kind": "TASK_LISTENER", "listenerEventType": "COMPLETING", "type": "audit-task", "state": "FAILED", "retries": 0, "processInstanceKey": "124", "elementInstanceKey": "element-child", "elementId": "child-task", "tenantId": "tenant", "errorCode": "LISTENER_FAILED", "errorMessage": "worker failed"}), + walkedJobSearchJSON(t, map[string]any{"jobKey": "job-task-child", "kind": "TASK_LISTENER", "listenerEventType": "COMPLETING", "type": "audit-task", "state": "CANCELED", "retries": 0, "creationTime": "2026-09-16T13:07:16.359+02:00", "endTime": "2026-09-16T13:07:16.842+02:00", "deadline": "2026-09-16T13:08:00+02:00", "processInstanceKey": "124", "elementInstanceKey": "element-child", "elementId": "child-task", "tenantId": "tenant", "errorCode": "LISTENER_FAILED", "errorMessage": "worker failed"}), }, }) t.Cleanup(srv.Close) @@ -335,10 +337,11 @@ func TestWalkProcessInstanceCommand_WithListenersFamilyHumanOutputNestsListenerR }, requests) require.Contains(t, output, "123 tenant demo v3 ACTIVE") require.Contains(t, output, "├─ elements:\n│ └─ element-root SERVICE_TASK root-task ACTIVE") - require.Contains(t, output, "│ └─ listeners:\n│ └─ job-exec-root EXECUTION_LISTENER lsnr:START CREATED tp:audit-start r:3 worker:worker-a") + require.Contains(t, output, "│ └─ listeners:\n│ └─ job-exec-root EXECUTION_LISTENER lsnr:START ACTIVATED tp:audit-start r:3 worker:worker-a s:2026-09-16T13:07:16.359 d:2026-09-16T13:08:00.000") require.Contains(t, output, "└─ 124 tenant demo v3 ACTIVE") require.Contains(t, output, " └─ elements:\n └─ element-child SERVICE_TASK child-task ACTIVE") - require.Contains(t, output, " └─ listeners:\n └─ job-task-child TASK_LISTENER lsnr:COMPLETING FAILED tp:audit-task r:0") + require.Contains(t, output, " └─ listeners:\n └─ job-task-child TASK_LISTENER lsnr:COMPLETING CANCELED tp:audit-task r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842") + require.NotRegexp(t, `(?m)^.*job-task-child TASK_LISTENER .* d:.*$`, output) require.Contains(t, output, "ec:LISTENER_FAILED") require.Less(t, strings.Index(output, "element-root"), strings.Index(output, "job-exec-root")) require.Less(t, strings.Index(output, "job-exec-root"), strings.Index(output, "124 tenant demo")) @@ -494,7 +497,7 @@ func TestWalkProcessInstanceCommand_WithListenersChildrenParentAndFlatModes(t *t wantFirst: "123 tenant demo", wantSecond: "124 tenant demo", wantElement: "element-124 SERVICE_TASK task-124 ACTIVE", - wantJob: "job-task-124 TASK_LISTENER lsnr:COMPLETING CREATED tp:audit-task r:1", + wantJob: "job-task-124 TASK_LISTENER lsnr:COMPLETING CREATED tp:audit-task r:1 s:2026-09-16T13:07:16.359", }, { name: "parent", @@ -503,7 +506,7 @@ func TestWalkProcessInstanceCommand_WithListenersChildrenParentAndFlatModes(t *t wantFirst: "124 tenant demo", wantSecond: "123 tenant demo", wantElement: "element-124 SERVICE_TASK task-124 ACTIVE", - wantJob: "job-task-124 TASK_LISTENER lsnr:COMPLETING CREATED tp:audit-task r:1", + wantJob: "job-task-124 TASK_LISTENER lsnr:COMPLETING CREATED tp:audit-task r:1 s:2026-09-16T13:07:16.359", }, { name: "flat", @@ -512,7 +515,7 @@ func TestWalkProcessInstanceCommand_WithListenersChildrenParentAndFlatModes(t *t wantFirst: "123 tenant demo", wantSecond: "124 tenant demo", wantElement: "element-124 SERVICE_TASK task-124 ACTIVE", - wantJob: "job-task-124 TASK_LISTENER lsnr:COMPLETING CREATED tp:audit-task r:1", + wantJob: "job-task-124 TASK_LISTENER lsnr:COMPLETING CREATED tp:audit-task r:1 s:2026-09-16T13:07:16.359", }, } @@ -533,7 +536,7 @@ func TestWalkProcessInstanceCommand_WithListenersChildrenParentAndFlatModes(t *t }, "124": { walkedJobSearchJSON(t), - walkedJobSearchJSON(t, map[string]any{"jobKey": "job-task-124", "kind": "TASK_LISTENER", "listenerEventType": "COMPLETING", "type": "audit-task", "state": "CREATED", "retries": 1, "processInstanceKey": "124", "elementInstanceKey": "element-124", "elementId": "task-124", "tenantId": "tenant"}), + walkedJobSearchJSON(t, map[string]any{"jobKey": "job-task-124", "kind": "TASK_LISTENER", "listenerEventType": "COMPLETING", "type": "audit-task", "state": "CREATED", "retries": 1, "creationTime": "2026-09-16T13:07:16.359+02:00", "processInstanceKey": "124", "elementInstanceKey": "element-124", "elementId": "task-124", "tenantId": "tenant"}), }, }) t.Cleanup(srv.Close) @@ -813,7 +816,7 @@ func TestWalkProcessInstanceCommand_WithListenersJSONOutputPreservesEmptyArraysA }, map[string][]string{ "123": { walkedJobSearchJSON(t, - map[string]any{"jobKey": "job-exec-root", "kind": "EXECUTION_LISTENER", "listenerEventType": "START", "type": "audit-start", "state": "CREATED", "retries": 3, "processInstanceKey": "123", "elementInstanceKey": "element-root", "elementId": "root-task", "tenantId": "tenant"}, + map[string]any{"jobKey": "job-exec-root", "kind": "EXECUTION_LISTENER", "listenerEventType": "START", "type": "audit-start", "state": "COMPLETED", "retries": 3, "endTime": "2026-09-16T13:07:16.842-03:00", "deadline": "2026-09-16T13:08:00-03:00", "processInstanceKey": "123", "elementInstanceKey": "element-root", "elementId": "root-task", "tenantId": "tenant"}, map[string]any{"jobKey": "job-unmatched", "kind": "EXECUTION_LISTENER", "listenerEventType": "END", "type": "audit-end", "state": "CREATED", "retries": 3, "processInstanceKey": "123", "elementInstanceKey": "element-missing", "elementId": "missing", "tenantId": "tenant"}, ), walkedJobSearchJSON(t), @@ -827,7 +830,7 @@ func TestWalkProcessInstanceCommand_WithListenersJSONOutputPreservesEmptyArraysA cfgPath := writeTestConfigForVersion(t, srv.URL, "8.9") - output := executeRootForProcessInstanceTest(t, + output, stderr := executeRootForProcessInstanceWithSeparateOutputs(t, "--config", cfgPath, "--json", "walk", "process-instance", @@ -836,6 +839,7 @@ func TestWalkProcessInstanceCommand_WithListenersJSONOutputPreservesEmptyArraysA "--with-listeners", ) + require.Empty(t, stderr) require.Contains(t, strings.Join(requests, ","), "POST /v2/jobs/search") payload := requireWalkProcessInstanceJSONPayload(t, output) require.Equal(t, "family", payload["mode"]) @@ -849,7 +853,11 @@ func TestWalkProcessInstanceCommand_WithListenersJSONOutputPreservesEmptyArraysA elementsByKey[key] = element } firstListeners := requireJSONItems(t, elementsByKey["element-root"]["listeners"], 1) - require.Equal(t, "job-exec-root", requireJSONObject(t, firstListeners[0])["jobKey"]) + listener := requireJSONObject(t, firstListeners[0]) + require.Equal(t, "job-exec-root", listener["jobKey"]) + require.NotContains(t, listener, "creationTime") + require.Equal(t, "2026-09-16T13:07:16.842-03:00", listener["endTime"]) + require.Equal(t, "2026-09-16T13:08:00-03:00", listener["deadline"]) require.Empty(t, requireJSONItems(t, elementsByKey["element-empty"]["listeners"], 0)) child := requireJSONObject(t, items[1]) require.Empty(t, requireJSONItems(t, child["elements"], 0)) @@ -2614,8 +2622,7 @@ func newWalkProcessInstanceWithListenersServer(t *testing.T, requests *[]string, func requireWalkProcessInstanceJSONPayload(t *testing.T, output string) map[string]any { t.Helper() - var envelope map[string]any - require.NoError(t, json.Unmarshal([]byte(output), &envelope)) + envelope := requireSingleJSONObjectDocument(t, output) require.Equal(t, string(OutcomeSucceeded), envelope["outcome"]) require.Equal(t, "walk process-instance", envelope["command"]) return requireJSONObject(t, envelope["payload"]) diff --git a/docs/cli/c8volt_get_element.md b/docs/cli/c8volt_get_element.md index 8c7ddeea..82c9773d 100644 --- a/docs/cli/c8volt_get_element.md +++ b/docs/cli/c8volt_get_element.md @@ -16,6 +16,10 @@ Use --key for a known element instance. Otherwise search by process instance, BP --batch-size controls each discovery request; --limit caps returned elements across all pages. Use --total to count matching elements, or --with-listeners to include runtime listener jobs. +Listener rows label job creation time as s: (not worker execution start) and job end time as e:. They show d: only for an ACTIVATED job with a deadline, and omit unavailable timestamps. A completed listener can appear as: + + job-1 TASK_LISTENER lsnr:CREATING COMPLETED tp:updateTaskData r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842 + Requires Camunda 8.8 or newer. ``` diff --git a/docs/cli/c8volt_get_process-instance.md b/docs/cli/c8volt_get_process-instance.md index 618bf12f..c0ecd317 100644 --- a/docs/cli/c8volt_get_process-instance.md +++ b/docs/cli/c8volt_get_process-instance.md @@ -18,7 +18,7 @@ Use a known key or search by process definition, tenant, state, incidents, varia --tenant limits search and selector discovery. Explicit --key and stdin keys use backend authorization without tenant filtering. A --bpmn-process-id selector must match a visible process definition before discovery. -Use --with-incidents for direct incidents, --with-vars for process-instance-scope variables, or --with-elements for runtime element instances. Add --with-listeners to --with-elements for runtime listener jobs. +Use --with-incidents for direct incidents, --with-vars for process-instance-scope variables, or --with-elements for runtime element instances. Add --with-listeners to --with-elements for runtime listener jobs. Listener rows use s: for job creation (not worker execution start), e: for job end, and d: for an available deadline only while the state is exactly ACTIVATED; missing times are omitted. Use variable-search flags to narrow list/search results natively on Camunda 8.8 or newer; Camunda 8.7 returns an unsupported-version error for those flags. --var-exists requires every listed variable name to exist. --var accepts name=value equality shorthand plus advanced name.$operator=value clauses for $eq, $neq, $exists, $in, $notIn, and $like; $notin is accepted as $notIn. --var-like uses native wildcard patterns: * matches zero or more characters, ? matches one character, and escaped wildcards remain literal. Commas inside quoted values and JSON arrays stay inside the variable clause. Variable scopeKey means the scope where the variable is directly defined. diff --git a/docs/cli/c8volt_ops_analyse_slow-process-instances.md b/docs/cli/c8volt_ops_analyse_slow-process-instances.md index 636d4e29..616f377c 100644 --- a/docs/cli/c8volt_ops_analyse_slow-process-instances.md +++ b/docs/cli/c8volt_ops_analyse_slow-process-instances.md @@ -13,7 +13,7 @@ Analyse process-instance and runtime-element durations without changing cluster Select explicit --key values or exactly one process-definition selector. --batch-size controls each discovery request; --limit caps selected instances across all pages. Explicit keys bypass discovery paging. ---dur-longer selects roots whose total duration exceeds a threshold. --element-id, --type, --element-state, and --dur-element-longer restrict analysis to matching element or transition details. Use --with-full-timeline to inspect the complete chronology, or --with-listeners to include runtime listener jobs. +--dur-longer selects roots whose total duration exceeds a threshold. --element-id, --type, --element-state, and --dur-element-longer restrict analysis to matching element or transition details. Use --with-full-timeline to inspect the complete chronology, or --with-listeners to include runtime listener jobs. Listener rows use s: for job creation (not worker execution start), e: for job end, and d: for an available deadline only while the state is exactly ACTIVATED; missing times are omitted. Durations use Go syntax such as 500ms, 30s, 5m, 1h30m, or 24h. Calendar units such as 1d are not supported. diff --git a/docs/cli/c8volt_walk_process-instance.md b/docs/cli/c8volt_walk_process-instance.md index 7a6b52f1..b046b570 100644 --- a/docs/cli/c8volt_walk_process-instance.md +++ b/docs/cli/c8volt_walk_process-instance.md @@ -14,7 +14,7 @@ Inspect process-instance ancestry, descendants, or the full family. Use --parent for ancestry or --children for descendants; the default scope is the full family. Explicit --key uses backend authorization without tenant filtering. -Add --with-incidents, --with-vars, or --with-elements for incident details, process-instance-scope variables, or runtime elements. Add --with-listeners to --with-elements for runtime listener jobs. +Add --with-incidents, --with-vars, or --with-elements for incident details, process-instance-scope variables, or runtime elements. Add --with-listeners to --with-elements for runtime listener jobs. Listener rows use s: for job creation (not worker execution start), e: for job end, and d: for an available deadline only while the state is exactly ACTIVATED; missing times are omitted. When an ancestor is missing but reachable family data remains, walk returns the available family. Direct single-resource lookups remain strict. diff --git a/docs/index.md b/docs/index.md index d37d8690..7fadf96b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ nav_exclude: true has_toc: true --- -> Generated from build `c8volt v4.3.0-beta.1-288-g391dfd7c-dirty`, commit `391dfd7c`, built `2026-09-13T15:09:37Z` | Supported Camunda 8 versions: 8.7, 8.8, 8.9, 8.10 | Camunda 8.10 baseline: 8.10.0-alpha4 (prerelease) +> Generated from build `c8volt v4.3.3-5-gae81a8da-dirty`, commit `ae81a8da`, built `2026-09-16T03:08:26Z` | Supported Camunda 8 versions: 8.7, 8.8, 8.9, 8.10 | Camunda 8.10 baseline: 8.10.0-alpha4 (prerelease) c8volt logo @@ -272,6 +272,14 @@ Generated reference: [get process-instance](./cli/c8volt_get_process-instance). Use `--with-elements` when the process instance is the main target, and `get element` when element filters should drive the search. +Listener rows use `s:` for the job creation time—not worker execution start—and `e:` for the recorded job end time. An available deadline appears as `d:` only while the job state is exactly `ACTIVATED`; unavailable timestamps are omitted independently. For example, a completed listener can appear as: + +```text +job-1 TASK_LISTENER lsnr:CREATING COMPLETED tp:updateTaskData r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842 +``` + +The same timestamp grammar applies to listener rows from `get element`, `get process-instance`, `walk process-instance`, and `ops analyse slow-process-instances`. + ```bash ./c8volt get process-instance --key --with-elements ./c8volt get process-instance --key --with-elements --with-listeners diff --git a/internal/domain/job.go b/internal/domain/job.go index 42b38dbe..c71ad9a0 100644 --- a/internal/domain/job.go +++ b/internal/domain/job.go @@ -14,6 +14,8 @@ type Job struct { Key string `json:"key,omitempty"` State string `json:"state,omitempty"` Retries int32 `json:"retries"` + CreationTime *time.Time `json:"creationTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` Deadline *time.Time `json:"deadline,omitempty"` Type string `json:"type,omitempty"` Worker string `json:"worker,omitempty"` @@ -35,6 +37,8 @@ type RuntimeListenerJob struct { State string `json:"state,omitempty"` Retries int32 `json:"retries"` Worker string `json:"worker,omitempty"` + CreationTime *time.Time `json:"creationTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` Deadline *time.Time `json:"deadline,omitempty"` ProcessInstanceKey string `json:"processInstanceKey,omitempty"` ElementInstanceKey string `json:"elementInstanceKey,omitempty"` @@ -53,6 +57,8 @@ func RuntimeListenerJobFromJob(job Job) RuntimeListenerJob { State: job.State, Retries: job.Retries, Worker: job.Worker, + CreationTime: job.CreationTime, + EndTime: job.EndTime, Deadline: job.Deadline, ProcessInstanceKey: job.ProcessInstanceKey, ElementInstanceKey: job.ElementInstanceKey, diff --git a/internal/domain/job_test.go b/internal/domain/job_test.go index b1569d4e..2239593e 100644 --- a/internal/domain/job_test.go +++ b/internal/domain/job_test.go @@ -4,11 +4,119 @@ package domain import ( + "encoding/json" "testing" + "time" "github.com/stretchr/testify/require" ) +// TestRuntimeListenerJobFromJobPreservesLifecycleTimestamps verifies that +// listener projection keeps independently optional recorded times unchanged. +func TestRuntimeListenerJobFromJobPreservesLifecycleTimestamps(t *testing.T) { + creationTime := time.Date(2026, time.September, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+05:30", 5*60*60+30*60)) + endTime := time.Date(2026, time.September, 16, 13, 7, 16, 842000000, time.FixedZone("UTC-07:00", -7*60*60)) + deadline := time.Date(2026, time.September, 16, 13, 8, 0, 0, time.UTC) + + tests := []struct { + name string + creationTime *time.Time + endTime *time.Time + }{ + {name: "both timestamps", creationTime: &creationTime, endTime: &endTime}, + {name: "creation time only", creationTime: &creationTime}, + {name: "end time only", endTime: &endTime}, + {name: "neither timestamp"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + job := Job{ + Key: "job-1", + State: "COMPLETED", + CreationTime: tt.creationTime, + EndTime: tt.endTime, + Deadline: &deadline, + } + + listener := RuntimeListenerJobFromJob(job) + + require.Equal(t, tt.creationTime, listener.CreationTime) + require.Equal(t, tt.endTime, listener.EndTime) + require.Equal(t, &deadline, listener.Deadline) + if tt.creationTime != nil { + _, sourceOffset := tt.creationTime.Zone() + _, projectedOffset := listener.CreationTime.Zone() + require.True(t, listener.CreationTime.Equal(*tt.creationTime)) + require.Equal(t, sourceOffset, projectedOffset) + } + if tt.endTime != nil { + _, sourceOffset := tt.endTime.Zone() + _, projectedOffset := listener.EndTime.Zone() + require.True(t, listener.EndTime.Equal(*tt.endTime)) + require.Equal(t, sourceOffset, projectedOffset) + } + }) + } +} + +// TestJobLifecycleTimestampJSON verifies that domain job representations use +// the stable optional field names without suppressing non-active deadlines. +func TestJobLifecycleTimestampJSON(t *testing.T) { + creationTime := time.Date(2026, time.September, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+05:30", 5*60*60+30*60)) + endTime := time.Date(2026, time.September, 16, 13, 7, 16, 842000000, time.FixedZone("UTC-07:00", -7*60*60)) + deadline := time.Date(2026, time.September, 16, 13, 8, 0, 0, time.UTC) + + tests := []struct { + name string + value any + want string + }{ + { + name: "job values", + value: Job{ + State: "COMPLETED", + CreationTime: &creationTime, + EndTime: &endTime, + Deadline: &deadline, + }, + want: `{ + "state":"COMPLETED", + "retries":0, + "creationTime":"2026-09-16T13:07:16.359+05:30", + "endTime":"2026-09-16T13:07:16.842-07:00", + "deadline":"2026-09-16T13:08:00Z" + }`, + }, + { + name: "listener values", + value: RuntimeListenerJob{ + State: "CANCELED", + CreationTime: &creationTime, + EndTime: &endTime, + Deadline: &deadline, + }, + want: `{ + "state":"CANCELED", + "retries":0, + "creationTime":"2026-09-16T13:07:16.359+05:30", + "endTime":"2026-09-16T13:07:16.842-07:00", + "deadline":"2026-09-16T13:08:00Z" + }`, + }, + {name: "job omissions", value: Job{}, want: `{"retries":0}`}, + {name: "listener omissions", value: RuntimeListenerJob{}, want: `{"retries":0}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.value) + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + func TestJobUpdateRequestHasUpdates(t *testing.T) { retries := int32(3) timeout := int64(300000) diff --git a/internal/services/element/enrichment_test.go b/internal/services/element/enrichment_test.go index 0ccb8160..4752f803 100644 --- a/internal/services/element/enrichment_test.go +++ b/internal/services/element/enrichment_test.go @@ -7,12 +7,38 @@ import ( "context" "errors" "testing" + "time" d "github.com/grafvonb/c8volt/internal/domain" "github.com/grafvonb/c8volt/internal/services" "github.com/stretchr/testify/require" ) +func TestEnrichElementWithListenersPreservesLifecycleTimestamps(t *testing.T) { + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+2", 2*60*60)) + deadline := time.Date(2026, 9, 16, 13, 8, 0, 0, time.FixedZone("UTC+2", 2*60*60)) + got, err := EnrichElementWithListeners(context.Background(), stubElementAPI{ + get: func(context.Context, string, ...services.CallOption) (d.Element, error) { + return d.Element{ElementInstanceKey: "el-1", ProcessInstanceKey: "pi-1"}, nil + }, + }, stubJobAPI{ + search: func(_ context.Context, query d.JobSearchQuery, _ ...services.CallOption) (d.JobSearchResult, error) { + if query.Kind != d.JobKindTaskListener { + return d.JobSearchResult{}, nil + } + return d.JobSearchResult{Items: []d.Job{{Key: "job-1", Kind: query.Kind, State: "COMPLETED", ProcessInstanceKey: "pi-1", ElementInstanceKey: "el-1", CreationTime: &creation, EndTime: &end, Deadline: &deadline}}}, nil + }, + }, "el-1") + + require.NoError(t, err) + require.NotNil(t, got.Listeners) + require.Len(t, *got.Listeners, 1) + require.Equal(t, &creation, (*got.Listeners)[0].CreationTime) + require.Equal(t, &end, (*got.Listeners)[0].EndTime) + require.Equal(t, &deadline, (*got.Listeners)[0].Deadline) +} + type stubElementAPI struct { get func(context.Context, string, ...services.CallOption) (d.Element, error) search func(context.Context, d.ElementSearchQuery, ...services.CallOption) (d.ElementSearchResult, error) diff --git a/internal/services/job/v810/convert.go b/internal/services/job/v810/convert.go index 216f6219..14db63d4 100644 --- a/internal/services/job/v810/convert.go +++ b/internal/services/job/v810/convert.go @@ -171,6 +171,8 @@ func fromJobSearchResult(r camundav810.JobSearchResult) d.Job { Key: string(r.JobKey), State: string(r.State), Retries: r.Retries, + CreationTime: r.CreationTime, + EndTime: r.EndTime, Deadline: r.Deadline, Type: r.Type, Worker: r.Worker, diff --git a/internal/services/job/v810/service_test.go b/internal/services/job/v810/service_test.go index 64503350..5cadccb0 100644 --- a/internal/services/job/v810/service_test.go +++ b/internal/services/job/v810/service_test.go @@ -62,6 +62,8 @@ func (m *mockJobClient) FailJobWithResponse(ctx context.Context, jobKey camundav // TestSearchJobsByKey verifies v8.10 key lookup builds a job-key search and maps the returned row. func TestSearchJobsByKey(t *testing.T) { + creationTime := time.Date(2026, 5, 8, 8, 10, 0, 0, time.FixedZone("UTC+02", 2*60*60)) + endTime := time.Date(2026, 5, 8, 9, 20, 0, 0, time.FixedZone("UTC-04", -4*60*60)) deadline := time.Date(2026, 5, 8, 10, 15, 0, 0, time.UTC) svc := newJobServiceTest(t, &mockJobClient{ searchJobsWithResponse: func(_ context.Context, body camundav810.SearchJobsJSONRequestBody, _ ...camundav810.RequestEditorFn) (*camundav810.SearchJobsResponse, error) { @@ -73,6 +75,8 @@ func TestSearchJobsByKey(t *testing.T) { JobKey: "2251799813711967", State: camundav810.JobStateEnum("FAILED"), Retries: 2, + CreationTime: &creationTime, + EndTime: &endTime, Deadline: &deadline, ProcessInstanceKey: "2251799813711000", ElementInstanceKey: "2251799813711001", @@ -92,6 +96,8 @@ func TestSearchJobsByKey(t *testing.T) { Key: "2251799813711967", State: "FAILED", Retries: 2, + CreationTime: &creationTime, + EndTime: &endTime, Deadline: &deadline, ProcessInstanceKey: "2251799813711000", ElementInstanceKey: "2251799813711001", @@ -124,6 +130,8 @@ func TestService_GetJob_NotFound(t *testing.T) { func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { retries := int32(0) elementID := camundav810.ElementId("charge-card") + creationTime := time.Date(2026, 5, 8, 8, 10, 0, 0, time.FixedZone("UTC+02", 2*60*60)) + endTime := time.Date(2026, 5, 8, 9, 20, 0, 0, time.FixedZone("UTC-04", -4*60*60)) svc := newJobServiceTest(t, &mockJobClient{ searchJobsWithResponse: func(_ context.Context, body camundav810.SearchJobsJSONRequestBody, _ ...camundav810.RequestEditorFn) (*camundav810.SearchJobsResponse, error) { requireJobSearchFilterJSON(t, body, map[string]any{ @@ -144,6 +152,8 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { JobKey: "2251799813711967", State: camundav810.JobStateEnumFAILED, Retries: retries, + CreationTime: &creationTime, + EndTime: &endTime, Type: "payment-worker", Worker: "worker-a", Kind: camundav810.JobKindEnumBPMNELEMENT, @@ -152,6 +162,18 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { ElementInstanceKey: "2251799813711001", ElementId: &elementID, TenantId: "tenant-a", + }, { + JobKey: "creation-only", + CreationTime: &creationTime, + EndTime: nil, + }, { + JobKey: "end-only", + CreationTime: nil, + EndTime: &endTime, + }, { + JobKey: "timestamps-null", + CreationTime: nil, + EndTime: nil, }}, }, }, nil @@ -173,13 +195,21 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { require.NoError(t, err) require.Equal(t, int32(25), result.Limit) - require.Len(t, result.Items, 1) + require.Len(t, result.Items, 4) require.Equal(t, "2251799813711967", result.Items[0].Key) require.Equal(t, "payment-worker", result.Items[0].Type) require.Equal(t, "worker-a", result.Items[0].Worker) require.Equal(t, "BPMN_ELEMENT", result.Items[0].Kind) require.Equal(t, "COMPLETING", result.Items[0].ListenerEventType) require.Equal(t, "charge-card", result.Items[0].ElementId) + require.Equal(t, &creationTime, result.Items[0].CreationTime) + require.Equal(t, &endTime, result.Items[0].EndTime) + require.Equal(t, &creationTime, result.Items[1].CreationTime) + require.Nil(t, result.Items[1].EndTime) + require.Nil(t, result.Items[2].CreationTime) + require.Equal(t, &endTime, result.Items[2].EndTime) + require.Nil(t, result.Items[3].CreationTime) + require.Nil(t, result.Items[3].EndTime) } // TestService_SearchJobsPagesByBatchSizeUntilComplete verifies service-owned offset paging for v8.10 job search. diff --git a/internal/services/job/v88/convert.go b/internal/services/job/v88/convert.go index 388194a6..3d7808ff 100644 --- a/internal/services/job/v88/convert.go +++ b/internal/services/job/v88/convert.go @@ -148,6 +148,8 @@ func fromJobSearchResult(r camundav88.JobSearchResult) d.Job { Key: string(r.JobKey), State: string(r.State), Retries: r.Retries, + CreationTime: r.CreationTime, + EndTime: r.EndTime, Deadline: r.Deadline, Type: r.Type, Worker: r.Worker, diff --git a/internal/services/job/v88/service_test.go b/internal/services/job/v88/service_test.go index 480b2034..531a8a49 100644 --- a/internal/services/job/v88/service_test.go +++ b/internal/services/job/v88/service_test.go @@ -61,7 +61,10 @@ func (m *mockJobClient) FailJobWithResponse(ctx context.Context, jobKey camundav return m.failJobWithResponse(ctx, jobKey, body, reqEditors...) } +// TestSearchJobsByKey verifies v8.8 key lookup preserves all supplied job timestamps. func TestSearchJobsByKey(t *testing.T) { + creationTime := time.Date(2026, 5, 8, 8, 10, 0, 0, time.FixedZone("UTC+02", 2*60*60)) + endTime := time.Date(2026, 5, 8, 9, 20, 0, 0, time.FixedZone("UTC-04", -4*60*60)) deadline := time.Date(2026, 5, 8, 10, 15, 0, 0, time.UTC) svc := newJobServiceTest(t, &mockJobClient{ searchJobsWithResponse: func(_ context.Context, body camundav88.SearchJobsJSONRequestBody, _ ...camundav88.RequestEditorFn) (*camundav88.SearchJobsResponse, error) { @@ -73,6 +76,8 @@ func TestSearchJobsByKey(t *testing.T) { JobKey: "2251799813711967", State: camundav88.JobStateEnum("FAILED"), Retries: 2, + CreationTime: &creationTime, + EndTime: &endTime, Deadline: &deadline, ProcessInstanceKey: "2251799813711000", ElementInstanceKey: "2251799813711001", @@ -92,6 +97,8 @@ func TestSearchJobsByKey(t *testing.T) { Key: "2251799813711967", State: "FAILED", Retries: 2, + CreationTime: &creationTime, + EndTime: &endTime, Deadline: &deadline, ProcessInstanceKey: "2251799813711000", ElementInstanceKey: "2251799813711001", @@ -123,6 +130,8 @@ func TestService_GetJob_NotFound(t *testing.T) { func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { retries := int32(0) elementID := camundav88.ElementId("charge-card") + creationTime := time.Date(2026, 5, 8, 8, 10, 0, 0, time.FixedZone("UTC+02", 2*60*60)) + endTime := time.Date(2026, 5, 8, 9, 20, 0, 0, time.FixedZone("UTC-04", -4*60*60)) svc := newJobServiceTest(t, &mockJobClient{ searchJobsWithResponse: func(_ context.Context, body camundav88.SearchJobsJSONRequestBody, _ ...camundav88.RequestEditorFn) (*camundav88.SearchJobsResponse, error) { requireJobSearchFilterJSON(t, body, map[string]any{ @@ -143,6 +152,8 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { JobKey: "2251799813711967", State: camundav88.JobStateEnumFAILED, Retries: retries, + CreationTime: &creationTime, + EndTime: &endTime, Type: "payment-worker", Worker: "worker-a", Kind: camundav88.BPMNELEMENT, @@ -151,6 +162,18 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { ElementInstanceKey: "2251799813711001", ElementId: &elementID, TenantId: "tenant-a", + }, { + JobKey: "creation-only", + CreationTime: &creationTime, + EndTime: nil, + }, { + JobKey: "end-only", + CreationTime: nil, + EndTime: &endTime, + }, { + JobKey: "timestamps-null", + CreationTime: nil, + EndTime: nil, }}, }, }, nil @@ -172,13 +195,21 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { require.NoError(t, err) require.Equal(t, int32(25), result.Limit) - require.Len(t, result.Items, 1) + require.Len(t, result.Items, 4) require.Equal(t, "2251799813711967", result.Items[0].Key) require.Equal(t, "payment-worker", result.Items[0].Type) require.Equal(t, "worker-a", result.Items[0].Worker) require.Equal(t, "BPMN_ELEMENT", result.Items[0].Kind) require.Equal(t, "COMPLETING", result.Items[0].ListenerEventType) require.Equal(t, "charge-card", result.Items[0].ElementId) + require.Equal(t, &creationTime, result.Items[0].CreationTime) + require.Equal(t, &endTime, result.Items[0].EndTime) + require.Equal(t, &creationTime, result.Items[1].CreationTime) + require.Nil(t, result.Items[1].EndTime) + require.Nil(t, result.Items[2].CreationTime) + require.Equal(t, &endTime, result.Items[2].EndTime) + require.Nil(t, result.Items[3].CreationTime) + require.Nil(t, result.Items[3].EndTime) } func TestService_SearchJobsPageTrimsV88OverfullResponses(t *testing.T) { diff --git a/internal/services/job/v89/convert.go b/internal/services/job/v89/convert.go index 791e6d41..89c6145d 100644 --- a/internal/services/job/v89/convert.go +++ b/internal/services/job/v89/convert.go @@ -171,6 +171,8 @@ func fromJobSearchResult(r camundav89.JobSearchResult) d.Job { Key: string(r.JobKey), State: string(r.State), Retries: r.Retries, + CreationTime: r.CreationTime, + EndTime: r.EndTime, Deadline: r.Deadline, Type: r.Type, Worker: r.Worker, diff --git a/internal/services/job/v89/service_test.go b/internal/services/job/v89/service_test.go index e5b61e52..2bec109a 100644 --- a/internal/services/job/v89/service_test.go +++ b/internal/services/job/v89/service_test.go @@ -60,7 +60,10 @@ func (m *mockJobClient) FailJobWithResponse(ctx context.Context, jobKey camundav return m.failJobWithResponse(ctx, jobKey, body, reqEditors...) } +// TestSearchJobsByKey verifies v8.9 key lookup preserves all supplied job timestamps. func TestSearchJobsByKey(t *testing.T) { + creationTime := time.Date(2026, 5, 8, 8, 10, 0, 0, time.FixedZone("UTC+02", 2*60*60)) + endTime := time.Date(2026, 5, 8, 9, 20, 0, 0, time.FixedZone("UTC-04", -4*60*60)) deadline := time.Date(2026, 5, 8, 10, 15, 0, 0, time.UTC) svc := newJobServiceTest(t, &mockJobClient{ searchJobsWithResponse: func(_ context.Context, body camundav89.SearchJobsJSONRequestBody, _ ...camundav89.RequestEditorFn) (*camundav89.SearchJobsResponse, error) { @@ -72,6 +75,8 @@ func TestSearchJobsByKey(t *testing.T) { JobKey: "2251799813711967", State: camundav89.JobStateEnum("FAILED"), Retries: 2, + CreationTime: &creationTime, + EndTime: &endTime, Deadline: &deadline, ProcessInstanceKey: "2251799813711000", ElementInstanceKey: "2251799813711001", @@ -91,6 +96,8 @@ func TestSearchJobsByKey(t *testing.T) { Key: "2251799813711967", State: "FAILED", Retries: 2, + CreationTime: &creationTime, + EndTime: &endTime, Deadline: &deadline, ProcessInstanceKey: "2251799813711000", ElementInstanceKey: "2251799813711001", @@ -122,6 +129,8 @@ func TestService_GetJob_NotFound(t *testing.T) { func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { retries := int32(0) elementID := camundav89.ElementId("charge-card") + creationTime := time.Date(2026, 5, 8, 8, 10, 0, 0, time.FixedZone("UTC+02", 2*60*60)) + endTime := time.Date(2026, 5, 8, 9, 20, 0, 0, time.FixedZone("UTC-04", -4*60*60)) svc := newJobServiceTest(t, &mockJobClient{ searchJobsWithResponse: func(_ context.Context, body camundav89.SearchJobsJSONRequestBody, _ ...camundav89.RequestEditorFn) (*camundav89.SearchJobsResponse, error) { requireJobSearchFilterJSON(t, body, map[string]any{ @@ -142,6 +151,8 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { JobKey: "2251799813711967", State: camundav89.JobStateEnumFAILED, Retries: retries, + CreationTime: &creationTime, + EndTime: &endTime, Type: "payment-worker", Worker: "worker-a", Kind: camundav89.BPMNELEMENT, @@ -150,6 +161,18 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { ElementInstanceKey: "2251799813711001", ElementId: &elementID, TenantId: "tenant-a", + }, { + JobKey: "creation-only", + CreationTime: &creationTime, + EndTime: nil, + }, { + JobKey: "end-only", + CreationTime: nil, + EndTime: &endTime, + }, { + JobKey: "timestamps-null", + CreationTime: nil, + EndTime: nil, }}, }, }, nil @@ -171,13 +194,21 @@ func TestService_SearchJobs_ConstructsFiltersAndConvertsRows(t *testing.T) { require.NoError(t, err) require.Equal(t, int32(25), result.Limit) - require.Len(t, result.Items, 1) + require.Len(t, result.Items, 4) require.Equal(t, "2251799813711967", result.Items[0].Key) require.Equal(t, "payment-worker", result.Items[0].Type) require.Equal(t, "worker-a", result.Items[0].Worker) require.Equal(t, "BPMN_ELEMENT", result.Items[0].Kind) require.Equal(t, "COMPLETING", result.Items[0].ListenerEventType) require.Equal(t, "charge-card", result.Items[0].ElementId) + require.Equal(t, &creationTime, result.Items[0].CreationTime) + require.Equal(t, &endTime, result.Items[0].EndTime) + require.Equal(t, &creationTime, result.Items[1].CreationTime) + require.Nil(t, result.Items[1].EndTime) + require.Nil(t, result.Items[2].CreationTime) + require.Equal(t, &endTime, result.Items[2].EndTime) + require.Nil(t, result.Items[3].CreationTime) + require.Nil(t, result.Items[3].EndTime) } func TestService_SearchJobsPagesByBatchSizeUntilComplete(t *testing.T) { diff --git a/internal/services/ops/slow_process_analysis_test.go b/internal/services/ops/slow_process_analysis_test.go index 6aab4015..0d0fea7f 100644 --- a/internal/services/ops/slow_process_analysis_test.go +++ b/internal/services/ops/slow_process_analysis_test.go @@ -955,6 +955,8 @@ func TestSlowProcessAnalysisRuntimeElementsBuildChronologicalTimeline(t *testing func TestSlowProcessAnalysisWithListenersAttachesOnlyMatchingElementJobs(t *testing.T) { captured := slowProcessAnalysisFixtureTime(t, "2026-07-18T10:10:00Z") start := slowProcessAnalysisFixtureTime(t, "2026-07-18T10:00:00Z") + listenerCreated := slowProcessAnalysisFixtureTime(t, "2026-07-18T10:00:10+02:00") + listenerEnded := slowProcessAnalysisFixtureTime(t, "2026-07-18T10:00:11+02:00") root := slowProcessAnalysisFixtureProcessInstance("2251799813685249", start, start.Add(10*time.Minute)) elements := []d.Element{ slowProcessAnalysisFixtureElement(root.Key, "2251799813685250", "ReserveStock", start.Add(10*time.Second), start.Add(time.Minute)), @@ -972,11 +974,16 @@ func TestSlowProcessAnalysisWithListenersAttachesOnlyMatchingElementJobs(t *test }, } var jobQueries []d.JobSearchQuery + includeTimestamps := true jobAPI := stubSlowProcessAnalysisJobAPI{ search: func(_ context.Context, query d.JobSearchQuery, _ ...services.CallOption) (d.JobSearchResult, error) { jobQueries = append(jobQueries, query) + var creation, end *time.Time + if includeTimestamps { + creation, end = &listenerCreated, &listenerEnded + } return d.JobSearchResult{Items: []d.Job{ - {Key: "job-match", Kind: query.Kind, ListenerEventType: "START", State: "CREATED", Type: "audit", Retries: 3, ProcessInstanceKey: root.Key, ElementInstanceKey: "2251799813685250"}, + {Key: "job-match", Kind: query.Kind, ListenerEventType: "START", State: "CREATED", Type: "audit", Retries: 3, CreationTime: creation, EndTime: end, ProcessInstanceKey: root.Key, ElementInstanceKey: "2251799813685250"}, {Key: "job-unmatched-element", Kind: query.Kind, ProcessInstanceKey: root.Key, ElementInstanceKey: "missing"}, {Key: "job-other-process", Kind: query.Kind, ProcessInstanceKey: "other", ElementInstanceKey: "2251799813685250"}, }}, nil @@ -998,7 +1005,30 @@ func TestSlowProcessAnalysisWithListenersAttachesOnlyMatchingElementJobs(t *test elementRows := slowProcessAnalysisTimelineElements(got.Items[0].Timeline) require.NotNil(t, elementRows[0].Listeners) require.Equal(t, []string{"job-match", "job-match"}, []string{(*elementRows[0].Listeners)[0].JobKey, (*elementRows[0].Listeners)[1].JobKey}) + require.Equal(t, &listenerCreated, (*elementRows[0].Listeners)[0].CreationTime) + require.Equal(t, &listenerEnded, (*elementRows[0].Listeners)[0].EndTime) + require.EqualValues(t, 10*time.Minute/time.Millisecond, got.Items[0].DurationMillis) require.Equal(t, []d.RuntimeListenerJob{}, *elementRows[1].Listeners) + + // Run the same analysis without lifecycle facts. Clear only those added + // fields before comparing the complete result, including every duration, + // ranking, transition, filter outcome, and listener association. + includeTimestamps = false + baseline, err := NewWithAnalysisDependencies(nil, piAPI, nil, nil, nil, jobAPI, elementAPI, toolx.V88).AnalyseSlowProcessInstances(context.Background(), got.Request) + require.NoError(t, err) + for i := range got.Items { + for j := range got.Items[i].Timeline { + listeners := got.Items[i].Timeline[j].Listeners + if listeners == nil { + continue + } + for k := range *listeners { + (*listeners)[k].CreationTime = nil + (*listeners)[k].EndTime = nil + } + } + } + require.Equal(t, baseline, got) } // TestSlowProcessAnalysisWithoutListenersDoesNotLookupJobs verifies default output remains listener-free. diff --git a/internal/services/processinstance/enrichment_test.go b/internal/services/processinstance/enrichment_test.go index 3402091c..19b1996a 100644 --- a/internal/services/processinstance/enrichment_test.go +++ b/internal/services/processinstance/enrichment_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "testing" + "time" d "github.com/grafvonb/c8volt/internal/domain" "github.com/grafvonb/c8volt/internal/services" @@ -202,6 +203,8 @@ func TestEnrichProcessInstancesWithElementsEmitsFrozenProgress(t *testing.T) { func TestEnrichProcessInstancesWithElementListenersAttachesByOwnerAndOmitsUnmatched(t *testing.T) { elementCalls := []string{} jobCalls := []d.JobSearchQuery{} + creation := time.Date(2026, 9, 16, 13, 7, 16, 359000000, time.FixedZone("UTC+2", 2*60*60)) + end := time.Date(2026, 9, 16, 13, 7, 16, 842000000, time.FixedZone("UTC+2", 2*60*60)) got, err := EnrichProcessInstancesWithElementListeners(context.Background(), stubElementSearcher{ search: func(_ context.Context, query d.ElementSearchQuery, opts ...services.CallOption) (d.ElementSearchResult, error) { @@ -238,7 +241,7 @@ func TestEnrichProcessInstancesWithElementListenersAttachesByOwnerAndOmitsUnmatc }}, nil case "pi-2/" + d.JobKindTaskListener: return d.JobSearchResult{Items: []d.Job{ - {Key: "job-2", Kind: d.JobKindTaskListener, ListenerEventType: "COMPLETING", Type: "review-listener", State: "CREATED", Retries: 3, ProcessInstanceKey: "pi-2", ElementInstanceKey: "el-2", ElementId: "review", Worker: "worker-a"}, + {Key: "job-2", Kind: d.JobKindTaskListener, ListenerEventType: "COMPLETING", Type: "review-listener", State: "CREATED", Retries: 3, Worker: "worker-a", CreationTime: &creation, EndTime: &end, ProcessInstanceKey: "pi-2", ElementInstanceKey: "el-2", ElementId: "review"}, {Key: "job-1", Kind: d.JobKindTaskListener, ListenerEventType: "CREATING", Type: "review-listener", State: "CREATED", Retries: 1, ProcessInstanceKey: "pi-2", ElementInstanceKey: "el-2", ElementId: "review"}, }}, nil case "pi-1/" + d.JobKindExecutionListener, "pi-1/" + d.JobKindTaskListener: @@ -267,7 +270,7 @@ func TestEnrichProcessInstancesWithElementListenersAttachesByOwnerAndOmitsUnmatc require.Empty(t, *got.Items[0].Elements[0].Listeners) require.Equal(t, []d.RuntimeListenerJob{ {JobKey: "job-1", Kind: d.JobKindTaskListener, ListenerEventType: "CREATING", Type: "review-listener", State: "CREATED", Retries: 1, ProcessInstanceKey: "pi-2", ElementInstanceKey: "el-2", ElementId: "review"}, - {JobKey: "job-2", Kind: d.JobKindTaskListener, ListenerEventType: "COMPLETING", Type: "review-listener", State: "CREATED", Retries: 3, Worker: "worker-a", ProcessInstanceKey: "pi-2", ElementInstanceKey: "el-2", ElementId: "review"}, + {JobKey: "job-2", Kind: d.JobKindTaskListener, ListenerEventType: "COMPLETING", Type: "review-listener", State: "CREATED", Retries: 3, Worker: "worker-a", CreationTime: &creation, EndTime: &end, ProcessInstanceKey: "pi-2", ElementInstanceKey: "el-2", ElementId: "review"}, }, *got.Items[0].Elements[1].Listeners) require.Equal(t, []d.RuntimeListenerJob{ {JobKey: "job-3", Kind: d.JobKindExecutionListener, ListenerEventType: "END", Type: "ship-listener", State: "FAILED", Retries: 0, ProcessInstanceKey: "pi-2", ElementInstanceKey: "el-3", ElementId: "ship", ErrorCode: "E_SHIP"}, diff --git a/specs/321-listener-timestamps/checklists/requirements.md b/specs/321-listener-timestamps/checklists/requirements.md new file mode 100644 index 00000000..6e4bfb3c --- /dev/null +++ b/specs/321-listener-timestamps/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Consistent Listener Timestamps + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-09-16 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Review passed all 16 items. No clarification markers or unresolved quality issues remain. +- Command names, timestamp tags, and JSON field names describe required user-visible contracts, not implementation choices. +- Story 1 covers FR-002–FR-004; Story 2 covers FR-005–FR-006 and FR-009–FR-010; Story 3 covers FR-001 and FR-007–FR-008. Edge cases extend coverage to both listener kinds and version-specific missing data. +- Explicit assumptions bound standalone human job output and preserve programmatic deadlines. No implementation architecture is prescribed. +- Items marked incomplete require spec updates before `$speckit-clarify` or `$speckit-plan`. diff --git a/specs/321-listener-timestamps/contracts/listener-timestamps.md b/specs/321-listener-timestamps/contracts/listener-timestamps.md new file mode 100644 index 00000000..43066d0b --- /dev/null +++ b/specs/321-listener-timestamps/contracts/listener-timestamps.md @@ -0,0 +1,55 @@ +# Contract: Listener Timestamp Output + +## Affected commands + +- `get element --with-listeners` (keyed and existing search forms). +- `get process-instance --with-elements --with-listeners`. +- `walk process-instance --with-elements --with-listeners` (existing supported tree and flat modes). +- `ops analyse slow-process-instances --with-listeners`. + +Existing aliases, required context, output-mode validation, exit codes, and unsupported-version errors remain unchanged. + +## Human rows + +The existing identity/status/retry/worker columns are followed by three optional timestamp columns in this order, then existing error columns: + +| Tag | Source | Visibility | +| --- | --- | --- | +| `s:` | Job creation time | Whenever present, for any state. | +| `e:` | Job end time | Whenever present, for any state. | +| `d:` | Activation deadline | Only when present and state is exactly `ACTIVATED`. | + +Creation time is not worker execution start. End time is not a deadline. Missing values omit tags; internal blank alignment columns may remain to align mixed rows, but no placeholder timestamp is printed. Execution and task listeners use the same rules. Completed, canceled, failed, created, blank, and unfamiliar states never show `d:`. + +Reuse fixed-millisecond full-date/time formatting and `app.show_timezone_offset`. For example, a compact completed row with no worker or error fields can read: + +```text +job-1 TASK_LISTENER lsnr:CREATING COMPLETED tp:updateTaskData r:0 s:2026-09-16T13:07:16.359 e:2026-09-16T13:07:16.842 +``` + +With numeric offset display enabled, the corresponding supplied offset is appended to each timestamp. Tree prefixes and row alignment remain command-specific. Do not shorten timestamps to time-only as a consequence of the issue's illustrative example. + +## Public and JSON contracts + +`job.Job` and the listener objects in element, process, and ops expose `CreationTime` and `EndTime` as optional times. JSON encodes these as `creationTime` and `endTime`. Standard time JSON formatting preserves the instant; it need not duplicate the millisecond-only human rendering. Nil values are omitted, not emitted as null, empty strings, or zero-time defaults. + +Example fragment within an existing listener collection: + +```json +{ + "jobKey": "job-1", + "kind": "TASK_LISTENER", + "listenerEventType": "CREATING", + "state": "COMPLETED", + "retries": 0, + "creationTime": "2026-09-16T13:07:16.359Z", + "endTime": "2026-09-16T13:07:16.842Z", + "deadline": "2026-09-16T13:08:00Z" +} +``` + +This is a fragment, not a new envelope. Preserve each command's existing envelope and nesting, existing fields, requested-empty listener arrays, and omission of unrequested listeners. Public standalone job JSON gains the same optional timestamp fields while its existing key naming remains unchanged. Human standalone job output is outside this change. + +## Invariants + +Rendering adds no requests. Adding timestamps changes neither listener association nor result order. Process/element durations and slow-analysis results are unchanged for identical inputs. Existing output without enrichment is unchanged apart from the specified additive fields where public job objects are already serialized. Results stay on stdout and existing diagnostics/control text stay on stderr. diff --git a/specs/321-listener-timestamps/data-model.md b/specs/321-listener-timestamps/data-model.md new file mode 100644 index 00000000..cd21c5c8 --- /dev/null +++ b/specs/321-listener-timestamps/data-model.md @@ -0,0 +1,40 @@ +# Data Model: Listener Timestamps + +## Entities and fields + +| Representation | Location | Change | +| --- | --- | --- | +| Job | `internal/domain/job.go` | Add optional creation and end times. | +| RuntimeListenerJob | `internal/domain/job.go` | Add the same fields for element-owned listener projections. | +| Job | `c8volt/job/model.go` | Expose both optional values to public job consumers. | +| RuntimeListenerJob | `c8volt/element/model.go`, `c8volt/process/model.go`, `c8volt/ops/model.go` | Expose the same optional values in each existing facade. | + +Each new field uses the same representation everywhere: + +| Go field | Type | JSON name | Meaning | +| --- | --- | --- | --- | +| CreationTime | `*time.Time` | `creationTime,omitempty` | Recorded job creation instant, not worker execution start. | +| EndTime | `*time.Time` | `endTime,omitempty` | Recorded job end instant; not inferred from state or deadline. | + +Existing `Deadline *time.Time` with `deadline,omitempty` and `State string` remain unchanged. The state controls human deadline visibility only. Nil is absence, and fields are independent. No synthetic defaults, chronology correction, or conversion to local wall time is introduced. Existing source timestamp decoding errors retain their existing handling; this feature does not add an alternative parser. + +## Mapping and relationships + +1. Generated `JobSearchResult` in each v88/v89/v810 client supplies optional pointers. +2. Version-owned `fromJobSearchResult` maps them to domain `Job`. +3. Public job `fromDomainJob` preserves them for get, search, and paged results. +4. `RuntimeListenerJobFromJob` preserves them when a job becomes an element-owned listener record. +5. Existing element/process enrichment attaches records by element instance ownership. Unmatched jobs remain omitted and existing ordering is preserved. Slow analysis continues consuming these records without recalculating listener-based durations. +6. Each facade's `fromDomainRuntimeListenerJob` preserves both values in the public listener object. + +Retain `Listeners *[]RuntimeListenerJob` semantics: nil means not requested; a pointer to an empty slice means requested with no matches. Adding timestamp fields must not change that distinction. + +## Validation and lifecycle rules + +- Non-nil creation/end values retain the same instant and timezone offset through mappings. +- Missing or null source values remain nil and are omitted from public JSON. +- Human rows show each available creation/end time independently of state. +- Only exact `ACTIVATED` plus non-nil deadline produces human `d:`. +- JSON preserves supplied deadlines in every state. +- No new state transitions, mutations, persistence, migrations, duration values, or validation of lifecycle ordering are introduced. +- v87 lookup remains unsupported; v88/v89/v810 responses with missing fields remain valid. diff --git a/specs/321-listener-timestamps/plan.md b/specs/321-listener-timestamps/plan.md new file mode 100644 index 00000000..b2804a0e --- /dev/null +++ b/specs/321-listener-timestamps/plan.md @@ -0,0 +1,111 @@ +# Implementation Plan: Consistent Listener Timestamps + +**Branch**: `codex/321-listener-timestamps` | **Date**: 2026-09-16 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/321-listener-timestamps/spec.md`, issue #321. + +## Summary + +Preserve job creation and end timestamps through the existing versioned service, domain, and public facade mappings. Display them as `s:` and `e:` on listener rows in element inspection, process inspection, process walking, and slow-process analysis. Restrict human `d:` to `ACTIVATED` jobs while retaining recorded deadlines in JSON. Reuse the existing formatter and a narrow shared listener timestamp-column helper. Keep discovery, grouping, and duration calculations unchanged. + +## Technical Context + +**Language/Version**: Go 1.26; repository toolchain go1.26.2. + +**Primary Dependencies**: Existing Cobra 1.10.2, standard `time` and `encoding/json`, generated Camunda clients, testify 1.11.1, and repository `toolx`/`testx` helpers. No dependency changes. + +**Storage**: None added; timestamp fields remain in existing in-memory response models. + +**Testing**: Go tests with existing fake services, HTTP fixtures, and command subprocess helpers; targeted checks first. Planning artifacts receive only documentation checks. + +**Target Platform**: Existing cross-platform CLI and Go library environments; no platform-specific behavior added. + +**Project Type**: CLI and public Go facade library. + +**Performance Goals**: Zero additional discovery requests; constant additional formatting work per displayed listener. No new timers, loops, or polling. + +**Constraints**: Optional values remain independently nil; preserve instants, configured timestamp formatting, JSON envelopes, listener-array omission/empty distinctions, and recorded deadlines. Camunda v87 job lookup remains unsupported. No generated files are hand-edited. + +**Scale/Scope**: Three versioned job converters; two domain structs; four public model/conversion boundaries; three listener row renderers covering four commands; associated tests and command documentation. + +## Constitution Check + +*Pre-research gate: PASS. Post-design gate: PASS. No exceptions.* + +| Principle | Design evidence | +| --- | --- | +| I. Operational proof | Read-only display correction uses recorded facts only; no fabricated completion, mutation, or new duration claims. | +| II. CLI-first and script-safe | Existing commands/flags, exit handling, streams, and envelopes remain. Available JSON timestamps are additive; non-active human deadline removal is the intentional compatibility correction documented below. | +| III. Proportional validation | This planning change uses whitespace, reference, and completeness checks only. Implementation uses targeted mapping, enrichment, and command tests, with broader testing only if evidence warrants it. | +| IV. Documentation | Implementation updates README and four command descriptions/examples and runs `make docs-content`; generated docs are not hand-edited. | +| V. Repository-native changes | Reuse adapters, thin facade mappings, time helpers, and flat-row alignment. One narrow private view helper avoids duplicated timestamp rules; no public abstraction or dependency is introduced. | + +Repository boundaries are respected: commands do not access generated clients; adapters own version-specific conversion; facades only map data; enrichment mechanics remain in internal services. No distinct command lifecycle is added, so the command-mode file rule is not triggered. + +## Project Structure + +### Documentation (this feature) + +```text +specs/321-listener-timestamps/ +├── spec.md +├── checklists/requirements.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +└── contracts/listener-timestamps.md +``` + +`tasks.md` is the next workflow output and is not generated by this command. + +### Source Code (repository root) + +```text +internal/domain/job.go +internal/services/job/{v88,v89,v810}/convert.go +internal/services/job/{v88,v89,v810}/service_test.go +internal/services/job/v87/ # preserve unsupported behavior +internal/services/element/enrichment.go # existing attachment; regression coverage +internal/services/processinstance/enrichment.go +internal/services/ops/slow_process_analysis.go +c8volt/job/{model.go,client.go,client_test.go} +c8volt/{element,process,ops}/{model.go,convert.go,client_test.go} +cmd/cmd_views_listener.go # new narrow timestamp-column helper +cmd/cmd_views_element.go +cmd/cmd_views_processinstance_activity.go # shared get/walk rendering +cmd/cmd_views_ops_slow_process_analysis.go +cmd/{get_element,get_processinstance,walk_processinstance,ops_analyse_slow_process_instances}.go +cmd/{get_element_test,get_processinstance_test,walk_test,ops_analyse_slow_process_instances_test}.go +toolx/timestamp.go # reuse without changing formatting +README.md +docs/cli/ # regenerate from command source +``` + +**Structure Decision**: Extend existing owning packages and their adjacent tests. Preserve separate public listener types. Limit the new helper to timestamp columns in the command-view layer, with no transport dependency. + +## Phase 0: Research Outcomes + +[research.md](research.md) resolves field availability, conversion ownership, rendering reuse, compatibility, and validation. Checked-in v88/v89/v810 clients already contain optional creation/end timestamps. Model presence is not a runtime guarantee; actual response availability controls display. No open technical questions remain. + +## Phase 1: Design + +1. Extend domain jobs/listeners and the four public job/listener models with optional `CreationTime` and `EndTime` fields. Map source values through all three adapters, `RuntimeListenerJobFromJob`, `fromDomainJob`, and each facade's listener conversion. Preserve nils and deadline data. +2. Add the shared three-column helper described in [research.md](research.md). Insert its fixed-position `s:`, `e:`, `d:` columns where the current deadline column appears, after worker and before error fields. Existing empty-column alignment removes absent tags without shifting other fields. +3. Use the helper in all three listener row builders. Reuse the existing timezone-offset setting and `toolx.FormatTime`. Do not modify standalone human job formatting or element/process duration paths. +4. Extend adapter and facade tests to prove supplied/absent fields survive. Extend command execution fixtures across all four commands to prove transport-to-output behavior and JSON compatibility, supplemented by a focused state/optional-column matrix. +5. Update README and command help metadata to describe creation versus execution start, end times, activated-only deadlines, and missing-value behavior. Regenerate affected CLI references and review the generated diff. + +Entity and mapping details are in [data-model.md](data-model.md); observable grammar and JSON expectations are in [contracts/listener-timestamps.md](contracts/listener-timestamps.md). Runnable validation is in [quickstart.md](quickstart.md). + +## Compatibility and Validation Strategy + +Human listener output intentionally gains available `s:`/`e:` values and loses `d:` for every non-`ACTIVATED` state. Preserve all other identity, retry, worker, error, nesting, and sorting behavior. Public JSON adds optional fields without renaming existing ones, and keeps non-active deadlines. New exported struct fields are additive for normal keyed-field consumers; external unkeyed struct literals may require adjustment, as with any public struct extension. + +Test completed, activated, canceled, created, failed, missing, and unfamiliar states; both listener kinds; independently absent creation/end/deadline; mixed rows; numeric offsets and millisecond formatting. Exercise element keyed/search paths, process get, walk default/children/parent/flat modes, and slow analysis using existing fixtures. Capture stdout/stderr separately in new command tests and decode JSON to EOF so additional text cannot silently pass. Retain no-listener, unsupported-version, without-enrichment, validation/error, and unchanged-duration regression coverage. No prompt or mutation logic changes, so new PTY or mutation test matrices are unnecessary. + +Start with targeted tests listed in quickstart. Broaden to `make test` only if the implementation changes shared behavior beyond the bounded mapping/rendering paths, concurrency/generated clients/dependencies, or failures reveal unresolved wider impact. Do not run the full suite solely to commit or finish a workflow step. + +## Complexity Tracking + +No constitution violations or additional complexity exceptions. The private three-column helper is justified by three existing duplicate deadline-rendering sites and preserves existing package ownership. diff --git a/specs/321-listener-timestamps/progress.md b/specs/321-listener-timestamps/progress.md new file mode 100644 index 00000000..c32b268e --- /dev/null +++ b/specs/321-listener-timestamps/progress.md @@ -0,0 +1,181 @@ +# Ralph Progress Log + +Feature: 321-listener-timestamps +Started: 2026-09-16 04:43:39 + +--- + +## Iteration 1 - 2026-09-16 04:44 +**Work Unit**: Setup — verify implementation context +**Tasks Completed**: +- [x] T001: Verify repository guidance, feature pointer, branch, test helpers, and Go toolchain +**Tasks Remaining in Work Unit**: 0 +**Commit**: This work-unit commit +**Files Changed**: +- specs/321-listener-timestamps/tasks.md +- specs/321-listener-timestamps/ralph-memory.md +- specs/321-listener-timestamps/progress.md +**Learnings**: +- Branch, feature pointer, architecture guidance, and Go 1.26.2 toolchain are consistent; reusable command-test helpers already cover separate streams and versioned HTTP fixtures. +--- + +## Iteration 2 - 2026-09-16 04:47 +**Work Unit**: Foundational — preserve domain listener timestamps +**Tasks Completed**: +- [x] T002: Add listener projection regression coverage for optional lifecycle timestamps +- [x] T003: Add and map optional creation and end timestamps in domain jobs +**Tasks Remaining in Work Unit**: T004–T007 +**Commit**: This work-unit commit +**Files Changed**: +- internal/domain/job.go +- internal/domain/job_test.go +- specs/321-listener-timestamps/tasks.md +- specs/321-listener-timestamps/ralph-memory.md +- specs/321-listener-timestamps/progress.md +**Learnings**: +- Direct pointer projection preserves timestamp instants and numeric offsets; domain JSON omits nil lifecycle fields while retaining non-active deadlines. +--- + +## Iteration 3 - 2026-09-16 04:52 +**Work Unit**: Foundational — preserve adapter listener timestamps +**Tasks Completed**: +- [x] T004: Preserve optional creation and end timestamps in v8.8 job conversion +- [x] T005: Preserve optional creation and end timestamps in v8.9 job conversion +- [x] T006: Preserve optional creation and end timestamps in v8.10 job conversion +- [x] T007: Validate domain, supported adapters, v8.7 unsupported behavior, and generated-client stability +**Tasks Remaining in Work Unit**: 0 +**Commit**: This work-unit commit +**Files Changed**: +- internal/services/job/v88/convert.go +- internal/services/job/v88/service_test.go +- internal/services/job/v89/convert.go +- internal/services/job/v89/service_test.go +- internal/services/job/v810/convert.go +- internal/services/job/v810/service_test.go +- specs/321-listener-timestamps/tasks.md +- specs/321-listener-timestamps/ralph-memory.md +- specs/321-listener-timestamps/progress.md +**Learnings**: +- All supported generated job rows expose optional timestamp pointers; direct adapter mapping preserves supplied offsets and independent absence without new requests or version gates. +--- + +## Iteration 4 - 2026-09-16 04:59 +**Work Unit**: User Story 1 — Distinguish Listener Creation, End, and Deadline +**Tasks Completed**: +- [x] T008: Add listener timestamp-column and rendered-row regression coverage +- [x] T009: Extend keyed and search element execution fixtures with timestamp-bearing listeners +- [x] T010: Preserve optional listener timestamps through the public element facade +- [x] T011: Render fixed creation, end, and activated-only deadline columns in element listener rows +- [x] T012: Document listener timestamp meanings and regenerate element CLI documentation +- [x] T013: Validate the US1 facade, command, acceptance scenarios, and generated documentation +**Tasks Remaining in Work Unit**: 0 +**Commit**: This work-unit commit +**Files Changed**: +- README.md +- c8volt/element/client_test.go +- c8volt/element/convert.go +- c8volt/element/model.go +- cmd/cmd_views_element.go +- cmd/cmd_views_element_test.go +- cmd/cmd_views_listener.go +- cmd/cmd_views_listener_test.go +- cmd/command_contract_test.go +- cmd/get_element.go +- cmd/get_element_test.go +- docs/cli/c8volt_get_element.md +- docs/index.md +- specs/321-listener-timestamps/tasks.md +- specs/321-listener-timestamps/ralph-memory.md +- specs/321-listener-timestamps/progress.md +**Learnings**: +- A fixed three-column helper preserves flat-row alignment while making lifecycle timestamp semantics reusable; command execution fixtures confirm mapping survives transport without additional requests. +--- + +## Iteration 5 - 2026-09-16 05:10 +**Work Unit**: User Story 2 — Read the Same Timeline Across Investigation Commands +**Tasks Completed**: +- [x] T014: Add process get/walk timestamp rendering regressions across keyed, list, family, children, parent, and flat paths +- [x] T015: Add slow-analysis timestamp rendering and normal/full-timeline command execution regressions +- [x] T016: Preserve process facade listener timestamps and reuse the shared human timestamp grammar +- [x] T017: Preserve ops facade listener timestamps and reuse the shared human timestamp grammar +- [x] T018: Verify enrichment retains timestamps without changing ownership, requests, durations, or analysis outcomes +- [x] T019: Document the common listener timestamp contract and regenerate affected CLI references +- [x] T020: Validate US2 facade, enrichment, command, and unsupported-version regression selections +**Tasks Remaining in Work Unit**: 0 +**Commit**: This work-unit commit +**Files Changed**: +- README.md +- c8volt/process/model.go +- c8volt/process/convert.go +- c8volt/process/client_test.go +- c8volt/ops/model.go +- c8volt/ops/convert.go +- c8volt/ops/client_test.go +- cmd/cmd_views_processinstance_activity.go +- cmd/cmd_views_processinstance_activity_test.go +- cmd/cmd_views_ops_slow_process_analysis.go +- cmd/cmd_views_ops_slow_process_analysis_test.go +- cmd/get_processinstance.go +- cmd/get_processinstance_test.go +- cmd/walk_processinstance.go +- cmd/walk_test.go +- cmd/ops_analyse_slow_process_instances.go +- cmd/ops_analyse_slow_process_instances_test.go +- internal/services/element/enrichment_test.go +- internal/services/processinstance/enrichment_test.go +- internal/services/ops/slow_process_analysis_test.go +- docs/cli/c8volt_get_process-instance.md +- docs/cli/c8volt_walk_process-instance.md +- docs/cli/c8volt_ops_analyse_slow-process-instances.md +- docs/index.md +- specs/321-listener-timestamps/tasks.md +- specs/321-listener-timestamps/ralph-memory.md +- specs/321-listener-timestamps/progress.md +**Learnings**: +- The shared fixed-column helper applies cleanly to process and slow-analysis views; existing enrichment already preserves projected timestamps without affecting timeline calculations or discovery counts. +--- + +## Iteration 6 - 2026-09-16 05:18 +**Work Unit**: User Story 3 — Retain Available Timestamps for Programmatic Consumers +**Tasks Completed**: +- [x] T021: Cover public job get, search, collected-page, and single-page timestamp conversion and JSON omission +- [x] T022: Verify all four command JSON execution paths preserve listener timestamps and existing envelopes +- [x] T023: Verify public listener JSON names, optional values, deadlines, offsets, and collection states +- [x] T024: Expose optional creation and end timestamps through the public job model and converter +- [x] T025: Validate the complete public job and listener programmatic contract +**Tasks Remaining in Work Unit**: 0 +**Commit**: This work-unit commit +**Files Changed**: +- c8volt/element/client_test.go +- c8volt/job/client.go +- c8volt/job/client_test.go +- c8volt/job/model.go +- c8volt/ops/client_test.go +- c8volt/process/client_test.go +- cmd/get_element_test.go +- cmd/get_processinstance_test.go +- cmd/ops_analyse_slow_process_instances_test.go +- cmd/walk_test.go +- specs/321-listener-timestamps/tasks.md +- specs/321-listener-timestamps/ralph-memory.md +- specs/321-listener-timestamps/progress.md +**Learnings**: +- The shared public job converter covers direct, search, visitor-page, and single-page results; one-document command assertions close the remaining JSON contract gap without altering request behavior. +--- + +--- +## Iteration 7 - 2026-09-16 05:21 +**Work Unit**: Polish & Cross-Cutting Concerns — final audit and validation reconciliation +**Tasks Completed**: +- [x] T026: Audit FR-001–FR-010, the listener timestamp contract, documentation consistency, formatting, and whitespace +- [x] T027: Reconcile targeted validation evidence, actual regression names, and material gaps in the quickstart +**Tasks Remaining in Work Unit**: 0 +**Commit**: This work-unit commit +**Files Changed**: +- specs/321-listener-timestamps/quickstart.md +- specs/321-listener-timestamps/tasks.md +- specs/321-listener-timestamps/ralph-memory.md +- specs/321-listener-timestamps/progress.md +**Learnings**: +- The final diff satisfies the functional and output contracts without broader-impact changes; targeted checks were sufficient, while the full race suite and optional live inspection remain explicitly unexecuted. +--- diff --git a/specs/321-listener-timestamps/quickstart.md b/specs/321-listener-timestamps/quickstart.md new file mode 100644 index 00000000..65e6014d --- /dev/null +++ b/specs/321-listener-timestamps/quickstart.md @@ -0,0 +1,87 @@ +# Validation Guide: Listener Timestamps + +## Prerequisites + +Run from the repository root on `codex/321-listener-timestamps`, after implementation. Use the Go version/toolchain declared in `go.mod`. Automated tests use local fixtures and do not require a Camunda server. The commands below are implementation validation instructions, not claims that tests have already run. + +## Targeted automated validation + +```sh +go test ./internal/services/job/... -run 'TestSearchJobsByKey|TestService_SearchJobs|Timestamp' -count=1 +go test ./internal/domain -run 'RuntimeListenerJob|Timestamp' -count=1 +go test ./c8volt/job -run 'TestClient_GetJob|TestClient_SearchJobs|Timestamp' -count=1 +go test ./c8volt/element ./c8volt/process ./c8volt/ops -run 'Listener|Timestamp' -count=1 +go test ./internal/services/element/... ./internal/services/processinstance/... ./internal/services/ops/... -run 'Listener|SlowProcessAnalysis' -count=1 +go test ./cmd -run 'Listener|Timestamp|SlowProcessAnalysis' -count=1 +``` + +Extend existing test names or use a `Timestamp`/`Listener` name for new coverage so these patterns select the new cases. Confirm tests actually execute; a no-tests match is not validation. Include public job page conversion where its existing test name falls outside these patterns by running that test explicitly. + +Expected proofs: + +1. v88/v89/v810 mapping retains distinct source times; missing-field fixtures still succeed; v87 unsupported behavior stays intact. +2. Every public mapping preserves both values independently; absent fields disappear from JSON while non-active deadlines remain. +3. All four commands obey the [output contract](contracts/listener-timestamps.md). Use command execution fixtures to detect mapping loss, not just constructed view objects. +4. Row tests cover both listener kinds, completed/activated/canceled/created/failed/blank/unknown states, all creation/end presence combinations, absent deadlines, and mixed rows with error fields. Assert tag order, stable alignment, no fabricated values, and existing offset formatting. +5. Command JSON tests capture stdout and stderr separately, decode exactly one existing result envelope and require EOF. Preserve requested-empty versus unrequested arrays, mode validation, error behavior, and ordinary output without listeners. +6. Reuse walk default/children/parent/flat coverage and slow-analysis fixtures to assert unchanged grouping, durations, and analysis outcomes with timestamp-bearing listener data. + +## Optional live inspection + +Use an existing configured Camunda environment that supports listener lookup and has known element/process keys with retained listener jobs. Set shell variables to those keys and a valid configuration file. Select a slow-analysis target that satisfies existing analysis selection criteria. Live environments may omit historical timestamps; controlled fixtures above remain authoritative for the full matrix. + +```sh +make build +./bin/c8volt --config "$C8VOLT_CONFIG" get element --key "$ELEMENT_KEY" --with-listeners +./bin/c8volt --config "$C8VOLT_CONFIG" get process-instance --key "$PROCESS_KEY" --with-elements --with-listeners +./bin/c8volt --config "$C8VOLT_CONFIG" walk process-instance --key "$PROCESS_KEY" --with-elements --with-listeners +./bin/c8volt --config "$C8VOLT_CONFIG" ops analyse slow-process-instances --key "$PROCESS_KEY" --with-listeners +./bin/c8volt --config "$C8VOLT_CONFIG" --json get element --key "$ELEMENT_KEY" --with-listeners +``` + +Repeat each affected command with `--json` and compare the timestamps in its existing nested result structure. Use a copy of the configuration with `app.show_timezone_offset: true` to verify numeric offsets, then compare with the default false setting. Do not interpret absent `e:` as a fabricated completion or display a retained completed-job deadline as `d:`. Inspect equivalent results without listener enrichment to confirm unchanged element/process durations. + +## Documentation and final checks + +Update README and the four command descriptions/examples, then run: + +```sh +make docs-content +git diff --check +``` + +Review generated CLI references and README-derived documentation for consistent tag definitions and the completed-listener example. Run `gofmt` on touched Go files during implementation. Broaden to `make test` only if targeted failures or a wider actual diff justify it, as described in [plan.md](plan.md). + +For planning-only edits, validate Markdown links, required artifacts, whitespace, and absence of unresolved placeholders; do not run runtime tests or regenerate command documentation. + +## Recorded implementation validation + +The targeted checks above were executed successfully during Ralph iterations 2–6, with these explicit additions and narrower selections where the consolidated patterns do not name every regression: + +- `go test ./internal/services/job/v87 -run 'TestService_GetJob_Unsupported|TestService_SearchJobs_Unsupported' -count=1 -v` passed, preserving the unsupported-version boundary. +- `go test ./c8volt/job -run 'TestClient_SearchJobsPage_OmitsMissingTimestamps' -count=1` passed, covering the single-page conversion not uniquely selected by the consolidated job pattern. +- The element command selection included keyed and search human output, JSON output, help, validation, and the shared timestamp-column tests. +- The process/walk/slow-analysis command selection included keyed, list, family, children, parent, flat, normal-timeline, full-timeline, JSON, and v8.7 unsupported paths. +- Original facade and enrichment selections covered populated, requested-empty, and unrequested listener collections, retained deadlines, timestamp transport, and ownership/request assertions. Independent optional-field coverage was distributed across the suite; the listener-enriched analysis test checked the root duration but did not yet compare complete analysis results. The review follow-up below closes those specific gaps. +- `make docs-content` completed after the command-source guidance changes. Final review confirmed the four generated command references match their source descriptions, README and generated index use the same timestamp definitions, all touched Go files produce an empty `gofmt -d`, and `git diff --check main...HEAD` passes. + +No targeted failures or broader-impact changes required `make test`, so the full race suite was not rerun. Optional live inspection was not performed; controlled fixtures remain the authoritative validation for missing and independently populated timestamps. + +## Review follow-up validation + +The follow-up changes tests and validation evidence only; production code and generated command documentation are unchanged. + +- Deadline suppression assertions for process get and walk now reject a `d:` token anywhere on the target listener row, including after creation/end tags. +- `TestListenerTimestampCommandsHonorTimezoneConfig` executes all four commands with `app.show_timezone_offset` explicitly false and true. It verifies exact creation/end/deadline tokens, clean stderr, and two listener discovery requests for each case, including an activated listener with an end time. +- Each facade's `TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates` now sends both, creation-only, end-only, and neither-present domain listener timestamps through its converter and JSON marshaler, checking omission, supplied values, and retained canceled-job deadlines. +- `TestSlowProcessAnalysisWithListenersAttachesOnlyMatchingElementJobs` now runs identical input with and without listener lifecycle timestamps. After clearing only those timestamp fields, it compares the complete analysis result, including process/element/transition durations, rankings, and associations for that fixture. + +The following targeted checks passed after these changes: + +```sh +go test ./cmd -run 'TestListenerTimestampCommandsHonorTimezoneConfig|TestGetProcessInstanceWithElementsAndListeners_Human|TestWalkProcessInstanceCommand_WithListenersFamily' -count=1 +go test ./c8volt/element ./c8volt/process ./c8volt/ops -run '^TestRuntimeListenerJobJSONPreservesTimestampsAndCollectionStates$' -count=1 +go test ./internal/services/ops -run '^TestSlowProcessAnalysisWithListenersAttachesOnlyMatchingElementJobs$' -count=1 +``` + +Touched Go files were formatted and `git diff --check` passed. These are focused fixture-based checks, not an exhaustive cross-product of every command mode, lifecycle state, and timestamp combination. Full race-suite and live-server validation remain unperformed. Existing Ralph commit subjects remain unchanged; future feature commits should explicitly reference #321 rather than relying on issue-number inference from the namespaced branch. diff --git a/specs/321-listener-timestamps/ralph-memory.md b/specs/321-listener-timestamps/ralph-memory.md new file mode 100644 index 00000000..85030d54 --- /dev/null +++ b/specs/321-listener-timestamps/ralph-memory.md @@ -0,0 +1,48 @@ +# Ralph Memory + +Feature: 321-listener-timestamps +Started: 2026-09-16T02:43:39Z + +## Codebase Patterns + +- Command execution tests can use `testx.RunCmdSubprocessInDirWithSeparateOutputs`; HTTP fixtures use `testx.WriteTestConfigForVersion` and `testx.NewIPv4Server`. +- Concurrent request observations use `testx.SafeSlice` and `testx.AtomicCounter`. +- Domain listener projection copies optional timestamp pointers directly; table-driven tests in `internal/domain/job_test.go` cover independent absence, offsets, and non-active deadlines. +- Versioned job adapters map optional generated `CreationTime` and `EndTime` pointers directly in `fromJobSearchResult`; the existing get/search fixtures are the narrow regression seam for all supported versions. +- Element facade listener conversion preserves optional creation/end pointers while retaining nil-versus-requested-empty listener collection semantics. +- `cmd/listenerTimestampColumns` returns fixed `s:`, `e:`, `d:` positions, formats through `toolx.FormatTime`, and exposes deadlines only for exact `ACTIVATED`; element listener rows consume it after worker and before errors. +- Process get/walk share `flatRowProcessInstanceElementListenerWithTimezone`, while slow analysis owns a parallel row builder; both now consume `listenerTimestampColumns` and preserve their surrounding nesting and duration output. +- Process and ops public listener facades copy optional creation/end pointers directly, preserving requested-empty versus unrequested listener collections. +- Public job get, search, collected-page, and single-page results share `fromDomainJob`, which now preserves optional creation/end pointers and `omitempty` JSON behavior without changing standalone human rendering. +- Command JSON execution regressions use `requireSingleJSONObjectDocument` to enforce exactly one envelope plus EOF while checking stdout/stderr separation, offsets, independent omission, retained non-active deadlines, and requested-empty listeners. +- Final FR-001–FR-010 and output-contract review found no gaps: the feature diff is formatted, passes `git diff --check`, preserves request/duration/grouping behavior, and keeps README, command metadata, and generated CLI references consistent. + +## Decisions + +- The active feature pointer, branch, task artifacts, and plan agree on `321-listener-timestamps`; no conflict with `AGENTS.md`, constitution v2.0.0, or `specs/ralph-implementation-rules.md` was found. +- Use the repository-declared Go 1.26 / go1.26.2 toolchain and proportionate targeted validation; setup-only artifact changes do not require runtime tests. +- Preserve supplied timestamps without version cutoffs in v88, v89, and v810; missing/null values remain nil, while v87 retrieval stays explicitly unsupported. + +## Gotchas + +- The issue number is present in the feature name and tasks, but the branch begins with `codex/`; Ralph `commit.issue: auto` therefore does not infer a commit-subject suffix. + +## Reusable Commands + +- `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` +- `go test ./internal/domain -run 'RuntimeListenerJob|Timestamp' -count=1` +- `go test ./internal/services/job/... -run 'TestSearchJobsByKey|TestService_SearchJobs|Timestamp' -count=1` +- `go test ./internal/services/job/v87 -run 'TestService_GetJob_Unsupported|TestService_SearchJobs_Unsupported' -count=1 -v` +- `go test ./c8volt/element -run 'Listener|Timestamp' -count=1` +- `go test ./cmd -run 'GetElement|ElementListener|ListenerTimestamp' -count=1` +- `go test ./c8volt/process ./c8volt/ops -run 'Listener|Timestamp|SlowProcessAnalysis' -count=1` +- `go test ./internal/services/element/... ./internal/services/processinstance/... ./internal/services/ops/... -run 'Listener|SlowProcessAnalysis' -count=1` +- `go test ./cmd -run 'GetProcessInstance|WalkProcessInstance|ProcessInstance.*Listener|SlowProcessAnalysis|OpsAnalyseSlowProcessInstances' -count=1` + +## Do Not Repeat + +- Do not run runtime tests for setup-only or documentation-only task-state changes; use structural and whitespace checks. + +## Current Handoff + +- Feature complete; no handoff required. diff --git a/specs/321-listener-timestamps/research.md b/specs/321-listener-timestamps/research.md new file mode 100644 index 00000000..0b1eabd6 --- /dev/null +++ b/specs/321-listener-timestamps/research.md @@ -0,0 +1,47 @@ +# Research: Consistent Listener Timestamps + +## 1. Timestamp availability and adapter ownership + +**Decision**: Copy `CreationTime` and `EndTime` directly from generated `JobSearchResult` into domain jobs in `internal/services/job/v88/convert.go`, `v89/convert.go`, and `v810/convert.go`, in `fromJobSearchResult`. Leave v87 unsupported. + +**Rationale**: The checked-in generated models in `internal/clients/camunda/{v88,v89,v810}/camunda/client.gen.go` already contain both optional `*time.Time` fields. All three service converters drop them today. The generated creation-time comment says it is present for jobs created after 8.9, so a field's presence in a client does not guarantee its availability in a response. Preserve whatever is actually supplied, rather than hard-coding a version cutoff. `internal/services/job/v87/service.go` explicitly rejects job retrieval/search as requiring 8.8 or newer. + +**Alternatives considered**: Regenerate clients (unnecessary); force timestamps on older jobs or infer from deadlines (incorrect); version-gate otherwise supplied values (would discard valid facts); add unsupported-version fallback (outside scope). + +## 2. Lossless domain and public representations + +**Decision**: Add `CreationTime *time.Time` and `EndTime *time.Time` with `json:"creationTime,omitempty"` and `json:"endTime,omitempty"` to domain `Job` and `RuntimeListenerJob`, public `job.Job`, and public `RuntimeListenerJob` in `element`, `process`, and `ops`. Copy both fields through each existing conversion. + +**Rationale**: `internal/domain/job.go:RuntimeListenerJobFromJob` projects jobs into listener records. `c8volt/job/client.go:fromDomainJob` serves job retrieval and list/page conversion. Each of `c8volt/element/convert.go`, `c8volt/process/convert.go`, and `c8volt/ops/convert.go` owns `fromDomainRuntimeListenerJob`. Updating every boundary prevents the original data-loss bug from moving downstream. The existing deadline already uses an optional time pointer. Omitted and explicit-null source fields decode to nil; both remain omitted in public JSON. + +**Alternatives considered**: Strings (unnecessary parsing and inconsistent with deadline); non-pointer times (cannot reliably distinguish absence); a public shared listener type migration (unrelated compatibility work). + +## 3. Enrichment and operational behavior + +**Decision**: Keep enrichment queries, ownership matching, ordering, pagination, and duration calculations unchanged. Verify that timestamps survive existing enrichment results. + +**Rationale**: `internal/services/element/enrichment.go` and `internal/services/processinstance/enrichment.go` already project jobs through `RuntimeListenerJobFromJob` and attach complete listener records. Slow analysis under `internal/services/ops/slow_process_analysis.go` consumes listener enrichment. The missing values require mapping changes, not additional discovery or calculations. + +**Alternatives considered**: Fetch historical times separately (adds requests and changes availability semantics); derive listener duration or worker execution time (not requested and not supported by creation/end semantics). + +## 4. Consistent human rendering with stable columns + +**Decision**: Add a small command-view helper in `cmd/cmd_views_listener.go` that returns exactly three optional columns in `s:`, `e:`, `d:` order from creation time, end time, deadline, state, and timezone-offset setting. Use it from the existing element, process-activity, and slow-analysis listener row functions. Return empty strings for unavailable or ineligible columns. + +**Rationale**: `flatRowElementListenerWithTimezone`, `flatRowProcessInstanceElementListenerWithTimezone`, and `flatRowOpsSlowProcessAnalysisListenerWithTimezone` repeat the same unconditional deadline rendering today. Process get and walk share the process-activity renderer. `cmd/cmd_views_flat.go:formatFlatRows` aligns by column position, so omitting entries from individual rows would misalign error and timestamp columns in mixed-state results. A narrow helper centralizes the predicate without introducing a new public abstraction or changing surrounding row grammar. + +Use `toolx.FormatTime` unchanged. `toolx/timestamp.go` formats full date/time at millisecond precision and optionally a numeric timezone offset. The issue's shortened time-only example illustrates semantics; it does not authorize replacing the repository's full-date display format. Show `d:` only for exact state `ACTIVATED`. Keep supplied `s:` and `e:` independent of state. Keep the ordinary job renderer in `cmd/cmd_views_job.go` unchanged. + +**Alternatives considered**: Duplicate the predicate in three renderers (risks renewed drift); refactor all listener row fields (broader than needed); change `toolx` formatting (affects unrelated timestamps); suppress deadlines in models (breaks programmatic data). + +## 5. Validation and documentation + +**Decision**: Extend existing fixture-driven service, facade, and command tests. Cover both listener kinds, mixed states and missing-field combinations, JSON omission, all four commands, and duration invariance. Update README and the four command descriptions, then regenerate documentation with `make docs-content` during implementation. + +**Rationale**: Relevant coverage already exists in the three versioned job `service_test.go` files, `c8volt/{job,element,process,ops}/client_test.go`, `internal/services/processinstance/enrichment_test.go`, `internal/services/ops/slow_process_analysis_test.go`, and `cmd/{get_element_test.go,get_processinstance_test.go,walk_test.go,ops_analyse_slow_process_instances_test.go}` plus the three view test files. Existing subprocess/fixture helpers support execution-path checks. `app.show_timezone_offset` is the existing display control; no new flag is needed. + +**Alternatives considered**: Only testing view helpers (would miss discarded transport fields); relying solely on live completed listeners (availability can vary); running runtime tests for this documentation-only planning change (prohibited by constitution principle III). + +## Resolution + +All technical questions are resolved from repository evidence. No new dependency, generated-client update, schema migration, network research, or user clarification is required. Phase 1 may proceed. diff --git a/specs/321-listener-timestamps/spec.md b/specs/321-listener-timestamps/spec.md new file mode 100644 index 00000000..798699f5 --- /dev/null +++ b/specs/321-listener-timestamps/spec.md @@ -0,0 +1,110 @@ +# Feature Specification: Consistent Listener Timestamps + +**Feature Branch**: `codex/321-listener-timestamps` + +**Created**: 2026-09-16 + +**Status**: Draft + +**Input**: User description: "https://github.com/grafvonb/c8volt/issues/321 — fix(cli): show listener start and end timestamps consistently" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Distinguish Listener Creation, End, and Deadline (Priority: P1) + +An operator inspecting listener jobs can tell when a job was created and when it ended, without mistaking an activation deadline for completion time. + +**Why this priority**: Showing a deadline on a completed job while omitting its actual timestamps misrepresents the listener timeline during troubleshooting. + +**Independent Test**: Inspect an element with listener jobs in completed, activated, canceled, and other non-active states, with distinct creation, end, and deadline values, and verify the meaning and visibility of each timestamp. + +**Acceptance Scenarios**: + +1. **Given** a completed listener with creation time, end time, and a retained deadline, **When** the operator runs `get element --with-listeners`, **Then** its row shows creation time as `s:` and end time as `e:`, and does not show `d:`. +2. **Given** an `ACTIVATED` listener with creation time and a deadline but no end time, **When** its row is displayed, **Then** it shows `s:` and `d:` and omits `e:`. +3. **Given** a canceled or other non-active listener with a retained deadline, **When** its row is displayed, **Then** it omits `d:` and shows only the available creation and end timestamps. +4. **Given** a listener with only one or none of its timestamps available, **When** its row is displayed, **Then** unavailable timestamp tags are omitted, available timestamps retain their meaning, and no deadline is substituted for an end time. + +--- + +### User Story 2 - Read the Same Timeline Across Investigation Commands (Priority: P1) + +An operator switching between element inspection, process-instance inspection, process-tree walking, and slow-process analysis sees the same listener timestamp semantics and familiar time formatting. + +**Why this priority**: Operators must be able to correlate the same job across investigation views without reinterpreting its timestamps. + +**Independent Test**: View the same listener data through all four affected commands using the existing timezone and timestamp display settings and compare timestamp tags and values. + +**Acceptance Scenarios**: + +1. **Given** the same listener data, **When** the operator uses `get element --with-listeners`, `get process-instance --with-elements --with-listeners`, `walk process-instance --with-elements --with-listeners`, or `ops analyse slow-process-instances --with-listeners`, **Then** all four commands apply the same `s:`, `e:`, and activated-only `d:` rules. +2. **Given** an existing timezone or timestamp display setting, **When** listener timestamps are shown in any affected command, **Then** they follow that setting and the existing timestamp formatting conventions. +3. **Given** unchanged process and element data, **When** the operator requests listener details after this change, **Then** process and element durations and slow-process analysis results remain unchanged. +4. **Given** an operator consulting command documentation, **When** they read the listener timestamp guidance, **Then** it explains that `s:` is job creation rather than worker execution start, `e:` is job end, and `d:` is an activated-job deadline, with a completed-listener example. + +--- + +### User Story 3 - Retain Available Timestamps for Programmatic Consumers (Priority: P2) + +A user consuming job data programmatically receives the available creation and end timestamps, including in listener-enriched JSON results, and can distinguish missing data from recorded times. + +**Why this priority**: Correct human rendering depends on retaining the data, and automation needs the same facts for timeline analysis. + +**Independent Test**: Retrieve jobs with distinct creation and end times through the public job interface and supported JSON output, then repeat with each timestamp absent and with a supported environment that does not supply those fields. + +**Acceptance Scenarios**: + +1. **Given** job data containing creation and end times, **When** it is retrieved through the public job interface or included in an affected command's supported JSON output, **Then** both times are preserved as the same instants, with JSON fields named `creationTime` and `endTime`. +2. **Given** one or both timestamps are unavailable, including due to connected-version differences, **When** otherwise supported job or listener retrieval succeeds, **Then** available data remains accessible, absent timestamps are omitted from JSON, and no fabricated timestamp is returned. +3. **Given** a non-active job with a recorded deadline, **When** its data is returned programmatically, **Then** the existing deadline field is preserved; hiding `d:` applies to human listener rows. + +### Edge Cases + +- Completed or canceled jobs retain a deadline that differs from the actual end time. +- An activated job has no deadline, or unexpectedly has an end time: omit missing tags and show supplied creation/end times without inventing state-dependent values. +- Only creation time or only end time is present; neither timestamp depends on the other being available. +- A listener has no timestamps at all: retain its ordinary identifying and status fields without empty tags or placeholder times. +- A job has a missing, unfamiliar, or any non-`ACTIVATED` state: suppress the human deadline tag. +- Execution listeners and user task listeners obey identical timestamp rules. +- A supported connected version lacks one or both fields: render available information without manufacturing timestamps or failing solely because those fields are absent. +- No listener jobs are found, or listener lookup itself is unsupported: preserve existing empty-result and unsupported-version behavior. +- Timezone offsets and date boundaries must not change the represented instants or introduce a separate listener-only formatting convention. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Job data MUST retain available creation and end timestamps from retrieval through all intermediate representations to the public job interface and listener-enriched results, without changing the represented instants. +- **FR-002**: Human listener rows MUST label job creation time with `s:` and job end time with `e:` whenever those values are available. `s:` MUST NOT be described as worker execution start. +- **FR-003**: Human listener rows MUST show an available activation deadline with `d:` only when the job state is `ACTIVATED`; all other states MUST omit that tag. +- **FR-004**: Missing timestamps MUST be omitted independently. The product MUST NOT substitute a deadline, current time, or another timestamp for a missing creation or end time. +- **FR-005**: The rules MUST apply consistently to both listener kinds across the four commands listed in User Story 2 and their existing listener-supported modes. +- **FR-006**: Listener timestamps MUST use the existing timezone controls, precision, and timestamp formatting conventions. When multiple timestamp tags are present, they MUST appear in `s:`, `e:`, `d:` order. +- **FR-007**: Supported JSON job representations MUST expose available timestamps as `creationTime` and `endTime`, omit unavailable values, and preserve existing fields and surrounding output contracts, including recorded deadlines regardless of state. +- **FR-008**: Missing timestamp fields in a supported connected version MUST NOT cause otherwise supported retrieval to fail. Existing unsupported listener-lookup behavior MUST remain unchanged. +- **FR-009**: Process and element duration calculations, analysis outcomes, listener selection and grouping, and behavior without listener enrichment MUST remain unchanged, except for the additive job timestamp fields in programmatic results. +- **FR-010**: User-facing command guidance and affected generated CLI documentation MUST explain the timestamp meanings, conditional deadline visibility, and omission of unavailable timestamps, and include a completed-listener example. + +### Key Entities *(include if feature involves data)* + +- **Listener Job**: An execution-listener or user-task-listener job associated with an element, with a state and independently optional creation time, end time, and activation deadline. +- **Listener Timeline**: The recorded creation and end instants shown to an operator; the activation deadline is a separate deadline, not evidence of completion or worker execution start. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In all four affected commands, 100% of acceptance cases show available creation/end timestamps under the correct tags and show zero deadline tags for non-active listeners. +- **SC-002**: For completed, activated, canceled, and other non-active listener cases, users can identify every available creation/end time directly from the row without treating a deadline as completion evidence. +- **SC-003**: Across presence/absence combinations for creation and end timestamps, 100% of programmatic acceptance cases preserve supplied instants and return zero fabricated timestamps. +- **SC-004**: Comparing identical process and element inputs before and after the change yields zero differences in their calculated durations or slow-process analysis outcomes. +- **SC-005**: All four commands' affected documentation describes the same timestamp grammar, and every displayed listener timestamp follows existing timezone and formatting settings in the acceptance cases. + +## Assumptions + +- Scope is the listener timestamp correction in issue #321; new commands, new timestamp flags, worker execution timing, and changes to duration calculations are excluded. +- Existing listener lookup availability and supported command modes remain authoritative. Missing timestamp fields do not imply that listener lookup itself is supported on an otherwise unsupported version. +- Creation and end timestamps are independently optional facts supplied by the connected environment; this feature does not infer lifecycle times. +- The activation deadline remains available in existing programmatic job data even when hidden in a non-active human listener row. +- Standalone job human-output redesign is outside scope; shared public job data gains available timestamps as required by the issue. +- Documentation regeneration belongs to implementation when command source guidance changes; this specification establishes the required documentation outcome. diff --git a/specs/321-listener-timestamps/tasks.md b/specs/321-listener-timestamps/tasks.md new file mode 100644 index 00000000..a5dee0f8 --- /dev/null +++ b/specs/321-listener-timestamps/tasks.md @@ -0,0 +1,158 @@ +# Tasks: Consistent Listener Timestamps + +**Input**: Design documents in `specs/321-listener-timestamps/`. +**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md), [data-model.md](data-model.md), [output contract](contracts/listener-timestamps.md), and [quickstart.md](quickstart.md). +**Branch**: `codex/321-listener-timestamps` · **Issue**: #321. + +**Tests**: Regression tests are required by the originating issue's acceptance criteria and the feature's acceptance scenarios. Extend existing tests rather than duplicating coverage. Write behavioral regression assertions before their corresponding implementation; demonstrate the expected failure when executable, then verify they pass after the change. Documentation-only work does not trigger runtime tests. + +**Organization**: Shared transport prerequisites precede independently verifiable user-story increments. Paths below are repository-relative. All checkboxes describe future implementation work; task generation does not mark implementation complete. + +## Format: `[ID] [P?] [Story] Description` + +`[P]` identifies tasks that can run concurrently with the listed peers after their prerequisites are complete. Story labels apply only within story phases. Complete each phase's validation before declaring its checkpoint achieved. Run `gofmt` on touched Go files before the relevant validation, and retain earlier passing evidence unless subsequent changes invalidate it. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm existing project context; no new dependencies or scaffolding are needed. + +- [x] T001 Verify `AGENTS.md`, `.specify/memory/constitution.md`, and `specs/321-listener-timestamps/plan.md` against the current branch and feature pointer in `.specify/feature.json`; inspect existing test helpers and confirm the Go toolchain from `go.mod`. If executing through Ralph, also read `specs/ralph-implementation-rules.md` and surface any conflicting instructions before implementation. + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Make recorded timestamps available to every listener workflow without altering discovery or lifecycle behavior. + +- [x] T002 Add regression assertions in `internal/domain/job_test.go` for `RuntimeListenerJobFromJob`, covering both timestamps, each independently absent, neither present, preserved offsets, and a retained non-active deadline; enforce “Non-nil creation/end values retain the same instant and timezone offset through mappings.” +- [x] T003 Add `CreationTime *time.Time` and `EndTime *time.Time` with `json:"creationTime,omitempty"` and `json:"endTime,omitempty"` to `Job` and `RuntimeListenerJob` in `internal/domain/job.go`, and copy them in `RuntimeListenerJobFromJob`; enforce “Nil is absence, and fields are independent.” and “JSON preserves supplied deadlines in every state.” (depends on T002). +- [x] T004 [P] Extend get/search conversion fixtures in `internal/services/job/v88/service_test.go`, then map both pointers in `internal/services/job/v88/convert.go:fromJobSearchResult`; cover distinct supplied values, each missing, both missing, and explicit null without a version cutoff. Enforce “v87 lookup remains unsupported; v88/v89/v810 responses with missing fields remain valid.” (depends on T003). +- [x] T005 [P] Extend get/search conversion fixtures in `internal/services/job/v89/service_test.go`, then map both pointers in `internal/services/job/v89/convert.go:fromJobSearchResult`; cover independent missing/null values, retained deadlines, and unchanged instants without synthesizing timestamps (depends on T003). +- [x] T006 [P] Extend get/search conversion fixtures in `internal/services/job/v810/service_test.go`, then map both pointers in `internal/services/job/v810/convert.go:fromJobSearchResult`; cover independent missing/null values, retained deadlines, and unchanged instants without synthesizing timestamps (depends on T003). +- [x] T007 Validate the domain and adapter changes using the domain/job commands in `specs/321-listener-timestamps/quickstart.md`, and run existing unsupported tests in `internal/services/job/v87/service_test.go` by their actual names; confirm generated files under `internal/clients/camunda/` are unchanged and no discovery requests were added (depends on T004–T006). + +**Checkpoint**: All adapters preserve available timestamps and domain listener projection preserves them; absence remains valid. No user-story phase starts until this checkpoint passes. + +## Phase 3: User Story 1 — Distinguish Listener Creation, End, and Deadline (Priority: P1) — MVP + +**Goal**: `get element --with-listeners` displays trustworthy listener lifecycle facts. + +**Independent Test**: Execute keyed and search element lookups using fixture jobs with different creation, end, and deadline times. Completed/canceled/non-active rows omit `d:`; activated rows show only an available deadline; missing creation/end values omit their tags independently. + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Add timestamp-column and rendered-row regression tests in new `cmd/cmd_views_listener_test.go` and existing `cmd/cmd_views_element_test.go`; cover completed, activated, canceled, created, failed, blank, unfamiliar, and noncanonical lowercase states, both listener kinds, all creation/end presence combinations, absent deadlines, activated jobs with supplied end times, mixed rows with worker/error columns, full-date millisecond precision, and timezone-offset display. Enforce “Human rows show each available creation/end time independently of state.” and “Only exact `ACTIVATED` plus non-nil deadline produces human `d:`.” +- [x] T009 [P] [US1] Extend keyed/search execution fixtures in `cmd/get_element_test.go` with timestamp-bearing listener responses and capture stdout/stderr separately; assert exact human tags, no deadline substitution, unchanged nesting and duration, and unchanged request counts, retaining no-listener, without-enrichment, validation, and error coverage. + +### Implementation for User Story 1 + +- [x] T010 [US1] Extend listener mapping assertions in `c8volt/element/client_test.go`, then add optional timestamp fields in `c8volt/element/model.go` and copy them in `c8volt/element/convert.go:fromDomainRuntimeListenerJob`; use the field types/tags from T003 and enforce “nil means not requested; a pointer to an empty slice means requested with no matches.” for the existing listener collection. +- [x] T011 [US1] Implement the private timestamp-column helper in new `cmd/cmd_views_listener.go` and use it in `cmd/cmd_views_element.go:flatRowElementListenerWithTimezone`; return exactly three columns in `s:`, `e:`, `d:` order after worker and before errors, with empty strings for absent/ineligible columns. Reuse `toolx/timestamp.go:FormatTime` and existing flat-row alignment without changing either helper or the standalone job renderer (depends on T008–T010). +- [x] T012 [US1] Document the element command's creation-versus-worker-start meaning, end time, activated-only deadline, and omitted missing values in `cmd/get_element.go` and `README.md`; add a full-date completed-listener example, update affected help assertions in `cmd/get_element_test.go`, and run `make docs-content` to regenerate affected content under `docs/cli/`. +- [x] T013 [US1] Run `go test ./c8volt/element -run 'Listener|Timestamp' -count=1` and `go test ./cmd -run 'GetElement|ElementListener|ListenerTimestamp' -count=1`, explicitly selecting new tests if names differ; verify the independent scenarios from `specs/321-listener-timestamps/spec.md` and review generated element docs before marking US1 complete (depends on T011–T012). + +**Checkpoint**: US1 can be demonstrated independently on a real element command path. This is the MVP, not completion of issue #321. + +## Phase 4: User Story 2 — Read the Same Timeline Across Investigation Commands (Priority: P1) + +**Goal**: Process get, walk, and slow analysis use the same listener grammar and preserve their own layouts and duration results. + +**Independent Test**: Feed equivalent listener facts into all four commands with offset display on and off; compare tags/values and verify identical process/element durations and analysis results for unchanged inputs. + +### Tests for User Story 2 + +- [x] T014 [P] [US2] Extend human-output execution tests in `cmd/get_processinstance_test.go` and `cmd/walk_test.go`, plus row assertions in `cmd/cmd_views_processinstance_activity_test.go`, for timestamp-bearing listeners; cover both listener kinds and completed/activated/canceled/other non-active cases, process get keyed/list paths, walk default/children/parent/flat modes, missing fields, offsets, unchanged tree ownership and request counts, and uncontaminated result streams. +- [x] T015 [P] [US2] Extend human-output tests in `cmd/cmd_views_ops_slow_process_analysis_test.go` and command execution coverage in `cmd/ops_analyse_slow_process_instances_test.go` for the same listener states and independent missing timestamps; exercise actual command dispatch with existing service/HTTP fixtures, normal and full-timeline output, timezone display, and separate stdout/stderr capture. + +### Implementation for User Story 2 + +- [x] T016 [P] [US2] Extend facade mapping coverage in `c8volt/process/client_test.go`, add optional timestamp fields in `c8volt/process/model.go`, copy them in `c8volt/process/convert.go:fromDomainRuntimeListenerJob`, and use the US1 helper in `cmd/cmd_views_processinstance_activity.go:flatRowProcessInstanceElementListenerWithTimezone`; preserve fields/collection constraints from T003 and T010 (depends on T014 and completed US1). +- [x] T017 [P] [US2] Extend facade mapping coverage in `c8volt/ops/client_test.go`, add optional timestamp fields in `c8volt/ops/model.go`, copy them in `c8volt/ops/convert.go:fromDomainRuntimeListenerJob`, and use the US1 helper in `cmd/cmd_views_ops_slow_process_analysis.go:flatRowOpsSlowProcessAnalysisListenerWithTimezone`; preserve fields/collection constraints from T003 and T010 (depends on T015 and completed US1). +- [x] T018 [US2] Extend existing enrichment/analysis fixtures in `internal/services/element/enrichment_test.go`, `internal/services/processinstance/enrichment_test.go`, and `internal/services/ops/slow_process_analysis_test.go` to assert timestamp retention, unmatched-job omission, stable ordering/request counts, and zero differences in process/element durations and slow-analysis outcomes for identical execution data; enforce “No new state transitions, mutations, persistence, migrations, duration values, or validation of lifecycle ordering are introduced.” +- [x] T019 [US2] Update listener guidance/examples in `cmd/get_processinstance.go`, `cmd/walk_processinstance.go`, `cmd/ops_analyse_slow_process_instances.go`, and `README.md` for the common timestamp contract; extend relevant help assertions in `cmd/get_processinstance_test.go`, `cmd/walk_test.go`, and `cmd/ops_analyse_slow_process_instances_test.go`, then run `make docs-content` and review affected generated references under `docs/cli/`. +- [x] T020 [US2] Run the facade, enrichment/analysis, and command test selections from `specs/321-listener-timestamps/quickstart.md` relevant to this story, ensuring the new tests actually execute; compare all four human outputs and preserve existing unsupported-version, without-listener, empty-array, and invalid-mode regression results (depends on T016–T019). + +**Checkpoint**: All four human views share the timestamp semantics; durations and investigation behavior remain unchanged. + +## Phase 5: User Story 3 — Retain Available Timestamps for Programmatic Consumers (Priority: P2) + +**Goal**: Public job retrieval and all listener JSON results expose available times and omit missing values without losing recorded deadlines. + +**Independent Test**: Retrieve timestamp-bearing and partially populated jobs through public get/search/page interfaces and decode each command's listener JSON. Verify exact property names, equivalent instants, missing-field omission, retained non-active deadlines, and unchanged envelopes/collections. + +### Tests for User Story 3 + +- [x] T021 [P] [US3] Extend public job get/search/page conversion tests in `c8volt/job/client_test.go` with creation-only, end-only, both, and neither cases, plus numeric offsets and retained non-active deadlines; add marshaling assertions enforcing “Missing or null source values remain nil and are omitted from public JSON.” +- [x] T022 [P] [US3] Extend JSON execution tests in `cmd/get_element_test.go`, `cmd/get_processinstance_test.go`, `cmd/walk_test.go`, and `cmd/ops_analyse_slow_process_instances_test.go` to decode exactly one existing envelope and require EOF; verify `creationTime`/`endTime`, independent omission, retained completed/canceled deadlines, requested-empty/unrequested listener distinctions, unchanged request counts, and separate stdout/stderr. Use actual execution results rather than only view fixtures and retain unsupported-version coverage. +- [x] T023 [P] [US3] Extend public listener JSON assertions in `c8volt/element/client_test.go`, `c8volt/process/client_test.go`, and `c8volt/ops/client_test.go` for exact names, optional values, preserved instants/offsets, and retained deadlines; verify populated, nil, and requested-empty listener collections without changing their schemas. + +### Implementation for User Story 3 + +- [x] T024 [US3] Add `CreationTime *time.Time` and `EndTime *time.Time` with the T003 JSON tags to `c8volt/job/model.go` and map both in `c8volt/job/client.go:fromDomainJob`, including existing list/page paths; enforce “Missing or null source values remain nil and are omitted from public JSON.” and “JSON preserves supplied deadlines in every state.” Leave standalone human formatting in `cmd/cmd_views_job.go` unchanged (depends on T021). +- [x] T025 [US3] Run `go test ./c8volt/job -run 'TestClient_GetJob|TestClient_SearchJobs|Timestamp' -count=1`, explicitly run page tests not matched, and run the relevant listener facade/command JSON tests from `specs/321-listener-timestamps/quickstart.md`; verify the complete programmatic contract and correct any mapping omissions in the owning converter identified by a failing regression (depends on T022–T024). + +**Checkpoint**: Public job consumers and all four command JSON outputs preserve available timestamp facts with no fabricated times or deadline loss. + +## Phase 6: Polish & Cross-Cutting Concerns + +- [x] T026 Audit the final diff against FR-001–FR-010 in `specs/321-listener-timestamps/spec.md` and the matrix in `specs/321-listener-timestamps/contracts/listener-timestamps.md`; review `README.md` and generated `docs/cli/` output for consistent definitions and intentional compatibility changes, ensure touched Go files are formatted, and run `git diff --check`. Do not regenerate documentation again unless source guidance changed after the last generation. +- [x] T027 Reconcile actual test names and validation evidence with `specs/321-listener-timestamps/quickstart.md` and record checks, results, and material gaps there; run only missing or invalidated targeted checks. Use `make test` only if the actual diff or unresolved failures meet the broader-validation conditions in `specs/321-listener-timestamps/plan.md`; do not rerun tests merely to finish or commit. Live inspection remains optional and must not be reported as executed unless performed. + +## Dependencies & Execution Order + +### Phase dependencies + +```text +T001 Setup + -> T002 -> T003 + -> T004 / T005 / T006 -> T007 Foundation gate + -> US1 T008–T013 (MVP) + -> US2 T014–T020 + -> US3 T021–T025 + -> T026 -> T027 +``` + +The graph shows the recommended sequential delivery order. US2 depends on the US1 helper. US3 standalone-job work (T021/T024) can begin after the foundation gate; its all-command contract tasks and completion gate wait for US1/US2 mappings and execution-test edits. Do not concurrently edit the same command/facade test files across story phases. + +### Within-phase dependencies + +- T004–T006 are independent once T003 completes; T007 waits for all three. +- T008/T009 can be authored concurrently after T007. T010 supplies the public element model; T011 waits for the US1 test tasks and model mapping. T012 follows integration and T013 validates the full slice. +- T014/T015 can be authored concurrently after US1. T016/T017 can run concurrently after their respective tests exist; they touch different files and consume the already-complete helper. T018 is independent of their file edits but must finish before T020. T019 runs after T014/T015 to avoid conflicting command-test edits. +- T021/T022/T023 can run concurrently after US2; they touch disjoint test files. T024 follows T021, and T025 waits for all story changes. +- T026/T027 wait for all three story checkpoints. + +### Parallel examples per story + +- **US1**: Author helper/element-row regression cases (T008) alongside element command execution cases (T009). +- **US2**: Author process-get/walk cases (T014) alongside slow-analysis cases (T015); after those finish, implement process facade/rendering (T016) alongside ops facade/rendering (T017). +- **US3**: Author public job cases (T021), command JSON cases (T022), and public listener JSON cases (T023) concurrently. Public job mapping (T024) can follow T021 while the other test tasks finish. + +These are scheduling opportunities, not an instruction to launch agents or autonomous implementation during task generation. + +## Requirement Traceability + +| Requirement | Tasks | +| --- | --- | +| FR-001 Preserve timestamps through every representation | T002–T007, T010, T016–T018, T021, T023–T025 | +| FR-002 Creation/end semantics | T008–T013, T014–T017, T019–T020 | +| FR-003 Activated-only human deadline | T008–T011, T014–T017, T020 | +| FR-004 Independent absence/no substitution | T002–T006, T008–T011, T014–T017, T021–T025 | +| FR-005 All four commands/both listener kinds | T008–T020, T022 | +| FR-006 Formatting and tag order | T008, T011, T014–T017, T020 | +| FR-007 Additive JSON and preserved contracts | T010, T016–T017, T021–T025 | +| FR-008 Version-specific missing data | T004–T007, T020, T022 | +| FR-009 Unchanged durations/discovery/grouping | T009, T014–T018, T020, T022, T026 | +| FR-010 Documentation | T012, T019, T026 | + +SC-001/SC-002 are demonstrated by the human execution and row tests; SC-003 by adapter/facade/JSON checks; SC-004 by T009/T018/T020; SC-005 by timezone regressions and T012/T019/T026. + +## Implementation Strategy + +### MVP first + +Complete Setup, Foundation, and US1 (T001–T013). Demonstrate trustworthy element listener timestamps with targeted tests and matching docs. This delivers the smallest usable slice while leaving the other investigation commands explicitly unfinished. + +### Incremental delivery + +Complete US2 to extend the shared grammar to all human investigation views, then US3 to close public job and JSON verification. Existing listener timestamp mappings delivered in earlier stories are reused, not reimplemented in US3. Finish with the cross-cutting review and accurate validation record. + +Keep commits small and Conventional Commits compliant, referencing #321 when committing. Do not require a full test suite solely for a commit. No new API client generation, dependencies, polling, mutation behavior, timestamp format, or public shared-type refactoring is needed.