@syncended/dsh-automations 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/cordis.patch.yml +5 -0
  4. package/dist/agent-executor.d.ts +15 -0
  5. package/dist/agent-executor.d.ts.map +1 -0
  6. package/dist/agent-executor.js +173 -0
  7. package/dist/agent-executor.js.map +1 -0
  8. package/dist/cron.d.ts +15 -0
  9. package/dist/cron.d.ts.map +1 -0
  10. package/dist/cron.js +74 -0
  11. package/dist/cron.js.map +1 -0
  12. package/dist/http.d.ts +8 -0
  13. package/dist/http.d.ts.map +1 -0
  14. package/dist/http.js +203 -0
  15. package/dist/http.js.map +1 -0
  16. package/dist/index.d.ts +68 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +201 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/project-policy.d.ts +9 -0
  21. package/dist/project-policy.d.ts.map +1 -0
  22. package/dist/project-policy.js +53 -0
  23. package/dist/project-policy.js.map +1 -0
  24. package/dist/scheduler.d.ts +58 -0
  25. package/dist/scheduler.d.ts.map +1 -0
  26. package/dist/scheduler.js +518 -0
  27. package/dist/scheduler.js.map +1 -0
  28. package/dist/state.d.ts +8 -0
  29. package/dist/state.d.ts.map +1 -0
  30. package/dist/state.js +44 -0
  31. package/dist/state.js.map +1 -0
  32. package/dist/store.d.ts +21 -0
  33. package/dist/store.d.ts.map +1 -0
  34. package/dist/store.js +317 -0
  35. package/dist/store.js.map +1 -0
  36. package/dist/types.d.ts +169 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +3 -0
  39. package/dist/types.js.map +1 -0
  40. package/dist/validation.d.ts +13 -0
  41. package/dist/validation.d.ts.map +1 -0
  42. package/dist/validation.js +159 -0
  43. package/dist/validation.js.map +1 -0
  44. package/docs/architecture.md +195 -0
  45. package/docs/http-api.md +40 -0
  46. package/docs/security.md +51 -0
  47. package/lib/client.js +1251 -0
  48. package/package.json +105 -0
