@worca/app 1.3.0 → 1.4.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 (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -0,0 +1,432 @@
1
+ // ui/public/schedules-view.mjs
2
+ // The Schedules view, three tabs (the Statistics .seg idiom): Activity — the feed (every
3
+ // miss, failure, skip, late start and self-pause, with read state); Once — one-off runs;
4
+ // Repeating — the series. app.js owns routing and the WebSocket; this module owns the
5
+ // hosts it is given and routes a tab click through `deps.route(tab)` (#schedules/<tab>).
6
+ //
7
+ // const view = createSchedulesView({ tabsHost, feedHost, onceHost, repeatingHost, subEl, msgEl, deps });
8
+ // view.load() fetch + paint (view entry, `schedules-changed`)
9
+ // view.showTab(tab) 'activity' (default) | 'once' | 'repeating' — the route's param
10
+ // view.loadFeed() feed only (`notification`, `notifications-changed`)
11
+ // view.upcoming(ms) tickets due within `ms` — the Running view's Scheduled group
12
+ // view.destroy() stop the countdown tick
13
+
14
+ import { formatInstant, formatCountdown } from '../../src/shared/schedule/recurrence.mjs';
15
+ import { openScheduleSheet, browserTimeZone } from './schedule-sheet.mjs';
16
+
17
+ const ICON = {
18
+ clock: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"></circle><path d="M12 7.5V12l3 2"></path></svg>',
19
+ repeat: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3l3 3-3 3"></path><path d="M4 11V9a3 3 0 0 1 3-3h13"></path><path d="M7 21l-3-3 3-3"></path><path d="M20 13v2a3 3 0 0 1-3 3H4"></path></svg>',
20
+ bang: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M12 6.5v7"></path><path d="M12 17.4h.01"></path></svg>',
21
+ pause: '<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1.5"></rect><rect x="14" y="5" width="4" height="14" rx="1.5"></rect></svg>',
22
+ };
23
+ /** Feed kinds -> badge word + colour family (the app's .badge variants). */
24
+ const KIND = {
25
+ missed: ['Missed', 'amber'], failed: ['Failed', 'red'], run_error: ['Error', 'red'], paused: ['Paused', 'amber'],
26
+ run_paused: ['Paused', 'amber'], skipped: ['Skipped', 'grey'], late: ['Late', 'blue'], retrying: ['Retrying', 'grey'],
27
+ completed: ['Completed', 'green'], ended: ['Ended', 'grey'],
28
+ };
29
+ const RESULT_WORD = { completed: 'completed', error: 'ended with an error', failed: 'could not start', missed: 'missed', skipped: 'skipped', paused: 'paused', stopped: 'stopped' };
30
+ const RESULT_FAMILY = { completed: 'green', error: 'red', failed: 'red', missed: 'amber', skipped: 'grey', paused: 'amber', stopped: 'grey' };
31
+ /** How the run an after-ticket waits for stands (its pipeline or ticket status). */
32
+ const AFTER_WORD = { running: 'running', starting: 'starting', created: 'starting', pausing: 'pausing', paused: 'paused', scheduled: 'scheduled', firing: 'starting', fired: 'starting',
33
+ done: 'finished', error: 'ended with an error', stopped: 'stopped', interrupted: 'interrupted', missed: 'missed', canceled: 'canceled', failed: 'could not start', skipped: 'skipped' };
34
+ const afterWord = (a) => (a && a.status ? (AFTER_WORD[a.status] || a.status) : 'waiting');
35
+ const afterName = (a) => `‘${(a && a.title) || 'the run before it'}’`;
36
+
37
+ /** A constant SVG string from ICON as a node, parsed as XML. */
38
+ function svgIcon(markup) {
39
+ const doc = new DOMParser().parseFromString(markup.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" '), 'image/svg+xml');
40
+ return document.importNode(doc.documentElement, true);
41
+ }
42
+
43
+ function h(tag, attrs = {}, ...kids) {
44
+ const node = document.createElement(tag);
45
+ for (const [k, v] of Object.entries(attrs)) {
46
+ if (v == null || v === false) continue;
47
+ if (k === 'class') node.className = v;
48
+ else if (k === 'text') node.textContent = v;
49
+ else if (k === 'icon') node.append(svgIcon(v));
50
+ else if (k === 'hidden') node.hidden = !!v;
51
+ else node.setAttribute(k, v === true ? '' : String(v));
52
+ }
53
+ for (const kid of kids.flat()) if (kid != null) node.append(kid);
54
+ return node;
55
+ }
56
+
57
+ async function api(method, url, body) {
58
+ const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
59
+ let data = {};
60
+ try { data = await res.json(); } catch { /* empty body */ }
61
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
62
+ return data;
63
+ }
64
+
65
+ function relativeAgo(iso) {
66
+ const ms = Date.now() - Date.parse(iso);
67
+ if (!Number.isFinite(ms)) return '';
68
+ if (ms < 60_000) return 'just now';
69
+ if (ms < 3600_000) return `${Math.floor(ms / 60_000)} min ago`;
70
+ if (ms < 86400_000) return `${Math.floor(ms / 3600_000)} h ago`;
71
+ return formatInstant(Date.parse(iso), browserTimeZone());
72
+ }
73
+
74
+ export const SCHEDULE_TABS = ['activity', 'once', 'repeating'];
75
+
76
+ /**
77
+ * @param {object} o
78
+ * @param {HTMLElement} o.tabsHost the .seg holding button[data-tab]
79
+ * @param {HTMLElement} o.feedHost the Activity pane (the feed)
80
+ * @param {HTMLElement} o.onceHost the Once pane (one-off runs)
81
+ * @param {HTMLElement} o.repeatingHost the Repeating pane (series)
82
+ * @param {HTMLElement} [o.subEl] the topbar sub line
83
+ * @param {HTMLElement} [o.msgEl] a .form-msg line for action errors
84
+ * @param {object} o.deps { confirmModal, targetLabel(item), workflowLabel(id), onCounts(counts), openRun(item), route(tab) }
85
+ */
86
+ export function createSchedulesView({ tabsHost = null, feedHost, onceHost, repeatingHost, subEl = null, msgEl = null, deps }) {
87
+ const tz = browserTimeZone();
88
+ const model = { schedules: [], tickets: [], counts: {}, defaults: { graceMin: 360, ifMissed: 'run', maxFailures: 3 }, feed: [], filter: 'all', loaded: false, tab: 'activity' };
89
+ let timer = null;
90
+
91
+ const say = (text, kind = '') => { if (msgEl) { msgEl.textContent = text || ''; msgEl.className = `form-msg${kind ? ` ${kind}` : ''}`; } };
92
+ const when = (iso) => formatInstant(Date.parse(iso), tz);
93
+ const countdown = (iso) => formatCountdown(Date.parse(iso) - Date.now());
94
+
95
+ async function act(fn, okText = '') {
96
+ say('');
97
+ try { await fn(); if (okText) say(okText, 'ok'); }
98
+ catch (err) { say(err.message, 'err'); }
99
+ await load();
100
+ }
101
+
102
+ // ── rows ───────────────────────────────────────────────────────────────────
103
+ function metaLine(item) {
104
+ const bits = [deps.targetLabel(item), deps.workflowLabel(item.summary.workflowId)];
105
+ if (item.summary.sourceBranch) bits.push(`from ${item.summary.sourceBranch}`);
106
+ if (item.sourceFromPrevious) bits.push('from the run before it');
107
+ if (item.summary.mock) bits.push('mock');
108
+ return bits.filter(Boolean).join(' · ');
109
+ }
110
+
111
+ function detailsBlock(item) {
112
+ const body = h('div', { class: 'sched-details-body', hidden: true });
113
+ const row = (k, v) => (v ? h('div', { class: 'sched-kv' }, h('span', { class: 'sched-k', text: k }), h('span', { class: 'sched-v', text: v })) : null);
114
+ const src = item.summary.source && item.summary.source.type === 'plugin' ? item.summary.source : null;
115
+ body.append(...[
116
+ row('Task', src ? `${src.plugin} · ${src.taskId} — fetched when the run starts` : (item.summary.prompt || '—')),
117
+ item.after ? row('After', `${(item.after.title || item.after.id.slice(0, 8))} · ${afterWord(item.after)}`) : null,
118
+ item.after ? row('If it fails', item.after.policy === 'any' ? 'Start anyway' : 'Do not start') : null,
119
+ item.sourceFromPrevious ? row('Source branch', 'the run before it') : null,
120
+ row('Feature branch', item.summary.featureBranch ? (item.kind === 'recurring' ? `${item.summary.featureBranch}-<date>` : item.summary.featureBranch) : ''),
121
+ row('Guardrails', item.summary.guardrailsId && item.summary.guardrailsId !== 'permissive' ? item.summary.guardrailsId : ''),
122
+ row('Extra files', item.summary.extras ? String(item.summary.extras) : ''),
123
+ item.after ? null : row('If Worca is not running', item.ifMissed === 'skip' ? 'Skip it' : `Start it late, at most ${item.graceMin >= 60 && item.graceMin % 60 === 0 ? `${item.graceMin / 60} h` : `${item.graceMin} min`}`),
124
+ item.kind === 'recurring' ? row('If the previous run is still going', { skip: 'Skip this one', queue: 'Wait, then start', start: 'Start anyway' }[item.overlap]) : null,
125
+ item.kind === 'recurring' ? row('Pause after failures in a row', item.maxFailures ? String(item.maxFailures) : 'Never') : null,
126
+ item.kind === 'recurring' ? row('Runs so far', String(item.runsCount)) : null,
127
+ item.ownerPid != null && item.status === 'scheduled' ? row('Held by', 'a waiting terminal (worca --wait) — it starts the run') : null,
128
+ ].filter(Boolean));
129
+ return body;
130
+ }
131
+
132
+ /** The card's footer: a Details toggle on the left, the actions on the right. */
133
+ function footer(item, acts, body) {
134
+ const more = h('button', { type: 'button', class: 'sched-more', 'aria-expanded': 'false', text: 'Details' });
135
+ more.addEventListener('click', () => {
136
+ body.hidden = !body.hidden;
137
+ more.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
138
+ });
139
+ return h('div', { class: 'sched-foot' }, more, acts);
140
+ }
141
+
142
+ function ticketRow(t) {
143
+ const missed = t.status === 'missed';
144
+ const firing = t.status === 'firing';
145
+ const chained = !!t.after;
146
+ const statusWord = missed ? 'Missed' : firing ? 'Starting' : chained ? 'Waiting for a run' : t.queued ? 'Waiting for the previous run' : t.retryAt ? 'Retrying' : 'Scheduled';
147
+ const family = missed ? 'amber' : firing ? 'peach' : 'grey';
148
+ // The word after the dot always describes the PREDECESSOR (running / finished / ended with an
149
+ // error); a missed after-ticket says Missed in the status word and its fail_reason below.
150
+ const timeText = chained
151
+ ? `After ${afterName(t.after)} · ${afterWord(t.after)}`
152
+ : (missed ? `was due ${when(t.runAt)}` : `${when(t.runAt)}${countdown(t.runAt) ? ` · in ${countdown(t.runAt)}` : ''}`);
153
+ const acts = h('div', { class: 'sched-acts' });
154
+ if (!firing) {
155
+ const runNow = h('button', { type: 'button', class: 'btn btn-mini', text: 'Run now' });
156
+ runNow.addEventListener('click', () => act(async () => {
157
+ const out = await api('POST', `/api/schedules/${t.id}/run-now`);
158
+ if (out.status === 'failed') throw new Error(out.failReason || 'The run could not be started.');
159
+ if (out.status === 'fired' && deps.openRun) deps.openRun({ runId: out.runId });
160
+ }));
161
+ acts.append(runNow);
162
+ if (!t.scheduleId) {
163
+ const move = h('button', { type: 'button', class: 'btn btn-mini', text: chained ? 'Change…' : (missed ? 'Reschedule' : 'Change time') });
164
+ move.addEventListener('click', async () => {
165
+ const res = await openScheduleSheet({
166
+ mode: 'ticket', runTitle: t.title || '', defaults: model.defaults,
167
+ initial: chained ? { after: t.after, afterPolicy: t.after.policy } : { scheduledFor: t.runAt, ifMissed: t.ifMissed, graceMin: t.graceMin },
168
+ candidates: () => api('GET', `/api/schedules/after-candidates?${t.workspaceId ? `workspaceId=${encodeURIComponent(t.workspaceId)}` : `projectDir=${encodeURIComponent(t.projectDir || '')}`}`),
169
+ });
170
+ if (res) act(() => api('PATCH', `/api/schedules/${t.id}`, res));
171
+ });
172
+ const cancel = h('button', { type: 'button', class: 'btn btn-danger btn-mini', text: missed ? 'Dismiss' : 'Cancel' });
173
+ cancel.addEventListener('click', async () => {
174
+ if (!missed) {
175
+ // The same sentence, cap and guard as app.js's afterDependentsNote (the Archive note) — not
176
+ // named `deps`: that is this view's injected dependencies object. The read is async and no modal
177
+ // is up yet: hold the button, or a second click sends a second DELETE.
178
+ cancel.disabled = true;
179
+ let note = '';
180
+ try {
181
+ const d = await api('GET', `/api/schedules/dependents?ticketId=${encodeURIComponent(t.id)}`);
182
+ const waiting = Array.isArray(d && d.dependents) ? d.dependents : [];
183
+ if (waiting.length) {
184
+ const names = waiting.slice(0, 4).map((x) => `“${x.title || 'Scheduled run'}”`).join(', ');
185
+ note = `\n\n${names}${waiting.length > 4 ? ` and ${waiting.length - 4} more` : ''} ${waiting.length === 1 ? 'waits' : 'wait'} for this run and will be marked missed.`;
186
+ }
187
+ } catch { /* the note is optional */ }
188
+ cancel.disabled = false;
189
+ if (!(await deps.confirmModal({ title: 'Cancel scheduled run', message: `Cancel “${t.title || 'this run'}”?\nIt will not start. Nothing has run yet, so there is nothing to clean up.${note}`, confirmLabel: 'Cancel run' }))) return;
190
+ }
191
+ act(() => api('DELETE', `/api/schedules/${t.id}`));
192
+ });
193
+ acts.append(move, cancel);
194
+ }
195
+ }
196
+ if (!t.scheduleId && !missed) {
197
+ const next = h('button', { type: 'button', class: 'btn btn-mini', text: 'Schedule next…', title: 'Schedule a run that starts after this one' });
198
+ next.addEventListener('click', () => { location.hash = `#new/after/t:${t.id}`; });
199
+ acts.append(next);
200
+ }
201
+ const details = detailsBlock(t);
202
+ return h('section', { class: `card sched-item${missed ? ' attention' : ''}`, 'data-id': t.id },
203
+ h('div', { class: 'sched-head' },
204
+ h('span', { class: `rc-sic st-${missed ? 'amber' : 'grey'}`, icon: missed ? ICON.bang : ICON.clock, role: 'img', 'aria-label': statusWord }),
205
+ h('div', { class: 'rc-body' },
206
+ h('div', { class: 'rc-title', text: t.title || 'Scheduled run' }),
207
+ h('div', { class: 'rc-meta' },
208
+ h('span', { class: `rc-status-word st-${family}`, text: statusWord }),
209
+ h('span', { class: 'rc-seg' }, h('span', { class: 'rc-dot', text: '·' }), h('span', { class: 'sched-when', 'data-at': missed || chained ? '' : t.runAt, text: timeText }))),
210
+ h('div', { class: 'sched-target', text: `${t.scheduleId ? 'Repeating' : 'Once'} · ${metaLine(t)}` }),
211
+ missed && t.failReason ? h('div', { class: 'sched-reason', text: t.failReason }) : null)),
212
+ footer(t, acts, details), details);
213
+ }
214
+
215
+ function seriesRow(s) {
216
+ const paused = s.status === 'paused';
217
+ const ended = s.status === 'ended';
218
+ const streakPause = paused && s.pauseReason === 'failure_streak';
219
+ const statusWord = ended ? 'Ended' : streakPause ? `Paused after ${s.failureStreak} failure${s.failureStreak === 1 ? '' : 's'}` : paused ? 'Paused' : 'Active';
220
+ const family = ended ? 'grey' : paused ? 'amber' : 'green';
221
+ const sw = h('div', { class: `switch${s.status === 'active' ? ' on' : ''}`, role: 'switch', tabindex: ended ? '-1' : '0', 'aria-checked': s.status === 'active' ? 'true' : 'false', 'aria-label': paused ? 'Resume this schedule' : 'Pause this schedule', title: paused ? 'Resume' : 'Pause' });
222
+ const toggle = () => { if (!ended) act(() => api('POST', `/api/schedules/${s.id}/${paused ? 'resume' : 'pause'}`)); };
223
+ sw.addEventListener('click', toggle);
224
+ sw.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle(); } });
225
+
226
+ const acts = h('div', { class: 'sched-acts' });
227
+ if (!ended) {
228
+ const runNow = h('button', { type: 'button', class: 'btn btn-mini', text: 'Run now', title: 'Start one extra run now — the schedule itself does not shift' });
229
+ runNow.addEventListener('click', () => act(async () => {
230
+ const out = await api('POST', `/api/schedules/${s.id}/run-now`);
231
+ if (out.status === 'failed') throw new Error(out.failReason || 'The run could not be started.');
232
+ if (out.status === 'fired' && deps.openRun) deps.openRun({ runId: out.runId });
233
+ }));
234
+ const skip = h('button', { type: 'button', class: 'btn btn-mini', text: 'Skip next', hidden: paused });
235
+ skip.addEventListener('click', () => act(() => api('POST', `/api/schedules/${s.id}/skip-next`)));
236
+ const edit = h('button', { type: 'button', class: 'btn btn-mini', text: 'Edit' });
237
+ edit.addEventListener('click', async () => {
238
+ const res = await openScheduleSheet({ mode: 'series', runTitle: s.title || '', defaults: model.defaults, initial: { rule: s.rule, overlap: s.overlap, maxFailures: s.maxFailures, ifMissed: s.ifMissed, graceMin: s.graceMin } });
239
+ if (res) act(() => api('PATCH', `/api/schedules/${s.id}`, { rule: res.repeat.rule, overlap: res.repeat.overlap, maxFailures: res.repeat.maxFailures, ifMissed: res.ifMissed, graceMin: res.graceMin }));
240
+ });
241
+ acts.append(runNow, skip, edit);
242
+ }
243
+ const del = h('button', { type: 'button', class: 'btn btn-danger btn-mini', text: 'Delete' });
244
+ del.addEventListener('click', async () => {
245
+ if (!(await deps.confirmModal({ title: 'Delete schedule', message: `Delete “${s.title || 'this schedule'}”?\nIt stops repeating. Runs it already started stay in History.`, confirmLabel: 'Delete schedule' }))) return;
246
+ act(() => api('DELETE', `/api/schedules/${s.id}`));
247
+ });
248
+ acts.append(del);
249
+
250
+ const next = s.nextRunAt ? h('span', { class: 'sched-when', 'data-at': s.nextRunAt, 'data-prefix': 'Next: ', text: `Next: ${when(s.nextRunAt)}${countdown(s.nextRunAt) ? ` · in ${countdown(s.nextRunAt)}` : ''}` }) : null;
251
+ const last = s.lastResult ? h('span', { class: `badge ${RESULT_FAMILY[s.lastResult] || 'grey'}`, text: `Last: ${RESULT_WORD[s.lastResult] || s.lastResult}` }) : null;
252
+ const details = detailsBlock(s);
253
+ return h('section', { class: `card sched-item${streakPause ? ' attention' : ''}`, 'data-id': s.id },
254
+ h('div', { class: 'sched-head' },
255
+ h('span', { class: `rc-sic st-${streakPause ? 'amber' : 'grey'}`, icon: paused ? ICON.pause : ICON.repeat, role: 'img', 'aria-label': 'Repeating schedule' }),
256
+ h('div', { class: 'rc-body' },
257
+ h('div', { class: 'rc-title', text: s.title || 'Repeating schedule' }),
258
+ h('div', { class: 'rc-meta' },
259
+ h('span', { class: `rc-status-word st-${family}`, text: statusWord }),
260
+ h('span', { class: 'rc-seg' }, h('span', { class: 'rc-dot', text: '·' }), h('span', { class: 'sched-rule', text: s.sentence }))),
261
+ h('div', { class: 'sched-target', text: metaLine(s) }),
262
+ (next || last) ? h('div', { class: 'sched-sub' }, next, last) : null),
263
+ ended ? null : sw),
264
+ footer(s, acts, details), details);
265
+ }
266
+
267
+ /** Replace a pane's rows, keeping an open Details panel open (a tick or a broadcast must not slam it shut). */
268
+ function paintPane(host, rows, emptyNode) {
269
+ if (!host) return;
270
+ const openIds = new Set([...host.querySelectorAll('.sched-item')].filter((c) => c.querySelector('.sched-more[aria-expanded="true"]')).map((c) => c.dataset.id));
271
+ host.replaceChildren(...(rows.length ? rows : [emptyNode]));
272
+ for (const c of host.querySelectorAll('.sched-item')) if (openIds.has(c.dataset.id)) c.querySelector('.sched-more').click();
273
+ }
274
+ const emptyOnce = () => h('div', { class: 'run-empty' }, 'No one-off run is waiting. Pick a time with ',
275
+ h('a', { href: '#new/schedule', text: 'Schedule a run' }), ', then describe the task — or use Schedule… next to Start run on New pipeline.');
276
+ const emptyRepeating = () => h('div', { class: 'run-empty' }, 'No repeating schedule. Pick a time with ',
277
+ h('a', { href: '#new/schedule', text: 'Schedule a run' }), ' and choose Every day, Weekdays, Weekly or Monthly.');
278
+
279
+ function paintTabs() {
280
+ if (!tabsHost) return;
281
+ const once = model.tickets.filter((t) => !t.scheduleId).length;
282
+ const series = model.schedules.filter((s) => s.status !== 'ended').length;
283
+ const unread = model.feed.filter((n) => n.unread).length;
284
+ const label = { activity: unread ? `Activity · ${unread}` : 'Activity', once: once ? `Once · ${once}` : 'Once', repeating: series ? `Repeating · ${series}` : 'Repeating' };
285
+ for (const b of tabsHost.querySelectorAll('button[data-tab]')) {
286
+ b.textContent = label[b.dataset.tab] || b.dataset.tab;
287
+ b.classList.toggle('on', b.dataset.tab === model.tab);
288
+ b.setAttribute('aria-pressed', b.dataset.tab === model.tab ? 'true' : 'false');
289
+ }
290
+ for (const [tab, host] of [['activity', feedHost], ['once', onceHost], ['repeating', repeatingHost]]) if (host) host.hidden = tab !== model.tab;
291
+ }
292
+ /** Show one tab (the route's param); an unknown or empty name is Activity. */
293
+ function showTab(tab) {
294
+ model.tab = SCHEDULE_TABS.includes(tab) ? tab : 'activity';
295
+ paintTabs();
296
+ }
297
+ tabsHost?.addEventListener('click', (e) => {
298
+ const b = e.target.closest('button[data-tab]');
299
+ if (!b) return;
300
+ if (typeof deps.route === 'function') deps.route(b.dataset.tab); else showTab(b.dataset.tab);
301
+ });
302
+
303
+ function paintList() {
304
+ const series = model.schedules;
305
+ const once = model.tickets.filter((t) => !t.scheduleId);
306
+ paintPane(onceHost, once.map(ticketRow), emptyOnce());
307
+ paintPane(repeatingHost, series.map(seriesRow), emptyRepeating());
308
+ paintTabs();
309
+ if (subEl) {
310
+ const n = once.filter((t) => t.status !== 'missed').length;
311
+ const active = series.filter((s) => s.status === 'active').length;
312
+ subEl.textContent = `${n} run${n === 1 ? '' : 's'} waiting · ${active} repeating schedule${active === 1 ? '' : 's'} active`;
313
+ }
314
+ }
315
+
316
+ // ── feed ───────────────────────────────────────────────────────────────────
317
+ function feedItem(n) {
318
+ const [word, family] = KIND[n.kind] || [n.kind, 'grey'];
319
+ const ticket = n.ticketId ? model.tickets.find((t) => t.id === n.ticketId) : null;
320
+ const series = n.scheduleId ? model.schedules.find((s) => s.id === n.scheduleId) : null;
321
+ const acts = h('div', { class: 'sched-feed-acts' });
322
+ if (!n.resolvedAt && n.kind === 'missed' && ticket && ticket.status === 'missed') {
323
+ const b = h('button', { type: 'button', class: 'btn btn-mini', text: 'Run now' });
324
+ b.addEventListener('click', () => act(() => api('POST', `/api/schedules/${ticket.id}/run-now`)));
325
+ acts.append(b);
326
+ }
327
+ if (!n.resolvedAt && n.kind === 'paused' && series && series.status === 'paused') {
328
+ const b = h('button', { type: 'button', class: 'btn btn-mini', text: 'Resume schedule' });
329
+ b.addEventListener('click', () => act(() => api('POST', `/api/schedules/${series.id}/resume`)));
330
+ acts.append(b);
331
+ }
332
+ if (n.pipelineId && deps.openRun) {
333
+ const b = h('button', { type: 'button', class: 'btn btn-mini sched-feed-open', text: 'Open run' });
334
+ b.addEventListener('click', () => deps.openRun({ pipelineId: n.pipelineId, projectDir: n.projectDir }));
335
+ acts.append(b);
336
+ }
337
+ if (n.unread) {
338
+ const b = h('button', { type: 'button', class: 'sched-feed-read', text: 'Mark read', title: 'Mark as read' });
339
+ b.addEventListener('click', async () => { try { await api('POST', `/api/notifications/${n.id}/read`); } catch (err) { say(err.message, 'err'); } await loadFeed(); });
340
+ acts.append(b);
341
+ }
342
+ return h('div', { class: `sched-feed-item${n.unread ? ' unread' : ''}`, 'data-id': n.id },
343
+ h('span', { class: `badge ${family}`, text: word }),
344
+ h('div', { class: 'sched-feed-body' },
345
+ h('div', { class: 'sched-feed-text' }, h('b', { text: n.title || 'Scheduled run' }), ` ${humanMessage(n)}`),
346
+ h('div', { class: 'sched-feed-time', text: relativeAgo(n.createdAt), title: new Date(n.createdAt).toLocaleString() })),
347
+ acts);
348
+ }
349
+
350
+ /** Server messages carry ISO instants; show them in local time. */
351
+ function humanMessage(n) {
352
+ return String(n.message || '').replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, (isoStr) => when(isoStr));
353
+ }
354
+
355
+ function paintFeed() {
356
+ const unread = model.feed.filter((n) => n.unread).length;
357
+ const rows = model.feed.filter((n) => (model.filter === 'unread' ? n.unread : model.filter === 'problems' ? n.severity === 'problem' : true));
358
+ const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Filter activity' });
359
+ for (const [key, label] of [['all', 'All'], ['unread', unread ? `Unread · ${unread}` : 'Unread'], ['problems', 'Problems']]) {
360
+ const b = h('button', { type: 'button', class: model.filter === key ? 'on' : '', 'aria-pressed': model.filter === key ? 'true' : 'false', text: label });
361
+ b.addEventListener('click', () => { model.filter = key; paintFeed(); });
362
+ seg.append(b);
363
+ }
364
+ const markAll = h('button', { type: 'button', class: 'btn btn-mini', text: 'Mark all read', disabled: unread === 0 });
365
+ markAll.addEventListener('click', async () => { try { await api('POST', '/api/notifications/read-all', {}); } catch (err) { say(err.message, 'err'); } await loadFeed(); });
366
+ const body = rows.length
367
+ ? rows.map(feedItem)
368
+ : [h('div', { class: 'run-empty', text: model.filter === 'all' ? 'No activity yet. Missed, failed, skipped and completed scheduled runs show up here.' : 'Nothing here.' })];
369
+ feedHost.replaceChildren(
370
+ h('div', { class: 'card-head sched-feed-head' }, h('h2', { text: 'Activity' }), h('div', { class: 'sched-feed-tools' }, seg, markAll)),
371
+ h('div', { class: 'sched-feed-list' }, ...body));
372
+ }
373
+
374
+ // ── data ───────────────────────────────────────────────────────────────────
375
+ async function loadFeed() {
376
+ try {
377
+ const data = await api('GET', '/api/notifications?scope=schedule');
378
+ model.feed = Array.isArray(data.notifications) ? data.notifications : [];
379
+ model.counts = { ...model.counts, unread: data.unread || 0 };
380
+ deps.onCounts?.(model.counts);
381
+ if (feedHost.isConnected) { paintFeed(); paintTabs(); }
382
+ } catch (err) { say(err.message, 'err'); }
383
+ }
384
+
385
+ async function load() {
386
+ ensureTimer();
387
+ try {
388
+ const data = await api('GET', '/api/schedules');
389
+ model.schedules = Array.isArray(data.schedules) ? data.schedules : [];
390
+ model.tickets = Array.isArray(data.tickets) ? data.tickets : [];
391
+ model.counts = data.counts || {};
392
+ if (data.defaults) model.defaults = data.defaults;
393
+ model.loaded = true;
394
+ deps.onCounts?.(model.counts);
395
+ paintList();
396
+ } catch (err) { say(err.message, 'err'); }
397
+ await loadFeed();
398
+ }
399
+
400
+ /** Repaint the "in 7h 12m" parts in place — no refetch, no rebuild. */
401
+ function tickCountdowns(root = document) {
402
+ for (const node of root.querySelectorAll('.sched-when[data-at]')) {
403
+ const at = node.dataset.at;
404
+ if (!at) continue;
405
+ const cd = countdown(at);
406
+ node.textContent = `${node.dataset.prefix || ''}${when(at)}${cd ? ` · in ${cd}` : ''}`;
407
+ }
408
+ }
409
+ // Started lazily on the first load (never at import: app.js is also imported under Node by
410
+ // the jsdom tests, where a live interval would hold the process open) and unref'd there.
411
+ function ensureTimer() {
412
+ if (timer) return;
413
+ timer = setInterval(() => tickCountdowns(), 15000);
414
+ if (timer && typeof timer.unref === 'function') timer.unref();
415
+ }
416
+
417
+ return {
418
+ load, loadFeed, tickCountdowns, showTab,
419
+ get tab() { return model.tab; },
420
+ get defaults() { return model.defaults; },
421
+ get counts() { return model.counts; },
422
+ /** One-off tickets and series occurrences due within `ms` (in the list's own order), plus missed — plus
423
+ * EVERY waiting after-ticket (its runAt is the sentinel): Running › Scheduled shows a chain whole. */
424
+ upcoming(ms) {
425
+ const limit = Date.now() + ms;
426
+ return model.tickets.filter((t) => t.status === 'missed' || (t.after && (t.status === 'scheduled' || t.status === 'firing')) || Date.parse(t.runAt) <= limit);
427
+ },
428
+ ticketRow,
429
+ isLoaded: () => model.loaded,
430
+ destroy() { clearInterval(timer); timer = null; },
431
+ };
432
+ }