@worca/app 1.3.0 → 1.4.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 +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// ui/public/schedule-sheet.mjs
|
|
2
|
+
// The schedule sheet: ONE dialog for "run once, later" and "repeat", used by the New
|
|
3
|
+
// pipeline form, the Ask run card and the Schedules view's Change time / Edit.
|
|
4
|
+
//
|
|
5
|
+
// The editor never shows cron. It builds a SENTENCE and proves it with the next dates —
|
|
6
|
+
// both computed here, in the browser, from the same recurrence module the server and the
|
|
7
|
+
// CLI use (src/shared/schedule/recurrence.mjs), so what is previewed is what will run.
|
|
8
|
+
//
|
|
9
|
+
// openScheduleSheet(opts) -> Promise<result|null>
|
|
10
|
+
// result (once): { scheduledFor: ISO-with-Z, ifMissed, graceMin }
|
|
11
|
+
// result (recurring): { repeat: { rule, overlap, maxFailures }, ifMissed, graceMin }
|
|
12
|
+
// result (after): { after: { kind, id, title }, afterPolicy }
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
WEEKDAYS, normalizeRule, previewOccurrences, describeRule, formatInstant, formatCountdown,
|
|
16
|
+
zonedToUtc, zonedParts, localDate, isValidTimeZone,
|
|
17
|
+
} from '../../src/shared/schedule/recurrence.mjs';
|
|
18
|
+
|
|
19
|
+
const DAY_LABEL = { mo: 'Mo', tu: 'Tu', we: 'We', th: 'Th', fr: 'Fr', sa: 'Sa', su: 'Su' };
|
|
20
|
+
const DAY_NAME = { mo: 'Monday', tu: 'Tuesday', we: 'Wednesday', th: 'Thursday', fr: 'Friday', sa: 'Saturday', su: 'Sunday' };
|
|
21
|
+
const PRESETS = [
|
|
22
|
+
['once', 'Once'], ['daily', 'Every day'], ['weekdays', 'Weekdays'],
|
|
23
|
+
['weekly', 'Weekly'], ['monthly', 'Monthly'], ['custom', 'Custom'],
|
|
24
|
+
];
|
|
25
|
+
/** The sheet's kind switch (run chains): a time, or another run. */
|
|
26
|
+
const KINDS = [['time', 'At a time'], ['after', 'After a run']];
|
|
27
|
+
const AFTER_STATE_WORD = { running: 'running', starting: 'starting', created: 'starting', pausing: 'pausing', paused: 'paused',
|
|
28
|
+
scheduled: 'scheduled', firing: 'starting', missed: 'missed', fired: 'starting',
|
|
29
|
+
done: 'finished', error: 'ended with an error', stopped: 'stopped', interrupted: 'interrupted', canceled: 'canceled',
|
|
30
|
+
failed: 'could not start', skipped: 'skipped' };
|
|
31
|
+
const GRACE_CHOICES = [[30, '30 minutes'], [60, '1 hour'], [120, '2 hours'], [360, '6 hours'], [720, '12 hours'], [1440, '24 hours']];
|
|
32
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
33
|
+
|
|
34
|
+
export function browserTimeZone() {
|
|
35
|
+
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function h(tag, attrs = {}, ...kids) {
|
|
39
|
+
const node = document.createElement(tag);
|
|
40
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
41
|
+
if (v == null || v === false) continue;
|
|
42
|
+
if (k === 'class') node.className = v;
|
|
43
|
+
else if (k === 'text') node.textContent = v;
|
|
44
|
+
else if (k === 'hidden') node.hidden = !!v;
|
|
45
|
+
else node.setAttribute(k, v === true ? '' : String(v));
|
|
46
|
+
}
|
|
47
|
+
for (const kid of kids.flat()) if (kid != null) node.append(kid);
|
|
48
|
+
return node;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function selectEl(id, options, value, label) {
|
|
52
|
+
const sel = h('select', { id, class: 'select', 'aria-label': label });
|
|
53
|
+
for (const [v, text] of options) sel.append(h('option', { value: String(v), text }));
|
|
54
|
+
sel.value = String(value);
|
|
55
|
+
return h('div', { class: 'select-wrap' }, sel);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The preset a stored rule corresponds to (for Edit). */
|
|
59
|
+
function presetOfRule(rule) {
|
|
60
|
+
if (!rule) return 'once';
|
|
61
|
+
if ((rule.interval || 1) !== 1) return 'custom';
|
|
62
|
+
if (rule.freq === 'daily') return 'daily';
|
|
63
|
+
if (rule.freq === 'weekly') return (rule.weekdays || []).join(',') === 'mo,tu,we,th,fr' ? 'weekdays' : 'weekly';
|
|
64
|
+
return 'monthly';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let openSheet = null; // one sheet at a time
|
|
68
|
+
|
|
69
|
+
/** Close the sheet if one is open (a view change must not strand it). */
|
|
70
|
+
export function closeScheduleSheet() {
|
|
71
|
+
if (openSheet) openSheet(null);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {object} o
|
|
76
|
+
* @param {'create'|'ticket'|'series'} [o.mode] create = both kinds; ticket = Change time; series = Edit
|
|
77
|
+
* @param {boolean} [o.allowRepeat] false hides the repeat presets (an Ask card runs once)
|
|
78
|
+
* @param {boolean} [o.allowAfter] false hides the At a time · After a run switch even when candidates are given
|
|
79
|
+
* @param {() => Promise<{runs:object[], tickets:object[]}>} [o.candidates] the host's after-candidates fetch; given (and not a series) => the kind switch shows
|
|
80
|
+
* @param {{kind:string, id:string, title?:string, status?:string}} [o.initial.after] a stored predecessor: the sheet opens on the After side
|
|
81
|
+
* @param {'done'|'any'} [o.initial.afterPolicy] the stored outcome policy
|
|
82
|
+
* @param {object} [o.initial] { scheduledFor?, rule?, overlap?, maxFailures?, ifMissed?, graceMin? }
|
|
83
|
+
* @param {{graceMin:number, ifMissed:string, maxFailures:number}} [o.defaults]
|
|
84
|
+
* @param {string} [o.runTitle] shown under the heading
|
|
85
|
+
* @param {string} [o.warning] an amber note (e.g. the workflow can ask questions)
|
|
86
|
+
*/
|
|
87
|
+
export function openScheduleSheet({
|
|
88
|
+
mode = 'create', allowRepeat = true, allowAfter = true, candidates = null,
|
|
89
|
+
initial = {}, defaults = { graceMin: 360, ifMissed: 'run', maxFailures: 3 },
|
|
90
|
+
runTitle = '', warning = '',
|
|
91
|
+
} = {}) {
|
|
92
|
+
closeScheduleSheet();
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let tz = (initial.rule && isValidTimeZone(initial.rule.tz)) ? initial.rule.tz : browserTimeZone();
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const startMs = initial.scheduledFor ? Date.parse(initial.scheduledFor) : null;
|
|
97
|
+
// Default slot: tomorrow 02:00 — the feature exists for out-of-hours runs.
|
|
98
|
+
const seedParts = zonedParts(startMs && startMs > now ? startMs : now + 86400000, tz);
|
|
99
|
+
// The kind switch needs a candidate list; a series can never wait for a run.
|
|
100
|
+
const showKind = mode !== 'series' && allowAfter && typeof candidates === 'function';
|
|
101
|
+
const state = {
|
|
102
|
+
preset: mode === 'series' || initial.rule ? presetOfRule(initial.rule) : 'once',
|
|
103
|
+
date: `${seedParts.y}-${pad(seedParts.m)}-${pad(seedParts.d)}`,
|
|
104
|
+
time: startMs && startMs > now ? `${pad(seedParts.hh)}:${pad(seedParts.mm)}` : (initial.rule?.time || '02:00'),
|
|
105
|
+
weekdays: new Set(initial.rule?.weekdays?.length ? initial.rule.weekdays : ['mo']),
|
|
106
|
+
monthDay: initial.rule?.monthDay ?? 1,
|
|
107
|
+
interval: initial.rule?.interval || 2,
|
|
108
|
+
unit: initial.rule?.freq === 'monthly' ? 'monthly' : initial.rule?.freq === 'daily' ? 'daily' : 'weekly',
|
|
109
|
+
endType: initial.rule?.end?.type || 'never',
|
|
110
|
+
until: initial.rule?.end?.until || '',
|
|
111
|
+
count: initial.rule?.end?.count || 10,
|
|
112
|
+
overlap: initial.overlap || 'skip',
|
|
113
|
+
maxFailures: initial.maxFailures ?? defaults.maxFailures,
|
|
114
|
+
ifMissed: initial.ifMissed || defaults.ifMissed,
|
|
115
|
+
graceMin: initial.graceMin ?? defaults.graceMin,
|
|
116
|
+
kind: showKind && initial.after ? 'after' : 'time',
|
|
117
|
+
after: initial.after || null, afterAny: initial.afterPolicy === 'any', cands: null, candsLoading: false, candsError: '',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const heading = mode === 'ticket' ? (showKind ? 'Change when it starts' : 'Change time') : mode === 'series' ? 'Edit schedule' : 'Schedule this run';
|
|
121
|
+
const confirmLabel = mode === 'create' ? 'Schedule run' : 'Save';
|
|
122
|
+
|
|
123
|
+
// ── build ────────────────────────────────────────────────────────────────
|
|
124
|
+
const seg = h('div', { class: 'seg sched-presets', role: 'group', 'aria-label': 'How often' });
|
|
125
|
+
for (const [key, label] of PRESETS) {
|
|
126
|
+
if (mode === 'series' && key === 'once') continue;
|
|
127
|
+
seg.append(h('button', { type: 'button', 'data-preset': key, text: label }));
|
|
128
|
+
}
|
|
129
|
+
const showPresets = mode === 'series' || (mode === 'create' && allowRepeat);
|
|
130
|
+
|
|
131
|
+
const kindSeg = h('div', { class: 'seg sched-kind', role: 'group', 'aria-label': 'Start it' });
|
|
132
|
+
for (const [key, label] of KINDS) kindSeg.append(h('button', { type: 'button', 'data-kind': key, text: label }));
|
|
133
|
+
|
|
134
|
+
const quick = h('div', { class: 'sched-quick' });
|
|
135
|
+
const dateIn = h('input', { type: 'date', id: 'sched-date', class: 'input', 'aria-label': 'Date' });
|
|
136
|
+
const timeIn = h('input', { type: 'time', id: 'sched-time', class: 'input', 'aria-label': 'Time' });
|
|
137
|
+
const onceRow = h('div', { class: 'field-grid-2 sched-once' },
|
|
138
|
+
h('div', { class: 'field field-compact' }, h('label', { for: 'sched-date', text: 'Date' }), dateIn),
|
|
139
|
+
h('div', { class: 'field field-compact sched-time-field' }, h('label', { for: 'sched-time', text: 'Time' }), timeIn));
|
|
140
|
+
|
|
141
|
+
const days = h('div', { class: 'sched-days', role: 'group', 'aria-label': 'Days of the week' });
|
|
142
|
+
for (const d of WEEKDAYS) days.append(h('button', { type: 'button', class: 'sched-day', 'data-day': d, title: DAY_NAME[d], 'aria-label': DAY_NAME[d], text: DAY_LABEL[d] }));
|
|
143
|
+
const daysField = h('div', { class: 'field field-compact' }, h('span', { class: 'label', text: 'On' }), days);
|
|
144
|
+
|
|
145
|
+
const intervalIn = h('input', { type: 'number', id: 'sched-interval', class: 'input', min: '1', max: '365', 'aria-label': 'Interval' });
|
|
146
|
+
const unitSel = selectEl('sched-unit', [['daily', 'days'], ['weekly', 'weeks'], ['monthly', 'months']], state.unit, 'Unit');
|
|
147
|
+
const customField = h('div', { class: 'field field-compact' }, h('label', { for: 'sched-interval', text: 'Every' }),
|
|
148
|
+
h('div', { class: 'sched-inline' }, intervalIn, unitSel));
|
|
149
|
+
|
|
150
|
+
const monthOpts = [...Array.from({ length: 31 }, (_, i) => [i + 1, `Day ${i + 1}`]), ['last', 'Last day']];
|
|
151
|
+
const monthSel = selectEl('sched-monthday', monthOpts, state.monthDay, 'Day of the month');
|
|
152
|
+
const monthField = h('div', { class: 'field field-compact' }, h('label', { for: 'sched-monthday', text: 'On' }), monthSel);
|
|
153
|
+
|
|
154
|
+
const afterSel = h('select', { id: 'sched-after', class: 'select', 'aria-label': 'The run to wait for' });
|
|
155
|
+
const afterAny = h('div', { class: 'switch', id: 'sched-after-any', role: 'switch', tabindex: '0', 'aria-checked': 'false', 'aria-label': 'Start even if it fails or is stopped' });
|
|
156
|
+
const afterRow = h('div', { class: 'sched-after' },
|
|
157
|
+
h('div', { class: 'field field-compact' }, h('label', { for: 'sched-after', text: 'After' }), h('div', { class: 'select-wrap' }, afterSel)),
|
|
158
|
+
h('div', { class: 'field field-compact switch-row' }, afterAny, h('span', { class: 'txt', text: 'Start even if it fails or is stopped' })));
|
|
159
|
+
|
|
160
|
+
const endSel = selectEl('sched-end', [['never', 'Never'], ['until', 'On a date'], ['count', 'After a number of runs']], state.endType, 'Ends');
|
|
161
|
+
const untilIn = h('input', { type: 'date', id: 'sched-until', class: 'input', 'aria-label': 'Last day' });
|
|
162
|
+
const countIn = h('input', { type: 'number', id: 'sched-count', class: 'input', min: '1', max: '100000', 'aria-label': 'Number of runs' });
|
|
163
|
+
const endField = h('div', { class: 'field field-compact' }, h('label', { for: 'sched-end', text: 'Ends' }),
|
|
164
|
+
h('div', { class: 'sched-inline' }, endSel, untilIn, countIn));
|
|
165
|
+
const repeatRow = h('div', { class: 'field-grid-2 sched-repeat' }); // time + ends (time field moves in here)
|
|
166
|
+
|
|
167
|
+
const sentence = h('div', { class: 'sched-sentence', 'aria-live': 'polite' });
|
|
168
|
+
const tzLine = h('div', { class: 'sched-tz' });
|
|
169
|
+
const tzIn = h('input', { type: 'text', id: 'sched-tz', class: 'input', spellcheck: 'false', list: 'sched-tz-list', 'aria-label': 'Timezone', hidden: true });
|
|
170
|
+
const tzList = h('datalist', { id: 'sched-tz-list' });
|
|
171
|
+
try { for (const z of Intl.supportedValuesOf('timeZone')) tzList.append(h('option', { value: z })); } catch { /* older browser: free text */ }
|
|
172
|
+
|
|
173
|
+
const overlapSel = selectEl('sched-overlap', [['skip', 'Skip this one'], ['queue', 'Wait, then start'], ['start', 'Start anyway']], state.overlap, 'If the previous run is still going');
|
|
174
|
+
const failIn = h('input', { type: 'number', id: 'sched-maxfail', class: 'input', min: '0', max: '100', 'aria-label': 'Pause after failures in a row' });
|
|
175
|
+
const policyRow = h('div', { class: 'field-grid-2 sched-policies' },
|
|
176
|
+
h('div', { class: 'field field-compact' }, h('label', { for: 'sched-overlap', text: 'If the previous run is still going' }), overlapSel),
|
|
177
|
+
h('div', { class: 'field field-compact' }, h('label', { for: 'sched-maxfail', text: 'Pause after failures in a row' }), failIn,
|
|
178
|
+
h('small', { class: 'hint', text: '0 never pauses it.' })));
|
|
179
|
+
|
|
180
|
+
const missedSel = selectEl('sched-missed', [['run', 'Start it late'], ['skip', 'Skip it']], state.ifMissed, 'If Worca is not running at that time');
|
|
181
|
+
const graceSel = selectEl('sched-grace', GRACE_CHOICES.some(([m]) => m === state.graceMin) ? GRACE_CHOICES : [...GRACE_CHOICES, [state.graceMin, `${state.graceMin} minutes`]], state.graceMin, 'At most this late');
|
|
182
|
+
const graceField = h('div', { class: 'field field-compact' }, h('label', { for: 'sched-grace', text: 'At most this late' }), graceSel);
|
|
183
|
+
const missedRow = h('div', { class: 'field-grid-2' },
|
|
184
|
+
h('div', { class: 'field field-compact' }, h('label', { for: 'sched-missed', text: 'If Worca is not running then' }), missedSel),
|
|
185
|
+
graceField);
|
|
186
|
+
|
|
187
|
+
const err = h('div', { class: 'hint err sched-err', hidden: true });
|
|
188
|
+
const cancelBtn = h('button', { type: 'button', class: 'btn btn-mini sched-cancel', text: 'Cancel' });
|
|
189
|
+
const okBtn = h('button', { type: 'button', class: 'btn btn-primary btn-mini sched-ok', text: confirmLabel });
|
|
190
|
+
const closeBtn = h('button', { type: 'button', class: 'btn btn-mini', text: 'Close' });
|
|
191
|
+
|
|
192
|
+
const card = h('div', { class: 'card sched-card' },
|
|
193
|
+
h('div', { class: 'card-head' }, h('h2', { id: 'sched-title', text: heading }), closeBtn),
|
|
194
|
+
runTitle ? h('div', { class: 'sched-run-title', text: runTitle }) : null,
|
|
195
|
+
showKind ? kindSeg : null,
|
|
196
|
+
showPresets ? seg : null,
|
|
197
|
+
quick, onceRow, daysField, customField, monthField, showKind ? afterRow : null, repeatRow,
|
|
198
|
+
sentence, tzLine, tzIn, tzList,
|
|
199
|
+
policyRow, missedRow,
|
|
200
|
+
h('small', { class: 'hint sched-note', text: 'A scheduled run starts only while Worca is running and this computer is awake.' }),
|
|
201
|
+
warning ? h('div', { class: 'hint warn sched-warn', text: warning }) : null,
|
|
202
|
+
err,
|
|
203
|
+
h('div', { class: 'confirm-actions' }, cancelBtn, okBtn));
|
|
204
|
+
const modal = h('div', { id: 'schedule-modal', class: 'viewer-modal confirm-modal sched-modal', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'sched-title' }, card);
|
|
205
|
+
|
|
206
|
+
// ── state -> DOM ─────────────────────────────────────────────────────────
|
|
207
|
+
dateIn.value = state.date; timeIn.value = state.time; intervalIn.value = state.interval;
|
|
208
|
+
untilIn.value = state.until; countIn.value = state.count; failIn.value = state.maxFailures;
|
|
209
|
+
|
|
210
|
+
const currentRule = () => {
|
|
211
|
+
const base = { time: state.time, tz, anchor: initial.rule?.anchor };
|
|
212
|
+
const end = state.endType === 'until' ? { type: 'until', until: state.until } : state.endType === 'count' ? { type: 'count', count: Number(state.count) } : { type: 'never' };
|
|
213
|
+
let r;
|
|
214
|
+
if (state.preset === 'daily') r = { freq: 'daily', interval: 1 };
|
|
215
|
+
else if (state.preset === 'weekdays') r = { freq: 'weekly', interval: 1, weekdays: ['mo', 'tu', 'we', 'th', 'fr'] };
|
|
216
|
+
else if (state.preset === 'weekly') r = { freq: 'weekly', interval: 1, weekdays: [...state.weekdays] };
|
|
217
|
+
else if (state.preset === 'monthly') r = { freq: 'monthly', interval: 1, monthDay: state.monthDay };
|
|
218
|
+
else {
|
|
219
|
+
r = { freq: state.unit, interval: Number(state.interval) };
|
|
220
|
+
if (state.unit === 'weekly') r.weekdays = [...state.weekdays];
|
|
221
|
+
if (state.unit === 'monthly') r.monthDay = state.monthDay;
|
|
222
|
+
}
|
|
223
|
+
return normalizeRule({ ...base, ...r, end }, { todayLocal: localDate(Date.now(), tz) });
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const onceInstant = () => {
|
|
227
|
+
const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(state.date);
|
|
228
|
+
const t = /^(\d{1,2}):(\d{2})$/.exec(state.time);
|
|
229
|
+
if (!d || !t) return null;
|
|
230
|
+
return zonedToUtc({ y: +d[1], m: +d[2], d: +d[3], hh: +t[1], mm: +t[2] }, tz);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const quickChoices = () => {
|
|
234
|
+
const p = zonedParts(Date.now(), tz);
|
|
235
|
+
const at = (addDays, hh, mm = 0) => {
|
|
236
|
+
const dt = new Date(Date.UTC(p.y, p.m - 1, p.d + addDays));
|
|
237
|
+
return { date: `${dt.getUTCFullYear()}-${pad(dt.getUTCMonth() + 1)}-${pad(dt.getUTCDate())}`, time: `${pad(hh)}:${pad(mm)}` };
|
|
238
|
+
};
|
|
239
|
+
const inHour = zonedParts(Date.now() + 3600000, tz);
|
|
240
|
+
const out = [['In 1 hour', { date: `${inHour.y}-${pad(inHour.m)}-${pad(inHour.d)}`, time: `${pad(inHour.hh)}:${pad(inHour.mm)}` }]];
|
|
241
|
+
// 22:00 is always offered: today's while it is still ahead, else tomorrow's.
|
|
242
|
+
out.push(p.hh < 22 ? ['Today 22:00', at(0, 22)] : ['Tomorrow 22:00', at(1, 22)]);
|
|
243
|
+
out.push(['Tomorrow 02:00', at(1, 2)]);
|
|
244
|
+
const dow = (new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() + 6) % 7; // Monday = 0
|
|
245
|
+
out.push(['Monday 06:00', at(((7 - dow) % 7) || 7, 6)]);
|
|
246
|
+
// Chronological, whatever the hour: after 21:00 "In 1 hour" is later than 22:00.
|
|
247
|
+
const ms = (v) => zonedToUtc({ y: +v.date.slice(0, 4), m: +v.date.slice(5, 7), d: +v.date.slice(8, 10), hh: +v.time.slice(0, 2), mm: +v.time.slice(3, 5) }, tz);
|
|
248
|
+
return out.sort((a, b) => ms(a[1]) - ms(b[1]));
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const afterKey = (a) => (a ? `${a.kind}:${a.id}` : '');
|
|
252
|
+
function fillAfter() {
|
|
253
|
+
const c = state.cands || { runs: [], tickets: [] };
|
|
254
|
+
afterSel.replaceChildren();
|
|
255
|
+
const running = h('optgroup', { label: 'Running' });
|
|
256
|
+
for (const r of c.runs) running.append(h('option', { value: `pipeline:${r.pipelineId}`, text: `${r.title || r.pipelineId} · ${AFTER_STATE_WORD[r.status] || r.status}` }));
|
|
257
|
+
const scheduled = h('optgroup', { label: 'Scheduled' });
|
|
258
|
+
for (const t of c.tickets) scheduled.append(h('option', { value: `ticket:${t.id}`, text: `${t.title || t.id.slice(0, 8)} · ${t.after ? 'after another run' : (AFTER_STATE_WORD[t.status] || t.status)}` }));
|
|
259
|
+
if (c.runs.length) afterSel.append(running);
|
|
260
|
+
if (c.tickets.length) afterSel.append(scheduled);
|
|
261
|
+
const all = [...c.runs.map((r) => ({ kind: 'pipeline', id: r.pipelineId, title: r.title || r.pipelineId, status: r.status })),
|
|
262
|
+
...c.tickets.map((t) => ({ kind: 'ticket', id: t.id, title: t.title || t.id.slice(0, 8), status: t.status }))];
|
|
263
|
+
const keep = state.after && all.find((a) => afterKey(a) === afterKey(state.after));
|
|
264
|
+
// after-candidates lists live runs and open tickets only; a ticket may wait on a run that has already finished.
|
|
265
|
+
if (state.after && !keep) {
|
|
266
|
+
const a = { kind: state.after.kind, id: state.after.id, title: state.after.title || state.after.id, status: state.after.status || null };
|
|
267
|
+
const current = h('optgroup', { label: 'Current' });
|
|
268
|
+
current.append(h('option', { value: afterKey(a), text: `${a.title}${a.status ? ` · ${AFTER_STATE_WORD[a.status] || a.status}` : ''}` }));
|
|
269
|
+
afterSel.prepend(current);
|
|
270
|
+
all.unshift(a);
|
|
271
|
+
}
|
|
272
|
+
state.after = keep || all[0] || null;
|
|
273
|
+
if (state.after) afterSel.value = afterKey(state.after);
|
|
274
|
+
afterSel._all = all;
|
|
275
|
+
}
|
|
276
|
+
function loadCandidates() {
|
|
277
|
+
// One request per sheet: every repaint (the policy switch, the 30 s interval) calls this while the
|
|
278
|
+
// list is still loading, so latch on the in-flight flag, not only on the settled result.
|
|
279
|
+
if (state.cands || state.candsLoading || !candidates) return;
|
|
280
|
+
state.candsLoading = true;
|
|
281
|
+
Promise.resolve().then(candidates).then((c) => { state.cands = { runs: c && Array.isArray(c.runs) ? c.runs : [], tickets: c && Array.isArray(c.tickets) ? c.tickets : [] }; })
|
|
282
|
+
.catch(() => { state.cands = { runs: [], tickets: [] }; state.candsError = 'The list of runs could not be loaded.'; })
|
|
283
|
+
.then(() => { state.candsLoading = false; fillAfter(); paint(); });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function paint() {
|
|
287
|
+
const once = state.preset === 'once';
|
|
288
|
+
const after = state.kind === 'after'; if (after) loadCandidates();
|
|
289
|
+
for (const b of kindSeg.querySelectorAll('button')) { const on = b.dataset.kind === state.kind; b.classList.toggle('on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); }
|
|
290
|
+
seg.hidden = after; // the time presets belong to At a time
|
|
291
|
+
const usesDays = state.preset === 'weekly' || (state.preset === 'custom' && state.unit === 'weekly');
|
|
292
|
+
const usesMonth = state.preset === 'monthly' || (state.preset === 'custom' && state.unit === 'monthly');
|
|
293
|
+
for (const b of seg.querySelectorAll('button')) {
|
|
294
|
+
const on = b.dataset.preset === state.preset;
|
|
295
|
+
b.classList.toggle('on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
296
|
+
}
|
|
297
|
+
quick.hidden = !once || after;
|
|
298
|
+
quick.replaceChildren(...(once && !after ? quickChoices().map(([label, v]) => {
|
|
299
|
+
const b = h('button', { type: 'button', class: 'chip sched-chip', text: label });
|
|
300
|
+
if (v.date === state.date && v.time === state.time) b.classList.add('on');
|
|
301
|
+
b.addEventListener('click', () => { state.date = v.date; state.time = v.time; dateIn.value = v.date; timeIn.value = v.time; paint(); });
|
|
302
|
+
return b;
|
|
303
|
+
}) : []));
|
|
304
|
+
// The time field lives beside Date (once) or beside Ends (repeat).
|
|
305
|
+
const timeField = timeIn.closest('.field');
|
|
306
|
+
if (once) { onceRow.append(timeField); } else { repeatRow.replaceChildren(timeField, endField); }
|
|
307
|
+
onceRow.hidden = !once || after; repeatRow.hidden = once || after;
|
|
308
|
+
afterRow.hidden = !after;
|
|
309
|
+
daysField.hidden = after || !usesDays; monthField.hidden = after || !usesMonth; customField.hidden = after || state.preset !== 'custom';
|
|
310
|
+
policyRow.hidden = once || after;
|
|
311
|
+
missedRow.hidden = after;
|
|
312
|
+
afterAny.classList.toggle('on', state.afterAny); afterAny.setAttribute('aria-checked', state.afterAny ? 'true' : 'false');
|
|
313
|
+
untilIn.hidden = state.endType !== 'until'; countIn.hidden = state.endType !== 'count';
|
|
314
|
+
graceField.hidden = state.ifMissed !== 'run';
|
|
315
|
+
for (const b of days.querySelectorAll('button')) {
|
|
316
|
+
const on = state.weekdays.has(b.dataset.day);
|
|
317
|
+
b.classList.toggle('on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The sentence + proof.
|
|
321
|
+
let problem = '';
|
|
322
|
+
if (after) {
|
|
323
|
+
if (!state.cands) problem = '';
|
|
324
|
+
else if (state.candsError) problem = state.candsError;
|
|
325
|
+
else if (!state.after) problem = 'Nothing is running or scheduled for this project.';
|
|
326
|
+
sentence.replaceChildren(
|
|
327
|
+
h('b', { text: state.after ? `After ‘${state.after.title}’ finishes` : 'After a run' }),
|
|
328
|
+
h('div', { class: 'sched-next', text: state.after && state.after.status ? `${state.after.title} is ${AFTER_STATE_WORD[state.after.status] || state.after.status}` : '' }));
|
|
329
|
+
} else if (once) {
|
|
330
|
+
const ms = onceInstant();
|
|
331
|
+
if (ms == null) problem = 'Pick a date and a time.';
|
|
332
|
+
else if (ms <= Date.now()) problem = 'That time is already in the past.';
|
|
333
|
+
sentence.replaceChildren(
|
|
334
|
+
h('b', { text: ms == null ? 'Once' : `Once, on ${formatInstant(ms, tz, { withYear: true })}` }),
|
|
335
|
+
h('div', { class: 'sched-next', text: ms != null && ms > Date.now() ? `Starts in ${formatCountdown(ms - Date.now())}` : '' }));
|
|
336
|
+
} else {
|
|
337
|
+
const norm = currentRule();
|
|
338
|
+
if (!norm.ok) {
|
|
339
|
+
problem = norm.error.replace(/^rule\.weekdays needs at least one day$/, 'Pick at least one day.')
|
|
340
|
+
.replace(/^rule\.end\.until.*/, 'Pick the last day it may run.').replace(/^rule\.end\.count.*/, 'Enter how many times it should run.')
|
|
341
|
+
.replace(/^rule\.interval.*/, 'Enter how often it repeats, from 1 to 365.').replace(/^rule\.time.*/, 'Pick a time.');
|
|
342
|
+
sentence.replaceChildren(h('b', { text: 'Repeats' }), h('div', { class: 'sched-next', text: '' }));
|
|
343
|
+
} else {
|
|
344
|
+
const next = previewOccurrences(norm.rule, Date.now(), 3, { firedCount: 0 });
|
|
345
|
+
if (!next.length) problem = 'This schedule has no future run.';
|
|
346
|
+
sentence.replaceChildren(
|
|
347
|
+
h('b', { text: describeRule(norm.rule) }),
|
|
348
|
+
h('div', { class: 'sched-next', text: next.length ? `Next: ${next.map((t) => formatInstant(t, tz)).join(' · ')}` : '' }));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
sentence.classList.toggle('bad', !!problem);
|
|
352
|
+
if (after) tzIn.hidden = true;
|
|
353
|
+
tzLine.hidden = after;
|
|
354
|
+
tzLine.replaceChildren(h('span', { text: `${tz} time` }), ' ',
|
|
355
|
+
h('button', { type: 'button', class: 'sched-tz-change', text: tzIn.hidden ? 'Change' : 'Done' }));
|
|
356
|
+
tzLine.querySelector('button').addEventListener('click', () => {
|
|
357
|
+
tzIn.hidden = !tzIn.hidden;
|
|
358
|
+
if (!tzIn.hidden) { tzIn.value = tz; tzIn.focus(); tzIn.select(); }
|
|
359
|
+
paint();
|
|
360
|
+
});
|
|
361
|
+
err.hidden = !problem; err.textContent = problem;
|
|
362
|
+
okBtn.disabled = !!problem || (after && !state.cands);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── DOM -> state ─────────────────────────────────────────────────────────
|
|
366
|
+
seg.addEventListener('click', (e) => { const b = e.target.closest('button[data-preset]'); if (b) { state.preset = b.dataset.preset; paint(); } });
|
|
367
|
+
days.addEventListener('click', (e) => {
|
|
368
|
+
const b = e.target.closest('button[data-day]'); if (!b) return;
|
|
369
|
+
if (state.weekdays.has(b.dataset.day)) state.weekdays.delete(b.dataset.day); else state.weekdays.add(b.dataset.day);
|
|
370
|
+
paint();
|
|
371
|
+
});
|
|
372
|
+
const bind = (node, key, conv = (v) => v) => node.addEventListener('input', () => { state[key] = conv(node.value); paint(); });
|
|
373
|
+
bind(dateIn, 'date'); bind(timeIn, 'time'); bind(intervalIn, 'interval', Number); bind(untilIn, 'until'); bind(countIn, 'count', Number);
|
|
374
|
+
bind(failIn, 'maxFailures', Number);
|
|
375
|
+
const bindSel = (wrap, key, conv = (v) => v) => wrap.firstChild.addEventListener('change', () => { state[key] = conv(wrap.firstChild.value); paint(); });
|
|
376
|
+
bindSel(unitSel, 'unit'); bindSel(endSel, 'endType'); bindSel(overlapSel, 'overlap'); bindSel(missedSel, 'ifMissed');
|
|
377
|
+
bindSel(graceSel, 'graceMin', Number);
|
|
378
|
+
bindSel(monthSel, 'monthDay', (v) => (v === 'last' ? 'last' : Number(v)));
|
|
379
|
+
kindSeg.addEventListener('click', (e) => {
|
|
380
|
+
const b = e.target.closest('button[data-kind]');
|
|
381
|
+
if (!b || b.dataset.kind === state.kind) return;
|
|
382
|
+
state.kind = b.dataset.kind; paint();
|
|
383
|
+
});
|
|
384
|
+
afterSel.addEventListener('change', () => { state.after = (afterSel._all || []).find((a) => afterKey(a) === afterSel.value) || null; paint(); });
|
|
385
|
+
const flipAny = () => { state.afterAny = !state.afterAny; paint(); };
|
|
386
|
+
afterAny.addEventListener('click', flipAny);
|
|
387
|
+
afterAny.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); flipAny(); } });
|
|
388
|
+
tzIn.addEventListener('change', () => { if (isValidTimeZone(tzIn.value.trim())) { tz = tzIn.value.trim(); tzIn.hidden = true; } paint(); });
|
|
389
|
+
|
|
390
|
+
// ── lifecycle ────────────────────────────────────────────────────────────
|
|
391
|
+
const prevFocus = document.activeElement;
|
|
392
|
+
const tick = setInterval(paint, 30000); // keep "Starts in …" and the quick chips honest
|
|
393
|
+
function finish(result) {
|
|
394
|
+
clearInterval(tick);
|
|
395
|
+
document.removeEventListener('keydown', onKey, true);
|
|
396
|
+
modal.remove();
|
|
397
|
+
openSheet = null;
|
|
398
|
+
if (prevFocus && typeof prevFocus.focus === 'function') { try { prevFocus.focus(); } catch { /* gone */ } }
|
|
399
|
+
resolve(result);
|
|
400
|
+
}
|
|
401
|
+
function onKey(e) {
|
|
402
|
+
if (e.key === 'Escape') { e.stopPropagation(); finish(null); }
|
|
403
|
+
}
|
|
404
|
+
document.addEventListener('keydown', onKey, true);
|
|
405
|
+
modal.addEventListener('click', (e) => { if (e.target === modal) finish(null); });
|
|
406
|
+
cancelBtn.addEventListener('click', () => finish(null));
|
|
407
|
+
closeBtn.addEventListener('click', () => finish(null));
|
|
408
|
+
okBtn.addEventListener('click', () => {
|
|
409
|
+
const maxFailures = Number.isInteger(state.maxFailures) && state.maxFailures >= 0 && state.maxFailures <= 100 ? state.maxFailures : defaults.maxFailures;
|
|
410
|
+
const common = { ifMissed: state.ifMissed, graceMin: state.graceMin };
|
|
411
|
+
if (state.kind === 'after') {
|
|
412
|
+
if (!state.after) return paint();
|
|
413
|
+
return finish({ after: { kind: state.after.kind, id: state.after.id, title: state.after.title }, afterPolicy: state.afterAny ? 'any' : 'done' });
|
|
414
|
+
}
|
|
415
|
+
if (state.preset === 'once') {
|
|
416
|
+
const ms = onceInstant();
|
|
417
|
+
if (ms == null || ms <= Date.now()) return paint();
|
|
418
|
+
return finish({ scheduledFor: new Date(ms).toISOString(), ...common });
|
|
419
|
+
}
|
|
420
|
+
const norm = currentRule();
|
|
421
|
+
if (!norm.ok) return paint();
|
|
422
|
+
finish({ repeat: { rule: norm.rule, overlap: state.overlap, maxFailures }, ...common });
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
openSheet = finish;
|
|
426
|
+
document.body.append(modal);
|
|
427
|
+
paint();
|
|
428
|
+
(showKind ? kindSeg.querySelector('button.on') : showPresets ? seg.querySelector('button.on') : dateIn)?.focus();
|
|
429
|
+
});
|
|
430
|
+
}
|