package/lib/client.js ADDED
@@ -0,0 +1,1251 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@syncended/dsh-automations",
3
+ factory: (require) => {
4
+ const module = { exports: {} };
5
+ const exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+
8
+ // The full settings UI is registered by this browser entry. It is kept as
9
+ // plain Client Plugin JavaScript so external package installs need no DSH
10
+ // monorepo build tooling. The page talks to the automations service over
11
+ // the same-origin HTTP API at /api/automations; every mutation carries the
12
+ // x-dsh-automation-client fence header plus an application/json body.
13
+ const React = require("react");
14
+ const h = React.createElement;
15
+ const { useCallback, useEffect, useRef, useState } = React;
16
+ const inject = ["slots"];
17
+
18
+ const API_PREFIX = "/api/automations";
19
+ const POLL_MS = 5000;
20
+ const HISTORY_SHOWN = 25;
21
+ const DEFAULT_CRON = "0 9 * * 1-5";
22
+ const DEFAULT_TIMEOUT_MS = 3600000;
23
+ const MIN_TIMEOUT_MS = 1000;
24
+ const MAX_TIMEOUT_MS = 86400000;
25
+ const JOB_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
26
+
27
+ const BROWSER_TIMEZONE = (() => {
28
+ try {
29
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
30
+ } catch {
31
+ return "UTC";
32
+ }
33
+ })();
34
+
35
+ const TIMEZONES = Array.from(
36
+ new Set([
37
+ BROWSER_TIMEZONE,
38
+ "UTC",
39
+ "America/New_York",
40
+ "America/Chicago",
41
+ "America/Denver",
42
+ "America/Los_Angeles",
43
+ "Europe/London",
44
+ "Europe/Paris",
45
+ "Europe/Berlin",
46
+ "Asia/Shanghai",
47
+ "Asia/Tokyo",
48
+ "Asia/Singapore",
49
+ "Asia/Kolkata",
50
+ "Australia/Sydney",
51
+ ]),
52
+ );
53
+
54
+ const REASONING_EFFORTS = ["low", "medium", "high"];
55
+ const OVERLAP_OPTIONS = [
56
+ { value: "skip", label: "skip", hint: "Skip the run if a previous run is still active." },
57
+ { value: "queue", label: "queue", hint: "Defer the run until the previous run finishes." },
58
+ { value: "allow", label: "allow", hint: "Run concurrently with any active run." },
59
+ ];
60
+ const MISFIRE_OPTIONS = [
61
+ { value: "skip", label: "skip", hint: "Drop occurrences that were missed while the scheduler was down." },
62
+ { value: "run-once", label: "run-once", hint: "Run once after downtime for the latest missed occurrence." },
63
+ ];
64
+
65
+ function errMessage(error) {
66
+ return error instanceof Error ? error.message : String(error);
67
+ }
68
+
69
+ function browserTimezone() {
70
+ return BROWSER_TIMEZONE;
71
+ }
72
+
73
+ // Mirrors the server-side slugifyJobId (validation.ts) so a freshly typed
74
+ // name yields the same stable id the service would allocate itself.
75
+ function slugifyJobId(name) {
76
+ const slug = name
77
+ .normalize("NFKD")
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9]+/g, "-")
80
+ .replace(/^-+|-+$/g, "")
81
+ .slice(0, 48);
82
+ return slug === "" ? "automation" : slug;
83
+ }
84
+
85
+ function formatDate(value) {
86
+ if (!value) return "—";
87
+ const date = new Date(value);
88
+ if (Number.isNaN(date.getTime())) return value;
89
+ try {
90
+ return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
91
+ } catch {
92
+ return date.toLocaleString();
93
+ }
94
+ }
95
+
96
+ function formatDuration(run) {
97
+ const start = run.startedAt ? Date.parse(run.startedAt) : NaN;
98
+ if (Number.isNaN(start)) return "";
99
+ const end = run.finishedAt ? Date.parse(run.finishedAt) : Date.now();
100
+ const seconds = Math.max(0, Math.round((end - start) / 1000));
101
+ if (seconds < 60) return seconds + "s";
102
+ const minutes = Math.floor(seconds / 60);
103
+ const rest = seconds % 60;
104
+ if (minutes < 60) return rest > 0 ? minutes + "m " + rest + "s" : minutes + "m";
105
+ const hours = Math.floor(minutes / 60);
106
+ return minutes % 60 > 0 ? hours + "h " + (minutes % 60) + "m" : hours + "h";
107
+ }
108
+
109
+ function formatTimeout(ms) {
110
+ if (!Number.isFinite(ms)) return "";
111
+ if (ms % 3600000 === 0) return ms / 3600000 + "h";
112
+ if (ms % 60000 === 0) return ms / 60000 + "m";
113
+ return Math.round(ms / 1000) + "s";
114
+ }
115
+
116
+ async function apiFetch(path, options = {}) {
117
+ const headers = Object.assign(
118
+ { "content-type": "application/json", "x-dsh-automation-client": "1" },
119
+ options.headers,
120
+ );
121
+ let response;
122
+ try {
123
+ response = await fetch(API_PREFIX + path, Object.assign({}, options, { headers }));
124
+ } catch (error) {
125
+ throw new Error("Network error: " + errMessage(error));
126
+ }
127
+ const text = await response.text();
128
+ let body = null;
129
+ if (text) {
130
+ try {
131
+ body = JSON.parse(text);
132
+ } catch {
133
+ body = null;
134
+ }
135
+ }
136
+ if (!response.ok) {
137
+ const message =
138
+ body && body.error && typeof body.error.message === "string"
139
+ ? body.error.message
140
+ : "Request failed (" + response.status + ").";
141
+ const error = new Error(message);
142
+ error.status = response.status;
143
+ error.code = body && body.error ? body.error.code : undefined;
144
+ throw error;
145
+ }
146
+ return body;
147
+ }
148
+
149
+ function emptyDraft(meta) {
150
+ const presets = meta && Array.isArray(meta.permissionPresets) ? meta.permissionPresets : [];
151
+ const permissionPreset = presets.indexOf("workspace-write") !== -1
152
+ ? "workspace-write"
153
+ : presets.length > 0
154
+ ? presets[0]
155
+ : "workspace-write";
156
+ return {
157
+ id: "",
158
+ name: "",
159
+ enabled: true,
160
+ cron: DEFAULT_CRON,
161
+ timezone: browserTimezone(),
162
+ prompt: "",
163
+ cwd: "",
164
+ provider: "",
165
+ model: "",
166
+ reasoningEffort: "",
167
+ agentPreset: "",
168
+ permissionPreset,
169
+ timeoutMs: String(DEFAULT_TIMEOUT_MS),
170
+ overlap: "skip",
171
+ misfire: "run-once",
172
+ };
173
+ }
174
+
175
+ function draftFromJob(job) {
176
+ return {
177
+ id: job.id,
178
+ name: job.name,
179
+ enabled: job.enabled,
180
+ cron: job.schedule.cron,
181
+ timezone: job.schedule.timezone,
182
+ prompt: job.task.prompt,
183
+ cwd: job.execution.cwd,
184
+ provider: job.execution.provider || "",
185
+ model: job.execution.model || "",
186
+ reasoningEffort: job.execution.reasoningEffort || "",
187
+ agentPreset: job.execution.agentPreset || "",
188
+ permissionPreset: job.execution.permissionPreset,
189
+ timeoutMs: String(job.execution.timeoutMs),
190
+ overlap: job.policies.overlap,
191
+ misfire: job.policies.misfire,
192
+ };
193
+ }
194
+
195
+ function buildSpec(draft) {
196
+ const execution = {
197
+ cwd: draft.cwd.trim(),
198
+ permissionPreset: draft.permissionPreset,
199
+ timeoutMs: Number(draft.timeoutMs),
200
+ };
201
+ const provider = draft.provider.trim();
202
+ if (provider !== "") {
203
+ execution.provider = provider;
204
+ execution.model = draft.model.trim();
205
+ }
206
+ const reasoningEffort = draft.reasoningEffort.trim();
207
+ if (reasoningEffort !== "") execution.reasoningEffort = reasoningEffort;
208
+ const agentPreset = draft.agentPreset.trim();
209
+ if (agentPreset !== "") execution.agentPreset = agentPreset;
210
+ return {
211
+ name: draft.name.trim(),
212
+ enabled: draft.enabled,
213
+ schedule: { cron: draft.cron.trim(), timezone: draft.timezone.trim() },
214
+ task: { kind: "agent", prompt: draft.prompt },
215
+ execution,
216
+ policies: { overlap: draft.overlap, misfire: draft.misfire },
217
+ };
218
+ }
219
+
220
+ function Field(props) {
221
+ const { label, htmlFor, required, hint, full, children } = props;
222
+ return h(
223
+ "div",
224
+ { className: "dsh-auto-field" + (full ? " dsh-auto-field-full" : "") },
225
+ h(
226
+ "label",
227
+ { className: "dsh-auto-label", htmlFor },
228
+ label,
229
+ required ? h("span", { className: "dsh-auto-required", "aria-hidden": "true" }, " *") : null,
230
+ ),
231
+ children,
232
+ hint ? h("p", { className: "dsh-auto-hint" }, hint) : null,
233
+ );
234
+ }
235
+
236
+ function StatusPill(props) {
237
+ return h("span", { className: "dsh-auto-status dsh-auto-status-" + props.status }, props.status);
238
+ }
239
+
240
+ function JobForm(props) {
241
+ const { meta, draft, editing, saving, error, onChange, onIdTouched, onSubmit, onCancel } = props;
242
+ const providers = meta && Array.isArray(meta.providers) ? meta.providers : [];
243
+ const providerModels = providers.find((provider) => provider.id === draft.provider);
244
+ const models = providerModels ? providerModels.models : [];
245
+ const permissionPresets =
246
+ meta && Array.isArray(meta.permissionPresets) && meta.permissionPresets.length > 0
247
+ ? meta.permissionPresets
248
+ : ["workspace-write"];
249
+ const agentPresets = meta && Array.isArray(meta.agentPresets) ? meta.agentPresets : [];
250
+ const creating = editing === null;
251
+
252
+ return h(
253
+ "form",
254
+ {
255
+ className: "dsh-auto-form",
256
+ onSubmit,
257
+ noValidate: true,
258
+ "aria-label": creating ? "New automation" : "Edit automation",
259
+ },
260
+ h("h3", null, creating ? "New automation" : "Edit \u201c" + editing.name + "\u201d"),
261
+ h(
262
+ "div",
263
+ { className: "dsh-auto-formgrid" },
264
+ h(
265
+ Field,
266
+ { label: "Name", htmlFor: "dsh-auto-f-name", required: true },
267
+ h("input", {
268
+ id: "dsh-auto-f-name",
269
+ className: "dsh-auto-input",
270
+ type: "text",
271
+ value: draft.name,
272
+ autoFocus: true,
273
+ placeholder: "e.g. Morning standup notes",
274
+ onChange: (event) => onChange("name", event.target.value),
275
+ }),
276
+ ),
277
+ creating
278
+ ? h(
279
+ Field,
280
+ { label: "Job id", htmlFor: "dsh-auto-f-id", hint: "Lowercase letters, digits and hyphens; auto-derived from the name. Leave blank to let the server generate one." },
281
+ h("input", {
282
+ id: "dsh-auto-f-id",
283
+ className: "dsh-auto-input",
284
+ type: "text",
285
+ value: draft.id,
286
+ spellCheck: false,
287
+ placeholder: "auto",
288
+ onChange: (event) => {
289
+ onIdTouched();
290
+ onChange("id", event.target.value);
291
+ },
292
+ }),
293
+ )
294
+ : h(
295
+ Field,
296
+ { label: "Job id", htmlFor: "dsh-auto-f-id", hint: "Fixed after creation." },
297
+ h("input", {
298
+ id: "dsh-auto-f-id",
299
+ className: "dsh-auto-input",
300
+ type: "text",
301
+ value: draft.id,
302
+ disabled: true,
303
+ spellCheck: false,
304
+ }),
305
+ ),
306
+ h(
307
+ Field,
308
+ { label: "Cron", htmlFor: "dsh-auto-f-cron", required: true, hint: "Five-field cron (minute hour day-of-month month day-of-week)." },
309
+ h("input", {
310
+ id: "dsh-auto-f-cron",
311
+ className: "dsh-auto-input",
312
+ type: "text",
313
+ value: draft.cron,
314
+ spellCheck: false,
315
+ onChange: (event) => onChange("cron", event.target.value),
316
+ }),
317
+ ),
318
+ h(
319
+ Field,
320
+ { label: "Timezone", htmlFor: "dsh-auto-f-timezone", required: true },
321
+ h("input", {
322
+ id: "dsh-auto-f-timezone",
323
+ className: "dsh-auto-input",
324
+ type: "text",
325
+ list: "dsh-auto-tz-list",
326
+ value: draft.timezone,
327
+ spellCheck: false,
328
+ onChange: (event) => onChange("timezone", event.target.value),
329
+ }),
330
+ h(
331
+ "datalist",
332
+ { id: "dsh-auto-tz-list" },
333
+ TIMEZONES.map((zone) => h("option", { key: zone, value: zone })),
334
+ ),
335
+ ),
336
+ h(
337
+ Field,
338
+ { label: "Enabled", htmlFor: "dsh-auto-f-enabled" },
339
+ h(
340
+ "label",
341
+ { className: "dsh-auto-check" },
342
+ h("input", {
343
+ id: "dsh-auto-f-enabled",
344
+ type: "checkbox",
345
+ checked: draft.enabled,
346
+ onChange: (event) => onChange("enabled", event.target.checked),
347
+ }),
348
+ h("span", null, "Schedule is active"),
349
+ ),
350
+ ),
351
+ h(
352
+ Field,
353
+ { label: "Timeout (ms)", htmlFor: "dsh-auto-f-timeout", required: true, hint: "Wall-clock limit for each run (1s to 24h)." },
354
+ h("input", {
355
+ id: "dsh-auto-f-timeout",
356
+ className: "dsh-auto-input",
357
+ type: "number",
358
+ min: MIN_TIMEOUT_MS,
359
+ max: MAX_TIMEOUT_MS,
360
+ step: 1000,
361
+ value: draft.timeoutMs,
362
+ onChange: (event) => onChange("timeoutMs", event.target.value),
363
+ }),
364
+ ),
365
+ h(
366
+ Field,
367
+ { label: "Provider", htmlFor: "dsh-auto-f-provider", hint: "Blank uses the current Harness default. You may type an unlisted provider route." },
368
+ h("input", {
369
+ id: "dsh-auto-f-provider",
370
+ className: "dsh-auto-input",
371
+ type: "text",
372
+ list: "dsh-auto-provider-list",
373
+ value: draft.provider,
374
+ spellCheck: false,
375
+ placeholder: "Harness default",
376
+ onChange: (event) => onChange("provider", event.target.value),
377
+ }),
378
+ h(
379
+ "datalist",
380
+ { id: "dsh-auto-provider-list" },
381
+ providers.map((provider) => h("option", { key: provider.id, value: provider.id })),
382
+ ),
383
+ ),
384
+ h(
385
+ Field,
386
+ {
387
+ label: "Model",
388
+ htmlFor: "dsh-auto-f-model",
389
+ required: draft.provider !== "",
390
+ hint: draft.provider === ""
391
+ ? "Set a provider first, or leave both blank for the Harness default."
392
+ : "Choose a discovered model or type an adapter-supported model id.",
393
+ },
394
+ h("input", {
395
+ id: "dsh-auto-f-model",
396
+ className: "dsh-auto-input",
397
+ type: "text",
398
+ list: "dsh-auto-model-list",
399
+ value: draft.model,
400
+ spellCheck: false,
401
+ placeholder: draft.provider === "" ? "Harness default" : "Model id",
402
+ disabled: draft.provider === "",
403
+ onChange: (event) => onChange("model", event.target.value),
404
+ }),
405
+ h(
406
+ "datalist",
407
+ { id: "dsh-auto-model-list" },
408
+ models.map((model) => h("option", { key: model, value: model })),
409
+ ),
410
+ ),
411
+ h(
412
+ Field,
413
+ { label: "Reasoning effort", htmlFor: "dsh-auto-f-effort", hint: "Blank uses the provider default." },
414
+ h("input", {
415
+ id: "dsh-auto-f-effort",
416
+ className: "dsh-auto-input",
417
+ type: "text",
418
+ list: "dsh-auto-effort-list",
419
+ value: draft.reasoningEffort,
420
+ spellCheck: false,
421
+ placeholder: "e.g. medium",
422
+ onChange: (event) => onChange("reasoningEffort", event.target.value),
423
+ }),
424
+ h(
425
+ "datalist",
426
+ { id: "dsh-auto-effort-list" },
427
+ REASONING_EFFORTS.map((effort) => h("option", { key: effort, value: effort })),
428
+ ),
429
+ ),
430
+ h(
431
+ Field,
432
+ { label: "Agent preset", htmlFor: "dsh-auto-f-preset", hint: "Blank uses the current Harness agent-preset default." },
433
+ h(
434
+ "select",
435
+ {
436
+ id: "dsh-auto-f-preset",
437
+ className: "dsh-auto-select",
438
+ value: draft.agentPreset,
439
+ onChange: (event) => onChange("agentPreset", event.target.value),
440
+ },
441
+ h("option", { value: "" }, "Harness default"),
442
+ agentPresets.map((preset) =>
443
+ h(
444
+ "option",
445
+ { key: preset.id, value: preset.id },
446
+ preset.name + (preset.broken ? " (broken: " + preset.broken + ")" : ""),
447
+ ),
448
+ ),
449
+ ),
450
+ ),
451
+ h(
452
+ Field,
453
+ { label: "Permission preset", htmlFor: "dsh-auto-f-permission", required: true },
454
+ h(
455
+ "select",
456
+ {
457
+ id: "dsh-auto-f-permission",
458
+ className: "dsh-auto-select",
459
+ value: draft.permissionPreset,
460
+ onChange: (event) => onChange("permissionPreset", event.target.value),
461
+ },
462
+ permissionPresets.map((preset) => h("option", { key: preset, value: preset }, preset)),
463
+ ),
464
+ ),
465
+ h(
466
+ Field,
467
+ { label: "Overlap policy", htmlFor: "dsh-auto-f-overlap", full: true, hint: "What happens when a scheduled run fires while another run for the same job is still active." },
468
+ h(
469
+ "select",
470
+ {
471
+ id: "dsh-auto-f-overlap",
472
+ className: "dsh-auto-select",
473
+ value: draft.overlap,
474
+ onChange: (event) => onChange("overlap", event.target.value),
475
+ },
476
+ OVERLAP_OPTIONS.map((option) => h("option", { key: option.value, value: option.value }, option.label)),
477
+ ),
478
+ ),
479
+ h(
480
+ Field,
481
+ { label: "Misfire policy", htmlFor: "dsh-auto-f-misfire", hint: "How a missed occurrence is handled after the scheduler is back." },
482
+ h(
483
+ "select",
484
+ {
485
+ id: "dsh-auto-f-misfire",
486
+ className: "dsh-auto-select",
487
+ value: draft.misfire,
488
+ onChange: (event) => onChange("misfire", event.target.value),
489
+ },
490
+ MISFIRE_OPTIONS.map((option) => h("option", { key: option.value, value: option.value }, option.label)),
491
+ ),
492
+ ),
493
+ h(
494
+ Field,
495
+ { label: "Working directory", htmlFor: "dsh-auto-f-cwd", required: true, full: true, hint: "Absolute project directory the agent runs in." },
496
+ h("input", {
497
+ id: "dsh-auto-f-cwd",
498
+ className: "dsh-auto-input",
499
+ type: "text",
500
+ value: draft.cwd,
501
+ spellCheck: false,
502
+ placeholder: "/home/you/workspace/project",
503
+ onChange: (event) => onChange("cwd", event.target.value),
504
+ }),
505
+ ),
506
+ h(
507
+ Field,
508
+ { label: "Prompt", htmlFor: "dsh-auto-f-prompt", required: true, full: true, hint: "Instructions for the agent run. Prompts are never shown in run history." },
509
+ h("textarea", {
510
+ id: "dsh-auto-f-prompt",
511
+ className: "dsh-auto-textarea",
512
+ value: draft.prompt,
513
+ placeholder: "Summarize yesterday's progress and list today's priorities\u2026",
514
+ onChange: (event) => onChange("prompt", event.target.value),
515
+ }),
516
+ ),
517
+ ),
518
+ error
519
+ ? h("p", { className: "dsh-auto-formerror", role: "alert" }, error)
520
+ : null,
521
+ h(
522
+ "div",
523
+ { className: "dsh-auto-formactions" },
524
+ h(
525
+ "button",
526
+ { type: "submit", className: "dsh-auto-btn dsh-auto-btn-primary", disabled: saving },
527
+ saving ? "Saving\u2026" : creating ? "Create automation" : "Save changes",
528
+ ),
529
+ h(
530
+ "button",
531
+ { type: "button", className: "dsh-auto-btn", onClick: onCancel, disabled: saving },
532
+ "Cancel",
533
+ ),
534
+ ),
535
+ );
536
+ }
537
+
538
+ function JobCard(props) {
539
+ const {
540
+ job,
541
+ meta,
542
+ busy,
543
+ busyForJob,
544
+ onToggle,
545
+ onRun,
546
+ onEdit,
547
+ onDelete,
548
+ onDeleteConfirm,
549
+ onDeleteCancel,
550
+ confirmingDelete,
551
+ } = props;
552
+ const toggling = busy["toggle:" + job.id] === true;
553
+ const running = busy["run:" + job.id] === true;
554
+ const deleting = busy["delete:" + job.id] === true;
555
+ const modelLabel = job.execution.provider
556
+ ? job.execution.provider + "/" + job.execution.model
557
+ : "Default model";
558
+ const agentPreset = meta && Array.isArray(meta.agentPresets)
559
+ ? meta.agentPresets.find((preset) => preset.id === job.execution.agentPreset)
560
+ : undefined;
561
+ const presetLabel = job.execution.agentPreset
562
+ ? (agentPreset ? agentPreset.name : job.execution.agentPreset)
563
+ : "";
564
+
565
+ return h(
566
+ "article",
567
+ { className: "dsh-auto-card" + (job.enabled ? "" : " dsh-auto-card-disabled") },
568
+ h(
569
+ "div",
570
+ { className: "dsh-auto-card-head" },
571
+ h(
572
+ "div",
573
+ { className: "dsh-auto-card-title" },
574
+ h("h3", null, job.name),
575
+ h("span", { className: "dsh-auto-card-id", title: "Job id" }, job.id),
576
+ ),
577
+ h(
578
+ "label",
579
+ { className: "dsh-auto-switch" },
580
+ h("input", {
581
+ type: "checkbox",
582
+ role: "switch",
583
+ checked: job.enabled,
584
+ disabled: toggling || busyForJob,
585
+ "aria-label": (job.enabled ? "Disable" : "Enable") + " automation " + job.name,
586
+ onChange: onToggle,
587
+ }),
588
+ h("span", { className: "dsh-auto-switch-track" }),
589
+ h("span", { className: "dsh-auto-switch-text" }, job.enabled ? "Enabled" : "Disabled"),
590
+ ),
591
+ ),
592
+ h(
593
+ "div",
594
+ { className: "dsh-auto-card-meta" },
595
+ h("span", { title: "Schedule" }, job.schedule.cron + " \u00b7 " + job.schedule.timezone),
596
+ h(
597
+ "span",
598
+ { className: "dsh-auto-next" },
599
+ !job.enabled
600
+ ? "Paused"
601
+ : job.nextRunAt
602
+ ? "Next " + formatDate(job.nextRunAt)
603
+ : "No next run",
604
+ ),
605
+ h("span", { title: "Model" }, modelLabel),
606
+ presetLabel !== ""
607
+ ? h("span", { title: "Agent preset" }, presetLabel)
608
+ : null,
609
+ h("span", { title: "Permission preset" }, job.execution.permissionPreset),
610
+ h("span", { title: "Timeout" }, formatTimeout(job.execution.timeoutMs)),
611
+ h("span", { className: "dsh-auto-muted", title: "Version" }, "v" + job.version),
612
+ ),
613
+ h(
614
+ "div",
615
+ { className: "dsh-auto-card-actions" },
616
+ h(
617
+ "button",
618
+ { type: "button", className: "dsh-auto-btn", onClick: onEdit, disabled: busyForJob || running },
619
+ "Edit",
620
+ ),
621
+ h(
622
+ "button",
623
+ {
624
+ type: "button",
625
+ className: "dsh-auto-btn dsh-auto-btn-primary",
626
+ onClick: onRun,
627
+ disabled: busyForJob,
628
+ },
629
+ running ? "Starting\u2026" : "Run now",
630
+ ),
631
+ confirmingDelete
632
+ ? h(
633
+ "span",
634
+ { className: "dsh-auto-confirm" },
635
+ h(
636
+ "button",
637
+ {
638
+ type: "button",
639
+ className: "dsh-auto-btn dsh-auto-btn-danger",
640
+ onClick: onDeleteConfirm,
641
+ disabled: deleting,
642
+ },
643
+ deleting ? "Deleting\u2026" : "Confirm delete",
644
+ ),
645
+ h(
646
+ "button",
647
+ { type: "button", className: "dsh-auto-btn", onClick: onDeleteCancel, disabled: deleting },
648
+ "Keep",
649
+ ),
650
+ )
651
+ : h(
652
+ "button",
653
+ {
654
+ type: "button",
655
+ className: "dsh-auto-btn dsh-auto-btn-danger-ghost",
656
+ onClick: onDelete,
657
+ disabled: busyForJob,
658
+ },
659
+ "Delete",
660
+ ),
661
+ ),
662
+ );
663
+ }
664
+
665
+ function RunsList(props) {
666
+ const { runs, busy, confirmCancelId, onCancel, onCancelConfirm, onCancelReset } = props;
667
+ const visible = runs.slice(0, HISTORY_SHOWN);
668
+ const isActive = (run) => run.status === "queued" || run.status === "running";
669
+
670
+ return h(
671
+ "section",
672
+ { className: "dsh-auto-runs", "aria-label": "Recent runs" },
673
+ h("h3", null, "Recent runs" + (runs.length > 0 ? " (" + runs.length + ")" : "")),
674
+ runs.length === 0
675
+ ? h(
676
+ "p",
677
+ { className: "dsh-auto-empty" },
678
+ "No runs yet. Runs appear here once an automation is triggered manually or its schedule fires.",
679
+ )
680
+ : h(
681
+ "ul",
682
+ { className: "dsh-auto-runlist" },
683
+ visible.map((run) => {
684
+ const active = isActive(run);
685
+ const cancelling = busy["cancel:" + run.id] === true;
686
+ return h(
687
+ "li",
688
+ { key: run.id, className: "dsh-auto-run" + (active ? " dsh-auto-run-active" : "") },
689
+ h(StatusPill, { status: run.status }),
690
+ h("span", { className: "dsh-auto-run-name", title: run.jobName }, run.jobName),
691
+ h(
692
+ "span",
693
+ { className: "dsh-auto-run-meta" },
694
+ run.trigger + (run.scheduledFor ? " \u00b7 " + formatDate(run.scheduledFor) : ""),
695
+ ),
696
+ run.startedAt
697
+ ? h("span", { className: "dsh-auto-run-dur", title: "Elapsed" }, formatDuration(run))
698
+ : null,
699
+ run.error
700
+ ? h(
701
+ "span",
702
+ {
703
+ className: "dsh-auto-run-error",
704
+ title: (run.error.code ? run.error.code + ": " : "") + run.error.message,
705
+ },
706
+ run.error.message,
707
+ )
708
+ : null,
709
+ run.skipReason
710
+ ? h("span", { className: "dsh-auto-run-skip" }, "skipped: " + run.skipReason)
711
+ : null,
712
+ run.sessionId
713
+ ? h(
714
+ "span",
715
+ { className: "dsh-auto-run-session", title: run.sessionId },
716
+ "session " + run.sessionId.slice(0, 10) + "\u2026",
717
+ )
718
+ : null,
719
+ active
720
+ ? confirmCancelId === run.id
721
+ ? h(
722
+ "span",
723
+ { className: "dsh-auto-confirm" },
724
+ h(
725
+ "button",
726
+ {
727
+ type: "button",
728
+ className: "dsh-auto-btn dsh-auto-btn-danger",
729
+ onClick: () => onCancelConfirm(run),
730
+ disabled: cancelling,
731
+ },
732
+ cancelling ? "Cancelling\u2026" : "Confirm",
733
+ ),
734
+ h(
735
+ "button",
736
+ { type: "button", className: "dsh-auto-btn", onClick: onCancelReset, disabled: cancelling },
737
+ "Back",
738
+ ),
739
+ )
740
+ : h(
741
+ "button",
742
+ {
743
+ type: "button",
744
+ className: "dsh-auto-btn dsh-auto-btn-ghost",
745
+ onClick: () => onCancel(run),
746
+ },
747
+ "Cancel",
748
+ )
749
+ : null,
750
+ );
751
+ }),
752
+ runs.length > visible.length
753
+ ? h(
754
+ "li",
755
+ { className: "dsh-auto-run-more" },
756
+ runs.length - visible.length + " more run(s) not shown.",
757
+ )
758
+ : null,
759
+ ),
760
+ );
761
+ }
762
+
763
+ function AutomationsSection() {
764
+ const [meta, setMeta] = useState(null);
765
+ const [snapshot, setSnapshot] = useState(null);
766
+ const [loading, setLoading] = useState(true);
767
+ const [loadError, setLoadError] = useState(null);
768
+ const [metaWarning, setMetaWarning] = useState(null);
769
+ const [flash, setFlash] = useState(null);
770
+ const [busy, setBusy] = useState({});
771
+ const [formOpen, setFormOpen] = useState(false);
772
+ const [editing, setEditing] = useState(null);
773
+ const [draft, setDraft] = useState(() => emptyDraft(null));
774
+ const [formError, setFormError] = useState(null);
775
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null);
776
+ const [confirmCancelId, setConfirmCancelId] = useState(null);
777
+
778
+ const aliveRef = useRef(true);
779
+ const pollRef = useRef(false);
780
+ const hasDataRef = useRef(false);
781
+ const idTouchedRef = useRef(false);
782
+ const flashTimerRef = useRef(null);
783
+
784
+ const loadMeta = useCallback(async () => {
785
+ try {
786
+ const data = await apiFetch("/meta");
787
+ if (!aliveRef.current) return;
788
+ setMeta(data);
789
+ setMetaWarning(null);
790
+ } catch (error) {
791
+ if (!aliveRef.current) return;
792
+ setMetaWarning(errMessage(error));
793
+ }
794
+ }, []);
795
+
796
+ const loadSnapshot = useCallback(async (loud) => {
797
+ if (pollRef.current) return;
798
+ pollRef.current = true;
799
+ try {
800
+ const data = await apiFetch("?limit=100");
801
+ if (!aliveRef.current) return;
802
+ hasDataRef.current = true;
803
+ setSnapshot(data);
804
+ setLoading(false);
805
+ setLoadError(null);
806
+ } catch (error) {
807
+ if (!aliveRef.current) return;
808
+ if (!hasDataRef.current) setLoadError(errMessage(error));
809
+ } finally {
810
+ pollRef.current = false;
811
+ }
812
+ }, []);
813
+
814
+ useEffect(() => {
815
+ aliveRef.current = true;
816
+ loadMeta();
817
+ loadSnapshot(true);
818
+ const timer = window.setInterval(() => loadSnapshot(false), POLL_MS);
819
+ return () => {
820
+ aliveRef.current = false;
821
+ window.clearInterval(timer);
822
+ window.clearTimeout(flashTimerRef.current);
823
+ };
824
+ }, [loadMeta, loadSnapshot]);
825
+
826
+ const flashMessage = useCallback((kind, message) => {
827
+ setFlash({ kind, message });
828
+ window.clearTimeout(flashTimerRef.current);
829
+ flashTimerRef.current = window.setTimeout(() => setFlash(null), 6000);
830
+ }, []);
831
+
832
+ const setBusyKey = useCallback((key, value) => {
833
+ setBusy((previous) => Object.assign({}, previous, { [key]: value }));
834
+ }, []);
835
+
836
+ const runAction = useCallback(
837
+ async (key, action, onSuccess) => {
838
+ setBusyKey(key, true);
839
+ try {
840
+ const result = await action();
841
+ await loadSnapshot(true);
842
+ if (onSuccess) onSuccess(result);
843
+ return result;
844
+ } catch (error) {
845
+ flashMessage("error", errMessage(error));
846
+ return null;
847
+ } finally {
848
+ setBusyKey(key, false);
849
+ }
850
+ },
851
+ [flashMessage, loadSnapshot, setBusyKey],
852
+ );
853
+
854
+ const handleToggle = useCallback(
855
+ (job) => {
856
+ runAction("toggle:" + job.id, () =>
857
+ apiFetch("/jobs/" + encodeURIComponent(job.id) + "/enabled", {
858
+ method: "POST",
859
+ body: JSON.stringify({ enabled: !job.enabled, expectedVersion: job.version }),
860
+ }),
861
+ );
862
+ },
863
+ [runAction],
864
+ );
865
+
866
+ const handleRunNow = useCallback(
867
+ (job) => {
868
+ runAction(
869
+ "run:" + job.id,
870
+ () => apiFetch("/jobs/" + encodeURIComponent(job.id) + "/run", { method: "POST", body: "{}" }),
871
+ (run) => flashMessage(
872
+ run && run.status === "skipped" ? "error" : "success",
873
+ run && run.status === "skipped"
874
+ ? "Run skipped for \u201c" + job.name + "\u201d (" + (run.skipReason || "policy") + ")."
875
+ : "Run queued for \u201c" + job.name + "\u201d.",
876
+ ),
877
+ );
878
+ },
879
+ [runAction, flashMessage],
880
+ );
881
+
882
+ const handleDeleteStart = useCallback((job) => {
883
+ setConfirmCancelId(null);
884
+ setConfirmDeleteId(job.id);
885
+ }, []);
886
+
887
+ const handleDeleteConfirm = useCallback(
888
+ (job) => {
889
+ runAction(
890
+ "delete:" + job.id,
891
+ async () => {
892
+ await apiFetch("/jobs/" + encodeURIComponent(job.id), { method: "DELETE", body: "{}" });
893
+ setConfirmDeleteId(null);
894
+ },
895
+ () => flashMessage("success", "Automation \u201c" + job.name + "\u201d deleted."),
896
+ );
897
+ },
898
+ [runAction, flashMessage],
899
+ );
900
+
901
+ const handleDeleteCancel = useCallback(() => setConfirmDeleteId(null), []);
902
+
903
+ const handleCancelRunStart = useCallback((run) => {
904
+ setConfirmDeleteId(null);
905
+ setConfirmCancelId(run.id);
906
+ }, []);
907
+
908
+ const handleCancelRunConfirm = useCallback(
909
+ (run) => {
910
+ runAction(
911
+ "cancel:" + run.id,
912
+ () => apiFetch("/runs/" + encodeURIComponent(run.id) + "/cancel", { method: "POST", body: "{}" }),
913
+ () => {
914
+ setConfirmCancelId(null);
915
+ flashMessage("success", "Run cancellation requested.");
916
+ },
917
+ );
918
+ },
919
+ [runAction, flashMessage],
920
+ );
921
+
922
+ const handleCancelRunReset = useCallback(() => setConfirmCancelId(null), []);
923
+
924
+ const openCreate = useCallback(() => {
925
+ setEditing(null);
926
+ idTouchedRef.current = false;
927
+ setDraft(emptyDraft(meta));
928
+ setFormError(null);
929
+ setFormOpen(true);
930
+ }, [meta]);
931
+
932
+ const openEdit = useCallback((job) => {
933
+ setEditing(job);
934
+ idTouchedRef.current = true;
935
+ setDraft(draftFromJob(job));
936
+ setFormError(null);
937
+ setFormOpen(true);
938
+ }, []);
939
+
940
+ const closeForm = useCallback(() => {
941
+ setFormOpen(false);
942
+ setEditing(null);
943
+ setFormError(null);
944
+ }, []);
945
+
946
+ const handleDraftChange = useCallback(
947
+ (key, value) => {
948
+ setDraft((previous) => {
949
+ let next = Object.assign({}, previous, { [key]: value });
950
+ if (key === "name" && editing === null && !idTouchedRef.current) {
951
+ next = Object.assign({}, next, { id: slugifyJobId(value) });
952
+ }
953
+ if (key === "provider") {
954
+ next = Object.assign({}, next, { model: "" });
955
+ }
956
+ return next;
957
+ });
958
+ },
959
+ [editing],
960
+ );
961
+
962
+ const handleIdTouched = useCallback(() => {
963
+ idTouchedRef.current = true;
964
+ }, []);
965
+
966
+ const handleSubmit = useCallback(
967
+ async (event) => {
968
+ event.preventDefault();
969
+ const creating = editing === null;
970
+
971
+ const name = draft.name.trim();
972
+ if (name === "") return setFormError("Give the automation a name.");
973
+ const cron = draft.cron.trim();
974
+ if (cron === "") return setFormError("A cron expression is required, e.g. 0 9 * * 1-5.");
975
+ const timezone = draft.timezone.trim();
976
+ if (timezone === "") return setFormError("A timezone is required.");
977
+ const cwd = draft.cwd.trim();
978
+ if (cwd === "") return setFormError("The working directory is required.");
979
+ const looksAbsolute = cwd.charAt(0) === "/" || /^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith("\\\\");
980
+ if (!looksAbsolute) {
981
+ return setFormError("The working directory must be an absolute filesystem path.");
982
+ }
983
+ const prompt = draft.prompt.trim();
984
+ if (prompt === "") return setFormError("A prompt is required.");
985
+ const timeoutMs = Number(draft.timeoutMs);
986
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) {
987
+ return setFormError(
988
+ "Timeout must be a whole number of milliseconds between " + MIN_TIMEOUT_MS + " and " + MAX_TIMEOUT_MS + ".",
989
+ );
990
+ }
991
+ const provider = draft.provider.trim();
992
+ const model = draft.model.trim();
993
+ if (provider !== "" && model === "") {
994
+ return setFormError("Pick a model for the chosen provider (or leave both blank for the Harness default).");
995
+ }
996
+ if (provider === "" && model !== "") {
997
+ return setFormError("A model needs a provider (or leave both blank for the Harness default).");
998
+ }
999
+ let id = "";
1000
+ if (creating) {
1001
+ id = draft.id.trim().toLowerCase();
1002
+ if (id !== "" && !JOB_ID_PATTERN.test(id)) {
1003
+ return setFormError("Job id must match [a-z0-9][a-z0-9-]{0,62} (lowercase letters, digits, hyphens).");
1004
+ }
1005
+ }
1006
+ const spec = buildSpec(draft);
1007
+ setFormError(null);
1008
+ setBusyKey("save", true);
1009
+ try {
1010
+ if (creating) {
1011
+ await apiFetch("/jobs", {
1012
+ method: "POST",
1013
+ body: JSON.stringify(id === "" ? { spec } : { spec, id }),
1014
+ });
1015
+ } else {
1016
+ await apiFetch("/jobs/" + encodeURIComponent(editing.id), {
1017
+ method: "PUT",
1018
+ body: JSON.stringify({ expectedVersion: editing.version, spec }),
1019
+ });
1020
+ }
1021
+ await loadSnapshot(true);
1022
+ flashMessage("success", creating ? "Automation created." : "Automation updated.");
1023
+ closeForm();
1024
+ } catch (error) {
1025
+ setFormError(errMessage(error));
1026
+ } finally {
1027
+ setBusyKey("save", false);
1028
+ }
1029
+ },
1030
+ [draft, editing, flashMessage, loadSnapshot, closeForm, setBusyKey],
1031
+ );
1032
+
1033
+ const jobs = snapshot ? snapshot.jobs : [];
1034
+ const runs = snapshot ? snapshot.runs : [];
1035
+ const busyForJob = (id) =>
1036
+ ["toggle:" + id, "run:" + id, "delete:" + id].some((key) => busy[key] === true);
1037
+
1038
+ return h(
1039
+ "section",
1040
+ { className: "dsh-auto-root", "aria-busy": loading ? "true" : null },
1041
+ h(
1042
+ "div",
1043
+ { className: "dsh-auto-head" },
1044
+ h(
1045
+ "div",
1046
+ null,
1047
+ h("h2", null, "Automations"),
1048
+ h(
1049
+ "p",
1050
+ { className: "dsh-auto-sub" },
1051
+ snapshot
1052
+ ? "Scheduled agent jobs \u00b7 revision " + snapshot.revision + " \u00b7 refreshes every " + POLL_MS / 1000 + "s."
1053
+ : "Scheduled agent jobs.",
1054
+ ),
1055
+ ),
1056
+ !formOpen
1057
+ ? h(
1058
+ "button",
1059
+ { type: "button", className: "dsh-auto-btn dsh-auto-btn-primary", onClick: openCreate },
1060
+ "New automation",
1061
+ )
1062
+ : null,
1063
+ ),
1064
+ flash
1065
+ ? h("p", { className: "dsh-auto-flash dsh-auto-flash-" + flash.kind, role: "status" }, flash.message)
1066
+ : null,
1067
+ metaWarning
1068
+ ? h(
1069
+ "p",
1070
+ { className: "dsh-auto-flash dsh-auto-flash-warn", role: "status" },
1071
+ "Could not load provider/model options: ",
1072
+ metaWarning,
1073
+ " ",
1074
+ h("button", { type: "button", className: "dsh-auto-linkbtn", onClick: loadMeta }, "Retry"),
1075
+ )
1076
+ : null,
1077
+ formOpen
1078
+ ? h(JobForm, {
1079
+ meta,
1080
+ draft,
1081
+ editing,
1082
+ saving: busy.save === true,
1083
+ error: formError,
1084
+ onChange: handleDraftChange,
1085
+ onIdTouched: handleIdTouched,
1086
+ onSubmit: handleSubmit,
1087
+ onCancel: closeForm,
1088
+ })
1089
+ : null,
1090
+ loadError !== null && snapshot === null
1091
+ ? h(
1092
+ "div",
1093
+ { className: "dsh-auto-error", role: "alert" },
1094
+ h("p", null, "Could not load automations: ", loadError),
1095
+ h(
1096
+ "button",
1097
+ { type: "button", className: "dsh-auto-btn dsh-auto-btn-primary", onClick: () => loadSnapshot(true) },
1098
+ "Retry",
1099
+ ),
1100
+ )
1101
+ : loading && snapshot === null
1102
+ ? h("div", { className: "dsh-auto-loading", role: "status" }, "Loading automations\u2026")
1103
+ : h(
1104
+ "div",
1105
+ { className: "dsh-auto-jobs" },
1106
+ jobs.length === 0
1107
+ ? h(
1108
+ "p",
1109
+ { className: "dsh-auto-empty" },
1110
+ "No automations yet. Create your first scheduled agent job.",
1111
+ )
1112
+ : jobs.map((job) =>
1113
+ h(JobCard, {
1114
+ key: job.id,
1115
+ job,
1116
+ meta,
1117
+ busy,
1118
+ busyForJob: busyForJob(job.id),
1119
+ onToggle: () => handleToggle(job),
1120
+ onRun: () => handleRunNow(job),
1121
+ onEdit: () => openEdit(job),
1122
+ onDelete: () => handleDeleteStart(job),
1123
+ onDeleteConfirm: () => handleDeleteConfirm(job),
1124
+ onDeleteCancel: handleDeleteCancel,
1125
+ confirmingDelete: confirmDeleteId === job.id,
1126
+ }),
1127
+ ),
1128
+ ),
1129
+ snapshot !== null
1130
+ ? h(RunsList, {
1131
+ runs,
1132
+ busy,
1133
+ confirmCancelId,
1134
+ onCancel: handleCancelRunStart,
1135
+ onCancelConfirm: handleCancelRunConfirm,
1136
+ onCancelReset: handleCancelRunReset,
1137
+ })
1138
+ : null,
1139
+ );
1140
+ }
1141
+
1142
+ const STYLE_CSS = [
1143
+ ".dsh-auto-root{--dsh-auto-border:var(--dsw-alias-border-subtle,rgba(128,128,128,.3));--dsh-auto-bg:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.05));box-sizing:border-box;max-width:820px;display:flex;flex-direction:column;gap:16px;font-family:inherit;color:var(--dsw-alias-label-primary,#e5e7eb);}",
1144
+ ".dsh-auto-root *,.dsh-auto-root *::before,.dsh-auto-root *::after{box-sizing:border-box;}",
1145
+ ".dsh-auto-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;}",
1146
+ ".dsh-auto-head h2{margin:0;font-size:18px;font-weight:600;line-height:1.35;}",
1147
+ ".dsh-auto-sub{margin:3px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#9ca3af);max-width:540px;}",
1148
+ ".dsh-auto-flash{margin:0;padding:9px 12px;border-radius:10px;font-size:13px;line-height:1.45;word-break:break-word;}",
1149
+ ".dsh-auto-flash-error{background:rgba(239,68,68,.13);color:var(--dsw-alias-label-error,#f87171);}",
1150
+ ".dsh-auto-flash-success{background:rgba(34,197,94,.13);color:var(--dsw-alias-label-success,#4ade80);}",
1151
+ ".dsh-auto-flash-warn{background:rgba(234,179,8,.13);color:var(--dsw-alias-label-warning,#fbbf24);}",
1152
+ ".dsh-auto-linkbtn{font:inherit;font-size:inherit;color:inherit;text-decoration:underline;cursor:pointer;background:none;border:none;padding:0;}",
1153
+ ".dsh-auto-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:30px;padding:5px 12px;border:1px solid var(--dsh-auto-border);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,#e5e7eb);font-family:inherit;font-size:13px;line-height:1.2;cursor:pointer;transition:background .15s ease,opacity .15s ease;white-space:nowrap;}",
1154
+ ".dsh-auto-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14));}",
1155
+ ".dsh-auto-btn:disabled{opacity:.5;cursor:default;}",
1156
+ ".dsh-auto-btn-primary{background:var(--dsw-alias-brand-primary,#3b82f6);border-color:transparent;color:#fff;}",
1157
+ ".dsh-auto-btn-primary:hover:not(:disabled){background:var(--dsw-alias-brand-primary-hover,#2563eb);}",
1158
+ ".dsh-auto-btn-danger{background:var(--dsw-alias-label-error,#ef4444);border-color:transparent;color:#fff;}",
1159
+ ".dsh-auto-btn-danger:hover:not(:disabled){background:#dc2626;}",
1160
+ ".dsh-auto-btn-danger-ghost{color:var(--dsw-alias-label-error,#ef4444);border-color:currentColor;}",
1161
+ ".dsh-auto-btn-danger-ghost:hover:not(:disabled){background:rgba(239,68,68,.12);}",
1162
+ ".dsh-auto-btn-ghost{color:var(--dsw-alias-label-secondary,#9ca3af);}",
1163
+ ".dsh-auto-input,.dsh-auto-select,.dsh-auto-textarea{width:100%;min-height:34px;padding:6px 10px;border:1px solid var(--dsh-auto-border);border-radius:8px;background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,#e5e7eb);font-family:inherit;font-size:13px;line-height:1.5;}",
1164
+ ".dsh-auto-input:focus-visible,.dsh-auto-select:focus-visible,.dsh-auto-textarea:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#3b82f6);outline-offset:1px;border-color:transparent;}",
1165
+ ".dsh-auto-input:disabled,.dsh-auto-select:disabled,.dsh-auto-textarea:disabled{opacity:.55;cursor:default;}",
1166
+ ".dsh-auto-textarea{resize:vertical;min-height:110px;}",
1167
+ ".dsh-auto-check{display:inline-flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;min-height:34px;}",
1168
+ ".dsh-auto-check input{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary,#3b82f6);cursor:pointer;}",
1169
+ ".dsh-auto-form{border:1px solid var(--dsh-auto-border);border-radius:14px;padding:16px;background:var(--dsh-auto-bg);display:flex;flex-direction:column;gap:14px;}",
1170
+ ".dsh-auto-form h3{margin:0;font-size:15px;font-weight:600;}",
1171
+ ".dsh-auto-formgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}",
1172
+ ".dsh-auto-field{display:flex;flex-direction:column;gap:4px;min-width:0;}",
1173
+ ".dsh-auto-field-full{grid-column:1/-1;}",
1174
+ ".dsh-auto-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary,#9ca3af);}",
1175
+ ".dsh-auto-required{color:var(--dsw-alias-label-error,#ef4444);}",
1176
+ ".dsh-auto-hint{margin:0;font-size:11px;line-height:1.45;color:var(--dsw-alias-label-tertiary,#9ca3af);}",
1177
+ ".dsh-auto-formerror{margin:0;padding:8px 10px;border-radius:8px;background:rgba(239,68,68,.12);color:var(--dsw-alias-label-error,#ef4444);font-size:12px;line-height:1.45;}",
1178
+ ".dsh-auto-formactions{display:flex;gap:8px;justify-content:flex-end;align-items:center;flex-wrap:wrap;}",
1179
+ ".dsh-auto-jobs{display:flex;flex-direction:column;gap:10px;}",
1180
+ ".dsh-auto-card{border:1px solid var(--dsh-auto-border);border-radius:12px;padding:12px 14px;background:var(--dsh-auto-bg);display:flex;flex-direction:column;gap:10px;}",
1181
+ ".dsh-auto-card-disabled{opacity:.68;}",
1182
+ ".dsh-auto-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}",
1183
+ ".dsh-auto-card-title{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;min-width:0;}",
1184
+ ".dsh-auto-card-title h3{margin:0;font-size:14px;font-weight:600;overflow-wrap:anywhere;}",
1185
+ ".dsh-auto-card-id{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}",
1186
+ ".dsh-auto-card-meta{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;color:var(--dsw-alias-label-secondary,#9ca3af);line-height:1.5;}",
1187
+ ".dsh-auto-card-meta span{white-space:nowrap;}",
1188
+ ".dsh-auto-next{font-variant-numeric:tabular-nums;}",
1189
+ ".dsh-auto-muted{color:var(--dsw-alias-label-tertiary,#9ca3af);}",
1190
+ ".dsh-auto-card-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}",
1191
+ ".dsh-auto-confirm{display:inline-flex;gap:8px;align-items:center;flex-wrap:wrap;}",
1192
+ ".dsh-auto-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--dsw-alias-label-secondary,#9ca3af);user-select:none;}",
1193
+ ".dsh-auto-switch input{position:absolute;opacity:0;width:1px;height:1px;margin:0;}",
1194
+ ".dsh-auto-switch-track{position:relative;width:32px;height:18px;border-radius:9px;background:var(--dsw-alias-border-strong,rgba(128,128,128,.5));transition:background .15s ease;flex:none;}",
1195
+ ".dsh-auto-switch-track::after{content:\"\";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .15s ease;}",
1196
+ ".dsh-auto-switch input:checked+.dsh-auto-switch-track{background:var(--dsw-alias-brand-primary,#3b82f6);}",
1197
+ ".dsh-auto-switch input:checked+.dsh-auto-switch-track::after{transform:translateX(14px);}",
1198
+ ".dsh-auto-switch input:focus-visible+.dsh-auto-switch-track{outline:2px solid var(--dsw-alias-brand-primary,#3b82f6);outline-offset:2px;}",
1199
+ ".dsh-auto-switch input:disabled~.dsh-auto-switch-text{opacity:.6;}",
1200
+ ".dsh-auto-switch:has(input:disabled){cursor:default;}",
1201
+ ".dsh-auto-runs{border-top:1px solid var(--dsh-auto-border);padding-top:14px;display:flex;flex-direction:column;gap:10px;min-width:0;}",
1202
+ ".dsh-auto-runs h3{margin:0;font-size:14px;font-weight:600;}",
1203
+ ".dsh-auto-runlist{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;max-height:360px;overflow-y:auto;}",
1204
+ ".dsh-auto-run{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:8px 10px;border:1px solid var(--dsh-auto-border);border-radius:10px;background:var(--dsh-auto-bg);font-size:12px;line-height:1.4;}",
1205
+ ".dsh-auto-run-active{border-color:var(--dsw-alias-brand-primary,rgba(59,130,246,.5));}",
1206
+ ".dsh-auto-run-name{font-weight:500;color:var(--dsw-alias-label-primary,#e5e7eb);max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
1207
+ ".dsh-auto-run-meta{color:var(--dsw-alias-label-tertiary,#9ca3af);font-variant-numeric:tabular-nums;}",
1208
+ ".dsh-auto-run-dur{color:var(--dsw-alias-label-secondary,#9ca3af);font-variant-numeric:tabular-nums;white-space:nowrap;}",
1209
+ ".dsh-auto-run-error{color:var(--dsw-alias-label-error,#ef4444);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
1210
+ ".dsh-auto-run-skip{color:var(--dsw-alias-label-tertiary,#9ca3af);}",
1211
+ ".dsh-auto-run-session{color:var(--dsw-alias-label-tertiary,#9ca3af);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;}",
1212
+ ".dsh-auto-run-more{color:var(--dsw-alias-label-tertiary,#9ca3af);font-size:11px;padding:2px 4px;}",
1213
+ ".dsh-auto-status{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:500;text-transform:capitalize;white-space:nowrap;}",
1214
+ ".dsh-auto-status::before{content:\"\";width:6px;height:6px;border-radius:50%;background:currentColor;flex:none;}",
1215
+ ".dsh-auto-status-queued{background:rgba(128,128,128,.16);color:#9ca3af;}",
1216
+ ".dsh-auto-status-running{background:rgba(59,130,246,.16);color:#60a5fa;}",
1217
+ ".dsh-auto-status-running::before{animation:dsh-auto-pulse 1.1s ease-in-out infinite;}",
1218
+ ".dsh-auto-status-succeeded{background:rgba(34,197,94,.16);color:#4ade80;}",
1219
+ ".dsh-auto-status-failed,.dsh-auto-status-timed-out{background:rgba(239,68,68,.16);color:#f87171;}",
1220
+ ".dsh-auto-status-cancelled,.dsh-auto-status-skipped,.dsh-auto-status-interrupted{background:rgba(128,128,128,.16);color:#9ca3af;}",
1221
+ "@keyframes dsh-auto-pulse{0%,100%{opacity:1}50%{opacity:.35}}",
1222
+ ".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(--dsw-alias-label-secondary,#9ca3af);}",
1223
+ ".dsh-auto-error{color:var(--dsw-alias-label-error,#ef4444);display:flex;flex-direction:column;gap:10px;align-items:center;}",
1224
+ "@media (max-width:640px){.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;}}",
1225
+ ].join("\n");
1226
+
1227
+ function apply(ctx) {
1228
+ // The page styles ride a style element owned by this plugin's fiber:
1229
+ // created now and removed when the plugin unloads.
1230
+ ctx.effect(() => {
1231
+ const tag = document.createElement("style");
1232
+ tag.setAttribute("data-plugin", "@syncended/dsh-automations");
1233
+ tag.textContent = STYLE_CSS;
1234
+ document.head.appendChild(tag);
1235
+ return () => {
1236
+ tag.remove();
1237
+ };
1238
+ }, "@syncended/dsh-automations: settings page styles");
1239
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
1240
+ name: "settings.section",
1241
+ id: "automations",
1242
+ order: 25,
1243
+ label: "Automations",
1244
+ }, AutomationsSection));
1245
+ }
1246
+
1247
+ exports.inject = inject;
1248
+ exports.apply = apply;
1249
+ return module.exports;
1250
+ },
1251
+ });