@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
@@ -20,7 +20,8 @@
20
20
  * SQLite is the only product Store. `YUI_STORE_WORKER` selects the observer
21
21
  * execution boundary; it does not select another authority.
22
22
  */
23
- import { BoundedRpcClient, nextRequestId } from "../core/boundedRpc.js";
23
+ import { BoundedRpcClient, deserializeError as deserializeRpcError, nextRequestId } from "../core/boundedRpc.js";
24
+ import { ControllerWorkObserver } from "../core/controllerTelemetry.js";
24
25
  import { StorageCancelledError, StorageConflictError, StorageRecordError } from "./taskStore.js";
25
26
  // -- Read-only classification ------------------------------------------------
26
27
  /**
@@ -37,6 +38,8 @@ const READ_ONLY_STORE_METHODS = new Set([
37
38
  "getHomeIdentity",
38
39
  "getReviewConfig",
39
40
  "getRevision",
41
+ "getStateRevision",
42
+ "getTaskReadRevision",
40
43
  "listConfiguredAgents",
41
44
  "getConfiguredAgent",
42
45
  "listProjects",
@@ -57,6 +60,7 @@ const READ_ONLY_STORE_METHODS = new Set([
57
60
  "queryTaskSearch",
58
61
  "latestEventSequence",
59
62
  "listEventsByType",
63
+ "hasRuntimeObservation",
60
64
  "readTaskUsageFacts",
61
65
  "listTaskRunWorkspaceBases",
62
66
  "contextInputReferences",
@@ -72,6 +76,7 @@ const READ_ONLY_STORE_METHODS = new Set([
72
76
  "listActiveDurableJobs",
73
77
  "getJobDefinition",
74
78
  "listJobDefinitions",
79
+ "listJobScopeSummaries",
75
80
  "getGlobalJobRun",
76
81
  "getAgentJobRun",
77
82
  "listAgentJobRuns",
@@ -137,12 +142,10 @@ function deserializeError(serialized) {
137
142
  if (name === "StorageCancelledError" || name === "AbortError") {
138
143
  return new StorageCancelledError(message);
139
144
  }
140
- const error = new Error(message);
141
- error.name = name;
142
- return error;
145
+ return deserializeRpcError(serialized);
143
146
  }
144
147
  // -- Storage protocol adapter -------------------------------------------------
145
- function storageProtocol(home, options) {
148
+ function storageProtocol(home, options, observe) {
146
149
  return {
147
150
  initRequest: () => ({
148
151
  kind: "init",
@@ -162,6 +165,8 @@ function storageProtocol(home, options) {
162
165
  return response.requestId;
163
166
  },
164
167
  settle: (response, settlement) => {
168
+ if (response.observation !== undefined)
169
+ observe(response.observation);
165
170
  if (response.kind === "result") {
166
171
  settlement.resolve(response.result);
167
172
  return;
@@ -188,8 +193,17 @@ function storageProtocol(home, options) {
188
193
  */
189
194
  export class AsyncTaskStoreClient {
190
195
  #rpc;
196
+ #waits = new ControllerWorkObserver();
197
+ #worker = null;
198
+ /** Cached observations only: status never waits on a busy persistence worker. */
199
+ observationMetrics() {
200
+ return { roundTrips: this.#waits.snapshot(), worker: this.#worker,
201
+ inFlight: this.inFlight, queueDepth: this.queueDepth };
202
+ }
191
203
  constructor(home, options = {}) {
192
- this.#rpc = new BoundedRpcClient(storageProtocol(home, options), {
204
+ this.#rpc = new BoundedRpcClient(storageProtocol(home, options, observation => {
205
+ this.#worker = observation;
206
+ }), {
193
207
  maxInFlight: options.maxInFlight,
194
208
  maxQueue: options.maxQueue,
195
209
  workerScript: options.workerScript ?? new URL("./persistenceWorker.js", import.meta.url)
@@ -198,13 +212,13 @@ export class AsyncTaskStoreClient {
198
212
  /** Invoke a TaskStore method over the RPC (used by the Proxy). */
199
213
  callStore(method, args, options) {
200
214
  const requestId = options?.requestId ?? nextRequestId();
201
- return this.#rpc.send(requestId, {
215
+ return this.#waits.observe("store-call", requestId, () => this.#rpc.send(requestId, {
202
216
  kind: "call",
203
217
  requestId,
204
218
  method,
205
219
  args,
206
220
  readOnly: isReadOnlyMethod(method)
207
- }, { signal: options?.signal });
221
+ }, { signal: options?.signal }));
208
222
  }
