@zq-silk/yui 2.11.0 → 2.12.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 (79) hide show
  1. package/dist/artifacts/artifactCapability.js +14 -8
  2. package/dist/artifacts/artifactCommitLock.js +7 -5
  3. package/dist/artifacts/jobArtifact.js +93 -0
  4. package/dist/artifacts/jobArtifactTransport.js +13 -0
  5. package/dist/artifacts/taskArtifactRepository.js +58 -10
  6. package/dist/cli/commandCatalog.js +45 -14
  7. package/dist/cli/commandDiscovery.js +1 -1
  8. package/dist/commands/durableJobCommands.js +31 -6
  9. package/dist/commands/globalRoleCommands.js +5 -0
  10. package/dist/commands/scheduleCommands.js +14 -3
  11. package/dist/commands/taskCommands.js +5 -2
  12. package/dist/context/taskCatalog.js +6 -1
  13. package/dist/controlPlaneCli.js +7 -2
  14. package/dist/controller/agentJobControl.js +391 -0
  15. package/dist/controller/controller.js +24 -3
  16. package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
  17. package/dist/controller/globalInputDelivery.js +8 -1
  18. package/dist/controller/jobArtifactControl.js +111 -0
  19. package/dist/controller/jobControl.js +4 -0
  20. package/dist/controller/jobDefinitionControl.js +191 -19
  21. package/dist/controller/jobSupervisor.js +83 -3
  22. package/dist/controller/jobTaskDelegationControl.js +193 -0
  23. package/dist/controller/runtime.js +7 -1
  24. package/dist/controller/scheduleControl.js +83 -20
  25. package/dist/integration/gitIntegrationService.js +2 -0
  26. package/dist/job/agentJob.js +131 -0
  27. package/dist/job/agentJobObservation.js +61 -0
  28. package/dist/job/agentRoleScope.js +33 -0
  29. package/dist/job/durableJob.js +63 -28
  30. package/dist/job/jobAssignmentScope.js +1 -0
  31. package/dist/job/jobBusinessResult.js +72 -0
  32. package/dist/job/jobCleanupInspection.js +4 -0
  33. package/dist/job/jobDefinition.js +26 -2
  34. package/dist/job/jobRunner.js +4 -3
  35. package/dist/job/projectRefreshRunner.js +54 -18
  36. package/dist/job/projectSync.js +162 -0
  37. package/dist/job/scheduleService.js +55 -0
  38. package/dist/job/taskDelegation.js +48 -0
  39. package/dist/message/messageContinuation.js +1 -1
  40. package/dist/resources/projectResourceService.js +1 -1
  41. package/dist/resources/resourceGc.js +1 -1
  42. package/dist/role/role.js +12 -0
  43. package/dist/schedule/schedule.js +65 -15
  44. package/dist/scheduler/wakeReason.js +4 -0
  45. package/dist/storage/agentJobSchema.js +10 -0
  46. package/dist/storage/recordValidation.js +2 -0
  47. package/dist/storage/sqliteSchema.js +77 -6
  48. package/dist/storage/sqliteStore.js +53 -0
  49. package/dist/storage/storageVersions.js +1 -1
  50. package/dist/storage/storeRpc.js +3 -0
  51. package/dist/storage/taskCatalog.js +10 -2
  52. package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
  53. package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
  54. package/dist/task/archivePreflight.js +15 -0
  55. package/dist/task/completionReadiness.js +7 -0
  56. package/dist/task/task.js +17 -1
  57. package/dist/verification/verificationGateService.js +4 -0
  58. package/dist/web/assets/client/app/catalog.js +16 -0
  59. package/dist/web/assets/client/i18n/en.js +29 -2
  60. package/dist/web/assets/client/i18n/zh-CN.js +29 -2
  61. package/dist/web/assets/client/layout/manageShell.js +9 -0
  62. package/dist/web/assets/client/layout/router.js +1 -0
  63. package/dist/web/assets/client/views/jobs.js +166 -37
  64. package/dist/web/assets/client/views/task/files.js +5 -4
  65. package/dist/web/assets/client/views/task/page.js +5 -1
  66. package/dist/web/taskPreviews.js +2 -2
  67. package/dist/web/webJobs.js +36 -11
  68. package/dist/web/webMaterials.js +21 -9
  69. package/dist/web/webServer.js +6 -5
  70. package/dist/web/webTaskSurface.js +1 -0
  71. package/docs/job-runs.md +392 -17
  72. package/docs/release-workflow.md +42 -0
  73. package/docs/release-workflow.zh-CN.md +31 -0
  74. package/docs/schedules.md +69 -15
  75. package/docs/storage-baseline.md +38 -1
  76. package/docs/storage-baseline.zh-CN.md +27 -1
  77. package/docs/testing/verification-levels.md +2 -2
  78. package/docs/testing/verification-levels.zh-CN.md +2 -2
  79. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  export const JOBS_SCRIPT = String.raw `
2
2
  import { h } from "/assets/js/lib/dom.js";
3
3
  import { requestJson, submitMutation } from "/assets/js/lib/api.js";
4
+ import { drawArtifact } from "/assets/js/views/task/files.js";
4
5
 
5
6
  // One view for two server-fixed scopes. Refresh only reads facts; it never
6
7
  // admits due work, derives nextDue or replaces a selected exact Run with latest.
@@ -11,7 +12,17 @@ export function mountJobs(root, t, taskId) {
11
12
  const list = h("div.jobs-list");
12
13
  const detail = h("section.jobs-detail", { "aria-label": copy("details") });
13
14
  const editor = h("section");
14
- let jobs = [], schedules = [], selected, selectedRun, generation = 0, pending = false, dirty = false;
15
+ const params = new URLSearchParams(location.search);
16
+ 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() {
21
+ const url = new URL(location.href);
22
+ if (selected) url.searchParams.set("job", selected); else url.searchParams.delete("job");
23
+ if (selectedRun) url.searchParams.set("jobRun", selectedRun); else url.searchParams.delete("jobRun");
24
+ history.replaceState(history.state, "", url);
25
+ }
15
26
  const blocked = new Set();
16
27
  const read = (family, input) => requestJson(base + "/" + family + "?" + new URLSearchParams(input));
17
28
  const button = (text, action, primary = false) => h("button.btn" + (primary ? ".btn-primary" : ""), {
@@ -40,6 +51,11 @@ export function mountJobs(root, t, taskId) {
40
51
  status(copy("submitting"));
41
52
  try {
42
53
  const result = await submitMutation(key, base + "/" + family, payload);
54
+ if (result.status === "saved-unregistered") {
55
+ status(copy("registerHelp") + "\n" + result.error, true);
56
+ await refresh(false);
57
+ return;
58
+ }
43
59
  status(payload.op === "run"
44
60
  ? (result.reason === "overlap" ? copy("overlap") : copy("admitted")) + " · " + result.run.id + " · " + result.run.status
45
61
  : payload.op === "run-cancel" ? copy("cancelRequested") : copy("saved"));
@@ -73,39 +89,53 @@ export function mountJobs(root, t, taskId) {
73
89
  function editJob(job) {
74
90
  const id = input(job?.id || "", { required: true, pattern: "[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", disabled: !!job });
75
91
  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;
76
94
  const action = choose(["system.noop", "project.refresh"], job?.configuration.action);
77
95
  const project = input(job?.configuration.projectId || "");
78
96
  const configuration = h("textarea", { rows: 12, required: true });
79
97
  configuration.value = JSON.stringify(job?.configuration || { kind: "shell", owner: { kind: "task" },
80
98
  projectId: "", head: "", workspace: "", env: {}, steps: [{ name: "check", command: "true" }] }, null, 2);
81
- const fields = [field("ID", id), field(copy("name"), title)];
82
- if (taskId) fields.push(h("p", null, copy("taskConfig")), field(copy("configuration"), configuration));
83
- else {
84
- fields.push(field(copy("action"), action), field(copy("project"), project), h("p", null, copy("rebind")));
85
- const sync = () => { project.disabled = action.value !== "project.refresh"; project.required = !project.disabled; };
86
- action.addEventListener("change", sync); sync();
87
- }
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;
106
+ };
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();
88
117
  form(job ? copy("editJob") : copy("newJob"), fields, () => {
89
- const config = taskId ? JSON.parse(configuration.value) : {
118
+ const config = kind.value !== "builtin" ? JSON.parse(configuration.value) : {
90
119
  kind: "builtin", action: action.value, input: {},
91
120
  ...(action.value === "project.refresh" ? { projectId: project.value.trim() } : {})
92
121
  };
93
122
  void mutate("jobs", { op: job ? "update" : "define", id: id.value.trim(), title: title.value.trim(),
94
123
  configuration: config, ...(job ? { expectedRevision: job.revision } : {}) }, result => {
95
- selected = result.id; selectedRun = undefined; closeEditor();
124
+ selected = result.id; selectedRun = undefined; pinSelection(); closeEditor();
96
125
  });
97
126
  });
98
127
  }
99
128
  function editSchedule(schedule) {
100
129
  const id = input(schedule?.id || "", { required: true, disabled: !!schedule });
101
- const jobId = choose(jobs.map(j => j.id), schedule?.jobId || selected);
102
- const kind = choose(["once", "interval", "cron"], schedule?.rule.kind || "interval");
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");
103
133
  const rule = schedule?.rule;
104
134
  const at = input(rule?.at || rule?.anchor || "", { placeholder: "2026-10-17T09:00:00+08:00" });
105
- const every = input(String(rule?.everyMs || 3600000), { type: "number", min: 1000, step: 1 });
135
+ const every = input(String(rule?.everyMs || 3600000), { type: "number", min: schedule?.builtin ? 60000 : 1000, step: 1 });
106
136
  const expression = input(rule?.expression || "0 9 * * *");
107
137
  const timezone = input(rule?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
108
- const when = field(copy("instant"), at), interval = field(copy("every"), every);
138
+ const when = field(copy("instant"), at), interval = field(copy(schedule?.builtin ? "syncEvery" : "every"), every);
109
139
  const cron = field(copy("cron"), expression), zone = field(copy("timezone"), timezone);
110
140
  const sync = () => {
111
141
  when.hidden = kind.value === "cron"; interval.hidden = kind.value !== "interval";
@@ -118,31 +148,119 @@ export function mountJobs(root, t, taskId) {
118
148
  const rule = kind.value === "once" ? { kind: "once", at: at.value }
119
149
  : kind.value === "interval" ? { kind: "interval", anchor: at.value, everyMs: Number(every.value) }
120
150
  : { kind: "cron", expression: expression.value, timezone: timezone.value };
121
- void mutate("schedules", { op: schedule ? "update" : "create", id: id.value.trim(), jobId: jobId.value, rule,
151
+ void mutate("schedules", { op: schedule ? "update" : "create", id: id.value.trim(), rule,
152
+ ...(schedule?.builtin ? {} : { jobId: jobId.value, ...(!taskId ? { authorization: "service" } : {}) }),
122
153
  ...(schedule ? { expectedRevision: schedule.revision } : {}) }, closeEditor);
123
154
  });
124
155
  }
125
156
  async function showRun(jobId, runId, target) {
157
+ const epoch = ++runGeneration;
126
158
  selectedRun = runId;
159
+ pinSelection();
127
160
  target.replaceChildren(h("p", null, copy("loading")));
128
161
  try {
129
- const [run, logs] = await Promise.all([read("jobs", { op: "run-get", id: jobId, runId }),
130
- read("jobs", { op: "logs", id: jobId, runId })]);
131
- if (selected !== jobId || selectedRun !== runId) return;
132
- target.replaceChildren(h("h4", null, run.id), fact(copy("state"), run.status),
162
+ const run = await read("jobs", { op: "run-get", id: jobId, runId });
163
+ 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")),
167
+ run.delegation ? h("a.btn", { href: "/?task=" + encodeURIComponent(run.delegation.taskId) },
168
+ 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,
133
172
  fact(copy("trigger"), run.trigger.source + " · " + run.trigger.key),
134
173
  fact(copy("created"), run.createdAt), fact(copy("finished"), run.terminalAt),
135
- raw(copy("snapshot"), run),
136
- h("h4", null, copy("logs")), logs.length ? h("div", null, logs.map(log => h("section", null,
137
- h("h5", null, log.step + (log.truncated ? " · " + copy("truncated") : "")),
138
- h("pre.settings-observation", null, log.text)))) : h("p", null, copy("noLogs")));
174
+ raw(copy("snapshot"), run), artifacts, evidence, session));
139
175
  if (["queued", "running", "unknown-needs-attention"].includes(run.status)) target.prepend(
140
- button(copy("cancelRun"), () => void mutate("jobs", { op: "run-cancel", id: jobId, runId })));
141
- } catch (error) { target.replaceChildren(h("p.settings-error", null, error.message)); }
176
+ button(copy("cancelRun"), () => {
177
+ if (window.confirm(copy("cancelBoundary") + "\n" + runId))
178
+ void mutate("jobs", { op: "run-cancel", id: jobId, runId });
179
+ }));
180
+ drawArtifacts(artifacts, jobId, run, epoch);
181
+ if (run.executor === "agent" && run.configuration.target.kind === "job") drawSession(session, jobId, runId);
182
+ try {
183
+ const value = await read("jobs", { op: "run-evidence", id: jobId, runId });
184
+ if (epoch !== runGeneration) return;
185
+ evidence.append(raw(copy("diagnostics"), value));
186
+ if (run.executor !== "agent" && !run.delegation) {
187
+ const logs = await read("jobs", { op: "logs", id: jobId, runId });
188
+ 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")));
192
+ }
193
+ } catch (error) { if (epoch === runGeneration) evidence.append(h("p.settings-error", null, copy("diagnostics") + ": " + error.message)); }
194
+ } catch (error) { if (epoch === runGeneration) target.replaceChildren(h("p.settings-error", null, error.message)); }
195
+ }
196
+ function drawArtifacts(target, jobId, run, epoch) {
197
+ const refs = run.artifacts || [];
198
+ const viewer = h("div.file-viewer");
199
+ const readRef = async (ref, offset = 0) => {
200
+ const value = await read("jobs", { op: "artifact-preview", id: jobId, runId: run.id,
201
+ reference: JSON.stringify(ref), offset: String(offset) });
202
+ return { ...value, relativePath: ref.relativePath, commit: ref.version };
203
+ };
204
+ const select = async (ref, offset = 0) => {
205
+ artifactState.reference = ref;
206
+ const url = new URL(location.href);
207
+ url.searchParams.set("artifact", JSON.stringify(ref));
208
+ history.replaceState(history.state, "", url);
209
+ const key = JSON.stringify(ref);
210
+ viewer.replaceChildren(h("p", null, copy("loading")));
211
+ try {
212
+ const value = await readRef(ref, offset);
213
+ if (epoch !== runGeneration || JSON.stringify(artifactState.reference) !== key) return;
214
+ drawArtifact(viewer, value, JSON.stringify(ref.owner), t, {
215
+ compare: false,
216
+ readArtifact: async (_owner, path, commit, page) => {
217
+ const exact = refs.find(r => r.relativePath === path && r.version === commit
218
+ && JSON.stringify(r.owner) === JSON.stringify(ref.owner));
219
+ if (!exact) throw new Error(copy("registeredOnly"));
220
+ return readRef(exact, page);
221
+ }
222
+ }, (_path, _commit, page) => void select(ref, page), artifactState);
223
+ } catch (error) { if (epoch === runGeneration && JSON.stringify(artifactState.reference) === key)
224
+ viewer.replaceChildren(h("p.settings-error", null, error.message)); }
225
+ };
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);
228
+ if (artifactState.reference && refs.some(ref => JSON.stringify(ref) === JSON.stringify(artifactState.reference)))
229
+ void select(artifactState.reference);
230
+ const recovery = h("div");
231
+ target.append(button(copy("inspectSaved"), async () => {
232
+ recovery.replaceChildren();
233
+ try {
234
+ const result = await read("jobs", { op: "artifact-list", id: jobId, runId: run.id });
235
+ const missing = result.repository.saved.filter(ref => !result.artifacts.some(r => JSON.stringify(r) === JSON.stringify(ref)));
236
+ recovery.append(raw(copy("artifacts"), result), h("p", null, copy("registerHelp")),
237
+ ...missing.map(ref => button(copy("register") + " · " + ref.relativePath, () => {
238
+ if (window.confirm(copy("registerHelp") + "\n" + ref.version))
239
+ void mutate("jobs", { op: "artifact-register", id: jobId, runId: run.id, reference: ref });
240
+ })));
241
+ } catch (error) { recovery.append(h("p.settings-error", null, error.message)); }
242
+ }), recovery);
243
+ }
244
+ function drawSession(target, jobId, runId) {
245
+ target.append(button(copy("inspectSession"), async () => {
246
+ try {
247
+ const value = await read("jobs", { op: "session-inspect", id: jobId, runId });
248
+ const set = value.sessions, current = set?.sessions?.[set.activeAgentId];
249
+ target.replaceChildren(raw(copy("inspectSession"), value), h("p", null, copy("sessionBoundary")));
250
+ if (current) target.append(...["session-stop", "session-new"].map(op =>
251
+ button(copy(op === "session-stop" ? "stopSession" : "newSession"), () => {
252
+ if (window.confirm(copy("sessionBoundary") + "\n" + current.nativeSessionId))
253
+ void mutate("jobs", { op, id: jobId, runId, expectedSessionId: current.nativeSessionId });
254
+ })));
255
+ } catch (error) { target.append(h("p.settings-error", null, error.message)); }
256
+ }));
142
257
  }
143
258
  async function showDetail(epoch) {
144
259
  const job = jobs.find(j => j.id === selected);
145
- if (!job) { detail.replaceChildren(); return; }
260
+ if (!job) {
261
+ detail.replaceChildren(...(selected ? [h("p.settings-error", null, copy("unavailable") + " · " + selected)] : []));
262
+ return;
263
+ }
146
264
  const [runs, plans] = await Promise.all([
147
265
  read("jobs", { op: "runs", id: job.id }),
148
266
  Promise.all(schedules.filter(s => s.jobId === job.id).map(s => read("schedules", { op: "show", id: s.id })))
@@ -150,38 +268,43 @@ export function mountJobs(root, t, taskId) {
150
268
  if (epoch !== generation) return;
151
269
  runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
152
270
  const summary = run => run ? (run.id || run.runId) + " · " + run.status + " · "
153
- + (run.terminalAt || run.finishedAt || run.createdAt || run.attemptedAt) : null;
271
+ + (run.terminalAt || run.finishedAt || run.createdAt || run.attemptedAt)
272
+ + (run.summary || run.businessResult?.summary ? " · " + (run.summary || run.businessResult.summary) : "") : null;
154
273
  const runDetail = h("section.job-run");
155
274
  detail.replaceChildren(h("div.settings-stack", null, h("h3", null, job.title), fact("ID", job.id),
156
275
  fact(copy("scope"), taskId || "global"), fact(copy("project"), job.configuration.projectId),
276
+ job.builtin ? h("p", null, copy("builtinSync")) : null,
157
277
  fact(copy("revision"), job.revision),
278
+ fact(copy("executor"), job.configuration.kind),
158
279
  fact(copy("recentRun"), summary(runs[0])),
280
+ fact(copy("recentSuccess"), summary(runs.find(run => run.businessResult?.outcome === "succeeded"))),
159
281
  job.refresh ? h("div", null,
160
282
  fact(copy("refreshAttempt"), summary(job.refresh.lastAttempt)),
161
283
  fact(copy("refreshSuccess"), summary(job.refresh.lastSuccessfulRefresh))) : null,
162
284
  h("div.settings-actions", null,
163
285
  button(copy("runNow"), () => void mutate("jobs", { op: "run", id: job.id, requestId: crypto.randomUUID() }, result => {
164
- selectedRun = result.run.id;
286
+ selectedRun = result.run.id; pinSelection();
165
287
  }), true),
166
- button(copy("editJob"), () => editJob(job)),
167
- button(copy("newSchedule"), () => editSchedule())),
288
+ !job.builtin ? button(copy("editJob"), () => editJob(job)) : null,
289
+ !job.builtin && !["agent", "task"].includes(job.configuration.kind) ? button(copy("newSchedule"), () => editSchedule()) : null),
168
290
  raw(copy("configuration"), job.configuration),
169
291
  job.refresh ? raw(copy("refreshFacts"), job.refresh) : null,
170
292
  h("h3", null, copy("schedules")),
171
293
  plans.length ? h("div.settings-stack", null, plans.map(({ schedule: s, lastSuccess }) => h("article.settings-card", null,
172
294
  h("h4", null, s.id), fact(copy("state"), s.state),
295
+ s.builtin ? fact(copy("automatic"), copy(s.builtin.enabled ? "enabled" : "disabled")) : null,
173
296
  s.state === "completed" ? h("p", null, copy("onceCompleted")) : null,
174
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"),
175
298
  fact(copy("timezone"), s.rule.timezone || "UTC / offset"),
176
- fact(copy("nextDue"), s.nextDue), fact(copy("lastAttempt"), s.lastAttempt
299
+ fact(copy("nextDue"), s.state === "enabled" ? s.nextDue : null), fact(copy("lastAttempt"), s.lastAttempt
177
300
  ? s.lastAttempt.at + " · " + s.lastAttempt.outcome + " · " + (s.lastAttempt.runId || s.lastAttempt.reason || "") : null),
178
301
  fact(copy("lastSuccess"), lastSuccess ? lastSuccess.id + " · " + (lastSuccess.terminalAt || lastSuccess.updatedAt) : null),
179
302
  s.reason ? h("p.settings-error", null, s.reason) : null,
180
303
  h("div.settings-actions", null,
181
304
  button(copy("editSchedule"), () => editSchedule(s)),
182
- button(copy(s.state === "enabled" ? "disable" : "enable"), () => void mutate("schedules", {
183
- op: s.state === "enabled" ? "disable" : "enable", id: s.id, expectedRevision: s.revision
184
- })), s.lastAttempt?.runId ? button(copy("inspectRun"), () => void showRun(job.id, s.lastAttempt.runId, runDetail)) : null),
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),
185
308
  raw(copy("details"), s)))) : h("p", null, copy("noSchedules")),
186
309
  h("h3", null, copy("history")),
187
310
  runs.length ? h("div.jobs-runs", null, runs.map(run =>
@@ -201,14 +324,14 @@ export function mountJobs(root, t, taskId) {
201
324
  list.replaceChildren(...(jobs.length ? jobs.map(job => h("button.job-choice", {
202
325
  type: "button", "aria-pressed": String(selected === job.id), onclick: () => {
203
326
  if (pending || !guardDraft()) return;
204
- closeEditor(); selected = job.id; selectedRun = undefined; void refresh();
327
+ closeEditor(); selected = job.id; selectedRun = undefined; pinSelection(); void refresh();
205
328
  }
206
329
  }, h("strong", null, job.title), h("code", null, job.id),
207
- h("small", null, job.configuration.action || job.configuration.kind),
330
+ h("small", null, job.builtin ? copy("builtinSync") : job.configuration.action || job.configuration.kind),
208
331
  h("small", null, job.configuration.projectId || "—"))) : [h("p", null, copy("empty"))]));
209
332
  await showDetail(epoch);
210
333
  if (announce && epoch === generation) status(copy("observed") + " · " + new Date().toISOString());
211
- } catch (error) { if (epoch === generation) status(error.message, true); }
334
+ } catch (error) { if (epoch === generation) status(copy("stale") + " " + error.message, true); }
212
335
  }
213
336
  root.classList.add("jobs-manager");
214
337
  root.replaceChildren(h("h2", null, copy("heading")),
@@ -219,4 +342,10 @@ export function mountJobs(root, t, taskId) {
219
342
  void refresh();
220
343
  return { isDirty: () => dirty, isBusy: () => pending, refresh };
221
344
  }
345
+
346
+ 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;
350
+ }
222
351
  `;
