@zq-silk/yui 2.17.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/cli/commandCatalog.js +2 -1
  2. package/dist/commands/controllerCommands.js +13 -4
  3. package/dist/commands/durableJobCommands.js +50 -3
  4. package/dist/commands/globalRoleCommands.js +18 -12
  5. package/dist/commands/inboxCommands.js +1 -0
  6. package/dist/commands/operatorCommands.js +10 -2
  7. package/dist/controlPlaneCli.js +21 -4
  8. package/dist/controller/clientRuntime.js +34 -13
  9. package/dist/controller/controller.js +35 -22
  10. package/dist/controller/diagnosticJournal.js +214 -0
  11. package/dist/controller/fileSchedulerStoreAdapter.js +139 -101
  12. package/dist/controller/globalInputDelivery.js +6 -2
  13. package/dist/controller/globalRuntimeAttention.js +11 -3
  14. package/dist/controller/jobControl.js +1 -1
  15. package/dist/controller/jobDefinitionControl.js +24 -13
  16. package/dist/controller/jobSupervisor.js +4 -0
  17. package/dist/controller/runtime.js +66 -4
  18. package/dist/controller/workerSchedulerStore.js +24 -0
  19. package/dist/core/boundedRpc.js +4 -0
  20. package/dist/core/controllerServer.js +1 -1
  21. package/dist/core/controllerTelemetry.js +92 -19
  22. package/dist/job/globalShell.js +51 -0
  23. package/dist/job/jobBusinessResult.js +4 -3
  24. package/dist/job/jobDefinition.js +6 -1
  25. package/dist/job/jobRunner.js +9 -0
  26. package/dist/job/scheduleService.js +2 -0
  27. package/dist/message/globalInboxRecovery.js +30 -19
  28. package/dist/message/globalProviderRetry.js +7 -1
  29. package/dist/runtime/builtinAgentErrorMappers.js +4 -0
  30. package/dist/runtime/globalSessionHealth.js +39 -0
  31. package/dist/scheduler/roleRunStall.js +10 -10
  32. package/dist/storage/persistenceWorker.js +41 -19
  33. package/dist/storage/sqliteSchema.js +35 -11
  34. package/dist/storage/sqliteStore.js +86 -8
  35. package/dist/storage/storageVersions.js +1 -1
  36. package/dist/storage/storeRpc.js +25 -11
  37. package/dist/storage/taskReadRevisionSchema.js +39 -0
  38. package/dist/web/assets/client/app/catalog.js +0 -29
  39. package/dist/web/assets/client/app/jobs.js +115 -0
  40. package/dist/web/assets/client/app/main.js +17 -6
  41. package/dist/web/assets/client/app/manage.js +1 -0
  42. package/dist/web/assets/client/app/settings.js +1 -5
  43. package/dist/web/assets/client/i18n/en.js +203 -18
  44. package/dist/web/assets/client/i18n/zh-CN.js +202 -17
  45. package/dist/web/assets/client/index.js +2 -0
  46. package/dist/web/assets/client/layout/manageShell.js +61 -15
  47. package/dist/web/assets/client/layout/router.js +2 -1
  48. package/dist/web/assets/client/lib/prefs.js +1 -1
  49. package/dist/web/assets/client/views/jobs.js +1042 -160
  50. package/dist/web/assets/client/views/sidebar.js +3 -0
  51. package/dist/web/assets/shared/manageNav.js +3 -1
  52. package/dist/web/assets/shell/appRail.js +2 -1
  53. package/dist/web/assets/shell/manage.js +7 -2
  54. package/dist/web/assets/shell/models.js +4 -3
  55. package/dist/web/assets/shell/settingsContent.js +0 -3
  56. package/dist/web/assets/shell/sidebar.js +0 -1
  57. package/dist/web/assets/styles/views/jobs.js +301 -12
  58. package/dist/web/assets/styles/views/sidebar.js +1 -2
  59. package/dist/web/webServer.js +28 -1
  60. package/docs/global-shell-jobs.md +78 -0
  61. package/docs/job-runs.md +6 -3
  62. package/docs/managed-turn-and-session-runtime.md +74 -0
  63. package/docs/managed-turn-and-session-runtime.zh-CN.md +51 -0
  64. package/docs/release-workflow.md +43 -0
  65. package/docs/release-workflow.zh-CN.md +29 -0
  66. package/docs/storage-baseline.md +14 -1
  67. package/docs/storage-baseline.zh-CN.md +6 -1
  68. package/docs/testing/verification-levels.md +2 -2
  69. package/docs/testing/verification-levels.zh-CN.md +2 -2
  70. 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
