@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.
- package/dist/cli/commandCatalog.js +2 -1
- package/dist/commands/controllerCommands.js +13 -4
- package/dist/commands/durableJobCommands.js +50 -3
- package/dist/commands/globalRoleCommands.js +18 -12
- package/dist/commands/inboxCommands.js +1 -0
- package/dist/commands/operatorCommands.js +10 -2
- package/dist/controlPlaneCli.js +21 -4
- package/dist/controller/clientRuntime.js +34 -13
- package/dist/controller/controller.js +35 -22
- package/dist/controller/diagnosticJournal.js +214 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +139 -101
- package/dist/controller/globalInputDelivery.js +6 -2
- package/dist/controller/globalRuntimeAttention.js +11 -3
- package/dist/controller/jobControl.js +1 -1
- package/dist/controller/jobDefinitionControl.js +24 -13
- package/dist/controller/jobSupervisor.js +4 -0
- package/dist/controller/runtime.js +66 -4
- package/dist/controller/workerSchedulerStore.js +24 -0
- package/dist/core/boundedRpc.js +4 -0
- package/dist/core/controllerServer.js +1 -1
- package/dist/core/controllerTelemetry.js +92 -19
- package/dist/job/globalShell.js +51 -0
- package/dist/job/jobBusinessResult.js +4 -3
- package/dist/job/jobDefinition.js +6 -1
- package/dist/job/jobRunner.js +9 -0
- package/dist/job/scheduleService.js +2 -0
- package/dist/message/globalInboxRecovery.js +30 -19
- package/dist/message/globalProviderRetry.js +7 -1
- package/dist/runtime/builtinAgentErrorMappers.js +4 -0
- package/dist/runtime/globalSessionHealth.js +39 -0
- package/dist/scheduler/roleRunStall.js +10 -10
- package/dist/storage/persistenceWorker.js +41 -19
- package/dist/storage/sqliteSchema.js +35 -11
- package/dist/storage/sqliteStore.js +86 -8
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +25 -11
- package/dist/storage/taskReadRevisionSchema.js +39 -0
- package/dist/web/assets/client/app/catalog.js +0 -29
- package/dist/web/assets/client/app/jobs.js +115 -0
- package/dist/web/assets/client/app/main.js +17 -6
- package/dist/web/assets/client/app/manage.js +1 -0
- package/dist/web/assets/client/app/settings.js +1 -5
- package/dist/web/assets/client/i18n/en.js +203 -18
- package/dist/web/assets/client/i18n/zh-CN.js +202 -17
- package/dist/web/assets/client/index.js +2 -0
- package/dist/web/assets/client/layout/manageShell.js +61 -15
- package/dist/web/assets/client/layout/router.js +2 -1
- package/dist/web/assets/client/lib/prefs.js +1 -1
- package/dist/web/assets/client/views/jobs.js +1042 -160
- package/dist/web/assets/client/views/sidebar.js +3 -0
- package/dist/web/assets/shared/manageNav.js +3 -1
- package/dist/web/assets/shell/appRail.js +2 -1
- package/dist/web/assets/shell/manage.js +7 -2
- package/dist/web/assets/shell/models.js +4 -3
- package/dist/web/assets/shell/settingsContent.js +0 -3
- package/dist/web/assets/shell/sidebar.js +0 -1
- package/dist/web/assets/styles/views/jobs.js +301 -12
- package/dist/web/assets/styles/views/sidebar.js +1 -2
- package/dist/web/webServer.js +28 -1
- package/docs/global-shell-jobs.md +78 -0
- package/docs/job-runs.md +6 -3
- package/docs/managed-turn-and-session-runtime.md +74 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +51 -0
- package/docs/release-workflow.md +43 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/storage-baseline.md +14 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- package/package.json +1 -1
package/dist/storage/storeRpc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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"),
|
|
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
|
-
//
|
|
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
|
|
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)
|
|
68
|
+
if (!groups.some(g => g.dirty)) return;
|
|
73
69
|
event.preventDefault(); event.returnValue = "";
|
|
74
70
|
});
|
|
75
71
|
|