@syncended/dsh-automations 0.6.0 → 0.7.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/README.md CHANGED
@@ -55,7 +55,7 @@ dsh plugin --profile web remove @syncended/dsh-automations
55
55
  | Field | Meaning |
56
56
  |---|---|
57
57
  | Name | Human-readable job name. |
58
- | Cron | Five fields: `minute hour day-of-month month day-of-week`. Example: `0 9 * * 1-5`. |
58
+ | Schedule | Choose Minutes, Hourly, Daily, Weekdays, or Weekly and adjust its simple controls. Custom exposes the raw five-field cron (`minute hour day-of-month month day-of-week`) with an inline field guide. |
59
59
  | Time zone | Search by city or region in the picker, or enter `UTC` / an IANA name such as `Europe/Berlin`. Current UTC offsets are shown; DST is handled by `cron-parser`. |
60
60
  | Workspace | Choose an existing Harness workspace or enter an absolute directory manually. Its canonical filesystem identity becomes the Session cwd and `workspace-write` root. |
61
61
  | Prompt | The user message sent to a fresh Harness Agent. |
@@ -66,7 +66,7 @@ dsh plugin --profile web remove @syncended/dsh-automations
66
66
  | Timeout | Wall-clock run limit. Cancellation is cooperative through the Agent loop. |
67
67
  | Overlap / misfire | Admission behavior described above. |
68
68
 
69
- A disabled job may still be started with **Run now**.
69
+ The form groups common settings into **Task**, **Schedule**, and **Agent & access**. Job ID, timeout, overlap, and misfire stay available in the collapsed **Advanced** section. A paused job may still be started with **Run now**.
70
70
 
71
71
  ## Plugin configuration
72
72
 
@@ -32,7 +32,7 @@ Sidebar / Center workspace / Settings UI / HTTP API
32
32
 
33
33
  The same automation page is available through Settings and a dedicated center workspace. `settings.section` keeps configuration available inside Settings; `sidebar.footer.action` activates a temporary, higher-priority `conversation` entry that replaces the current center occupant. This provides a split-screen-like full-center experience while using the single-slot election directly rather than Split Screen's internal view contributions. Closing Automations disposes its entry immediately, revealing whichever center surface was previously active. A small client-only disclosure store coordinates the sidebar trigger and dynamic registration; neither surface owns scheduler state, and both read the same package HTTP API.
34
34
 
35
- Interactive chrome uses the ambient `@deepseek-ai/dsh-client-ui-primitives` `Button`, `Menu`, and icon components. The Workspace selector subscribes to `ctx.workspaces.list`, projecting the Host's live workspace order while retaining manual absolute-path entry. Package CSS is limited to the automation-specific layout and composes only public DSH semantic tokens, so theme, menu, focus, and button behavior stay aligned with the host UI.
35
+ Interactive chrome uses the ambient `@deepseek-ai/dsh-client-ui-primitives` `Button`, `Menu`, `Pill`, and icon components. The form separates Task, Schedule, Agent/access, and Advanced concerns; common schedule pills generate canonical five-field cron while Custom keeps the raw expression and field guide available. The Workspace selector subscribes to `ctx.workspaces.list`, projecting the Host's live workspace order while retaining manual absolute-path entry. Package CSS is limited to the automation-specific layout and composes only public DSH semantic tokens, so theme, menu, focus, and button behavior stay aligned with the host UI.
36
36
 
37
37
  ### AutomationService
38
38
 