- // One view for two server-fixed scopes. Refresh only reads facts; it never
7
- // admits due work, derives nextDue or replaces a selected exact Run with latest.
8
- export function mountJobs(root, t, taskId) {
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
- const editor = h("section");
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
- const artifactState = {};
19
- try { artifactState.reference = JSON.parse(params.get("artifact") || "null"); } catch {}
20
- function pinSelection() {
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
- const fact = (name, value) => h("p.job-fact", null, h("strong", null, name + ": "), String(value ?? "—"));
33
- const raw = (name, value) => h("details", null, h("summary", null, name), json(value));
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() { editor.replaceChildren(); dirty = false; root.dataset.unsent = "false"; }
42
- function status(text, error = false) { receipt.textContent = text; receipt.classList.toggle("settings-error", error); }
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
- const node = h("form.settings-card.settings-stack", null, h("h3", null, title), contents,
77
- h("div.settings-actions", null, h("button.btn.btn-primary", { type: "submit" }, copy("save")),
78
- button(copy("close"), () => { if (guardDraft()) closeEditor(); })));
79
- const markDirty = () => { dirty = true; root.dataset.unsent = "true"; };
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
- node.querySelector("input,select,textarea")?.focus();
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
- kind.disabled = !!job;
94
- const action = choose(["system.noop", "project.refresh"], job?.configuration.action);
95
- const project = input(job?.configuration.projectId || "");
96
- const configuration = h("textarea", { rows: 12, required: true });
97
- configuration.value = JSON.stringify(job?.configuration || { kind: "shell", owner: { kind: "task" },
98
- projectId: "", head: "", workspace: "", env: {}, steps: [{ name: "check", command: "true" }] }, null, 2);
99
- const controlled = h("div", null, field(copy("action"), action), field(copy("project"), project), h("p", null, copy("rebind")));
100
- const configured = h("div", null, h("p", null, copy("taskConfig")), field(copy("configuration"), configuration));
101
- const fields = [field("ID", id), field(copy("name"), title), field(copy("executor"), kind), controlled, configured];
102
- const sync = () => {
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
- kind.addEventListener("change", () => {
108
- configuration.value = JSON.stringify(kind.value === "agent"
109
- ? { kind: "agent", target: { kind: "job", agentId: "" }, input: "", concurrency: "serial" }
110
- : kind.value === "task" ? { kind: "task", goal: "delivery", target: {
111
- kind: "new", title: "", description: "", projectBindings: [] } }
112
- : { kind: "shell", owner: { kind: "task" }, projectId: "", head: "", workspace: "", env: {},
113
- steps: [{ name: "check", command: "true" }] }, null, 2);
114
- sync();
115
- });
116
- action.addEventListener("change", sync); sync();
117
- form(job ? copy("editJob") : copy("newJob"), fields, () => {
118
- const config = kind.value !== "builtin" ? JSON.parse(configuration.value) : {
119
- kind: "builtin", action: action.value, input: {},
120
- ...(action.value === "project.refresh" ? { projectId: project.value.trim() } : {})
121
- };
122
- void mutate("jobs", { op: job ? "update" : "define", id: id.value.trim(), title: title.value.trim(),
123
- configuration: config, ...(job ? { expectedRevision: job.revision } : {}) }, result => {
124
- selected = result.id; selectedRun = undefined; pinSelection(); closeEditor();
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 id = input(schedule?.id || "", { required: true, disabled: !!schedule });
130
- const jobId = choose(jobs.filter(j => !j.builtin || j.id === schedule?.jobId).map(j => j.id), schedule?.jobId || selected);
131
- jobId.disabled = !!schedule?.builtin;
132
- const kind = choose(schedule?.builtin ? ["interval", "cron"] : ["once", "interval", "cron"], schedule?.rule.kind || "interval");
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 || "", { placeholder: "2026-10-17T09:00:00+08:00" });
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 = input(rule?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
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 cron = field(copy("cron"), expression), zone = field(copy("timezone"), timezone);
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 = zone.hidden = kind.value !== "cron"; at.required = !when.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
- [field("ID", id), field("Job", jobId), field(copy("rule"), kind), when, interval, cron, zone,
147
- h("p", null, copy("scheduleHelp"))], () => {
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: expression.value, timezone: timezone.value };
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.value, ...(!taskId ? { authorization: "service" } : {}) }),
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
- target.replaceChildren(h("p", null, copy("loading")));
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
- target.replaceChildren(h("div.settings-stack", null, h("h4", null, run.id), fact(copy("state"), run.status),
166
- fact(copy("executor"), run.executor || (run.delegation ? "task" : "process")),
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
- ...(run.businessResult ? [h("div", null, fact(copy("result"), run.businessResult.summary),
170
- raw(copy("result"), run.businessResult))] : []),
171
- run.cancelRequestedAt ? fact(copy("cancelRequested"), run.cancelRequestedAt) : null,
172
- fact(copy("trigger"), run.trigger.source + " · " + run.trigger.key),
173
- fact(copy("created"), run.createdAt), fact(copy("finished"), run.terminalAt),
174
- raw(copy("snapshot"), run), artifacts, evidence, session));
175
- if (["queued", "running", "unknown-needs-attention"].includes(run.status)) target.prepend(
176
- button(copy("cancelRun"), () => {
177
- if (window.confirm(copy("cancelBoundary") + "\n" + runId))
178
- void mutate("jobs", { op: "run-cancel", id: jobId, runId });
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
- evidence.append(h("h4", null, copy("logs")), logs.length ? h("div", null, logs.map(log => h("section", null,
190
- h("h5", null, log.step + (log.truncated ? " · " + copy("truncated") : "")),
191
- h("pre.settings-observation", null, log.text)))) : h("p", null, copy("noLogs")));
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
- const url = new URL(location.href);
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("h4", null, copy("artifacts")), h("p", null, copy("registeredOnly")),
227
- ...refs.map(ref => button(ref.relativePath + " · " + ref.version.slice(0, 12), () => void select(ref))), viewer);
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(button(copy("inspectSaved"), async () => {
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 summary = run => run ? (run.id || run.runId) + " · " + run.status + " · "
271
- + (run.terminalAt || run.finishedAt || run.createdAt || run.attemptedAt)
272
- + (run.summary || run.businessResult?.summary ? " · " + (run.summary || run.businessResult.summary) : "") : null;
273
- const runDetail = h("section.job-run");
274
- detail.replaceChildren(h("div.settings-stack", null, h("h3", null, job.title), fact("ID", job.id),
275
- fact(copy("scope"), taskId || "global"), fact(copy("project"), job.configuration.projectId),
276
- job.builtin ? h("p", null, copy("builtinSync")) : null,
277
- fact(copy("revision"), job.revision),
278
- fact(copy("executor"), job.configuration.kind),
279
- fact(copy("recentRun"), summary(runs[0])),
280
- fact(copy("recentSuccess"), summary(runs.find(run => run.businessResult?.outcome === "succeeded"))),
281
- job.refresh ? h("div", null,
282
- fact(copy("refreshAttempt"), summary(job.refresh.lastAttempt)),
283
- fact(copy("refreshSuccess"), summary(job.refresh.lastSuccessfulRefresh))) : null,
284
- h("div.settings-actions", null,
285
- button(copy("runNow"), () => void mutate("jobs", { op: "run", id: job.id, requestId: crypto.randomUUID() }, result => {
286
- selectedRun = result.run.id; pinSelection();
287
- }), true),
288
- !job.builtin ? button(copy("editJob"), () => editJob(job)) : null,
289
- !job.builtin && !["agent", "task"].includes(job.configuration.kind) ? button(copy("newSchedule"), () => editSchedule()) : null),
290
- raw(copy("configuration"), job.configuration),
291
- job.refresh ? raw(copy("refreshFacts"), job.refresh) : null,
292
- h("h3", null, copy("schedules")),
293
- plans.length ? h("div.settings-stack", null, plans.map(({ schedule: s, lastSuccess }) => h("article.settings-card", null,
294
- h("h4", null, s.id), fact(copy("state"), s.state),
295
- s.builtin ? fact(copy("automatic"), copy(s.builtin.enabled ? "enabled" : "disabled")) : null,
296
- s.state === "completed" ? h("p", null, copy("onceCompleted")) : null,
297
- fact(copy("rule"), s.rule.kind === "cron" ? s.rule.expression : s.rule.kind === "once" ? s.rule.at : s.rule.anchor + " + " + s.rule.everyMs + " ms"),
298
- fact(copy("timezone"), s.rule.timezone || "UTC / offset"),
299
- fact(copy("nextDue"), s.state === "enabled" ? s.nextDue : null), fact(copy("lastAttempt"), s.lastAttempt
300
- ? s.lastAttempt.at + " · " + s.lastAttempt.outcome + " · " + (s.lastAttempt.runId || s.lastAttempt.reason || "") : null),
301
- fact(copy("lastSuccess"), lastSuccess ? lastSuccess.id + " · " + (lastSuccess.terminalAt || lastSuccess.updatedAt) : null),
302
- s.reason ? h("p.settings-error", null, s.reason) : null,
303
- h("div.settings-actions", null,
304
- button(copy("editSchedule"), () => editSchedule(s)),
305
- button(copy((s.builtin ? s.builtin.enabled : s.state === "enabled") ? "disable" : "enable"), () => void mutate("schedules", {
306
- op: (s.builtin ? s.builtin.enabled : s.state === "enabled") ? "disable" : "enable", id: s.id, expectedRevision: s.revision
307
- })), s.lastAttempt?.runId ? h("a.btn", { href: jobLink(taskId, s.lastAttempt.jobId || job.id, s.lastAttempt.runId) }, copy("inspectRun")) : null),
308
- raw(copy("details"), s)))) : h("p", null, copy("noSchedules")),
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) status(copy("loading"));
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
- list.replaceChildren(...(jobs.length ? jobs.map(job => h("button.job-choice", {
325
- type: "button", "aria-pressed": String(selected === job.id), onclick: () => {
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) status(copy("observed") + " · " + new Date().toISOString());
334
- } catch (error) { if (epoch === generation) status(copy("stale") + " " + error.message, true); }
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
- root.replaceChildren(h("h2", null, copy("heading")),
338
- h("p", null, copy("help")), fact(copy("scope"), taskId || "global"),
339
- h("div.settings-actions", null, button(copy("newJob"), () => editJob(), true),
340
- button(copy("refresh"), () => { if (!pending) void refresh(); })),
341
- receipt, editor, h("div.jobs-layout", null, list, detail));
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
- return { isDirty: () => dirty, isBusy: () => pending, refresh };
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({ job: jobId, ...(runId ? { jobRun: runId } : {}),
348
- ...(taskId ? { task: taskId, section: "jobs" } : {}) });
349
- return (taskId ? "/" : "/settings/jobs") + "?" + query;
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
  `;