@zq-silk/yui 2.8.0 → 2.9.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 (86) hide show
  1. package/dist/cli/commandCatalog.js +35 -12
  2. package/dist/cli/interactionPolicy.js +2 -2
  3. package/dist/cli/updatePorts.js +20 -0
  4. package/dist/commands/durableJobCommands.js +50 -0
  5. package/dist/commands/projectCommands.js +4 -33
  6. package/dist/commands/scheduleCommands.js +45 -0
  7. package/dist/commands/taskActivationCommands.js +19 -5
  8. package/dist/commands/taskCommands.js +2 -1
  9. package/dist/commands/taskFactCommands.js +24 -12
  10. package/dist/commands/taskPublicationCommands.js +6 -3
  11. package/dist/commands/taskPublicationVerifyCommand.js +74 -28
  12. package/dist/context/runContextPack.js +24 -4
  13. package/dist/context/taskContext.js +52 -15
  14. package/dist/context/taskPhase.js +99 -0
  15. package/dist/controlPlaneCli.js +24 -5
  16. package/dist/controller/controller.js +35 -0
  17. package/dist/controller/fileSchedulerStoreAdapter.js +16 -1
  18. package/dist/controller/jobControl.js +42 -12
  19. package/dist/controller/jobDefinitionControl.js +304 -0
  20. package/dist/controller/jobSupervisor.js +47 -32
  21. package/dist/controller/runtime.js +11 -2
  22. package/dist/controller/scheduleControl.js +176 -0
  23. package/dist/coordination/workMailbox.js +3 -3
  24. package/dist/external/githubPublicationVerifier.js +14 -5
  25. package/dist/external/gitlabPublicationVerifier.js +2 -1
  26. package/dist/interaction/notificationPresentation.js +3 -3
  27. package/dist/interaction/operatorPresentation.js +5 -0
  28. package/dist/job/builtinJob.js +58 -0
  29. package/dist/job/durableJob.js +72 -49
  30. package/dist/job/jobDefinition.js +68 -0
  31. package/dist/job/jobRunner.js +2 -1
  32. package/dist/job/projectRefreshRunner.js +48 -0
  33. package/dist/repository/projectRefresh.js +58 -0
  34. package/dist/repository/taskWorkspacePreparer.js +2 -1
  35. package/dist/schedule/schedule.js +108 -0
  36. package/dist/scheduler/operatorEvent.js +2 -0
  37. package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
  38. package/dist/storage/contextRecords.js +8 -0
  39. package/dist/storage/jobSchema.js +15 -0
  40. package/dist/storage/maintenanceCaller.js +69 -0
  41. package/dist/storage/recordValidation.js +5 -0
  42. package/dist/storage/scheduleSchema.js +12 -0
  43. package/dist/storage/sqliteSchema.js +75 -25
  44. package/dist/storage/sqliteStore.js +110 -9
  45. package/dist/storage/storageVersions.js +1 -1
  46. package/dist/storage/storeRpc.js +5 -0
  47. package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
  48. package/dist/task/publicationToolObservation.js +128 -0
  49. package/dist/task/taskActivationService.js +35 -0
  50. package/dist/web/assets/client/app/settings.js +7 -1
  51. package/dist/web/assets/client/i18n/en.js +61 -0
  52. package/dist/web/assets/client/i18n/zh-CN.js +61 -0
  53. package/dist/web/assets/client/index.js +2 -0
  54. package/dist/web/assets/client/views/jobs.js +222 -0
  55. package/dist/web/assets/client/views/task/page.js +11 -1
  56. package/dist/web/assets/shared/manageNav.js +1 -0
  57. package/dist/web/assets/shared/taskTabs.js +2 -2
  58. package/dist/web/assets/shell/settingsContent.js +3 -0
  59. package/dist/web/assets/styles/index.js +2 -0
  60. package/dist/web/assets/styles/views/jobs.js +19 -0
  61. package/dist/web/webJobs.js +52 -0
  62. package/dist/web/webServer.js +31 -0
  63. package/docs/cli-information-contract.md +43 -2
  64. package/docs/cli-information-contract.zh-CN.md +32 -1
  65. package/docs/job-runs.md +224 -0
  66. package/docs/release-workflow.md +65 -0
  67. package/docs/release-workflow.zh-CN.md +49 -0
  68. package/docs/schedules.md +132 -0
  69. package/docs/storage-baseline.md +18 -1
  70. package/docs/storage-baseline.zh-CN.md +15 -1
  71. package/docs/task-delivery.md +68 -2
  72. package/docs/task-delivery.zh-CN.md +33 -1
  73. package/docs/testing/verification-levels.md +6 -2
  74. package/docs/testing/verification-levels.zh-CN.md +6 -3
  75. package/package.json +4 -3
  76. package/skills/yui-leader/SKILL.md +18 -1
  77. package/skills/yui-leader/VERSION +1 -1
  78. package/skills/yui-leader/references/planning.md +9 -1
  79. package/skills/yui-reviewer/SKILL.md +4 -0
  80. package/skills/yui-reviewer/VERSION +1 -1
  81. package/skills/yui-runtime/SKILL.md +9 -3
  82. package/skills/yui-runtime/VERSION +1 -1
  83. package/skills/yui-runtime/references/context-ordering.md +103 -0
  84. package/skills/yui-runtime/references/publication.md +44 -4
  85. package/skills/yui-worker/SKILL.md +5 -0
  86. package/skills/yui-worker/VERSION +1 -1