209
223
  /** Run a command batch atomically in the worker (§3.2). */
210
224
  transactionAsync(commands, options) {
@@ -213,12 +227,12 @@ export class AsyncTaskStoreClient {
213
227
  op: command.op,
214
228
  args: command.args
215
229
  }));
216
- return this.#rpc.send(requestId, {
230
+ return this.#waits.observe("store-transaction", requestId, () => this.#rpc.send(requestId, {
217
231
  kind: "transaction",
218
232
  requestId,
219
233
  commands: wireCommands,
220
234
  ...(options?.expectedRevision === undefined ? {} : { expectedRevision: options.expectedRevision })
221
- }, { signal: options?.signal });
235
+ }, { signal: options?.signal }));
222
236
  }
223
237
  /**
224
238
  * Invoke a controller observer method hosted by the worker (the
@@ -227,7 +241,7 @@ export class AsyncTaskStoreClient {
227
241
  */
228
242
  invokeObserver(method, args, options) {
229
243
  const requestId = options?.requestId ?? nextRequestId();
230
- return this.#rpc.send(requestId, { kind: "observer", requestId, method, args }, { signal: options?.signal });
244
+ return this.#waits.observe("store-observer", requestId, () => this.#rpc.send(requestId, { kind: "observer", requestId, method, args }, { signal: options?.signal }));
231
245
  }
232
246
  /** Close the worker and release its connections. */
233
247
  close() {
@@ -0,0 +1,39 @@
1
+ /** Storage 1.24 definition: immutable once published. These are cache tokens,
2
+ * not a second Task state or a CAS/authorization source. Triggers cover every
3
+ * source family, including direct SQL, deletions and rolled-back transactions.
4
+ */
5
+ export const TASK_READ_REVISION_SOURCES = [
6
+ "events", "turns", "work_items", "review_rounds", "change_sets",
7
+ "integration_attempts", "input_requests", "durable_jobs", "messages"
8
+ ];
9
+ export const TASK_READ_REVISION_SCHEMA_SQL = `
10
+ CREATE TABLE task_read_revisions (
11
+ task_id TEXT PRIMARY KEY,
12
+ revision INTEGER NOT NULL
13
+ );
14
+ CREATE INDEX idx_runtime_observation_semantic
15
+ ON events(task_id, json_extract(payload, '$.payload.semanticKey'))
16
+ WHERE type = 'runtime.observation';
17
+ CREATE INDEX idx_runtime_observation_event
18
+ ON events(task_id, json_extract(payload, '$.payload.eventId'))
19
+ WHERE type = 'runtime.observation';
20
+ ${TASK_READ_REVISION_SOURCES.map(table => `
21
+ CREATE TRIGGER ${table}_read_ai AFTER INSERT ON ${table}
22
+ BEGIN
23
+ INSERT INTO task_read_revisions VALUES (NEW.task_id, 1)
24
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
25
+ END;
26
+ CREATE TRIGGER ${table}_read_ad AFTER DELETE ON ${table}
27
+ BEGIN
28
+ INSERT INTO task_read_revisions VALUES (OLD.task_id, 1)
29
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
30
+ END;
31
+ CREATE TRIGGER ${table}_read_au AFTER UPDATE ON ${table}
32
+ BEGIN
33
+ INSERT INTO task_read_revisions VALUES (NEW.task_id, 1)
34
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
35
+ INSERT INTO task_read_revisions
36
+ SELECT OLD.task_id, 1 WHERE OLD.task_id != NEW.task_id
37
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
38
+ END;
39
+ `).join("")}`;
@@ -111,10 +111,6 @@ function renderSidebar(deps, progress) {
111
111
  reset(deps, progress);
112
112
  });
113
113
  el.catalogCount.textContent = fill(t("catalog.count"), { shown: state.tasks.length, total: state.catalogTotal });
114
- renderSource(el.source, state, t, function (value) {
115
- state.source = value; state.attentionFilter = null; state.query = ""; el.search.value = "";
116
- syncUrl(state, false); reset(deps, progress);
117
- });
118
114
  el.catalogReset.hidden = !state.catalogCursor;
119
115
  el.catalogNext.hidden = !state.nextCursor && !state.catalogCursor;