package/lib/client.js CHANGED
@@ -17,6 +17,7 @@ window.__ModuleLoader__.load({
17
17
  const {
18
18
  Button,
19
19
  Menu,
20
+ Pill,
20
21
  RiskConfirmation,
21
22
  IconAgentPresetOutline16,
22
23
  IconBrowseOutline16,
@@ -44,6 +45,32 @@ window.__ModuleLoader__.load({
44
45
  const MIN_TIMEOUT_MS = 1000;
45
46
  const MAX_TIMEOUT_MS = 86400000;
46
47
  const JOB_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
48
+ const SCHEDULE_MODES = [
49
+ { id: "minutes", label: "Minutes" },
50
+ { id: "hourly", label: "Hourly" },
51
+ { id: "daily", label: "Daily" },
52
+ { id: "weekdays", label: "Weekdays" },
53
+ { id: "weekly", label: "Weekly" },
54
+ { id: "custom", label: "Custom" },
55
+ ];
56
+ const MINUTE_INTERVALS = [5, 10, 15, 20, 30];
57
+ const HOURLY_MINUTES = Array.from({ length: 60 }, (_, index) => index);
58
+ const WEEKDAYS = [
59
+ { value: "1", label: "Monday" },
60
+ { value: "2", label: "Tuesday" },
61
+ { value: "3", label: "Wednesday" },
62
+ { value: "4", label: "Thursday" },
63
+ { value: "5", label: "Friday" },
64
+ { value: "6", label: "Saturday" },
65
+ { value: "0", label: "Sunday" },
66
+ ];
67
+ const CRON_FIELD_GUIDE = [
68
+ { label: "Minute", range: "0–59" },
69
+ { label: "Hour", range: "0–23" },
70
+ { label: "Day", range: "1–31" },
71
+ { label: "Month", range: "1–12" },
72
+ { label: "Weekday", range: "0–7" },
73
+ ];
47
74
  let formInstanceSerial = 0;
48
75
 
49
76
  const BROWSER_TIMEZONE = (() => {
@@ -140,13 +167,13 @@ window.__ModuleLoader__.load({
140
167
  error: null,
141
168
  });
142
169
  const OVERLAP_OPTIONS = [
143
- { value: "skip", label: "skip", hint: "Skip the run if a previous run is still active." },
144
- { value: "queue", label: "queue", hint: "Defer the run until the previous run finishes." },
145
- { value: "allow", label: "allow", hint: "Run concurrently with any active run." },
170
+ { value: "skip", label: "Skip the new run", hint: "Skip the run if a previous run is still active." },
171
+ { value: "queue", label: "Queue the next run", hint: "Defer the run until the previous run finishes." },
172
+ { value: "allow", label: "Allow concurrent runs", hint: "Run concurrently with any active run." },
146
173
  ];
147
174
  const MISFIRE_OPTIONS = [
148
- { value: "skip", label: "skip", hint: "Drop occurrences that were missed while the scheduler was down." },
149
- { value: "run-once", label: "run-once", hint: "Run once after downtime for the latest missed occurrence." },
175
+ { value: "skip", label: "Skip missed runs", hint: "Drop occurrences that were missed while the scheduler was down." },
176
+ { value: "run-once", label: "Run once after downtime", hint: "Run once after downtime for the latest missed occurrence." },
150
177
  ];
151
178
 
152
179
  function errMessage(error) {
@@ -395,6 +422,31 @@ window.__ModuleLoader__.load({
395
422
  return Math.round(ms / 1000) + "s";
396
423
  }
397
424
 
425
+ function overlapPolicySummary(value) {
426
+ if (value === "queue") return "Queue overlaps";
427
+ if (value === "allow") return "Allow concurrent runs";
428
+ return "Skip overlaps";
429
+ }
430
+
431
+ function inferFormErrorField(message) {
432
+ const text = String(message || "").toLowerCase();
433
+ if (text.includes("job id")) return "id";
434
+ if (text.includes("timeout")) return "timeout";
435
+ if (text.includes("overlap")) return "overlap";
436
+ if (text.includes("misfire")) return "misfire";
437
+ if (text.includes("cron")) return "cron";
438
+ if (text.includes("timezone") || text.includes("time zone")) return "timezone";
439
+ if (text.includes("workspace")) return "cwd";
440
+ if (text.includes("prompt")) return "prompt";
441
+ if (text.includes("reasoning effort")) return "effort";
442
+ if (text.includes("agent preset")) return "preset";
443
+ if (text.includes("permission")) return "permission";
444
+ if (text.includes("model")) return "model";
445
+ if (text.includes("provider")) return "provider";
446
+ if (text.includes("name")) return "name";
447
+ return null;
448
+ }
449
+
398
450
  async function apiFetch(path, options = {}) {
399
451
  const headers = Object.assign(
400
452
  { "content-type": "application/json", "x-dsh-automation-client": "1" },
@@ -500,6 +552,304 @@ window.__ModuleLoader__.load({
500
552
  };
501
553
  }
502
554
 
555
+ function cronFields(value) {
556
+ return String(value || "").trim().split(/\s+/).filter(Boolean);
557
+ }
558
+
559
+ function twoDigits(value) {
560
+ return String(value).padStart(2, "0");
561
+ }
562
+
563
+ function simpleSchedule(cronValue) {
564
+ const cron = cronFields(cronValue).join(" ");
565
+ let match = cron.match(/^\*\/(5|10|15|20|30) \* \* \* \*$/);
566
+ if (match) return { mode: "minutes", interval: Number(match[1]), minute: 0, hour: 9, weekday: "1" };
567
+ match = cron.match(/^(\d{1,2}) \* \* \* \*$/);
568
+ if (match && Number(match[1]) <= 59) {
569
+ return { mode: "hourly", minute: Number(match[1]), hour: 9, weekday: "1", interval: 15 };
570
+ }
571
+ match = cron.match(/^(\d{1,2}) (\d{1,2}) \* \* 1-5$/);
572
+ if (match && Number(match[1]) <= 59 && Number(match[2]) <= 23) {
573
+ return { mode: "weekdays", minute: Number(match[1]), hour: Number(match[2]), weekday: "1", interval: 15 };
574
+ }
575
+ match = cron.match(/^(\d{1,2}) (\d{1,2}) \* \* ([0-7])$/);
576
+ if (match && Number(match[1]) <= 59 && Number(match[2]) <= 23) {
577
+ return {
578
+ mode: "weekly",
579
+ minute: Number(match[1]),
580
+ hour: Number(match[2]),
581
+ weekday: match[3] === "7" ? "0" : match[3],
582
+ interval: 15,
583
+ };
584
+ }
585
+ match = cron.match(/^(\d{1,2}) (\d{1,2}) \* \* \*$/);
586
+ if (match && Number(match[1]) <= 59 && Number(match[2]) <= 23) {
587
+ return { mode: "daily", minute: Number(match[1]), hour: Number(match[2]), weekday: "1", interval: 15 };
588
+ }
589
+ return { mode: "custom", minute: 0, hour: 9, weekday: "1", interval: 15 };
590
+ }
591
+
592
+ function scheduleControlValues(cronValue, fallback = {}) {
593
+ const fields = cronFields(cronValue);
594
+ const minute = /^\d{1,2}$/.test(fields[0] || "") && Number(fields[0]) <= 59
595
+ ? Number(fields[0])
596
+ : Number.isInteger(fallback.minute) ? fallback.minute : 0;
597
+ const hour = /^\d{1,2}$/.test(fields[1] || "") && Number(fields[1]) <= 23
598
+ ? Number(fields[1])
599
+ : Number.isInteger(fallback.hour) ? fallback.hour : 9;
600
+ const normalizedWeekday = fields[4] === "7" ? "0" : fields[4];
601
+ const weekday = WEEKDAYS.some((day) => day.value === normalizedWeekday)
602
+ ? normalizedWeekday
603
+ : WEEKDAYS.some((day) => day.value === String(fallback.weekday)) ? String(fallback.weekday) : "1";
604
+ const intervalMatch = /^\*\/(\d+)$/.exec(fields[0] || "");
605
+ const parsedInterval = intervalMatch ? Number(intervalMatch[1]) : NaN;
606
+ const interval = MINUTE_INTERVALS.includes(parsedInterval)
607
+ ? parsedInterval
608
+ : MINUTE_INTERVALS.includes(fallback.interval) ? fallback.interval : 15;
609
+ return { minute, hour, weekday, interval };
610
+ }
611
+
612
+ function cronForSimpleSchedule(mode, values = {}) {
613
+ const minute = Number.isInteger(values.minute) && values.minute >= 0 && values.minute <= 59 ? values.minute : 0;
614
+ const hour = Number.isInteger(values.hour) && values.hour >= 0 && values.hour <= 23 ? values.hour : 9;
615
+ const interval = MINUTE_INTERVALS.includes(values.interval) ? values.interval : 15;
616
+ const weekday = WEEKDAYS.some((day) => day.value === String(values.weekday)) ? String(values.weekday) : "1";
617
+ if (mode === "minutes") return "*/" + interval + " * * * *";
618
+ if (mode === "hourly") return minute + " * * * *";
619
+ if (mode === "daily") return minute + " " + hour + " * * *";
620
+ if (mode === "weekdays") return minute + " " + hour + " * * 1-5";
621
+ if (mode === "weekly") return minute + " " + hour + " * * " + weekday;
622
+ return null;
623
+ }
624
+
625
+ function scheduleModeTransition(mode, cron, values, customCron) {
626
+ if (mode === "custom") {
627
+ const restored = customCron === null ? cron : customCron;
628
+ return { cron: restored, customCron: restored };
629
+ }
630
+ return {
631
+ cron: cronForSimpleSchedule(mode, values) || cron,
632
+ customCron,
633
+ };
634
+ }
635
+
636
+ function describeSimpleSchedule(schedule, timezone) {
637
+ const time = twoDigits(schedule.hour) + ":" + twoDigits(schedule.minute);
638
+ const zone = timezone.trim() || "the selected time zone";
639
+ if (schedule.mode === "minutes") return "Every " + schedule.interval + " minutes · " + zone;
640
+ if (schedule.mode === "hourly") return "Every hour at :" + twoDigits(schedule.minute) + " · " + zone;
641
+ if (schedule.mode === "daily") return "Every day at " + time + " · " + zone;
642
+ if (schedule.mode === "weekdays") return "Monday–Friday at " + time + " · " + zone;
643
+ if (schedule.mode === "weekly") {
644
+ const day = WEEKDAYS.find((entry) => entry.value === schedule.weekday)?.label || "Monday";
645
+ return "Every " + day + " at " + time + " · " + zone;
646
+ }
647
+ return cronFields(schedule.cron).length === 5
648
+ ? "Custom five-field schedule · " + zone
649
+ : "Cron needs exactly five fields · " + zone;
650
+ }
651
+
652
+ function FormSection({ title, description, Icon, children, className = "" }) {
653
+ return h(
654
+ "section",
655
+ { className: "dsh-auto-form-section" + (className ? " " + className : "") },
656
+ h(
657
+ "header",
658
+ { className: "dsh-auto-form-section-head" },
659
+ Icon ? h("span", { className: "dsh-auto-form-section-icon", "aria-hidden": "true" }, h(Icon)) : null,
660
+ h(
661
+ "span",
662
+ { className: "dsh-auto-form-section-copy" },
663
+ h("h4", { className: "dsh-auto-form-section-title" }, title),
664
+ description ? h("p", { className: "dsh-auto-form-section-description" }, description) : null,
665
+ ),
666
+ ),
667
+ children,
668
+ );
669
+ }
670
+
671
+ function CronFieldGuide({ value }) {
672
+ const fields = cronFields(value);
673
+ return h(
674
+ React.Fragment,
675
+ null,
676
+ h(
677
+ "div",
678
+ { className: "dsh-auto-cron-guide", "aria-label": "Cron field order" },
679
+ CRON_FIELD_GUIDE.map((field, index) => h(
680
+ "span",
681
+ { key: field.label, className: "dsh-auto-cron-guide-field" },
682
+ h("code", { className: "dsh-auto-cron-guide-value" }, fields[index] || "—"),
683
+ h("span", { className: "dsh-auto-cron-guide-label" }, field.label),
684
+ h("span", { className: "dsh-auto-cron-guide-range" }, field.range),
685
+ )),
686
+ ),
687
+ h("p", { className: "dsh-auto-cron-syntax" }, "* any value · , list · - range · / step"),
688
+ );
689
+ }
690
+
691
+ function ScheduleEditor({ fieldId, cron, timezone, onCronChange, onTimezoneChange }) {
692
+ const detected = simpleSchedule(cron);
693
+ const [forceCustom, setForceCustom] = useState(false);
694
+ const [values, setValues] = useState(() => scheduleControlValues(cron));
695
+ const customCronRef = useRef(detected.mode === "custom" ? cron : null);
696
+ const customCronPinnedRef = useRef(detected.mode === "custom");
697
+ const activeMode = forceCustom || detected.mode === "custom" ? "custom" : detected.mode;
698
+ const applySimpleValues = (mode, nextValues) => {
699
+ setValues(nextValues);
700
+ const expression = cronForSimpleSchedule(mode, nextValues);
701
+ if (expression !== null) onCronChange(expression);
702
+ };
703
+ const setMode = (mode) => {
704
+ if (mode !== "custom" && !customCronPinnedRef.current) customCronRef.current = null;
705
+ const transition = scheduleModeTransition(mode, cron, values, customCronRef.current);
706
+ customCronRef.current = transition.customCron;
707
+ setForceCustom(mode === "custom");
708
+ if (transition.cron !== cron) onCronChange(transition.cron);
709
+ };
710
+ const setTime = (raw) => {
711
+ const match = /^(\d{2}):(\d{2})$/.exec(raw);
712
+ if (!match) return;
713
+ applySimpleValues(activeMode, Object.assign({}, values, {
714
+ hour: Number(match[1]),
715
+ minute: Number(match[2]),
716
+ }));
717
+ };
718
+ const summarySchedule = Object.assign({}, values, { mode: activeMode, cron });
719
+ const displayedCron = cronFields(cron).join(" ") || "—";
720
+
721
+ return h(
722
+ React.Fragment,
723
+ null,
724
+ h(
725
+ "div",
726
+ { className: "dsh-auto-schedule-modes dsh-auto-field-full", role: "group", "aria-label": "Schedule frequency" },
727
+ SCHEDULE_MODES.map((mode) => h(
728
+ Pill,
729
+ {
730
+ key: mode.id,
731
+ type: "button",
732
+ active: activeMode === mode.id,
733
+ "aria-pressed": activeMode === mode.id,
734
+ onClick: () => setMode(mode.id),
735
+ },
736
+ mode.label,
737
+ )),
738
+ ),
739
+ activeMode === "minutes"
740
+ ? h(
741
+ Field,
742
+ { label: "Interval", htmlFor: fieldId("interval") },
743
+ h(
744
+ "select",
745
+ {
746
+ id: fieldId("interval"),
747
+ className: "dsh-auto-select",
748
+ value: values.interval,
749
+ onChange: (event) => applySimpleValues("minutes", Object.assign({}, values, { interval: Number(event.target.value) })),
750
+ },
751
+ MINUTE_INTERVALS.map((minutes) => h("option", { key: minutes, value: minutes }, "Every " + minutes + " minutes")),
752
+ ),
753
+ )
754
+ : null,
755
+ activeMode === "hourly"
756
+ ? h(
757
+ Field,
758
+ { label: "Minute past the hour", htmlFor: fieldId("hour-minute") },
759
+ h(
760
+ "select",
761
+ {
762
+ id: fieldId("hour-minute"),
763
+ className: "dsh-auto-select",
764
+ value: values.minute,
765
+ onChange: (event) => applySimpleValues("hourly", Object.assign({}, values, { minute: Number(event.target.value) })),
766
+ },
767
+ HOURLY_MINUTES.map((minute) => h("option", { key: minute, value: minute }, ":" + twoDigits(minute))),
768
+ ),
769
+ )
770
+ : null,
771
+ ["daily", "weekdays", "weekly"].includes(activeMode)
772
+ ? h(
773
+ Field,
774
+ { label: "Time", htmlFor: fieldId("schedule-time") },
775
+ h("input", {
776
+ id: fieldId("schedule-time"),
777
+ className: "dsh-auto-input",
778
+ type: "time",
779
+ value: twoDigits(values.hour) + ":" + twoDigits(values.minute),
780
+ onChange: (event) => setTime(event.target.value),
781
+ }),
782
+ )
783
+ : null,
784
+ activeMode === "weekly"
785
+ ? h(
786
+ Field,
787
+ { label: "Day", htmlFor: fieldId("schedule-day") },
788
+ h(
789
+ "select",
790
+ {
791
+ id: fieldId("schedule-day"),
792
+ className: "dsh-auto-select",
793
+ value: values.weekday,
794
+ onChange: (event) => applySimpleValues("weekly", Object.assign({}, values, { weekday: event.target.value })),
795
+ },
796
+ WEEKDAYS.map((day) => h("option", { key: day.value, value: day.value }, day.label)),
797
+ ),
798
+ )
799
+ : null,
800
+ h(
801
+ Field,
802
+ {
803
+ label: "Time zone",
804
+ htmlFor: fieldId("timezone"),
805
+ required: true,
806
+ full: activeMode === "weekly" || activeMode === "custom",
807
+ hint: "Current UTC offsets and daylight-saving changes are handled by the selected IANA time zone.",
808
+ },
809
+ h(TimeZonePicker, {
810
+ id: fieldId("timezone"),
811
+ value: timezone,
812
+ onChange: onTimezoneChange,
813
+ }),
814
+ ),
815
+ activeMode === "custom"
816
+ ? h(
817
+ Field,
818
+ {
819
+ label: "Cron expression",
820
+ htmlFor: fieldId("cron"),
821
+ required: true,
822
+ full: true,
823
+ hint: "Five fields only. Seconds and hashed H expressions are not supported.",
824
+ },
825
+ h("input", {
826
+ id: fieldId("cron"),
827
+ className: "dsh-auto-input dsh-auto-cron-input",
828
+ type: "text",
829
+ value: cron,
830
+ spellCheck: false,
831
+ placeholder: "0 9 * * 1-5",
832
+ onChange: (event) => {
833
+ const nextCron = event.target.value;
834
+ customCronRef.current = nextCron;
835
+ customCronPinnedRef.current = true;
836
+ setValues(scheduleControlValues(nextCron, values));
837
+ setForceCustom(true);
838
+ onCronChange(nextCron);
839
+ },
840
+ }),
841
+ h(CronFieldGuide, { value: cron }),
842
+ )
843
+ : null,
844
+ h(
845
+ "div",
846
+ { className: "dsh-auto-schedule-summary dsh-auto-field-full" },
847
+ h("span", { className: "dsh-auto-schedule-summary-copy" }, describeSimpleSchedule(summarySchedule, timezone)),
848
+ h("code", { className: "dsh-auto-schedule-expression", title: displayedCron }, displayedCron),
849
+ ),
850
+ );
851
+ }
852
+
503
853
  function Field(props) {
504
854
  const { label, htmlFor, required, hint, full, children } = props;
505
855
  return h(
@@ -1506,7 +1856,7 @@ window.__ModuleLoader__.load({
1506
1856
  }
1507
1857
 
1508
1858
  function JobForm(props) {
1509
- const { meta, workspaceSnapshot, draft, editing, saving, error, onChange, onIdTouched, onSubmit, onCancel } = props;
1859
+ const { meta, workspaceSnapshot, draft, editing, saving, error, errorField, onChange, onIdTouched, onSubmit, onCancel } = props;
1510
1860
  const providers = normalizedProviders(meta && Array.isArray(meta.providers) ? meta.providers : []);
1511
1861
  const providerModels = providers.find((provider) => provider.id === draft.provider);
1512
1862
  const models = providerModels ? providerModels.models : [];
@@ -1522,6 +1872,43 @@ window.__ModuleLoader__.load({
1522
1872
  formPrefixRef.current = nextFormInstancePrefix(reactFormId);
1523
1873
  }
1524
1874
  const fieldId = (name) => formPrefixRef.current + "-" + name;
1875
+ const errorId = fieldId("form-error");
1876
+ const [advancedOpen, setAdvancedOpen] = useState(false);
1877
+ const advancedError = ["id", "timeout", "overlap", "misfire"].includes(errorField);
1878
+ const errorPanelReady = !advancedError || advancedOpen;
1879
+ useEffect(() => {
1880
+ if (!error || !errorField) return undefined;
1881
+ if (!errorPanelReady) {
1882
+ setAdvancedOpen(true);
1883
+ return undefined;
1884
+ }
1885
+ let target = null;
1886
+ let previousInvalid = null;
1887
+ let previousDescribedBy = null;
1888
+ const frame = requestAnimationFrame(() => {
1889
+ target = document.getElementById(fieldId(errorField));
1890
+ if (!target) return;
1891
+ previousInvalid = target.getAttribute("aria-invalid");
1892
+ previousDescribedBy = target.getAttribute("aria-describedby");
1893
+ target.setAttribute("aria-invalid", "true");
1894
+ target.setAttribute("aria-describedby", errorId);
1895
+ target.focus();
1896
+ });
1897
+ return () => {
1898
+ cancelAnimationFrame(frame);
1899
+ if (!target) return;
1900
+ if (previousInvalid === null) target.removeAttribute("aria-invalid");
1901
+ else target.setAttribute("aria-invalid", previousInvalid);
1902
+ if (previousDescribedBy === null) target.removeAttribute("aria-describedby");
1903
+ else target.setAttribute("aria-describedby", previousDescribedBy);
1904
+ };
1905
+ }, [error, errorField, errorPanelReady]);
1906
+ const advancedSummary = [
1907
+ creating ? (draft.id.trim() === "" ? "Automatic job ID" : "ID " + draft.id.trim()) : "ID " + draft.id,
1908
+ (formatTimeout(Number(draft.timeoutMs)) || draft.timeoutMs + "ms") + " timeout",
1909
+ overlapPolicySummary(draft.overlap),
1910
+ draft.misfire === "skip" ? "Skip missed runs" : "Run once after downtime",
1911
+ ].join(" · ");
1525
1912
 
1526
1913
  return h(
1527
1914
  "form",
@@ -1531,262 +1918,327 @@ window.__ModuleLoader__.load({
1531
1918
  noValidate: true,
1532
1919
  "aria-label": creating ? "New automation" : "Edit automation",
1533
1920
  },
1534
- h("h3", null, creating ? "New automation" : "Edit \u201c" + editing.name + "\u201d"),
1535
1921
  h(
1536
- "div",
1537
- { className: "dsh-auto-formgrid" },
1922
+ "header",
1923
+ { className: "dsh-auto-form-header" },
1538
1924
  h(
1539
- Field,
1540
- { label: "Name", htmlFor: fieldId("name"), required: true },
1541
- h("input", {
1542
- id: fieldId("name"),
1543
- className: "dsh-auto-input",
1544
- type: "text",
1545
- value: draft.name,
1546
- autoFocus: true,
1547
- placeholder: "e.g. Morning standup notes",
1548
- onChange: (event) => onChange("name", event.target.value),
1549
- }),
1925
+ "span",
1926
+ { className: "dsh-auto-form-heading" },
1927
+ h("h3", null, creating ? "New automation" : "Edit \u201c" + editing.name + "\u201d"),
1928
+ h("p", null, "Configure what runs, when it runs, and which Harness agent executes it."),
1550
1929
  ),
1551
- creating
1552
- ? h(
1553
- Field,
1554
- { label: "Job id", htmlFor: fieldId("id"), hint: "Lowercase letters, digits and hyphens; auto-derived from the name. Leave blank to let the server generate one." },
1555
- h("input", {
1556
- id: fieldId("id"),
1557
- className: "dsh-auto-input",
1558
- type: "text",
1559
- value: draft.id,
1560
- spellCheck: false,
1561
- placeholder: "auto",
1562
- onChange: (event) => {
1563
- onIdTouched();
1564
- onChange("id", event.target.value);
1565
- },
1566
- }),
1567
- )
1568
- : h(
1569
- Field,
1570
- { label: "Job id", htmlFor: fieldId("id"), hint: "Fixed after creation." },
1571
- h("input", {
1572
- id: fieldId("id"),
1573
- className: "dsh-auto-input",
1574
- type: "text",
1575
- value: draft.id,
1576
- disabled: true,
1577
- spellCheck: false,
1578
- }),
1579
- ),
1580
1930
  h(
1581
- Field,
1582
- { label: "Cron", htmlFor: fieldId("cron"), required: true, hint: "Five-field cron (minute hour day-of-month month day-of-week)." },
1931
+ "label",
1932
+ { className: "dsh-auto-switch dsh-auto-form-enabled" },
1583
1933
  h("input", {
1584
- id: fieldId("cron"),
1585
- className: "dsh-auto-input",
1586
- type: "text",
1587
- value: draft.cron,
1588
- spellCheck: false,
1589
- onChange: (event) => onChange("cron", event.target.value),
1590
- }),
1591
- ),
1592
- h(
1593
- Field,
1594
- {
1595
- label: "Time zone",
1596
- htmlFor: fieldId("timezone"),
1597
- required: true,
1598
- hint: "Enter an IANA time zone. Current UTC offsets may change with daylight saving time.",
1599
- },
1600
- h(TimeZonePicker, {
1601
- id: fieldId("timezone"),
1602
- value: draft.timezone,
1603
- onChange: (value) => onChange("timezone", value),
1934
+ id: fieldId("enabled"),
1935
+ type: "checkbox",
1936
+ role: "switch",
1937
+ checked: draft.enabled,
1938
+ onChange: (event) => onChange("enabled", event.target.checked),
1604
1939
  }),
1940
+ h("span", { className: "dsh-auto-switch-track" }),
1941
+ h("span", { className: "dsh-auto-switch-text" }, draft.enabled ? "Active" : "Paused"),
1605
1942
  ),
1943
+ ),
1944
+ h(
1945
+ FormSection,
1946
+ {
1947
+ title: "Task",
1948
+ description: "Describe the work and choose where the agent should run.",
1949
+ Icon: IconEditOutline16,
1950
+ },
1606
1951
  h(
1607
- Field,
1608
- { label: "Enabled", htmlFor: fieldId("enabled") },
1952
+ "div",
1953
+ { className: "dsh-auto-formgrid" },
1609
1954
  h(
1610
- "label",
1611
- { className: "dsh-auto-check" },
1955
+ Field,
1956
+ { label: "Name", htmlFor: fieldId("name"), required: true, full: true },
1612
1957
  h("input", {
1613
- id: fieldId("enabled"),
1614
- type: "checkbox",
1615
- checked: draft.enabled,
1616
- onChange: (event) => onChange("enabled", event.target.checked),
1958
+ id: fieldId("name"),
1959
+ className: "dsh-auto-input",
1960
+ type: "text",
1961
+ value: draft.name,
1962
+ autoFocus: true,
1963
+ placeholder: "e.g. Morning standup notes",
1964
+ onChange: (event) => onChange("name", event.target.value),
1617
1965
  }),
1618
- h("span", null, "Schedule is active"),
1619
1966
  ),
1620
- ),
1621
- h(
1622
- Field,
1623
- { label: "Timeout (ms)", htmlFor: fieldId("timeout"), required: true, hint: "Wall-clock limit for each run (1s to 24h)." },
1624
- h("input", {
1625
- id: fieldId("timeout"),
1626
- className: "dsh-auto-input",
1627
- type: "number",
1628
- min: MIN_TIMEOUT_MS,
1629
- max: MAX_TIMEOUT_MS,
1630
- step: 1000,
1631
- value: draft.timeoutMs,
1632
- onChange: (event) => onChange("timeoutMs", event.target.value),
1633
- }),
1634
- ),
1635
- h(
1636
- Field,
1637
- { label: "Provider", htmlFor: fieldId("provider"), hint: "Blank uses the current Harness default. You may type an unlisted provider route." },
1638
- h(ProviderPicker, {
1639
- id: fieldId("provider"),
1640
- value: draft.provider,
1641
- providers,
1642
- defaultModel: meta?.defaultModel,
1643
- onChange: (value) => onChange("provider", value),
1644
- }),
1645
- ),
1646
- h(
1647
- Field,
1648
- {
1649
- label: "Model",
1650
- htmlFor: fieldId("model"),
1651
- required: draft.provider !== "",
1652
- hint: draft.provider === ""
1653
- ? "Set a provider first, or leave both blank for the Harness default."
1654
- : "Choose a discovered model or type an adapter-supported model id.",
1655
- },
1656
- h("input", {
1657
- id: fieldId("model"),
1658
- className: "dsh-auto-input",
1659
- type: "text",
1660
- list: fieldId("model-list"),
1661
- value: draft.model,
1662
- spellCheck: false,
1663
- placeholder: draft.provider === "" ? "Harness default" : "Model id",
1664
- disabled: draft.provider === "",
1665
- onChange: (event) => onChange("model", event.target.value),
1666
- }),
1667
1967
  h(
1668
- "datalist",
1669
- { id: fieldId("model-list") },
1670
- models.map((model) => h("option", {
1671
- key: model.id,
1672
- value: model.id,
1673
- label: model.name === model.id ? undefined : model.name,
1674
- })),
1968
+ Field,
1969
+ {
1970
+ label: "Prompt",
1971
+ htmlFor: fieldId("prompt"),
1972
+ required: true,
1973
+ full: true,
1974
+ hint: "Instructions sent to a fresh agent. Prompts are not copied into run-history summaries.",
1975
+ },
1976
+ h("textarea", {
1977
+ id: fieldId("prompt"),
1978
+ className: "dsh-auto-textarea dsh-auto-prompt",
1979
+ value: draft.prompt,
1980
+ placeholder: "Summarize yesterday's progress and list today's priorities\u2026",
1981
+ onChange: (event) => onChange("prompt", event.target.value),
1982
+ }),
1983
+ ),
1984
+ h(
1985
+ Field,
1986
+ {
1987
+ label: "Workspace",
1988
+ htmlFor: fieldId("cwd"),
1989
+ required: true,
1990
+ full: true,
1991
+ hint: "Choose a Harness workspace or enter the absolute directory where the agent runs.",
1992
+ },
1993
+ h(WorkspacePicker, {
1994
+ id: fieldId("cwd"),
1995
+ value: draft.cwd,
1996
+ workspaceSnapshot,
1997
+ onChange: (value) => onChange("cwd", value),
1998
+ }),
1675
1999
  ),
1676
2000
  ),
2001
+ ),
2002
+ h(
2003
+ FormSection,
2004
+ {
2005
+ title: "Schedule",
2006
+ description: "Choose a common pattern, or switch to Custom for a five-field cron expression.",
2007
+ Icon: IconGlobeOutline14,
2008
+ },
1677
2009
  h(
1678
- Field,
1679
- { label: "Reasoning effort", htmlFor: fieldId("effort"), hint: "Default follows the selected model. Custom adapter-owned IDs remain supported." },
1680
- h(ReasoningEffortPicker, {
1681
- id: fieldId("effort"),
1682
- value: draft.reasoningEffort,
1683
- provider: draft.provider,
1684
- model: draft.model,
1685
- defaultModel: meta?.defaultModel,
1686
- onChange: (value) => onChange("reasoningEffort", value),
1687
- }),
1688
- ),
1689
- h(
1690
- Field,
1691
- { label: "Agent preset", htmlFor: fieldId("preset"), hint: "Uses the current Harness default when not explicitly selected." },
1692
- h(AgentPresetPicker, {
1693
- id: fieldId("preset"),
1694
- value: draft.agentPreset,
1695
- presets: agentPresets,
1696
- onChange: (value) => onChange("agentPreset", value),
1697
- }),
1698
- ),
1699
- h(
1700
- Field,
1701
- {
1702
- label: "Permission preset",
1703
- htmlFor: fieldId("permission"),
1704
- required: true,
1705
- hint: permissionPresetPresentation(draft.permissionPreset).detail,
1706
- },
1707
- h(PermissionPresetPicker, {
1708
- id: fieldId("permission"),
1709
- value: draft.permissionPreset,
1710
- presets: permissionPresets,
1711
- onChange: (value) => onChange("permissionPreset", value),
2010
+ "div",
2011
+ { className: "dsh-auto-formgrid dsh-auto-schedule-grid" },
2012
+ h(ScheduleEditor, {
2013
+ fieldId,
2014
+ cron: draft.cron,
2015
+ timezone: draft.timezone,
2016
+ onCronChange: (value) => onChange("cron", value),
2017
+ onTimezoneChange: (value) => onChange("timezone", value),
1712
2018
  }),
1713
2019
  ),
2020
+ ),
2021
+ h(
2022
+ FormSection,
2023
+ {
2024
+ title: "Agent & access",
2025
+ description: "Select the model, agent behavior, and filesystem permissions for each fresh run.",
2026
+ Icon: IconAgentPresetOutline16,
2027
+ },
1714
2028
  h(
1715
- Field,
1716
- { label: "Overlap policy", htmlFor: fieldId("overlap"), full: true, hint: "What happens when a scheduled run fires while another run for the same job is still active." },
2029
+ "div",
2030
+ { className: "dsh-auto-formgrid" },
2031
+ h(
2032
+ Field,
2033
+ { label: "Provider", htmlFor: fieldId("provider"), hint: "Blank uses the current Harness default. Custom provider routes remain supported." },
2034
+ h(ProviderPicker, {
2035
+ id: fieldId("provider"),
2036
+ value: draft.provider,
2037
+ providers,
2038
+ defaultModel: meta?.defaultModel,
2039
+ onChange: (value) => onChange("provider", value),
2040
+ }),
2041
+ ),
1717
2042
  h(
1718
- "select",
2043
+ Field,
1719
2044
  {
1720
- id: fieldId("overlap"),
1721
- className: "dsh-auto-select",
1722
- value: draft.overlap,
1723
- onChange: (event) => onChange("overlap", event.target.value),
2045
+ label: "Model",
2046
+ htmlFor: fieldId("model"),
2047
+ required: draft.provider !== "",
2048
+ hint: draft.provider === ""
2049
+ ? "Set a provider first, or leave both blank for the Harness default."
2050
+ : "Choose a discovered model or type an adapter-supported model id.",
1724
2051
  },
1725
- OVERLAP_OPTIONS.map((option) => h("option", { key: option.value, value: option.value }, option.label)),
2052
+ h("input", {
2053
+ id: fieldId("model"),
2054
+ className: "dsh-auto-input",
2055
+ type: "text",
2056
+ list: fieldId("model-list"),
2057
+ value: draft.model,
2058
+ spellCheck: false,
2059
+ placeholder: draft.provider === "" ? "Harness default" : "Model id",
2060
+ disabled: draft.provider === "",
2061
+ onChange: (event) => onChange("model", event.target.value),
2062
+ }),
2063
+ h(
2064
+ "datalist",
2065
+ { id: fieldId("model-list") },
2066
+ models.map((model) => h("option", {
2067
+ key: model.id,
2068
+ value: model.id,
2069
+ label: model.name === model.id ? undefined : model.name,
2070
+ })),
2071
+ ),
2072
+ ),
2073
+ h(
2074
+ Field,
2075
+ { label: "Reasoning effort", htmlFor: fieldId("effort"), hint: "Default follows the selected model. Custom adapter-owned IDs remain supported." },
2076
+ h(ReasoningEffortPicker, {
2077
+ id: fieldId("effort"),
2078
+ value: draft.reasoningEffort,
2079
+ provider: draft.provider,
2080
+ model: draft.model,
2081
+ defaultModel: meta?.defaultModel,
2082
+ onChange: (value) => onChange("reasoningEffort", value),
2083
+ }),
2084
+ ),
2085
+ h(
2086
+ Field,
2087
+ { label: "Agent preset", htmlFor: fieldId("preset"), hint: "Uses the current Harness default when not explicitly selected." },
2088
+ h(AgentPresetPicker, {
2089
+ id: fieldId("preset"),
2090
+ value: draft.agentPreset,
2091
+ presets: agentPresets,
2092
+ onChange: (value) => onChange("agentPreset", value),
2093
+ }),
1726
2094
  ),
1727
- ),
1728
- h(
1729
- Field,
1730
- { label: "Misfire policy", htmlFor: fieldId("misfire"), hint: "How a missed occurrence is handled after the scheduler is back." },
1731
2095
  h(
1732
- "select",
2096
+ Field,
1733
2097
  {
1734
- id: fieldId("misfire"),
1735
- className: "dsh-auto-select",
1736
- value: draft.misfire,
1737
- onChange: (event) => onChange("misfire", event.target.value),
2098
+ label: "Permission preset",
2099
+ htmlFor: fieldId("permission"),
2100
+ required: true,
2101
+ full: true,
2102
+ hint: permissionPresetPresentation(draft.permissionPreset).detail,
1738
2103
  },
1739
- MISFIRE_OPTIONS.map((option) => h("option", { key: option.value, value: option.value }, option.label)),
2104
+ h(PermissionPresetPicker, {
2105
+ id: fieldId("permission"),
2106
+ value: draft.permissionPreset,
2107
+ presets: permissionPresets,
2108
+ onChange: (value) => onChange("permissionPreset", value),
2109
+ }),
1740
2110
  ),
1741
2111
  ),
2112
+ ),
2113
+ h(
2114
+ "section",
2115
+ { className: "dsh-auto-advanced", "data-open": advancedOpen ? "true" : undefined },
1742
2116
  h(
1743
- Field,
2117
+ "button",
1744
2118
  {
1745
- label: "Workspace",
1746
- htmlFor: fieldId("cwd"),
1747
- required: true,
1748
- full: true,
1749
- hint: "Choose a Harness workspace or enter the absolute directory where the agent runs.",
2119
+ type: "button",
2120
+ className: "dsh-auto-advanced-trigger",
2121
+ "aria-expanded": advancedOpen,
2122
+ "aria-controls": fieldId("advanced-content"),
2123
+ onClick: () => setAdvancedOpen((open) => !open),
1750
2124
  },
1751
- h(WorkspacePicker, {
1752
- id: fieldId("cwd"),
1753
- value: draft.cwd,
1754
- workspaceSnapshot,
1755
- onChange: (value) => onChange("cwd", value),
1756
- }),
1757
- ),
1758
- h(
1759
- Field,
1760
- { label: "Prompt", htmlFor: fieldId("prompt"), required: true, full: true, hint: "Instructions for the agent run. Prompts are never shown in run history." },
1761
- h("textarea", {
1762
- id: fieldId("prompt"),
1763
- className: "dsh-auto-textarea",
1764
- value: draft.prompt,
1765
- placeholder: "Summarize yesterday's progress and list today's priorities\u2026",
1766
- onChange: (event) => onChange("prompt", event.target.value),
1767
- }),
2125
+ h("span", { className: "dsh-auto-form-section-icon", "aria-hidden": "true" }, h(IconSettingsOutline16)),
2126
+ h(
2127
+ "span",
2128
+ { className: "dsh-auto-advanced-copy" },
2129
+ h("span", { className: "dsh-auto-form-section-title" }, "Advanced"),
2130
+ h("span", { className: "dsh-auto-advanced-summary" }, advancedSummary),
2131
+ ),
2132
+ h(
2133
+ "span",
2134
+ { className: "dsh-auto-advanced-chevron" + (advancedOpen ? " dsh-auto-advanced-chevron-open" : ""), "aria-hidden": "true" },
2135
+ h(IconChevronDownOutline14),
2136
+ ),
1768
2137
  ),
1769
- ),
1770
- error
1771
- ? h("p", { className: "dsh-auto-formerror", role: "alert" }, error)
1772
- : null,
1773
- h(
1774
- "div",
1775
- { className: "dsh-auto-formactions" },
1776
2138
  h(
1777
- Button,
2139
+ "div",
1778
2140
  {
1779
- type: "submit",
1780
- variant: "primary",
1781
- icon: saving ? null : h(IconCheckOutline16),
1782
- disabled: saving,
2141
+ id: fieldId("advanced-content"),
2142
+ className: "dsh-auto-formgrid dsh-auto-advanced-content",
2143
+ hidden: !advancedOpen,
1783
2144
  },
1784
- saving ? "Saving\u2026" : creating ? "Create automation" : "Save changes",
2145
+ creating
2146
+ ? h(
2147
+ Field,
2148
+ { label: "Job ID", htmlFor: fieldId("id"), hint: "Auto-derived from the name. Use lowercase letters, digits, and hyphens." },
2149
+ h("input", {
2150
+ id: fieldId("id"),
2151
+ className: "dsh-auto-input",
2152
+ type: "text",
2153
+ value: draft.id,
2154
+ spellCheck: false,
2155
+ placeholder: "auto",
2156
+ onChange: (event) => {
2157
+ onIdTouched();
2158
+ onChange("id", event.target.value);
2159
+ },
2160
+ }),
2161
+ )
2162
+ : h(
2163
+ Field,
2164
+ { label: "Job ID", htmlFor: fieldId("id"), hint: "Fixed after creation." },
2165
+ h("input", {
2166
+ id: fieldId("id"),
2167
+ className: "dsh-auto-input",
2168
+ type: "text",
2169
+ value: draft.id,
2170
+ disabled: true,
2171
+ spellCheck: false,
2172
+ }),
2173
+ ),
2174
+ h(
2175
+ Field,
2176
+ { label: "Timeout (ms)", htmlFor: fieldId("timeout"), required: true, hint: "Wall-clock limit for each run (1s to 24h)." },
2177
+ h("input", {
2178
+ id: fieldId("timeout"),
2179
+ className: "dsh-auto-input",
2180
+ type: "number",
2181
+ min: MIN_TIMEOUT_MS,
2182
+ max: MAX_TIMEOUT_MS,
2183
+ step: 1000,
2184
+ value: draft.timeoutMs,
2185
+ onChange: (event) => onChange("timeoutMs", event.target.value),
2186
+ }),
2187
+ ),
2188
+ h(
2189
+ Field,
2190
+ { label: "Overlap policy", htmlFor: fieldId("overlap"), hint: OVERLAP_OPTIONS.find((option) => option.value === draft.overlap)?.hint },
2191
+ h(
2192
+ "select",
2193
+ {
2194
+ id: fieldId("overlap"),
2195
+ className: "dsh-auto-select",
2196
+ value: draft.overlap,
2197
+ onChange: (event) => onChange("overlap", event.target.value),
2198
+ },
2199
+ OVERLAP_OPTIONS.map((option) => h("option", { key: option.value, value: option.value }, option.label)),
2200
+ ),
2201
+ ),
2202
+ h(
2203
+ Field,
2204
+ { label: "Misfire policy", htmlFor: fieldId("misfire"), hint: MISFIRE_OPTIONS.find((option) => option.value === draft.misfire)?.hint },
2205
+ h(
2206
+ "select",
2207
+ {
2208
+ id: fieldId("misfire"),
2209
+ className: "dsh-auto-select",
2210
+ value: draft.misfire,
2211
+ onChange: (event) => onChange("misfire", event.target.value),
2212
+ },
2213
+ MISFIRE_OPTIONS.map((option) => h("option", { key: option.value, value: option.value }, option.label)),
2214
+ ),
2215
+ ),
1785
2216
  ),
2217
+ ),
2218
+ h(
2219
+ "footer",
2220
+ { className: "dsh-auto-form-footer" },
2221
+ error
2222
+ ? h("p", { id: errorId, className: "dsh-auto-formerror", role: "alert" }, error)
2223
+ : null,
1786
2224
  h(
1787
- Button,
1788
- { type: "button", variant: "outline", onClick: onCancel, disabled: saving },
1789
- "Cancel",
2225
+ "div",
2226
+ { className: "dsh-auto-formactions" },
2227
+ h(
2228
+ Button,
2229
+ {
2230
+ type: "submit",
2231
+ variant: "primary",
2232
+ icon: saving ? null : h(IconCheckOutline16),
2233
+ disabled: saving,
2234
+ },
2235
+ saving ? "Saving\u2026" : creating ? "Create automation" : "Save changes",
2236
+ ),
2237
+ h(
2238
+ Button,
2239
+ { type: "button", variant: "outline", onClick: onCancel, disabled: saving },
2240
+ "Cancel",
2241
+ ),
1790
2242
  ),
1791
2243
  ),
1792
2244
  );
@@ -2070,6 +2522,7 @@ window.__ModuleLoader__.load({
2070
2522
  const [editing, setEditing] = useState(null);
2071
2523
  const [draft, setDraft] = useState(() => emptyDraft(null));
2072
2524
  const [formError, setFormError] = useState(null);
2525
+ const [formErrorField, setFormErrorField] = useState(null);
2073
2526
  const [confirmDeleteId, setConfirmDeleteId] = useState(null);
2074
2527
  const [confirmCancelId, setConfirmCancelId] = useState(null);
2075
2528
 
@@ -2226,6 +2679,7 @@ window.__ModuleLoader__.load({
2226
2679
  workspaceTouchedRef.current = false;
2227
2680
  setDraft(Object.assign(emptyDraft(meta), { cwd: preferredWorkspacePath(workspaceSnapshot) }));
2228
2681
  setFormError(null);
2682
+ setFormErrorField(null);
2229
2683
  setFormOpen(true);
2230
2684
  }, [meta, workspaceSnapshot]);
2231
2685
 
@@ -2235,6 +2689,7 @@ window.__ModuleLoader__.load({
2235
2689
  workspaceTouchedRef.current = true;
2236
2690
  setDraft(draftFromJob(job));
2237
2691
  setFormError(null);
2692
+ setFormErrorField(null);
2238
2693
  setFormOpen(true);
2239
2694
  }, []);
2240
2695
 
@@ -2242,6 +2697,7 @@ window.__ModuleLoader__.load({
2242
2697
  setFormOpen(false);
2243
2698
  setEditing(null);
2244
2699
  setFormError(null);
2700
+ setFormErrorField(null);
2245
2701
  }, []);
2246
2702
 
2247
2703
  useEffect(() => {
@@ -2254,6 +2710,8 @@ window.__ModuleLoader__.load({
2254
2710
  const handleDraftChange = useCallback(
2255
2711
  (key, value) => {
2256
2712
  if (key === "cwd") workspaceTouchedRef.current = true;
2713
+ setFormError(null);
2714
+ setFormErrorField(null);
2257
2715
  setDraft((previous) => {
2258
2716
  let next = Object.assign({}, previous, { [key]: value });
2259
2717
  if (key === "name" && editing === null && !idTouchedRef.current) {
@@ -2278,43 +2736,52 @@ window.__ModuleLoader__.load({
2278
2736
  async (event) => {
2279
2737
  event.preventDefault();
2280
2738
  const creating = editing === null;
2739
+ const fail = (field, message) => {
2740
+ setFormErrorField(field);
2741
+ setFormError(message);
2742
+ };
2281
2743
 
2282
2744
  const name = draft.name.trim();
2283
- if (name === "") return setFormError("Give the automation a name.");
2745
+ if (name === "") return fail("name", "Give the automation a name.");
2284
2746
  const cron = draft.cron.trim();
2285
- if (cron === "") return setFormError("A cron expression is required, e.g. 0 9 * * 1-5.");
2747
+ if (cron === "") return fail("cron", "A cron expression is required, e.g. 0 9 * * 1-5.");
2748
+ if (cronFields(cron).length !== 5) {
2749
+ return fail("cron", "Cron needs exactly five fields: minute, hour, day, month, and weekday.");
2750
+ }
2286
2751
  const timezone = draft.timezone.trim();
2287
- if (timezone === "") return setFormError("A timezone is required.");
2752
+ if (timezone === "") return fail("timezone", "A timezone is required.");
2288
2753
  const cwd = draft.cwd.trim();
2289
- if (cwd === "") return setFormError("A workspace is required.");
2754
+ if (cwd === "") return fail("cwd", "A workspace is required.");
2290
2755
  if (!looksLikeAbsolutePath(cwd)) {
2291
- return setFormError("The workspace must be an absolute filesystem path.");
2756
+ return fail("cwd", "The workspace must be an absolute filesystem path.");
2292
2757
  }
2293
2758
  const prompt = draft.prompt.trim();
2294
- if (prompt === "") return setFormError("A prompt is required.");
2759
+ if (prompt === "") return fail("prompt", "A prompt is required.");
2295
2760
  const timeoutMs = Number(draft.timeoutMs);
2296
2761
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) {
2297
- return setFormError(
2762
+ return fail(
2763
+ "timeout",
2298
2764
  "Timeout must be a whole number of milliseconds between " + MIN_TIMEOUT_MS + " and " + MAX_TIMEOUT_MS + ".",
2299
2765
  );
2300
2766
  }
2301
2767
  const provider = draft.provider.trim();
2302
2768
  const model = draft.model.trim();
2303
2769
  if (provider !== "" && model === "") {
2304
- return setFormError("Pick a model for the chosen provider (or leave both blank for the Harness default).");
2770
+ return fail("model", "Pick a model for the chosen provider (or leave both blank for the Harness default).");
2305
2771
  }
2306
2772
  if (provider === "" && model !== "") {
2307
- return setFormError("A model needs a provider (or leave both blank for the Harness default).");
2773
+ return fail("provider", "A model needs a provider (or leave both blank for the Harness default).");
2308
2774
  }
2309
2775
  let id = "";
2310
2776
  if (creating) {
2311
2777
  id = draft.id.trim().toLowerCase();
2312
2778
  if (id !== "" && !JOB_ID_PATTERN.test(id)) {
2313
- return setFormError("Job id must match [a-z0-9][a-z0-9-]{0,62} (lowercase letters, digits, hyphens).");
2779
+ return fail("id", "Job id must match [a-z0-9][a-z0-9-]{0,62} (lowercase letters, digits, hyphens).");
2314
2780
  }
2315
2781
  }
2316
2782
  const spec = buildSpec(draft);
2317
2783
  setFormError(null);
2784
+ setFormErrorField(null);
2318
2785
  setBusyKey("save", true);
2319
2786
  try {
2320
2787
  if (creating) {
@@ -2332,7 +2799,9 @@ window.__ModuleLoader__.load({
2332
2799
  flashMessage("success", creating ? "Automation created." : "Automation updated.");
2333
2800
  closeForm();
2334
2801
  } catch (error) {
2335
- setFormError(errMessage(error));
2802
+ const message = errMessage(error);
2803
+ setFormErrorField(inferFormErrorField(message));
2804
+ setFormError(message);
2336
2805
  } finally {
2337
2806
  setBusyKey("save", false);
2338
2807
  }
@@ -2389,12 +2858,14 @@ window.__ModuleLoader__.load({
2389
2858
  : null,
2390
2859
  formOpen
2391
2860
  ? h(JobForm, {
2861
+ key: editing === null ? "create" : "edit:" + editing.id,
2392
2862
  meta,
2393
2863
  workspaceSnapshot,
2394
2864
  draft,
2395
2865
  editing,
2396
2866
  saving: busy.save === true,
2397
2867
  error: formError,
2868
+ errorField: formErrorField,
2398
2869
  onChange: handleDraftChange,
2399
2870
  onIdTouched: handleIdTouched,
2400
2871
  onSubmit: handleSubmit,
@@ -2648,6 +3119,7 @@ window.__ModuleLoader__.load({
2648
3119
  ".dsh-auto-input::placeholder,.dsh-auto-textarea::placeholder{color:var(--dsh-auto-caption);}",
2649
3120
  ".dsh-auto-select option{background:var(--dsh-auto-input-bg);color:var(--dsh-auto-text);}",
2650
3121
  ".dsh-auto-input:focus-visible,.dsh-auto-select:focus-visible,.dsh-auto-textarea:focus-visible{outline:none;border-color:var(--dsh-auto-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsh-auto-accent) 18%,transparent);}",
3122
+ ".dsh-auto-input[aria-invalid=\"true\"],.dsh-auto-select[aria-invalid=\"true\"],.dsh-auto-textarea[aria-invalid=\"true\"]{border-color:var(--dsh-auto-danger);}",
2651
3123
  ".dsh-auto-input:disabled,.dsh-auto-select:disabled,.dsh-auto-textarea:disabled{opacity:1;color:var(--dsh-auto-muted);background:var(--dsh-auto-surface-active);cursor:default;}",
2652
3124
  ".dsh-auto-picker-root{width:100%;display:flex;}",
2653
3125
  ".dsh-auto-picker-trigger{appearance:none;width:100%;height:34px;padding:0 9px;border:1px solid var(--dsh-auto-border);border-radius:8px;background:var(--dsh-auto-input-bg);color:var(--dsh-auto-text);display:flex;align-items:center;gap:8px;font-family:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer;}",
@@ -2693,16 +3165,50 @@ window.__ModuleLoader__.load({
2693
3165
  ".dsh-auto-permission-glyph-danger{color:var(--dsh-auto-danger);}",
2694
3166
  ".dsh-auto-permission-glyph-custom{color:var(--dsh-auto-muted);}",
2695
3167
  ".dsh-auto-textarea{resize:vertical;min-height:110px;}",
3168
+ ".dsh-auto-prompt{min-height:142px;line-height:1.55;}",
2696
3169
  ".dsh-auto-check{display:inline-flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;min-height:34px;}",
2697
3170
  ".dsh-auto-check input{width:16px;height:16px;margin:0;accent-color:var(--dsh-auto-accent);cursor:pointer;}",
2698
- ".dsh-auto-form{border:1px solid var(--dsh-auto-border);border-radius:14px;padding:16px;background:var(--dsh-auto-surface);display:flex;flex-direction:column;gap:14px;}",
2699
- ".dsh-auto-form h3{margin:0;font-size:15px;font-weight:600;}",
2700
- ".dsh-auto-formgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}",
2701
- ".dsh-auto-field{display:flex;flex-direction:column;gap:4px;min-width:0;}",
3171
+ ".dsh-auto-form{box-sizing:border-box;width:min(100%,760px);margin:0 auto;padding:0 0 4px;background:transparent;display:flex;flex-direction:column;gap:16px;}",
3172
+ ".dsh-auto-form-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:2px 2px 0;}",
3173
+ ".dsh-auto-form-heading{min-width:0;display:flex;flex-direction:column;gap:3px;}",
3174
+ ".dsh-auto-form h3{margin:0;font-size:17px;font-weight:600;line-height:24px;}",
3175
+ ".dsh-auto-form-heading p{margin:0;color:var(--dsh-auto-muted);font-size:12px;line-height:18px;}",
3176
+ ".dsh-auto-form-enabled{flex:none;margin-top:2px;padding:5px 8px;border:1px solid var(--dsh-auto-border);border-radius:999px;background:var(--dsh-auto-surface);}",
3177
+ ".dsh-auto-form-section,.dsh-auto-advanced{border:1px solid var(--dsh-auto-border);border-radius:14px;background:var(--dsh-auto-surface);box-shadow:0 1px 1px color-mix(in srgb,var(--dsh-auto-text) 4%,transparent);}",
3178
+ ".dsh-auto-form-section{padding:16px;display:flex;flex-direction:column;gap:15px;}",
3179
+ ".dsh-auto-form-section-head{display:flex;align-items:flex-start;gap:10px;}",
3180
+ ".dsh-auto-form-section-icon{width:30px;height:30px;flex:none;border-radius:9px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsh-auto-accent);background:color-mix(in srgb,var(--dsh-auto-accent) 10%,transparent);}",
3181
+ ".dsh-auto-form-section-copy{min-width:0;display:flex;flex-direction:column;gap:1px;}",
3182
+ ".dsh-auto-form-section-title{margin:0;color:var(--dsh-auto-text);font-size:14px;font-weight:600;line-height:20px;}",
3183
+ ".dsh-auto-form-section-description{margin:0;color:var(--dsh-auto-muted);font-size:11px;line-height:17px;}",
3184
+ ".dsh-auto-formgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 12px;}",
3185
+ ".dsh-auto-field{display:flex;flex-direction:column;gap:5px;min-width:0;}",
2702
3186
  ".dsh-auto-field-full{grid-column:1/-1;}",
2703
3187
  ".dsh-auto-label{font-size:12px;font-weight:500;color:var(--dsh-auto-text-secondary);}",
2704
3188
  ".dsh-auto-required{color:var(--dsh-auto-danger);}",
2705
3189
  ".dsh-auto-hint{margin:0;font-size:11px;line-height:1.45;color:var(--dsh-auto-muted);}",
3190
+ ".dsh-auto-schedule-modes{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}",
3191
+ ".dsh-auto-schedule-summary{min-height:42px;padding:9px 11px;border:1px solid color-mix(in srgb,var(--dsh-auto-accent) 20%,var(--dsh-auto-border));border-radius:10px;background:color-mix(in srgb,var(--dsh-auto-accent) 6%,var(--dsh-auto-input-bg));display:flex;align-items:center;justify-content:space-between;gap:10px;}",
3192
+ ".dsh-auto-schedule-summary-copy{min-width:0;color:var(--dsh-auto-text-secondary);font-size:12px;font-weight:500;line-height:18px;}",
3193
+ ".dsh-auto-schedule-expression{min-width:0;max-width:100%;flex:none;padding:2px 6px;border-radius:5px;background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06));color:var(--dsh-auto-muted);font-size:11px;line-height:17px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
3194
+ ".dsh-auto-cron-input{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;letter-spacing:.02em;}",
3195
+ ".dsh-auto-cron-guide{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px;margin-top:3px;}",
3196
+ ".dsh-auto-cron-guide-field{min-width:0;padding:7px 6px;border:1px solid var(--dsh-auto-border);border-radius:8px;background:var(--dsh-auto-input-bg);text-align:center;display:flex;flex-direction:column;align-items:center;gap:1px;}",
3197
+ ".dsh-auto-cron-guide-value{max-width:100%;overflow:hidden;text-overflow:ellipsis;color:var(--dsh-auto-text);font-size:12px;font-weight:600;line-height:17px;}",
3198
+ ".dsh-auto-cron-guide-label{color:var(--dsh-auto-text-secondary);font-size:10px;font-weight:500;line-height:14px;}",
3199
+ ".dsh-auto-cron-guide-range{color:var(--dsh-auto-caption);font-size:9px;line-height:12px;}",
3200
+ ".dsh-auto-cron-syntax{margin:0;color:var(--dsh-auto-muted);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10px;line-height:16px;}",
3201
+ ".dsh-auto-advanced{overflow:hidden;}",
3202
+ ".dsh-auto-advanced-trigger{box-sizing:border-box;width:100%;min-height:62px;padding:13px 15px;border:0;background:transparent;color:inherit;text-align:left;font:inherit;display:flex;align-items:center;gap:10px;cursor:pointer;}",
3203
+ ".dsh-auto-advanced-trigger:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06));}",
3204
+ ".dsh-auto-advanced-trigger:focus-visible{outline:2px solid var(--dsh-auto-accent);outline-offset:-2px;border-radius:13px;}",
3205
+ ".dsh-auto-advanced-copy{min-width:0;flex:1;display:flex;flex-direction:column;gap:1px;}",
3206
+ ".dsh-auto-advanced-summary{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsh-auto-muted);font-size:11px;line-height:17px;}",
3207
+ ".dsh-auto-advanced-chevron{flex:none;color:var(--dsh-auto-muted);transition:transform .15s ease;}",
3208
+ ".dsh-auto-advanced-chevron-open{transform:rotate(180deg);}",
3209
+ ".dsh-auto-advanced-content{margin:0 15px;padding:15px 0;border-top:1px solid var(--dsh-auto-border);}",
3210
+ ".dsh-auto-advanced-content[hidden]{display:none;}",
3211
+ ".dsh-auto-form-footer{position:sticky;z-index:4;bottom:8px;align-self:flex-end;max-width:100%;padding:9px 10px;border:1px solid var(--dsh-auto-border);border-radius:12px;background:var(--dsh-auto-surface);box-shadow:var(--dsw-shadow-lv2,0 6px 18px rgba(15,17,21,.12));display:flex;flex-direction:column;gap:8px;}",
2706
3212
  ".dsh-auto-formerror{margin:0;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--dsh-auto-danger) 10%,transparent);color:var(--dsh-auto-danger);font-size:12px;line-height:1.45;}",
2707
3213
  ".dsh-auto-formactions{display:flex;gap:8px;justify-content:flex-end;align-items:center;flex-wrap:wrap;}",
2708
3214
  ".dsh-auto-jobs{display:flex;flex-direction:column;gap:10px;}",
@@ -2752,8 +3258,8 @@ window.__ModuleLoader__.load({
2752
3258
  "@keyframes dsh-auto-pulse{0%,100%{opacity:1}50%{opacity:.35}}",
2753
3259
  ".dsh-auto-empty,.dsh-auto-loading,.dsh-auto-error{padding:22px 16px;text-align:center;border:1px dashed var(--dsh-auto-border);border-radius:12px;font-size:13px;color:var(--dsh-auto-text-secondary);}",
2754
3260
  ".dsh-auto-error{color:var(--dsh-auto-danger);display:flex;flex-direction:column;gap:10px;align-items:center;}",
2755
- "@container dsh-auto-workspace (max-width:680px){.dsh-auto-formgrid{grid-template-columns:1fr;}.dsh-auto-card-head{flex-direction:column;align-items:flex-start;}.dsh-auto-run-name{max-width:150px;}}",
2756
- "@media (max-width:640px){.dsh-auto-workspace-toolbar{padding-inline:8px;}.dsh-auto-workspace-scroll{padding:16px 14px 28px;}.dsh-auto-formgrid{grid-template-columns:1fr;}.dsh-auto-card-head{flex-direction:column;align-items:flex-start;}.dsh-auto-run-name{max-width:150px;}}",
3261
+ "@container dsh-auto-workspace (max-width:680px){.dsh-auto-formgrid{grid-template-columns:1fr;}.dsh-auto-form-header{align-items:center;}.dsh-auto-schedule-summary{align-items:flex-start;flex-direction:column;}.dsh-auto-card-head{flex-direction:column;align-items:flex-start;}.dsh-auto-run-name{max-width:150px;}}",
3262
+ "@media (max-width:640px){.dsh-auto-workspace-toolbar{padding-inline:8px;}.dsh-auto-workspace-scroll{padding:16px 14px 28px;}.dsh-auto-formgrid{grid-template-columns:1fr;}.dsh-auto-form-header{align-items:center;}.dsh-auto-form-section{padding:14px;}.dsh-auto-advanced-trigger{padding:12px 13px;}.dsh-auto-advanced-content{margin-inline:13px;}.dsh-auto-schedule-summary{align-items:flex-start;flex-direction:column;}.dsh-auto-cron-guide{gap:4px;}.dsh-auto-cron-guide-field{padding-inline:3px;}.dsh-auto-card-head{flex-direction:column;align-items:flex-start;}.dsh-auto-run-name{max-width:150px;}}",
2757
3263
  ].join("\n");
2758
3264
 
2759
3265
  function apply(ctx) {
@@ -2831,6 +3337,7 @@ window.__ModuleLoader__.load({
2831
3337
  exports.permissionPresetPresentation = permissionPresetPresentation;
2832
3338
  exports.PermissionPresetPicker = PermissionPresetPicker;
2833
3339
  exports.EditableCombobox = EditableCombobox;
3340
+ exports.ScheduleEditor = ScheduleEditor;
2834
3341
  exports.WorkspacePicker = WorkspacePicker;
2835
3342
  exports.ProviderPicker = ProviderPicker;
2836
3343
  exports.ReasoningEffortPicker = ReasoningEffortPicker;
@@ -2838,6 +3345,14 @@ window.__ModuleLoader__.load({
2838
3345
  exports.__testing = Object.freeze({
2839
3346
  nextFormInstancePrefix,
2840
3347
  canonicalTimezone,
3348
+ cronFields,
3349
+ simpleSchedule,
3350
+ scheduleControlValues,
3351
+ cronForSimpleSchedule,
3352
+ scheduleModeTransition,
3353
+ describeSimpleSchedule,
3354
+ inferFormErrorField,
3355
+ overlapPolicySummary,
2841
3356
  comboboxSearchResults,
2842
3357
  looksLikeAbsolutePath,
2843
3358
  normalizedWorkspaces,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncended/dsh-automations",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Durable cron automations for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",