@@ -3,6 +3,8 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
3
3
  import { createProjectResources } from "../resources/projectResourceService.js";
4
4
  import { admitTaskActivationRequest, adoptTaskActivationRequest, cancelTaskActivationRequest, canonicalEnvironmentPlan, createTaskActivationRequest, describeEnvironmentPlan, failTaskActivationRequest, settledActivationFromEvents, taskActivationInputDigest, TASK_ACTIVATION_EVENT } from "./taskActivation.js";
5
5
  import { setTaskActivationRequest, recordTaskActivationRequestEvidence } from "./task.js";
6
+ import { contextContentDigest } from "../context/contextSnapshot.js";
7
+ import { activationSourceEvent } from "../context/taskPhase.js";
6
8
  /**
7
9
  * The terminal outcome already recorded for a requestId, or `undefined` if it
8
10
  * was never cancelled or adopted.
@@ -63,6 +65,20 @@ export function recordTaskActivationRequestInTransaction(tx, input, now) {
63
65
  ? "immediate"
64
66
  : "after-planning-turn";
65
67
  const existing = task.activationRequest;
68
+ if (input.sourceMessageId !== undefined && input.sourceText !== undefined) {
69
+ throw new Error("Activation source requires either a Message or exact text, not both.");
70
+ }
71
+ if (input.sourceText !== undefined && (!input.sourceText.trim() || input.sourceText.includes("\0"))) {
72
+ throw new Error("Activation source text must be nonempty exact input.");
73
+ }
74
+ const sourceMessage = input.sourceMessageId === undefined ? undefined : tx.queryContextRecords(task.id, {
75
+ family: "task-message", ids: [input.sourceMessageId], limit: 1
76
+ }).records[0]?.value;
77
+ if (input.sourceMessageId !== undefined && (!sourceMessage
78
+ || !["user", "operator"].includes(sourceMessage.kind) || sourceMessage.recipient !== undefined
79
+ || sourceMessage.workItemId !== undefined)) {
80
+ throw new Error("Activation source must reference an existing shared user/Operator Message in this Task.");
81
+ }
66
82
  // A requestId whose outcome was already decided is never re-opened, whether
67
83
  // it still holds the slot or a later request displaced it. Cancelling A,
68
84
  // requesting B and then retrying A must not resurrect A: the withdrawal is
@@ -81,6 +97,11 @@ export function recordTaskActivationRequestInTransaction(tx, input, now) {
81
97
  + "Use a new requestId.");
82
98
  }
83
99
  if (existing?.operation.requestId === input.requestId) {
100
+ const source = activationSourceEvent(tx, task)?.payload;
101
+ if (source?.sourceMessageId !== input.sourceMessageId || source?.sourceText !== input.sourceText
102
+ || (sourceMessage !== undefined && source?.sourceBodyDigest !== contextContentDigest(sourceMessage.body))) {
103
+ throw new Error(`Activation requestId ${input.requestId} was already used with a different source.`);
104
+ }
84
105
  const digest = taskActivationInputDigest(task.id, {
85
106
  startMode,
86
107
  ...(planningRun === undefined ? {} : { afterPlanningRun: planningRun }),
@@ -112,11 +133,25 @@ export function recordTaskActivationRequestInTransaction(tx, input, now) {
112
133
  environmentPlan: plan
113
134
  }, now);
114
135
  tx.saveTask(setTaskActivationRequest(task, request, now));
136
+ const priorMessageId = tx.queryContextRecords(task.id, {
137
+ family: "task-message", descending: true, limit: 1
138
+ }).records[0]?.id;
115
139
  tx.saveEvent(task.id, createTaskEvent(tx.nextEventId(task.id), task.id, TASK_ACTIVATION_EVENT.requested, {
116
140
  requestId: request.operation.requestId,
117
141
  startMode: request.startMode,
118
142
  environmentPlan: describeEnvironmentPlan(request.environmentPlan),
119
143
  actor: request.operation.actorId,
144
+ ...(priorMessageId === undefined ? {} : { priorMessageId }),
145
+ ...(input.sourceMessageId === undefined ? {} : {
146
+ sourceMessageId: input.sourceMessageId,
147
+ sourceBodyDigest: contextContentDigest(sourceMessage.body),
148
+ sourceKind: "task-message"
149
+ }),
150
+ ...(input.sourceText === undefined ? {} : {
151
+ sourceText: input.sourceText,
152
+ sourceKind: input.actorId.startsWith("task:") ? "leader-attested-native-input" : "caller-provided-input",
153
+ ...(input.sourceNativeSessionId === undefined ? {} : { sourceNativeSessionId: input.sourceNativeSessionId })
154
+ }),
120
155
  ...(request.afterPlanningRun === undefined
121
156
  ? {}
122
157
  : { afterPlanningRun: request.afterPlanningRun })
@@ -8,6 +8,7 @@ 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";
11
12
 
12
13
  const $ = selector => document.querySelector(selector);
13
14
  const t = i18n.t;
@@ -18,11 +19,13 @@ function readSection() {
18
19
  return SECTIONS.includes(section) ? section : SECTIONS[0];
19
20
  }
20
21
  let section = readSection();
22
+ let jobs;
21
23
  const shell = createManageShell({ page: "settings", t, onNavigate: navigate });
22
24
  function showSection(next) {
23
25
  section = next;
24
26
  document.querySelectorAll("#settings-root > .manage-section[data-section]").forEach(panel => { panel.hidden = panel.dataset.section !== next; });
25
27
  shell.set(next);
28
+ if (next === "jobs" && !jobs) jobs = mountJobs($("#global-jobs"), t);
26
29
  }
27
30
  function navigate(next) {
28
31
  navigateManage({ page: "settings", section: next });
@@ -34,6 +37,9 @@ function markDirty(name, value) {
34
37
  }
35
38
  registerManagePage("settings", { show: next => showSection(next.section) });
36
39
  i18n.subscribe(() => shell.render());
40
+ i18n.subscribe(() => {
41
+ if (jobs && !jobs.isDirty() && !jobs.isBusy()) jobs = mountJobs($("#global-jobs"), t);
42
+ });
37
43
  showSection(section);
38
44
  const skills = mountSkills($("#skill-browser"), t, { heading: false, onDirty: value => markDirty("skills", value) });
39
45
  i18n.subscribe(() => skills?.translate());
@@ -59,7 +65,7 @@ let searchTimer;
59
65
  const more = h("button.btn", { type: "button", hidden: true }, t("settings.more"));
60
66
  container.after(more);
61
67
  window.addEventListener("beforeunload", event => {
62
- if (!groups.some(g => g.dirty)) return;
68
+ if (!groups.some(g => g.dirty) && !jobs?.isDirty()) return;
63
69
  event.preventDefault(); event.returnValue = "";
64
70
  });
65
71
 
@@ -3,6 +3,67 @@
3
3
  // domain vocabulary (statuses, owners, actions) keeps the exact product terms.
4
4
  // Placeholders such as {count} are filled by fill() in lib/format.
5
5
  export const EN = {
6
+ "tabs.jobs": "Jobs",
7
+ "settings.section.jobs": "Jobs & schedules",
8
+ "jobs.heading": "Jobs & schedules",
9
+ "jobs.help": "Run now starts one execution without changing the cadence. Cancel stops only the selected Run. Disable prevents future scheduled admissions. Operations use the current Operator binding.",
10
+ "jobs.details": "Details",
11
+ "jobs.scope": "Ownership",
12
+ "jobs.project": "Target Project",
13
+ "jobs.name": "Title",
14
+ "jobs.action": "Controlled action",
15
+ "jobs.newJob": "Create Job",
16
+ "jobs.editJob": "Edit Job",
17
+ "jobs.runNow": "Run now",
18
+ "jobs.newSchedule": "Create schedule",
19
+ "jobs.editSchedule": "Edit schedule",
20
+ "jobs.cancelRun": "Cancel this Run",
21
+ "jobs.disable": "Disable future runs",
22
+ "jobs.enable": "Enable schedule",
23
+ "jobs.refresh": "Refresh facts",
24
+ "jobs.save": "Save",
25
+ "jobs.close": "Close editor",
26
+ "jobs.discard": "Discard this unsaved draft?",
27
+ "jobs.loading": "Reading authoritative state…",
28
+ "jobs.submitting": "Submitting…",
29
+ "jobs.saved": "Saved. This is not proof of execution or success.",
30
+ "jobs.admitted": "Run admitted",
31
+ "jobs.overlap": "Existing Run occupies this Job; no new Run admitted",
32
+ "jobs.cancelRequested": "Cancellation requested. Inspect the exact Run for confirmed termination.",
33
+ "jobs.observed": "Facts refreshed",
34
+ "jobs.unknown": "Submission outcome is unknown; it will not be resent. Inspect current facts and the original operation before taking another action.",
35
+ "jobs.empty": "No Jobs in this scope. Creating a Job does not run it.",
36
+ "jobs.configuration": "Execution configuration",
37
+ "jobs.taskConfig": "Task-only shell specification: use the registered managed workspace, exact Project HEAD and owner. Commands and environment must not contain secrets. The server validates scope and execution prerequisites.",
38
+ "jobs.rebind": "Saving a Project refresh Job explicitly binds its current catalog target. It does not refresh the Project. No arbitrary global shell commands.",
39
+ "jobs.rule": "Time rule",
40
+ "jobs.instant": "Instant / interval anchor (ISO time with UTC offset)",
41
+ "jobs.every": "Interval (milliseconds, minimum 1000)",
42
+ "jobs.cron": "Cron expression (five fields)",
43
+ "jobs.timezone": "Timezone",
44
+ "jobs.scheduleHelp": "Creating a schedule enables future execution. Rule edits preserve enabled status only when already enabled. Cron uses the service's DST rules; next due is never calculated in this browser.",
45
+ "jobs.state": "State",
46
+ "jobs.recentRun": "Most recent Run",
47
+ "jobs.refreshAttempt": "Project: last attempt",
48
+ "jobs.refreshSuccess": "Project: last successful refresh",
49
+ "jobs.onceCompleted": "The once plan was admitted. Completed does not mean its Run succeeded.",
50
+ "jobs.revision": "Revision",
51
+ "jobs.schedules": "Schedules",
52
+ "jobs.noSchedules": "No schedules. Immediate execution is still available.",
53
+ "jobs.nextDue": "Next due (UTC; enabled plans only)",
54
+ "jobs.lastAttempt": "Last admission attempt",
55
+ "jobs.lastSuccess": "Last successful scheduled Run",
56
+ "jobs.refreshFacts": "Project refresh: last attempt / last success",
57
+ "jobs.history": "Execution history · exact Runs",
58
+ "jobs.noRuns": "No executions yet.",
59
+ "jobs.inspectRun": "Inspect attempt Run",
60
+ "jobs.trigger": "Trigger",
61
+ "jobs.created": "Created",
62
+ "jobs.finished": "Finished",
63
+ "jobs.snapshot": "Exact Run · target snapshot, result and process evidence",
64
+ "jobs.logs": "Step logs",
65
+ "jobs.truncated": "last 64 KiB",
66
+ "jobs.noLogs": "No checkpointed step logs yet. This does not establish success.",
6
67
  "models.subscriptionAuth": "Subscription",
7
68
  "models.account": "Account",
8
69
  "models.providerReadOnlyHelp": "Managed by its provider. Edit the configuration in the owning service.",
@@ -1,6 +1,67 @@
1
1
  // Simplified Chinese UI copy. Typed against the English catalog, so a key that
2
2
  // does not exist in en.ts is a compile error; a missing key falls back to en.
3
3
  export const ZH_CN = {
4
+ "tabs.jobs": "Job",
5
+ "settings.section.jobs": "Job 与时间计划",
6
+ "jobs.heading": "Job 与时间计划",
7
+ "jobs.help": "立即运行只新增一次执行,不改变周期;取消仅处理选中的 Run;停用计划阻止后续触发。操作使用当前 Operator 绑定。",
8
+ "jobs.details": "详情",
9
+ "jobs.scope": "所属范围",
10
+ "jobs.project": "目标 Project",
11
+ "jobs.name": "名称",
12
+ "jobs.action": "受控操作",
13
+ "jobs.newJob": "创建 Job",
14
+ "jobs.editJob": "编辑 Job",
15
+ "jobs.runNow": "立即运行",
16
+ "jobs.newSchedule": "创建时间计划",
17
+ "jobs.editSchedule": "编辑时间计划",
18
+ "jobs.cancelRun": "取消本次 Run",
19
+ "jobs.disable": "停用后续计划",
20
+ "jobs.enable": "启用时间计划",
21
+ "jobs.refresh": "刷新事实",
22
+ "jobs.save": "保存",
23
+ "jobs.close": "关闭编辑",
24
+ "jobs.discard": "放弃当前未保存的草稿?",
25
+ "jobs.loading": "正在读取权威状态…",
26
+ "jobs.submitting": "提交中…",
27
+ "jobs.saved": "已保存;不代表已执行或执行成功。",
28
+ "jobs.admitted": "已准入 Run",
29
+ "jobs.overlap": "已有 Run 占用此 Job,未新增执行",
30
+ "jobs.cancelRequested": "已请求取消;请查看准确 Run 确认是否终止。",
31
+ "jobs.observed": "事实已刷新",
32
+ "jobs.unknown": "提交结果未知,不会重发。请先检查当前事实和原操作,再决定后续动作。",
33
+ "jobs.empty": "此范围暂无 Job。创建定义不会立即执行。",
34
+ "jobs.configuration": "执行配置",
35
+ "jobs.taskConfig": "仅 Task 可配置 shell:使用已登记的受管工作区、准确 Project HEAD 和 owner。命令与环境变量不得包含凭据;服务端校验范围和执行前提。",
36
+ "jobs.rebind": "保存 Project 刷新 Job 会显式绑定当前目录中的目标,但不会执行刷新。全局不开放任意 shell 命令。",
37
+ "jobs.rule": "时间规则",
38
+ "jobs.instant": "执行时间/周期锚点(带 UTC 偏移的 ISO 时间)",
39
+ "jobs.every": "周期间隔(毫秒,至少 1000)",
40
+ "jobs.cron": "Cron 表达式(五字段)",
41
+ "jobs.timezone": "时区",
42
+ "jobs.scheduleHelp": "创建计划会启用后续执行;修改规则只在原本启用时保持启用。Cron 遵循服务端夏令时规则,浏览器不计算下次时间。",
43
+ "jobs.state": "状态",
44
+ "jobs.recentRun": "最近一次 Run",
45
+ "jobs.refreshAttempt": "Project 最近尝试",
46
+ "jobs.refreshSuccess": "Project 最近成功刷新",
47
+ "jobs.onceCompleted": "一次性计划已准入;completed 不代表其 Run 已成功。",
48
+ "jobs.revision": "修订",
49
+ "jobs.schedules": "时间计划",
50
+ "jobs.noSchedules": "暂无时间计划,仍可立即运行。",
51
+ "jobs.nextDue": "下次时间(UTC,仅启用时触发)",
52
+ "jobs.lastAttempt": "最近准入尝试",
53
+ "jobs.lastSuccess": "最近成功的计划 Run",
54
+ "jobs.refreshFacts": "Project 刷新:最近尝试/最近成功",
55
+ "jobs.history": "执行历史 · 精确 Run",
56
+ "jobs.noRuns": "尚无执行记录。",
57
+ "jobs.inspectRun": "查看此次尝试的 Run",
58
+ "jobs.trigger": "触发来源",
59
+ "jobs.created": "创建时间",
60
+ "jobs.finished": "结束时间",
61
+ "jobs.snapshot": "准确 Run · 目标快照、结果与进程证据",
62
+ "jobs.logs": "步骤日志",
63
+ "jobs.truncated": "末尾 64 KiB",
64
+ "jobs.noLogs": "暂无已写入检查点的步骤日志;这不代表成功。",
4
65
  "models.subscriptionAuth": "官方订阅",
5
66
  "models.account": "账号",
6
67
  "models.providerReadOnlyHelp": "由来源服务管理,请在对应服务中修改配置。",
@@ -60,6 +60,7 @@ import { SELECTION_SCRIPT } from "./app/selection.js";
60
60
  import { TASK_VIEW_SCRIPT } from "./app/taskView.js";
61
61
  import { SETTINGS_SCRIPT } from "./app/settings.js";
62
62
  import { SKILLS_SCRIPT } from "./views/skills.js";
63
+ import { JOBS_SCRIPT } from "./views/jobs.js";
63
64
  import { MODELS_VIEW_SCRIPT } from "./views/models.js";
64
65
  import { MODELS_SCRIPT } from "./app/models.js";
65
66
  import { MANAGE_SHELL_SCRIPT } from "./layout/manageShell.js";
@@ -67,6 +68,7 @@ import { MANAGE_SCRIPT } from "./app/manage.js";
67
68
  export { APP_SCRIPT } from "./app/main.js";
68
69
  export const CLIENT_MODULES = Object.freeze({
69
70
  "views/skills": SKILLS_SCRIPT,
71
+ "views/jobs": JOBS_SCRIPT,
70
72
  "views/models": MODELS_VIEW_SCRIPT,
71
73
  "lib/dom": DOM_SCRIPT,
72
74
  "lib/format": FORMAT_SCRIPT,
@@ -0,0 +1,222 @@
1
+ export const JOBS_SCRIPT = String.raw `
2
+ import { h } from "/assets/js/lib/dom.js";
3
+ import { requestJson, submitMutation } from "/assets/js/lib/api.js";
4
+
5
+ // One view for two server-fixed scopes. Refresh only reads facts; it never
6
+ // admits due work, derives nextDue or replaces a selected exact Run with latest.
7
+ export function mountJobs(root, t, taskId) {
8
+ const base = taskId ? "/api/tasks/" + encodeURIComponent(taskId) : "/api/settings";
9
+ const copy = key => t("jobs." + key);
10
+ const receipt = h("p.settings-receipt", { role: "status", "aria-live": "polite" });
11
+ const list = h("div.jobs-list");
12
+ const detail = h("section.jobs-detail", { "aria-label": copy("details") });
13
+ const editor = h("section");
14
+ let jobs = [], schedules = [], selected, selectedRun, generation = 0, pending = false, dirty = false;
15
+ const blocked = new Set();
16
+ const read = (family, input) => requestJson(base + "/" + family + "?" + new URLSearchParams(input));
17
+ const button = (text, action, primary = false) => h("button.btn" + (primary ? ".btn-primary" : ""), {
18
+ type: "button", onclick: action
19
+ }, text);
20
+ const json = value => h("pre.settings-observation", null, JSON.stringify(value, null, 2));
21
+ const fact = (name, value) => h("p.job-fact", null, h("strong", null, name + ": "), String(value ?? "—"));
22
+ const raw = (name, value) => h("details", null, h("summary", null, name), json(value));
23
+ function field(name, control) { return h("label.field", null, h("span", null, name), control); }
24
+ function input(value = "", attrs = {}) { return h("input", { value, ...attrs }); }
25
+ function choose(values, value) {
26
+ const node = h("select", null, values.map(v => h("option", { value: v }, v)));
27
+ node.value = value || values[0]; return node;
28
+ }
29
+ function guardDraft() { return !dirty || window.confirm(copy("discard")); }
30
+ function closeEditor() { editor.replaceChildren(); dirty = false; root.dataset.unsent = "false"; }
31
+ function status(text, error = false) { receipt.textContent = text; receipt.classList.toggle("settings-error", error); }
32
+ async function mutate(family, payload, after) {
33
+ const key = base + "/" + family + "/" + payload.id;
34
+ if (pending || blocked.has(key)) { status(copy("unknown"), true); return; }
35
+ pending = true;
36
+ root.dataset.reading = "true";
37
+ const controls = [...root.querySelectorAll("button,input,select,textarea")];
38
+ const disabled = controls.map(node => node.disabled);
39
+ controls.forEach(node => { node.disabled = true; });
40
+ status(copy("submitting"));
41
+ try {
42
+ const result = await submitMutation(key, base + "/" + family, payload);
43
+ status(payload.op === "run"
44
+ ? (result.reason === "overlap" ? copy("overlap") : copy("admitted")) + " · " + result.run.id + " · " + result.run.status
45
+ : payload.op === "run-cancel" ? copy("cancelRequested") : copy("saved"));
46
+ if (after) after(result);
47
+ await refresh(false);
48
+ } catch (error) {
49
+ if (error.disposition !== "not-submitted") blocked.add(key);
50
+ status(error.message + (blocked.has(key) ? "\n" + copy("unknown") : ""), true);
51
+ } finally {
52
+ pending = false;
53
+ root.dataset.reading = "false";
54
+ controls.forEach((node, index) => { node.disabled = disabled[index]; });
55
+ }
56
+ }
57
+ function form(title, contents, submit) {
58
+ if (!guardDraft()) return;
59
+ closeEditor();
60
+ const node = h("form.settings-card.settings-stack", null, h("h3", null, title), contents,
61
+ h("div.settings-actions", null, h("button.btn.btn-primary", { type: "submit" }, copy("save")),
62
+ button(copy("close"), () => { if (guardDraft()) closeEditor(); })));
63
+ const markDirty = () => { dirty = true; root.dataset.unsent = "true"; };
64
+ node.addEventListener("input", markDirty);
65
+ node.addEventListener("change", markDirty);
66
+ node.addEventListener("submit", event => {
67
+ event.preventDefault();
68
+ try { submit(); } catch (error) { status(error.message, true); }
69
+ });
70
+ editor.append(node);
71
+ node.querySelector("input,select,textarea")?.focus();
72
+ }
73
+ function editJob(job) {
74
+ const id = input(job?.id || "", { required: true, pattern: "[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", disabled: !!job });
75
+ const title = input(job?.title || "", { required: true });
76
+ const action = choose(["system.noop", "project.refresh"], job?.configuration.action);
77
+ const project = input(job?.configuration.projectId || "");
78
+ const configuration = h("textarea", { rows: 12, required: true });
79
+ configuration.value = JSON.stringify(job?.configuration || { kind: "shell", owner: { kind: "task" },
80
+ 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
+ }
88
+ form(job ? copy("editJob") : copy("newJob"), fields, () => {
89
+ const config = taskId ? JSON.parse(configuration.value) : {
90
+ kind: "builtin", action: action.value, input: {},
91
+ ...(action.value === "project.refresh" ? { projectId: project.value.trim() } : {})
92
+ };
93
+ void mutate("jobs", { op: job ? "update" : "define", id: id.value.trim(), title: title.value.trim(),
94
+ configuration: config, ...(job ? { expectedRevision: job.revision } : {}) }, result => {
95
+ selected = result.id; selectedRun = undefined; closeEditor();
96
+ });
97
+ });
98
+ }
99
+ function editSchedule(schedule) {
100
+ 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");
103
+ const rule = schedule?.rule;
104
+ 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 });
106
+ const expression = input(rule?.expression || "0 9 * * *");
107
+ const timezone = input(rule?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
108
+ const when = field(copy("instant"), at), interval = field(copy("every"), every);
109
+ const cron = field(copy("cron"), expression), zone = field(copy("timezone"), timezone);
110
+ const sync = () => {
111
+ when.hidden = kind.value === "cron"; interval.hidden = kind.value !== "interval";
112
+ cron.hidden = zone.hidden = kind.value !== "cron"; at.required = !when.hidden;
113
+ };
114
+ kind.addEventListener("change", sync); sync();
115
+ form(schedule ? copy("editSchedule") : copy("newSchedule"),
116
+ [field("ID", id), field("Job", jobId), field(copy("rule"), kind), when, interval, cron, zone,
117
+ h("p", null, copy("scheduleHelp"))], () => {
118
+ const rule = kind.value === "once" ? { kind: "once", at: at.value }
119
+ : kind.value === "interval" ? { kind: "interval", anchor: at.value, everyMs: Number(every.value) }
120
+ : { kind: "cron", expression: expression.value, timezone: timezone.value };
121
+ void mutate("schedules", { op: schedule ? "update" : "create", id: id.value.trim(), jobId: jobId.value, rule,
122
+ ...(schedule ? { expectedRevision: schedule.revision } : {}) }, closeEditor);
123
+ });
124
+ }
125
+ async function showRun(jobId, runId, target) {
126
+ selectedRun = runId;
127
+ target.replaceChildren(h("p", null, copy("loading")));
128
+ 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),
133
+ fact(copy("trigger"), run.trigger.source + " · " + run.trigger.key),
134
+ 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")));
139
+ 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)); }
142
+ }
143
+ async function showDetail(epoch) {
144
+ const job = jobs.find(j => j.id === selected);
145
+ if (!job) { detail.replaceChildren(); return; }
146
+ const [runs, plans] = await Promise.all([
147
+ read("jobs", { op: "runs", id: job.id }),
148
+ Promise.all(schedules.filter(s => s.jobId === job.id).map(s => read("schedules", { op: "show", id: s.id })))
149
+ ]);
150
+ if (epoch !== generation) return;
151
+ runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
152
+ const summary = run => run ? (run.id || run.runId) + " · " + run.status + " · "
153
+ + (run.terminalAt || run.finishedAt || run.createdAt || run.attemptedAt) : null;
154
+ const runDetail = h("section.job-run");
155
+ detail.replaceChildren(h("div.settings-stack", null, h("h3", null, job.title), fact("ID", job.id),
156
+ fact(copy("scope"), taskId || "global"), fact(copy("project"), job.configuration.projectId),
157
+ fact(copy("revision"), job.revision),
158
+ fact(copy("recentRun"), summary(runs[0])),
159
+ job.refresh ? h("div", null,
160
+ fact(copy("refreshAttempt"), summary(job.refresh.lastAttempt)),
161
+ fact(copy("refreshSuccess"), summary(job.refresh.lastSuccessfulRefresh))) : null,
162
+ h("div.settings-actions", null,
163
+ button(copy("runNow"), () => void mutate("jobs", { op: "run", id: job.id, requestId: crypto.randomUUID() }, result => {
164
+ selectedRun = result.run.id;
165
+ }), true),
166
+ button(copy("editJob"), () => editJob(job)),
167
+ button(copy("newSchedule"), () => editSchedule())),
168
+ raw(copy("configuration"), job.configuration),
169
+ job.refresh ? raw(copy("refreshFacts"), job.refresh) : null,
170
+ h("h3", null, copy("schedules")),
171
+ plans.length ? h("div.settings-stack", null, plans.map(({ schedule: s, lastSuccess }) => h("article.settings-card", null,
172
+ h("h4", null, s.id), fact(copy("state"), s.state),
173
+ s.state === "completed" ? h("p", null, copy("onceCompleted")) : null,
174
+ 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
+ fact(copy("timezone"), s.rule.timezone || "UTC / offset"),
176
+ fact(copy("nextDue"), s.nextDue), fact(copy("lastAttempt"), s.lastAttempt
177
+ ? s.lastAttempt.at + " · " + s.lastAttempt.outcome + " · " + (s.lastAttempt.runId || s.lastAttempt.reason || "") : null),
178
+ fact(copy("lastSuccess"), lastSuccess ? lastSuccess.id + " · " + (lastSuccess.terminalAt || lastSuccess.updatedAt) : null),
179
+ s.reason ? h("p.settings-error", null, s.reason) : null,
180
+ h("div.settings-actions", null,
181
+ 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),
185
+ raw(copy("details"), s)))) : h("p", null, copy("noSchedules")),
186
+ h("h3", null, copy("history")),
187
+ runs.length ? h("div.jobs-runs", null, runs.map(run =>
188
+ button(run.id + " · " + run.status + " · " + run.trigger.source + " · " + run.createdAt,
189
+ () => void showRun(job.id, run.id, runDetail)))) : h("p", null, copy("noRuns")),
190
+ runDetail));
191
+ if (selectedRun) await showRun(job.id, selectedRun, runDetail);
192
+ }
193
+ async function refresh(announce = true) {
194
+ const epoch = ++generation;
195
+ if (announce) status(copy("loading"));
196
+ try {
197
+ const result = await Promise.all([read("jobs", { op: "list" }), read("schedules", { op: "list" })]);
198
+ if (epoch !== generation) return;
199
+ [jobs, schedules] = result;
200
+ if (!selected && jobs.length) selected = jobs[0].id;
201
+ list.replaceChildren(...(jobs.length ? jobs.map(job => h("button.job-choice", {
202
+ type: "button", "aria-pressed": String(selected === job.id), onclick: () => {
203
+ if (pending || !guardDraft()) return;
204
+ closeEditor(); selected = job.id; selectedRun = undefined; void refresh();
205
+ }
206
+ }, h("strong", null, job.title), h("code", null, job.id),
207
+ h("small", null, job.configuration.action || job.configuration.kind),
208
+ h("small", null, job.configuration.projectId || "—"))) : [h("p", null, copy("empty"))]));
209
+ await showDetail(epoch);
210
+ if (announce && epoch === generation) status(copy("observed") + " · " + new Date().toISOString());
211
+ } catch (error) { if (epoch === generation) status(error.message, true); }
212
+ }
213
+ root.classList.add("jobs-manager");
214
+ root.replaceChildren(h("h2", null, copy("heading")),
215
+ h("p", null, copy("help")), fact(copy("scope"), taskId || "global"),
216
+ h("div.settings-actions", null, button(copy("newJob"), () => editJob(), true),
217
+ button(copy("refresh"), () => { if (!pending) void refresh(); })),
218
+ receipt, editor, h("div.jobs-layout", null, list, detail));
219
+ void refresh();
220
+ return { isDirty: () => dirty, isBusy: () => pending, refresh };
221
+ }
222
+ `;
@@ -12,6 +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
16
 
16
17
  // Overview: where it stands. Delivery: what it produced. Runtime: what is
17
18
  // live now. Records: what happened. Retired section names stay addressable.
@@ -20,9 +21,12 @@ export const TAB_ALIASES = ${JSON.stringify(TASK_TAB_ALIASES)};
20
21
  const TAB_ICONS = ${JSON.stringify(TASK_TAB_ICONS)};
21
22
 
22
23
  export function renderTaskDetail(container, data, t, locale, ctx) {
24
+ const savedJobs = container.dataset.taskId === data.task.id && container.dataset.jobsLocale === locale
25
+ ? container.querySelector("#panel-jobs") : null;
23
26
  clear(container);
24
27
  const task = data.task;
25
28
  container.dataset.taskId = task.id;
29
+ container.dataset.jobsLocale = locale;
26
30
  data.viewState.coreKey = data.core.coreCursor;
27
31
  const page = h("div.page.task-page");
28
32
  page.append(h("header.task-header", null, taskBar(task, t, ctx)),
@@ -30,10 +34,16 @@ export function renderTaskDetail(container, data, t, locale, ctx) {
30
34
  h("div.tabs-wrap", null, taskTabs(data.core, t, ctx)));
31
35
  const panels = {};
32
36
  TABS.forEach(function (tab) {
33
- panels[tab] = h("section.tab-panel", { id: "panel-" + tab, role: "tabpanel", "aria-labelledby": "tab-" + tab, hidden: ctx.activeTab !== tab });
37
+ panels[tab] = tab === "jobs" && savedJobs ? savedJobs
38
+ : h("section.tab-panel", { id: "panel-" + tab, role: "tabpanel", "aria-labelledby": "tab-" + tab });
39
+ panels[tab].hidden = ctx.activeTab !== tab;
34
40
  page.append(panels[tab]);
35
41
  });
36
42
  renderOverviewPanel(panels.overview, data, t, locale, ctx);
43
+ if (!savedJobs) panels.jobs.onShow = () => {
44
+ panels.jobs.onShow = null;
45
+ mountJobs(panels.jobs, t, task.id);
46
+ };
37
47
  [["delivery", renderDelivery], ["runtime", renderRuntime], ["records", renderRecords]].forEach(function (entry) {
38
48
  const panel = panels[entry[0]];
39
49
  panel.onShow = function () {
@@ -7,6 +7,7 @@ export const MANAGE_SECTIONS = Object.freeze({
7
7
  settings: [
8
8
  { page: "settings", section: "preferences", key: "settings.section.preferences", label: "Preferences" },
9
9
  { page: "settings", section: "skills", key: "settings.section.skills", label: "Skills" },
10
+ { page: "settings", section: "jobs", key: "settings.section.jobs", label: "Jobs & schedules" },
10
11
  { page: "settings", section: "server", key: "settings.section.server", label: "Agents & server" },
11
12
  { page: "models", section: "connection", key: "models.section.connection", label: "Model connection" }
12
13
  ]
@@ -1,8 +1,8 @@
1
1
  // Task page sections, shared by the shell's shortcut list and the client.
2
2
  // Overview: where it stands. Delivery: what it produced. Runtime: what is live
3
3
  // now. Records: what happened. Retired section names stay addressable.
4
- export const TASK_TABS = ["overview", "delivery", "runtime", "records"];
4
+ export const TASK_TABS = ["overview", "delivery", "runtime", "records", "jobs"];
5
5
  export const TASK_TAB_ICONS = {
6
- overview: "target", delivery: "package", runtime: "pulse", records: "history"
6
+ overview: "target", delivery: "package", runtime: "pulse", records: "history", jobs: "clock"
7
7
  };
8
8
  export const TASK_TAB_ALIASES = Object.freeze({ work: "delivery", execution: "runtime", history: "records", details: "records" });
@@ -22,6 +22,9 @@ export const SETTINGS_MAIN = `
22
22
  ${manageHead("settings-skills-title", "settings.section.skills", "Skills", "skills.browser.intro", "Read existing packages and choose additional bindings for one explicit Role. Scripts are displayed as data, never run.")}
23
23
  <section id="skill-browser" class="card settings-card settings-stack"></section>
24
24
  </section>
25
+ <section class="manage-section" data-section="jobs" hidden>
26
+ <section id="global-jobs"></section>
27
+ </section>
25
28
  <section class="manage-section" data-section="server" aria-labelledby="settings-server-title" hidden>
26
29
  ${manageHead("settings-server-title", "settings.section.server", "Agents & server", "settings.hint.server", "Desired configuration for Yui, Roles, Agents and Profiles. Saving targets later launches; it is not proof that existing Sessions adopted it.")}
27
30
  <label class="field settings-search"><span data-i18n="settings.search">Find a setting</span><input id="settings-search" type="search" autocomplete="off"></label>
@@ -18,6 +18,7 @@ import { TERMINAL_STYLES } from "./views/terminal.js";
18
18
  import { MANAGE_STYLES } from "./views/manage.js";
19
19
  import { SETTINGS_STYLES } from "./views/settings.js";
20
20
  import { MODELS_STYLES } from "./views/models.js";
21
+ import { JOBS_STYLES } from "./views/jobs.js";
21
22
  // The stylesheet registry, in cascade order: foundations (fonts, tokens,
22
23
  // element defaults), the workspace frame, shared UI primitives, then one
23
24
  // sheet per view, rendered prose, and breakpoints last so they override
@@ -42,6 +43,7 @@ export const STYLESHEETS = Object.freeze([
42
43
  { name: "views/manage", body: MANAGE_STYLES },
43
44
  { name: "views/settings", body: SETTINGS_STYLES },
44
45
  { name: "views/models", body: MODELS_STYLES },
46
+ { name: "views/jobs", body: JOBS_STYLES },
45
47
  { name: "markdown", body: MARKDOWN_STYLES },
46
48
  { name: "layout/responsive", body: RESPONSIVE_STYLES }
47
49
  ]);
@@ -0,0 +1,19 @@
1
+ export const JOBS_STYLES = `
2
+ .jobs-manager { min-width: 0; display: grid; gap: 16px; container-type: inline-size; }
3
+ .jobs-layout { display: grid; grid-template-columns: minmax(160px, 240px) minmax(0, 1fr); gap: 24px; align-items: start; }
4
+ .jobs-list, .jobs-detail, .jobs-runs { display: grid; gap: 12px; min-width: 0; }
5
+ .job-choice { display: grid; gap: 6px; text-align: left; padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: inherit; cursor: pointer; overflow-wrap: anywhere; }
6
+ .job-choice[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
7
+ .job-choice:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
8
+ .jobs-manager .settings-actions { flex-wrap: wrap; }
9
+ .jobs-manager .settings-observation { max-width: 100%; max-height: 420px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
10
+ .jobs-manager .btn, .job-fact { overflow-wrap: anywhere; white-space: normal; text-align: left; }
11
+ .jobs-runs .btn { font-family: var(--font-mono); font-size: 12px; }
12
+ .jobs-manager textarea { width: 100%; font-family: var(--font-mono); }
13
+ .jobs-manager [hidden] { display: none !important; }
14
+ @media (max-width: 720px) { .jobs-layout { grid-template-columns: minmax(0, 1fr); } }
15
+ @container (max-width: 640px) {
16
+ .jobs-layout { grid-template-columns: minmax(0, 1fr); }
17
+ .jobs-list { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
18
+ }
19
+ `;