@worca/app 1.4.0 → 1.5.0-rc.3

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 (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -0,0 +1,480 @@
1
+ // ui/public/team-metrics-timeline.mjs
2
+ // Team metrics → Timeline (docs/team-metrics.md "Timeline"): work items as bars on a calendar,
3
+ // month → week → day, for the people who plan the work rather than run it. Pure DOM: takes the
4
+ // work items built by src/shared/team-metrics/timeline.mjs and returns detached elements; app.js
5
+ // owns fetching, state and the delegated events (every control carries a data-tl-* attribute).
6
+ // Calendar windows are in the viewer's LOCAL time — a PM reads "Thursday", not a UTC day.
7
+ import { summarizeWindow, groupByPerson } from '../../src/shared/team-metrics/timeline.mjs';
8
+ import { safeHttpUrl } from '../../src/shared/team-metrics/aggregate.mjs';
9
+ import { TM_FMT } from './team-metrics-view.mjs';
10
+
11
+ export const TL_ZOOMS = Object.freeze(['month', 'week', 'day']);
12
+ export const TL_MODES = Object.freeze(['items', 'people']);
13
+ export const TL_FILTERS = Object.freeze(['shipped', 'review', 'attention']);
14
+ const HOUR = 3_600_000;
15
+ // Minimum widths; a wider panel stretches the calendar to fill it (timelineWindow's `fit`).
16
+ const DAY_PX = 24; // month: one day
17
+ const WEEKDAY_PX = 110; // week: one day
18
+ const HOUR_PX = 52; // day: one hour
19
+ const DAY_FIRST_HOUR = 6;
20
+ const DAY_HOURS = 16; // 06:00–22:00
21
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
22
+ const MONTH = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
23
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
24
+
25
+ export const TL_STATUS = Object.freeze({
26
+ shipped: { label: 'Shipped', tone: 'green' },
27
+ review: { label: 'In review', tone: 'blue' },
28
+ attention: { label: 'Needs attention', tone: 'red' },
29
+ stopped: { label: 'Stopped', tone: 'slate' },
30
+ closed: { label: 'Closed, not merged', tone: 'slate' },
31
+ done: { label: 'Done', tone: 'violet' },
32
+ });
33
+
34
+ // ---- calendar ------------------------------------------------------------------------------
35
+
36
+ const dayStart = (t) => { const d = new Date(t); return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); };
37
+ const addDays = (t, n) => { const d = new Date(t); return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n, d.getHours(), d.getMinutes()).getTime(); };
38
+ const weekStart = (t) => addDays(dayStart(t), -((new Date(t).getDay() + 6) % 7));
39
+
40
+ /** ISO-8601 week number of the local date `t`. */
41
+ export function isoWeek(t) {
42
+ const d = new Date(t);
43
+ const u = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
44
+ const wd = u.getUTCDay() || 7;
45
+ u.setUTCDate(u.getUTCDate() + 4 - wd);
46
+ return Math.ceil(((u - Date.UTC(u.getUTCFullYear(), 0, 1)) / 86_400_000 + 1) / 7);
47
+ }
48
+
49
+ /**
50
+ * The calendar window a zoom shows around `anchor`: { s, e (exclusive), W (px) }. `fit` is the
51
+ * width available for the calendar: W grows to it, never below the zoom's minimum (the panel then
52
+ * scrolls sideways).
53
+ */
54
+ export function timelineWindow(zoom, anchor, { fit = 0 } = {}) {
55
+ const a = new Date(anchor);
56
+ const width = (min) => Math.max(min, Math.floor(Number(fit) || 0));
57
+ if (zoom === 'week') { const s = weekStart(anchor); return { s, e: addDays(s, 7), W: width(7 * WEEKDAY_PX) }; }
58
+ if (zoom === 'day') {
59
+ const s = new Date(a.getFullYear(), a.getMonth(), a.getDate(), DAY_FIRST_HOUR).getTime();
60
+ return { s, e: s + DAY_HOURS * HOUR, W: width(DAY_HOURS * HOUR_PX) };
61
+ }
62
+ const s = new Date(a.getFullYear(), a.getMonth(), 1).getTime();
63
+ const e = new Date(a.getFullYear(), a.getMonth() + 1, 1).getTime();
64
+ return { s, e, W: width(Math.round((e - s) / 86_400_000) * DAY_PX) };
65
+ }
66
+
67
+ /** Previous / next period (dir −1 / +1). Month steps land mid-day on the 1st. */
68
+ export function shiftAnchor(zoom, anchor, dir) {
69
+ const a = new Date(anchor);
70
+ if (zoom === 'month') return new Date(a.getFullYear(), a.getMonth() + dir, 1, 12).getTime();
71
+ return addDays(anchor, zoom === 'week' ? 7 * dir : dir);
72
+ }
73
+
74
+ export const fmtDay = (t) => { const d = new Date(t); return `${WD[d.getDay()]} ${d.getDate()} ${MON[d.getMonth()]}`; };
75
+ const hhmm = (t) => { const d = new Date(t); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
76
+
77
+ /** "45m", "3h 10m", "2d 4h" — how long, in the words a PM uses. */
78
+ export function fmtSpan(ms) {
79
+ if (ms == null || !Number.isFinite(ms)) return '—';
80
+ const m = Math.max(0, Math.round(ms / 60_000));
81
+ if (m < 60) return `${m}m`;
82
+ if (m < 1440) { const hrs = Math.floor(m / 60); const r = m % 60; return r ? `${hrs}h ${r}m` : `${hrs}h`; }
83
+ const d = Math.floor(m / 1440); const hrs = Math.floor((m % 1440) / 60);
84
+ return hrs ? `${d}d ${hrs}h` : `${d}d`;
85
+ }
86
+
87
+ // ---- DOM helpers ---------------------------------------------------------------------------
88
+
89
+ function h(doc, tag, cls, text) {
90
+ const n = doc.createElement(tag);
91
+ if (cls) n.className = cls;
92
+ if (text != null) n.textContent = text;
93
+ return n;
94
+ }
95
+ function btn(doc, cls, text, data = {}) {
96
+ const b = h(doc, 'button', cls, text);
97
+ b.type = 'button';
98
+ for (const [k, v] of Object.entries(data)) b.dataset[k] = String(v);
99
+ return b;
100
+ }
101
+ const px = (n) => `${Math.round(n * 10) / 10}px`;
102
+
103
+ // ---- the page ------------------------------------------------------------------------------
104
+
105
+ /** [label, items][]: by project, or by person (groupByPerson: one person across runs and PRs). */
106
+ function groupItems(items, mode, known) {
107
+ if (mode === 'people') return groupByPerson(items, { known }).map((g) => [g.key ? g.label : '', g.items]);
108
+ const groups = new Map();
109
+ for (const it of items) {
110
+ if (!groups.has(it.project)) groups.set(it.project, []);
111
+ groups.get(it.project).push(it);
112
+ }
113
+ return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
114
+ }
115
+
116
+ function tiles(doc, sum, { filter }) {
117
+ const row = h(doc, 'div', 'tl-tiles');
118
+ const tile = ({ f, tone, label, value, sub }) => {
119
+ const t = f ? btn(doc, `card stat-tile tl-tile tl-t-${tone}`, null, { tlFilter: f }) : h(doc, 'div', 'card stat-tile tl-tile');
120
+ if (f) t.setAttribute('aria-pressed', String(filter === f));
121
+ const l = h(doc, 'div', 'stat-label');
122
+ if (tone) l.append(h(doc, 'i', 'tl-dot'));
123
+ l.append(h(doc, 'span', null, label));
124
+ t.append(l, h(doc, 'div', 'stat-value', value), h(doc, 'small', 'stat-sub', sub));
125
+ return t;
126
+ };
127
+ if (sum.prKnown) {
128
+ row.append(tile({ f: 'shipped', tone: 'green', label: 'Shipped', value: String(sum.shipped.length), sub: 'Pull requests merged in this period' }));
129
+ row.append(tile({ f: 'review', tone: 'blue', label: 'In review', value: String(sum.inReview.length), sub: 'Pull request open, not merged yet' }));
130
+ } else {
131
+ row.append(tile({ label: 'Completed', value: String(sum.completed.length), sub: 'Finished runs in this period (no merge data)' }));
132
+ }
133
+ const failing = sum.attention.filter((i) => !i.prs.some((p) => p.state === 'OPEN')).length;
134
+ const waiting = sum.attention.length - failing;
135
+ const attSub = sum.attention.length
136
+ ? [failing && `${failing} failing`, waiting && `${waiting} waiting for review`].filter(Boolean).join(' · ')
137
+ : 'Nothing is blocked';
138
+ row.append(tile({ f: 'attention', tone: 'red', label: 'Needs attention', value: String(sum.attention.length), sub: attSub }));
139
+ if (sum.prKnown) row.append(tile({ label: 'Median lead time', value: fmtSpan(sum.medianLeadMs), sub: 'First run (or PR opened) to merge' }));
140
+ row.append(tile({ label: 'Agent spend', value: TM_FMT.usd(sum.spendUsd), sub: 'Runs started in this period' }));
141
+ return row;
142
+ }
143
+
144
+ /** One line on where merge data comes from, only when something is missing. */
145
+ export function prNotice(doc, { status = null, loading = false, prKnown = false } = {}) {
146
+ const parts = [];
147
+ if (loading) parts.push('Checking pull requests…');
148
+ else if (status) {
149
+ const noAction = !(status.actionRepos || []).length;
150
+ if (noAction && (status.gh === 'missing' || status.gh === 'unauthenticated')) {
151
+ const why = status.gh === 'missing' ? 'the GitHub CLI (gh) is not installed' : 'the GitHub CLI (gh) is not signed in';
152
+ parts.push(`Merge dates are unavailable because ${why}. Install gh and run \`gh auth login\`, or add the merge-tracking workflow to the repository with \`worca metrics pr-workflow\`.${prKnown ? '' : ' Until then, Completed counts finished runs.'}`);
153
+ }
154
+ if (status.ghError) parts.push(`GitHub did not answer for some pull requests: ${status.ghError}`);
155
+ if ((status.unsupportedRepos || []).length) parts.push(`Merge tracking covers GitHub repositories; ${status.unsupportedRepos.join(', ')} show${status.unsupportedRepos.length === 1 ? 's' : ''} without merge data.`);
156
+ }
157
+ if (!parts.length) return null;
158
+ const n = h(doc, 'div', 'hint tl-note');
159
+ n.setAttribute('role', 'status');
160
+ for (const [i, p] of parts.entries()) {
161
+ if (i) n.append(h(doc, 'br'));
162
+ // `code` spans for the two commands, text for the rest.
163
+ p.split(/(`[^`]+`)/).forEach((seg) => n.append(seg.startsWith('`') ? h(doc, 'code', null, seg.slice(1, -1)) : doc.createTextNode(seg)));
164
+ }
165
+ return n;
166
+ }
167
+
168
+ function crumbs(doc, zoom, anchor) {
169
+ const a = new Date(anchor);
170
+ const parts = [{ z: 'month', label: `${MONTH[a.getMonth()]} ${a.getFullYear()}` }];
171
+ if (zoom !== 'month') parts.push({ z: 'week', label: `Week ${isoWeek(anchor)}` });
172
+ if (zoom === 'day') parts.push({ z: 'day', label: fmtDay(anchor) });
173
+ const nav = h(doc, 'nav', 'tl-crumbs');
174
+ nav.setAttribute('aria-label', 'Zoom');
175
+ parts.forEach((p, i) => {
176
+ if (i) nav.append(h(doc, 'span', 'tl-sep', '›'));
177
+ if (i === parts.length - 1) { const cur = h(doc, 'span', 'tl-crumb-cur', p.label); cur.setAttribute('aria-current', 'true'); nav.append(cur); }
178
+ else nav.append(btn(doc, 'tl-crumb', p.label, { tlZoom: p.z }));
179
+ });
180
+ return nav;
181
+ }
182
+
183
+ function controls(doc, { zoom, anchor, mode, outside, hasOutside }) {
184
+ const bar = h(doc, 'div', 'tl-controls');
185
+ const left = h(doc, 'div', 'tl-nav');
186
+ const prev = btn(doc, 'btn-ghost btn-mini tl-step', '‹', { tlShift: -1 }); prev.setAttribute('aria-label', `Previous ${zoom}`);
187
+ const next = btn(doc, 'btn-ghost btn-mini tl-step', '›', { tlShift: 1 }); next.setAttribute('aria-label', `Next ${zoom}`);
188
+ left.append(prev, next, btn(doc, 'btn-ghost btn-mini tl-today', 'Today', { tlToday: 1 }), crumbs(doc, zoom, anchor));
189
+ const right = h(doc, 'div', 'tl-opts');
190
+ if (hasOutside) {
191
+ const lbl = h(doc, 'label', 'tl-toggle');
192
+ const cb = h(doc, 'input');
193
+ cb.type = 'checkbox'; cb.id = 'tl-outside'; cb.checked = !!outside; cb.dataset.tlOutside = '1';
194
+ lbl.append(cb, 'PRs outside Worca');
195
+ lbl.title = 'Show pull requests that have no Worca run behind them';
196
+ right.append(lbl);
197
+ }
198
+ right.append(h(doc, 'span', 'hint', 'Group by'));
199
+ const seg = h(doc, 'div', 'seg tl-mode');
200
+ seg.setAttribute('role', 'group'); seg.setAttribute('aria-label', 'Group by');
201
+ for (const [m, label] of [['items', 'Work items'], ['people', 'People']]) {
202
+ const b = btn(doc, m === mode ? 'on' : '', label, { tlMode: m });
203
+ b.setAttribute('aria-pressed', String(m === mode));
204
+ seg.append(b);
205
+ }
206
+ right.append(seg);
207
+ bar.append(left, right);
208
+ return bar;
209
+ }
210
+
211
+ function mergedPips(doc, n) {
212
+ // Up to two diamonds; more merges on one day read as "◆ 5" so the narrow month cell never overflows.
213
+ const p = h(doc, 'span', 'tl-pips');
214
+ if (n > 2) { p.append(h(doc, 'i'), h(doc, 'em', null, String(n))); p.title = `${n} merged`; return p; }
215
+ for (let i = 0; i < n; i++) p.append(h(doc, 'i'));
216
+ return p;
217
+ }
218
+
219
+ function header(doc, { zoom, win, X, items, now }) {
220
+ const rows = [];
221
+ const cols = [];
222
+ const today = dayStart(now);
223
+ const mergedOn = (s, e) => items.filter((it) => it.status === 'shipped' && it.mergedAt != null && it.mergedAt >= s && it.mergedAt < e).length;
224
+ const row = (cls, labText, children) => {
225
+ const r = h(doc, 'div', `tl-row tl-hrow ${cls}`);
226
+ const lab = h(doc, 'div', 'tl-lab', labText);
227
+ const track = h(doc, 'div', 'tl-track');
228
+ track.style.width = px(win.W);
229
+ track.append(...children);
230
+ r.append(lab, track);
231
+ return r;
232
+ };
233
+ if (zoom === 'day') {
234
+ const ticks = [];
235
+ for (let t = win.s; t < win.e; t += HOUR) {
236
+ const tick = h(doc, 'span', 'tl-hour', hhmm(t));
237
+ tick.style.left = px(X(t));
238
+ ticks.push(tick);
239
+ cols.push({ l: X(t), w: X(t + HOUR) - X(t), we: false });
240
+ }
241
+ rows.push(row('tl-h-hours', null, ticks));
242
+ return { rows, cols };
243
+ }
244
+ const weeks = [];
245
+ const days = [];
246
+ for (let t = win.s; t < win.e; t = addDays(t, 1)) {
247
+ const d = new Date(t);
248
+ const next = addDays(t, 1);
249
+ const l = X(t); const w = X(next) - l;
250
+ const we = d.getDay() === 0 || d.getDay() === 6;
251
+ cols.push({ l, w, we });
252
+ const cell = btn(doc, `tl-hcell${t === today ? ' is-today' : ''}`, null, { tlDay: t });
253
+ cell.title = `Zoom to ${fmtDay(t)}`;
254
+ cell.style.left = px(l); cell.style.width = px(w);
255
+ cell.append(h(doc, 'span', 'tl-wd', zoom === 'month' ? WD[d.getDay()][0] : WD[d.getDay()]),
256
+ h(doc, 'span', 'tl-dn', zoom === 'month' ? String(d.getDate()) : `${d.getDate()} ${MON[d.getMonth()]}`), mergedPips(doc, mergedOn(t, next)));
257
+ days.push(cell);
258
+ if (zoom === 'month' && (d.getDay() === 1 || t === win.s)) {
259
+ const end = Math.min(addDays(weekStart(t), 7), win.e);
260
+ const wl = X(t); const ww = X(end) - wl;
261
+ const wk = btn(doc, 'tl-hcell tl-week', ww > 60 ? `Week ${isoWeek(t)}` : String(isoWeek(t)), { tlWeek: t });
262
+ wk.title = `Zoom to week ${isoWeek(t)}`;
263
+ wk.style.left = px(wl); wk.style.width = px(ww);
264
+ weeks.push(wk);
265
+ }
266
+ }
267
+ if (zoom === 'month') rows.push(row('tl-h-weeks', null, weeks));
268
+ rows.push(row('tl-h-days', 'Merged ◆', days));
269
+ return { rows, cols };
270
+ }
271
+
272
+ function bar(doc, it, { X, zoom, now }) {
273
+ const left = X(it.first);
274
+ const width = Math.max(X(it.end) - left, 14);
275
+ const rel = (t) => X(t) - left;
276
+ const st = TL_STATUS[it.status];
277
+ const hit = btn(doc, 'tl-hit', null, { tlItem: it.key });
278
+ hit.style.left = px(left); hit.style.width = px(width);
279
+ hit.setAttribute('aria-label', `${it.title}: ${st.label}`);
280
+ if (it.kind === 'pr') {
281
+ // No runs: the bar is the pull request itself, from opened (a hollow start) to merged/closed.
282
+ hit.append(h(doc, 'i', 'tl-pr-start'));
283
+ } else {
284
+ const span = h(doc, 'i', 'tl-span');
285
+ span.style.left = '0px'; span.style.width = px(Math.max(rel(it.lastEnd), 4));
286
+ hit.append(span);
287
+ }
288
+ it.runs.forEach((r, i) => {
289
+ const w = Math.max(rel(r.e) - rel(r.s), 3);
290
+ const run = h(doc, 'i', `tl-run is-${r.result}`, zoom === 'day' && w > 70 ? `${i + 1} · ${fmtSpan(r.e - r.s)}` : null);
291
+ run.style.left = px(rel(r.s)); run.style.width = px(w);
292
+ hit.append(run);
293
+ });
294
+ if (it.prOpenAt != null) {
295
+ const until = it.mergedAt ?? it.closedAt ?? (it.prs.some((p) => p.state === 'OPEN') ? now : null);
296
+ if (until != null) {
297
+ const tail = h(doc, 'i', 'tl-tail');
298
+ tail.style.left = px(rel(it.prOpenAt)); tail.style.width = px(Math.max(rel(until) - rel(it.prOpenAt), 0));
299
+ hit.append(tail);
300
+ }
301
+ }
302
+ const markAt = it.status === 'shipped' ? it.mergedAt : it.status === 'closed' ? it.closedAt : it.prs.some((p) => p.state === 'OPEN') ? now : null;
303
+ if (markAt != null) {
304
+ const kind = it.status === 'shipped' ? 'merged' : it.status === 'closed' ? 'closed' : 'open';
305
+ const mark = h(doc, 'i', `tl-mark is-${kind}`);
306
+ mark.style.left = px(rel(markAt));
307
+ hit.append(mark);
308
+ }
309
+ return hit;
310
+ }
311
+
312
+ function itemRow(doc, it, { mode, win, X, zoom, now }) {
313
+ const st = TL_STATUS[it.status];
314
+ const r = h(doc, 'div', `tl-row tl-item tl-t-${it.status === 'attention' && it.prs.some((p) => p.state === 'OPEN') ? 'blue' : st.tone}${it.kind === 'pr' ? ' is-outside' : ''}`);
315
+ const lab = h(doc, 'div', `tl-lab tl-t-${st.tone}`);
316
+ const t = h(doc, 'div', 'tl-title');
317
+ t.append(h(doc, 'i', 'tl-dot'), Object.assign(h(doc, 'span', 'tl-title-text', it.title), { title: it.title }));
318
+ if (it.kind === 'pr') { const tag = h(doc, 'span', 'tl-outside', 'outside Worca'); tag.title = 'A pull request with no Worca run behind it'; t.append(tag); }
319
+ const who = mode === 'people' ? it.project : (it.actor || 'Unattributed');
320
+ const ref = it.ticket?.ref || (it.prs[0]?.number != null ? `#${it.prs[0].number}` : null);
321
+ const meta = h(doc, 'div', 'tl-meta mono');
322
+ meta.append(`${[ref, who].filter(Boolean).join(' · ')} · `, h(doc, 'span', 'tl-st', it.status === 'done' && it.prKnown && !it.prs.length ? 'Done, no PR' : st.label));
323
+ lab.append(t, meta);
324
+ const track = h(doc, 'div', 'tl-track');
325
+ track.style.width = px(win.W);
326
+ track.append(bar(doc, it, { X, zoom, now }));
327
+ r.append(lab, track);
328
+ return r;
329
+ }
330
+
331
+ function legend(doc) {
332
+ const l = h(doc, 'div', 'tl-legend');
333
+ const entry = (sw, text) => { const s = h(doc, 'span', 'tl-lg'); s.append(sw, text); return s; };
334
+ const sw = (...kids) => { const b = h(doc, 'i', 'tl-lg-sw tl-t-green'); b.append(...kids); return b; };
335
+ const at = (cls, left, width) => { const n = h(doc, 'i', cls); n.style.left = px(left); if (width != null) n.style.width = px(width); return n; };
336
+ l.append(
337
+ entry(sw(at('tl-span', 0, 30)), 'Agent working, first run to last'),
338
+ entry(sw(at('tl-run is-done', 8, 7), at('tl-run is-failed', 18, 7)), 'One run (red: failed)'),
339
+ entry(sw(at('tl-tail', 0, 30)), 'Waiting for review'),
340
+ entry(sw(at('tl-mark is-merged', 15)), 'Merged: shipped'),
341
+ entry(sw(at('tl-mark is-open', 15)), 'Pull request open'),
342
+ entry(sw(at('tl-mark is-closed', 15)), 'Closed without merge'),
343
+ entry(sw(at('tl-pr-start', 4), at('tl-tail', 8, 22)), 'Pull request without a Worca run'),
344
+ );
345
+ return l;
346
+ }
347
+
348
+ /**
349
+ * @param {object} m
350
+ * @param {object[]} m.items buildWorkItems() output (the whole scope; the window filters).
351
+ * @param {'month'|'week'|'day'} m.zoom
352
+ * @param {number} m.anchor a time inside the period shown.
353
+ * @param {'items'|'people'} m.mode
354
+ * @param {string|null} m.filter one of TL_FILTERS, from the tiles.
355
+ * @param {object|null} m.prStatus resolveRunPrs().status; m.prLoading while it is out.
356
+ * @param {number} [m.fit] px available for the calendar (the card's width minus the labels).
357
+ */
358
+ export function renderTimeline(m, { doc = globalThis.document } = {}) {
359
+ const { items: all = [], zoom = 'month', anchor = Date.now(), mode = 'items', filter = null, now = Date.now(), prStatus = null, prLoading = false, fit = 0, outside = true } = m;
360
+ const hasOutside = all.some((it) => it.kind === 'pr');
361
+ const items = outside ? all : all.filter((it) => it.kind !== 'pr');
362
+ const win = timelineWindow(zoom, anchor, { fit });
363
+ const X = (t) => ((t - win.s) / (win.e - win.s)) * win.W;
364
+ const sum = summarizeWindow(items, { startMs: win.s, endMs: win.e, now });
365
+ const root = h(doc, 'div', 'tl');
366
+ root.append(tiles(doc, sum, { filter }));
367
+ const note = prNotice(doc, { status: prStatus, loading: prLoading, prKnown: sum.prKnown });
368
+ if (note) root.append(note);
369
+
370
+ const card = h(doc, 'section', 'card tl-card');
371
+ card.append(controls(doc, { zoom, anchor, mode, outside, hasOutside }));
372
+ const scroll = h(doc, 'div', 'tl-scroll');
373
+ const grid = h(doc, 'div', `tl-grid tl-z-${zoom}`);
374
+ grid.style.width = `calc(var(--tl-lab) + ${px(win.W)})`;
375
+ const head = header(doc, { zoom, win, X, items, now });
376
+ grid.append(...head.rows);
377
+
378
+ const body = h(doc, 'div', 'tl-body');
379
+ const layer = h(doc, 'div', 'tl-cols');
380
+ layer.style.width = px(win.W);
381
+ for (const c of head.cols) {
382
+ const col = h(doc, 'i', `tl-col${c.we ? ' is-weekend' : ''}`);
383
+ col.style.left = px(c.l); col.style.width = px(c.w);
384
+ layer.append(col);
385
+ }
386
+ if (now < win.e) {
387
+ const fut = h(doc, 'i', 'tl-future');
388
+ fut.style.left = px(Math.max(0, X(now)));
389
+ layer.append(fut);
390
+ }
391
+ if (now >= win.s && now < win.e) {
392
+ const line = h(doc, 'i', 'tl-now');
393
+ line.style.left = px(X(now));
394
+ layer.append(line);
395
+ const tag = h(doc, 'span', 'tl-now-tag', 'Now');
396
+ tag.style.left = `calc(var(--tl-lab) + ${px(X(now))})`;
397
+ body.append(tag);
398
+ }
399
+ body.append(layer);
400
+
401
+ let shown = sum.visible;
402
+ if (filter === 'shipped') shown = sum.shipped;
403
+ else if (filter === 'review') shown = sum.inReview;
404
+ else if (filter === 'attention') shown = sum.attention;
405
+ for (const [key, list] of groupItems(shown, mode, all)) {
406
+ const g = h(doc, 'div', 'tl-row tl-group');
407
+ const lab = h(doc, 'div', 'tl-lab');
408
+ const shipped = list.filter((i) => i.status === 'shipped').length;
409
+ if (mode === 'people') {
410
+ const name = key || 'Unattributed';
411
+ lab.append(h(doc, 'span', 'tl-ava', name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase()), h(doc, 'b', null, name));
412
+ } else lab.append(h(doc, 'i', 'tl-proj'), h(doc, 'b', null, key));
413
+ lab.append(h(doc, 'small', null, `${list.length} item${list.length === 1 ? '' : 's'}${sum.prKnown ? ` · ${shipped} shipped` : ''}`));
414
+ const track = h(doc, 'div', 'tl-track'); track.style.width = px(win.W);
415
+ g.append(lab, track);
416
+ body.append(g);
417
+ for (const it of list) body.append(itemRow(doc, it, { mode, win, X, zoom, now }));
418
+ }
419
+ if (!shown.length) body.append(h(doc, 'div', 'tl-empty hint', filter ? 'No work items match this filter in this period.' : 'No work in this period.'));
420
+ grid.append(body);
421
+ scroll.append(grid);
422
+ card.append(scroll, legend(doc));
423
+ root.append(card);
424
+ root.append(h(doc, 'small', 'hint tl-foot', 'Click a week or a day in the header to zoom in, and a bar for the work item. A work item is every run on one ticket, or on one branch when there is no ticket; a pull request with no run behind it is its own item.'));
425
+ return root;
426
+ }
427
+
428
+ /** The card a bar opens: what it is, where it stands, and the runs behind it. */
429
+ export function renderTimelinePopover(it, { doc = globalThis.document, now = Date.now() } = {}) {
430
+ const st = TL_STATUS[it.status];
431
+ const pop = h(doc, 'div', `tl-pop tl-t-${st.tone}`);
432
+ pop.setAttribute('role', 'dialog');
433
+ pop.setAttribute('aria-label', it.title);
434
+ const top = h(doc, 'div', 'tl-pop-top');
435
+ const close = btn(doc, 'tl-pop-x', '×', { tlClose: 1 }); close.setAttribute('aria-label', 'Close');
436
+ top.append(h(doc, 'span', 'tl-pill', it.status === 'done' && it.prKnown && !it.prs.length ? 'Done, no PR' : st.label), close);
437
+ pop.append(top);
438
+ const outside = it.kind === 'pr';
439
+ const kicker = h(doc, 'div', 'tl-pop-kicker mono', [it.ticket?.ref || (outside ? `#${it.prs[0].number}` : null), it.project].filter(Boolean).join(' · '));
440
+ pop.append(kicker, h(doc, 'h3', null, it.title));
441
+ if (outside) pop.append(h(doc, 'p', 'hint tl-pop-note', 'A pull request with no Worca run behind it: work done outside Worca.'));
442
+ if (it.reason) pop.append(h(doc, 'p', 'tl-reason', it.reason));
443
+ const dl = h(doc, 'dl');
444
+ const row = (k, v) => { if (v == null || v === '') return; dl.append(h(doc, 'dt', null, k)); const dd = h(doc, 'dd'); if (typeof v === 'string') dd.textContent = v; else dd.append(v); dl.append(dd); };
445
+ const open = it.prs.some((p) => p.state === 'OPEN');
446
+ if (outside) {
447
+ row('Author', !it.actor ? 'Unattributed' : it.login && it.login !== it.actor ? `${it.actor} (@${it.login})` : it.actor);
448
+ row('Opened', fmtDay(it.first));
449
+ if (it.mergedAt != null) row('Time to merge', fmtSpan(it.mergedAt - it.first));
450
+ else if (open) row('Open for', fmtSpan(now - it.first));
451
+ } else {
452
+ row('Driven by', it.actors.length ? it.actors.join(', ') : 'Unattributed');
453
+ row('Attempts', `${it.runs.length}${it.failed ? ` (${it.failed} failed)` : ''}`);
454
+ if (it.reviewCycles) row('Review cycles', String(it.reviewCycles));
455
+ row('Agent time', fmtSpan(it.activeMs));
456
+ if (it.prOpenAt != null) row('Waiting for review', `${fmtSpan((it.mergedAt ?? it.closedAt ?? now) - it.prOpenAt)}${open ? ' so far' : ''}`);
457
+ if (it.mergedAt != null) row('Lead time', `${fmtSpan(it.mergedAt - it.first)} (first run to merge)`);
458
+ row('Spend', TM_FMT.usd(it.costUsd));
459
+ }
460
+ const link = (text, url) => { const safe = safeHttpUrl(url); if (!safe) return text; const a = h(doc, 'a', null, text); a.href = safe; a.target = '_blank'; a.rel = 'noopener noreferrer'; return a; };
461
+ if (it.ticket && (it.ticket.url || it.ticket.ref)) row('Ticket', link(it.ticket.ref || 'Open ticket', it.ticket.url));
462
+ for (const p of it.prs) {
463
+ const when = p.state === 'MERGED' ? (p.mergedAt != null ? `merged ${fmtDay(p.mergedAt)}` : 'merged') : p.state === 'CLOSED' ? 'closed without merge' : p.createdAt != null ? `open since ${fmtDay(p.createdAt)}` : 'open';
464
+ const v = h(doc, 'span');
465
+ v.append(link(p.number != null ? `#${p.number}` : 'Pull request', p.url), ` ${when}`);
466
+ row('Pull request', v);
467
+ }
468
+ if (!it.prs.length) row('Pull request', it.prKnown ? 'None opened' : 'Unknown');
469
+ pop.append(dl);
470
+ if (!it.runs.length) return pop;
471
+ const runs = h(doc, 'div', 'tl-attempts');
472
+ runs.append(h(doc, 'span', 'tl-attempts-h', 'Runs'));
473
+ for (const r of it.runs) {
474
+ const line = h(doc, 'div', 'tl-att mono');
475
+ line.append(h(doc, 'i', `tl-att-dot is-${r.result}`), `${fmtDay(r.s)}, ${hhmm(r.s)}`, h(doc, 'span', 'tl-att-r', `${fmtSpan(r.e - r.s)} · ${r.result}`));
476
+ runs.append(line);
477
+ }
478
+ pop.append(runs);
479
+ return pop;
480
+ }
@@ -395,7 +395,11 @@ export function renderBreakdownTable(dim, rows, { doc = globalThis.document, sor
395
395
  for (const [key] of spec.cols) {
396
396
  const td = h(doc, 'td', key === 'label' ? null : 'num mono');
397
397
  if (key === 'label') {
398
- td.append(h(doc, 'span', null, r.label));
398
+ // An actor may be a full email now (attribution: the person who started the run on a
399
+ // shared deployment): clipped to the column, the whole name in the tooltip.
400
+ const lab = h(doc, 'span', dim === 'actor' ? 'tm-person' : null, r.label);
401
+ if (dim === 'actor' && r.label) lab.title = String(r.label);
402
+ td.append(lab);
399
403
  if (r.sub) td.append(' ', h(doc, 'small', 'hint', r.sub));
400
404
  if (dim === 'project' && homeSlug && r.key === homeSlug) td.append(' ', h(doc, 'span', 'badge violet', 'metrics home'));
401
405
  } else if (key === 'share' || key === 'runShare') {
@@ -458,7 +462,11 @@ export function renderRunsTable(runs, { doc = globalThis.document, total = runs.
458
462
  if (href) { const a = h(doc, 'a', null, r.pr.number != null ? `#${r.pr.number}` : 'PR'); a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; pr.append(a); }
459
463
  else pr.textContent = r.pr?.number != null ? `#${r.pr.number}` : '—';
460
464
  tr.append(pr);
461
- tr.append(h(doc, 'td', null, r.actor ?? '—'));
465
+ const who = h(doc, 'td', null);
466
+ const whoText = h(doc, 'span', r.actor ? 'tm-person' : null, r.actor ?? '—');
467
+ if (r.actor) whoText.title = String(r.actor);
468
+ who.append(whoText);
469
+ tr.append(who);
462
470
  tr.append(h(doc, 'td', 'mono', TM_FMT.day(r.startedAt)));
463
471
  tbody.append(tr);
464
472
  }
@@ -340,7 +340,12 @@ export function renderPolicyHeader(payload, { doc = globalThis.document, now = D
340
340
  }
341
341
  const ver = [];
342
342
  if (policy.sha) { const c = code(doc, String(policy.sha).slice(0, 7)); c.title = `Policy version: commit ${String(policy.sha).slice(0, 7)} on ${policy.home}'s worca-policy branch`; ver.push(c); }
343
- if (policy.doc?.updatedAt) ver.push(h(doc, 'span', null, `${ver.length ? ' · ' : ''}updated ${relTime(policy.doc.updatedAt, now) || ''}${policy.doc.updatedBy ? ` by ${policy.doc.updatedBy}` : ''}`));
343
+ if (policy.doc?.updatedAt) {
344
+ // updatedBy is the publisher: the signed-in person on a shared deployment, else the git user.
345
+ const upd = h(doc, 'span', null, `${ver.length ? ' · ' : ''}updated ${relTime(policy.doc.updatedAt, now) || ''}${policy.doc.updatedBy ? ` by ${policy.doc.updatedBy}` : ''}`);
346
+ if (policy.doc.updatedBy) upd.title = `Published by ${policy.doc.updatedBy}`;
347
+ ver.push(upd);
348
+ }
344
349
  if (ver.length) fact('VERSION', ...ver);
345
350
  if (policy.doc?.notes) fact('NOTES', h(doc, 'span', 'tp-head-notes', policy.doc.notes));
346
351
  main.append(facts);