120
116
  el.catalogAttentionReset.hidden = !state.attentionFilter;
@@ -122,27 +118,6 @@ function renderSidebar(deps, progress) {
122
118
  el.lastSync.textContent = state.generatedAt ? formatClock(state.generatedAt, deps.locale()) : "—";
123
119
  }
124
120
 
125
- // Task category: search and attention are cross-category, so they show "all";
126
- // picking a category clears them, as the server filter then applies again.
127
- function renderSource(container, state, t, onPick) {
128
- const current = state.query.trim() || state.attentionFilter ? "all" : state.source || "ordinary";
129
- // Bound once; the handler reads the live state, not this render's snapshot.
130
- if (!container.childElementCount) {
131
- ["ordinary", "job", "all"].forEach(function (value) {
132
- container.append(h("button.seg-btn", { type: "button", role: "radio", dataset: { source: value },
133
- onclick: function () {
134
- if (value !== (state.source || "ordinary") || state.query.trim() || state.attentionFilter) onPick(value);
135
- } }));
136
- });
137
- }
138
- container.querySelectorAll("[data-source]").forEach(function (button) {
139
- const value = button.dataset.source;
140
- button.textContent = t("jobs.categoryShort." + value);
141
- button.title = t("jobs.category." + value);
142
- button.setAttribute("aria-checked", String(value === current));
143
- });
144
- }
145
-
146
121
  // Redraw the list in place, keeping its scroll position.
