Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,5 @@

## Active Speckit Plan
<!-- SPECKIT START -->
- Active Speckit implementation plan: `specs/316-delete-cancel-logging/plan.md`
- Active Speckit implementation plan: `specs/321-listener-timestamps/plan.md`
<!-- SPECKIT END -->
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <process-instance-key> --with-elements
./c8volt get process-instance --key <process-instance-key> --with-elements --with-listeners
Expand Down
82 changes: 81 additions & 1 deletion c8volt/element/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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
},
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions c8volt/element/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions c8volt/element/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down
3 changes: 3 additions & 0 deletions c8volt/job/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
65 changes: 62 additions & 3 deletions c8volt/job/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package job

import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
Expand Down Expand Up @@ -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) {
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
},
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions c8volt/job/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading
Loading