@@ -74,7 +74,7 @@ function artifactViewer(viewer, data, t, actions) {
74
74
  return { element: viewer, read: read, draw: draw };
75
75
  }
76
76
 
77
- function drawArtifact(viewer, value, taskId, t, actions, read, state = {}) {
77
+ export function drawArtifact(viewer, value, taskId, t, actions, read, state = {}) {
78
78
  clear(viewer);
79
79
  if (!value) return;
80
80
  const selected = value.after || value;
@@ -121,12 +121,13 @@ function drawArtifact(viewer, value, taskId, t, actions, read, state = {}) {
121
121
  viewer.append(h("p.viewer-ref", null, mono("sha256:" + selected.digest)));
122
122
  if (value.before) viewer.append(h("p.viewer-ref", null, mono("← git:" + value.before.commit + ":" + value.before.relativePath + " · sha256:" + value.before.digest)));
123
123
  viewer.append(h("p.faint.small", null, t("evidence.fixedNote")));
124
- viewer.append(h("div.record-actions", null, value.kind === "image" ? null : copyText, download, feedback));
124
+ viewer.append(h("div.record-actions", null, value.kind === "image" ? null : copyText, download,
125
+ actions.openConversation ? feedback : null));
125
126
  if (value.kind === "image") { viewer.append(imagePreview(value, t, undefined, preview)); return; }
126
127
  const before = h("input", { placeholder: t("materials.before"), "aria-label": t("materials.before"), maxLength: 64 });
127
128
  const compare = button(t("materials.compare"), { variant: "ghost" });
128
129
  compare.addEventListener("click", function () { read(selected.relativePath, selected.commit, 0, before.value); });
129
- viewer.append(h("div.record-actions", null, before, compare));
130
+ if (actions.compare !== false) viewer.append(h("div.record-actions", null, before, compare));
130
131
  const source = h("pre.code-block.artifact-text", { tabIndex: 0, "aria-label": t("evidence.fixedText") }, value.content);
131
132
  if (!value.before && /\.md$|\.markdown$/i.test(selected.relativePath)) {
132
133
  viewer.append(markdownPreview(value, taskId, t, actions, preview));
@@ -207,7 +208,7 @@ function markdownPreview(value, taskId, t, actions, preview) {
207
208
  feedback.addEventListener("click", function () { actions.openConversation([artifactRef(value), artifactRef(image)]); });
208
209
  slot.append(imagePreview(image, t, entry.alt, saved), h("span.viewer-ref", null,
209
210
  path + " · git:" + image.commit + " · sha256:" + image.digest),
210
- h("span.record-actions", null, download, feedback));
211
+ h("span.record-actions", null, download, actions.openConversation ? feedback : null));
211
212
  }
212
213
  async function observeRead() {
213
214
  load.disabled = true; body.dataset.reading = "true"; status.textContent = t("evidence.reading");
@@ -12,7 +12,7 @@ import { updateObservation } from "/assets/js/views/task/observation.js";
12
12
  import { renderDelivery } from "/assets/js/views/task/delivery.js";
13
13
  import { renderRuntime } from "/assets/js/views/task/runtime.js";
14
14
  import { renderRecords } from "/assets/js/views/task/records.js";
15
- import { mountJobs } from "/assets/js/views/jobs.js";
15
+ import { mountJobs, jobLink } from "/assets/js/views/jobs.js";
16
16
 
17
17
  // Overview: where it stands. Delivery: what it produced. Runtime: what is
18
18
  // live now. Records: what happened. Retired section names stay addressable.
@@ -94,6 +94,10 @@ function dockToggle(mode, iconName, title, t, ctx) {
94
94
 
95
95
  function taskMeta(task, t, locale) {
96
96
  return h("div.task-meta", null,
97
+ task.source?.kind === "job-run" ? h("a.btn", {
98
+ href: jobLink(task.source.run.scope.kind === "task" ? task.source.run.scope.taskId : undefined,
99
+ task.source.jobId, task.source.run.runId)
100
+ }, t("jobs.sourceJob") + " · " + task.source.jobId + " / " + task.source.run.runId) : null,
97
101
  statusBadge(t, "task", "status", task.status),
98
102
  h("span", { dataset: { slot: "exec-badge" } }),
99
103
  task.priority ? badge(label(t, "priority", task.priority), task.priority === "urgent" || task.priority === "high" ? "accent" : "idle") : null,
@@ -25,7 +25,7 @@ export function createTaskPreviews(store, home) {
25
25
  };
26
26
  const registered = (taskId, jobId) => {
27
27
  const job = store.getDurableJob(taskId, jobId);
28
- if (!store.getTask(taskId) || !job || job.taskId !== taskId || !isHttpPreview(job.env)) {
28
+ if (!store.getTask(taskId) || !job || job.delegation || job.taskId !== taskId || !isHttpPreview(job.env)) {
29
29
  throw new WebRequestRejected("Registered Task preview not found.");
30
30
  }
31
31
  validateHttpPreview(job.env, job.steps);
@@ -69,7 +69,7 @@ export function createTaskPreviews(store, home) {
69
69
  async list(taskId) {
70
70
  if (!store.getTask(taskId))
71
71
  throw new WebRequestRejected("Task not found.");
72
- const jobs = store.listDurableJobs(taskId).filter(job => isHttpPreview(job.env)).reverse();
72
+ const jobs = store.listDurableJobs(taskId).filter((job) => !job.delegation && isHttpPreview(job.env)).reverse();
73
73
  const services = await Promise.all(jobs.slice(0, 16).map(async (job) => {
74
74
  let current = state(job);
75
75
  let detail = job.result?.unknownReason ?? `Job: ${job.status}`;
@@ -5,8 +5,8 @@ import { requireKnownFields } from "../domain/validation.js";
5
5
  import { WebRequestRejected, webLocalMutation } from "./webMutation.js";
6
6
  /** Local-user ingress only, behind the Web token/origin boundary. Never use
7
7
  * this adapter for managed RPC callers. The route fixes scope; the server
8
- * resolves the current Operator binding on every request. No invented Session
9
- * or durable second authority: the public services revalidate the binding.
8
+ * uses public user authority for global controlled actions. Task shell access
9
+ * still resolves the current Operator binding; no invented Session.
10
10
  */
11
11
  export function createWebJobs(store, home) {
12
12
  function invoke(scope, family, value, write, now) {
@@ -14,21 +14,31 @@ export function createWebJobs(store, home) {
14
14
  if (!value || typeof value !== "object" || Array.isArray(value))
15
15
  throw new Error("Expected an operation.");
16
16
  requireKnownFields(value, family === "jobs"
17
- ? ["op", "id", "title", "configuration", "expectedRevision", "runId", "requestId"]
18
- : ["op", "id", "jobId", "rule", "expectedRevision"], "Web Job/Schedule request");
17
+ ? ["op", "id", "title", "configuration", "expectedRevision", "runId", "requestId",
18
+ "reference", "offset", "expectedSessionId"]
19
+ : ["op", "id", "jobId", "rule", "expectedRevision", "authorization", "target"], "Web Job/Schedule request");
19
20
  const input = value;
20
- const reads = family === "jobs" ? ["list", "show", "runs", "run-get", "logs"] : ["list", "show"];
21
- const writes = family === "jobs" ? ["define", "update", "run", "run-cancel"] : ["create", "update", "enable", "disable"];
21
+ const reads = family === "jobs" ? ["list", "show", "runs", "run-get", "run-result", "run-evidence",
22
+ "run-delegation", "logs", "artifact-list", "artifact-preview", "session-inspect"] : ["list", "show"];
23
+ const writes = family === "jobs" ? ["define", "update", "run", "run-cancel", "artifact-register",
24
+ "session-stop", "session-new"] : ["create", "update", "enable", "disable"];
22
25
  if (!(write ? writes : reads).includes(String(input.op)))
23
26
  throw new Error("Operation is not available on this endpoint.");
24
27
  if (scope.kind === "task" && !tx.getTask(scope.taskId))
25
28
  throw new Error("Task not found.");
26
- const session = activeLiveRoleAgentSession(tx.getGlobalRoleSessionSet("operator"));
27
- if (!session)
28
- throw new Error("A current Operator Session is required for Job management.");
29
- const caller = { scope: "global", role: "operator", nativeSessionId: session.nativeSessionId,
30
- agentId: session.agentId, adapterId: session.adapterId };
29
+ const session = scope.kind === "task" ? activeLiveRoleAgentSession(tx.getGlobalRoleSessionSet("operator")) : null;
30
+ if (scope.kind === "task" && !session)
31
+ throw new Error("A current Operator Session is required for Task Job management.");
32
+ const caller = session ? { scope: "global", role: "operator", nativeSessionId: session.nativeSessionId,
33
+ agentId: session.agentId, adapterId: session.adapterId } : { scope: "user" };
31
34
  const { requestId, ...fields } = input;
35
+ if (typeof fields.reference === "string")
36
+ fields.reference = JSON.parse(fields.reference);
37
+ if (fields.offset !== undefined) {
38
+ if (!/^\d{1,9}$/u.test(String(fields.offset)))
39
+ throw new Error("Invalid artifact offset.");
40
+ fields.offset = Number(fields.offset);
41
+ }
32
42
  if (input.op === "run" && (typeof requestId !== "string" || !requestId.trim() || requestId.length > 200)) {
33
43
  throw new Error("Immediate execution requires a stable requestId.");
34
44
  }
@@ -36,6 +46,21 @@ export function createWebJobs(store, home) {
36
46
  ...(input.op === "run" ? { trigger: { source: "manual", key: requestId } } : {}) };
37
47
  return (family === "jobs" ? createJobDefinitionControl(tx, home) : createScheduleControl(tx, home))(params, now);
38
48
  };
49
+ // Artifact Git I/O is asynchronous, with its own authorization recheck and
50
+ // short publication transaction. Never keep a SQLite transaction across it.
51
+ if (family === "jobs" && value && typeof value === "object"
52
+ && "op" in value && String(value.op).startsWith("artifact-")) {
53
+ try {
54
+ return Promise.resolve(apply(store)).catch(error => {
55
+ if (!write)
56
+ throw new WebRequestRejected(error instanceof Error ? error.message : String(error));
57
+ throw error; // A Git/publication failure is not proof of non-submission.
58
+ });
59
+ }
60
+ catch (error) {
61
+ throw new WebRequestRejected(error instanceof Error ? error.message : String(error));
62
+ }
63
+ }
39
64
  if (write)
40
65
  return webLocalMutation(store, apply);
41
66
  try {
@@ -30,24 +30,36 @@ async function fixedImage(home, taskId, input) {
30
30
  /** One selected immutable file, never a workspace path or a remote URL.
31
31
  * Markdown is bounded and whole so fences/tables are not split by pagination. */
32
32
  export async function readArtifactPreview(home, taskId, ref, offset = 0) {
33
- if (/\.(svg|gif|avif|bmp|ico|tiff?)$/iu.test(ref.relativePath)) {
33
+ if (ref.taskId !== taskId)
34
+ throw new WebRequestRejected("Materials must belong to the same Task.");
35
+ const valid = validateGitArtifactRef(ref);
36
+ const file = await resolveGitArtifact(home, valid);
37
+ return { ...valid, ...previewArtifactContent(file, offset) };
38
+ }
39
+ /** Shared data-only preview for Task and Job evidence; no path lookup or authority. */
40
+ export function previewArtifactContent(file, offset = 0) {
41
+ if (/\.(svg|gif|avif|bmp|ico|tiff?)$/iu.test(file.relativePath)) {
34
42
  throw new WebRequestRejected("Unsupported image format. Save a static PNG, JPEG or WebP instead.");
35
43
  }
36
- if (isRasterArtifact(ref.relativePath)) {
44
+ if (file.relativePath.length > 512)
45
+ throw new WebRequestRejected("Artifact path is too long.");
46
+ if (isRasterArtifact(file.relativePath)) {
37
47
  if (offset !== 0)
38
48
  throw new WebRequestRejected("Images do not have text offsets.");
39
- return fixedImage(home, taskId, ref);
49
+ return { digest: file.digest, kind: "image", byteSize: file.bytes.length,
50
+ ...rasterMetadata(file.bytes), base64: file.bytes.toString("base64") };
40
51
  }
41
- if (/\.md$|\.markdown$/iu.test(ref.relativePath)) {
52
+ textPath(file.relativePath);
53
+ const text = decodeText(file.bytes);
54
+ if (/\.md$|\.markdown$/iu.test(file.relativePath)) {
42
55
  if (offset !== 0)
43
56
  throw new WebRequestRejected("Markdown previews start at offset zero.");
44
- const file = await fixedText(home, taskId, ref);
45
- if (file.bytes > MAX_MATERIAL_BYTES)
57
+ if (file.bytes.length > MAX_MATERIAL_BYTES)
46
58
  throw new WebRequestRejected("Markdown preview exceeds 256 KiB; split the document.");
47
- return { ...file.ref, kind: "markdown", byteSize: file.bytes, content: file.text,
48
- offset: 0, totalCharacters: file.text.length, nextOffset: null };
59
+ return { digest: file.digest, kind: "markdown", byteSize: file.bytes.length, content: text,
60
+ offset: 0, totalCharacters: text.length, nextOffset: null };
49
61
  }
50
- return readTextArtifactPage(home, taskId, ref, offset);
62
+ return { digest: file.digest, byteSize: file.bytes.length, ...page(text, offset) };
51
63
  }
52
64
  function textPath(path) {
53
65
  const safe = safeRelativeArtifactPath(path);
@@ -284,15 +284,16 @@ async function handleHttpRequest(request, response, store, dependencies, token,
284
284
  let value;
285
285
  if (method === "GET") {
286
286
  const query = new URL(request.url, "http://localhost").searchParams;
287
- if ([...query.keys()].some(key => !["op", "id", "runId"].includes(key)))
288
- throw new WebRequestRejected("Unknown Job query.");
289
- value = dependencies.jobs.read(scope, family, Object.fromEntries(query), now());
287
+ if ([...query.keys()].some(key => !["op", "id", "runId", "reference", "offset"].includes(key)
288
+ || query.getAll(key).length !== 1))
289
+ throw new WebRequestRejected("Unknown or repeated Job query.");
290
+ value = await dependencies.jobs.read(scope, family, Object.fromEntries(query), now());
290
291
  }
291
292
  else if (method === "POST") {
292
293
  if (headerValue(request, "origin") !== `http://${headerValue(request, "host")}`) {
293
294
  throw new WebRequestRejected("Job changes require a same-origin request.");
294
295
  }
295
- value = dependencies.jobs.mutate(scope, family, await readMutationBody(request), now());
296
+ value = await dependencies.jobs.mutate(scope, family, await readMutationBody(request), now());
296
297
  }
297
298
  else
298
299
  throw new WebRequestRejected("Method not allowed.");
@@ -631,7 +632,7 @@ async function handleHttpRequest(request, response, store, dependencies, token,
631
632
  try {
632
633
  const assetVersion = new URL(request.url, "http://localhost").searchParams.get("v");
633
634
  const asset = findWebAsset(pathname, assetVersion);
634
- const managementPage = /^\/(?:models(?:\/(?:providers|catalog|quota))?|settings(?:\/(?:preferences|skills|server|connection))?)\/?$/.test(pathname);
635
+ const managementPage = /^\/(?:models(?:\/(?:providers|catalog|quota))?|settings(?:\/(?:preferences|skills|jobs|server|connection))?)\/?$/.test(pathname);
635
636
  if (pathname === "/" || pathname === "/index.html" || managementPage) {
636
637
  sendText(response, 200, "text/html; charset=utf-8", prepareWebDocument(managementPage ? MODELS_HTML.replace("__YUI_WEB_TOKEN__", token) : dashboardHtml(token), managementPage ? "/assets/js/app/manage.js" : "/assets/app.js"), method === "HEAD");
637
638
  }
@@ -53,6 +53,7 @@ export function createWebTaskSurface(store, options = {}, observations = [], hos
53
53
  core: readTaskContext(tx, taskId, environment),
54
54
  task: {
55
55
  id: task.id, title: clip(task.title), status: task.status, updatedAt: task.updatedAt,
56
+ source: task.source,
56
57
  executionGate: task.executionGate, priority: task.priority, tags: task.tags?.slice(0, 16).map(value => clip(value)),
57
58
  projectBindings: task.projectBindings.slice(0, 32),
58
59
  description: clip(task.description), completionSummary: clip(task.completionSummary),