@zq-silk/yui 2.17.0 → 2.19.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/dist/cli/commandCatalog.js +2 -1
- package/dist/commands/controllerCommands.js +14 -4
- package/dist/commands/durableJobCommands.js +50 -3
- package/dist/commands/globalRoleCommands.js +18 -12
- package/dist/commands/inboxCommands.js +1 -0
- package/dist/commands/operatorCommands.js +10 -2
- package/dist/controlPlaneCli.js +21 -4
- package/dist/controller/clientRuntime.js +34 -13
- package/dist/controller/controller.js +45 -22
- package/dist/controller/diagnosticJournal.js +214 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +143 -101
- package/dist/controller/globalInputDelivery.js +6 -2
- package/dist/controller/globalRuntimeAttention.js +11 -3
- package/dist/controller/jobControl.js +1 -1
- package/dist/controller/jobDefinitionControl.js +24 -13
- package/dist/controller/jobSupervisor.js +4 -0
- package/dist/controller/messageHealth.js +354 -0
- package/dist/controller/runtime.js +75 -5
- package/dist/controller/taskHostTerminalRecovery.js +11 -0
- package/dist/controller/workerSchedulerStore.js +24 -0
- package/dist/core/boundedRpc.js +4 -0
- package/dist/core/controllerServer.js +1 -1
- package/dist/core/controllerTelemetry.js +92 -19
- package/dist/job/globalShell.js +51 -0
- package/dist/job/jobBusinessResult.js +4 -3
- package/dist/job/jobDefinition.js +6 -1
- package/dist/job/jobRunner.js +9 -0
- package/dist/job/scheduleService.js +2 -0
- package/dist/message/globalInboxRecovery.js +30 -19
- package/dist/message/globalProviderRetry.js +7 -1
- package/dist/runtime/builtinAgentErrorMappers.js +4 -0
- package/dist/runtime/globalSessionHealth.js +39 -0
- package/dist/scheduler/roleRunStall.js +10 -10
- package/dist/storage/contextRecords.js +16 -0
- package/dist/storage/persistenceWorker.js +41 -19
- package/dist/storage/sqliteSchema.js +35 -11
- package/dist/storage/sqliteStore.js +102 -8
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +25 -11
- package/dist/storage/taskReadRevisionSchema.js +39 -0
- package/dist/web/assets/client/app/catalog.js +0 -29
- package/dist/web/assets/client/app/jobs.js +115 -0
- package/dist/web/assets/client/app/main.js +17 -6
- package/dist/web/assets/client/app/manage.js +1 -0
- package/dist/web/assets/client/app/settings.js +1 -5
- package/dist/web/assets/client/i18n/en.js +203 -18
- package/dist/web/assets/client/i18n/zh-CN.js +202 -17
- package/dist/web/assets/client/index.js +2 -0
- package/dist/web/assets/client/layout/manageShell.js +61 -15
- package/dist/web/assets/client/layout/router.js +2 -1
- package/dist/web/assets/client/lib/prefs.js +1 -1
- package/dist/web/assets/client/views/jobs.js +1042 -160
- package/dist/web/assets/client/views/sidebar.js +3 -0
- package/dist/web/assets/shared/manageNav.js +3 -1
- package/dist/web/assets/shell/appRail.js +2 -1
- package/dist/web/assets/shell/manage.js +7 -2
- package/dist/web/assets/shell/models.js +4 -3
- package/dist/web/assets/shell/settingsContent.js +0 -3
- package/dist/web/assets/shell/sidebar.js +0 -1
- package/dist/web/assets/styles/views/jobs.js +301 -12
- package/dist/web/assets/styles/views/sidebar.js +1 -2
- package/dist/web/webServer.js +28 -1
- package/docs/global-shell-jobs.md +78 -0
- package/docs/job-runs.md +6 -3
- package/docs/managed-turn-and-session-runtime.md +170 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +116 -0
- package/docs/release-workflow.md +63 -0
- package/docs/release-workflow.zh-CN.md +44 -0
- package/docs/storage-baseline.md +14 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- package/package.json +1 -1
|
@@ -1,26 +1,108 @@
|
|
|
1
1
|
export const JOBS_SCRIPT = String.raw `
|
|
2
|
-
import { h } from "/assets/js/lib/dom.js";
|
|
2
|
+
import { h, icon } from "/assets/js/lib/dom.js";
|
|
3
|
+
import { formatDateTime } from "/assets/js/lib/format.js";
|
|
3
4
|
import { requestJson, submitMutation } from "/assets/js/lib/api.js";
|
|
5
|
+
import { badge, dot } from "/assets/js/ui/primitives.js";
|
|
4
6
|
import { drawArtifact } from "/assets/js/views/task/files.js";
|
|
5
7
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
8
|
+
// Job definition, Schedule and JobRun are three separate facts and are shown
|
|
9
|
+
// as such: a Schedule's on/off state is planning intent, a Run's status is one
|
|
10
|
+
// execution, and neither borrows Task lifecycle words.
|
|
11
|
+
const SCHEDULE_TONE = { enabled: "ok", disabled: "idle", completed: "idle", "needs-attention": "warn", retired: "idle" };
|
|
12
|
+
const RUN_TONE = { queued: "idle", running: "info", succeeded: "ok", failed: "bad", "timed-out": "bad",
|
|
13
|
+
cancelled: "idle", "unknown-needs-attention": "warn" };
|
|
14
|
+
|
|
15
|
+
// A lossless editor for a deliberately small subset, not a scheduler. Anything
|
|
16
|
+
// else remains an exact expression, validated by the existing server parser.
|
|
17
|
+
export function cronFields(expression) {
|
|
18
|
+
const p = String(expression).trim().split(/\s+/);
|
|
19
|
+
if (p.length !== 5 || !/^\d{1,2}$/.test(p[0]) || !/^\d{1,2}$/.test(p[1])
|
|
20
|
+
|| +p[0] > 59 || +p[1] > 23 || p[3] !== "*") return null;
|
|
21
|
+
const time = p[1].padStart(2, "0") + ":" + p[0].padStart(2, "0");
|
|
22
|
+
if (p[2] === "*" && p[4] === "*") return { frequency: "daily", time };
|
|
23
|
+
if (p[2] === "*" && /^[0-6](,[0-6])*$/.test(p[4]))
|
|
24
|
+
return { frequency: "weekly", time, days: p[4].split(",") };
|
|
25
|
+
if (/^\d{1,2}$/.test(p[2]) && +p[2] >= 1 && +p[2] <= 31 && p[4] === "*")
|
|
26
|
+
return { frequency: "monthly", time, day: +p[2] };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function cronExpression(fields) {
|
|
30
|
+
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(fields.time)) throw new Error("cronTime");
|
|
31
|
+
const [hour, minute] = fields.time.split(":").map(Number);
|
|
32
|
+
if (fields.frequency === "daily") return minute + " " + hour + " * * *";
|
|
33
|
+
if (fields.frequency === "weekly" && fields.days?.length && fields.days.every(day => /^[0-6]$/.test(String(day))))
|
|
34
|
+
return minute + " " + hour + " * * " + fields.days.join(",");
|
|
35
|
+
if (fields.frequency === "monthly" && Number.isInteger(+fields.day) && +fields.day >= 1 && +fields.day <= 31)
|
|
36
|
+
return minute + " " + hour + " " + fields.day + " * *";
|
|
37
|
+
throw new Error(fields.frequency === "weekly" ? "cronDays" : "cronDay");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The plan state a reader acts on. A builtin Project sync keeps the user's
|
|
41
|
+
// on/off preference separate from its eligibility state; show the preference.
|
|
42
|
+
export function scheduleState(schedule) {
|
|
43
|
+
if (schedule.builtin && schedule.state !== "needs-attention" && schedule.state !== "retired")
|
|
44
|
+
return schedule.builtin.enabled ? "enabled" : "disabled";
|
|
45
|
+
return schedule.state;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// One server-fixed scope (global, or one owning Task). Refresh only reads
|
|
49
|
+
// facts; it never admits due work, derives nextDue or replaces a selected
|
|
50
|
+
// exact Run with latest.
|
|
51
|
+
// options.index separate element for the Job index (the Jobs entry);
|
|
52
|
+
// without it, the index sits beside the detail (Task tab)
|
|
53
|
+
// options.navigate ({ job }) a reader picked a Job: the caller records it
|
|
54
|
+
// in history and calls select(); without it the choice
|
|
55
|
+
// replaces the current URL
|
|
56
|
+
// options.sync ({ state, text, at }) the last read, shown by the caller
|
|
57
|
+
// (the Jobs list head); without it the receipt line says it
|
|
58
|
+
// options.notice one-time explanatory notice (for example a moved link)
|
|
59
|
+
// options.locale () => locale for absolute times
|
|
60
|
+
// options.pin ({ job, jobRun, artifact }, step) the view settled on an exact
|
|
61
|
+
// selection; the caller records it in its own route.
|
|
62
|
+
// Without it the selection replaces the current URL
|
|
63
|
+
// The returned dispose() retires the view: no later read, write receipt or
|
|
64
|
+
// timer of a retired view touches the URL or the page that replaced it.
|
|
65
|
+
export function mountJobs(root, t, taskId, options = {}) {
|
|
9
66
|
const base = taskId ? "/api/tasks/" + encodeURIComponent(taskId) : "/api/settings";
|
|
10
67
|
const copy = key => t("jobs." + key);
|
|
11
68
|
const receipt = h("p.settings-receipt", { role: "status", "aria-live": "polite" });
|
|
12
69
|
const list = h("div.jobs-list");
|
|
70
|
+
const locale = () => (options.locale ? options.locale() : undefined);
|
|
71
|
+
const when = iso => iso ? formatDateTime(iso, locale()) : null;
|
|
72
|
+
const indexSearch = h("input", { type: "search", autocomplete: "off", spellcheck: "false",
|
|
73
|
+
placeholder: copy("search"), "aria-label": copy("search") });
|
|
74
|
+
const indexCount = h("p.jobs-index-count", { "aria-live": "polite" });
|
|
75
|
+
let filterText = "";
|
|
76
|
+
indexSearch.addEventListener("input", () => { filterText = indexSearch.value.trim().toLowerCase(); drawIndex(); });
|
|
13
77
|
const detail = h("section.jobs-detail", { "aria-label": copy("details") });
|
|
14
|
-
|
|
78
|
+
// Job and plan editors open as a modal dialog, like the workbench's new
|
|
79
|
+
// Task: the page under it keeps its place. Escape asks before discarding.
|
|
80
|
+
const editor = h("dialog.dialog.job-dialog", { "aria-label": copy("newJob") });
|
|
81
|
+
const editorReceipt = h("p.settings-receipt.job-dialog-receipt", { role: "status", "aria-live": "polite" });
|
|
82
|
+
editor.addEventListener("cancel", event => {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
if (!pending && guardDraft()) closeEditor();
|
|
85
|
+
});
|
|
86
|
+
let readFailed = false;
|
|
87
|
+
const editorOpen = () => !!editor.children?.length;
|
|
88
|
+
const showEditor = () => { if (editorOpen() && !editor.open && typeof editor.showModal === "function" && root.isConnected !== false) editor.showModal(); };
|
|
15
89
|
const params = new URLSearchParams(location.search);
|
|
16
90
|
let jobs = [], schedules = [], selected = params.get("job") || undefined,
|
|
17
|
-
selectedRun = params.get("jobRun") || undefined, generation = 0, runGeneration = 0, pending = false, dirty = false
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
91
|
+
selectedRun = params.get("jobRun") || undefined, generation = 0, runGeneration = 0, pending = false, dirty = false,
|
|
92
|
+
disposed = false;
|
|
93
|
+
const artifactState = { reference: parseArtifact(params.get("artifact")) };
|
|
94
|
+
// The exact selection is written only by a live view, and in the Jobs entry
|
|
95
|
+
// only through its owner (which also knows whether Jobs is still shown).
|
|
96
|
+
// step: the reader opened or closed a Run in place; the Jobs entry records
|
|
97
|
+
// it as a history step so Back and Forward walk the Runs read.
|
|
98
|
+
function pinSelection(step = false) {
|
|
99
|
+
if (disposed || (!options.pin && root.isConnected === false)) return;
|
|
100
|
+
const artifact = artifactState.reference ? JSON.stringify(artifactState.reference) : null;
|
|
101
|
+
if (options.pin) { options.pin({ job: selected || null, jobRun: selectedRun || null, artifact }, step); return; }
|
|
21
102
|
const url = new URL(location.href);
|
|
22
103
|
if (selected) url.searchParams.set("job", selected); else url.searchParams.delete("job");
|
|
23
104
|
if (selectedRun) url.searchParams.set("jobRun", selectedRun); else url.searchParams.delete("jobRun");
|
|
105
|
+
if (artifact) url.searchParams.set("artifact", artifact); else url.searchParams.delete("artifact");
|
|
24
106
|
history.replaceState(history.state, "", url);
|
|
25
107
|
}
|
|
26
108
|
const blocked = new Set();
|
|
@@ -29,17 +111,243 @@ export function mountJobs(root, t, taskId) {
|
|
|
29
111
|
type: "button", onclick: action
|
|
30
112
|
}, text);
|
|
31
113
|
const json = value => h("pre.settings-observation", null, JSON.stringify(value, null, 2));
|
|
32
|
-
|
|
33
|
-
|
|
114
|
+
// The exact record stays one disclosure away: an advanced, copyable view,
|
|
115
|
+
// never the default body of a section.
|
|
116
|
+
const raw = (name, value) => h("details.job-raw", null, h("summary", null, h("span", null, name),
|
|
117
|
+
h("span.job-raw-hint", null, "JSON")), h("div.job-raw-body", null,
|
|
118
|
+
h("div.job-raw-tools", null, copyButton(JSON.stringify(value, null, 2), copy("copyJson"), true)), json(value)));
|
|
119
|
+
const ago = iso => relative(iso, locale());
|
|
120
|
+
// ---- Small readers shared by the detail, the Run panel and the editor ----
|
|
121
|
+
const isEmpty = value => value === undefined || value === null || value === ""
|
|
122
|
+
|| (Array.isArray(value) && !value.length) || (isPlain(value) && !Object.keys(value).length);
|
|
123
|
+
// A JSON object, as opposed to a rendered node (DOM, or a test double).
|
|
124
|
+
const isPlain = value => !!value && typeof value === "object" && !Array.isArray(value) && value.nodeType === undefined && !value.spec;
|
|
125
|
+
function relative(iso, loc) {
|
|
126
|
+
const at = iso ? Date.parse(iso) : NaN;
|
|
127
|
+
if (!Number.isFinite(at)) return "";
|
|
128
|
+
const seconds = (at - Date.now()) / 1000, size = Math.abs(seconds);
|
|
129
|
+
const units = [["year", 31536000], ["month", 2592000], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]];
|
|
130
|
+
try {
|
|
131
|
+
const format = new Intl.RelativeTimeFormat(loc || undefined, { numeric: "auto", style: "short" });
|
|
132
|
+
const unit = units.find(entry => size >= entry[1]);
|
|
133
|
+
return unit ? format.format(Math.round(seconds / unit[1]), unit[0]) : format.format(Math.round(seconds), "second");
|
|
134
|
+
} catch { return ""; }
|
|
135
|
+
}
|
|
136
|
+
// A copy control never moves focus or selection; a refused clipboard falls
|
|
137
|
+
// back to a prompt the reader can copy from by hand.
|
|
138
|
+
function copyButton(text, name, withText = false) {
|
|
139
|
+
const node = h("button.job-copy" + (withText ? ".job-copy-text" : ""), { type: "button", title: name,
|
|
140
|
+
"aria-label": withText ? null : name, onclick: async event => {
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
try { await navigator.clipboard.writeText(text); node.classList.add("is-copied"); node.title = copy("copied");
|
|
143
|
+
setTimeout(() => { node.classList.remove("is-copied"); node.title = name; }, 1400); }
|
|
144
|
+
catch { window.prompt(copy("copyManual"), text); }
|
|
145
|
+
} }, icon("copy"), withText ? h("span", null, name) : null);
|
|
146
|
+
return node;
|
|
147
|
+
}
|
|
148
|
+
function shortId(id) {
|
|
149
|
+
const value = String(id ?? "");
|
|
150
|
+
return value.length > 22 ? value.slice(0, 12) + "…" + value.slice(-6) : value;
|
|
151
|
+
}
|
|
152
|
+
function idChip(id, copyable = true) {
|
|
153
|
+
if (!id) return "—";
|
|
154
|
+
return h("span.job-id", null, h("code.id", { title: String(id) }, shortId(id)),
|
|
155
|
+
copyable ? copyButton(String(id), copy("copy") + " · " + id) : null);
|
|
156
|
+
}
|
|
157
|
+
function stamp(iso) {
|
|
158
|
+
if (!iso) return "—";
|
|
159
|
+
return h("time.job-time", { dateTime: iso, title: iso }, when(iso), h("span.job-time-ago", null, ago(iso)));
|
|
160
|
+
}
|
|
161
|
+
function duration(ms) {
|
|
162
|
+
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
163
|
+
if (ms < 1000) return Math.round(ms) + " ms";
|
|
164
|
+
if (ms < 60000) return (ms / 1000).toFixed(ms < 10000 ? 1 : 0) + " s";
|
|
165
|
+
const minutes = Math.floor(ms / 60000);
|
|
166
|
+
if (minutes < 60) return minutes + "m " + Math.round((ms % 60000) / 1000) + "s";
|
|
167
|
+
const hours = Math.floor(minutes / 60);
|
|
168
|
+
return hours < 48 ? hours + "h " + (minutes % 60) + "m" : Math.floor(hours / 24) + "d " + (hours % 24) + "h";
|
|
169
|
+
}
|
|
170
|
+
function runSpan(run) {
|
|
171
|
+
const start = Date.parse(run.startedAt || run.attempts?.[0]?.createdAt || run.createdAt);
|
|
172
|
+
const end = Date.parse(run.terminalAt || run.finishedAt || "");
|
|
173
|
+
return Number.isFinite(start) && Number.isFinite(end) ? end - start : null;
|
|
174
|
+
}
|
|
175
|
+
// A plain period in the reader's language ("6 小时", "6 hours").
|
|
176
|
+
function period(ms) {
|
|
177
|
+
const units = [["day", 86400000], ["hour", 3600000], ["minute", 60000], ["second", 1000]];
|
|
178
|
+
const unit = units.find(([, size]) => ms >= size && ms % size === 0) || ["millisecond", 1];
|
|
179
|
+
try { return new Intl.NumberFormat(locale() || undefined, { style: "unit", unit: unit[0], unitDisplay: "long" }).format(ms / unit[1]); }
|
|
180
|
+
catch { return ms + " ms"; }
|
|
181
|
+
}
|
|
182
|
+
function kvList(rows, className = "") {
|
|
183
|
+
const items = rows.filter(Boolean);
|
|
184
|
+
return h("dl.job-kv" + className, null, items.map(([name, value]) => h("div.job-kv-row", null,
|
|
185
|
+
h("dt", null, name), h("dd", null, isEmpty(value) || value === "—" ? h("span.job-empty", null, "—") : isPlain(value) ? valueView(value) : value))));
|
|
186
|
+
}
|
|
187
|
+
function section(title, body, extra) {
|
|
188
|
+
return h("section.job-section", null, h("header.job-section-head", null, h("h4", null, title), extra || null), body);
|
|
189
|
+
}
|
|
190
|
+
// Any JSON value, readable: scalars inline, arrays as lists, objects as
|
|
191
|
+
// nested label/value rows. Nothing is dropped; deep nesting folds.
|
|
192
|
+
function valueView(value, depth = 0) {
|
|
193
|
+
if (value === null || value === undefined) return h("span.job-empty", null, "null");
|
|
194
|
+
if (typeof value === "boolean") return h("code.job-bool", null, String(value));
|
|
195
|
+
if (typeof value === "number") return h("code", null, String(value));
|
|
196
|
+
if (typeof value === "string") return value === "" ? h("span.job-empty", null, copy("cfg.emptyText"))
|
|
197
|
+
: value.length > 80 || value.includes("\n") ? h("pre.job-prose", null, value) : h("span.job-text", null, value);
|
|
198
|
+
if (Array.isArray(value)) {
|
|
199
|
+
if (!value.length) return h("span.job-empty", null, copy("cfg.emptyList"));
|
|
200
|
+
if (value.every(item => item === null || typeof item !== "object"))
|
|
201
|
+
return h("span.job-chips", null, value.map(item => h("code.job-chip", null, String(item))));
|
|
202
|
+
return h("ol.job-list", null, value.map(item => h("li", null, valueView(item, depth + 1))));
|
|
203
|
+
}
|
|
204
|
+
const keys = Object.keys(value);
|
|
205
|
+
if (!keys.length) return h("span.job-empty", null, copy("cfg.noParams"));
|
|
206
|
+
const rows = keys.map(key => [h("code.job-key", null, key), valueView(value[key], depth + 1)]);
|
|
207
|
+
if (depth < 2) return kvList(rows, ".is-nested");
|
|
208
|
+
return h("details.job-fold", null, h("summary", null, fill(copy("cfg.fields"), { count: keys.length })), kvList(rows, ".is-nested"));
|
|
209
|
+
}
|
|
210
|
+
function envView(env) {
|
|
211
|
+
const keys = env && typeof env === "object" ? Object.keys(env) : [];
|
|
212
|
+
if (!keys.length) return h("span.job-empty", null, copy("cfg.noEnv"));
|
|
213
|
+
return h("span.job-chips", null, keys.map(key => h("code.job-chip", { title: key + "=" + env[key] },
|
|
214
|
+
h("span.job-chip-key", null, key), "=", String(env[key]))));
|
|
215
|
+
}
|
|
216
|
+
// Friendly names for the documented nested fields; anything else keeps its key.
|
|
217
|
+
const FIELD = { title: "name", description: "cfg.description", projectBindings: "cfg.bindings", taskId: "cfg.task",
|
|
218
|
+
workItemId: "cfg.workItem", roleName: "cfg.role", agentId: "cfg.agent", ownerRunId: "cfg.ownerRun",
|
|
219
|
+
revision: "revision", type: "type", integrationAttemptId: "cfg.attempt" };
|
|
220
|
+
function fieldRows(object, skip = []) {
|
|
221
|
+
if (!object || typeof object !== "object" || Array.isArray(object)) return [[copy("cfg.value"), valueView(object)]];
|
|
222
|
+
return Object.keys(object).filter(key => !skip.includes(key)).map(key => [FIELD[key] ? copy(FIELD[key]) : h("code.job-key", null, key),
|
|
223
|
+
key === "taskId" && typeof object[key] === "string" ? h("a", { href: "/?task=" + encodeURIComponent(object[key]) }, object[key])
|
|
224
|
+
: valueView(object[key], 1)]);
|
|
225
|
+
}
|
|
226
|
+
function stepsView(steps) {
|
|
227
|
+
if (!Array.isArray(steps)) return section(copy("steps"), valueView(steps));
|
|
228
|
+
return section(copy("steps") + " · " + steps.length, h("ol.job-steps", null, steps.map((step, index) => {
|
|
229
|
+
const s = step && typeof step === "object" ? step : { command: String(step) };
|
|
230
|
+
const known = ["name", "command", "timeoutMs", "cwd", "env", "argv"];
|
|
231
|
+
const extra = Object.keys(s).filter(key => !known.includes(key));
|
|
232
|
+
const chips = [
|
|
233
|
+
s.timeoutMs !== undefined ? h("span.job-tag", null, copy("cfg.timeout") + " " + (Number.isFinite(s.timeoutMs) ? period(s.timeoutMs) : String(s.timeoutMs))) : null,
|
|
234
|
+
s.cwd ? h("span.job-tag", { title: s.cwd }, copy("cfg.cwd") + " " + s.cwd) : null,
|
|
235
|
+
s.argv ? h("span.job-tag", null, "argv · " + (Array.isArray(s.argv) ? s.argv.length : "?")) : null
|
|
236
|
+
].filter(Boolean);
|
|
237
|
+
return h("li.job-step", null,
|
|
238
|
+
h("div.job-step-head", null, h("span.job-step-index", null, String(index + 1)),
|
|
239
|
+
h("code.job-step-name", null, s.name || copy("cfg.unnamed")), chips),
|
|
240
|
+
s.command !== undefined ? h("div.job-command", null, h("pre", null, String(s.command)), copyButton(String(s.command), copy("copyCommand"))) : null,
|
|
241
|
+
s.env && !isEmpty(s.env) ? h("div.job-step-env", null, h("span.job-step-label", null, copy("cfg.env")), envView(s.env)) : null,
|
|
242
|
+
s.argv && !isEmpty(s.argv) ? h("div.job-step-env", null, h("span.job-step-label", null, "argv"), valueView(s.argv)) : null,
|
|
243
|
+
extra.length ? kvList(extra.map(key => [h("code.job-key", null, key), valueView(s[key], 1)]), ".is-nested") : null);
|
|
244
|
+
})));
|
|
245
|
+
}
|
|
246
|
+
// The configuration as a reader needs it: per-type names and key fields
|
|
247
|
+
// first, steps and parameters grouped, unknown fields kept.
|
|
248
|
+
// lifted: the description is already shown above it (see purpose()).
|
|
249
|
+
function configView(config, lifted = false) {
|
|
250
|
+
const c = config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
|
251
|
+
const used = new Set(["kind"]);
|
|
252
|
+
const has = key => Object.prototype.hasOwnProperty.call(c, key);
|
|
253
|
+
const take = key => { used.add(key); return c[key]; };
|
|
254
|
+
const rows = [[copy("executor"), h("span.job-kind", null, label("jobs.kind", c.kind))]];
|
|
255
|
+
const blocks = [];
|
|
256
|
+
if (c.kind === "builtin") {
|
|
257
|
+
rows.push([copy("action"), label("jobs.action", take("action"))]);
|
|
258
|
+
if (has("projectId")) rows.push([copy("project"), take("projectId")]);
|
|
259
|
+
if (has("input")) { const value = take("input"); if (!isEmpty(value)) rows.push([copy("cfg.input"), valueView(value)]); }
|
|
260
|
+
} else if (c.kind === "shell") {
|
|
261
|
+
if (has("projectId")) rows.push([copy("project"), take("projectId")]);
|
|
262
|
+
if (has("owner")) rows.push([copy("cfg.owner"), ownerText(take("owner"))]);
|
|
263
|
+
if (has("workspace")) rows.push([copy("cfg.workspace"), c.workspace ? h("span.job-path", null, h("code", { title: c.workspace }, take("workspace")),
|
|
264
|
+
copyButton(String(c.workspace), copy("copy"))) : take("workspace")]);
|
|
265
|
+
if (has("head")) rows.push([copy("cfg.head"), c.head ? idChip(take("head")) : take("head")]);
|
|
266
|
+
if (has("env")) rows.push([copy("cfg.env"), envView(take("env"))]);
|
|
267
|
+
if (has("steps")) blocks.push(stepsView(take("steps")));
|
|
268
|
+
if (has("files")) blocks.push(section(copy("form.scripts"), take("files").map(file =>
|
|
269
|
+
h("details.job-raw", null, h("summary", null, file.path), h("div.job-command", null, h("pre", null, file.content))))));
|
|
270
|
+
} else if (c.kind === "task") {
|
|
271
|
+
if (has("goal")) rows.push([copy("cfg.goal"), label("jobs.goal", take("goal"))]);
|
|
272
|
+
if (has("target")) {
|
|
273
|
+
const target = take("target");
|
|
274
|
+
rows.push([copy("cfg.target"), label("jobs.target", target?.kind)], ...fieldRows(target, lifted ? ["kind", "description"] : ["kind"]));
|
|
275
|
+
}
|
|
276
|
+
} else if (c.kind === "agent") {
|
|
277
|
+
if (has("target")) {
|
|
278
|
+
const target = take("target");
|
|
279
|
+
rows.push([copy("cfg.target"), label("jobs.target", target?.kind)], ...fieldRows(target, ["kind"]));
|
|
280
|
+
}
|
|
281
|
+
if (has("concurrency")) rows.push([copy("cfg.concurrency"), label("jobs.concurrency", take("concurrency"))]);
|
|
282
|
+
if (has("input") && lifted && typeof c.input === "string") take("input");
|
|
283
|
+
else if (has("input")) blocks.push(section(copy("cfg.agentInput"), typeof c.input === "string"
|
|
284
|
+
? h("div.job-command.is-prose", null, h("pre", null, take("input") || "—"), copyButton(String(c.input), copy("copy")))
|
|
285
|
+
: valueView(take("input"))));
|
|
286
|
+
}
|
|
287
|
+
const rest = Object.keys(c).filter(key => !used.has(key));
|
|
288
|
+
if (rest.length) blocks.push(section(copy("cfg.other"), kvList(rest.map(key => [h("code.job-key", null, key), valueView(c[key], 1)]))));
|
|
289
|
+
return h("div.job-config", null, kvList(rows), blocks);
|
|
290
|
+
}
|
|
291
|
+
function ownerText(owner) {
|
|
292
|
+
if (!owner || typeof owner !== "object") return valueView(owner);
|
|
293
|
+
if (owner.kind === "task") return copy("cfg.ownerTask");
|
|
294
|
+
if (owner.kind === "work-item") return h("span", null, "WorkItem ", h("code", null, String(owner.workItemId ?? "—")));
|
|
295
|
+
return valueView(owner);
|
|
296
|
+
}
|
|
297
|
+
function stepBadge(step) {
|
|
298
|
+
const tone = step.timedOut ? "bad" : step.exitCode === 0 ? "ok" : step.signal ? "warn" : step.exitCode === null || step.exitCode === undefined ? "idle" : "bad";
|
|
299
|
+
const name = step.timedOut ? label("job", "timed-out") : step.exitCode === 0 ? label("job", "succeeded")
|
|
300
|
+
: step.signal ? String(step.signal) : step.exitCode === null || step.exitCode === undefined ? "—" : label("job", "failed");
|
|
301
|
+
return badge(name, tone, { dot: true });
|
|
302
|
+
}
|
|
303
|
+
// Schedule rules in words when they are simple; the exact rule stays visible.
|
|
304
|
+
function ruleText(rule) {
|
|
305
|
+
if (!rule) return "—";
|
|
306
|
+
if (rule.kind === "interval") return fill(copy("ruleEvery"), { period: period(Number(rule.everyMs)) });
|
|
307
|
+
if (rule.kind === "once") return fill(copy("ruleOnce"), { at: when(rule.at) || "—" });
|
|
308
|
+
const visual = cronFields(rule.expression);
|
|
309
|
+
if (visual?.frequency === "monthly") return fill(copy("form.monthRule"), { day: visual.day, time: visual.time });
|
|
310
|
+
const parts = String(rule.expression || "").trim().split(/\s+/);
|
|
311
|
+
if (parts.length === 5 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1]) && parts[2] === "*" && parts[3] === "*") {
|
|
312
|
+
const time = parts[1].padStart(2, "0") + ":" + parts[0].padStart(2, "0");
|
|
313
|
+
if (parts[4] === "*") return fill(copy("ruleDaily"), { time });
|
|
314
|
+
if (/^[0-7](?:-[0-7])?(?:,[0-7](?:-[0-7])?)*$/.test(parts[4])) {
|
|
315
|
+
const values = parts[4].split(",").flatMap(part => {
|
|
316
|
+
const [first, last = first] = part.split("-").map(Number);
|
|
317
|
+
return last >= first ? Array.from({ length: last - first + 1 }, (_, i) => first + i) : [];
|
|
318
|
+
});
|
|
319
|
+
if (!values.length) return copy("form.customCron");
|
|
320
|
+
const days = [...new Set(values.map(day => day % 7))].map(day => {
|
|
321
|
+
try { return new Intl.DateTimeFormat(locale() || undefined, { weekday: "short", timeZone: "UTC" })
|
|
322
|
+
.format(new Date(Date.UTC(2026, 9, 18 + (Number(day) % 7)))); } catch { return day; }
|
|
323
|
+
});
|
|
324
|
+
return fill(copy("ruleWeekly"), { days: days.join(" "), time });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return copy("form.customCron");
|
|
328
|
+
}
|
|
34
329
|
function field(name, control) { return h("label.field", null, h("span", null, name), control); }
|
|
35
330
|
function input(value = "", attrs = {}) { return h("input", { value, ...attrs }); }
|
|
36
|
-
function choose(values, value) {
|
|
37
|
-
const node = h("select", null, values.map(v => h("option", { value: v }, v)));
|
|
331
|
+
function choose(values, value, name = v => v) {
|
|
332
|
+
const node = h("select", null, values.map(v => h("option", { value: v }, name(v))));
|
|
38
333
|
node.value = value || values[0]; return node;
|
|
39
334
|
}
|
|
40
335
|
function guardDraft() { return !dirty || window.confirm(copy("discard")); }
|
|
41
|
-
function closeEditor() {
|
|
42
|
-
|
|
336
|
+
function closeEditor() {
|
|
337
|
+
if (editor.open && typeof editor.close === "function") editor.close();
|
|
338
|
+
editor.replaceChildren(); dirty = false; root.dataset.unsent = "false";
|
|
339
|
+
}
|
|
340
|
+
function status(text, error = false) {
|
|
341
|
+
const node = editorOpen() ? editorReceipt : receipt;
|
|
342
|
+
node.textContent = text; node.classList.toggle("settings-error", error);
|
|
343
|
+
}
|
|
344
|
+
// The routine read state (loading, last read, stale) belongs to the caller's
|
|
345
|
+
// sync indicator when it has one; a failure also stays on the page.
|
|
346
|
+
function observe(state, text) {
|
|
347
|
+
if (!options.sync) { status(text, state === "error"); return; }
|
|
348
|
+
options.sync({ state, text, at: state === "ok" ? new Date().toISOString() : null });
|
|
349
|
+
if (state === "error") { readFailed = true; status(text, true); }
|
|
350
|
+
}
|
|
43
351
|
async function mutate(family, payload, after) {
|
|
44
352
|
const key = base + "/" + family + "/" + payload.id;
|
|
45
353
|
if (pending || blocked.has(key)) { status(copy("unknown"), true); return; }
|
|
@@ -51,6 +359,9 @@ export function mountJobs(root, t, taskId) {
|
|
|
51
359
|
status(copy("submitting"));
|
|
52
360
|
try {
|
|
53
361
|
const result = await submitMutation(key, base + "/" + family, payload);
|
|
362
|
+
// A retired view's receipt has no page left to update; the shared
|
|
363
|
+
// submission layer still remembers an unresolved write by its key.
|
|
364
|
+
if (disposed) return;
|
|
54
365
|
if (result.status === "saved-unregistered") {
|
|
55
366
|
status(copy("registerHelp") + "\n" + result.error, true);
|
|
56
367
|
await refresh(false);
|
|
@@ -63,6 +374,7 @@ export function mountJobs(root, t, taskId) {
|
|
|
63
374
|
await refresh(false);
|
|
64
375
|
} catch (error) {
|
|
65
376
|
if (error.disposition !== "not-submitted") blocked.add(key);
|
|
377
|
+
readFailed = false;
|
|
66
378
|
status(error.message + (blocked.has(key) ? "\n" + copy("unknown") : ""), true);
|
|
67
379
|
} finally {
|
|
68
380
|
pending = false;
|
|
@@ -70,129 +382,414 @@ export function mountJobs(root, t, taskId) {
|
|
|
70
382
|
controls.forEach((node, index) => { node.disabled = disabled[index]; });
|
|
71
383
|
}
|
|
72
384
|
}
|
|
73
|
-
function form(title, contents, submit) {
|
|
385
|
+
function form(title, contents, submit, saveButton = h("button.btn.btn-primary", { type: "submit" }, copy("save"))) {
|
|
74
386
|
if (!guardDraft()) return;
|
|
75
387
|
closeEditor();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
388
|
+
receipt.textContent = ""; receipt.classList.toggle("settings-error", false);
|
|
389
|
+
editorReceipt.textContent = ""; editorReceipt.classList.toggle("settings-error", false);
|
|
390
|
+
const node = h("form.dialog-body.job-form", null,
|
|
391
|
+
h("header.dialog-head", null, h("div", null, h("h2", null, title),
|
|
392
|
+
h("p.dialog-sub", null, copy("ownerShort") + " · " + (taskId ? copy("ownerTask") + " " + taskId : copy("ownerGlobal")))),
|
|
393
|
+
h("button.icon-btn", { type: "button", title: copy("close"), "aria-label": copy("close"),
|
|
394
|
+
onclick: () => { if (!pending && guardDraft()) closeEditor(); } }, icon("close"))),
|
|
395
|
+
contents, editorReceipt,
|
|
396
|
+
h("div.dialog-actions", null, button(copy("close"), () => { if (!pending && guardDraft()) closeEditor(); }),
|
|
397
|
+
saveButton));
|
|
398
|
+
const markDirty = () => { dirty = true; root.dataset.unsent = "true"; clearEditorError(); };
|
|
80
399
|
node.addEventListener("input", markDirty);
|
|
81
400
|
node.addEventListener("change", markDirty);
|
|
82
401
|
node.addEventListener("submit", event => {
|
|
83
402
|
event.preventDefault();
|
|
84
403
|
try { submit(); } catch (error) { status(error.message, true); }
|
|
85
404
|
});
|
|
405
|
+
editor.setAttribute?.("aria-label", title);
|
|
86
406
|
editor.append(node);
|
|
87
|
-
|
|
407
|
+
showEditor();
|
|
408
|
+
node.querySelector("input:not([disabled]),select:not([disabled]),textarea")?.focus();
|
|
409
|
+
}
|
|
410
|
+
function clearEditorError() {
|
|
411
|
+
editorReceipt.textContent = ""; editorReceipt.classList.toggle("settings-error", false);
|
|
88
412
|
}
|
|
89
413
|
function editJob(job) {
|
|
90
|
-
const id = input(job?.id || "", { required: true, pattern: "[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", disabled: !!job });
|
|
414
|
+
const id = input(job?.id || newId("job"), { required: true, pattern: "[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", disabled: !!job });
|
|
91
415
|
const title = input(job?.title || "", { required: true });
|
|
92
|
-
const kind = choose(taskId ? ["shell", "task", "agent"] : ["builtin", "task", "agent"], job?.configuration.kind);
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
controlled.hidden = kind.value !== "builtin"; configured.hidden = !controlled.hidden;
|
|
104
|
-
project.disabled = controlled.hidden || action.value !== "project.refresh"; project.required = !project.disabled;
|
|
105
|
-
configuration.required = !configured.hidden;
|
|
416
|
+
const kind = choose(taskId ? ["shell", "task", "agent"] : ["shell", "builtin", "task", "agent"], job?.configuration.kind, v => label("jobs.kind", v));
|
|
417
|
+
const newDefinitionId = newId("job");
|
|
418
|
+
const kindNotice = h("p.job-note", { role: "status", "aria-live": "polite" });
|
|
419
|
+
const saveButton = h("button.btn.btn-primary", { type: "submit" });
|
|
420
|
+
const changesKind = () => !!job && kind.value !== job.configuration.kind;
|
|
421
|
+
const seeds = {
|
|
422
|
+
builtin: { kind: "builtin", action: "system.noop", input: {} },
|
|
423
|
+
shell: taskId ? { kind: "shell", owner: { kind: "task" }, projectId: "", head: "", workspace: "", env: {},
|
|
424
|
+
steps: [{ name: "check", command: "" }] } : { kind: "shell", env: {}, files: [], steps: [{ name: "run", command: "" }] },
|
|
425
|
+
task: { kind: "task", goal: "delivery", target: { kind: "new", title: "", description: "", projectBindings: [] } },
|
|
426
|
+
agent: { kind: "agent", target: { kind: "job", agentId: "" }, input: "", concurrency: "serial" }
|
|
106
427
|
};
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
428
|
+
const drafts = new Map();
|
|
429
|
+
const configured = h("div.job-field-stack");
|
|
430
|
+
let active;
|
|
431
|
+
function selectKind() {
|
|
432
|
+
active = drafts.get(kind.value);
|
|
433
|
+
if (!active) {
|
|
434
|
+
const original = job?.configuration.kind === kind.value ? job.configuration : seeds[kind.value];
|
|
435
|
+
active = configurationForm(original);
|
|
436
|
+
drafts.set(kind.value, active);
|
|
437
|
+
}
|
|
438
|
+
// Keep each type's actual controls, including invalid drafts, when switching.
|
|
439
|
+
configured.replaceChildren(active.node);
|
|
440
|
+
kindNotice.textContent = changesKind() ? copy("kindCopyHelp") : "";
|
|
441
|
+
kindNotice.hidden = !changesKind();
|
|
442
|
+
saveButton.textContent = changesKind() ? copy("saveAsNew") : copy("save");
|
|
443
|
+
}
|
|
444
|
+
kind.addEventListener("change", selectKind); selectKind();
|
|
445
|
+
form(job ? copy("editJob") : copy("newJob"), [
|
|
446
|
+
h("div.job-field-grid", null, field(copy("name"), title), field(copy("executor"), kind)),
|
|
447
|
+
kindNotice,
|
|
448
|
+
configured,
|
|
449
|
+
h("p.job-note", null, copy("taskConfig"))
|
|
450
|
+
], () => {
|
|
451
|
+
const update = !!job && !changesKind();
|
|
452
|
+
void mutate("jobs", { op: update ? "update" : "define", id: changesKind() ? newDefinitionId : id.value.trim(), title: title.value.trim(),
|
|
453
|
+
configuration: active.read(), ...(update ? { expectedRevision: job.revision } : {}) }, result => {
|
|
454
|
+
selected = result.id; selectedRun = undefined; pinSelection(); closeEditor();
|
|
125
455
|
});
|
|
126
|
-
});
|
|
456
|
+
}, saveButton);
|
|
457
|
+
}
|
|
458
|
+
// Explicit field editors patch a clone of the original, never reconstruct a
|
|
459
|
+
// saved configuration from only the visible keys. Optional untouched fields
|
|
460
|
+
// keep their exact presence/value. Backend validation remains authoritative.
|
|
461
|
+
function configurationForm(original) {
|
|
462
|
+
const c = JSON.parse(JSON.stringify(original));
|
|
463
|
+
const node = h("div.job-field-stack"), readers = [];
|
|
464
|
+
function text(target, key, name, options = {}) {
|
|
465
|
+
const control = options.multiline ? h("textarea", { rows: 4, required: options.required })
|
|
466
|
+
: input(target[key] ?? "", { required: options.required, ...(options.number ? { type: "number", min: 1, step: 1 } : {}) });
|
|
467
|
+
if (options.multiline) control.value = target[key] ?? "";
|
|
468
|
+
let touched = false;
|
|
469
|
+
control.addEventListener("input", () => { touched = true; });
|
|
470
|
+
readers.push(() => {
|
|
471
|
+
if (!touched) return;
|
|
472
|
+
if (!options.required && control.value === "") { delete target[key]; return; }
|
|
473
|
+
if (options.number && (!Number.isSafeInteger(+control.value) || +control.value < 1))
|
|
474
|
+
throw new Error(name + ": " + copy("form.positiveInteger"));
|
|
475
|
+
target[key] = options.number ? +control.value : control.value;
|
|
476
|
+
});
|
|
477
|
+
return field(name, control);
|
|
478
|
+
}
|
|
479
|
+
function collection(items, create, draw, addLabel, commit) {
|
|
480
|
+
const list = h("div.job-field-stack"), rows = [];
|
|
481
|
+
function add(item, focus = false) {
|
|
482
|
+
const edit = draw(item);
|
|
483
|
+
const row = h("div.job-edit-row", null, edit.node, button(copy("form.remove"), () => {
|
|
484
|
+
rows.splice(rows.indexOf(record), 1); row.remove(); dirty = true; root.dataset.unsent = "true";
|
|
485
|
+
}));
|
|
486
|
+
const record = { row, read: edit.read }; rows.push(record); list.append(row);
|
|
487
|
+
if (focus) row.querySelector("input,textarea")?.focus();
|
|
488
|
+
}
|
|
489
|
+
items.forEach(item => add(item));
|
|
490
|
+
readers.push(() => commit(rows.map(row => row.read())));
|
|
491
|
+
return h("div.job-field-stack", null, list, button(addLabel, () => {
|
|
492
|
+
add(create(), true); dirty = true; root.dataset.unsent = "true";
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
function environment(target) {
|
|
496
|
+
const existing = target.env;
|
|
497
|
+
return collection(Object.entries(existing || {}), () => ["", ""], ([key, value]) => {
|
|
498
|
+
const name = input(key, { required: true }), val = input(value);
|
|
499
|
+
return { node: h("div.job-field-grid", null, field(copy("form.envName"), name), field(copy("form.envValue"), val)),
|
|
500
|
+
read: () => [name.value, val.value] };
|
|
501
|
+
}, copy("form.addEnv"), entries => {
|
|
502
|
+
if (entries.some(([name], index) => !name || entries.findIndex(e => e[0] === name) !== index))
|
|
503
|
+
throw new Error(copy("form.duplicateEnv"));
|
|
504
|
+
if (existing !== undefined || entries.length) target.env = Object.fromEntries(entries);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function stepForm(step) {
|
|
508
|
+
const nested = configurationForm({ kind: "_step", ...step });
|
|
509
|
+
return { node: nested.node, read: () => { const value = nested.read(); delete value.kind; return value; } };
|
|
510
|
+
}
|
|
511
|
+
if (c.kind === "builtin") {
|
|
512
|
+
const action = choose(["system.noop", "project.refresh"], c.action, v => label("jobs.action", v));
|
|
513
|
+
const project = text(c, "projectId", copy("project"));
|
|
514
|
+
const sync = () => { project.hidden = action.value !== "project.refresh"; };
|
|
515
|
+
action.addEventListener("change", sync); sync();
|
|
516
|
+
readers.push(() => { c.action = action.value; });
|
|
517
|
+
node.append(field(copy("action"), action), project, h("p.job-note", null, copy("rebind")));
|
|
518
|
+
if (!isEmpty(c.input)) node.append(section(copy("cfg.input"), valueView(c.input)), h("p.job-note", null, copy("form.unchanged")));
|
|
519
|
+
} else if (c.kind === "shell") {
|
|
520
|
+
if (taskId) node.append(h("div.job-field-grid", null, text(c, "projectId", copy("project"), { required: true }),
|
|
521
|
+
text(c, "workspace", copy("cfg.workspace"), { required: true }), text(c, "head", "HEAD", { required: true })));
|
|
522
|
+
else node.append(h("p.job-note", null, copy("form.shellHome")));
|
|
523
|
+
if (taskId) node.append(section(copy("cfg.env"), environment(c)));
|
|
524
|
+
node.append(section(copy("steps"), collection(c.steps || [], () => ({ name: "", command: "" }), stepForm, copy("form.addStep"), steps => {
|
|
525
|
+
if (!steps.length) throw new Error(copy("form.needStep")); c.steps = steps;
|
|
526
|
+
})));
|
|
527
|
+
if (!taskId) node.append(section(copy("form.scripts"), collection(c.files || [], () => ({ path: "scripts/run.sh", content: "" }), file => {
|
|
528
|
+
const path = input(file.path, { required: true, placeholder: "scripts/run.sh" });
|
|
529
|
+
const content = h("textarea", { rows: 8, spellcheck: "false" }); content.value = file.content;
|
|
530
|
+
return { node: h("div.job-field-stack", null, field(copy("form.scriptPath"), path), field(copy("form.scriptContent"), content)),
|
|
531
|
+
read: () => ({ path: path.value.trim(), content: content.value }) };
|
|
532
|
+
}, copy("form.addScript"), files => { c.files = files; })));
|
|
533
|
+
if (!taskId) node.append(h("details.job-raw", null, h("summary", null, copy("cfg.env")), environment(c)));
|
|
534
|
+
} else if (c.kind === "_step") {
|
|
535
|
+
node.append(text(c, "name", copy("name"), { required: true }),
|
|
536
|
+
text(c, "command", copy("form.command"), { required: true, multiline: true }));
|
|
537
|
+
if (taskId) node.append(
|
|
538
|
+
h("div.job-field-grid", null, text(c, "timeoutMs", copy("form.timeout"), { number: true }), text(c, "cwd", copy("cfg.cwd"))),
|
|
539
|
+
environment(c));
|
|
540
|
+
else node.append(h("details.job-raw", null, h("summary", null, copy("form.stepOptions")),
|
|
541
|
+
text(c, "timeoutMs", copy("form.timeout"), { number: true }), environment(c)));
|
|
542
|
+
if (c.argv) node.append(h("p.job-note", null, copy("form.argvHelp")),
|
|
543
|
+
collection(c.argv, () => "", value => {
|
|
544
|
+
const control = input(value);
|
|
545
|
+
return { node: field("argv", control), read: () => control.value };
|
|
546
|
+
}, copy("form.addArgument"), values => { c.argv = values; }));
|
|
547
|
+
} else if (c.kind === "task" || c.kind === "agent") {
|
|
548
|
+
const isTask = c.kind === "task";
|
|
549
|
+
const targetKind = choose(isTask ? ["new", "work-item"] : ["job", "operator", "task"], c.target.kind, v => copy("form." + v));
|
|
550
|
+
const targetDrafts = new Map(), targetArea = h("div.job-field-stack");
|
|
551
|
+
let targetEdit, currentTarget;
|
|
552
|
+
function drawTarget() {
|
|
553
|
+
currentTarget = targetKind.value;
|
|
554
|
+
if (!targetDrafts.has(currentTarget)) {
|
|
555
|
+
const seed = c.target.kind === currentTarget ? c.target
|
|
556
|
+
: currentTarget === "new" ? { kind: "new", title: "", description: "", projectBindings: [] }
|
|
557
|
+
: currentTarget === "work-item" ? { kind: "work-item", taskId: taskId || "", workItemId: "", revision: 1 }
|
|
558
|
+
: currentTarget === "job" ? { kind: "job", agentId: "" }
|
|
559
|
+
: currentTarget === "task" ? { kind: "task", taskId: taskId || "", roleName: "leader" } : { kind: "operator" };
|
|
560
|
+
targetDrafts.set(currentTarget, configurationForm({ ...seed, kind: "_target", targetKind: seed.kind }));
|
|
561
|
+
}
|
|
562
|
+
targetEdit = targetDrafts.get(currentTarget); targetArea.replaceChildren(targetEdit.node);
|
|
563
|
+
if (isTask) goalField.hidden = currentTarget === "work-item";
|
|
564
|
+
else concurrencyField.hidden = currentTarget !== "job";
|
|
565
|
+
}
|
|
566
|
+
const goal = choose(["delivery", "task-created"], c.goal === "work-item" ? "delivery" : c.goal, v => copy("form." + v));
|
|
567
|
+
const goalField = field(copy("form.goal"), goal);
|
|
568
|
+
const concurrency = choose(["serial", "isolated"], c.concurrency || "serial", v => copy("form." + v));
|
|
569
|
+
const concurrencyField = field(copy("form.concurrency"), concurrency);
|
|
570
|
+
targetKind.addEventListener("change", drawTarget); drawTarget();
|
|
571
|
+
readers.push(() => {
|
|
572
|
+
const value = targetEdit.read(); delete value.targetKind; value.kind = currentTarget; c.target = value;
|
|
573
|
+
if (isTask) c.goal = currentTarget === "work-item" ? "work-item" : goal.value;
|
|
574
|
+
else if (c.concurrency !== undefined || concurrency.value !== "serial") c.concurrency = currentTarget === "job" ? concurrency.value : "serial";
|
|
575
|
+
});
|
|
576
|
+
node.append(field(copy("form.target"), targetKind), targetArea, isTask ? goalField : concurrencyField);
|
|
577
|
+
if (!isTask) node.append(text(c, "input", copy("cfg.agentInput"), { required: true, multiline: true }));
|
|
578
|
+
} else if (c.kind === "_target") {
|
|
579
|
+
if (c.targetKind === "new") node.append(text(c, "title", copy("name"), { required: true }),
|
|
580
|
+
text(c, "description", copy("cfg.description"), { required: true, multiline: true }), text(c, "type", copy("type")),
|
|
581
|
+
section(copy("cfg.bindings"), collection(c.projectBindings, () => ({ projectId: "", directory: "", baseRef: "" }), binding => {
|
|
582
|
+
const nested = configurationForm({ kind: "_binding", ...binding });
|
|
583
|
+
return { node: nested.node, read: () => { const b = nested.read(); delete b.kind; return b; } };
|
|
584
|
+
}, copy("form.addBinding"), values => { c.projectBindings = values; })));
|
|
585
|
+
if (c.targetKind === "work-item" || c.targetKind === "task") node.append(text(c, "taskId", copy("cfg.task"), { required: true }));
|
|
586
|
+
if (c.targetKind === "work-item") node.append(text(c, "workItemId", copy("cfg.workItem"), { required: true }),
|
|
587
|
+
text(c, "revision", copy("revision"), { required: true, number: true }));
|
|
588
|
+
if (c.targetKind === "task") node.append(text(c, "roleName", copy("cfg.role"), { required: true }), text(c, "ownerRunId", copy("cfg.ownerRun")));
|
|
589
|
+
if (c.targetKind === "job") node.append(text(c, "agentId", copy("cfg.agent"), { required: true }));
|
|
590
|
+
} else if (c.kind === "_binding") {
|
|
591
|
+
node.append(h("div.job-field-grid", null, text(c, "projectId", copy("project"), { required: true }),
|
|
592
|
+
text(c, "directory", copy("form.directory"), { required: true }), text(c, "baseRef", copy("form.baseRef"))));
|
|
593
|
+
}
|
|
594
|
+
return { node, read: () => { readers.forEach(read => read()); return JSON.parse(JSON.stringify(c)); } };
|
|
127
595
|
}
|
|
128
596
|
function editSchedule(schedule) {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
const kind = choose(schedule?.builtin ? ["interval", "cron"] : ["
|
|
597
|
+
const boundJob = jobs.find(j => j.id === (schedule?.jobId || selected));
|
|
598
|
+
const id = input(schedule?.id || newId("plan"), { required: true, disabled: !!schedule,
|
|
599
|
+
pattern: "[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}" });
|
|
600
|
+
const kind = choose(schedule?.builtin ? ["interval", "cron"] : ["cron", "interval", "once"], schedule?.rule.kind || "cron",
|
|
601
|
+
value => copy("form." + value));
|
|
133
602
|
const rule = schedule?.rule;
|
|
134
|
-
const at = input(rule?.at || rule?.anchor ||
|
|
603
|
+
const at = input(rule?.at || rule?.anchor || new Date().toISOString());
|
|
135
604
|
const every = input(String(rule?.everyMs || 3600000), { type: "number", min: schedule?.builtin ? 60000 : 1000, step: 1 });
|
|
136
|
-
const expression = input(rule?.expression || "0 9 * * *");
|
|
137
|
-
const timezone =
|
|
605
|
+
const expression = input(rule?.expression || "0 9 * * *", { placeholder: "0 9 * * *", spellcheck: "false", autocomplete: "off" });
|
|
606
|
+
const timezone = rule?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
607
|
+
const parsed = cronFields(expression.value);
|
|
608
|
+
const mode = choose(["daily", "weekly", "monthly", "advanced"], parsed?.frequency || "advanced", value => copy("form." + (value === "advanced" ? "customCron" : value)));
|
|
609
|
+
const time = input(parsed?.time || "09:00", { type: "text", placeholder: "HH:mm",
|
|
610
|
+
pattern: "([01][0-9]|2[0-3]):[0-5][0-9]", maxlength: 5, inputmode: "text", autocomplete: "off" });
|
|
611
|
+
const day = input(String(parsed?.day || 1), { type: "number", min: 1, max: 31, step: 1 });
|
|
612
|
+
// Retain original weekday order (and the complete expression until edited).
|
|
613
|
+
let days = parsed?.days?.slice() || ["1"], visualChanged = false;
|
|
614
|
+
const weekdays = h("div.job-weekdays", { role: "group", "aria-label": copy("form.weekdays") });
|
|
615
|
+
const weekdayButtons = [1, 2, 3, 4, 5, 6, 0].map(n => {
|
|
616
|
+
const name = new Intl.DateTimeFormat(locale() || undefined, { weekday: "short", timeZone: "UTC" })
|
|
617
|
+
.format(new Date(Date.UTC(2026, 9, 18 + n)));
|
|
618
|
+
const b = button(name, () => {
|
|
619
|
+
const value = String(n); days = days.includes(value) ? days.filter(d => d !== value) : [...days, value];
|
|
620
|
+
b.setAttribute("aria-pressed", String(days.includes(value)));
|
|
621
|
+
visualChanged = true; dirty = true; root.dataset.unsent = "true"; clearEditorError(); updateSummary();
|
|
622
|
+
});
|
|
623
|
+
b.setAttribute("aria-pressed", String(days.includes(String(n)))); weekdays.append(b); return b;
|
|
624
|
+
});
|
|
138
625
|
const when = field(copy("instant"), at), interval = field(copy(schedule?.builtin ? "syncEvery" : "every"), every);
|
|
139
|
-
const
|
|
626
|
+
const quickTimes = h("div.job-time-shortcuts", { role: "group", "aria-label": copy("form.quickTime") },
|
|
627
|
+
["09:00", "12:00", "18:00"].map(value => button(value, () => {
|
|
628
|
+
time.value = value; visualChanged = true; dirty = true; root.dataset.unsent = "true";
|
|
629
|
+
clearEditorError(); updateSummary(); time.focus?.();
|
|
630
|
+
})));
|
|
631
|
+
const clock = h("div.job-field-stack.job-clock", null, field(copy("form.time24"), time), quickTimes);
|
|
632
|
+
const month = h("div.job-field-stack", null, field(copy("form.day"), day), h("p.job-note", null, copy("form.monthHelp")));
|
|
633
|
+
const guide = h("dl.job-cron-guide");
|
|
634
|
+
const exact = h("div.job-field-stack", null, field(copy("cron"), expression), guide,
|
|
635
|
+
h("p.job-note", null, copy("form.cronSymbols")));
|
|
636
|
+
const summary = h("div.job-rule-preview", { "aria-live": "polite" });
|
|
637
|
+
function expressionValue() {
|
|
638
|
+
if (mode.value === "advanced") {
|
|
639
|
+
if (!expression.value.trim()) throw new Error(copy("form.cronRequired"));
|
|
640
|
+
return expression.value;
|
|
641
|
+
}
|
|
642
|
+
if (!visualChanged) return expression.value;
|
|
643
|
+
try { return cronExpression({ frequency: mode.value, time: time.value, day: +day.value, days }); }
|
|
644
|
+
catch (error) { throw new Error(copy("form." + error.message)); }
|
|
645
|
+
}
|
|
646
|
+
function updateSummary() {
|
|
647
|
+
const parts = expression.value.trim().split(/\s+/);
|
|
648
|
+
guide.replaceChildren(...["minute", "hour", "date", "month", "weekday"].map((name, i) =>
|
|
649
|
+
h("div", null, h("dt", null, copy("form.cron." + name)), h("dd", null,
|
|
650
|
+
h("code", null, parts[i] || "—"), h("span", null, copy("form.cron." + name + "Range"))))));
|
|
651
|
+
try {
|
|
652
|
+
const value = expressionValue(), description = ruleText({ kind: "cron", expression: value, timezone });
|
|
653
|
+
summary.hidden = mode.value === "advanced" && description === copy("form.customCron");
|
|
654
|
+
summary.replaceChildren(...(mode.value === "advanced" ? [] : [h("code", null, value)]), h("span", null, description));
|
|
655
|
+
summary.classList.toggle("is-error", false);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
summary.hidden = false;
|
|
658
|
+
summary.replaceChildren(error.message); summary.classList.toggle("is-error", true);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const cron = h("div.job-field-stack", null, h("div.job-field-grid", null, field(copy("form.frequency"), mode), clock),
|
|
662
|
+
weekdays, month, exact, summary);
|
|
140
663
|
const sync = () => {
|
|
141
664
|
when.hidden = kind.value === "cron"; interval.hidden = kind.value !== "interval";
|
|
142
|
-
cron.hidden =
|
|
665
|
+
cron.hidden = kind.value !== "cron"; at.required = !when.hidden; every.required = !interval.hidden;
|
|
666
|
+
time.required = !cron.hidden && mode.value !== "advanced";
|
|
667
|
+
clock.hidden = mode.value === "advanced"; weekdays.hidden = mode.value !== "weekly";
|
|
668
|
+
month.hidden = mode.value !== "monthly"; exact.hidden = mode.value !== "advanced";
|
|
669
|
+
day.required = !cron.hidden && !month.hidden; expression.required = !cron.hidden && !exact.hidden;
|
|
670
|
+
at.disabled = when.hidden; every.disabled = interval.hidden;
|
|
671
|
+
time.disabled = cron.hidden || clock.hidden; day.disabled = cron.hidden || month.hidden;
|
|
672
|
+
expression.disabled = cron.hidden || exact.hidden;
|
|
673
|
+
updateSummary();
|
|
143
674
|
};
|
|
675
|
+
let previousMode = mode.value;
|
|
676
|
+
mode.addEventListener("change", () => {
|
|
677
|
+
if (mode.value === "advanced") {
|
|
678
|
+
const chosen = mode.value; mode.value = previousMode;
|
|
679
|
+
try { expression.value = expressionValue(); }
|
|
680
|
+
// The incomplete visual draft remains in its controls. Let the user
|
|
681
|
+
// write a rule instead; never invent weekdays or reuse a stale rule.
|
|
682
|
+
catch { expression.value = ""; }
|
|
683
|
+
mode.value = chosen;
|
|
684
|
+
} else {
|
|
685
|
+
const value = cronFields(expression.value);
|
|
686
|
+
if (previousMode === "advanced" && expression.value.trim() && !value && !window.confirm(copy("form.replaceComplex"))) {
|
|
687
|
+
mode.value = previousMode; return;
|
|
688
|
+
}
|
|
689
|
+
if (previousMode === "advanced" && value) {
|
|
690
|
+
time.value = value.time; day.value = String(value.day || 1); days = value.days || ["1"];
|
|
691
|
+
weekdayButtons.forEach((b, i) => b.setAttribute("aria-pressed", String(days.includes(String([1, 2, 3, 4, 5, 6, 0][i])))));
|
|
692
|
+
}
|
|
693
|
+
visualChanged = true;
|
|
694
|
+
}
|
|
695
|
+
previousMode = mode.value; clearEditorError(); sync();
|
|
696
|
+
});
|
|
697
|
+
[time, day].forEach(control => control.addEventListener("input", () => { visualChanged = true; updateSummary(); }));
|
|
698
|
+
expression.addEventListener("input", updateSummary);
|
|
144
699
|
kind.addEventListener("change", sync); sync();
|
|
145
700
|
form(schedule ? copy("editSchedule") : copy("newSchedule"),
|
|
146
|
-
[
|
|
147
|
-
|
|
701
|
+
[h("p.job-bound-target", null, copy("form.forJob"), h("strong", null, boundJob?.title || schedule?.jobId || selected)),
|
|
702
|
+
field(copy("rule"), kind), h("div.job-field-grid", null, when, interval), cron,
|
|
703
|
+
h("p.job-note", null, copy("scheduleHelp"))], () => {
|
|
148
704
|
const rule = kind.value === "once" ? { kind: "once", at: at.value }
|
|
149
705
|
: kind.value === "interval" ? { kind: "interval", anchor: at.value, everyMs: Number(every.value) }
|
|
150
|
-
: { kind: "cron", expression:
|
|
706
|
+
: { kind: "cron", expression: expressionValue(), timezone };
|
|
151
707
|
void mutate("schedules", { op: schedule ? "update" : "create", id: id.value.trim(), rule,
|
|
152
|
-
...(schedule?.builtin ? {} : { jobId: jobId
|
|
708
|
+
...(schedule?.builtin ? {} : { jobId: schedule?.jobId || selected, ...(!taskId ? { authorization: "service" } : {}) }),
|
|
153
709
|
...(schedule ? { expectedRevision: schedule.revision } : {}) }, closeEditor);
|
|
154
710
|
});
|
|
155
711
|
}
|
|
712
|
+
function newId(prefix) {
|
|
713
|
+
// getRandomValues is also available on the authorized HTTP LAN preview.
|
|
714
|
+
const suffix = crypto.randomUUID ? crypto.randomUUID()
|
|
715
|
+
: Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, "0")).join("");
|
|
716
|
+
return prefix + "-" + suffix;
|
|
717
|
+
}
|
|
156
718
|
async function showRun(jobId, runId, target) {
|
|
157
719
|
const epoch = ++runGeneration;
|
|
720
|
+
// An artifact belongs to one Run; picking another Run drops it.
|
|
721
|
+
if (selectedRun !== runId) artifactState.reference = null;
|
|
158
722
|
selectedRun = runId;
|
|
159
723
|
pinSelection();
|
|
160
|
-
|
|
724
|
+
detail.querySelectorAll(".job-run-row").forEach(row => row.setAttribute("aria-current", String(row.dataset.run === runId)));
|
|
725
|
+
target.replaceChildren(h("p.job-loading", null, copy("loading")));
|
|
161
726
|
try {
|
|
162
727
|
const run = await read("jobs", { op: "run-get", id: jobId, runId });
|
|
163
728
|
if (epoch !== runGeneration || selected !== jobId || selectedRun !== runId) return;
|
|
164
|
-
const evidence = h("section"), artifacts = h("section"), session = h("section");
|
|
165
|
-
|
|
166
|
-
|
|
729
|
+
const evidence = h("section.job-run-evidence"), artifacts = h("section.job-run-artifacts"), session = h("section");
|
|
730
|
+
const steps = run.result?.steps || [];
|
|
731
|
+
const exit = run.result ? [run.result.exitCode === null || run.result.exitCode === undefined ? null : String(run.result.exitCode),
|
|
732
|
+
run.result.signal].filter(Boolean).join(" · ") : null;
|
|
733
|
+
const cancel = ["queued", "running", "unknown-needs-attention"].includes(run.status) ? button(copy("cancelRun"), () => {
|
|
734
|
+
if (window.confirm(copy("cancelBoundary") + "\n" + runId))
|
|
735
|
+
void mutate("jobs", { op: "run-cancel", id: jobId, runId });
|
|
736
|
+
}) : null;
|
|
737
|
+
const reason = run.result?.unknownReason;
|
|
738
|
+
target.replaceChildren(h("div.job-run-panel", null,
|
|
739
|
+
h("header.job-run-head", null, h("div.job-run-title", null, h("h4", null, copy("runDetail")), idChip(run.id), runBadge(run.status)),
|
|
740
|
+
h("div.job-badges", null, cancel, h("button.icon-btn.job-run-close", { type: "button", title: copy("closeRun"),
|
|
741
|
+
"aria-label": copy("closeRun"), onclick: () => closeRun(target, true) }, icon("close")))),
|
|
742
|
+
run.businessResult ? h("p.job-result.tone-" + (RUN_TONE[run.status] || "idle"), null,
|
|
743
|
+
h("span.job-result-label", null, copy("result")), run.businessResult.summary) : null,
|
|
744
|
+
reason && reason !== run.businessResult?.summary ? h("p.job-result.tone-warn", null, reason) : null,
|
|
167
745
|
run.delegation ? h("a.btn", { href: "/?task=" + encodeURIComponent(run.delegation.taskId) },
|
|
168
746
|
copy("executionTask") + " · " + run.delegation.taskId) : null,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
747
|
+
kvList([
|
|
748
|
+
[copy("trigger"), h("span", null, label("trigger", run.trigger.source), " ", h("code.id", { title: run.trigger.key }, shortId(run.trigger.key)))],
|
|
749
|
+
[copy("executor"), label("jobs.kind", run.executor || (run.delegation ? "task" : "process"))],
|
|
750
|
+
[copy("created"), stamp(run.createdAt)],
|
|
751
|
+
run.startedAt ? [copy("started"), stamp(run.startedAt)] : null,
|
|
752
|
+
[copy("finished"), stamp(run.terminalAt)],
|
|
753
|
+
[copy("duration"), duration(runSpan(run))],
|
|
754
|
+
exit ? [copy("exit"), h("code", null, exit)] : null,
|
|
755
|
+
run.result?.failedStep ? [copy("failedStep"), h("code", null, run.result.failedStep)] : null,
|
|
756
|
+
run.cancelRequestedAt ? [copy("cancelRequested"), stamp(run.cancelRequestedAt)] : null,
|
|
757
|
+
run.retryOf ? [copy("retryOf"), h("code.id", { title: run.retryOf }, shortId(run.retryOf))] : null,
|
|
758
|
+
run.definitionRevision ? [copy("revision"), String(run.definitionRevision)] : null
|
|
759
|
+
]),
|
|
760
|
+
steps.length ? h("section.job-section", null, h("h5.job-section-title", null, copy("steps") + " · " + steps.length),
|
|
761
|
+
h("ol.job-run-steps", null, steps.map(step => h("li.job-run-step", null,
|
|
762
|
+
stepBadge(step), h("code.job-run-step-name", null, step.name), h("span.job-run-step-meta", null,
|
|
763
|
+
[duration(step.durationMs), step.exitCode !== null && step.exitCode !== undefined ? "exit " + step.exitCode : step.signal]
|
|
764
|
+
.filter(Boolean).join(" · ")))))) : null,
|
|
765
|
+
evidence, artifacts, session,
|
|
766
|
+
h("div.job-advanced", null, run.businessResult ? raw(copy("result"), run.businessResult) : null, raw(copy("snapshot"), run))));
|
|
180
767
|
drawArtifacts(artifacts, jobId, run, epoch);
|
|
181
768
|
if (run.executor === "agent" && run.configuration.target.kind === "job") drawSession(session, jobId, runId);
|
|
182
769
|
try {
|
|
183
770
|
const value = await read("jobs", { op: "run-evidence", id: jobId, runId });
|
|
184
771
|
if (epoch !== runGeneration) return;
|
|
185
|
-
evidence.append(raw(copy("diagnostics"), value));
|
|
186
772
|
if (run.executor !== "agent" && !run.delegation) {
|
|
187
773
|
const logs = await read("jobs", { op: "logs", id: jobId, runId });
|
|
188
774
|
if (epoch !== runGeneration) return;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
775
|
+
const failed = run.result?.failedStep;
|
|
776
|
+
evidence.append(h("h5.job-section-title", null, copy("logs")), logs.length ? h("div.job-logs", null, logs.map((log, i) => h("details.job-log", {
|
|
777
|
+
open: log.step === failed || (!failed && i === logs.length - 1 && run.status !== "succeeded") }, h("summary", null,
|
|
778
|
+
h("code", null, log.step), log.truncated ? h("span.job-raw-hint", null, copy("truncated")) : null),
|
|
779
|
+
h("pre.job-log-text", null, log.text)))) : h("p.job-note", null, copy("noLogs")));
|
|
192
780
|
}
|
|
781
|
+
evidence.append(raw(copy("diagnostics"), value));
|
|
193
782
|
} catch (error) { if (epoch === runGeneration) evidence.append(h("p.settings-error", null, copy("diagnostics") + ": " + error.message)); }
|
|
194
783
|
} catch (error) { if (epoch === runGeneration) target.replaceChildren(h("p.settings-error", null, error.message)); }
|
|
195
784
|
}
|
|
785
|
+
function closeRun(target, focusRow = false) {
|
|
786
|
+
const closed = selectedRun;
|
|
787
|
+
runGeneration++;
|
|
788
|
+
selectedRun = undefined; artifactState.reference = null;
|
|
789
|
+
pinSelection(focusRow);
|
|
790
|
+
target.replaceChildren();
|
|
791
|
+
placeRun(focusRow ? closed : null);
|
|
792
|
+
}
|
|
196
793
|
function drawArtifacts(target, jobId, run, epoch) {
|
|
197
794
|
const refs = run.artifacts || [];
|
|
198
795
|
const viewer = h("div.file-viewer");
|
|
@@ -203,9 +800,7 @@ export function mountJobs(root, t, taskId) {
|
|
|
203
800
|
};
|
|
204
801
|
const select = async (ref, offset = 0) => {
|
|
205
802
|
artifactState.reference = ref;
|
|
206
|
-
|
|
207
|
-
url.searchParams.set("artifact", JSON.stringify(ref));
|
|
208
|
-
history.replaceState(history.state, "", url);
|
|
803
|
+
pinSelection();
|
|
209
804
|
const key = JSON.stringify(ref);
|
|
210
805
|
viewer.replaceChildren(h("p", null, copy("loading")));
|
|
211
806
|
try {
|
|
@@ -223,12 +818,14 @@ export function mountJobs(root, t, taskId) {
|
|
|
223
818
|
} catch (error) { if (epoch === runGeneration && JSON.stringify(artifactState.reference) === key)
|
|
224
819
|
viewer.replaceChildren(h("p.settings-error", null, error.message)); }
|
|
225
820
|
};
|
|
226
|
-
target.append(h("
|
|
227
|
-
|
|
821
|
+
target.append(...[h("h5.job-section-title", null, copy("artifacts") + " · " + refs.length),
|
|
822
|
+
refs.length ? h("p.job-note", null, copy("registeredOnly")) : null,
|
|
823
|
+
refs.length ? h("div.job-artifact-list", null, refs.map(ref => button(ref.relativePath + " · " + ref.version.slice(0, 12), () => void select(ref)))) : null,
|
|
824
|
+
viewer].filter(Boolean));
|
|
228
825
|
if (artifactState.reference && refs.some(ref => JSON.stringify(ref) === JSON.stringify(artifactState.reference)))
|
|
229
826
|
void select(artifactState.reference);
|
|
230
827
|
const recovery = h("div");
|
|
231
|
-
target.append(
|
|
828
|
+
target.append(h("button.btn.btn-ghost.job-inline-action", { type: "button", onclick: async () => {
|
|
232
829
|
recovery.replaceChildren();
|
|
233
830
|
try {
|
|
234
831
|
const result = await read("jobs", { op: "artifact-list", id: jobId, runId: run.id });
|
|
@@ -239,7 +836,7 @@ export function mountJobs(root, t, taskId) {
|
|
|
239
836
|
void mutate("jobs", { op: "artifact-register", id: jobId, runId: run.id, reference: ref });
|
|
240
837
|
})));
|
|
241
838
|
} catch (error) { recovery.append(h("p.settings-error", null, error.message)); }
|
|
242
|
-
}), recovery);
|
|
839
|
+
} }, copy("inspectSaved")), recovery);
|
|
243
840
|
}
|
|
244
841
|
function drawSession(target, jobId, runId) {
|
|
245
842
|
target.append(button(copy("inspectSession"), async () => {
|
|
@@ -255,10 +852,173 @@ export function mountJobs(root, t, taskId) {
|
|
|
255
852
|
} catch (error) { target.append(h("p.settings-error", null, error.message)); }
|
|
256
853
|
}));
|
|
257
854
|
}
|
|
855
|
+
// Run history filters, in the order a reader scans for trouble.
|
|
856
|
+
const RUN_FILTERS = [["all", null], ["succeeded", ["succeeded"]], ["failed", ["failed"]], ["timed-out", ["timed-out"]],
|
|
857
|
+
["cancelled", ["cancelled"]], ["active", ["queued", "running"]], ["unknown-needs-attention", ["unknown-needs-attention"]]];
|
|
858
|
+
let runFilter = "all", runLimit = 15, shownJob = null, rowNodes = new Map(), placeRun = () => {};
|
|
859
|
+
// A route-driven Run (deep link, refresh, Back/Forward, run now, a plan's
|
|
860
|
+
// link) is brought into view with its detail once it has loaded.
|
|
861
|
+
let revealRun = !!selectedRun;
|
|
862
|
+
const runPanelId = "job-run-" + Math.random().toString(36).slice(2, 10);
|
|
863
|
+
function runSummary(run) { return run.businessResult?.summary || run.summary || run.result?.unknownReason || ""; }
|
|
864
|
+
function runRow(job, run, panel) {
|
|
865
|
+
const started = run.startedAt || run.createdAt;
|
|
866
|
+
const span = duration(runSpan(run));
|
|
867
|
+
const open = selectedRun === run.id;
|
|
868
|
+
// One control per Run: it opens the detail right under itself, and
|
|
869
|
+
// closes it again. Its expanded state is the single selection.
|
|
870
|
+
return h("button.job-run-row", {
|
|
871
|
+
type: "button", "aria-current": String(open), "aria-expanded": String(open),
|
|
872
|
+
"aria-controls": open ? runPanelId : null, dataset: { run: run.id }, title: run.id,
|
|
873
|
+
onclick: () => { if (selectedRun === run.id) closeRun(panel, true); else void openRun(job.id, run.id, panel); }
|
|
874
|
+
}, h("span.job-run-status", null, runBadge(run.status)),
|
|
875
|
+
h("span.job-run-main", null, h("span.job-run-summary", null, runSummary(run) || label("job", run.status)),
|
|
876
|
+
h("span.job-run-sub", null, h("code", null, shortId(run.id)), h("span", null, label("trigger", run.trigger && run.trigger.source)))),
|
|
877
|
+
h("span.job-run-when", { title: when(started) || "" }, ago(started) || when(started) || "—"),
|
|
878
|
+
h("span.job-run-span", null, span || (["queued", "running"].includes(run.status) ? "…" : "—")),
|
|
879
|
+
h("span.job-run-caret", { "aria-hidden": "true" }, icon("chevron")));
|
|
880
|
+
}
|
|
881
|
+
async function openRun(jobId, runId, panel) {
|
|
882
|
+
const keep = focusedRun();
|
|
883
|
+
selectedRun = runId; artifactState.reference = null;
|
|
884
|
+
pinSelection(true);
|
|
885
|
+
placeRun(keep ? runId : null);
|
|
886
|
+
await showRun(jobId, runId, panel);
|
|
887
|
+
if (selected === jobId && selectedRun === runId) revealSelectedRun(panel, true);
|
|
888
|
+
}
|
|
889
|
+
function focusedRun() {
|
|
890
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
891
|
+
return active && active.dataset && active.dataset.run && rowNodes.get(active.dataset.run) === active ? active.dataset.run : null;
|
|
892
|
+
}
|
|
893
|
+
// Bring the selected row and as much of its detail as fits into view; the
|
|
894
|
+
// row stays on screen so the list context is never lost.
|
|
895
|
+
function revealSelectedRun(panel, smooth) {
|
|
896
|
+
const row = rowNodes.get(selectedRun);
|
|
897
|
+
if (!row || typeof row.getBoundingClientRect !== "function" || row.isConnected === false) return;
|
|
898
|
+
const top = row.getBoundingClientRect().top, bottom = panel.getBoundingClientRect?.().bottom ?? top;
|
|
899
|
+
const height = typeof window !== "undefined" && window.innerHeight ? window.innerHeight : 0;
|
|
900
|
+
if (top < 64 || (height && (bottom > height || top > height - 80)))
|
|
901
|
+
row.scrollIntoView?.({ block: "start", behavior: smooth ? "smooth" : "auto" });
|
|
902
|
+
}
|
|
903
|
+
function historyView(job, runs, panel) {
|
|
904
|
+
const counts = Object.fromEntries(RUN_FILTERS.map(([key, set]) => [key, set ? runs.filter(r => set.includes(r.status)).length : runs.length]));
|
|
905
|
+
if (!counts[runFilter]) runFilter = "all";
|
|
906
|
+
// A Run chosen by the route (deep link, Back/Forward, a plan's link, Run
|
|
907
|
+
// now) wins over an earlier filter that would hide it: the filter returns
|
|
908
|
+
// to all so the Run opens in place. A filter the reader picks afterwards
|
|
909
|
+
// still closes a Run it hides (see the filter buttons below).
|
|
910
|
+
const routed = selectedRun && runs.find(run => run.id === selectedRun);
|
|
911
|
+
const filterSet = RUN_FILTERS.find(([key]) => key === runFilter)[1];
|
|
912
|
+
if (routed && filterSet && !filterSet.includes(routed.status)) runFilter = "all";
|
|
913
|
+
const body = h("div.job-history-body");
|
|
914
|
+
const visible = () => {
|
|
915
|
+
const set = RUN_FILTERS.find(([key]) => key === runFilter)[1];
|
|
916
|
+
return set ? runs.filter(run => set.includes(run.status)) : runs;
|
|
917
|
+
};
|
|
918
|
+
const draw = focusRun => {
|
|
919
|
+
const shown = visible();
|
|
920
|
+
// The selected Run stays visible even past the page limit, with its
|
|
921
|
+
// detail directly under its row. A selected Run missing from the
|
|
922
|
+
// history read (just admitted, or older than it) opens above the list.
|
|
923
|
+
const index = shown.findIndex(run => run.id === selectedRun);
|
|
924
|
+
const limit = Math.max(runLimit, index + 1);
|
|
925
|
+
rowNodes = new Map();
|
|
926
|
+
const items = [];
|
|
927
|
+
for (const run of shown.slice(0, limit)) {
|
|
928
|
+
const row = runRow(job, run, panel);
|
|
929
|
+
rowNodes.set(run.id, row); items.push(row);
|
|
930
|
+
if (run.id === selectedRun) items.push(panel);
|
|
931
|
+
}
|
|
932
|
+
const orphan = selectedRun && !runs.some(run => run.id === selectedRun);
|
|
933
|
+
body.replaceChildren(h("div.job-runs-head", { "aria-hidden": "true" }, h("span", null, copy("col.status")),
|
|
934
|
+
h("span", null, copy("col.run")), h("span", null, copy("col.started")), h("span", null, copy("duration")), h("span")),
|
|
935
|
+
h("div.jobs-runs", null, orphan ? panel : null, items),
|
|
936
|
+
...(shown.length > limit ? [h("button.job-more", { type: "button", onclick: () => { runLimit += 30; draw(); } },
|
|
937
|
+
fill(copy("showMore"), { count: shown.length - limit }))] : []));
|
|
938
|
+
if (focusRun) rowNodes.get(focusRun)?.focus?.();
|
|
939
|
+
};
|
|
940
|
+
placeRun = draw;
|
|
941
|
+
const tabs = h("div.chip-tabs.job-filters", { role: "group", "aria-label": copy("filterRuns") },
|
|
942
|
+
RUN_FILTERS.filter(([key]) => key === "all" || counts[key]).map(([key]) => h("button.chip-tab", {
|
|
943
|
+
type: "button", "aria-pressed": String(runFilter === key), dataset: { filter: key },
|
|
944
|
+
onclick: event => { runFilter = key; runLimit = 15;
|
|
945
|
+
for (const tab of tabs.children) tab.setAttribute("aria-pressed", String(tab === event.currentTarget));
|
|
946
|
+
// A filter that hides the open Run closes its detail rather than
|
|
947
|
+
// leaving it detached from any row.
|
|
948
|
+
if (selectedRun && runs.some(run => run.id === selectedRun) && !visible().some(run => run.id === selectedRun)) closeRun(panel);
|
|
949
|
+
else draw(); }
|
|
950
|
+
}, key === "all" ? copy("filter.all") : key === "active" ? copy("filter.active") : label("job", key), h("span.count", null, String(counts[key])))));
|
|
951
|
+
draw();
|
|
952
|
+
return section(copy("history"), runs.length ? h("div.job-history", null, overviewView(job, runs), tabs, body)
|
|
953
|
+
: h("div.job-history", null, selectedRun ? h("div.jobs-runs", null, panel) : null, h("p.job-empty-block", null, copy("noRuns"))),
|
|
954
|
+
h("span.job-section-count", null, fill(copy("runCount"), { count: runs.length })));
|
|
955
|
+
}
|
|
956
|
+
function overviewView(job, runs) {
|
|
957
|
+
const terminal = runs.filter(run => !["queued", "running"].includes(run.status));
|
|
958
|
+
const ok = terminal.filter(run => run.status === "succeeded");
|
|
959
|
+
const spans = ok.map(runSpan).filter(Number.isFinite);
|
|
960
|
+
const recent = runs.slice(0, 30).reverse();
|
|
961
|
+
const latest = runs[0], success = runs.find(run => run.businessResult?.outcome === "succeeded");
|
|
962
|
+
const stat = (name, value, sub) => h("div.job-stat", null, h("span.job-stat-label", null, name),
|
|
963
|
+
h("span.job-stat-value", null, value), sub ? h("span.job-stat-sub", null, sub) : null);
|
|
964
|
+
const runRef = run => run ? h("span.job-stat-run", null, h("code", { title: run.id || run.runId }, shortId(run.id || run.runId)),
|
|
965
|
+
runBadge(run.status), h("span.job-stat-sub", null, ago(run.terminalAt || run.createdAt))) : h("span.job-empty", null, "—");
|
|
966
|
+
const refreshRef = item => item ? h("span.job-stat-run", null, h("code", { title: item.runId || item.id || "" }, shortId(item.runId || item.id || "—")),
|
|
967
|
+
item.status ? runBadge(item.status) : null, h("span.job-stat-sub", null, ago(item.finishedAt || item.terminalAt || item.attemptedAt || item.at || item.createdAt))) : h("span.job-empty", null, "—");
|
|
968
|
+
return h("section.job-overview", { "aria-label": copy("overview") },
|
|
969
|
+
h("div.job-stats", null,
|
|
970
|
+
stat(copy("successRate"), terminal.length ? Math.round(ok.length * 100 / terminal.length) + "%" : "—",
|
|
971
|
+
terminal.length ? ok.length + " / " + terminal.length : null),
|
|
972
|
+
stat(copy("avgDuration"), spans.length ? duration(spans.reduce((a, b) => a + b, 0) / spans.length) : "—"),
|
|
973
|
+
h("div.job-stat", null, h("span.job-stat-label", null, copy("recentRun")), runRef(latest)),
|
|
974
|
+
h("div.job-stat", null, h("span.job-stat-label", null, copy("recentSuccess")), runRef(success)),
|
|
975
|
+
job.refresh ? h("div.job-stat", null, h("span.job-stat-label", null, copy("refreshAttempt")), refreshRef(job.refresh.lastAttempt)) : null,
|
|
976
|
+
job.refresh ? h("div.job-stat", null, h("span.job-stat-label", null, copy("refreshSuccess")), refreshRef(job.refresh.lastSuccessfulRefresh)) : null),
|
|
977
|
+
recent.length ? h("div.job-spark", { role: "img", "aria-label": fill(copy("sparkLabel"), { count: recent.length,
|
|
978
|
+
ok: recent.filter(run => run.status === "succeeded").length }) },
|
|
979
|
+
recent.map(run => h("span.job-spark-bar.tone-" + (RUN_TONE[run.status] || "idle"), { title: label("job", run.status) + " · " + when(run.createdAt) })))
|
|
980
|
+
: null);
|
|
981
|
+
}
|
|
982
|
+
function plansView(job, plans) {
|
|
983
|
+
const items = plans.map(({ schedule: s, lastSuccess }) => {
|
|
984
|
+
const on = s.builtin ? s.builtin.enabled : s.state === "enabled";
|
|
985
|
+
const attempt = s.lastAttempt;
|
|
986
|
+
const routinePause = s.state === "disabled" && (!s.reason
|
|
987
|
+
|| s.reason === "Explicitly disabled; no future admission. Existing Runs are unchanged."
|
|
988
|
+
|| s.reason === "Project sync disabled by user.");
|
|
989
|
+
return h("li.job-plan", null,
|
|
990
|
+
h("div.job-plan-main", null,
|
|
991
|
+
h("div.job-plan-line", null, scheduleBadge(s), h("strong.job-plan-rule", null, ruleText(s.rule)),
|
|
992
|
+
s.rule?.kind === "cron" ? h("code.job-plan-expression", null, s.rule.expression) : null),
|
|
993
|
+
h("div.job-plan-line.job-plan-sub", null,
|
|
994
|
+
s.state === "enabled" && s.nextDue ? h("span", { title: when(s.nextDue) }, copy("nextShort") + " " + when(s.nextDue) + " · " + ago(s.nextDue)) : null,
|
|
995
|
+
attempt ? h("span", { title: attempt.reason || "" }, copy("lastAttemptShort") + " " + ago(attempt.at) + " · " + label("admission", attempt.outcome)) : null,
|
|
996
|
+
lastSuccess ? h("span", null, copy("lastSuccessShort") + " " + ago(lastSuccess.terminalAt || lastSuccess.updatedAt)) : null),
|
|
997
|
+
s.state === "completed" ? h("p.job-note", null, copy("onceCompleted")) : null,
|
|
998
|
+
routinePause ? h("p.job-note", null, copy("form.paused"))
|
|
999
|
+
: s.reason ? h("p.job-note.is-error", null, s.reason) : null),
|
|
1000
|
+
h("div.job-plan-actions", null,
|
|
1001
|
+
button(copy(on ? "disable" : "enable"), () => void mutate("schedules", { op: on ? "disable" : "enable", id: s.id, expectedRevision: s.revision })),
|
|
1002
|
+
button(copy("editSchedule"), () => editSchedule(s))));
|
|
1003
|
+
});
|
|
1004
|
+
const add = !job.builtin && !["agent", "task"].includes(job.configuration.kind)
|
|
1005
|
+
? h("button.job-section-action", { type: "button", onclick: () => editSchedule() }, icon("plus"), copy("newSchedule")) : null;
|
|
1006
|
+
return section(copy("schedules"), items.length ? h("ul.job-plans", null, items) : h("p.job-empty-block", null, copy("noSchedules")), add);
|
|
1007
|
+
}
|
|
1008
|
+
// The Job's own description, when its configuration really has one: a
|
|
1009
|
+
// delegated Task's description or an Agent's instruction. Never invented.
|
|
1010
|
+
function purpose(config) {
|
|
1011
|
+
if (config.kind === "task" && typeof config.target?.description === "string" && config.target.description.trim())
|
|
1012
|
+
return { name: copy("cfg.description"), text: config.target.description };
|
|
1013
|
+
if (config.kind === "agent" && typeof config.input === "string" && config.input.trim())
|
|
1014
|
+
return { name: copy("cfg.agentInput"), text: config.input };
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
258
1017
|
async function showDetail(epoch) {
|
|
259
1018
|
const job = jobs.find(j => j.id === selected);
|
|
260
1019
|
if (!job) {
|
|
261
|
-
detail.replaceChildren(...(selected ? [h("p.settings-error", null, copy("unavailable") + " · " + selected)]
|
|
1020
|
+
detail.replaceChildren(...(selected ? [h("p.settings-error", null, copy("unavailable") + " · " + selected)]
|
|
1021
|
+
: jobs.length ? [] : [h("div.job-blank", null, icon("clock", "icon-xl"), h("p", null, copy("empty")))]));
|
|
262
1022
|
return;
|
|
263
1023
|
}
|
|
264
1024
|
const [runs, plans] = await Promise.all([
|
|
@@ -266,86 +1026,208 @@ export function mountJobs(root, t, taskId) {
|
|
|
266
1026
|
Promise.all(schedules.filter(s => s.jobId === job.id).map(s => read("schedules", { op: "show", id: s.id })))
|
|
267
1027
|
]);
|
|
268
1028
|
if (epoch !== generation) return;
|
|
1029
|
+
if (shownJob !== job.id) { shownJob = job.id; runFilter = "all"; runLimit = 15; }
|
|
269
1030
|
runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
h("
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
h("h3", null, copy("history")),
|
|
310
|
-
runs.length ? h("div.jobs-runs", null, runs.map(run =>
|
|
311
|
-
button(run.id + " · " + run.status + " · " + run.trigger.source + " · " + run.createdAt,
|
|
312
|
-
() => void showRun(job.id, run.id, runDetail)))) : h("p", null, copy("noRuns")),
|
|
313
|
-
runDetail));
|
|
314
|
-
if (selectedRun) await showRun(job.id, selectedRun, runDetail);
|
|
1031
|
+
const runDetail = h("div.job-run-inline", { id: runPanelId, role: "region", "aria-label": copy("runDetail") });
|
|
1032
|
+
runDetail.addEventListener("keydown", event => {
|
|
1033
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
1034
|
+
event.preventDefault(); closeRun(runDetail, true);
|
|
1035
|
+
});
|
|
1036
|
+
const own = schedules.filter(s => s.jobId === job.id);
|
|
1037
|
+
const config = job.configuration || {};
|
|
1038
|
+
const about = purpose(config);
|
|
1039
|
+
// Scope is named once, by the shell (list head and crumb); the header
|
|
1040
|
+
// keeps what identifies this Job.
|
|
1041
|
+
// Reading order: what the Job is and does (description, configuration,
|
|
1042
|
+
// plans, definition), then how it has run (overview and history).
|
|
1043
|
+
detail.replaceChildren(h("article.job-page", null,
|
|
1044
|
+
h("header.job-header", null,
|
|
1045
|
+
h("div.job-title", null,
|
|
1046
|
+
h("h2", null, job.title),
|
|
1047
|
+
h("div.job-badges", null, own.length ? own.map(s => scheduleBadge(s)) : badge(copy("noSchedule"), "idle"),
|
|
1048
|
+
runs[0] ? runBadge(runs[0].status) : badge(copy("noRunsShort"), "idle"))),
|
|
1049
|
+
h("div.job-actions", null,
|
|
1050
|
+
!job.builtin ? button(copy("editJob"), () => editJob(job)) : null,
|
|
1051
|
+
button(copy("runNow"), () => void mutate("jobs", { op: "run", id: job.id, requestId: crypto.randomUUID() }, result => {
|
|
1052
|
+
selectedRun = result.run.id; revealRun = true; pinSelection();
|
|
1053
|
+
}), true))),
|
|
1054
|
+
h("div.job-about", null,
|
|
1055
|
+
h("div.job-primary", null,
|
|
1056
|
+
job.builtin ? h("p.job-purpose.is-note", null, copy("builtinSync")) : null,
|
|
1057
|
+
about ? section(about.name, h("div.job-purpose", null, h("pre.job-prose", null, about.text),
|
|
1058
|
+
copyButton(about.text, copy("copy")))) : null,
|
|
1059
|
+
section(copy("configuration"), configView(config, !!about))),
|
|
1060
|
+
h("aside.job-aside", null,
|
|
1061
|
+
plansView(job, plans),
|
|
1062
|
+
job.refresh ? raw(copy("refreshFacts"), job.refresh) : null)),
|
|
1063
|
+
historyView(job, runs, runDetail)));
|
|
1064
|
+
if (selectedRun) {
|
|
1065
|
+
const reveal = revealRun;
|
|
1066
|
+
revealRun = false;
|
|
1067
|
+
await showRun(job.id, selectedRun, runDetail);
|
|
1068
|
+
if (reveal && epoch === generation && selected === job.id) revealSelectedRun(runDetail, false);
|
|
1069
|
+
}
|
|
315
1070
|
}
|
|
316
1071
|
async function refresh(announce = true) {
|
|
317
1072
|
const epoch = ++generation;
|
|
318
|
-
if (announce)
|
|
1073
|
+
if (announce) observe("syncing", copy("loading"));
|
|
319
1074
|
try {
|
|
320
1075
|
const result = await Promise.all([read("jobs", { op: "list" }), read("schedules", { op: "list" })]);
|
|
321
1076
|
if (epoch !== generation) return;
|
|
322
1077
|
[jobs, schedules] = result;
|
|
323
1078
|
if (!selected && jobs.length) selected = jobs[0].id;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (pending || !guardDraft()) return;
|
|
327
|
-
closeEditor(); selected = job.id; selectedRun = undefined; pinSelection(); void refresh();
|
|
328
|
-
}
|
|
329
|
-
}, h("strong", null, job.title), h("code", null, job.id),
|
|
330
|
-
h("small", null, job.builtin ? copy("builtinSync") : job.configuration.action || job.configuration.kind),
|
|
331
|
-
h("small", null, job.configuration.projectId || "—"))) : [h("p", null, copy("empty"))]));
|
|
1079
|
+
loaded = true; loadError = null;
|
|
1080
|
+
drawIndex();
|
|
332
1081
|
await showDetail(epoch);
|
|
333
|
-
if (announce && epoch === generation)
|
|
334
|
-
|
|
1082
|
+
if (announce && epoch === generation) {
|
|
1083
|
+
// A manual or first read settles the page: an old write receipt
|
|
1084
|
+
// (never an unresolved one, which stays blocked) gives way to it.
|
|
1085
|
+
if (options.sync && (readFailed || !receipt.classList.contains?.("settings-error"))) status("");
|
|
1086
|
+
readFailed = false;
|
|
1087
|
+
observe("ok", copy("observed") + " · " + when(new Date().toISOString()));
|
|
1088
|
+
}
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
if (epoch !== generation) return;
|
|
1091
|
+
loadError = error.message;
|
|
1092
|
+
drawIndex();
|
|
1093
|
+
observe("error", copy("stale") + " " + error.message);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// The Job index. Its count, search and empty state describe exactly the
|
|
1097
|
+
// Jobs of this one scope as returned by its list read; nothing else.
|
|
1098
|
+
let loaded = false, loadError = null;
|
|
1099
|
+
function pick(job) {
|
|
1100
|
+
if (pending || !guardDraft()) return;
|
|
1101
|
+
closeEditor();
|
|
1102
|
+
if (options.navigate) { options.navigate({ job: job.id }); return; }
|
|
1103
|
+
selected = job.id; selectedRun = undefined; pinSelection(); void refresh();
|
|
1104
|
+
}
|
|
1105
|
+
function matches(job) {
|
|
1106
|
+
if (!filterText) return true;
|
|
1107
|
+
return [job.title, job.id, job.configuration.action, job.configuration.kind, job.configuration.projectId]
|
|
1108
|
+
.some(value => typeof value === "string" && value.toLowerCase().includes(filterText));
|
|
1109
|
+
}
|
|
1110
|
+
function jobRow(job) {
|
|
1111
|
+
const own = schedules.filter(s => s.jobId === job.id);
|
|
1112
|
+
const states = own.map(scheduleState);
|
|
1113
|
+
const state = states.includes("needs-attention") ? "needs-attention" : states.includes("enabled") ? "enabled"
|
|
1114
|
+
: states[0] || null;
|
|
1115
|
+
const latest = job.results && job.results.latest;
|
|
1116
|
+
const next = own.filter(s => s.state === "enabled" && s.nextDue).map(s => s.nextDue).sort()[0];
|
|
1117
|
+
const planText = state ? label("schedule", state) + (next ? " · " + copy("nextShort") + " " + (ago(next) || when(next)) : "") : copy("noSchedule");
|
|
1118
|
+
return h("button.job-row", {
|
|
1119
|
+
type: "button", dataset: { job: job.id }, "aria-current": String(selected === job.id),
|
|
1120
|
+
title: job.title + " · " + job.id, onclick: () => pick(job)
|
|
1121
|
+
}, dot(state ? SCHEDULE_TONE[state] || "idle" : "idle", planText),
|
|
1122
|
+
h("span.job-row-body", null,
|
|
1123
|
+
h("span.job-row-line", null, h("span.job-row-name", null, job.title),
|
|
1124
|
+
// Plan (on/off) and the latest Run's status are separate facts, side by side.
|
|
1125
|
+
latest ? runBadge(latest.status) : h("span.job-row-none", null, copy("noRunsShort"))),
|
|
1126
|
+
h("span.job-row-line.job-row-sub", null, h("span.job-row-type", null, typeLabel(job)),
|
|
1127
|
+
h("span.job-row-plan", null, planText))));
|
|
1128
|
+
}
|
|
1129
|
+
function drawIndex() {
|
|
1130
|
+
if (!loaded && !loadError) { list.replaceChildren(h("p.jobs-index-empty", null, copy("loading"))); indexCount.textContent = ""; return; }
|
|
1131
|
+
if (loadError && !loaded) {
|
|
1132
|
+
list.replaceChildren(h("p.jobs-index-empty.is-error", null, copy("stale") + " " + loadError));
|
|
1133
|
+
indexCount.textContent = "";
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const shown = jobs.filter(matches);
|
|
1137
|
+
indexCount.textContent = filterText ? fill(copy("countFiltered"), { shown: shown.length, total: jobs.length })
|
|
1138
|
+
: fill(copy("count"), { total: jobs.length });
|
|
1139
|
+
// Redrawing must not drop a keyboard reader's place in the list.
|
|
1140
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
1141
|
+
const focused = active && list.contains && list.contains(active) ? active.dataset.job : null;
|
|
1142
|
+
list.replaceChildren(...(shown.length ? shown.map(jobRow)
|
|
1143
|
+
: [h("p.jobs-index-empty", null, copy(jobs.length ? "noMatch" : "empty"))]));
|
|
1144
|
+
if (focused) list.querySelector('.job-row[data-job="' + CSS.escape(focused) + '"]')?.focus();
|
|
1145
|
+
}
|
|
1146
|
+
function fill(template, values) {
|
|
1147
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => values[key] === undefined ? match : String(values[key]));
|
|
1148
|
+
}
|
|
1149
|
+
function label(kind, value) {
|
|
1150
|
+
if (value === undefined || value === null || value === "") return "—";
|
|
1151
|
+
return t(kind + "." + value, String(value).replace(/[-_]/g, " "));
|
|
1152
|
+
}
|
|
1153
|
+
function runBadge(value) {
|
|
1154
|
+
const node = badge(label("job", value), RUN_TONE[value] || "idle", { dot: true });
|
|
1155
|
+
node.dataset.status = value;
|
|
1156
|
+
return node;
|
|
1157
|
+
}
|
|
1158
|
+
function scheduleBadge(schedule) {
|
|
1159
|
+
const state = scheduleState(schedule);
|
|
1160
|
+
const node = badge(copy("plan") + " · " + label("schedule", state), SCHEDULE_TONE[state] || "idle", { dot: true });
|
|
1161
|
+
node.dataset.schedule = state;
|
|
1162
|
+
return node;
|
|
1163
|
+
}
|
|
1164
|
+
function typeLabel(job) {
|
|
1165
|
+
const config = job.configuration;
|
|
1166
|
+
if (job.builtin || (config.kind === "builtin" && config.action === "project.refresh")) return copy("type.projectSync");
|
|
1167
|
+
return config.kind === "builtin" ? label("jobs.action", config.action) : label("jobs.kind", config.kind);
|
|
1168
|
+
}
|
|
1169
|
+
function ownerValue() {
|
|
1170
|
+
return taskId ? h("a", { href: "/?task=" + encodeURIComponent(taskId) }, copy("ownerTask") + " · " + taskId)
|
|
1171
|
+
: copy("ownerGlobal");
|
|
335
1172
|
}
|
|
336
1173
|
root.classList.add("jobs-manager");
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
1174
|
+
// In the Jobs entry the shell already names the area and scope (list head,
|
|
1175
|
+
// crumb) and owns "new" and refresh, as on the workbench; the page starts
|
|
1176
|
+
// with the Job itself. Embedded in a Task page, one quiet bar carries them.
|
|
1177
|
+
const head = options.index ? null : h("header.jobs-bar", null,
|
|
1178
|
+
h("div.jobs-bar-title", null, h("h2", { title: copy("help") }, copy("heading")), h("p.sr-only", null, copy("help"))),
|
|
1179
|
+
h("div.jobs-bar-actions", null, receipt,
|
|
1180
|
+
h("button.btn.btn-ghost.jobs-bar-refresh", { type: "button", title: copy("refresh"), onclick: () => { if (!pending) void refresh(); } },
|
|
1181
|
+
icon("refresh"), h("span", null, copy("refresh"))),
|
|
1182
|
+
taskId ? h("a.btn.btn-ghost", { href: jobLink(taskId) }, copy("openInJobs")) : null,
|
|
1183
|
+
h("button.btn.btn-primary", { type: "button", onclick: () => editJob() }, icon("plus"), copy("newJob"))));
|
|
1184
|
+
const indexTools = h("div.jobs-index-tools", null,
|
|
1185
|
+
h("label.search-field", null, icon("search"), h("span.sr-only", null, copy("search")), indexSearch), indexCount);
|
|
1186
|
+
if (options.index) options.index.replaceChildren(indexTools, list);
|
|
1187
|
+
root.replaceChildren(...[head, options.notice ? h("p.job-notice", { role: "status" }, options.notice) : null,
|
|
1188
|
+
options.index ? h("div.jobs-receipt", null, receipt) : null,
|
|
1189
|
+
options.index ? detail : h("div.jobs-layout", null, h("div.jobs-index", null, indexTools, list), detail), editor].filter(Boolean));
|
|
1190
|
+
drawIndex();
|
|
342
1191
|
void refresh();
|
|
343
|
-
|
|
1192
|
+
// Route-driven selection (history navigation in the Jobs entry). The
|
|
1193
|
+
// owner has already resolved a draft through leave(); an editor never
|
|
1194
|
+
// outlives the Job it was opened on.
|
|
1195
|
+
function select(jobId, runId, artifact) {
|
|
1196
|
+
const reference = parseArtifact(artifact);
|
|
1197
|
+
if (selected === (jobId || undefined) && selectedRun === (runId || undefined)
|
|
1198
|
+
&& JSON.stringify(artifactState.reference) === JSON.stringify(reference)) return;
|
|
1199
|
+
if (selected !== (jobId || undefined)) closeEditor();
|
|
1200
|
+
if (runId && (selectedRun !== runId || selected !== (jobId || undefined))) revealRun = true;
|
|
1201
|
+
selected = jobId || undefined; selectedRun = runId || undefined; artifactState.reference = reference;
|
|
1202
|
+
void refresh(false);
|
|
1203
|
+
}
|
|
1204
|
+
// May the owner move this view to another selection (replace: another Job
|
|
1205
|
+
// or scope, which closes any editor)? A write in flight keeps its view so
|
|
1206
|
+
// its receipt stays observable; a draft is discarded only on confirmation.
|
|
1207
|
+
function leave(replace) {
|
|
1208
|
+
if (pending) { status(copy("leaveBusy"), true); return false; }
|
|
1209
|
+
if (!replace) return true;
|
|
1210
|
+
if (!guardDraft()) return false;
|
|
1211
|
+
closeEditor();
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1214
|
+
function dispose() { disposed = true; generation++; runGeneration++; closeEditor(); }
|
|
1215
|
+
// A hidden page must not hold a modal open; its draft waits for the return.
|
|
1216
|
+
function suspend() { if (editor.open && typeof editor.close === "function") editor.close(); }
|
|
1217
|
+
return { isDirty: () => dirty, isBusy: () => pending, refresh, select, leave, dispose,
|
|
1218
|
+
create: () => editJob(), suspend, resume: showEditor };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function parseArtifact(value) {
|
|
1222
|
+
if (!value) return null;
|
|
1223
|
+
try { return JSON.parse(value); } catch { return null; }
|
|
344
1224
|
}
|
|
345
1225
|
|
|
1226
|
+
// The one address of a Job and optionally an exact Run: the Jobs entry,
|
|
1227
|
+
// scoped to its owning Task when it has one.
|
|
346
1228
|
export function jobLink(taskId, jobId, runId) {
|
|
347
|
-
const query = new URLSearchParams({
|
|
348
|
-
...(
|
|
349
|
-
return
|
|
1229
|
+
const query = new URLSearchParams({ ...(taskId ? { task: taskId } : {}), ...(jobId ? { job: jobId } : {}),
|
|
1230
|
+
...(runId ? { jobRun: runId } : {}) });
|
|
1231
|
+
return "/jobs" + (query.size ? "?" + query : "");
|
|
350
1232
|
}
|
|
351
1233
|
`;
|