147
122
  function renderTaskList(deps) {
148
123
  const scroll = deps.el.tasks.scrollTop;
@@ -175,9 +150,6 @@ function catalogQuery(state) {
175
150
  if (state.filter !== "all") query.set("status", state.filter);
176
151
  if (state.query.trim()) query.set("search", state.query.trim());
177
152
  if (state.attentionFilter) query.set("attention", state.attentionFilter);
178
- // Search and attention remain cross-category discovery; the server owns filtering.
179
- if (!state.query.trim() && !state.attentionFilter && state.source !== "all")
180
- query.set("source", state.source || "ordinary");
181
153
  if (state.catalogCursor) query.set("cursor", state.catalogCursor);
182
154
  return query.toString();
183
155
  }
@@ -233,7 +205,6 @@ function applyFilters(deps, params) {
233
205
  state.query = params.get("q") || "";
234
206
  state.attentionFilter = ATTENTION_KINDS.includes(params.get("attention")) ? params.get("attention") : null;
235
207
  state.catalogAll = params.get("all") === "true";
236
- state.source = ["ordinary", "job", "all"].includes(params.get("source")) ? params.get("source") : "ordinary";
237
208
  deps.el.search.value = state.query;
238
209
  }
239
210
 
@@ -0,0 +1,115 @@
1
+ export const JOBS_APP_SCRIPT = String.raw `
2
+ import { h, icon } from "/assets/js/lib/dom.js";
3
+ import { formatClock } from "/assets/js/lib/format.js";
4
+ import { requestJson } from "/assets/js/lib/api.js";
5
+ import { i18n, registerManagePage, navigateManage, replaceManageRoute, pushManageRoute } from "/assets/js/layout/manageShell.js";
6
+ import { mountJobs } from "/assets/js/views/jobs.js";
7
+
8
+ // The Jobs entry: one shared Job module (the same one the Task page uses)
9
+ // over exactly one scope at a time, global or one owning Task. The scope,
10
+ // the selected Job and Run live in the URL, so deep links, refresh and
11
+ // Back/Forward all reopen the same view. One view is live at a time: a
12
+ // replaced view is retired, so its late reads cannot write this page.
13
+ const t = i18n.t;
14
+ const root = document.querySelector("#jobs-root");
15
+ const index = document.querySelector("#jobs-index");
16
+ const scopeSelect = h("select", { id: "jobs-scope", "aria-describedby": "jobs-scope-help" });
17
+ const scopeLabel = h("label", { for: "jobs-scope" });
18
+ const scopeHelp = h("p.jobs-scope-help", { id: "jobs-scope-help" });
19
+ const listMount = h("div.jobs-index-body");
20
+ // The list head works as on the workbench: one primary "new" action at the
21
+ // top of the list (creating in the selected scope), the last read time and
22
+ // a refresh control in the head. The page itself carries no second title.
23
+ const createLabel = h("span");
24
+ const create = h("button.btn.btn-primary.side-create", { id: "jobs-create", type: "button",
25
+ onclick: () => { if (view) view.create(); } }, icon("plus"), createLabel);
26
+ const sync = document.querySelector("#jobs-sync");
27
+ const lastSync = document.querySelector("#jobs-last-sync");
28
+ const refreshButton = document.querySelector("#jobs-refresh");
29
+ if (refreshButton) refreshButton.addEventListener("click", () => { if (view && !view.isBusy()) void view.refresh(); });
30
+ index.replaceChildren(create, h("div.jobs-scope", null, scopeLabel, scopeSelect, scopeHelp), listMount);
31
+ let route = null, view = null, mountedScope, scopes = null, scopesError = null;
32
+
33
+ scopeSelect.addEventListener("change", () => {
34
+ const task = scopeSelect.value || null;
35
+ if ((route && route.task) === task) return;
36
+ if (!navigateManage({ page: "jobs", section: "jobs", task })) drawScopes();
37
+ });
38
+
39
+ function drawScopes() {
40
+ createLabel.textContent = t("jobs.newJob");
41
+ create.title = t("jobs.newJobIn") + " · " + (route && route.task ? route.task : t("jobs.ownerGlobal"));
42
+ scopeLabel.textContent = t("jobs.scopeLabel");
43
+ scopeHelp.textContent = scopesError ? t("jobs.scopesError") + " " + scopesError : t("jobs.scopeHelp");
44
+ scopeHelp.classList.toggle("is-error", !!scopesError);
45
+ const current = route ? route.task : null;
46
+ const tasks = scopes ? scopes.tasks.slice() : [];
47
+ // A deep-linked Task stays selectable even when it has no Job yet.
48
+ if (current && !tasks.some(item => item.taskId === current)) tasks.unshift({ taskId: current, title: "" });
49
+ scopeSelect.replaceChildren(h("option", { value: "" }, t("jobs.ownerGlobal")),
50
+ ...tasks.map(item => h("option", { value: item.taskId }, item.taskId + (item.title ? " · " + item.title : ""))));
51
+ scopeSelect.value = current || "";
52
+ }
53
+ async function loadScopes() {
54
+ try { scopes = await requestJson("/api/jobs/scopes"); scopesError = null; }
55
+ catch (error) { scopesError = error.message; }
56
+ drawScopes();
57
+ }
58
+ function mount(next) {
59
+ if (view) view.dispose();
60
+ mountedScope = next.task || null;
61
+ let self = null;
62
+ self = view = mountJobs(root, t, mountedScope || undefined, {
63
+ index: listMount,
64
+ navigate: selection => navigateManage({ ...route, job: selection.job, jobRun: null, artifact: null, moved: undefined }),
65
+ pin: (selection, step) => pin(self, selection, step),
66
+ sync: observation => observe(self, observation),
67
+ notice: next.moved ? t("jobs.moved") : "",
68
+ locale: i18n.getLocale
69
+ });
70
+ }
71
+ // The last read of the live view, in the list head (as the workbench's sync).
72
+ function observe(owner, observation) {
73
+ if (owner !== view || !sync) return;
74
+ sync.dataset.state = observation.state;
75
+ if (observation.at) lastSync.textContent = formatClock(observation.at, i18n.getLocale());
76
+ sync.title = observation.text;
77
+ }
78
+ // Only the live view, while Jobs is the shown page, records its selection.
79
+ // A Run the reader opened or closed in place is a history step; anything else
80
+ // the view settles on refines the current entry.
81
+ function pin(owner, selection, step) {
82
+ if (owner !== view || root.hidden || !route || route.page !== "jobs") return;
83
+ route = { ...route, ...selection, moved: undefined };
84
+ if (step) pushManageRoute(route); else replaceManageRoute(route);
85
+ }
86
+ function show(next) {
87
+ route = next;
88
+ drawScopes();
89
+ if (!view || mountedScope !== (next.task || null)) mount(next);
90
+ else { view.resume(); view.select(next.job, next.jobRun, next.artifact); }
91
+ }
92
+ // Leaving Jobs sets a modal editor aside (its draft is kept for the return).
93
+ function hide() { if (view) view.suspend(); }
94
+ // Asked before any route change that leaves or enters Jobs, whatever its
95
+ // source. Leaving Jobs keeps its view (hidden) as it is; a change of scope or
96
+ // Job replaces the editor, so a draft needs confirmation; while a write is in
97
+ // flight the selection stays put so its receipt remains observable.
98
+ function canNavigate(next) {
99
+ if (!view || next.page !== "jobs" || !route) return true;
100
+ const same = key => (next[key] || null) === (route[key] || null);
101
+ if ((next.task || null) === mountedScope && same("job") && same("jobRun") && same("artifact")) return true;
102
+ if (view.isBusy() && root.hidden) { window.alert(t("jobs.leaveBusy")); return false; }
103
+ return view.leave((next.task || null) !== mountedScope || !same("job"));
104
+ }
105
+ registerManagePage("jobs", { show, hide, canNavigate, crumb: () => route && route.task ? route.task : t("jobs.ownerGlobal") });
106
+ void loadScopes();
107
+ i18n.subscribe(() => {
108
+ drawScopes();
109
+ if (route && route.page === "jobs" && view && !view.isDirty() && !view.isBusy()) mount({ ...route, moved: false });
110
+ });
111
+ window.addEventListener("beforeunload", event => {
112
+ if (!view || !view.isDirty()) return;
113
+ event.preventDefault(); event.returnValue = "";
114
+ });
115
+ `;
@@ -18,7 +18,7 @@ export const APP_SCRIPT = String.raw `
18
18
  // refresh catalog.refresh → sidebar, then the overview (nothing selected)
19
19
  // or selection.reload → taskView.render
20
20
  // select sidebar row / overview row / URL → selection.selectTask
21
- // leave rail Workbench or Escape → selection.clearSelection
21
+ // leave rail Tasks or Escape → selection.clearSelection
22
22
  // write Task page or discussion afterWrite → quiet catalog refresh
23
23
  // URL load and back/forward → catalog.applyFilters + selection.followUrl
24
24
  import { createI18n } from "/assets/js/lib/i18n.js";
@@ -30,7 +30,7 @@ import { createPreviewController } from "/assets/js/views/dock/preview.js";
30
30
  import { bindGlobalInput } from "/assets/js/views/globalInput.js";
31
31
  import { bindCreateTask } from "/assets/js/views/createTask.js";
32
32
  import { bindBodySearch } from "/assets/js/views/bodySearch.js";
33
- import { readQuery } from "/assets/js/layout/router.js";
33
+ import { readQuery, applyUrl } from "/assets/js/layout/router.js";
34
34
  import { rememberPlace, linkPlaces, readSessionAccessMode } from "/assets/js/lib/prefs.js";
35
35
  import { createWorkspace } from "/assets/js/layout/workspace.js";
36
36
  import { createSidebarNav } from "/assets/js/layout/sidebarNav.js";
@@ -43,7 +43,7 @@ const $ = function (selector) { return document.querySelector(selector); };
43
43
  const el = {
44
44
  sidebar: $(".sidebar"), rail: $(".app-rail"), railWorkbench: $("#rail-workbench"),
45
45
  sidebarScrim: $("#sidebar-scrim"), sidebarCollapse: $("#sidebar-collapse"), sidebarDivider: $("#sidebar-divider"), center: $("#center"), detail: $("#detail"),
46
- search: $("#search"), source: $("#catalog-source"), filters: $("#status-filters"), attention: $("#attention-bar"), tasks: $("#task-list"),
46
+ search: $("#search"), filters: $("#status-filters"), attention: $("#attention-bar"), tasks: $("#task-list"),
47
47
  catalogNext: $("#catalog-next"), catalogReset: $("#catalog-reset"), catalogCount: $("#catalog-count"),
48
48
  catalogAttentionReset: $("#catalog-attention-reset"),
49
49
  refresh: $("#refresh"), sync: $("#sync-state"), lastSync: $("#last-sync"), toast: $("#toast"),
@@ -119,11 +119,11 @@ bindShortcuts({
119
119
  el: el, state: state, globalInput: globalInput, workspace: workspace, sidebarNav: sidebarNav,
120
120
  selection: selection, catalog: catalog, taskView: taskView
121
121
  });
122
- // Workbench <-> Models/Settings: each side remembers where the user was, so a
122
+ // Tasks <-> Jobs/Models/Settings: each side remembers where the user was, so a
123
123
  // round trip returns to the same filters, Task and management section.
124
124
  function rememberWorkbench() { rememberPlace("workbench", window.location.pathname + window.location.search); }
125
125
  el.rail.querySelectorAll("a[data-nav-area]").forEach(function (link) { link.addEventListener("click", rememberWorkbench); });
126
- // The rail's Workbench entry on the workbench itself returns to the overview.
126
+ // The rail's Tasks entry on the Task workbench itself returns to the overview.
127
127
  el.railWorkbench.addEventListener("click", function (event) {
128
128
  if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
129
129
  event.preventDefault();
@@ -135,9 +135,20 @@ function linkAll() { linkPlaces(el.rail); linkPlaces(el.sidebar); }
135
135
  window.addEventListener("pageshow", linkAll);
136
136
  linkAll();
137
137
 
138
+ // The retired Task category filter (?source=ordinary|job|all): the Task
139
+ // list now always shows every real Task, so drop the parameter in place
140
+ // (no extra history step) and say why once; Jobs have their own entry.
141
+ let sourceHinted = false;
142
+ function stripRetiredSource(params) {
143
+ if (!params.has("source")) return params;
144
+ params.delete("source");
145
+ applyUrl(params, true);
146
+ if (!sourceHinted) { sourceHinted = true; toast(t("catalog.sourceRemoved")); }
147
+ return params;
148
+ }
138
149
  // The URL names the catalog filters and the selected Task (with its section).
139
150
  function applyStateFromUrl() {
140
- const params = readQuery();
151
+ const params = stripRetiredSource(readQuery());
141
152
  catalog.applyFilters(params);
142
153
  selection.followUrl(params);
143
154
  }
@@ -1,6 +1,7 @@
1
1
  export const MANAGE_SCRIPT = String.raw `
2
2
  import "/assets/js/app/settings.js";
3
3
  import "/assets/js/app/models.js";
4
+ import "/assets/js/app/jobs.js";
4
5
  import { startManagement } from "/assets/js/layout/manageShell.js";
5
6
  import { createSidebarNav } from "/assets/js/layout/sidebarNav.js";
6
7
  import { isEditing } from "/assets/js/lib/dom.js";
@@ -8,7 +8,6 @@ import { createThemeController } from "/assets/js/lib/theme.js";
8
8
  import { clearPreference, readSessionAccessMode, writeSessionAccessMode } from "/assets/js/lib/prefs.js";
9
9
  import { i18n, createManageShell, pageSections, registerManagePage, navigateManage } from "/assets/js/layout/manageShell.js";
10
10
  import { mountSkills } from "/assets/js/views/skills.js";
11
- import { mountJobs } from "/assets/js/views/jobs.js";
12
11
  import { mountPermissions } from "/assets/js/views/permissions.js";
13
12
 
14
13
  const $ = selector => document.querySelector(selector);
@@ -20,14 +19,12 @@ function readSection() {
20
19
  return SECTIONS.includes(section) ? section : SECTIONS[0];
21
20
  }
22
21
  let section = readSection();
23
- let jobs;
24
22
  let permissions;
25
23
  const shell = createManageShell({ page: "settings", t, onNavigate: navigate });
26
24
  function showSection(next) {
27
25
  section = next;
28
26
  document.querySelectorAll("#settings-root > .manage-section[data-section]").forEach(panel => { panel.hidden = panel.dataset.section !== next; });
29
27
  shell.set(next);
30
- if (next === "jobs" && !jobs) jobs = mountJobs($("#global-jobs"), t);
31
28
  if (next === "permissions" && !permissions) permissions = mountPermissions($("#permission-browser"), t);
32
29
  }
33
30
  function navigate(next) {
@@ -41,7 +38,6 @@ function markDirty(name, value) {
41
38
  registerManagePage("settings", { show: next => showSection(next.section) });
42
39
  i18n.subscribe(() => shell.render());
43
40
  i18n.subscribe(() => {
44
- if (jobs && !jobs.isDirty() && !jobs.isBusy()) jobs = mountJobs($("#global-jobs"), t);
45
41
  permissions?.translate();
46
42
  });
47
43
  showSection(section);
@@ -69,7 +65,7 @@ let searchTimer;
69
65
  const more = h("button.btn", { type: "button", hidden: true }, t("settings.more"));
70
66
  container.after(more);
71
67
  window.addEventListener("beforeunload", event => {
72
- if (!groups.some(g => g.dirty) && !jobs?.isDirty()) return;
68
+ if (!groups.some(g => g.dirty)) return;
73
69
  event.preventDefault(); event.returnValue = "";
74
70
  });
75
71