@stigmer/react 3.5.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/AgentPicker.d.ts +10 -1
- package/agent/AgentPicker.d.ts.map +1 -1
- package/agent/AgentPicker.js +2 -2
- package/agent/AgentPicker.js.map +1 -1
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +22 -5
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelTemplatesDialog.d.ts +53 -0
- package/channel/ChannelTemplatesDialog.d.ts.map +1 -0
- package/channel/ChannelTemplatesDialog.js +150 -0
- package/channel/ChannelTemplatesDialog.js.map +1 -0
- package/channel/index.d.ts +8 -0
- package/channel/index.d.ts.map +1 -1
- package/channel/index.js +4 -0
- package/channel/index.js.map +1 -1
- package/channel/providers.d.ts +12 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +14 -2
- package/channel/providers.js.map +1 -1
- package/channel/templatePresentation.d.ts +37 -0
- package/channel/templatePresentation.d.ts.map +1 -0
- package/channel/templatePresentation.js +69 -0
- package/channel/templatePresentation.js.map +1 -0
- package/channel/useChannelTemplateList.d.ts +64 -0
- package/channel/useChannelTemplateList.d.ts.map +1 -0
- package/channel/useChannelTemplateList.js +69 -0
- package/channel/useChannelTemplateList.js.map +1 -0
- package/channel/useChannelTemplateReadiness.d.ts +53 -0
- package/channel/useChannelTemplateReadiness.d.ts.map +1 -0
- package/channel/useChannelTemplateReadiness.js +42 -0
- package/channel/useChannelTemplateReadiness.js.map +1 -0
- package/dashboard/useDashboardFailedRuns.d.ts.map +1 -1
- package/dashboard/useDashboardFailedRuns.js +2 -0
- package/dashboard/useDashboardFailedRuns.js.map +1 -1
- package/index.d.ts +4 -2
- package/index.d.ts.map +1 -1
- package/index.js +9 -1
- package/index.js.map +1 -1
- package/library/useExportResource.d.ts +4 -3
- package/library/useExportResource.d.ts.map +1 -1
- package/library/useExportResource.js +1 -1
- package/library/useExportResource.js.map +1 -1
- package/organization/OrgProvider.d.ts +11 -4
- package/organization/OrgProvider.d.ts.map +1 -1
- package/organization/OrgProvider.js +28 -4
- package/organization/OrgProvider.js.map +1 -1
- package/package.json +4 -4
- package/resource-detail/useDeleteResource.d.ts +1 -1
- package/resource-detail/useDeleteResource.d.ts.map +1 -1
- package/resource-detail/useDeleteResource.js +5 -0
- package/resource-detail/useDeleteResource.js.map +1 -1
- package/resource-workbench/components/ResourceWorkbench.d.ts +10 -1
- package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
- package/resource-workbench/components/ResourceWorkbench.js +6 -2
- package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
- package/resource-workbench/hooks/useResourceCollection.d.ts +8 -0
- package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
- package/resource-workbench/hooks/useResourceCollection.js +2 -2
- package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
- package/schedule/CadenceField.d.ts +31 -0
- package/schedule/CadenceField.d.ts.map +1 -0
- package/schedule/CadenceField.js +116 -0
- package/schedule/CadenceField.js.map +1 -0
- package/schedule/ScheduleDetailView.d.ts +58 -0
- package/schedule/ScheduleDetailView.d.ts.map +1 -0
- package/schedule/ScheduleDetailView.js +236 -0
- package/schedule/ScheduleDetailView.js.map +1 -0
- package/schedule/ScheduleForm.d.ts +43 -0
- package/schedule/ScheduleForm.d.ts.map +1 -0
- package/schedule/ScheduleForm.js +129 -0
- package/schedule/ScheduleForm.js.map +1 -0
- package/schedule/ScheduleRowActions.d.ts +20 -0
- package/schedule/ScheduleRowActions.d.ts.map +1 -0
- package/schedule/ScheduleRowActions.js +54 -0
- package/schedule/ScheduleRowActions.js.map +1 -0
- package/schedule/TimeZoneField.d.ts +32 -0
- package/schedule/TimeZoneField.d.ts.map +1 -0
- package/schedule/TimeZoneField.js +104 -0
- package/schedule/TimeZoneField.js.map +1 -0
- package/schedule/cadence.d.ts +95 -0
- package/schedule/cadence.d.ts.map +1 -0
- package/schedule/cadence.js +242 -0
- package/schedule/cadence.js.map +1 -0
- package/schedule/index.d.ts +31 -0
- package/schedule/index.d.ts.map +1 -0
- package/schedule/index.js +16 -0
- package/schedule/index.js.map +1 -0
- package/schedule/scheduleColumns.d.ts +26 -0
- package/schedule/scheduleColumns.d.ts.map +1 -0
- package/schedule/scheduleColumns.js +77 -0
- package/schedule/scheduleColumns.js.map +1 -0
- package/schedule/scheduleListFn.d.ts +50 -0
- package/schedule/scheduleListFn.d.ts.map +1 -0
- package/schedule/scheduleListFn.js +65 -0
- package/schedule/scheduleListFn.js.map +1 -0
- package/schedule/scheduleState.d.ts +43 -0
- package/schedule/scheduleState.d.ts.map +1 -0
- package/schedule/scheduleState.js +67 -0
- package/schedule/scheduleState.js.map +1 -0
- package/schedule/useCreateSchedule.d.ts +57 -0
- package/schedule/useCreateSchedule.d.ts.map +1 -0
- package/schedule/useCreateSchedule.js +61 -0
- package/schedule/useCreateSchedule.js.map +1 -0
- package/schedule/useResumeSchedule.d.ts +30 -0
- package/schedule/useResumeSchedule.d.ts.map +1 -0
- package/schedule/useResumeSchedule.js +45 -0
- package/schedule/useResumeSchedule.js.map +1 -0
- package/schedule/useSchedule.d.ts +39 -0
- package/schedule/useSchedule.d.ts.map +1 -0
- package/schedule/useSchedule.js +44 -0
- package/schedule/useSchedule.js.map +1 -0
- package/schedule/useScheduleCount.d.ts +38 -0
- package/schedule/useScheduleCount.d.ts.map +1 -0
- package/schedule/useScheduleCount.js +30 -0
- package/schedule/useScheduleCount.js.map +1 -0
- package/schedule/useScheduleList.d.ts +48 -0
- package/schedule/useScheduleList.d.ts.map +1 -0
- package/schedule/useScheduleList.js +43 -0
- package/schedule/useScheduleList.js.map +1 -0
- package/schedule/useSetScheduleEnabled.d.ts +31 -0
- package/schedule/useSetScheduleEnabled.d.ts.map +1 -0
- package/schedule/useSetScheduleEnabled.js +53 -0
- package/schedule/useSetScheduleEnabled.js.map +1 -0
- package/schedule/useTriggerSchedule.d.ts +28 -0
- package/schedule/useTriggerSchedule.d.ts.map +1 -0
- package/schedule/useTriggerSchedule.js +43 -0
- package/schedule/useTriggerSchedule.js.map +1 -0
- package/search/useResourceCount.d.ts +2 -1
- package/search/useResourceCount.d.ts.map +1 -1
- package/search/useResourceCount.js +6 -1
- package/search/useResourceCount.js.map +1 -1
- package/src/agent/AgentPicker.tsx +13 -1
- package/src/channel/AgentChannelsPanel.tsx +43 -1
- package/src/channel/ChannelTemplatesDialog.tsx +433 -0
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +73 -0
- package/src/channel/__tests__/ChannelTemplatesDialog.test.tsx +292 -0
- package/src/channel/__tests__/providers.test.ts +9 -0
- package/src/channel/__tests__/templatePresentation.test.ts +96 -0
- package/src/channel/__tests__/useChannelTemplateList.test.tsx +216 -0
- package/src/channel/__tests__/useChannelTemplateReadiness.test.tsx +124 -0
- package/src/channel/index.ts +15 -0
- package/src/channel/providers.ts +26 -2
- package/src/channel/templatePresentation.ts +82 -0
- package/src/channel/useChannelTemplateList.ts +123 -0
- package/src/channel/useChannelTemplateReadiness.ts +69 -0
- package/src/dashboard/__tests__/useDashboardFailedRuns.test.tsx +106 -0
- package/src/dashboard/useDashboardFailedRuns.ts +2 -0
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +23 -2
- package/src/index.ts +65 -0
- package/src/library/useExportResource.ts +4 -3
- package/src/organization/OrgProvider.tsx +29 -4
- package/src/organization/__tests__/OrgProvider.orgSwitch.test.tsx +89 -0
- package/src/resource-detail/useDeleteResource.ts +11 -1
- package/src/resource-workbench/__tests__/ResourceWorkbench.test.tsx +112 -0
- package/src/resource-workbench/components/ResourceWorkbench.tsx +32 -15
- package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
- package/src/schedule/CadenceField.tsx +365 -0
- package/src/schedule/ScheduleDetailView.tsx +583 -0
- package/src/schedule/ScheduleForm.tsx +405 -0
- package/src/schedule/ScheduleRowActions.tsx +124 -0
- package/src/schedule/TimeZoneField.tsx +199 -0
- package/src/schedule/__tests__/ScheduleDetailView.test.tsx +260 -0
- package/src/schedule/__tests__/cadence.test.ts +278 -0
- package/src/schedule/__tests__/scheduleColumnsRowActions.test.tsx +176 -0
- package/src/schedule/__tests__/scheduleCreation.test.tsx +340 -0
- package/src/schedule/__tests__/scheduleHooks.test.tsx +312 -0
- package/src/schedule/__tests__/scheduleListFn.test.ts +128 -0
- package/src/schedule/__tests__/scheduleState.test.ts +133 -0
- package/src/schedule/cadence.ts +298 -0
- package/src/schedule/index.ts +57 -0
- package/src/schedule/scheduleColumns.tsx +118 -0
- package/src/schedule/scheduleListFn.ts +108 -0
- package/src/schedule/scheduleState.ts +96 -0
- package/src/schedule/useCreateSchedule.ts +88 -0
- package/src/schedule/useResumeSchedule.ts +67 -0
- package/src/schedule/useSchedule.ts +67 -0
- package/src/schedule/useScheduleCount.ts +57 -0
- package/src/schedule/useScheduleList.ts +83 -0
- package/src/schedule/useSetScheduleEnabled.ts +84 -0
- package/src/schedule/useTriggerSchedule.ts +65 -0
- package/src/search/__tests__/useResourceCount.test.tsx +89 -0
- package/src/search/useResourceCount.ts +8 -2
- package/src/workflow/WorkflowDashboard.tsx +1 -0
- package/src/workflow/useWorkflowExecutionList.ts +13 -2
- package/styles.css +1 -1
- package/workflow/CanvasTransitionEdge.d.ts +1 -1
- package/workflow/WorkflowDashboard.d.ts.map +1 -1
- package/workflow/WorkflowDashboard.js +1 -0
- package/workflow/WorkflowDashboard.js.map +1 -1
- package/workflow/useWorkflowExecutionList.d.ts +10 -0
- package/workflow/useWorkflowExecutionList.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionList.js +3 -2
- package/workflow/useWorkflowExecutionList.js.map +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { ScheduleSchema, type Schedule } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
|
|
4
|
+
import { ScheduleListSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/io_pb";
|
|
5
|
+
import { createScheduleListFn, listSchedulesPage } from "../scheduleListFn";
|
|
6
|
+
|
|
7
|
+
function schedules(count: number): Schedule[] {
|
|
8
|
+
return Array.from({ length: count }, (_, i) =>
|
|
9
|
+
create(ScheduleSchema, {
|
|
10
|
+
metadata: { id: `sch_${i}`, name: `sched-${i}`, slug: `sched-${i}`, org: "acme" },
|
|
11
|
+
}),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function clientReturning(items: Schedule[], totalCount: number) {
|
|
16
|
+
const list = vi
|
|
17
|
+
.fn()
|
|
18
|
+
.mockResolvedValue(create(ScheduleListSchema, { items, totalCount }));
|
|
19
|
+
return { client: { schedule: { list } }, list };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("listSchedulesPage", () => {
|
|
23
|
+
it("sends the org and requested page in the query", async () => {
|
|
24
|
+
const { client, list } = clientReturning([], 0);
|
|
25
|
+
|
|
26
|
+
await listSchedulesPage(client, "acme", { num: 2, size: 10 });
|
|
27
|
+
|
|
28
|
+
expect(list).toHaveBeenCalledOnce();
|
|
29
|
+
const request = list.mock.calls[0][0];
|
|
30
|
+
expect(request.org).toBe("acme");
|
|
31
|
+
expect(request.pageInfo?.num).toBe(2);
|
|
32
|
+
expect(request.pageInfo?.size).toBe(10);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("defaults to page 1, size 20", async () => {
|
|
36
|
+
const { client, list } = clientReturning([], 0);
|
|
37
|
+
|
|
38
|
+
await listSchedulesPage(client, "acme");
|
|
39
|
+
|
|
40
|
+
const request = list.mock.calls[0][0];
|
|
41
|
+
expect(request.pageInfo?.num).toBe(1);
|
|
42
|
+
expect(request.pageInfo?.size).toBe(20);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Cloud slices server-side: a response never exceeds the page size.
|
|
46
|
+
it("passes a cloud-shaped page through untouched", async () => {
|
|
47
|
+
const page = schedules(20);
|
|
48
|
+
const { client } = clientReturning(page, 50);
|
|
49
|
+
|
|
50
|
+
const result = await listSchedulesPage(client, "acme", { num: 2, size: 20 });
|
|
51
|
+
|
|
52
|
+
expect(result.items).toHaveLength(20);
|
|
53
|
+
expect(result.items[0].metadata?.slug).toBe("sched-0");
|
|
54
|
+
expect(result.totalCount).toBe(50);
|
|
55
|
+
expect(result.totalPages).toBe(3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("passes a cloud-shaped final short page through untouched", async () => {
|
|
59
|
+
const { client } = clientReturning(schedules(10), 50);
|
|
60
|
+
|
|
61
|
+
const result = await listSchedulesPage(client, "acme", { num: 3, size: 20 });
|
|
62
|
+
|
|
63
|
+
expect(result.items).toHaveLength(10);
|
|
64
|
+
expect(result.totalPages).toBe(3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// OSS ignores page_info and returns the full org list — an overflowing
|
|
68
|
+
// response is the tell, and only then is the page sliced locally.
|
|
69
|
+
it("slices an OSS-shaped full-list response with absolute indices", async () => {
|
|
70
|
+
const { client } = clientReturning(schedules(50), 50);
|
|
71
|
+
|
|
72
|
+
const result = await listSchedulesPage(client, "acme", { num: 2, size: 20 });
|
|
73
|
+
|
|
74
|
+
expect(result.items).toHaveLength(20);
|
|
75
|
+
expect(result.items[0].metadata?.slug).toBe("sched-20");
|
|
76
|
+
expect(result.items[19].metadata?.slug).toBe("sched-39");
|
|
77
|
+
expect(result.totalCount).toBe(50);
|
|
78
|
+
expect(result.totalPages).toBe(3);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("slices the OSS-shaped final short page correctly", async () => {
|
|
82
|
+
const { client } = clientReturning(schedules(50), 50);
|
|
83
|
+
|
|
84
|
+
const result = await listSchedulesPage(client, "acme", { num: 3, size: 20 });
|
|
85
|
+
|
|
86
|
+
expect(result.items).toHaveLength(10);
|
|
87
|
+
expect(result.items[0].metadata?.slug).toBe("sched-40");
|
|
88
|
+
expect(result.items[9].metadata?.slug).toBe("sched-49");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns an empty page with zero totals", async () => {
|
|
92
|
+
const { client } = clientReturning([], 0);
|
|
93
|
+
|
|
94
|
+
const result = await listSchedulesPage(client, "acme");
|
|
95
|
+
|
|
96
|
+
expect(result.items).toEqual([]);
|
|
97
|
+
expect(result.totalCount).toBe(0);
|
|
98
|
+
expect(result.totalPages).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("createScheduleListFn", () => {
|
|
103
|
+
it("adapts ListParams to the direct query and back to ListResult", async () => {
|
|
104
|
+
const { client, list } = clientReturning(schedules(3), 3);
|
|
105
|
+
const listFn = createScheduleListFn(client);
|
|
106
|
+
|
|
107
|
+
const result = await listFn({
|
|
108
|
+
org: "acme",
|
|
109
|
+
// No server-side text search for schedules — ignored by contract.
|
|
110
|
+
query: "ignored",
|
|
111
|
+
excludePublic: false,
|
|
112
|
+
crossOrgPublic: false,
|
|
113
|
+
page: { num: 1, size: 20 },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.totalCount).toBe(3);
|
|
117
|
+
expect(result.totalPages).toBe(1);
|
|
118
|
+
expect(result.entries).toHaveLength(3);
|
|
119
|
+
// Entries are full Schedule protos occupying the SearchResult slot.
|
|
120
|
+
expect((result.entries[0] as unknown as Schedule).metadata?.slug).toBe("sched-0");
|
|
121
|
+
|
|
122
|
+
const request = list.mock.calls[0][0];
|
|
123
|
+
expect(request.org).toBe("acme");
|
|
124
|
+
// The query never reaches the wire: ListSchedulesRequest has no
|
|
125
|
+
// text-search field.
|
|
126
|
+
expect("query" in request && request.query).toBeFalsy();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { ScheduleSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/spec_pb";
|
|
4
|
+
import { ScheduleStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/status_pb";
|
|
5
|
+
import { deriveScheduleState, formatNextFire } from "../scheduleState";
|
|
6
|
+
|
|
7
|
+
const enabledSpec = create(ScheduleSpecSchema, {
|
|
8
|
+
cron: "0 9 * * *",
|
|
9
|
+
timeZone: "Asia/Kolkata",
|
|
10
|
+
enabled: true,
|
|
11
|
+
});
|
|
12
|
+
const disabledSpec = create(ScheduleSpecSchema, {
|
|
13
|
+
cron: "0 9 * * *",
|
|
14
|
+
timeZone: "Asia/Kolkata",
|
|
15
|
+
enabled: false,
|
|
16
|
+
});
|
|
17
|
+
const pausedStatus = create(ScheduleStatusSchema, {
|
|
18
|
+
pausedReason: "5 consecutive failures",
|
|
19
|
+
consecutiveFailures: 5,
|
|
20
|
+
});
|
|
21
|
+
const healthyStatus = create(ScheduleStatusSchema, {});
|
|
22
|
+
|
|
23
|
+
describe("deriveScheduleState", () => {
|
|
24
|
+
// Every lever combination — the two levers must never collapse.
|
|
25
|
+
const cases = [
|
|
26
|
+
{
|
|
27
|
+
name: "enabled + not paused → active",
|
|
28
|
+
spec: enabledSpec,
|
|
29
|
+
status: healthyStatus,
|
|
30
|
+
state: "active",
|
|
31
|
+
phase: "ready",
|
|
32
|
+
label: "Active",
|
|
33
|
+
isPaused: false,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "enabled + paused → paused (platform latch)",
|
|
37
|
+
spec: enabledSpec,
|
|
38
|
+
status: pausedStatus,
|
|
39
|
+
state: "paused",
|
|
40
|
+
phase: "degraded",
|
|
41
|
+
label: "Paused",
|
|
42
|
+
isPaused: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "disabled + not paused → disabled (owner switch)",
|
|
46
|
+
spec: disabledSpec,
|
|
47
|
+
status: healthyStatus,
|
|
48
|
+
state: "disabled",
|
|
49
|
+
phase: "disabled",
|
|
50
|
+
label: "Disabled",
|
|
51
|
+
isPaused: false,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "disabled + paused → disabled wins, pause stays visible",
|
|
55
|
+
spec: disabledSpec,
|
|
56
|
+
status: pausedStatus,
|
|
57
|
+
state: "disabled",
|
|
58
|
+
phase: "disabled",
|
|
59
|
+
label: "Disabled",
|
|
60
|
+
isPaused: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "missing spec → disabled (fails closed, like the server)",
|
|
64
|
+
spec: undefined,
|
|
65
|
+
status: healthyStatus,
|
|
66
|
+
state: "disabled",
|
|
67
|
+
phase: "disabled",
|
|
68
|
+
label: "Disabled",
|
|
69
|
+
isPaused: false,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "missing status → not paused",
|
|
73
|
+
spec: enabledSpec,
|
|
74
|
+
status: undefined,
|
|
75
|
+
state: "active",
|
|
76
|
+
phase: "ready",
|
|
77
|
+
label: "Active",
|
|
78
|
+
isPaused: false,
|
|
79
|
+
},
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
for (const c of cases) {
|
|
83
|
+
it(c.name, () => {
|
|
84
|
+
const info = deriveScheduleState(c.spec, c.status);
|
|
85
|
+
expect(info.state).toBe(c.state);
|
|
86
|
+
expect(info.phase).toBe(c.phase);
|
|
87
|
+
expect(info.label).toBe(c.label);
|
|
88
|
+
expect(info.isPaused).toBe(c.isPaused);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("formatNextFire", () => {
|
|
94
|
+
const now = new Date("2026-08-04T10:00:00Z");
|
|
95
|
+
const at = (offsetMs: number) => new Date(now.getTime() + offsetMs);
|
|
96
|
+
const MINUTE = 60_000;
|
|
97
|
+
const HOUR = 60 * MINUTE;
|
|
98
|
+
const DAY = 24 * HOUR;
|
|
99
|
+
|
|
100
|
+
it("renders sub-minute and past instants as 'now' (tick mid-flight)", () => {
|
|
101
|
+
expect(formatNextFire(at(30_000), now)).toBe("now");
|
|
102
|
+
expect(formatNextFire(at(-5 * MINUTE), now)).toBe("now");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("renders minutes, hours, and days compactly", () => {
|
|
106
|
+
expect(formatNextFire(at(5 * MINUTE), now)).toBe("in 5m");
|
|
107
|
+
expect(formatNextFire(at(59 * MINUTE), now)).toBe("in 59m");
|
|
108
|
+
expect(formatNextFire(at(3 * HOUR), now)).toBe("in 3h");
|
|
109
|
+
expect(formatNextFire(at(23 * HOUR), now)).toBe("in 23h");
|
|
110
|
+
expect(formatNextFire(at(DAY), now)).toBe("in 1d");
|
|
111
|
+
expect(formatNextFire(at(6 * DAY + 23 * HOUR), now)).toBe("in 6d");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("falls back to a short date beyond a week", () => {
|
|
115
|
+
const target = at(10 * DAY);
|
|
116
|
+
// Locale-independent expectation: same formatting call, same options.
|
|
117
|
+
const expected = target.toLocaleDateString(undefined, {
|
|
118
|
+
month: "short",
|
|
119
|
+
day: "numeric",
|
|
120
|
+
});
|
|
121
|
+
expect(formatNextFire(target, now)).toBe(expected);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("includes the year when the date crosses a year boundary", () => {
|
|
125
|
+
const target = new Date("2027-01-15T10:00:00Z");
|
|
126
|
+
const expected = target.toLocaleDateString(undefined, {
|
|
127
|
+
month: "short",
|
|
128
|
+
day: "numeric",
|
|
129
|
+
year: "numeric",
|
|
130
|
+
});
|
|
131
|
+
expect(formatNextFire(target, now)).toBe(expected);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cadence model behind {@link CadenceField} — a pure, dependency-free
|
|
3
|
+
* bridge between human-friendly schedule presets and the 5-field cron
|
|
4
|
+
* grammar the Schedule spec stores.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT a cron parser. The platform owns no cron parsing in
|
|
7
|
+
* either edition — calendar and DST semantics live in the Temporal server
|
|
8
|
+
* (see stigmer-server's schedule/controller/cron.go, DD-008 D2). This
|
|
9
|
+
* module only:
|
|
10
|
+
*
|
|
11
|
+
* 1. GENERATES cron strings from presets (`cadenceToCron`) — trivial
|
|
12
|
+
* string construction, always valid by construction.
|
|
13
|
+
* 2. RECOGNIZES the exact shapes the builder emits (`cronToCadence`)
|
|
14
|
+
* so a stored cron round-trips back into the preset UI; anything
|
|
15
|
+
* else falls back to the `custom` preset showing the raw string.
|
|
16
|
+
* 3. MIRRORS the server's purely lexical validation (`validateCron`,
|
|
17
|
+
* `validateTimeZone`) with the same user-facing wording, so the
|
|
18
|
+
* form can reject bad input instantly. The server remains the
|
|
19
|
+
* authority — its errors surface verbatim on submit.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Preset model
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** A human-friendly cadence, convertible to/from a cron expression. */
|
|
27
|
+
export type CadencePreset =
|
|
28
|
+
| { readonly kind: "hourly"; readonly minute: number }
|
|
29
|
+
| { readonly kind: "daily"; readonly hour: number; readonly minute: number }
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: "weekly";
|
|
32
|
+
/** Days of week, cron numbering: 0 = Sunday … 6 = Saturday. */
|
|
33
|
+
readonly days: readonly number[];
|
|
34
|
+
readonly hour: number;
|
|
35
|
+
readonly minute: number;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
readonly kind: "monthly";
|
|
39
|
+
/** Day of month, 1–31. Days 29–31 skip months without that day. */
|
|
40
|
+
readonly day: number;
|
|
41
|
+
readonly hour: number;
|
|
42
|
+
readonly minute: number;
|
|
43
|
+
}
|
|
44
|
+
| { readonly kind: "custom"; readonly cron: string };
|
|
45
|
+
|
|
46
|
+
/** Discriminant of {@link CadencePreset}. */
|
|
47
|
+
export type CadenceKind = CadencePreset["kind"];
|
|
48
|
+
|
|
49
|
+
/** Day-of-week labels indexed by cron numbering (0 = Sunday). */
|
|
50
|
+
export const WEEKDAY_LABELS = [
|
|
51
|
+
"Sunday",
|
|
52
|
+
"Monday",
|
|
53
|
+
"Tuesday",
|
|
54
|
+
"Wednesday",
|
|
55
|
+
"Thursday",
|
|
56
|
+
"Friday",
|
|
57
|
+
"Saturday",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Preset → cron
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the 5-field cron expression for a preset.
|
|
66
|
+
*
|
|
67
|
+
* Output is always valid by construction: numeric fields only,
|
|
68
|
+
* day-of-week in cron numbering (0 = Sunday). For `custom`, the raw
|
|
69
|
+
* string is returned as-is — validate it with {@link validateCron}.
|
|
70
|
+
*/
|
|
71
|
+
export function cadenceToCron(preset: CadencePreset): string {
|
|
72
|
+
switch (preset.kind) {
|
|
73
|
+
case "hourly":
|
|
74
|
+
return `${preset.minute} * * * *`;
|
|
75
|
+
case "daily":
|
|
76
|
+
return `${preset.minute} ${preset.hour} * * *`;
|
|
77
|
+
case "weekly": {
|
|
78
|
+
// Sorted, deduplicated day list keeps the output canonical so
|
|
79
|
+
// cronToCadence round-trips regardless of selection order.
|
|
80
|
+
const days = [...new Set(preset.days)].sort((a, b) => a - b);
|
|
81
|
+
return `${preset.minute} ${preset.hour} * * ${days.join(",")}`;
|
|
82
|
+
}
|
|
83
|
+
case "monthly":
|
|
84
|
+
return `${preset.minute} ${preset.hour} ${preset.day} * *`;
|
|
85
|
+
case "custom":
|
|
86
|
+
return preset.cron;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Cron → preset (recognition, not parsing)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const HOURLY_RE = /^(\d{1,2}) \* \* \* \*$/;
|
|
95
|
+
const DAILY_RE = /^(\d{1,2}) (\d{1,2}) \* \* \*$/;
|
|
96
|
+
const WEEKLY_RE = /^(\d{1,2}) (\d{1,2}) \* \* (\d(?:,\d)*)$/;
|
|
97
|
+
const MONTHLY_RE = /^(\d{1,2}) (\d{1,2}) (\d{1,2}) \* \*$/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Recognize a cron expression as one of the builder's presets.
|
|
101
|
+
*
|
|
102
|
+
* Only the exact shapes {@link cadenceToCron} emits (plus the
|
|
103
|
+
* calendar shorthands `@hourly`/`@daily`/`@weekly`/`@monthly`) map to a
|
|
104
|
+
* structured preset. Everything else — ranges, steps, names, `@yearly`
|
|
105
|
+
* — is a perfectly valid schedule that this UI simply cannot decompose,
|
|
106
|
+
* so it round-trips as `custom` with the raw string intact.
|
|
107
|
+
*/
|
|
108
|
+
export function cronToCadence(cron: string): CadencePreset {
|
|
109
|
+
const normalized = cron.trim().replace(/\s+/g, " ");
|
|
110
|
+
|
|
111
|
+
switch (normalized) {
|
|
112
|
+
case "@hourly":
|
|
113
|
+
return { kind: "hourly", minute: 0 };
|
|
114
|
+
case "@daily":
|
|
115
|
+
return { kind: "daily", hour: 0, minute: 0 };
|
|
116
|
+
case "@weekly":
|
|
117
|
+
return { kind: "weekly", days: [0], hour: 0, minute: 0 };
|
|
118
|
+
case "@monthly":
|
|
119
|
+
return { kind: "monthly", day: 1, hour: 0, minute: 0 };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let m = HOURLY_RE.exec(normalized);
|
|
123
|
+
if (m) {
|
|
124
|
+
const minute = Number(m[1]);
|
|
125
|
+
if (isMinute(minute)) return { kind: "hourly", minute };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
m = DAILY_RE.exec(normalized);
|
|
129
|
+
if (m) {
|
|
130
|
+
const minute = Number(m[1]);
|
|
131
|
+
const hour = Number(m[2]);
|
|
132
|
+
if (isMinute(minute) && isHour(hour)) return { kind: "daily", hour, minute };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
m = WEEKLY_RE.exec(normalized);
|
|
136
|
+
if (m) {
|
|
137
|
+
const minute = Number(m[1]);
|
|
138
|
+
const hour = Number(m[2]);
|
|
139
|
+
const days = m[3].split(",").map(Number);
|
|
140
|
+
if (
|
|
141
|
+
isMinute(minute) &&
|
|
142
|
+
isHour(hour) &&
|
|
143
|
+
days.length > 0 &&
|
|
144
|
+
days.every((d) => d >= 0 && d <= 6) &&
|
|
145
|
+
new Set(days).size === days.length
|
|
146
|
+
) {
|
|
147
|
+
return { kind: "weekly", days: [...days].sort((a, b) => a - b), hour, minute };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
m = MONTHLY_RE.exec(normalized);
|
|
152
|
+
if (m) {
|
|
153
|
+
const minute = Number(m[1]);
|
|
154
|
+
const hour = Number(m[2]);
|
|
155
|
+
const day = Number(m[3]);
|
|
156
|
+
if (isMinute(minute) && isHour(hour) && day >= 1 && day <= 31) {
|
|
157
|
+
return { kind: "monthly", day, hour, minute };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { kind: "custom", cron };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isMinute(n: number): boolean {
|
|
165
|
+
return Number.isInteger(n) && n >= 0 && n <= 59;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isHour(n: number): boolean {
|
|
169
|
+
return Number.isInteger(n) && n >= 0 && n <= 23;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Plain-English summary
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Human-readable summary of a cadence, e.g.
|
|
178
|
+
* `"Every day at 09:00 (Asia/Kolkata)"`.
|
|
179
|
+
*
|
|
180
|
+
* This is a description of the chosen preset, not a computed fire-time
|
|
181
|
+
* forecast — the platform's next fire time is published by the server
|
|
182
|
+
* as `status.next_fire_at` after apply.
|
|
183
|
+
*/
|
|
184
|
+
export function describeCadence(
|
|
185
|
+
preset: CadencePreset,
|
|
186
|
+
timeZone?: string,
|
|
187
|
+
): string {
|
|
188
|
+
const zone = timeZone ? ` (${timeZone})` : "";
|
|
189
|
+
switch (preset.kind) {
|
|
190
|
+
case "hourly":
|
|
191
|
+
return `Every hour at minute ${preset.minute}${zone}`;
|
|
192
|
+
case "daily":
|
|
193
|
+
return `Every day at ${formatTime(preset.hour, preset.minute)}${zone}`;
|
|
194
|
+
case "weekly": {
|
|
195
|
+
const days = [...new Set(preset.days)].sort((a, b) => a - b);
|
|
196
|
+
const names =
|
|
197
|
+
days.length === 7
|
|
198
|
+
? "day"
|
|
199
|
+
: listWithAnd(days.map((d) => WEEKDAY_LABELS[d]));
|
|
200
|
+
return `Every ${names} at ${formatTime(preset.hour, preset.minute)}${zone}`;
|
|
201
|
+
}
|
|
202
|
+
case "monthly":
|
|
203
|
+
return `On day ${preset.day} of every month at ${formatTime(preset.hour, preset.minute)}${zone}`;
|
|
204
|
+
case "custom":
|
|
205
|
+
return `Cron “${preset.cron.trim()}”${zone}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `9, 5` → `"09:05"` (24-hour clock, zero-padded). */
|
|
210
|
+
export function formatTime(hour: number, minute: number): string {
|
|
211
|
+
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function listWithAnd(items: readonly string[]): string {
|
|
215
|
+
if (items.length === 1) return items[0];
|
|
216
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Lexical validation — mirrors stigmer-server schedule/controller/cron.go
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
// The accepted @-shorthands, the field character set, the rejection
|
|
224
|
+
// cases AND the message wording below mirror the server byte-for-byte
|
|
225
|
+
// (Go: cron.go / cron_test.go; Java: ValidateScheduleSpec). A change on
|
|
226
|
+
// any side must change all three, or the instant client feedback drifts
|
|
227
|
+
// from what the server enforces on submit.
|
|
228
|
+
|
|
229
|
+
const CRON_SHORTHANDS = new Set([
|
|
230
|
+
"@hourly",
|
|
231
|
+
"@daily",
|
|
232
|
+
"@weekly",
|
|
233
|
+
"@monthly",
|
|
234
|
+
"@yearly",
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
const CRON_FIELD_PATTERN = /^[0-9A-Za-z*,/-]+$/;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate a cron expression against the platform's lexical grammar.
|
|
241
|
+
*
|
|
242
|
+
* Returns the user-facing error message (identical wording to the
|
|
243
|
+
* server's) or `null` when the expression is acceptable. Purely
|
|
244
|
+
* lexical — it does not evaluate calendar semantics.
|
|
245
|
+
*/
|
|
246
|
+
export function validateCron(cron: string): string | null {
|
|
247
|
+
if (cron.startsWith("CRON_TZ=") || cron.startsWith("TZ=")) {
|
|
248
|
+
return "spec.cron must not carry a timezone prefix (CRON_TZ=/TZ=) — spec.time_zone is the single timezone authority";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (cron.includes("#")) {
|
|
252
|
+
return "spec.cron must not contain a comment (#)";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (cron.startsWith("@")) {
|
|
256
|
+
if (cron.startsWith("@every")) {
|
|
257
|
+
return "spec.cron must be a calendar expression — @every intervals are not supported";
|
|
258
|
+
}
|
|
259
|
+
if (!CRON_SHORTHANDS.has(cron)) {
|
|
260
|
+
return `spec.cron shorthand "${cron}" is not supported — use @hourly, @daily, @weekly, @monthly, or @yearly, or a 5-field cron expression`;
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Matches Go's strings.Fields: split on runs of whitespace, so
|
|
266
|
+
// surrounding/duplicate whitespace never produces empty fields.
|
|
267
|
+
const fields = cron.split(/\s+/).filter((f) => f.length > 0);
|
|
268
|
+
if (fields.length !== 5) {
|
|
269
|
+
return `spec.cron must have exactly 5 fields (minute hour day-of-month month day-of-week); got ${fields.length}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const field of fields) {
|
|
273
|
+
if (!CRON_FIELD_PATTERN.test(field)) {
|
|
274
|
+
return `spec.cron field "${field}" contains unsupported characters — allowed: digits, names, '*', ',', '-', '/'`;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Validate an IANA time zone name using the runtime's own tz database
|
|
283
|
+
* (`Intl`), mirroring the server's check against the platform tz
|
|
284
|
+
* database. Returns the user-facing error message or `null`.
|
|
285
|
+
*
|
|
286
|
+
* `"Local"` is rejected explicitly for the same reason the server
|
|
287
|
+
* rejects it: it is host-dependent, not a zone.
|
|
288
|
+
*/
|
|
289
|
+
export function validateTimeZone(timeZone: string): string | null {
|
|
290
|
+
const invalid = `spec.time_zone "${timeZone}" is not a valid IANA time zone (e.g. "Asia/Kolkata")`;
|
|
291
|
+
if (timeZone === "Local") return invalid;
|
|
292
|
+
try {
|
|
293
|
+
new Intl.DateTimeFormat("en-US", { timeZone });
|
|
294
|
+
return null;
|
|
295
|
+
} catch {
|
|
296
|
+
return invalid;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export { deriveScheduleState, formatNextFire } from "./scheduleState.js";
|
|
2
|
+
export type { ScheduleState, ScheduleStateInfo } from "./scheduleState.js";
|
|
3
|
+
|
|
4
|
+
export { createScheduleListFn, listSchedulesPage } from "./scheduleListFn.js";
|
|
5
|
+
export type { ScheduleListClient, SchedulePage } from "./scheduleListFn.js";
|
|
6
|
+
|
|
7
|
+
export { useSchedule } from "./useSchedule.js";
|
|
8
|
+
export type { UseScheduleReturn } from "./useSchedule.js";
|
|
9
|
+
|
|
10
|
+
export { useScheduleList } from "./useScheduleList.js";
|
|
11
|
+
export type {
|
|
12
|
+
UseScheduleListOptions,
|
|
13
|
+
UseScheduleListReturn,
|
|
14
|
+
} from "./useScheduleList.js";
|
|
15
|
+
|
|
16
|
+
export { useScheduleCount } from "./useScheduleCount.js";
|
|
17
|
+
export type {
|
|
18
|
+
UseScheduleCountOptions,
|
|
19
|
+
UseScheduleCountReturn,
|
|
20
|
+
} from "./useScheduleCount.js";
|
|
21
|
+
|
|
22
|
+
export { useResumeSchedule } from "./useResumeSchedule.js";
|
|
23
|
+
export type { UseResumeScheduleReturn } from "./useResumeSchedule.js";
|
|
24
|
+
|
|
25
|
+
export { useTriggerSchedule } from "./useTriggerSchedule.js";
|
|
26
|
+
export type { UseTriggerScheduleReturn } from "./useTriggerSchedule.js";
|
|
27
|
+
|
|
28
|
+
export { useSetScheduleEnabled } from "./useSetScheduleEnabled.js";
|
|
29
|
+
export type { UseSetScheduleEnabledReturn } from "./useSetScheduleEnabled.js";
|
|
30
|
+
|
|
31
|
+
export { createScheduleColumns } from "./scheduleColumns.js";
|
|
32
|
+
export type { ScheduleColumnsOptions } from "./scheduleColumns.js";
|
|
33
|
+
|
|
34
|
+
export { ScheduleRowActions } from "./ScheduleRowActions.js";
|
|
35
|
+
export type { ScheduleRowActionsProps } from "./ScheduleRowActions.js";
|
|
36
|
+
|
|
37
|
+
export { ScheduleDetailView, ScheduleIcon } from "./ScheduleDetailView.js";
|
|
38
|
+
export type { ScheduleDetailViewProps } from "./ScheduleDetailView.js";
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
cadenceToCron,
|
|
42
|
+
cronToCadence,
|
|
43
|
+
describeCadence,
|
|
44
|
+
validateCron,
|
|
45
|
+
validateTimeZone,
|
|
46
|
+
WEEKDAY_LABELS,
|
|
47
|
+
} from "./cadence.js";
|
|
48
|
+
export type { CadencePreset, CadenceKind } from "./cadence.js";
|
|
49
|
+
|
|
50
|
+
export { useCreateSchedule } from "./useCreateSchedule.js";
|
|
51
|
+
export type { UseCreateScheduleReturn } from "./useCreateSchedule.js";
|
|
52
|
+
|
|
53
|
+
export { CadenceField } from "./CadenceField.js";
|
|
54
|
+
export type { CadenceFieldProps } from "./CadenceField.js";
|
|
55
|
+
|
|
56
|
+
export { ScheduleForm } from "./ScheduleForm.js";
|
|
57
|
+
export type { ScheduleFormProps } from "./ScheduleForm.js";
|